Coverage for plugin/scripts/_doc_examples_readme.py: 100%

170 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-26 04:21 +0000

1#!/usr/bin/env python3 

2"""The README half of `check_doc_examples.py`: fenced blocks, checked by language. 

3 

4`markdownlint` checks that a fence is well-formed *markdown* and says nothing about 

5whether the shell inside it parses or the Python inside it runs. A README's examples are 

6the first thing a newcomer executes and the last thing anyone re-reads, so they rot 

7silently — which is why the rhiza template ships `test_readme.py` and 

8`test_readme_validation.py` into every managed repo. This module is that pair, minus the 

9`.rhiza/` directory, so an unmanaged repo gets the same check. 

10 

11The conventions are the template's, deliberately: the `+RHIZA_SKIP` fence flag is spelled 

12the same, directory trees and comment-only blocks are skipped the same way, and `pycon` 

13fences are doctest transcripts — `>>>`/`...` prompts with the expected output inline, 

14every unskipped one in the file joined in document order and run as one doctest under 

15`ELLIPSIS`. A repo that adopts rhiza later keeps whatever verdict it had here. 

16 

17The older convention — a `python` fence diffed against the ```result``` block after it — 

18is still understood, so a README that hasn't migrated keeps its verdict. New examples 

19should be `pycon`: the output sits next to the line that produced it, and a mismatch 

20names the example rather than the whole program. 

21 

22**Shell fences are parsed, never executed** — under any flag. A README's shell is 

23routinely destructive-adjacent (`make clean`, `git push`, `rm -rf`), and a fence that 

24cannot parse is a documentation bug whether or not anyone runs it. 

25""" 

26 

27from __future__ import annotations 

28 

29import doctest 

30import json 

31import re 

32import shutil 

33import subprocess # nosec B404 

34import sys 

35from pathlib import Path 

36from typing import Any, NamedTuple 

37 

38# A fenced block: the info string (language plus any flags) and the body. The closing 

39# fence has to start a line, so an indented fence inside a block doesn't end it early. 

40_FENCE = re.compile(r"^```([^\n`]*)\n(.*?)^```", re.S | re.M) 

41 

42# The template's own marker for a fence that is illustrative rather than runnable, spelled 

43# exactly as `.rhiza/tests/test_readme.py` spells it. Sharing the spelling is the point: a 

44# fence a repo opted out of upstream is opted out here too. 

45SKIP_FLAG = "+RHIZA_SKIP" 

46 

47# Box-drawing characters mean the fence is a directory tree wearing a `bash` label. 

48_TREE_MARKERS = ("├──", "└──", "│") 

49 

50_SHELL_LANGS = frozenset({"bash", "sh", "shell", "zsh"}) 

51_PYTHON_LANGS = frozenset({"python", "py"}) 

52_PYCON_LANG = "pycon" 

53_RESULT_LANG = "result" 

54 

55# What the child process runs for the `pycon` fences: the joined transcript arrives on 

56# stdin, one doctest runs over it, and a JSON verdict goes back on stdout. A subprocess 

57# rather than an in-process run for the same reason the `python` fences get one — a 

58# README's examples import the repo's code and may mutate `sys.path`, and neither should 

59# leak into the checker. 

60_DOCTEST_CHILD = """ 

61import doctest, io, json, sys 

62text = sys.stdin.read() 

63test = doctest.DocTestParser().get_doctest( 

64 text, {"__name__": "__main__"}, sys.argv[1], sys.argv[1], 0 

65) 

66buf = io.StringIO() 

67runner = doctest.DocTestRunner(optionflags=doctest.ELLIPSIS) 

68result = runner.run(test, out=buf.write, clear_globs=True) 

69print() 

70print(json.dumps({"failed": result.failed, "attempted": result.attempted, 

71 "report": buf.getvalue()})) 

72""" 

73 

74 

75class Fence(NamedTuple): 

76 """One fenced code block: its language, its flags, its body and where it starts.""" 

77 

78 language: str 

79 flags: str 

80 body: str 

81 line: int 

82 

83 

84def fences(text: str) -> list[Fence]: 

85 """Return every fenced block in *text*, in document order.""" 

86 found: list[Fence] = [] 

87 for match in _FENCE.finditer(text): 

88 language, _, flags = match.group(1).strip().partition(" ") 

89 line = text.count("\n", 0, match.start()) + 1 

90 found.append(Fence(language.lower(), flags.strip(), match.group(2), line)) 

91 return found 

92 

93 

94def should_skip(flags: str) -> bool: 

95 """Is this fence marked `+RHIZA_SKIP`?""" 

96 return SKIP_FLAG in flags 

97 

98 

99def shell_skip_reason(body: str) -> str | None: 

100 """Why *body* isn't shell worth parsing, or None when it is. 

101 

102 Two shapes wear a ``bash`` label without being runnable shell: a directory tree drawn 

103 with box characters, and a block of nothing but comments. Either would pass or fail 

104 ``bash -n`` for reasons that say nothing about the documentation. 

105 """ 

106 if any(marker in body for marker in _TREE_MARKERS): 

107 return "directory tree, not shell" 

108 lines = [line.strip() for line in body.splitlines() if line.strip()] 

109 if not [line for line in lines if not line.startswith("#")]: 

110 return "comments only" 

111 return None 

112 

113 

114def last_line(text: str, fallback: str) -> str: 

115 """Return the last non-empty line of *text*, or *fallback* when there is none.""" 

116 stripped = text.strip() 

117 return stripped.splitlines()[-1] if stripped else fallback 

118 

119 

120def check_shell(fence: Fence) -> tuple[str, str]: 

121 """Parse a shell fence with ``bash -n``; return (status, detail). 

122 

123 Parsing only — never execution. See this module's docstring for why. 

124 """ 

125 bash = shutil.which("bash") 

126 if bash is None: # pragma: no cover - bash is present everywhere this runs 

127 return "skipped", "bash not available" 

128 result = subprocess.run( # nosec B603 

129 [bash, "-n"], input=fence.body, capture_output=True, text=True, check=False 

130 ) 

131 if result.returncode == 0: 

132 return "ok", "" 

133 return "failed", last_line(result.stderr, "bash -n reported a syntax error") 

134 

135 

136def check_python(fence: Fence) -> tuple[str, str]: 

137 """Compile a Python fence without running it; return (status, detail).""" 

138 try: 

139 compile(fence.body, f"<readme:{fence.line}>", "exec") 

140 except SyntaxError as exc: 

141 return "failed", f"{exc.msg} (line {exc.lineno})" 

142 return "ok", "" 

143 

144 

145def check_pycon(fence: Fence) -> tuple[str, str]: 

146 """Parse a ``pycon`` transcript and compile each example without running it. 

147 

148 Syntax only, like `check_python`: a transcript whose prompts are malformed or whose 

149 source doesn't compile is a documentation bug before anyone executes it. 

150 """ 

151 try: 

152 examples = doctest.DocTestParser().get_examples(fence.body, f"<readme:{fence.line}>") 

153 except ValueError as exc: 

154 return "failed", str(exc) 

155 if not examples: 

156 return "skipped", "no `>>>` prompt in the fence" 

157 for example in examples: 

158 try: 

159 compile(example.source, f"<readme:{fence.line}>", "single") 

160 except SyntaxError as exc: 

161 where = fence.line + 1 + example.lineno 

162 return "failed", f"{exc.msg} (README line {where})" 

163 return "ok", "" 

164 

165 

166def check_fence(fence: Fence) -> tuple[str, str]: 

167 """Check one fence as far as its language allows; return (status, detail).""" 

168 if should_skip(fence.flags): 

169 return "skipped", f"{SKIP_FLAG} on the fence" 

170 if fence.language in _SHELL_LANGS: 

171 reason = shell_skip_reason(fence.body) 

172 return ("skipped", reason) if reason else check_shell(fence) 

173 if fence.language in _PYTHON_LANGS: 

174 return check_python(fence) 

175 if fence.language == _PYCON_LANG: 

176 return check_pycon(fence) 

177 if fence.language == _RESULT_LANG: 

178 return "skipped", "expected output for a python fence" 

179 if not fence.language: 

180 return "untagged", "no language on the fence — nothing can check it" 

181 return "skipped", f"`{fence.language}` fences are not checkable" 

182 

183 

184def run_python_fences(readme: Path, blocks: list[Fence]) -> dict[str, Any]: 

185 """Execute the README's Python fences and diff the output against its ``result`` blocks. 

186 

187 The fences are concatenated and run as one program, exactly as the template's 

188 `test_readme_validation.py` does it: a README's examples are usually one session split 

189 across prose, so running each in isolation would break every one that builds on the 

190 last. 

191 

192 **With no ``result`` block, only the exit status is asserted.** The template compares 

193 against the empty string there, which fails any example that prints — right for a repo 

194 whose README is expected to carry them, wrong as a general rule, and this script runs 

195 against repos that never adopted the convention. Undocumented output is a note, not a 

196 failure. 

197 """ 

198 code = "".join( 

199 f.body for f in blocks if f.language in _PYTHON_LANGS and not should_skip(f.flags) 

200 ) 

201 expected = "".join(f.body for f in blocks if f.language == _RESULT_LANG) 

202 if not code.strip(): 

203 return {"ran": False, "violations": [], "notes": ["no executable python fence"]} 

204 

205 result = subprocess.run( # nosec B603 

206 [sys.executable, "-c", code], 

207 cwd=str(readme.parent), 

208 capture_output=True, 

209 text=True, 

210 check=False, 

211 ) 

212 violations: list[str] = [] 

213 notes: list[str] = [] 

214 matched: bool | None = None 

215 if result.returncode != 0: 

216 detail = last_line(result.stderr, "no stderr") 

217 violations.append(f"{readme.name}: python fences exited {result.returncode} — {detail}") 

218 elif expected.strip(): 

219 matched = result.stdout.strip() == expected.strip() 

220 if not matched: 

221 violations.append( 

222 f"{readme.name}: python fence output does not match its ```result``` block " 

223 f"(expected {expected.strip()[:60]!r}, got {result.stdout.strip()[:60]!r})" 

224 ) 

225 else: 

226 notes.append("python fences ran, but no ```result``` block documents their output") 

227 return { 

228 "ran": True, 

229 "returncode": result.returncode, 

230 "matched": matched, 

231 "violations": violations, 

232 "notes": notes, 

233 } 

234 

235 

236def _first_failed_example(report: str) -> str: 

237 """Return the source line of the first failing example in a doctest *report*. 

238 

239 That line, not the header doctest prints above it, is what a reader searches the 

240 README for. 

241 """ 

242 lines = report.splitlines() 

243 for index, line in enumerate(lines[:-1]): 

244 if line.strip() == "Failed example:": 

245 return lines[index + 1].strip() 

246 return last_line(report, "see the doctest report") 

247 

248 

249def run_pycon_fences(readme: Path, blocks: list[Fence]) -> dict[str, Any]: 

250 """Doctest the README's ``pycon`` fences as one transcript; return a summary dict. 

251 

252 Every unskipped ``pycon`` fence is joined in document order and run as a single 

253 doctest with ``ELLIPSIS``, in a subprocess whose working directory is the README's 

254 own — the same semantics as the template's `test_readme_validation.py`. One doctest 

255 rather than one per fence, because a README's examples are one session split across 

256 prose: the second fence uses the imports of the first. 

257 """ 

258 text = "".join( 

259 f.body if f.body.endswith("\n") else f.body + "\n" 

260 for f in blocks 

261 if f.language == _PYCON_LANG and not should_skip(f.flags) 

262 ) 

263 if not doctest.DocTestParser().get_examples(text): 

264 return {"ran": False, "violations": [], "notes": ["no executable pycon fence"]} 

265 

266 proc = subprocess.run( # nosec B603 

267 [sys.executable, "-c", _DOCTEST_CHILD, readme.name], 

268 input=text, 

269 cwd=str(readme.parent), 

270 capture_output=True, 

271 text=True, 

272 check=False, 

273 ) 

274 try: 

275 # The last line only: an example that writes to fd 1 behind doctest's back (a 

276 # subprocess, say) would otherwise corrupt the verdict. 

277 verdict = json.loads(last_line(proc.stdout, "")) 

278 except ValueError: 

279 detail = last_line(proc.stderr, "no stderr") 

280 return { 

281 "ran": True, 

282 "failed": None, 

283 "attempted": None, 

284 "violations": [f"{readme.name}: pycon doctest exited {proc.returncode} — {detail}"], 

285 "notes": [], 

286 } 

287 violations: list[str] = [] 

288 if verdict["failed"]: 

289 violations.append( 

290 f"{readme.name}: pycon fences — {verdict['failed']} of {verdict['attempted']} " 

291 f"example(s) failed, first: {_first_failed_example(verdict['report'])}" 

292 ) 

293 return { 

294 "ran": True, 

295 "failed": verdict["failed"], 

296 "attempted": verdict["attempted"], 

297 "report": verdict["report"], 

298 "violations": violations, 

299 "notes": [], 

300 } 

301 

302 

303def _untagged_note(checked: list[dict[str, Any]]) -> list[str]: 

304 """Name the fences that carry no language, which nothing can check.""" 

305 untagged = [block for block in checked if block["status"] == "untagged"] 

306 if not untagged: 

307 return [] 

308 return [ 

309 f"{len(untagged)} fence(s) carry no language, so nothing can check them: " 

310 + ", ".join(f"line {block['line']}" for block in untagged) 

311 ] 

312 

313 

314def readme_report(readme: Path, *, run: bool) -> dict[str, Any]: 

315 """Check every fence in *readme*; return a summary dict.""" 

316 if not readme.is_file(): 

317 return { 

318 "path": str(readme), 

319 "present": False, 

320 "blocks": [], 

321 "violations": [], 

322 "notes": [f"no {readme.name} — README examples are out of scope, not failing"], 

323 } 

324 

325 checked: list[dict[str, Any]] = [] 

326 violations: list[str] = [] 

327 blocks = fences(readme.read_text(encoding="utf-8")) 

328 for fence in blocks: 

329 status, detail = check_fence(fence) 

330 checked.append( 

331 { 

332 "line": fence.line, 

333 "language": fence.language or "(none)", 

334 "status": status, 

335 "detail": detail, 

336 } 

337 ) 

338 if status == "failed": 

339 violations.append(f"{readme.name}:{fence.line}: {fence.language} fence — {detail}") 

340 

341 report: dict[str, Any] = { 

342 "path": str(readme), 

343 "present": True, 

344 "blocks": checked, 

345 "violations": violations, 

346 "notes": _untagged_note(checked), 

347 } 

348 if run: 

349 _run_fences(readme, blocks, report) 

350 return report 

351 

352 

353def _run_fences(readme: Path, blocks: list[Fence], report: dict[str, Any]) -> None: 

354 """Execute whichever convention *blocks* use, recording each under its own key. 

355 

356 ``pycon`` is run when present; the legacy ``python``/``result`` pair is run when any 

357 ``python`` fence is present, or when there is no ``pycon`` fence either — so a README 

358 with no examples at all still gets the one note that says so. 

359 """ 

360 has_pycon = any(f.language == _PYCON_LANG for f in blocks) 

361 if has_pycon: 

362 report["doctest"] = run_pycon_fences(readme, blocks) 

363 if not has_pycon or any(f.language in _PYTHON_LANGS for f in blocks): 

364 report["execution"] = run_python_fences(readme, blocks) 

365 for key in ("doctest", "execution"): 

366 if key in report: 

367 report["violations"].extend(report[key]["violations"]) 

368 report["notes"].extend(report[key]["notes"]) 

369 

370 

371def print_report(readme: dict[str, Any]) -> None: 

372 """Print the README half of a report as text.""" 

373 if not readme["present"]: 

374 print(f"{'unavailable':<12} README fences ({readme['path']} is missing)") 

375 return 

376 counts: dict[str, int] = {} 

377 for block in readme["blocks"]: 

378 counts[block["status"]] = counts.get(block["status"], 0) + 1 

379 tally = ", ".join(f"{count} {status}" for status, count in sorted(counts.items())) 

380 print(f"{'readme':<12} {readme['path']}: {len(readme['blocks'])} fence(s) — {tally or 'none'}") 

381 for block in readme["blocks"]: 

382 detail = f" — {block['detail']}" if block["detail"] else "" 

383 print(f"{block['status']:<12} {readme['path']}:{block['line']} {block['language']}{detail}") 

384 _print_runs(readme) 

385 

386 

387def _print_runs(readme: dict[str, Any]) -> None: 

388 """Print one line per execution pass that actually ran over the README's fences.""" 

389 execution = readme.get("execution") 

390 if execution is not None and execution["ran"]: 

391 print( 

392 f"{'ran':<12} python fences exited {execution['returncode']}, " 

393 f"output matched: {execution['matched']}" 

394 ) 

395 doctest_run = readme.get("doctest") 

396 if doctest_run is not None and doctest_run["ran"]: 

397 print( 

398 f"{'ran':<12} pycon fences as one doctest: " 

399 f"{doctest_run['attempted']} example(s), {doctest_run['failed']} failed" 

400 )