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

93 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +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 rarely defines ``help`` itself: up to 

62 template v1.3 it was essentially ``include .rhiza/rhiza.mk`` and the target lived in 

63 the *included* file, and from v1.4 it is a shim whose ``help`` shells out to the 

64 pinned `rhiza-task`. A text scan of the root therefore answers "no" for every repo 

65 this is meant to serve, which silently disabled the whole sync there. 

66 

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

68 """ 

69 make = shutil.which("make") 

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

71 return False 

72 result = subprocess.run( # nosec B603 

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

74 cwd=str(makefile.parent), 

75 capture_output=True, 

76 text=True, 

77 check=False, 

78 ) 

79 return result.returncode == 0 

80 

81 

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

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

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

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

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

87 kept.pop(0) 

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

89 kept.pop() 

90 return "\n".join(kept) 

91 

92 

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

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

95 

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

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

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

99 """ 

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

101 if marker is None: 

102 return None 

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

104 opening = None 

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

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

107 continue 

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

109 break 

110 if opening is None: 

111 return None 

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

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

114 return opening + 1, j 

115 return None 

116 

117 

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

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

120 readme = target / readme_name 

121 

122 if not readme.is_file(): 

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

124 

125 makefile = find_makefile(target) 

126 if makefile is None: 

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

128 

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

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

131 make = shutil.which("make") 

132 if make is None: 

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

134 if not has_help_target(makefile): 

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

136 

137 original = readme.read_text(encoding="utf-8") 

138 span = find_block(original.splitlines()) 

139 if span is None: 

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

141 

142 env = os.environ.copy() 

143 env["NO_COLOR"] = "1" 

144 proc = subprocess.run( # nosec B603 

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

146 ) 

147 if proc.returncode != 0: 

148 return { 

149 "status": "failed", 

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

151 "exit_code": EXIT_MAKE_FAILED, 

152 } 

153 

154 body = clean_help_output(proc.stdout) 

155 lines = original.splitlines() 

156 start, close = span 

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

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

159 

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

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

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

163 updated += "\n" 

164 readme.write_text(updated, encoding="utf-8") 

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

166 

167 

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

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

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

171 

172 

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

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

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

176 parser.add_argument( 

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

178 ) 

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

180 parser.add_argument( 

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

182 ) 

183 args = parser.parse_args(argv) 

184 

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

186 

187 if args.json_output: 

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

189 else: 

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

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

192 return int(summary["exit_code"]) 

193 

194 

195if __name__ == "__main__": 

196 raise SystemExit(main())