|
| 1 | +"""Fail the docs build when API rendering depends on page processing order. |
| 2 | +
|
| 3 | +Zensical discovers pages with an unsorted directory walk and renders them |
| 4 | +all through one shared mkdocstrings handler, so the griffe collection that |
| 5 | +resolves `::: module` blocks accumulates in filesystem-dependent order, and |
| 6 | +nothing in the toolchain checks that the order is safe: a cross-package |
| 7 | +re-export that only resolved when its target package had been collected |
| 8 | +earlier built fine on one machine and died with `AliasResolutionError` on |
| 9 | +another (a GitHub runner-image update reshuffled readdir order and broke |
| 10 | +every CI docs build this way). `load_external_modules: true` in `mkdocs.yml` |
| 11 | +makes resolution order-independent; this check enforces that property, |
| 12 | +because a regular build only ever exercises one arbitrary order. |
| 13 | +
|
| 14 | +mkdocstrings applies module loading and per-page options only on the first |
| 15 | +collect of a package (later pages find the package already collected), so |
| 16 | +each package under `docs/api/` gets the two hostile sides of that |
| 17 | +asymmetry, each from a fresh handler with an empty collection: |
| 18 | +
|
| 19 | +- its subpages first and its package index last, so pages rendering |
| 20 | + cross-package re-exports (the index above all) come after a plain |
| 21 | + subpage has already collected the package and nothing they declare |
| 22 | + themselves can still affect collection; |
| 23 | +- its package index alone, so the page with the most re-exports is itself |
| 24 | + the first collect over an empty collection. |
| 25 | +
|
| 26 | +Only the package's own pages are rendered: resolving `mcp` re-exports |
| 27 | +without ever rendering an `mcp_types` page is exactly the property under |
| 28 | +test. |
| 29 | +
|
| 30 | +Usage: |
| 31 | + python scripts/docs/check_render_order.py [--config mkdocs.gen.yml] |
| 32 | +
|
| 33 | +Run after `build_config.py` has produced the config and the `docs/api/` |
| 34 | +tree. |
| 35 | +""" |
| 36 | + |
| 37 | +from __future__ import annotations |
| 38 | + |
| 39 | +import argparse |
| 40 | +import traceback |
| 41 | +from pathlib import Path |
| 42 | + |
| 43 | +# Sibling modules, same direct-invocation pattern as build_config.py: |
| 44 | +# gen_ref_pages owns the docs/api layout, llms_txt owns the page-URL mapping. |
| 45 | +import gen_ref_pages |
| 46 | +from llms_txt import page_url |
| 47 | +from zensical.compat import mkdocstrings as zensical_mkdocstrings |
| 48 | +from zensical.config import parse_config |
| 49 | +from zensical.markdown.render import render |
| 50 | + |
| 51 | +API_DIR = gen_ref_pages.API_DIR |
| 52 | +DOCS_DIR = API_DIR.parent |
| 53 | + |
| 54 | + |
| 55 | +def _passes(package: str, pages: list[Path]) -> list[tuple[str, list[Path]]]: |
| 56 | + """The labeled render orders exercising both sides of the first-collect asymmetry.""" |
| 57 | + index = API_DIR / package / "index.md" |
| 58 | + if index not in pages: |
| 59 | + return [(f"'{package}'", pages)] |
| 60 | + subpages = [page for page in pages if page != index] |
| 61 | + if not subpages: |
| 62 | + return [(f"'{package}' index-alone", [index])] |
| 63 | + return [(f"'{package}' index-last", [*subpages, index]), (f"'{package}' index-alone", [index])] |
| 64 | + |
| 65 | + |
| 66 | +def _render(page: Path) -> None: |
| 67 | + """Render one page the way Zensical's Rust core drives the Python side.""" |
| 68 | + rel = page.relative_to(DOCS_DIR).as_posix() |
| 69 | + render(page.read_text(encoding="utf-8"), rel, page_url(rel)) |
| 70 | + |
| 71 | + |
| 72 | +def main() -> None: |
| 73 | + parser = argparse.ArgumentParser(description=__doc__) |
| 74 | + parser.add_argument( |
| 75 | + "--config", default=str(gen_ref_pages.ROOT / "mkdocs.gen.yml"), help="Built config to render with" |
| 76 | + ) |
| 77 | + args = parser.parse_args() |
| 78 | + parse_config(args.config) |
| 79 | + |
| 80 | + packages: dict[str, list[Path]] = {} |
| 81 | + for page in sorted(API_DIR.rglob("*.md")): |
| 82 | + packages.setdefault(page.relative_to(API_DIR).parts[0], []).append(page) |
| 83 | + if not packages: |
| 84 | + raise SystemExit(f"check_render_order: no pages under {API_DIR} (run build_config.py first)") |
| 85 | + |
| 86 | + for package in sorted(packages): |
| 87 | + for label, order in _passes(package, packages[package]): |
| 88 | + # Fresh Handlers -> empty griffe collection. Autorefs anchors |
| 89 | + # accumulate across passes, but they play no part in collection |
| 90 | + # or alias resolution (check_crossrefs owns link health). |
| 91 | + zensical_mkdocstrings.reset() |
| 92 | + for position, page in enumerate(order): |
| 93 | + try: |
| 94 | + _render(page) |
| 95 | + # Top-level handler: any exception from any page fails the |
| 96 | + # check; the traceback identifies whether the order was at |
| 97 | + # fault or something else broke (network, missing file). |
| 98 | + except Exception: |
| 99 | + traceback.print_exc() |
| 100 | + rel = page.relative_to(DOCS_DIR).as_posix() |
| 101 | + raise SystemExit( |
| 102 | + f"check_render_order: {rel} failed at position {position + 1}/{len(order)} of the" |
| 103 | + f" {label} order (traceback above; an AliasResolutionError means API rendering" |
| 104 | + " depends on page order — see `load_external_modules` in mkdocs.yml)" |
| 105 | + ) from None |
| 106 | + print(f"check_render_order: {label} order OK ({len(order)} pages)") |
| 107 | + |
| 108 | + |
| 109 | +if __name__ == "__main__": |
| 110 | + main() |
0 commit comments