Coverage for scripts/sync_readme_help.py: 100%

93 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-31 06:18 +0000

1#!/usr/bin/env python3 

2"""Sync the README's `make help` block from live output — behind `/rhiza:docs`. 

3 

4Keeps a README's list of `make` targets in lockstep with the actual `Makefile`, so 

5contributors never read a stale target list. Replaces the retired rhiza-tools 

6``update-readme`` command. 

7 

8The contract is deliberately narrow, because this edits a file humans have written: 

9 

10* it finds the marker line ``Run `make help` to see all available targets:`` and the 

11 fenced code block immediately after it, and replaces **only that block's 

12 contents** — the marker, the fences, and every other byte stay put; 

13* marker missing, or present with no following fence? **No-op**, reported as such. 

14 It never invents a place to put the list; 

15* ``make help`` output is sanitised first — help targets colourise names, and 

16 recursive makes emit ``Entering directory`` chatter — so the result is stable; 

17* it is **idempotent**: against an unchanged ``Makefile`` a second run writes 

18 nothing, which is what makes it safe to run on every `/rhiza:docs`. 

19 

20Usage: 

21 uv run --python 3.12 --no-project python \ 

22 scripts/sync_readme_help.py [TARGET] [--readme README.md] [--json] 

23 

24Exit codes: 

25 0 block refreshed, or already up to date, or nothing to do (no marker/Makefile) 

26 2 `make help` failed 

27""" 

28 

29from __future__ import annotations 

30 

31import argparse 

32import json 

33import os 

34import re 

35import shutil 

36import subprocess # nosec B404 

37import sys 

38from pathlib import Path 

39from typing import Any 

40 

41MARKER = "Run `make help` to see all available targets:" 

42_FENCE = "```" 

43_MAKEFILES = ("Makefile", "makefile", "GNUmakefile") 

44 

45_ANSI = re.compile(r"\x1b\[[0-9;]*m") 

46_MAKE_CHATTER = re.compile(r"^make\[\d+\]: (Entering|Leaving) directory") 

47 

48EXIT_OK = 0 

49EXIT_MAKE_FAILED = 2 

50 

51 

52def find_makefile(target: Path) -> Path | None: 

53 """Return the repo's makefile, or None when there isn't one.""" 

54 return next((target / name for name in _MAKEFILES if (target / name).is_file()), None) 

55 

56 

57def has_help_target(makefile: Path) -> bool: 

58 """Can make resolve a ``help`` target from *makefile*'s directory? 

59 

60 Asks **make**, via ``make -n help``, rather than reading the file. That matters 

61 because a rhiza-managed repo's root Makefile is essentially just 

62 ``include .rhiza/rhiza.mk`` — the ``help`` target lives in the *included* file. A 

63 text scan of the root therefore answers "no" for every repo this is meant to serve, 

64 which silently disabled the whole sync there. 

65 

66 ``-n`` expands the recipe without running it, so the probe has no side effects. 

67 """ 

68 make = shutil.which("make") 

69 if make is None: # pragma: no cover - make is checked by the caller 

70 return False 

71 result = subprocess.run( # nosec B603 

72 [make, "-n", "help"], 

73 cwd=str(makefile.parent), 

74 capture_output=True, 

75 text=True, 

76 check=False, 

77 ) 

78 return result.returncode == 0 

79 

80 

81def clean_help_output(raw: str) -> str: 

82 """Strip ANSI colour codes and recursive-make chatter from ``make help`` output.""" 

83 lines = [_ANSI.sub("", line).rstrip() for line in raw.splitlines()] 

84 kept = [line for line in lines if not _MAKE_CHATTER.match(line)] 

85 while kept and not kept[0].strip(): 

86 kept.pop(0) 

87 while kept and not kept[-1].strip(): 

88 kept.pop() 

89 return "\n".join(kept) 

90 

91 

92def find_block(lines: list[str]) -> tuple[int, int] | None: 

93 """Return ``(first_content_idx, fence_close_idx)`` for the marker's fenced block. 

94 

95 ``first_content_idx`` is the line after the opening fence, so an empty block 

96 yields a span where start == the closing fence index. Returns None when the 

97 marker is absent, or present with no fence following it. 

98 """ 

99 marker = next((i for i, line in enumerate(lines) if MARKER in line), None) 

100 if marker is None: 

101 return None 

102 # The opening fence must follow the marker, allowing blank lines between. 

103 opening = None 

104 for i in range(marker + 1, len(lines)): 

105 if not lines[i].strip(): 

106 continue 

107 opening = i if lines[i].lstrip().startswith(_FENCE) else None 

108 break 

109 if opening is None: 

110 return None 

111 for j in range(opening + 1, len(lines)): 

112 if lines[j].lstrip().startswith(_FENCE): 

113 return opening + 1, j 

114 return None 

115 

116 

117def sync_readme_help(target: Path, readme_name: str = "README.md") -> dict[str, Any]: 

118 """Refresh the README's `make help` block at *target*; return a summary dict.""" 

119 readme = target / readme_name 

120 

121 if not readme.is_file(): 

122 return _result("skipped", f"no {readme_name}") 

123 

124 makefile = find_makefile(target) 

125 if makefile is None: 

126 return _result("skipped", "no Makefile") 

127 

128 # Before probing: the probe itself asks make, so its absence must be reported as 

129 # such rather than as "no help target", which would be misleading. 

130 make = shutil.which("make") 

131 if make is None: 

132 return _result("skipped", "make is not on PATH") 

133 if not has_help_target(makefile): 

134 return _result("skipped", "make cannot resolve a `help` target") 

135 

136 original = readme.read_text() 

137 span = find_block(original.splitlines()) 

138 if span is None: 

139 return _result("skipped", f"no `{MARKER}` marker with a following fenced block") 

140 

141 env = os.environ.copy() 

142 env["NO_COLOR"] = "1" 

143 proc = subprocess.run( # nosec B603 

144 [make, "help"], cwd=str(target), capture_output=True, text=True, env=env, check=False 

145 ) 

146 if proc.returncode != 0: 

147 return { 

148 "status": "failed", 

149 "note": f"`make help` failed: {proc.stderr.strip()[:200]}", 

150 "exit_code": EXIT_MAKE_FAILED, 

151 } 

152 

153 body = clean_help_output(proc.stdout) 

154 lines = original.splitlines() 

155 start, close = span 

156 if lines[start:close] == body.splitlines(): 

157 return _result("unchanged", "the target list already matches `make help`") 

158 

159 lines[start:close] = body.splitlines() 

160 updated = "\n".join(lines) 

161 if original.endswith("\n"): 

162 updated += "\n" 

163 readme.write_text(updated) 

164 return _result("refreshed", f"{len(body.splitlines())} line(s) from `make help`") 

165 

166 

167def _result(status: str, note: str) -> dict[str, Any]: 

168 """Build a summary dict for a non-failure outcome.""" 

169 return {"status": status, "note": note, "exit_code": EXIT_OK} 

170 

171 

172def main(argv: list[str] | None = None) -> int: 

173 """Entry point: sync the README block and return an exit code.""" 

174 parser = argparse.ArgumentParser(description="Sync a README's `make help` block.") 

175 parser.add_argument( 

176 "target", nargs="?", default=".", help="Repository root (default: current directory)." 

177 ) 

178 parser.add_argument("--readme", default="README.md", help="README filename.") 

179 parser.add_argument( 

180 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON." 

181 ) 

182 args = parser.parse_args(argv) 

183 

184 summary = sync_readme_help(Path(args.target).resolve(), args.readme) 

185 

186 if args.json_output: 

187 print(json.dumps(summary, indent=2)) 

188 else: 

189 stream = sys.stderr if summary["status"] in ("skipped", "failed") else sys.stdout 

190 print(f"{summary['status']:<10} {summary['note']}", file=stream) 

191 return int(summary["exit_code"]) 

192 

193 

194if __name__ == "__main__": 

195 raise SystemExit(main())