Coverage for src/rhiza_task/tasks/book.py: 100%
139 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:13 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:13 +0000
1"""The book and notebook tasks: book.mk and marimo.mk, as tasks.
3``book`` is the third recipe that resists the declarative form: it aggregates the report-
4producing gates, copies their output into the docs tree, exports every notebook, builds
5the site, and generates a coverage badge.
7The one artefact it does *not* copy is the paper's PDF. tectonic writes it beside its
8source, and ``paper_folder`` is already inside ``docs_dir``, so the site build finds it
9where it lies -- a prerequisite plus a ``nav`` entry, and no plumbing.
11Its prerequisite list is also where make's no-op stubs came from. book.mk has to declare
12``test:: ; @:``, ``benchmark:: ; @:``, ``stress:: ; @:`` and ``hypothesis-test:: ; @:``
13so that ``book`` can depend on gates the ``tests`` bundle may not have contributed. The
14runner skips unregistered prerequisites, so all four stubs are gone.
15"""
17from __future__ import annotations
19import re
20import shutil
21from pathlib import Path
23from ..config import Config
24from ..spec import Failed, Guard, Skip, task
25from ..uv import uv_run, uvx
26from .paper import AUX_SUFFIXES
29# `paper` is a prerequisite for the same reason the other four are: it produces something
30# the book publishes, and the book should be one command. It needs no copy step, unlike the
31# `_tests/` tree -- tectonic writes the PDF beside its source, and `paper_folder` defaults to
32# `docs/paper`, which is already inside `docs_dir`. So the build picks it up as an asset and
33# mkdocs.yml only has to name it in `nav`.
34#
35# Safe to add because a *skipped* prerequisite does not block a dependent -- only FAILED and
36# BLOCKED do (see `_run_one`) -- so a repository with no paper, or no tectonic, still builds
37# its book. Under `--strict` a skip becomes a failure and would block `book`, which is worth
38# knowing but is not new: `benchmark` and `stress` guard on folders most repositories do not
39# have, so `--strict book` already required a repo that has all of them.
40@task(
41 "book",
42 "build the companion book",
43 section="Book",
44 needs=("test", "benchmark", "stress", "hypothesis-test", "paper"),
45)
46def book(cfg: Config) -> None:
47 """Build the MkDocs/Zensical site, with test reports and notebooks folded in.
49 Args:
50 cfg: The resolved config.
52 Raises:
53 Skip: When there is no ``mkdocs.yml`` to build.
54 """
55 if not (cfg.root / "mkdocs.yml").is_file():
56 raise Skip("no mkdocs.yml")
58 _copy_reports(cfg)
59 _export_notebooks(cfg)
61 output = cfg.root / cfg.book_output
62 shutil.rmtree(output, ignore_errors=True)
63 uvx(
64 f"zensical{cfg.zensical_version}",
65 "build",
66 "-f",
67 str(cfg.root / "mkdocs.yml"),
68 cwd=cfg.root,
69 withs=cfg.mkdocs_extra_packages,
70 )
71 output.mkdir(parents=True, exist_ok=True)
72 (output / ".nojekyll").touch()
73 _prune_latex_artifacts(cfg, output)
75 coverage = cfg.root / "_tests" / "coverage.xml"
76 if coverage.is_file():
77 uvx(
78 "genbadge[coverage]",
79 "coverage",
80 "-i",
81 str(coverage),
82 "-o",
83 str(output / "coverage-badge.svg"),
84 cwd=cfg.root,
85 check=False,
86 )
87 print(f"[SUCCESS] book built at {cfg.book_output}/")
90@task("serve", "build the book and serve it on port 8000", section="Book", needs=("book",))
91def serve(cfg: Config) -> None:
92 """Serve the built book over HTTP.
94 Python's own server rather than an editor's built-in one, because the JetBrains server
95 refuses to serve gitignored directories and ``_book`` is one.
97 Args:
98 cfg: The resolved config.
99 """
100 print("[INFO] serving at http://localhost:8000 (Ctrl-C to stop)")
101 uv_run("python", "-m", "http.server", "8000", cwd=cfg.path("book_output"))
104@task(
105 "marimo",
106 "start the Marimo editor",
107 section="Book",
108 needs=("install",),
109 guards=(Guard("marimo_folder"),),
110)
111def marimo(cfg: Config) -> None:
112 """Start a headless Marimo server on the notebook folder.
114 ``--no-project`` is marimo.mk's: the editor runs against its own provisioned marimo
115 rather than the project environment.
117 Args:
118 cfg: The resolved config.
119 """
120 uv_run(
121 "marimo",
122 "edit",
123 "--no-token",
124 "--headless",
125 cwd=cfg.path("marimo_folder"),
126 withs=("marimo",),
127 no_project=True,
128 )
131@task(
132 "marimo-validate",
133 "check that every Marimo notebook runs",
134 section="Book",
135 needs=("install",),
136 guards=(Guard("marimo_folder"),),
137)
138def marimo_validate(cfg: Config) -> None:
139 """Run each notebook as a script, reporting per-notebook pass or fail.
141 Args:
142 cfg: The resolved config.
144 Raises:
145 Skip: When the folder holds no notebooks.
146 Failed: When any notebook fails to run.
147 """
148 notebooks = sorted(cfg.path("marimo_folder").glob("*.py"))
149 if not notebooks:
150 raise Skip(f"no notebooks in '{cfg.marimo_folder}'")
152 failures: list[str] = []
153 for notebook in notebooks:
154 artefacts = cfg.root / "results" / notebook.stem
155 artefacts.mkdir(parents=True, exist_ok=True)
156 print(f"[INFO] validating {notebook.name} (artefacts -> {artefacts})")
157 code = uv_run(
158 "python",
159 str(notebook),
160 cwd=cfg.root,
161 check=False,
162 env={"NOTEBOOK_OUTPUT_FOLDER": str(artefacts)},
163 )
164 if code:
165 failures.append(notebook.name)
167 if failures:
168 raise Failed(1, f"{len(failures)} notebook(s) failed: {', '.join(failures)}")
169 print(f"[SUCCESS] all {len(notebooks)} notebook(s) valid")
172def _prune_latex_artifacts(cfg: Config, output: Path) -> None:
173 """Remove the paper's auxiliary files from the built site, keeping the PDF and the source.
175 The paper's source sits inside ``docs_dir`` so that its PDF needs no copy step, and the
176 price is that everything *else* the build leaves beside it is copied into the site too.
177 ``paper.log`` is the one that matters, and the one ``rhiza-task paper`` deliberately asks
178 tectonic to keep: some 20 KB of build trace which records absolute paths from whichever
179 machine ran the build.
181 mkdocs would answer this with ``exclude_docs``. zensical does not implement it -- the
182 note in ``docs/mkdocs-base.yml`` records that an excluded page is still written -- and
183 deleting them at the source would throw away the log the run was asked to keep. So they
184 are pruned here, from the output, where nothing reads them again.
186 The suffix list is :data:`~rhiza_task.tasks.paper.AUX_SUFFIXES`, shared with
187 ``paper-clean`` rather than restated: one list, two callers, and the difference between
188 them is only whether the PDF goes with it -- which is the difference between publishing a
189 build and discarding one.
191 Scoped to the paper folder rather than swept over the whole site: ``.log`` and ``.out``
192 are not LaTeX-specific names, and a consumer with a genuine ``debug.log`` under ``docs/``
193 should keep it. ``docs`` is spelled out for the reason :func:`_copy_reports` spells it
194 out -- ``docs_dir`` is mkdocs's setting, not one this package resolves.
196 Args:
197 cfg: The resolved config.
198 output: The built site directory.
199 """
200 paper = cfg.path("paper_folder")
201 docs = cfg.root / "docs"
202 if not paper.is_relative_to(docs):
203 # The paper lives outside docs_dir, so the build never copied it and there is
204 # nothing in the site to prune.
205 return
207 published = output / paper.relative_to(docs)
208 if not published.is_dir():
209 return
210 for path in sorted(published.iterdir()):
211 if path.is_file() and path.name.endswith(AUX_SUFFIXES):
212 path.unlink(missing_ok=True)
215def _copy_reports(cfg: Config) -> None:
216 """Copy the test-report tree into the docs folder, if the gates produced one.
218 Args:
219 cfg: The resolved config.
220 """
221 reports = cfg.root / "_tests"
222 if not reports.is_dir() or not any(reports.iterdir()):
223 print("[WARN] no _tests output to fold into the book")
224 return
225 destination = cfg.root / "docs" / "reports"
226 destination.mkdir(parents=True, exist_ok=True)
227 shutil.copytree(reports, destination, dirs_exist_ok=True)
228 _scrub_local_paths(cfg.root, destination)
231SCRUBBED_SUFFIXES = (".html", ".htm", ".xml", ".json", ".js", ".css", ".txt", ".svg")
232"""Which report files are rewritten. Text formats only, so no binary is touched."""
235def _scrub_local_paths(root: Path, destination: Path) -> None:
236 """Mask absolute build paths in the *published* copy of the reports.
238 A report is written for the machine that produced it and then published to the web,
239 which is a change of audience nothing in the toolchain notices. Two paths leak:
241 * the repository root, which pytest records as its ``rootdir``;
242 * the home directory, because pytest-xdist stamps every test with the worker banner
243 ``[gw0] darwin -- Python 3.11.15 <interpreter>``, and under ``uv run --with`` that
244 interpreter lives in the user's uv cache. In this repository that was 300-odd
245 occurrences in one ``report.html``.
247 Neither is fixable upstream from here. coverage's own ``relative_files`` handles the
248 coverage artefacts and is set in ``pyproject.toml``; the xdist banner has no setting,
249 and dropping ``-n auto`` to avoid it would slow every consumer's suite to protect a
250 report. So the copy is rewritten and ``_tests/`` is left exactly as produced, which is
251 what a developer reads locally and where absolute paths are the useful form.
253 Ordering matters: the root is replaced before the home directory, because on CI the
254 root lives *inside* it and masking the shorter prefix first would leave a half-path.
256 Known limit: matching is textual, so a Windows path embedded in JSON arrives
257 backslash-escaped and is not recognised. The gate that publishes a book runs on Linux,
258 so this is a real gap rather than a closed one.
260 Args:
261 root: The repository root, as the reports spell it.
262 destination: The published copy, under ``docs/``.
263 """
264 # `Path.home()` rather than $HOME: on Windows the variable is often unset, and the
265 # masking has to be harmless there rather than crash.
266 masks = ((str(root), "."), (str(Path.home()), "~"))
267 for path in sorted(destination.rglob("*")):
268 if not path.is_file() or not path.name.endswith(SCRUBBED_SUFFIXES):
269 continue
270 try:
271 text = path.read_text(encoding="utf-8")
272 except (OSError, UnicodeDecodeError):
273 # A report file that cannot be read as text is one this function has no opinion
274 # about; skipping it must not cost the book its build.
275 continue
276 scrubbed = text
277 for absolute, mask in masks:
278 scrubbed = scrubbed.replace(absolute, mask)
279 if scrubbed != text:
280 path.write_text(scrubbed, encoding="utf-8")
283def _export_notebooks(cfg: Config) -> None:
284 """Export each notebook to a self-contained HTML file under ``docs/notebooks``.
286 Args:
287 cfg: The resolved config.
288 """
289 folder = cfg.path("marimo_folder")
290 if not folder.is_dir():
291 print("[WARN] no marimo folder; skipping notebook export")
292 return
293 destination = cfg.root / "docs" / "notebooks"
294 destination.mkdir(parents=True, exist_ok=True)
295 for notebook in sorted(folder.glob("*.py")):
296 target = destination / f"{notebook.stem}.html"
297 print(f"[INFO] exporting {notebook.name} -> {target}")
298 uv_run(
299 "marimo",
300 "export",
301 "html",
302 "--sandbox",
303 notebook.name,
304 "-o",
305 str(target),
306 cwd=folder,
307 withs=("marimo",),
308 )
311_NAV_KEY = re.compile(r"^nav:\s*(?:#.*)?$")
312"""The ``nav:`` mapping's own line: top-level, so no leading whitespace."""
315def _nav_targets(text: str) -> list[str]:
316 """Extract the file targets from a mkdocs ``nav:`` block.
318 Hand-parsed rather than loaded with a YAML library, for the reason
319 :func:`~rhiza_task.tasks.quality.docs_examples` hand-parses fences instead of pulling a
320 markdown parser: this package declares three runtime dependencies and adding a fourth to
321 read eleven lines of one file is the wrong trade. The subset relied on is the one mkdocs
322 documents -- ``- Title: path`` and ``- path``, nested under section keys -- and the
323 parser is deliberately shallow: it collects targets and does not reconstruct the tree,
324 because the tree is not what a missing file is about.
326 Two things are skipped rather than reported. A section header (``- Guides:``, no value)
327 names no file. An external target (anything carrying ``://``) is not this gate's to
328 check -- reaching the network would make a docs build fail on someone else's outage,
329 which is what ``weekly.yml``'s link checker is for.
331 Args:
332 text: The contents of ``mkdocs.yml``.
334 Returns:
335 Every in-repository nav target, in document order, with duplicates kept.
336 """
337 lines = text.splitlines()
338 start = next((i for i, line in enumerate(lines) if _NAV_KEY.match(line)), None)
339 if start is None:
340 return []
342 targets: list[str] = []
343 for line in lines[start + 1 :]:
344 stripped = line.strip()
345 if not stripped or stripped.startswith("#"):
346 continue
347 # A non-indented line is the next top-level key, which ends the nav block.
348 if not line[:1].isspace():
349 break
350 target = _nav_target(stripped)
351 if target:
352 targets.append(target)
353 return targets
356# Split out from the loop above rather than inlined, which is the opposite of the choice the
357# four C-ranked blocks elsewhere in this package defend -- and for the opposite reason. Those
358# keep a flat shape because decomposing them would cost the reader something real: the order
359# the guards fire in, or the one-branch-per-setting correspondence. Here there is no ordering
360# to protect. One line's grammar and the block's extent are genuinely separate questions, and
361# inlining both put the function at C (12) for no gain.
362def _nav_target(item: str) -> str | None:
363 """Return the file target a single nav list item names, if it names one.
365 Args:
366 item: One stripped line from inside the ``nav:`` block.
368 Returns:
369 The target, or None for a line that names no file -- a nested key with no ``- ``, a
370 section header carrying no value, or an external URL.
371 """
372 if not item.startswith("- "):
373 return None
374 value = item[2:].strip()
375 if "://" in value:
376 return None
377 target = value.rsplit(":", 1)[-1].strip() if ":" in value else value
378 return target or None
381def _built_candidates(target: str) -> tuple[str, ...]:
382 """Return the paths a nav target may legitimately have become in the built site.
384 A markdown page is not published under its own name: with mkdocs's default
385 ``use_directory_urls``, ``faq.md`` is written as ``faq/index.html``, and with it off as
386 ``faq.html``. Both are correct, and which one applies is a theme-and-config question this
387 function deliberately does not try to resolve -- accepting either is enough to answer
388 "was this page built at all?", which is the question. Anything that is not markdown is an
389 asset and is copied verbatim, so it has exactly one candidate.
391 Args:
392 target: A nav target as spelled in ``mkdocs.yml``.
394 Returns:
395 The candidate paths, relative to the built site, any one of which satisfies the nav
396 entry.
397 """
398 if not target.endswith(".md"):
399 return (target,)
400 stem = target[: -len(".md")]
401 return (f"{stem}.html", f"{stem}/index.html")
404@task(
405 "book-nav",
406 "check that every mkdocs nav entry resolves in the built book",
407 section="Book",
408 guards=(Guard(file="mkdocs.yml"),),
409)
410def book_nav(cfg: Config) -> None:
411 """Fail when ``mkdocs.yml`` names a nav target the built site does not contain.
413 The gap this closes, and it is a published one rather than a hypothetical: zensical
414 reports ``No issues found`` for a nav entry whose page does not exist *and* for one whose
415 asset does not exist. So `- Paper: paper/paper.pdf` survived a build in which
416 ``rhiza-task paper`` had skipped for want of an engine, and the site deployed with a 404 in
417 its own navigation, green the whole way. Every other gate here asks about the source; this
418 is the only one that asks whether what was *published* holds together.
420 **Not a prerequisite of** :func:`book`, deliberately. Half the nav entries in a repository
421 like this one resolve only after the gates that produce them have run -- the two
422 ``reports/`` pages need a ``_tests/`` tree, the paper needs tectonic -- and a
423 repository without it must keep building its book, which is exactly what a *skipped*
424 prerequisite buys. Making that a failure would break every consumer that documents a
425 paper it cannot compile locally. So this is a separate gate, named by ``rhiza_book.yml``
426 on the ref it deploys, where the entries are supposed to be complete and a dangling one is
427 a defect rather than a machine's shape.
429 Markdown targets are resolved through :func:`_built_candidates`; assets are matched
430 verbatim.
432 Args:
433 cfg: The resolved config.
435 Raises:
436 Skip: When the book has not been built, or ``mkdocs.yml`` declares no nav targets.
437 Both are "the question is not askable", not "the answer is wrong".
438 Failed: When at least one nav target is missing from the built site.
439 """
440 output = cfg.path("book_output")
441 if not output.is_dir():
442 raise Skip(f"no built book at '{cfg.book_output}'; run `rhiza-task book` first")
444 targets = _nav_targets((cfg.root / "mkdocs.yml").read_text(errors="replace"))
445 if not targets:
446 raise Skip("mkdocs.yml declares no nav targets")
448 missing = [
449 target
450 for target in targets
451 if not any((output / candidate).exists() for candidate in _built_candidates(target))
452 ]
453 for target in missing:
454 print(f"[ERROR] nav target not in the built book: {target}")
456 if missing:
457 raise Failed(
458 1,
459 f"{len(missing)} of {len(targets)} nav target(s) missing from '{cfg.book_output}' -- "
460 f"the site would publish a 404 in its own navigation",
461 )
462 print(f"[SUCCESS] all {len(targets)} nav target(s) resolve in {cfg.book_output}/")