Source code for phenotypic.abc_._figure_provider

"""Reusable visualization protocol: ``Control``, ``FigureSpec``, ``@figure``,
``FigureProvider``.

This is the renderer-neutral *contract* layer of PhenoTypic's plotting stack. It
turns ``@figure``-decorated methods into a uniform ``.inspect()`` / ``.dash()`` /
dashboard surface that works on both plain helper classes and pydantic
``ImageOperation`` models.

Design notes
------------
* **Stdlib only at import time.** This module imports nothing heavier than the
  standard library. ``plotly`` is touched *lazily* (the ``@figure`` wrapper
  applies the house theme only when a figure is actually rendered), and the
  ipywidgets/Dash shells are imported only inside :meth:`FigureProvider.dash`.
  An import-rule test enforces that importing this module pulls in no UI toolkit.
* **Pydantic-safe.** :class:`FigureProvider` is a methods-only mixin — no fields,
  no ``__init__``, no class-level annotations — so pydantic's ``ModelMetaclass``
  adds nothing and ``model_fields`` / ``model_json_schema()`` / ``to_json()`` are
  unchanged. It mirrors the proven ``LazyWidgetMixin`` shape. Any transient
  per-render cache lives on the throwaway :class:`BoundFigures` returned by
  :meth:`FigureProvider.figures`, never on the model.
* **Opt-in.** Mix :class:`FigureProvider` into the specific classes that declare
  ``@figure`` methods. It is intentionally *not* placed on ``BaseOperation`` — the
  CLI discovers ``--save-inspect`` targets via ``hasattr(measurer, "inspect")``,
  so a base-level ``inspect`` would mis-trigger on every operation.

Controls are bound to a figure method's keyword argument **by identity**: the same
``Control`` instance referenced by several methods becomes one shared widget;
distinct instances (even with the same label) are independent widgets.
"""

from __future__ import annotations

import functools
import inspect
import itertools
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable, Literal

if TYPE_CHECKING:  # pragma: no cover - typing only, never imported at runtime
    import plotly.graph_objects as go

__all__ = [
    "Control",
    "FigureSpec",
    "figure",
    "FigureProvider",
    "BoundFigures",
]

# Monotonic counter giving each @figure its definition-order index. Methods in a
# class body are decorated top-to-bottom, so this yields the authoring order
# (``dir()`` / ``vars()`` iteration order is not reliable for this).
_FIGURE_ORDER = itertools.count()

ControlKind = Literal["float", "select", "bool", "text"]


[docs] @dataclass(frozen=True) class Control: """Renderer-neutral input bound to a figure method's keyword argument. Bound **by identity**: the same instance referenced by several ``@figure`` methods renders as one shared widget; distinct instances (even with the same ``label``) are independent widgets. There is no global name namespace, so two unrelated figures may each have a ``sigma`` kwarg without colliding. Args: label: Human-readable widget label. kind: One of ``"float"``, ``"select"``, ``"bool"``, ``"text"``. default: Initial value. Must be consistent with ``kind`` (within ``bounds`` for ``float``; a member of ``options`` for ``select``; a ``bool`` for ``bool``). bounds: ``(low, high)`` range — **required** for ``"float"``. step: Optional slider step for ``"float"``. options: Allowed values — **required** for ``"select"``. help: Optional tooltip text. Raises: ValueError: If the kind-specific requirements are not met. """ label: str kind: ControlKind default: Any bounds: tuple[float, float] | None = None step: float | None = None options: tuple[Any, ...] | None = None help: str | None = None def __post_init__(self) -> None: if self.kind == "float": if self.bounds is None: raise ValueError( f"Control({self.label!r}): float requires bounds" ) low, high = self.bounds if not (low <= self.default <= high): raise ValueError( f"Control({self.label!r}): default {self.default!r} outside " f"bounds {self.bounds!r}" ) elif self.kind == "select": if not self.options: raise ValueError( f"Control({self.label!r}): select requires non-empty options" ) if self.default not in self.options: raise ValueError( f"Control({self.label!r}): default {self.default!r} not in " f"options {self.options!r}" ) elif self.kind == "bool": if not isinstance(self.default, bool): raise ValueError( f"Control({self.label!r}): bool default must be a bool" ) elif self.kind == "text": if not isinstance(self.default, str): raise ValueError( f"Control({self.label!r}): text default must be a str" ) else: # pragma: no cover - guarded by the Literal type, defensive only raise ValueError( f"Control({self.label!r}): unknown kind {self.kind!r}" )
[docs] @dataclass(frozen=True) class FigureSpec: """Introspectable metadata attached to a ``@figure`` method. Author-supplied fields come from the decorator call; the rest are derived by the decorator from the wrapped method's signature. Attributes: title: Figure title (author). section: Flat grouping tag → one collapsible card (author). controls: ``{method-kwarg: Control}`` (author). description: Optional interpretive block, e.g. a ``PanelDescription`` (author). Typed ``Any`` to keep this module dependency-free. primary: Marks the figure returned by :meth:`FigureProvider.inspect` (author). name: The method's ``__name__`` (decorator). method: The wrapped, auto-styled callable (decorator). wants_subject: Whether the method takes a subject as its first positional parameter (decorator). subject_param: Name of that subject parameter, or ``None`` (decorator). order: Definition-order index used to sort figures (decorator). """ title: str section: str controls: dict[str, Control] description: Any primary: bool name: str method: Callable[..., "go.Figure"] wants_subject: bool subject_param: str | None order: int
[docs] def figure( *, title: str, section: str = "default", controls: dict[str, Control] | None = None, description: Any = None, primary: bool = False, ) -> Callable[[Callable[..., "go.Figure"]], Callable[..., "go.Figure"]]: """Mark a method as a figure builder and auto-apply the house Plotly theme. The decorated method returns a raw ``plotly.graph_objects.Figure``, accepts each control as a keyword argument, and **may** accept a subject as its first positional parameter (any positional-or-keyword parameter that is not itself a control). The decorator: * validates that every ``controls`` key names a real parameter, * detects ``wants_subject`` / ``subject_param`` from the signature, * stashes a :class:`FigureSpec` on ``fn.__figure_spec__`` (invisible to pydantic), and * wraps the method so its returned figure is restyled via the ``"phenotypic"`` template (imported lazily, so this module stays UI-toolkit-free). Args: title: Figure title. section: Flat grouping tag for collapsible cards. controls: ``{method-kwarg: Control}`` recompute inputs. description: Optional interpretive block. primary: Mark this as the ``inspect()`` figure. Returns: A decorator that returns the wrapped, theme-applying method. Raises: ValueError: If a ``controls`` key is not a parameter of the method. """ controls = dict(controls) if controls else {} def decorator( fn: Callable[..., "go.Figure"], ) -> Callable[..., "go.Figure"]: sig = inspect.signature(fn) params = [p for name, p in sig.parameters.items() if name != "self"] param_names = {p.name for p in params} for kwarg in controls: if kwarg not in param_names: raise ValueError( f"@figure({fn.__name__!r}): control key {kwarg!r} is not a " f"parameter of the method" ) # Subject = first positional(-or-keyword) parameter that is not a control. subject_param: str | None = None for p in params: positional = p.kind in ( inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.POSITIONAL_ONLY, ) if positional and p.name not in controls: subject_param = p.name break @functools.wraps(fn) def wrapper(*args: Any, **kwargs: Any) -> "go.Figure": from phenotypic.sdk_.viz.figures._theme import apply_theme return apply_theme(fn(*args, **kwargs)) wrapper.__figure_spec__ = FigureSpec( # type: ignore[attr-defined] title=title, section=section, controls=controls, description=description, primary=primary, name=fn.__name__, method=wrapper, wants_subject=subject_param is not None, subject_param=subject_param, order=next(_FIGURE_ORDER), ) return wrapper return decorator
[docs] class BoundFigures: """A subject bound to a provider's figures — the transient renderable the GUI Dash adapter consumes. The per-render cache lives **here**, not on the provider, so a pydantic model stays free of transient state. Discard after use. """ def __init__(self, provider: "FigureProvider", subject: Any) -> None: self._provider = provider self._subject = subject self._cache: dict[tuple[Any, ...], "go.Figure"] = {} @property def subject(self) -> Any: """The bound subject (may be ``None`` for helpers that hold their own).""" return self._subject
[docs] def specs(self) -> list[FigureSpec]: """The provider's figures, sorted by definition order.""" return self._provider.iter_figures()
[docs] def render(self, spec: FigureSpec, **control_values: Any) -> "go.Figure": """Render ``spec`` with the given control values, caching by (name, values). Args: spec: The figure to render. **control_values: Control keyword values (simple scalars). Returns: The themed ``plotly.graph_objects.Figure``. """ key = (spec.name, *sorted(control_values.items())) if key not in self._cache: self._cache[key] = self._provider._render_spec( spec, self._subject, **control_values ) return self._cache[key]
[docs] class FigureProvider: """Mixin turning ``@figure`` methods into ``.inspect()`` / ``.dash()`` / a dashboard. Methods only — no fields, no ``__init__``, no instance state — so it is safe to mix into any ``pydantic.BaseModel`` in any MRO position (mirrors ``LazyWidgetMixin``). Mix it into the concrete class that owns the ``@figure`` methods:: class DiagnosticsPlotter(BasePlotter, FigureProvider): ... class MeasureSymmetricZones(MeasureFeatures, FigureProvider): ... Subject binding: * **Helpers** hold their subject — override :meth:`_figure_subject` to return it; their ``@figure`` methods read ``self`` and take no subject. * **Operations** pass the subject at call time (``op.dash(image)``); their ``@figure`` methods take it as the first positional parameter. """ # -- subject resolution ------------------------------------------------- def _figure_subject(self) -> Any: """Subject for subject-taking ``@figure`` methods. Helpers override to return their held state; operations leave this ``None`` and pass the subject at call time. """ return None def _resolve_subject(self, subject: Any) -> Any: """Return ``subject`` if given, else the held :meth:`_figure_subject`.""" return subject if subject is not None else self._figure_subject() # -- introspection ------------------------------------------------------
[docs] def iter_figures(self) -> list[FigureSpec]: """All ``@figure`` specs on this instance's class, in definition order. Walks the MRO so inherited figures are included. Normal Python override semantics apply: a subclass method without ``@figure`` shadows and removes the inherited figure, while a redecorated override keeps the inherited figure's original position. """ specs: dict[str, FigureSpec] = {} orders: dict[str, int] = {} shadowed: set[str] = set() for index, klass in enumerate(type(self).__mro__): # derived → base for name, attr in vars(klass).items(): if name in shadowed: continue shadowed.add(name) spec = getattr(attr, "__figure_spec__", None) if spec is not None: specs[name] = spec orders[name] = self._inherited_figure_order( name, spec.order, klass, index ) return sorted(specs.values(), key=lambda s: orders.get(s.name, s.order))
def _inherited_figure_order( self, name: str, fallback: int, selected_class: type, selected_index: int, ) -> int: """Return the inherited slot for a selected figure implementation.""" if selected_class is type(self): ancestors = type(self).__mro__[selected_index + 1 :] else: ancestors = selected_class.__mro__[1:] for klass in ancestors: if name not in vars(klass): continue ancestor_spec = getattr(vars(klass)[name], "__figure_spec__", None) return ancestor_spec.order if ancestor_spec is not None else fallback return fallback def _primary_spec(self) -> FigureSpec: """The figure ``inspect()`` returns: ``primary=True``, or the sole one.""" specs = self.iter_figures() if not specs: raise RuntimeError( f"{type(self).__name__} declares no @figure methods" ) primaries = [s for s in specs if s.primary] if primaries: return primaries[0] if len(specs) == 1: return specs[0] raise RuntimeError( f"{type(self).__name__} has multiple @figure methods but none is " f"marked primary=True; cannot pick an inspect() figure" ) # -- rendering ---------------------------------------------------------- def _render_spec( self, spec: FigureSpec, subject: Any = None, **control_values: Any ) -> "go.Figure": """Call ``spec``'s bound method, injecting the subject when required.""" method = getattr(self, spec.name) if spec.wants_subject: return method(self._resolve_subject(subject), **control_values) return method(**control_values)
[docs] def inspect( self, subject: Any = None, *, for_save: bool = False, **overrides: Any ) -> "go.Figure": """The primary saveable ``go.Figure``. This is the default ``inspect()`` contract used by control-free helpers and by the CLI's ``--save-inspect``. Classes that hand-write their own ``inspect`` (e.g. ``MeasureSymmetricZones``) override this naturally. Args: subject: Subject to render against (operations); ``None`` uses the held subject (helpers). for_save: Forwarded to the figure method if it accepts ``for_save`` (so legend-only layers can be flattened for a static raster). **overrides: Override control defaults by keyword. Returns: The themed primary ``plotly.graph_objects.Figure``. """ spec = self._primary_spec() method = getattr(self, spec.name) valid_params = set(inspect.signature(method).parameters) unknown = set(overrides) - set(spec.controls) if unknown: raise ValueError( f"inspect(): unknown override(s) {sorted(unknown)} for figure " f"{spec.name!r}; valid controls: {sorted(spec.controls)}" ) kwargs: dict[str, Any] = { kw: c.default for kw, c in spec.controls.items() } kwargs.update(overrides) if "for_save" in valid_params: kwargs["for_save"] = for_save if spec.wants_subject: return method(self._resolve_subject(subject), **kwargs) return method(**kwargs)
[docs] def dash(self, subject: Any = None) -> Any: """The interactive view. * No ``Control`` anywhere → a composed subplot ``go.Figure`` (preserving the repo-wide ``.dash() -> go.Figure`` contract). * Any ``Control`` present → the ipywidgets notebook dashboard. Args: subject: Subject to bind (operations); ``None`` uses the held subject. Returns: A ``go.Figure`` (control-free) or an ipywidgets widget (controls). """ specs = self.iter_figures() if not specs: raise RuntimeError( f"{type(self).__name__} declares no @figure methods" ) if any(s.controls for s in specs): from phenotypic.sdk_.viz.notebook._adapter import ( build_notebook_dashboard, ) return build_notebook_dashboard(self, subject) return self._compose_control_free_figure(subject)
[docs] def figures(self, subject: Any = None) -> BoundFigures: """Bind ``subject`` → a transient :class:`BoundFigures` the Dash adapter consumes. The per-render cache lives on the returned object, not here.""" return BoundFigures(self, subject)
def _compose_control_free_figure(self, subject: Any = None) -> "go.Figure": """Stack every control-free figure into one vertically-stacked subplot ``go.Figure`` (the default control-free ``dash()`` rendering). A provider that exposes a **single** control-free figure (e.g. a detect-modes faceted figure that is already a subplot grid) is returned as-is, with its own layout intact — no re-wrapping. Note: For multiple figures, only traces are carried over; per-subfigure layout (axis titles, ranges, colorbars, annotations) is not propagated. Dashboards needing richer multi-figure composition should override ``dash()``. """ from plotly.subplots import make_subplots from phenotypic.sdk_.viz.figures._theme import apply_theme specs = self.iter_figures() if len(specs) == 1: # single figure → return it directly so its layout/faceting survives return self._render_spec(specs[0], subject) titles = [s.title for s in specs] composed = make_subplots( rows=len(specs), cols=1, subplot_titles=titles ) for row, spec in enumerate(specs, start=1): sub = self._render_spec(spec, subject) for trace in sub.data: composed.add_trace(trace, row=row, col=1) return apply_theme(composed)