Coverage for src/rhiza_hooks/update_readme_help.py: 100%
48 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 05:51 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 05:51 +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 if START_MARKER not in content or END_MARKER not in content:
108 # No markers, nothing to update
109 return False
111 # Build the new content between markers
112 # ```text rather than a bare fence. The block holds `make help` output -- a rendered
113 # table, not code -- so there is no language to highlight, but an *untagged* fence is
114 # indistinguishable from one whose language nobody decided. Documentation checkers report
115 # it as uncheckable (jebel-quant/rhiza#1589), which means a fence that genuinely should
116 # have been tagged `python` or `bash` arrives into a README where one untagged fence is
117 # already normal. Tagging it says "checked, nothing to check".
118 #
119 # It must be written here rather than in the README: the substitution below replaces the
120 # whole span between the markers, fence lines included, so a hand-added tag downstream is
121 # overwritten by the next run.
122 new_section = f"{START_MARKER}\n```text\n{help_output}```\n{END_MARKER}"
124 # Replace the content between markers
125 pattern = re.compile(
126 re.escape(START_MARKER) + r".*?" + re.escape(END_MARKER),
127 re.DOTALL,
128 )
129 new_content = pattern.sub(new_section, content)
131 if new_content != content:
132 # newline="" suppresses the \n -> os.linesep translation. `content` came back
133 # from a universal-newline read and `help_output` from a text-mode subprocess,
134 # so `new_content` is all \n; without this the write would emit CRLF on Windows
135 # and reflow the entire README instead of just the help block.
136 readme_path.write_text(new_content, encoding="utf-8", newline="")
137 print(f"Updated {readme_path} with make help output", file=sys.stderr)
138 return True
140 return False
143def main(argv: list[str] | None = None) -> int:
144 """Execute the script."""
145 # This hook doesn't use filenames, it always operates on the repo root
146 _ = argv # Unused
148 repo_root = find_repo_root()
149 readme_path = repo_root / "README.md"
151 help_output = get_make_help_output()
152 if help_output is None:
153 # If make help fails, we don't fail the hook
154 # This allows the hook to be used in repos without a Makefile
155 return 0
157 if update_readme_with_help(readme_path, help_output):
158 # File was modified, fail so pre-commit knows to re-stage
159 return 1
161 return 0
164def _run() -> None:
165 """Entry point: delegate to :func:`main` and exit with its return code."""
166 sys.exit(main())
169if __name__ == "__main__": # pragma: no cover
170 _run()