Skip to content

Commit 2a349c4

Browse files
author
ci bot
committed
Merge branch 'feat/TG-1098-mcp-test-definition-export-and-import' into 'enterprise'
feat(mcp): add export_tests and import_tests tools See merge request dkinternal/testgen/dataops-testgen!569
2 parents abf2959 + 34d8947 commit 2a349c4

4 files changed

Lines changed: 388 additions & 7 deletions

File tree

testgen/mcp/server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,9 @@ def build_mcp_server(
235235
create_test,
236236
create_test_note,
237237
delete_test_note,
238+
export_tests,
238239
get_test,
240+
import_tests,
239241
list_test_notes,
240242
list_test_types,
241243
list_tests,
@@ -328,6 +330,8 @@ def safe_prompt(fn):
328330
safe_tool(update_test)
329331
safe_tool(validate_custom_test)
330332
safe_tool(bulk_update_tests)
333+
safe_tool(export_tests)
334+
safe_tool(import_tests)
331335
safe_tool(create_test_note)
332336
safe_tool(update_test_note)
333337
safe_tool(delete_test_note)

testgen/mcp/tools/common.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from dataclasses import dataclass, field
22
from datetime import date, datetime
33
from enum import StrEnum
4+
from typing import NoReturn, TypeVar
45
from uuid import UUID
56

67
from sqlalchemy import select
@@ -79,6 +80,17 @@ def parse_uuid(value: str, label: str = "ID") -> UUID:
7980
raise MCPUserError(f"Invalid {label}: `{value}` is not a valid UUID.") from err
8081

8182

83+
_ParsedEnum = TypeVar("_ParsedEnum", bound=StrEnum)
84+
85+
86+
def parse_enum(value: str, enum_cls: type[_ParsedEnum], label: str) -> _ParsedEnum:
87+
try:
88+
return enum_cls(value)
89+
except ValueError as err:
90+
valid = ", ".join(member.value for member in enum_cls)
91+
raise MCPUserError(f"Invalid {label} `{value}`. Valid values: {valid}") from err
92+
93+
8294
def parse_result_status(value: str) -> TestResultStatus:
8395
try:
8496
return TestResultStatus(value)
@@ -1385,7 +1397,7 @@ def effective_mode(connection: Connection, connection_mode: str | None) -> str |
13851397
return str(inferred) if inferred is not None else None
13861398

13871399

1388-
def raise_validation_error(errors: list[str], header: str) -> None:
1400+
def raise_validation_error(errors: list[str], header: str) -> NoReturn:
13891401
bullets = "\n".join(f"- {err}" for err in errors)
13901402
raise MCPUserError(f"{header}\n\n{bullets}")
13911403

testgen/mcp/tools/test_definitions.py

Lines changed: 168 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from enum import StrEnum
33
from typing import NoReturn
44

5+
from pydantic import ValidationError
56
from sqlalchemy import update
67

78
from testgen.common.custom_test_validation import validate_custom_query
@@ -17,15 +18,32 @@
1718
TestType,
1819
)
1920
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+
)
2036
from testgen.mcp.exceptions import MCPUserError
2137
from testgen.mcp.permissions import get_authorized_mcp_user, get_project_permissions, mcp_permission
2238
from testgen.mcp.tools.common import (
2339
DocGroup,
2440
format_page_footer,
2541
format_page_info,
42+
parse_enum,
2643
parse_impact_dimension,
2744
parse_quality_dimension,
2845
parse_uuid,
46+
raise_validation_error,
2947
resolve_test_definition,
3048
resolve_test_note,
3149
resolve_test_suite,
@@ -698,11 +716,7 @@ def bulk_update_tests(
698716
table_name: Optional table-name filter. Case-sensitive.
699717
test_type: Optional test type name (e.g. ``Alpha Truncation``).
700718
"""
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")
706720
suite = resolve_test_suite(test_suite_id)
707721
tt_code = resolve_test_type(test_type) if test_type else None
708722

@@ -742,3 +756,152 @@ def bulk_update_tests(
742756
doc.heading(1, f"{verb} {count} test(s) in suite `{suite.test_suite}`")
743757
doc.field("Filter", filter_str)
744758
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

Comments
 (0)