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

1"""The book and notebook tasks: book.mk and marimo.mk, as tasks. 

2 

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. 

6 

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. 

10 

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""" 

16 

17from __future__ import annotations 

18 

19import re 

20import shutil 

21from pathlib import Path 

22 

23from ..config import Config 

24from ..spec import Failed, Guard, Skip, task 

25from ..uv import uv_run, uvx 

26from .paper import AUX_SUFFIXES 

27 

28 

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. 

48 

49 Args: 

50 cfg: The resolved config. 

51 

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") 

57 

58 _copy_reports(cfg) 

59 _export_notebooks(cfg) 

60 

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) 

74 

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}/") 

88 

89 

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. 

93 

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. 

96 

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")) 

102 

103 

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. 

113 

114 ``--no-project`` is marimo.mk's: the editor runs against its own provisioned marimo 

115 rather than the project environment. 

116 

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 ) 

129 

130 

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. 

140 

141 Args: 

142 cfg: The resolved config. 

143 

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}'") 

151 

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) 

166 

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") 

170 

171 

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. 

174 

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. 

180 

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. 

185 

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. 

190 

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. 

195 

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 

206 

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) 

213 

214 

215def _copy_reports(cfg: Config) -> None: 

216 """Copy the test-report tree into the docs folder, if the gates produced one. 

217 

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) 

229 

230 

231SCRUBBED_SUFFIXES = (".html", ".htm", ".xml", ".json", ".js", ".css", ".txt", ".svg") 

232"""Which report files are rewritten. Text formats only, so no binary is touched.""" 

233 

234 

235def _scrub_local_paths(root: Path, destination: Path) -> None: 

236 """Mask absolute build paths in the *published* copy of the reports. 

237 

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: 

240 

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``. 

246 

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. 

252 

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. 

255 

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. 

259 

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") 

281 

282 

283def _export_notebooks(cfg: Config) -> None: 

284 """Export each notebook to a self-contained HTML file under ``docs/notebooks``. 

285 

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 ) 

309 

310 

311_NAV_KEY = re.compile(r"^nav:\s*(?:#.*)?$") 

312"""The ``nav:`` mapping's own line: top-level, so no leading whitespace.""" 

313 

314 

315def _nav_targets(text: str) -> list[str]: 

316 """Extract the file targets from a mkdocs ``nav:`` block. 

317 

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. 

325 

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. 

330 

331 Args: 

332 text: The contents of ``mkdocs.yml``. 

333 

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 [] 

341 

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 

354 

355 

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. 

364 

365 Args: 

366 item: One stripped line from inside the ``nav:`` block. 

367 

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 

379 

380 

381def _built_candidates(target: str) -> tuple[str, ...]: 

382 """Return the paths a nav target may legitimately have become in the built site. 

383 

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. 

390 

391 Args: 

392 target: A nav target as spelled in ``mkdocs.yml``. 

393 

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") 

402 

403 

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. 

412 

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. 

419 

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. 

428 

429 Markdown targets are resolved through :func:`_built_candidates`; assets are matched 

430 verbatim. 

431 

432 Args: 

433 cfg: The resolved config. 

434 

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") 

443 

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") 

447 

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}") 

455 

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}/")