|
2 | 2 | from enum import StrEnum |
3 | 3 | from typing import NoReturn |
4 | 4 |
|
| 5 | +from pydantic import ValidationError |
5 | 6 | from sqlalchemy import update |
6 | 7 |
|
7 | 8 | from testgen.common.custom_test_validation import validate_custom_query |
|
17 | 18 | TestType, |
18 | 19 | ) |
19 | 20 | from testgen.common.models.test_result import TestResult |
| 21 | +from testgen.common.test_definition_export_import_service import ( |
| 22 | + ImportAction, |
| 23 | + ImportConfig, |
| 24 | + ImportMode, |
| 25 | + ImportPayload, |
| 26 | + ImportResponse, |
| 27 | + ImportStrictViolation, |
| 28 | + InvalidImportPayload, |
| 29 | + OnAbsence, |
| 30 | + OnMatch, |
| 31 | + OnNew, |
| 32 | + Origin, |
| 33 | + export_definitions, |
| 34 | + import_definitions, |
| 35 | +) |
20 | 36 | from testgen.mcp.exceptions import MCPUserError |
21 | 37 | from testgen.mcp.permissions import get_authorized_mcp_user, get_project_permissions, mcp_permission |
22 | 38 | from testgen.mcp.tools.common import ( |
23 | 39 | DocGroup, |
24 | 40 | format_page_footer, |
25 | 41 | format_page_info, |
| 42 | + parse_enum, |
26 | 43 | parse_impact_dimension, |
27 | 44 | parse_quality_dimension, |
28 | 45 | parse_uuid, |
| 46 | + raise_validation_error, |
29 | 47 | resolve_test_definition, |
30 | 48 | resolve_test_note, |
31 | 49 | resolve_test_suite, |
@@ -698,11 +716,7 @@ def bulk_update_tests( |
698 | 716 | table_name: Optional table-name filter. Case-sensitive. |
699 | 717 | test_type: Optional test type name (e.g. ``Alpha Truncation``). |
700 | 718 | """ |
701 | | - try: |
702 | | - bulk_action = BulkAction(action) |
703 | | - except ValueError as err: |
704 | | - valid = ", ".join(f"`{a.value}`" for a in BulkAction) |
705 | | - raise MCPUserError(f"`action` must be one of: {valid}.") from err |
| 719 | + bulk_action = parse_enum(action, BulkAction, "action") |
706 | 720 | suite = resolve_test_suite(test_suite_id) |
707 | 721 | tt_code = resolve_test_type(test_type) if test_type else None |
708 | 722 |
|
@@ -742,3 +756,152 @@ def bulk_update_tests( |
742 | 756 | doc.heading(1, f"{verb} {count} test(s) in suite `{suite.test_suite}`") |
743 | 757 | doc.field("Filter", filter_str) |
744 | 758 | return doc.render() |
| 759 | + |
| 760 | + |
| 761 | +# --------------------------------------------------------------------------- |
| 762 | +# Export / import |
| 763 | +# |
| 764 | +# Thin wrappers over ``testgen.common.test_definition_export_import_service`` — the |
| 765 | +# same export document and import semantics as the REST API. |
| 766 | +# --------------------------------------------------------------------------- |
| 767 | + |
| 768 | + |
| 769 | +def _parse_import_payload(payload: str) -> ImportPayload: |
| 770 | + try: |
| 771 | + return ImportPayload.model_validate_json(payload) |
| 772 | + except ValidationError as err: |
| 773 | + errors = err.errors() |
| 774 | + bullets = [f"`{'.'.join(str(part) for part in e['loc']) or 'payload'}`: {e['msg']}" for e in errors[:5]] |
| 775 | + if len(errors) > 5: |
| 776 | + bullets.append(f"…and {len(errors) - 5} more") |
| 777 | + raise_validation_error(bullets, "`payload` is not a valid export document.") |
| 778 | + |
| 779 | + |
| 780 | +def _append_import_result(doc: MdDoc, result: ImportResponse, preview: bool) -> None: |
| 781 | + verb_suffix = " (projected)" if preview else "" |
| 782 | + doc.field(f"Created{verb_suffix}", result.summary.created) |
| 783 | + doc.field(f"Updated{verb_suffix}", result.summary.updated) |
| 784 | + doc.field(f"Skipped{verb_suffix}", result.summary.skipped) |
| 785 | + doc.field(f"Deleted{verb_suffix}", result.summary.deleted) |
| 786 | + |
| 787 | + rows_by_action: dict[ImportAction, list[list]] = {} |
| 788 | + for item in result.items: |
| 789 | + for td in item.tds: |
| 790 | + rows_by_action.setdefault(item.action, []).append( |
| 791 | + [item.reason.value, td.idx, td.target_id] |
| 792 | + ) |
| 793 | + |
| 794 | + for action in ImportAction: |
| 795 | + rows = rows_by_action.get(action) |
| 796 | + if not rows: |
| 797 | + continue |
| 798 | + doc.heading(2, f"{action.value.capitalize()} ({len(rows)})") |
| 799 | + doc.table(["Reason", "Index", "Test Definition ID"], rows, code=[2]) |
| 800 | + |
| 801 | + |
| 802 | +@with_database_session |
| 803 | +@mcp_permission("view") |
| 804 | +def export_tests( |
| 805 | + test_suite_id: str, |
| 806 | + origin: str = "both", |
| 807 | + table_name: str | None = None, |
| 808 | + test_type: str | None = None, |
| 809 | +) -> str: |
| 810 | + """Export test definitions from a test suite as a portable JSON document. |
| 811 | +
|
| 812 | + The returned JSON payload can be applied to another suite (or back to the same |
| 813 | + suite) with ``import_tests`` — as-is for promotion or copying, or after editing |
| 814 | + it for bulk changes such as threshold calibration. |
| 815 | +
|
| 816 | + Args: |
| 817 | + test_suite_id: UUID of the test suite to export from. |
| 818 | + origin: Which tests to include: ``both`` (default), ``manual`` (only manually |
| 819 | + created tests), or ``auto`` (only auto-generated tests). |
| 820 | + table_name: Optional table-name filter. Case-sensitive. |
| 821 | + test_type: Optional test type name filter, e.g. ``Alpha Truncation``. |
| 822 | + """ |
| 823 | + origin_enum = parse_enum(origin, Origin, "origin") |
| 824 | + suite = resolve_test_suite(test_suite_id) |
| 825 | + table_name = table_name or None |
| 826 | + test_type_code = resolve_test_type(test_type) if test_type else None |
| 827 | + |
| 828 | + export = export_definitions(suite, origin_enum, table_name, test_type_code) |
| 829 | + |
| 830 | + filters = [] |
| 831 | + if origin_enum is not Origin.both: |
| 832 | + filters.append(f"{origin_enum.value} tests only") |
| 833 | + if table_name: |
| 834 | + filters.append(f"table `{table_name}`") |
| 835 | + if test_type: |
| 836 | + filters.append(f"type `{test_type}`") |
| 837 | + |
| 838 | + doc = MdDoc() |
| 839 | + doc.heading(1, f"Test Definition Export from suite `{suite.test_suite}`") |
| 840 | + doc.field("Project", export.source.project_code, code=True) |
| 841 | + doc.field("Table Group", export.source.table_group) |
| 842 | + doc.field("Schema", export.source.table_group_schema, code=True) |
| 843 | + doc.field("Exported At", export.source.exported_at) |
| 844 | + if filters: |
| 845 | + doc.field("Filters", ", ".join(filters)) |
| 846 | + doc.field("Tests Exported", len(export.definitions)) |
| 847 | + doc.code_block(export.model_dump_json(indent=2, exclude_defaults=True), language="json") |
| 848 | + return doc.render() |
| 849 | + |
| 850 | + |
| 851 | +@with_database_session |
| 852 | +@mcp_permission("edit") |
| 853 | +def import_tests( |
| 854 | + test_suite_id: str, |
| 855 | + payload: str, |
| 856 | + mode: str = "preview", |
| 857 | + on_match: str = "overwrite_unlocked", |
| 858 | + on_new: str = "create", |
| 859 | + on_absence: str = "do_nothing", |
| 860 | +) -> str: |
| 861 | + """Apply an export document from ``export_tests`` to a test suite. |
| 862 | +
|
| 863 | + Args: |
| 864 | + test_suite_id: UUID of the target test suite. |
| 865 | + payload: The export document as a JSON string (the fenced JSON returned by |
| 866 | + ``export_tests``). |
| 867 | + mode: ``preview`` (default) reports the projected changes without persisting; |
| 868 | + ``apply`` persists, skipping entries that can't be applied; ``apply_strict`` |
| 869 | + persists only if nothing would be skipped, otherwise applies nothing. |
| 870 | + on_match: What to do when an incoming test matches an existing one: |
| 871 | + ``overwrite_unlocked`` (default), ``overwrite_all``, or ``skip``. |
| 872 | + on_new: What to do when an incoming test has no match: ``create`` (default), |
| 873 | + ``create_and_lock``, or ``skip``. |
| 874 | + on_absence: What to do with existing tests that are absent from the payload: |
| 875 | + ``do_nothing`` (default), ``delete_all``, or ``delete_unlocked``. |
| 876 | + """ |
| 877 | + config = ImportConfig( |
| 878 | + mode=parse_enum(mode, ImportMode, "mode"), |
| 879 | + on_match=parse_enum(on_match, OnMatch, "on_match"), |
| 880 | + on_new=parse_enum(on_new, OnNew, "on_new"), |
| 881 | + on_absence=parse_enum(on_absence, OnAbsence, "on_absence"), |
| 882 | + ) |
| 883 | + suite = resolve_test_suite(test_suite_id) |
| 884 | + parsed_payload = _parse_import_payload(payload) |
| 885 | + |
| 886 | + try: |
| 887 | + result = import_definitions(suite, config, parsed_payload) |
| 888 | + except InvalidImportPayload as err: |
| 889 | + raise MCPUserError(str(err)) from err |
| 890 | + except ImportStrictViolation as err: |
| 891 | + breakdown = MdDoc() |
| 892 | + _append_import_result(breakdown, err.result, preview=True) |
| 893 | + raise MCPUserError( |
| 894 | + f"Strict import failed: {err.result.summary.skipped} tests would be skipped. " |
| 895 | + f"Nothing was applied.\n\n{breakdown.render()}\n\n" |
| 896 | + "Retry with `mode='apply'` to import while skipping these, or adjust the payload or policies." |
| 897 | + ) from err |
| 898 | + |
| 899 | + preview = config.mode is ImportMode.preview |
| 900 | + doc = MdDoc() |
| 901 | + if preview: |
| 902 | + doc.heading(1, f"Test Definition Import Preview for suite `{suite.test_suite}`") |
| 903 | + doc.text("Preview only — no changes were persisted. Re-run with `mode='apply'` to persist.") |
| 904 | + else: |
| 905 | + doc.heading(1, f"Test Definition Import into suite `{suite.test_suite}`") |
| 906 | + _append_import_result(doc, result, preview=preview) |
| 907 | + return doc.render() |
0 commit comments