Coverage for scripts / build_wiki.py: 100.00%
111 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1#!/usr/bin/env python3
2"""Stage the wiki's source tree and build or preview it with Zensical.
4The orientation wiki is published from three places in the repository, none of
5which may be copied by hand into another: the pages under ``wiki/``, the tracked
6screenshots under ``images/`` and the generated API spec sheets (plus their
7interaction diagram) under ``diagrams/api_specs/``. MkDocs pulled the last two
8into the build through a hook and kept ``wiki/README.md`` out of it with
9``exclude_docs``; Zensical supports neither, so this script does the same three
10jobs explicitly, as a staging step Zensical then reads from (``docs_dir`` in
11``zensical.toml`` is ``build/wiki``)::
13 wiki/*.md -> build/wiki/<page>.md (README.md excluded)
14 images/* -> build/wiki/assets/images/ (README.md excluded)
15 diagrams/api_specs/*.md|svg -> build/wiki/api/
17Staging is a sync: files are copied when missing or changed, files that no
18longer have a source are removed, and the tree is never deleted wholesale, so
19the preview server keeps watching a directory that exists.
21Usage::
23 python scripts/build_wiki.py stage # assemble build/wiki only
24 python scripts/build_wiki.py build # stage, then `zensical build --clean --strict`
25 python scripts/build_wiki.py serve [--port N] # stage, strict build, then `zensical serve`
26 # re-staging whenever a source changes
28``build`` is the exact command the ``lint:zensical:strict`` PR job and the
29``pages.yml`` deploy run. ``serve`` runs it first, so anything that would fail
30CI fails before a page is opened; the server itself is not strict (a mid-edit
31broken link shows up in the terminal instead of killing the preview loop).
33Requirements: the docs toolchain on the current interpreter — ``pip install -e
34".[docs]"``. The script never installs anything itself.
35"""
37from __future__ import annotations
39import argparse
40import shutil
41import subprocess
42import sys
43import time
44from pathlib import Path
46REPO_ROOT = Path(__file__).resolve().parent.parent
48#: Where the staged tree lives, relative to the repository root. Matches
49#: ``docs_dir`` in zensical.toml; tests/test_wiki.py pins the two together.
50STAGE_DIR = Path("build") / "wiki"
51#: The three sources and where each lands inside the staged tree.
52WIKI_DIR = Path("wiki")
53IMAGES_DIR = Path("images")
54API_SPECS_DIR = Path("diagrams") / "api_specs"
55ASSETS_PREFIX = Path("assets") / "images"
56API_PREFIX = Path("api")
57#: A directory README documents the directory for contributors browsing
58#: GitHub; it is never a page (or an asset) of the site.
59README = "README.md"
60#: What of diagrams/api_specs/ is content: the sheets and the diagram they
61#: embed. The generator and its package marker stay out of the site.
62API_SUFFIXES = frozenset({".md", ".svg"})
64DEFAULT_PORT = 8000
65#: How often ``serve`` looks for source changes, in seconds.
66POLL_SECONDS = 1.0
69class ToolchainError(RuntimeError):
70 """Zensical is not installed on the interpreter running this script."""
73def staged_files(repo_root: Path) -> dict[Path, Path]:
74 """``{relative path inside the stage: source file}`` for everything the site includes."""
75 mapping: dict[Path, Path] = {}
76 for page in sorted((repo_root / WIKI_DIR).glob("*.md")):
77 if page.name != README:
78 mapping[Path(page.name)] = page
79 for asset in sorted((repo_root / IMAGES_DIR).iterdir()):
80 if asset.is_file() and asset.name != README:
81 mapping[ASSETS_PREFIX / asset.name] = asset
82 for sheet in sorted((repo_root / API_SPECS_DIR).iterdir()):
83 if sheet.is_file() and sheet.suffix in API_SUFFIXES:
84 mapping[API_PREFIX / sheet.name] = sheet
85 return mapping
88def _differs(source: Path, target: Path) -> bool:
89 if not target.is_file():
90 return True
91 src, dst = source.stat(), target.stat()
92 return (src.st_size, src.st_mtime_ns) != (dst.st_size, dst.st_mtime_ns)
95def stage(repo_root: Path) -> tuple[list[Path], list[Path]]:
96 """Sync the staged tree with its sources; return ``(copied, removed)`` relative paths."""
97 stage_root = repo_root / STAGE_DIR
98 stage_root.mkdir(parents=True, exist_ok=True)
99 mapping = staged_files(repo_root)
100 copied: list[Path] = []
101 for relative, source in mapping.items():
102 target = stage_root / relative
103 if _differs(source, target):
104 target.parent.mkdir(parents=True, exist_ok=True)
105 shutil.copy2(source, target) # copy2 keeps mtime, which _differs compares
106 copied.append(relative)
107 removed: list[Path] = []
108 for path in sorted(stage_root.rglob("*")):
109 if path.is_file() and path.relative_to(stage_root) not in mapping:
110 path.unlink()
111 removed.append(path.relative_to(stage_root))
112 for directory in sorted((p for p in stage_root.rglob("*") if p.is_dir()), reverse=True):
113 if not any(directory.iterdir()):
114 directory.rmdir()
115 return copied, removed
118def snapshot(repo_root: Path) -> dict[Path, tuple[int, int]]:
119 """Sizes and mtimes of every source file, so ``serve`` can tell when to re-stage."""
120 return {
121 relative: (source.stat().st_size, source.stat().st_mtime_ns)
122 for relative, source in staged_files(repo_root).items()
123 }
126def zensical_executable() -> str:
127 """The ``zensical`` console script of the running interpreter, or the one on PATH."""
128 sibling = Path(sys.executable).with_name("zensical")
129 if sibling.is_file():
130 return str(sibling)
131 on_path = shutil.which("zensical")
132 if on_path is None:
133 raise ToolchainError(
134 "zensical is not installed for this interpreter.\n"
135 'Install the docs toolchain into your active environment first: pip install -e ".[docs]"\n'
136 'See CONTRIBUTING.md — "Developing the wiki".'
137 )
138 return on_path
141def _report(action: str, copied: list[Path], removed: list[Path]) -> None:
142 print(f"==> {action}: {len(copied)} copied, {len(removed)} removed -> {STAGE_DIR}/", flush=True)
145def build(repo_root: Path) -> int:
146 """Stage, then run the strict Zensical build CI runs. Returns the exit status."""
147 zensical = zensical_executable()
148 _report("Staging the wiki sources", *stage(repo_root))
149 print("==> Strict build (the exact check CI runs)", flush=True)
150 return subprocess.run(
151 [zensical, "build", "--clean", "--strict"], cwd=repo_root, check=False
152 ).returncode
155def serve(repo_root: Path, port: int) -> int:
156 """Stage, build strictly, then serve with live reload, re-staging sources as they change."""
157 status = build(repo_root)
158 if status != 0:
159 return status
160 zensical = zensical_executable()
161 print(
162 f"==> Serving with live reload at http://127.0.0.1:{port}/ (Ctrl-C to stop)",
163 flush=True,
164 )
165 process = subprocess.Popen(
166 [zensical, "serve", "--dev-addr", f"127.0.0.1:{port}"], cwd=repo_root
167 )
168 seen = snapshot(repo_root)
169 try:
170 while process.poll() is None:
171 time.sleep(POLL_SECONDS)
172 current = snapshot(repo_root)
173 if current != seen:
174 seen = current
175 _report("Sources changed; re-staging", *stage(repo_root))
176 except KeyboardInterrupt:
177 process.terminate()
178 process.wait()
179 return 0
180 return process.returncode
183def main(argv: list[str] | None = None) -> int:
184 parser = argparse.ArgumentParser(
185 description="Stage the wiki's sources into build/wiki and build or preview them with Zensical."
186 )
187 commands = parser.add_subparsers(dest="command", required=True)
188 commands.add_parser(
189 "stage", help="assemble build/wiki from wiki/, images/ and diagrams/api_specs/"
190 )
191 commands.add_parser("build", help="stage, then `zensical build --clean --strict` into site/")
192 serve_parser = commands.add_parser(
193 "serve", help="stage, build strictly, then `zensical serve` with live reload"
194 )
195 serve_parser.add_argument(
196 "--port", type=int, default=DEFAULT_PORT, help=f"port to serve on (default {DEFAULT_PORT})"
197 )
198 args = parser.parse_args(argv)
199 try:
200 if args.command == "stage":
201 _report("Staging the wiki sources", *stage(REPO_ROOT))
202 return 0
203 if args.command == "build":
204 return build(REPO_ROOT)
205 return serve(REPO_ROOT, args.port)
206 except ToolchainError as exc:
207 print(f"error: {exc}", file=sys.stderr)
208 return 1
211if __name__ == "__main__":
212 raise SystemExit(main())