Source code for dartwork_mpl.validate._orchestrator

"""Orchestrator: runs every registered check and renders the stdout log."""

from __future__ import annotations

import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, TypeAlias, TypedDict

from .._figure_geometry import get_renderer
from ._checks import RegisteredCheck, registered_checks
from ._checks._detail import wire
from ._types import BBOX_ERRORS, Severity, VisualWarning

if TYPE_CHECKING:
    from matplotlib.backend_bases import RendererBase
    from matplotlib.figure import Figure

__all__ = ["validate_figure"]


@dataclass(frozen=True)
class CheckCompleted:
    """A check ran to the end; ``warnings`` may be empty (a clean result)."""

    warnings: list[VisualWarning]


@dataclass(frozen=True)
class CheckFailed:
    """A tolerated bbox error stopped the check; the figure is not proven clean."""

    check_id: str


CheckOutcome: TypeAlias = CheckCompleted | CheckFailed


class CheckFailedDetail(TypedDict):
    """Wire detail of a ``CHECK_FAILED`` warning: the check that could not run."""

    check_id: str


def _run_check_safely(
    check: RegisteredCheck, fig: Figure, renderer: RendererBase
) -> CheckOutcome:
    """Run one check as ``fn(fig, renderer)``; a bbox error is ``CheckFailed``.

    Isolating the ``try`` here (rather than inside the orchestration
    loop) keeps a failing check from crashing the save pipeline while
    still letting the caller distinguish "ran clean" from "could not
    run" — the latter must never be reported as a clean figure. Any
    other exception is a defect and propagates.
    """
    try:
        return CheckCompleted(check.fn(fig, renderer))
    except BBOX_ERRORS:
        return CheckFailed(check.check_id)


def _outcome_warnings(outcome: CheckOutcome) -> list[VisualWarning]:
    """The public warnings for one outcome; a failure becomes ``CHECK_FAILED``."""
    match outcome:
        case CheckCompleted(warnings):
            return warnings
        case CheckFailed(check_id):
            return [
                VisualWarning(
                    Severity.WARNING,
                    "CHECK_FAILED",
                    f"Check {check_id} could not run; validation is incomplete.",
                    wire(CheckFailedDetail(check_id=check_id)),
                )
            ]


[docs] def validate_figure( fig: Figure, *, checks: tuple[str, ...] | None = None, quiet: bool = False ) -> list[VisualWarning]: """Run comprehensive visual validation on a Matplotlib figure. Parameters ---------- fig : matplotlib.figure.Figure The figure to inspect for visual defects. checks : tuple[str, ...] | None, optional Check IDs to run. If None, all registered checks are executed. Supported IDs: ``OVERFLOW``, ``OVERLAP``, ``UNIT_DUP``, ``CROSS_AXES_OVERLAP``, ``LEGEND_OVERFLOW``, ``TICK_CROWD``, ``TICK_ROTATION``, ``TICK_DECIMAL``, ``EMPTY_AXES``, ``MARGIN_ASYMMETRY``, ``PIE_LABEL_OFFSET``, ``CLIPPED_TEXT``. quiet : bool, optional If True, suppresses stdout output. Default is False. Returns ------- list[VisualWarning] Detected issues in registry order, including ``CHECK_FAILED`` warnings with the original ID in ``detail["check_id"]`` when a tolerated bbox error prevents a check from completing. Quiet mode only changes output. Unexpected check exceptions propagate to the caller. """ # Render once so all bounding boxes are computed. fig.canvas.draw() renderer = get_renderer(fig) # Checks self-register via ``@register_check`` in their own modules; # ``registered_checks()`` returns them in deterministic run order, so # the orchestrator no longer hand-mirrors a check→callable dict that # could silently drift out of sync with the check modules. selected = registered_checks() valid_ids = {check.check_id for check in selected} # Fail loud on unknown IDs: a typo (``"OVERFLW"``) or a renamed # check would otherwise silently run *zero* checks and report the # figure clean when it was never inspected. if checks is not None: unknown = set(checks) - valid_ids if unknown: raise ValueError( f"Unknown check IDs: {sorted(unknown)}. " f"Valid IDs: {sorted(valid_ids)}" ) selected = [check for check in selected if check.check_id in checks] warnings: list[VisualWarning] = [] for check in selected: # Never crash the save pipeline, but don't silently swallow a # failed check either — a check that raises must not let the # figure be reported "clean" as if it had run and found nothing. # Every check is invoked uniformly as ``fn(fig, renderer)``. warnings.extend( _outcome_warnings(_run_check_safely(check, fig, renderer)) ) # Structured stdout output for agent consumption. if not quiet: if warnings: for w in warnings: print(str(w), file=sys.stdout, flush=True) else: print( "[VISUAL] ✅ No visual issues detected.", file=sys.stdout, flush=True, ) return warnings