Source code for glidertest.reports

"""HTML report generation: slot layer, plot adapters, and the public report entry point."""

from __future__ import annotations

import base64
import json
import warnings
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any

from ._vendored.page_model import validate_nav

if TYPE_CHECKING:
    from collections.abc import Mapping

    import xarray as xr

    from ..config.loader import Config
    from ._mission import Page

__all__ = ["navigator", "report", "resolve_mission_id"]

#: Masthead nav rows, in order: (``Page.nav_group`` key, row label). The inventory group is a
#: separate strip, not a row. Built in the planned shared-design-system shape
#: (``notes/2026-10-07-shared-masthead-nav-plan.md`` §2) so the eventual vendored macro is a drop-in.
_NAV_ROWS: tuple[tuple[str, str], ...] = (("summary", "Summary"), ("reports", "Reports"), ("derived", "Derived"))


def _save_config_copy(cfg: Config, dest: Path) -> None:
    """Write *cfg* to *dest* (``report-config.yaml``) so a re-run can consume it unchanged.

    A file-backed config is copied byte-for-byte, so its comments and the carried-but-deferred
    ``display``/``mission``/``pages`` blocks survive; a dict-backed config (``cfg.path`` is ``None``,
    the Python-API case) is dumped from its ``qc``/``region``/``source`` blocks, which have no
    comments to preserve. When the source file is already *dest* — a re-run that reads the saved
    copy — the copy is skipped (``shutil.copyfile`` would raise ``SameFileError``).

    No config value is a path today. Were one added as a path relative to the config file's own
    directory, this verbatim copy into the mission dir would not resolve the same way; handle it here.

    Parameters
    ----------
    cfg : glidertest.config.loader.Config
        The validated config to save.
    dest : pathlib.Path
        The destination ``report-config.yaml`` path in the mission directory.
    """
    import shutil

    import yaml

    if cfg.path is not None:
        if cfg.path.resolve() == dest.resolve():
            return
        shutil.copyfile(cfg.path, dest)
        return
    dest.write_text(yaml.safe_dump(cfg.user_blocks(), sort_keys=False), encoding="utf-8")


def _build_nav(pages: list[Page], current: Page, source_name: str, *, back: bool) -> dict[str, Any]:
    """Build the masthead nav for the *current* page: grouped pill rows, inventory strip, back pill.

    Shape matches the planned vendored contract (notes/2026-10-07-shared-masthead-nav-plan §2):
    ``{"rows": [{"label", "pills": [{"label", "href", "role", "state"}]}], "back": pill|None,
    "inventory": [pill]}``; ``state`` is ``"current"`` for *current*, else ``"link"``.
    """

    def pill(p: Page) -> dict[str, str]:
        return {
            "label": p.title,
            "href": p.filename,
            "role": p.role,
            "state": "current" if p is current else "link",
        }

    rows = [
        {"label": label, "pills": [pill(p) for p in group]}
        for key, label in _NAV_ROWS
        if (group := [p for p in pages if p.nav_group == key])
    ]
    # Inventory pills render as .file-pill, not .nav-<role>, so their role is cosmetic — but
    # validate_nav checks every pill's role against ROLE_ACCENT, so use a valid key ("component",
    # as ctdcast's _navs.py does) rather than the inventory Page's own "inventory" role.
    inventory = [
        {**pill(p), "label": source_name, "role": "component"}
        for p in pages
        if p.nav_group == "inventory"
    ]
    up_pill = (
        {"label": "← All missions", "href": "../index.html", "role": "up", "state": "link"}
        if back
        else None
    )
    # The up-link renders as a breadcrumb above the masthead (base.html topbar), not as an inline
    # nav pill, so it goes under "up", not "back" — the vendored page_nav draws "back" in the first
    # row, which we don't want. "back" stays None so page_nav omits it.
    nav = {"rows": rows, "back": None, "inventory": inventory, "up": up_pill}
    validate_nav(nav)  # fail loudly at build time on a malformed nav (bad role/state, >1 current)
    return nav


def resolve_mission_id(ds: xr.Dataset | None, mission_id: str | None = None) -> str:
    """Return the sanitised mission directory name: *mission_id* if given, else derived from *ds*.

    The single source for turning a dataset (and an optional caller override) into the subdirectory
    name used under a report root, shared by :func:`report` and the command-line interface so the
    two cannot disagree about where a mission is written. With *mission_id* the override is sanitised
    (path separators neutralised); *ds* is not read. Otherwise the name is the OG1 ``id`` attribute,
    else the source file stem (see :func:`glidertest.reports._mission.mission_id`).

    Parameters
    ----------
    ds : xarray.Dataset or None
        The dataset to derive the name from; may be None only when *mission_id* is given.
    mission_id : str, optional
        An explicit name to use instead of the derived one.

    Returns
    -------
    str
        The sanitised mission directory name.
    """
    from ._mission import _safe_dirname
    from ._mission import mission_id as _derive

    return _safe_dirname(mission_id) if mission_id else _derive(ds)


[docs] def report( ds: xr.Dataset, outdir: Path | str, *, navigator: bool = True, mission_id: str | None = None, layout: str = "root", config: str | Path | Mapping | None = None, ) -> Path: """Write a self-contained HTML report for *ds* under *outdir* and return the landing page path. With *layout* ``"root"`` (the default) *outdir* is treated as a **root**: the report is written into ``<outdir>/<mission_id>/`` (created if absent), so a root can hold many missions side by side. With *layout* ``"flat"`` the pages are written directly into *outdir* and no navigator is built (*navigator* is ignored) — one mission, one self-contained folder. The ``mission_id`` subdirectory name is the OG1 ``id`` attribute when present, else the source file stem (see :func:`glidertest.reports._mission.mission_id`); pass *mission_id* to override it (run through the same directory-name sanitising). Each applicable page in :data:`glidertest.reports._mission.PAGES` is written to ``<page>.html``; the pages share one ``figures/`` with page-prefixed slugs (``<page>_<panel>.png``) and a cross-page nav. A machine-readable ``report.json`` manifest is written beside the landing page. With *navigator* ``True`` (the default, root layout only), ``<outdir>/index.html`` — a fleet navigator over every ``<outdir>/*/report.json`` — is rebuilt after the mission is written (see :func:`navigator`). The rebuild redraws the fleet track map, so for a **batch** pass ``navigator=False`` in the loop and call :func:`navigator` once at the end rather than rebuilding on every mission. Figures are rendered under the non-interactive ``Agg`` backend for the duration of the build, then the caller's backend is restored. glidertest's plotters call ``plt.show()`` when they draw their own figure, which would pop a window per panel on an interactive backend (e.g. ``macosx``); ``Agg`` makes that a no-op. Switching the backend closes any figures the caller had open. Parameters ---------- ds : xarray.Dataset An OG1 glider dataset. outdir : pathlib.Path or str The root directory (``layout="root"``) or the mission's own directory (``layout="flat"``). navigator : bool, default True Rebuild ``<outdir>/index.html`` (the fleet navigator) after writing the mission. Ignored when ``layout="flat"``. mission_id : str, optional Subdirectory name for this mission, overriding the name derived from *ds*; run through the same sanitising as the derived name. Must not be combined with ``layout="flat"`` (there is no subdirectory to name) — doing so raises :class:`ValueError`. layout : {"root", "flat"}, default "root" ``"root"`` writes the mission into ``<outdir>/<mission_id>/``; ``"flat"`` writes the pages directly into *outdir* and builds no navigator. config : str or pathlib.Path or Mapping, optional A report config (a YAML file path or an already-parsed mapping) whose ``qc:`` block overrides the built-in QC thresholds per variable and test; see :mod:`glidertest.config.loader`. It is validated before any figure is drawn, raising :class:`~glidertest.config.loader.ConfigError` on an error. ``None`` (the default) uses the built-in thresholds, so the output is unchanged. When given, the config is saved beside ``report.json`` as ``report-config.yaml`` so a re-run can consume it (``config=<missiondir>/report-config.yaml``): a file is copied verbatim, a dict is dumped (and so carries no comments). Returns ------- pathlib.Path The path to the written landing page (``<outdir>/<mission_id>/index.html`` for root layout, ``<outdir>/index.html`` for flat). """ import matplotlib import matplotlib.pyplot as plt from .._version import __version__ from ..config import loader from . import _figdebug, paths from ._env import get_template from ._mission import PAGES, Ctx, build, header_card from .design import _JS_TOP_LINKS, PACKAGE_ACCENT, SHARED_CSS from .manifest import mission_manifest if layout not in ("root", "flat"): msg = f"layout must be 'root' or 'flat', got {layout!r}" raise ValueError(msg) if mission_id is not None and layout == "flat": msg = "mission_id cannot be combined with layout='flat' (there is no subdirectory to name)" raise ValueError(msg) root = Path(outdir) if layout == "flat" and paths.is_report_root(root): msg = ( f"{root} is a report root (it already holds mission subdirectories); writing a flat " f"report here would overwrite its fleet index.html. Use layout='root', or a fresh " f"directory." ) raise ValueError(msg) mid = resolve_mission_id(ds, mission_id) missiondir = root if layout == "flat" else paths.mission_dir(root, mid) do_navigator = navigator and layout == "root" (missiondir / "figures").mkdir(parents=True, exist_ok=True) cfg = loader.check(loader.load(config), ds) if config is not None else None resolved_qc = loader.resolve_qc(cfg) # the effective thresholds (defaults when cfg is None); resolve once ctx = Ctx( ds=ds, qc_config=resolved_qc, bbox=cfg.bbox if cfg is not None else None, ) pages = [p for p in PAGES if p.applies_to(ctx)] source = ds.encoding.get("source") source_name = Path(source).name if source else f"{ds.attrs.get('id') or mid}.nc" source_size = None if source: try: source_size = Path(source).stat().st_size except OSError: source_size = None # Overwriting on a re-run (same source file) is intended and silent; overwriting with a # *different* source file is worth a warning, whose cause differs by layout: in root layout two # files share one OG1 id (a metadata problem); in flat layout the output directory was reused. existing = paths.manifest_in(missiondir) if existing.exists(): try: prev = json.loads(existing.read_text(encoding="utf-8")) except (OSError, ValueError): prev = {} prev_source = prev.get("source_file") if prev_source and prev_source != source_name: if layout == "flat": msg = ( f"{root} already holds a report for {prev_source!r}; " f"overwriting with {source_name!r}." ) else: msg = ( f"mission id {mid!r} already reported from {prev_source!r}; overwriting with " f"{source_name!r}. Two files sharing one OG1 id is a metadata problem — check " f"the 'id' attribute of both files." ) warnings.warn(msg, stacklevel=2) generated_at = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") common = { "css": SHARED_CSS, "header": header_card(ds, facts=ctx.facts), "mission_id": mid, "source_name": source_name, "version": __version__, "generated_at": generated_at, "masthead_bg": PACKAGE_ACCENT, "js_top_links": _JS_TOP_LINKS, } template = get_template("mission.html") # Render figures headless so the plotters' plt.show() calls never pop a window; restore after. orig_backend = matplotlib.get_backend() switch = orig_backend.lower() != "agg" if switch: plt.switch_backend("Agg") try: _figdebug.clear() for page in pages: slug = page.filename.rsplit(".", 1)[0] resolved = build(ds, page.profile, ctx=ctx) for section in resolved.sections: for panel in section.panels: if panel.kind == "figure" and panel.payload is not None: (missiondir / "figures" / f"{slug}_{panel.id}.png").write_bytes( base64.b64decode(panel.payload) ) # Root layout always links back to the fleet page at ../index.html (the root convention), # whether this call rebuilds it or a later navigator() does; flat -o has no fleet page. nav = _build_nav(pages, page, source_name, back=(layout == "root")) rendered = template.render( report=resolved, nav=nav, page_title=page.title, page_type=page.type_label, page_landing=page.role == "landing", page_intro=(page.lead(ctx) if page.lead else ""), **common, ) # page has ✓/✗/⚠/– glyphs; Windows default is cp1252 (missiondir / page.filename).write_text(rendered, encoding="utf-8") # Save the config beside report.json so a re-run can consume it: # report(ds, outdir, config=<missiondir>/report-config.yaml). A file config is copied # verbatim (comments and the deferred display/mission/pages blocks survive); a dict config # is dumped (it has no comments to keep). None means the built-in defaults, so nothing to save. if cfg is not None: _save_config_copy(cfg, missiondir / "report-config.yaml") manifest = mission_manifest( ds, mission_id=mid, source_name=source_name, source_size_bytes=source_size, pages=[(p.filename, p.title) for p in pages], version=__version__, generated_at=generated_at, facts=ctx.facts, qc_thresholds=resolved_qc, qc_config=cfg.user_blocks() if cfg is not None else None, qc_config_source=cfg.source if cfg is not None else None, config_file=str(cfg.path) if cfg is not None and cfg.path is not None else None, region=cfg.bbox if cfg is not None else None, ) existing.write_text(json.dumps(manifest, indent=2), encoding="utf-8") if do_navigator: _build_navigator(root) # draws the tracks map under the same Agg backend finally: if switch: plt.switch_backend(orig_backend) # The landing page is the first entry in PAGES (always applicable); return its path even if the # filtered `pages` were somehow empty, so the contract never depends on an IndexError-free slice. return missiondir / PAGES[0].filename
def _build_navigator(root: Path, title: str | None = None) -> Path: """Render ``<root>/index.html`` from the manifests; caller owns the Matplotlib backend.""" from ._navigator import build_navigator return build_navigator(root, title)