Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
357 changes: 357 additions & 0 deletions oceanarray/reports/_manifest.py
Original file line number Diff line number Diff line change
@@ -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 ``<h2>``).
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)
)
Loading
Loading