Coverage for src/rhiza_hooks/update_readme_help.py: 100%
48 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 06:14 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 06:14 +0000
1#!/usr/bin/env python3
2"""Script to update README with Makefile help output.
4This hook runs 'make help' and embeds the output into README.md
5between special marker comments.
7Migrated from rhiza's local pre-commit hook that runs 'make readme'.
8This is a Python wrapper that provides the same functionality.
9"""
11from __future__ import annotations
13import re
14import subprocess # nosec B404
15import sys
16from pathlib import Path
18from rhiza_hooks._repo import find_repo_root
20# Markers used to identify the section to update in README
21START_MARKER = "<!-- MAKE_HELP_START -->"
22END_MARKER = "<!-- MAKE_HELP_END -->"
25def get_make_help_output() -> str | None:
26 """Run 'make help' and capture the output.
28 Returns:
29 The output from 'make help', or None if the command fails.
30 """
31 try:
32 result = subprocess.run( # nosec B603 B607
33 ["make", "help"], # noqa: S607
34 capture_output=True,
35 text=True,
36 check=True,
37 timeout=30,
38 )
39 except subprocess.CalledProcessError as e:
40 print(f"Error running 'make help': {e}", file=sys.stderr)
41 return None
42 except subprocess.TimeoutExpired:
43 print("Error: 'make help' timed out", file=sys.stderr)
44 return None
45 except FileNotFoundError:
46 print("Error: 'make' command not found", file=sys.stderr)
47 return None
48 else:
49 return result.stdout
52def update_readme_with_help(readme_path: Path, help_output: str) -> bool:
53 r"""Update README.md with the make help output.
55 Args:
56 readme_path: Path to the README.md file.
57 help_output: The output from 'make help'.
59 Returns:
60 True if the file was modified, False otherwise.
62 The marker pair is the whole contract. Without both markers there is nothing
63 to replace and the hook is a silent no-op — which is the usual answer to "why
64 did my README not update?":
66 >>> import contextlib, io, tempfile
67 >>> from pathlib import Path
68 >>> tmp = tempfile.TemporaryDirectory()
69 >>> readme = Path(tmp.name) / "README.md"
70 >>> _ = readme.write_text("intro\n", encoding="utf-8")
71 >>> update_readme_with_help(readme, "test: run tests\n")
72 False
74 With both markers present, everything between them is replaced by the fenced
75 help output. The "Updated ..." notice goes to stderr, so it is redirected here
76 rather than appearing as expected output:
78 >>> _ = readme.write_text(
79 ... "intro\n<!-- MAKE_HELP_START -->\nstale\n<!-- MAKE_HELP_END -->\n", encoding="utf-8"
80 ... )
81 >>> with contextlib.redirect_stderr(io.StringIO()):
82 ... update_readme_with_help(readme, "test: run tests\n")
83 True
84 >>> print(readme.read_text(encoding="utf-8"), end="")
85 intro
86 <!-- MAKE_HELP_START -->
87 ```text
88 test: run tests
89 ```
90 <!-- MAKE_HELP_END -->
92 Re-running with the same help output changes nothing, so the hook converges
93 instead of failing every commit:
95 >>> with contextlib.redirect_stderr(io.StringIO()):
96 ... update_readme_with_help(readme, "test: run tests\n")
97 False
98 >>> tmp.cleanup()
99 """
100 if not readme_path.exists():
101 print(f"Warning: {readme_path} not found, skipping update", file=sys.stderr)
102 return False
104 content = readme_path.read_text(encoding="utf-8")
106 # Check if markers exist
107 # pragma below: equivalent mutant — with only one marker present the substitution
108 # pattern (START.*?END) cannot match either way, so `or`->`and` changes no observable
109 # behaviour (return value and file contents are identical).
110 if START_MARKER not in content or END_MARKER not in content: # pragma: no mutate
111 # No markers, nothing to update
112 return False
114 # Build the new content between markers
115 # ```text rather than a bare fence. The block holds `make help` output -- a rendered
116 # table, not code -- so there is no language to highlight, but an *untagged* fence is
117 # indistinguishable from one whose language nobody decided. Documentation checkers report
118 # it as uncheckable (jebel-quant/rhiza#1589), which means a fence that genuinely should
119 # have been tagged `python` or `bash` arrives into a README where one untagged fence is
120 # already normal. Tagging it says "checked, nothing to check".
121 #
122 # It must be written here rather than in the README: the substitution below replaces the
123 # whole span between the markers, fence lines included, so a hand-added tag downstream is
124 # overwritten by the next run.
125 new_section = f"{START_MARKER}\n```text\n{help_output}```\n{END_MARKER}"
127 # Replace the content between markers
128 pattern = re.compile(
129 re.escape(START_MARKER) + r".*?" + re.escape(END_MARKER),
130 re.DOTALL,
131 )
132 new_content = pattern.sub(new_section, content)
134 if new_content != content:
135 # newline="" suppresses the \n -> os.linesep translation. `content` came back
136 # from a universal-newline read and `help_output` from a text-mode subprocess,
137 # so `new_content` is all \n; without this the write would emit CRLF on Windows
138 # and reflow the entire README instead of just the help block.
139 readme_path.write_text(new_content, encoding="utf-8", newline="")
140 print(f"Updated {readme_path} with make help output", file=sys.stderr)
141 return True
143 return False
146def main(argv: list[str] | None = None) -> int:
147 """Execute the script."""
148 # This hook doesn't use filenames, it always operates on the repo root
149 _ = argv # Unused # pragma: no mutate # equivalent: value is never read
151 repo_root = find_repo_root()
152 readme_path = repo_root / "README.md"
154 help_output = get_make_help_output()
155 if help_output is None:
156 # If make help fails, we don't fail the hook
157 # This allows the hook to be used in repos without a Makefile
158 return 0
160 if update_readme_with_help(readme_path, help_output):
161 # File was modified, fail so pre-commit knows to re-stage
162 return 1
164 return 0
167def _run() -> None:
168 """Entry point: delegate to :func:`main` and exit with its return code."""
169 sys.exit(main())
172if __name__ == "__main__": # pragma: no cover # pragma: no mutate
173 _run()