diff --git a/oceanarray/reports/_manifest.py b/oceanarray/reports/_manifest.py new file mode 100644 index 0000000..c405252 --- /dev/null +++ b/oceanarray/reports/_manifest.py @@ -0,0 +1,357 @@ +"""Section-manifest model and resolver for report pages. + +A report page is described by a :class:`Profile`: an ordered sequence of +:class:`Section` (and :class:`Expand`) entries, each naming :class:`Panel` ids +that render figures or tables. :func:`resolve` walks a profile once against a +render context and returns a :class:`ResolvedReport` whose sections are numbered +over the *rendered subset* — absent sections leave no gap, and identity is the +section id (a stable slug), not the integer. + +This module holds only the model and the resolution algorithm — it is +package-neutral (names no variable, page, or science) and is vendored +byte-identical to the sister repos. Each page's concrete registry (its +``Ctx`` builder, panels, sections, and profiles) lives in that page's own +module — grid's in ``reports/_grid.py``, and so on — never in a plural +``_manifests.py`` companion. The design rationale is in +``.claude/notes/2026-08-15-report-section-manifest-design.md``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Literal, Sequence + +#: Panel content kinds. ``"figure"`` renders a base64 PNG (escaped as an image +#: ``src``); ``"html"`` and ``"table"`` render pre-built markup that the template +#: macro emits with ``|safe``. The discriminator keeps the ``autoescape=True`` +#: boundary to one auditable branch — a figure payload is never ``|safe``-d. +PanelKind = Literal["figure", "html", "table"] + + +def _always(_ctx: Any) -> bool: + """Return True for any context (the default ``applies_to`` predicate).""" + return True + + +# --------------------------------------------------------------------------- +# Authored model — what a profile is built from +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Panel: + """One figure or table, addressable by id. + + Parameters + ---------- + id : str + Unique panel identifier, e.g. ``"temperature_field"``. Also the anchor + a caption or cross-reference can point at. + render : Callable + Adapter that returns the panel's payload — a base64 PNG string for a + ``"figure"``, or ready-to-emit markup for ``"html"``/``"table"`` — or + ``None`` when the panel applies but no output could be produced (a plot + raised, or the variable is absent), which the resolver turns into a + ``.warn`` stub. Figure adapters wrap the existing ``_make_*_b64`` + functions unchanged. + kind : {"figure", "html", "table"}, optional + Content discriminator. The template macro branches on it so that only + ``"html"``/``"table"`` payloads are emitted ``|safe``; a ``"figure"`` + payload is always escaped into an image ``src``. + slot : str or Callable, optional + ``SLOTS`` key giving the panel's display width, or a callable + ``ctx -> slot`` that computes it (e.g. from a section's aspect ratio). + Belongs to the panel, not the section, so a panel is the same width + wherever it is placed. The resolver calls it when callable. + caption : str, optional + Caption text rendered beneath the panel. + applies_to : Callable, optional + Predicate deciding whether the panel is attempted at all. ``False`` + omits the panel silently (it is excluded, not merely unavailable). + + """ + + id: str + render: Callable[[Any], str | None] + kind: PanelKind = "figure" + slot: str | Callable[[Any], str] = "full" + caption: str | None = None + applies_to: Callable[[Any], bool] = _always + + +@dataclass(frozen=True) +class Section: + """A numbered heading and the panels beneath it. + + Parameters + ---------- + id : str + Unique section identifier, e.g. ``"hydrography"``. Doubles as the anchor + and the cross-reference key. + title : str + Human-readable heading text (without a number — the number is computed). + panels : tuple of str + Panel ids, in display order. + level : int, optional + Heading level (``2`` for ``

``). + intro : str, optional + Introductory prose rendered under the heading, before the panels. + applies_to : Callable, optional + Predicate deciding whether the section is included. When ``None`` + (default), the section applies if *any* of its panels apply. A section + dropped here is named in the report's "not applicable" footer line. + role : str, optional + ``"content"`` (numbered ``1..N``) or ``"appendix"`` (numbered ``A..``), + so appendix material such as a NetCDF-variable table does not pad the + science numbering. + + """ + + id: str + title: str + panels: tuple[str, ...] + level: int = 2 + intro: str | None = None + applies_to: Callable[[Any], bool] | None = None + role: str = "content" + + +@dataclass(frozen=True) +class Expand: + """A single manifest entry that becomes N sections at resolution time. + + Parameters + ---------- + over : Callable + Returns the sequence of items to expand over (e.g. the variables present + on the page, in display order). + section : Callable + Builds one :class:`Section` per item yielded by ``over``. + + """ + + over: Callable[[Any], Sequence[Any]] + section: Callable[[Any], Section] + + +@dataclass(frozen=True) +class Profile: + """An ordered page description plus a numbering policy. + + Parameters + ---------- + numbering : str + ``"flat"`` (content ``1..N``, appendices ``A..``), ``"none"`` (no + numbers), or ``"grouped"`` (reserved — not yet implemented). + entries : tuple of (Section or Expand) + The page's sections, in order. + + """ + + numbering: str = "flat" + entries: tuple[Section | Expand, ...] = field(default_factory=tuple) + + +# --------------------------------------------------------------------------- +# Resolved model — what the template renders +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ResolvedPanel: + """A panel after rendering: a payload (figure b64 or markup) or a ``.warn`` stub.""" + + id: str + kind: PanelKind + slot: str + payload: str | None + caption: str | None + stub_reason: str | None = None + + @property + def is_stub(self) -> bool: + """True when the panel applied but produced no output.""" + return self.payload is None + + +@dataclass(frozen=True) +class ResolvedSection: + """A section after resolution: numbered heading plus resolved panels.""" + + id: str + number: str + title: str + level: int + intro: str | None + panels: tuple[ResolvedPanel, ...] + role: str + + +@dataclass(frozen=True) +class ResolvedReport: + """The full resolved page: numbered sections plus the not-applicable list.""" + + sections: tuple[ResolvedSection, ...] + not_applicable: tuple[str, ...] + + def anchor(self, section_id: str) -> str | None: + """Return the display number for *section_id*, or None if not rendered.""" + for sec in self.sections: + if sec.id == section_id: + return sec.number + return None + + +# --------------------------------------------------------------------------- +# Resolution algorithm +# --------------------------------------------------------------------------- + +_STUB_REASON = "applicable but unavailable" + + +def _letter(n: int) -> str: + """Return the appendix letter for a zero-based index (0 -> 'A', 25 -> 'Z', 26 -> 'AA').""" + letters = "" + n += 1 + while n > 0: + n, rem = divmod(n - 1, 26) + letters = chr(ord("A") + rem) + letters + return letters + + +def _expand(entries: Sequence[Section | Expand], ctx: Any) -> list[Section]: + """Splice every :class:`Expand` into the concrete sections it yields.""" + out: list[Section] = [] + for entry in entries: + if isinstance(entry, Expand): + out.extend(entry.section(item) for item in entry.over(ctx)) + else: + out.append(entry) + return out + + +def _section_applies(sec: Section, ctx: Any, panels: dict[str, Panel]) -> bool: + """Decide whether *sec* is included: explicit predicate, else any panel applies.""" + if sec.applies_to is not None: + return bool(sec.applies_to(ctx)) + return any(panels[pid].applies_to(ctx) for pid in sec.panels) + + +def _resolve_slot(panel: Panel, ctx: Any) -> str: + """Return the panel's slot, calling it with *ctx* when it is a callable.""" + return panel.slot(ctx) if callable(panel.slot) else panel.slot + + +def _resolve_panels( + sec: Section, ctx: Any, panels: dict[str, Panel] +) -> tuple[ResolvedPanel, ...]: + """Render each applicable panel of a kept section; None output becomes a stub. + + A kept section whose panels all drop out (none apply, or all render None) + keeps one stub so the heading is never empty. + """ + resolved: list[ResolvedPanel] = [] + for pid in sec.panels: + panel = panels[pid] + if not panel.applies_to(ctx): + continue + payload = panel.render(ctx) + resolved.append( + ResolvedPanel( + id=panel.id, + kind=panel.kind, + slot=_resolve_slot(panel, ctx), + payload=payload, + caption=panel.caption, + stub_reason=None if payload is not None else _STUB_REASON, + ) + ) + if not resolved: + # Kept but empty (case 2): keep the heading with a single stub. + first = panels[sec.panels[0]] if sec.panels else None + resolved.append( + ResolvedPanel( + id=first.id if first else f"{sec.id}_stub", + kind=first.kind if first else "figure", + slot=_resolve_slot(first, ctx) if first else "full", + payload=None, + caption=first.caption if first else None, + stub_reason=_STUB_REASON, + ) + ) + return tuple(resolved) + + +def resolve(profile: Profile, ctx: Any, panels: dict[str, Panel]) -> ResolvedReport: + """Resolve *profile* against *ctx* into a numbered :class:`ResolvedReport`. + + One pass: expand :class:`Expand` entries, drop sections whose ``applies_to`` + is false (collecting them for the not-applicable footer), resolve each kept + section's panels (``None`` render → ``.warn`` stub), then number the + survivors — ``content`` sections ``1..N`` and ``appendix`` sections ``A..``. + + Parameters + ---------- + profile : Profile + The page's ordered section description and numbering policy. + ctx : Any + The render context passed to every predicate and render callable + (dataset, config, paths — whatever the page's panels need). + panels : dict of str to Panel + The panel registry; section ``panels`` entries are ids into this map. + + Returns + ------- + ResolvedReport + Numbered, rendered sections plus the titles of any omitted sections. + + Raises + ------ + NotImplementedError + If ``profile.numbering == "grouped"`` (reserved for a later branch). + KeyError + If a section references a panel id absent from *panels*. + + """ + if profile.numbering not in ("flat", "none"): + raise NotImplementedError( + f"numbering={profile.numbering!r} is not implemented; use 'flat' or 'none'" + ) + + sections = _expand(profile.entries, ctx) + + kept: list[tuple[Section, tuple[ResolvedPanel, ...]]] = [] + not_applicable: list[str] = [] + for sec in sections: + if not _section_applies(sec, ctx, panels): + not_applicable.append(sec.title) + continue + kept.append((sec, _resolve_panels(sec, ctx, panels))) + + resolved: list[ResolvedSection] = [] + content_n = 0 + appendix_n = 0 + for sec, rpanels in kept: + if profile.numbering == "none": + number = "" + elif sec.role == "appendix": + number = _letter(appendix_n) + appendix_n += 1 + else: + content_n += 1 + number = str(content_n) + resolved.append( + ResolvedSection( + id=sec.id, + number=number, + title=sec.title, + level=sec.level, + intro=sec.intro, + panels=rpanels, + role=sec.role, + ) + ) + + return ResolvedReport( + sections=tuple(resolved), not_applicable=tuple(not_applicable) + ) diff --git a/tests/unit/test_manifest.py b/tests/unit/test_manifest.py new file mode 100644 index 0000000..01d08c4 --- /dev/null +++ b/tests/unit/test_manifest.py @@ -0,0 +1,228 @@ +"""Tests for the section-manifest model and resolver (``reports/_manifest.py``). + +These exercise the resolution algorithm in isolation with synthetic panels and +sections — no rendered page needed. They pin the invariants from the design +note: compact numbering over the rendered subset, appendix lettering, ``Expand`` +splicing, ``applies_to`` dropping to the not-applicable list, and ``None`` renders +becoming stubs while the section keeps its heading and number. +""" + +import pytest + +from oceanarray.reports._manifest import ( + Expand, + Panel, + Profile, + Section, + _letter, + resolve, +) + + +def _panel(pid: str, html: str | None = "
", **kw) -> Panel: + """Build a synthetic panel returning a fixed HTML string (or None).""" + return Panel(id=pid, render=lambda _ctx: html, **kw) + + +def _registry(*panels: Panel) -> dict[str, Panel]: + """Return a panel registry keyed by id.""" + return {p.id: p for p in panels} + + +# --------------------------------------------------------------------------- +# Numbering +# --------------------------------------------------------------------------- + + +def test_flat_numbering_is_one_to_n(): + """Content sections number 1..N in profile order.""" + panels = _registry(_panel("a"), _panel("b"), _panel("c")) + profile = Profile( + numbering="flat", + entries=( + Section("s1", "One", ("a",)), + Section("s2", "Two", ("b",)), + Section("s3", "Three", ("c",)), + ), + ) + report = resolve(profile, ctx=None, panels=panels) + assert [s.number for s in report.sections] == ["1", "2", "3"] + assert [s.title for s in report.sections] == ["One", "Two", "Three"] + + +def test_appendix_sections_get_letters_and_do_not_pad_content(): + """Appendix sections number A.. independently of the content 1..N run.""" + panels = _registry(_panel("a"), _panel("b"), _panel("z")) + profile = Profile( + numbering="flat", + entries=( + Section("s1", "One", ("a",)), + Section("appx", "NetCDF variables", ("z",), role="appendix"), + Section("s2", "Two", ("b",)), + ), + ) + report = resolve(profile, ctx=None, panels=panels) + nums = {s.id: s.number for s in report.sections} + assert nums == {"s1": "1", "appx": "A", "s2": "2"} + + +def test_numbering_none_leaves_numbers_blank(): + """numbering='none' renders every section with an empty number.""" + panels = _registry(_panel("a"), _panel("b")) + profile = Profile( + numbering="none", + entries=(Section("s1", "One", ("a",)), Section("s2", "Two", ("b",))), + ) + report = resolve(profile, ctx=None, panels=panels) + assert all(s.number == "" for s in report.sections) + + +def test_grouped_numbering_not_implemented(): + """numbering='grouped' is reserved and raises until a later branch builds it.""" + profile = Profile(numbering="grouped", entries=()) + with pytest.raises(NotImplementedError): + resolve(profile, ctx=None, panels={}) + + +@pytest.mark.parametrize( + "n,expected", [(0, "A"), (1, "B"), (25, "Z"), (26, "AA"), (27, "AB")] +) +def test_letter_sequence(n, expected): + """Appendix lettering rolls over A..Z, AA, AB like spreadsheet columns.""" + assert _letter(n) == expected + + +# --------------------------------------------------------------------------- +# Inclusion / not-applicable +# --------------------------------------------------------------------------- + + +def test_section_dropped_by_explicit_predicate_lands_in_not_applicable(): + """A section whose applies_to is false is omitted and named in the footer list.""" + panels = _registry(_panel("a"), _panel("v")) + profile = Profile( + entries=( + Section("hydro", "Hydrography", ("a",)), + Section("vel", "Velocity", ("v",), applies_to=lambda _c: False), + ), + ) + report = resolve(profile, ctx=None, panels=panels) + assert [s.id for s in report.sections] == ["hydro"] + assert report.not_applicable == ("Velocity",) + # The kept section renumbers compactly — no gap where Velocity was. + assert report.sections[0].number == "1" + + +def test_default_section_applies_iff_any_panel_applies(): + """With applies_to=None, a section is dropped when none of its panels apply.""" + panels = _registry( + _panel("present"), + _panel("absent", applies_to=lambda _c: False), + ) + profile = Profile( + entries=( + Section("kept", "Kept", ("present",)), + Section("gone", "Gone", ("absent",)), + ), + ) + report = resolve(profile, ctx=None, panels=panels) + assert [s.id for s in report.sections] == ["kept"] + assert report.not_applicable == ("Gone",) + + +# --------------------------------------------------------------------------- +# Panel rendering / stubs +# --------------------------------------------------------------------------- + + +def test_none_render_becomes_stub_but_keeps_content(): + """A panel that renders None becomes a stub alongside rendered panels.""" + panels = _registry(_panel("ok", "
"), _panel("empty", None)) + profile = Profile(entries=(Section("s", "S", ("ok", "empty")),)) + report = resolve(profile, ctx=None, panels=panels) + rpanels = report.sections[0].panels + assert rpanels[0].is_stub is False and rpanels[0].payload == "
" + assert rpanels[1].is_stub is True and rpanels[1].stub_reason + + +def test_kept_section_with_all_panels_none_keeps_heading_and_number(): + """A section applicable but with every panel None keeps its heading + one stub.""" + panels = _registry(_panel("empty", None)) + profile = Profile( + entries=(Section("before", "Before", ("empty",), applies_to=lambda _c: True),), + ) + report = resolve(profile, ctx=None, panels=panels) + sec = report.sections[0] + assert sec.number == "1" + assert len(sec.panels) == 1 and sec.panels[0].is_stub + + +def test_panel_kind_is_carried_to_resolved_panel(): + """A panel's kind survives resolution so the macro can branch on escaping.""" + panels = _registry( + _panel("fig"), + _panel("tbl", "", kind="table"), + _panel("prose", "
    ", kind="html"), + ) + profile = Profile(entries=(Section("s", "S", ("fig", "tbl", "prose")),)) + report = resolve(profile, ctx=None, panels=panels) + kinds = {p.id: p.kind for p in report.sections[0].panels} + assert kinds == {"fig": "figure", "tbl": "table", "prose": "html"} + + +def test_callable_slot_is_resolved_against_ctx(): + """A panel whose slot is a callable has it computed from ctx at resolution.""" + panels = _registry(_panel("f", slot=lambda ctx: ctx["width"])) + profile = Profile(entries=(Section("s", "S", ("f",)),)) + report = resolve(profile, ctx={"width": "half"}, panels=panels) + assert report.sections[0].panels[0].slot == "half" + + +def test_panel_not_applying_is_omitted_silently(): + """A panel whose applies_to is false is dropped without a stub.""" + panels = _registry( + _panel("shown"), + _panel("hidden", applies_to=lambda _c: False), + ) + profile = Profile(entries=(Section("s", "S", ("shown", "hidden")),)) + report = resolve(profile, ctx=None, panels=panels) + ids = [p.id for p in report.sections[0].panels] + assert ids == ["shown"] + + +# --------------------------------------------------------------------------- +# Expand +# --------------------------------------------------------------------------- + + +def test_expand_splices_one_section_per_item(): + """An Expand entry becomes N sections, numbered in the spliced order.""" + panels = _registry(_panel("head"), _panel("var")) + expand = Expand( + over=lambda _c: ["Temperature", "Salinity"], + section=lambda name: Section(f"var_{name.lower()}", name, ("var",)), + ) + profile = Profile( + entries=(Section("intro", "Processing history", ("head",)), expand), + ) + report = resolve(profile, ctx=None, panels=panels) + assert [s.title for s in report.sections] == [ + "Processing history", + "Temperature", + "Salinity", + ] + assert [s.number for s in report.sections] == ["1", "2", "3"] + + +def test_anchor_returns_number_or_none(): + """ResolvedReport.anchor maps a section id to its number, or None if dropped.""" + panels = _registry(_panel("a"), _panel("v")) + profile = Profile( + entries=( + Section("hydro", "Hydrography", ("a",)), + Section("vel", "Velocity", ("v",), applies_to=lambda _c: False), + ), + ) + report = resolve(profile, ctx=None, panels=panels) + assert report.anchor("hydro") == "1" + assert report.anchor("vel") is None