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
« 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`.
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.
8The contract is deliberately narrow, because this edits a file humans have written:
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`.
20Usage:
21 uv run --python 3.12 --no-project python \
22 scripts/sync_readme_help.py [TARGET] [--readme README.md] [--json]
24Exit codes:
25 0 block refreshed, or already up to date, or nothing to do (no marker/Makefile)
26 2 `make help` failed
27"""
29from __future__ import annotations
31import argparse
32import json
33import os
34import re
35import shutil
36import subprocess # nosec B404
37import sys
38from pathlib import Path
39from typing import Any
41MARKER = "Run `make help` to see all available targets:"
42_FENCE = "```"
43_MAKEFILES = ("Makefile", "makefile", "GNUmakefile")
45_ANSI = re.compile(r"\x1b\[[0-9;]*m")
46_MAKE_CHATTER = re.compile(r"^make\[\d+\]: (Entering|Leaving) directory")
48EXIT_OK = 0
49EXIT_MAKE_FAILED = 2
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)
57def has_help_target(makefile: Path) -> bool:
58 """Can make resolve a ``help`` target from *makefile*'s directory?
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.
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
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)
92def find_block(lines: list[str]) -> tuple[int, int] | None:
93 """Return ``(first_content_idx, fence_close_idx)`` for the marker's fenced block.
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
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
121 if not readme.is_file():
122 return _result("skipped", f"no {readme_name}")
124 makefile = find_makefile(target)
125 if makefile is None:
126 return _result("skipped", "no Makefile")
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")
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")
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 }
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`")
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`")
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}
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)
184 summary = sync_readme_help(Path(args.target).resolve(), args.readme)
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"])
194if __name__ == "__main__":
195 raise SystemExit(main())