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

1#!/usr/bin/env python3 

2"""Stage the wiki's source tree and build or preview it with Zensical. 

3 

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``):: 

12 

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/ 

16 

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. 

20 

21Usage:: 

22 

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 

27 

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

32 

33Requirements: the docs toolchain on the current interpreter — ``pip install -e 

34".[docs]"``. The script never installs anything itself. 

35""" 

36 

37from __future__ import annotations 

38 

39import argparse 

40import shutil 

41import subprocess 

42import sys 

43import time 

44from pathlib import Path 

45 

46REPO_ROOT = Path(__file__).resolve().parent.parent 

47 

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

63 

64DEFAULT_PORT = 8000 

65#: How often ``serve`` looks for source changes, in seconds. 

66POLL_SECONDS = 1.0 

67 

68 

69class ToolchainError(RuntimeError): 

70 """Zensical is not installed on the interpreter running this script.""" 

71 

72 

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 

86 

87 

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) 

93 

94 

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 

116 

117 

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 } 

124 

125 

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 

139 

140 

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) 

143 

144 

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 

153 

154 

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 

181 

182 

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 

209 

210 

211if __name__ == "__main__": 

212 raise SystemExit(main())