Source code for phenotypic.abc_.plotting._pht_plot
"""Renderer-neutral plotting capability and figure composition helpers.
This module deliberately imports only the Python standard library at runtime.
Plotly, the PhenoTypic theme, and notebook widgets are loaded only when a figure
is rendered. This keeps the plotting capability safe to mix into Pydantic
models without adding fields, constructors, or persistent render state.
"""
from __future__ import annotations
import functools
import inspect
import itertools
import weakref
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable, Literal
if TYPE_CHECKING: # pragma: no cover - typing only
import plotly.graph_objects as go
__all__ = [
"BoundFigures",
"Control",
"FigureSpec",
"PhtPlot",
"figure",
]
_FIGURE_ORDER = itertools.count()
ControlKind = Literal["float", "select", "bool", "text"]
[docs]
@dataclass(frozen=True)
class Control:
"""Renderer-neutral input bound to a figure method keyword argument.
Controls are bound by identity. Reusing one ``Control`` instance across
figure methods creates one shared control, while equal but distinct
instances remain independent.
Args:
label: Human-readable control label.
kind: Control renderer kind.
default: Initial value for the control.
bounds: Required ``(low, high)`` range for a float control.
step: Optional step for a float control.
options: Required allowed values for a select control.
help: Optional help text.
Raises:
ValueError: If the default or kind-specific settings are invalid.
"""
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 - defensive for untyped callers
raise ValueError(
f"Control({self.label!r}): unknown kind {self.kind!r}"
)
[docs]
@dataclass(frozen=True)
class FigureSpec:
"""Metadata attached to a method by :func:`figure`.
Attributes:
title: Human-readable figure title.
section: Grouping tag used by report adapters.
controls: Mapping from method keyword to its control.
description: Optional renderer-neutral explanatory content.
primary: Whether this is the default :meth:`PhtPlot.inspect` figure.
name: Decorated method name.
method: Wrapped, theme-applying method.
wants_subject: Whether the method accepts a positional subject.
subject_param: Positional subject parameter name, if present.
order: Definition-order index.
"""
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 lazily apply the house theme.
A figure method may accept a subject as its first positional parameter.
Any positional parameter not named by ``controls`` is treated as the
subject. Plot-specific parameters that are not subjects must therefore be
keyword-only.
Args:
title: Human-readable figure title.
section: Grouping tag used by report adapters.
controls: Mapping from method keyword to renderer-neutral control.
description: Optional renderer-neutral explanatory content.
primary: Whether this is the default figure returned by ``inspect``.
Returns:
A decorator for a Plotly figure-building method.
Raises:
ValueError: If a control key does not name a method parameter.
"""
declared_controls = dict(controls) if controls else {}
def decorator(
fn: Callable[..., "go.Figure"],
) -> Callable[..., "go.Figure"]:
signature = inspect.signature(fn)
params = [
parameter
for name, parameter in signature.parameters.items()
if name != "self"
]
param_names = {parameter.name for parameter in params}
for kwarg in declared_controls:
if kwarg not in param_names:
raise ValueError(
f"@figure({fn.__name__!r}): control key {kwarg!r} is not a "
"parameter of the method"
)
subject_param: str | None = None
for parameter in params:
is_positional = parameter.kind in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.POSITIONAL_ONLY,
)
if is_positional and parameter.name not in declared_controls:
subject_param = parameter.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=declared_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:
"""Transient subject binding for a :class:`PhtPlot`.
Image subjects are held weakly so a notebook widget cannot extend the
lifetime of a complete plate image. Aggregate subjects such as measurement
tables are held strongly because they are compact, reusable plot inputs.
Figures are rendered on demand rather than cached across control values.
Args:
provider: Plot provider whose figures will be rendered.
subject: Runtime subject bound to those figures.
"""
def __init__(self, provider: PhtPlot, subject: Any) -> None:
self._provider = provider
self._subject: Any = None
self._subject_ref: weakref.ReferenceType[Any] | None = None
if provider._weakly_bind_subject and subject is not None:
self._subject_ref = weakref.ref(subject)
else:
self._subject = subject
@property
def subject(self) -> Any:
"""Return the bound runtime subject."""
if self._subject_ref is not None:
subject = self._subject_ref()
if subject is None:
raise RuntimeError(
"The image bound to this plotting report has been released. "
"Keep the image alive while interacting with the report."
)
return subject
return self._subject
[docs]
def specs(self) -> list[FigureSpec]:
"""Return provider figures in definition order."""
return self._provider.iter_figures()
[docs]
def render(self, spec: FigureSpec, **control_values: Any) -> "go.Figure":
"""Render a figure for the supplied control values.
Args:
spec: Figure metadata to render.
**control_values: Control values passed to the figure method.
Returns:
The themed Plotly figure.
"""
return self._provider._render_spec(
spec, self.subject, **control_values
)
[docs]
class PhtPlot:
"""Methods-only mixin for saveable figures and complete reports.
``PhtPlot`` has no fields, constructor, abstract methods, or persistent
instance state. It can therefore be combined with an existing Pydantic
model without changing that model's schema or serialization.
Subject-taking figure methods receive a subject passed to ``inspect`` or
``report``. Helpers that already hold their subject may instead override
:meth:`_figure_subject` and declare subject-free figure methods.
"""
_weakly_bind_subject = False
def _figure_subject(self) -> Any:
"""Return a held figure subject, if the provider owns one."""
return None
def _resolve_subject(self, subject: Any) -> Any:
"""Resolve a call-time subject before falling back to held state."""
return subject if subject is not None else self._figure_subject()
[docs]
def iter_figures(self) -> list[FigureSpec]:
"""Return all visible figure specs in definition order.
Figure discovery follows normal Python override rules across the MRO.
An undecorated override removes an inherited figure, while a decorated
override retains the inherited figure's position.
"""
specs: dict[str, FigureSpec] = {}
orders: dict[str, int] = {}
shadowed: set[str] = set()
for index, klass in enumerate(type(self).__mro__):
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 spec: orders[spec.name])
def _inherited_figure_order(
self,
name: str,
fallback: int,
selected_class: type,
selected_index: int,
) -> int:
"""Return the inherited definition slot for a selected override."""
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:
"""Return the explicit primary figure or the only declared figure."""
specs = self.iter_figures()
if not specs:
raise RuntimeError(
f"{type(self).__name__} declares no @figure methods"
)
primaries = [spec for spec in specs if spec.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 "
"marked primary=True; cannot pick an inspect() figure"
)
def _render_spec(
self,
spec: FigureSpec,
subject: Any = None,
**control_values: Any,
) -> "go.Figure":
"""Render one figure spec with its resolved subject and controls."""
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,
) -> Any:
"""Return the primary saveable figure.
Args:
subject: Runtime subject, or ``None`` to use held subject state.
for_save: Forwarded when the selected figure accepts it.
**overrides: Values overriding declared control defaults.
Returns:
The themed primary figure. Multi-page producers may override this
method and return a runtime plotting output.
Raises:
ValueError: If an override is not a declared control.
"""
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 = {
kwarg: control.default for kwarg, control 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 report(self, subject: Any = None, **overrides: Any) -> Any:
"""Return the complete composed or interactive report.
Args:
subject: Runtime subject, or ``None`` to use held subject state.
**overrides: Reserved for specialized report implementations.
Returns:
A composed Plotly figure when all figures are control-free, or an
ipywidgets report when any figure declares controls.
Raises:
RuntimeError: If no figure methods are declared.
ValueError: If the base report receives overrides. Concrete plots
may override this method to expose report-specific parameters.
"""
if overrides:
raise ValueError(
f"report(): override(s) {sorted(overrides)} require a "
"plot-specific report implementation"
)
specs = self.iter_figures()
if not specs:
raise RuntimeError(
f"{type(self).__name__} declares no @figure methods"
)
if any(spec.controls for spec 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 a subject to figures for use by a renderer adapter."""
return BoundFigures(self, subject)
def _compose_control_free_figure(self, subject: Any = None) -> "go.Figure":
"""Compose all control-free figures into one Plotly figure.
A single figure is returned without re-wrapping so its existing layout
and faceting remain intact. Multiple figures are stacked vertically in
definition order.
"""
from plotly.subplots import make_subplots
from phenotypic.sdk_.viz.figures._theme import apply_theme
specs = self.iter_figures()
if len(specs) == 1:
return self._render_spec(specs[0], subject)
composed = make_subplots(
rows=len(specs),
cols=1,
subplot_titles=[spec.title for spec in specs],
)
for row, spec in enumerate(specs, start=1):
rendered = self._render_spec(spec, subject)
for trace in rendered.data:
composed.add_trace(trace, row=row, col=1)
return apply_theme(composed)