|
| 1 | +from collections.abc import Callable, Mapping, Sequence |
1 | 2 | from dataclasses import dataclass, field |
2 | 3 | from datetime import date, datetime |
3 | 4 | from enum import StrEnum |
|
35 | 36 | TestRunNotificationTrigger, |
36 | 37 | ) |
37 | 38 | from testgen.common.models.profiling_run import ProfilingRun |
| 39 | +from testgen.common.models.project import Project |
38 | 40 | from testgen.common.models.scheduler import SCHEDULABLE_JOB_KEYS, JobSchedule |
39 | 41 | from testgen.common.models.scores import ScoreCategory, ScoreDefinition |
40 | 42 | from testgen.common.models.table_group import TableGroup |
@@ -628,10 +630,89 @@ def format_page_footer(total: int, page: int, limit: int) -> str: |
628 | 630 | return f"_Page {page} of {total_pages}. Use `page={page + 1}` for more._" |
629 | 631 |
|
630 | 632 |
|
| 633 | +def _default_render_diff_value(value: object) -> str | None: |
| 634 | + """Default formatter for before/after cells in :func:`render_diff_table`. |
| 635 | +
|
| 636 | + * ``bool`` → ``"Yes"`` / ``"No"`` |
| 637 | + * ``None`` or ``""`` → ``None`` (rendered as em-dash by the table cell) |
| 638 | + * else → ``str(value)`` |
| 639 | + """ |
| 640 | + if isinstance(value, bool): |
| 641 | + return "Yes" if value else "No" |
| 642 | + if value is None or value == "": |
| 643 | + return None |
| 644 | + return str(value) |
| 645 | + |
| 646 | + |
| 647 | +def render_diff_table( |
| 648 | + doc: MdDoc, |
| 649 | + before: Mapping[str, object], |
| 650 | + after: Mapping[str, object], |
| 651 | + *, |
| 652 | + attrs: Sequence[str], |
| 653 | + labels: Mapping[str, str], |
| 654 | + secret_attrs: frozenset[str] = frozenset(), |
| 655 | + value_renderer: Callable[[object], str | None] | None = None, |
| 656 | +) -> bool: |
| 657 | + """Emit a ``Field / Before / After`` table for attrs whose values changed. |
| 658 | +
|
| 659 | + Update tools across the MCP surface share this shape: snapshot before, apply |
| 660 | + field-by-field, snapshot after, render the delta. The shared helper keeps the |
| 661 | + ordering, label lookup, and secret redaction consistent. |
| 662 | +
|
| 663 | + Args: |
| 664 | + doc: ``MdDoc`` to append the table to. |
| 665 | + before / after: same-shape dicts of the snapshot keyed by attr name. |
| 666 | + Only entries in ``attrs`` are inspected. |
| 667 | + attrs: ordered tuple of attrs to consider; controls row order. |
| 668 | + labels: attr → user-facing label for the first column. |
| 669 | + secret_attrs: attrs whose values must not be echoed (API keys, passwords). |
| 670 | + Rendered as ``[secret]`` when present, em-dash when absent — distinct |
| 671 | + from a "rotated" cue. Tools that need to communicate rotation |
| 672 | + specifically (e.g. ``update_connection``) keep their own renderer. |
| 673 | + value_renderer: override for the non-secret cells. Defaults to |
| 674 | + :func:`_default_render_diff_value`. |
| 675 | +
|
| 676 | + Returns ``True`` if at least one row was rendered, ``False`` when nothing |
| 677 | + changed (lets the caller emit a "No fields changed" message instead). |
| 678 | + """ |
| 679 | + changed = {attr for attr in attrs if before.get(attr) != after.get(attr)} |
| 680 | + if not changed: |
| 681 | + return False |
| 682 | + render = value_renderer or _default_render_diff_value |
| 683 | + rows: list[list[object]] = [] |
| 684 | + for attr in attrs: |
| 685 | + if attr not in changed: |
| 686 | + continue |
| 687 | + if attr in secret_attrs: |
| 688 | + b = "[secret]" if before.get(attr) else None |
| 689 | + a = "[secret]" if after.get(attr) else None |
| 690 | + else: |
| 691 | + b = render(before.get(attr)) |
| 692 | + a = render(after.get(attr)) |
| 693 | + rows.append([labels.get(attr, attr), b, a]) |
| 694 | + doc.table(["Field", "Before", "After"], rows, code=[0]) |
| 695 | + return True |
| 696 | + |
| 697 | + |
631 | 698 | # Entity resolution helpers — see mcp-roadmap.md "Entity Resolution Helpers" guideline. |
632 | 699 | # Extract a new resolve_<entity> here when a second caller needs the same parse-uuid + |
633 | 700 | # perm-scoped lookup + collapsed-error pattern. |
634 | 701 |
|
| 702 | + |
| 703 | +def resolve_project(project_code: str) -> Project: |
| 704 | + """Resolve a project code to a Project, scoped to the user's allowed projects. |
| 705 | +
|
| 706 | + Collapses missing-or-inaccessible into a single ``MCPResourceNotAccessible`` so |
| 707 | + callers can't enumerate whether a project they can't see exists. |
| 708 | + """ |
| 709 | + perms = get_project_permissions() |
| 710 | + project = Project.get(project_code, Project.project_code.in_(perms.allowed_codes)) |
| 711 | + if project is None: |
| 712 | + raise MCPResourceNotAccessible("Project", project_code) |
| 713 | + return project |
| 714 | + |
| 715 | + |
635 | 716 | def resolve_connection(connection_id: int) -> Connection: |
636 | 717 | """Resolve a connection ID, collapsing missing-or-inaccessible into one error path.""" |
637 | 718 | perms = get_project_permissions() |
|
0 commit comments