Source code for figures.publish

"""Promoting generated figures into the committed documentation tree.

Figures are rendered under the gitignored ``artefacts/`` directory, so they do not reach the
published documentation on their own. ``figures publish`` copies the chosen PNGs into
``docs/source/_figures/``, a committed directory the documentation pages embed, and writes a
provenance sidecar beside each so a published figure traces back to the analysis run and the
commit it was built from.

Only the rendered PNGs cross into the committed tree. They are aggregate, non-disclosive
summaries (class profiles, correlations, drift), so committing them is within the data
governance that keeps the rest of the artefacts tree out of the history.
"""

from __future__ import annotations

import json
import shutil
from dataclasses import dataclass
from pathlib import Path

from analysis import cache

from figures import __version__, data, paths


[docs] @dataclass(frozen=True) class FigureSpec: """One publishable figure. Attributes ---------- name : str The figure's key: its ``figures <name>`` build command and its documentation handle. source_stage : str The analysis stage whose cached run the figure visualises. file_name : str The figure's base file name, without a suffix. """ name: str source_stage: str file_name: str
# Every figure the package can publish, in the order the documentation presents them. FIGURES: tuple[FigureSpec, ...] = ( FigureSpec("reproduce", "align", "reproduction"), FigureSpec("select", "select", "selection_criteria"), FigureSpec("stability", "stability", "stability"), FigureSpec("nmin", "nmin", "stratum_size"), FigureSpec("replicate", "replicate", "replication"), ) FIGURES_BY_NAME: dict[str, FigureSpec] = {spec.name: spec for spec in FIGURES}
[docs] def publish_figure(root: Path, spec: FigureSpec, run: str | None = None) -> Path: """Copy one rendered figure into the documentation tree and record its provenance. Parameters ---------- root : pathlib.Path The monorepo root. spec : FigureSpec The figure to publish. run : str, optional The source run's short hash. When omitted, the latest completed run of the figure's source stage is used, the same run :func:`figures.data.resolve_run` would pick. Returns ------- pathlib.Path The written PNG in the documentation tree. Raises ------ FileNotFoundError When no PNG has been rendered for the figure yet, so the caller is told to build it first. """ source_run = data.resolve_run(root, spec.source_stage, run) source_hash = source_run.name png = paths.figure_stem(root, spec.source_stage, source_hash, spec.file_name).with_suffix( ".png" ) if not png.is_file(): msg = f"no rendered figure at {png}; run `figures {spec.name}` first" raise FileNotFoundError(msg) destination_dir = paths.docs_figures_dir(root) destination_dir.mkdir(parents=True, exist_ok=True) destination = destination_dir / f"{spec.file_name}.png" shutil.copyfile(png, destination) # A timestamp is deliberately left out: the source run and commit already pin the figure, # so the sidecar changes only when the figure does, and re-publishing an unchanged figure # leaves no spurious diff. sidecar = { "figure": spec.name, "source_stage": spec.source_stage, "source_run": source_hash, "source_git_commit": (cache.read_manifest(source_run) or {}).get("git_commit"), "figures_version": __version__, } destination.with_suffix(".json").write_text( json.dumps(sidecar, indent=2) + "\n", encoding="utf-8" ) return destination