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

1#!/usr/bin/env python3 

2"""Script to update README with Makefile help output. 

3 

4This hook runs 'make help' and embeds the output into README.md 

5between special marker comments. 

6 

7Migrated from rhiza's local pre-commit hook that runs 'make readme'. 

8This is a Python wrapper that provides the same functionality. 

9""" 

10 

11from __future__ import annotations 

12 

13import re 

14import subprocess # nosec B404 

15import sys 

16from pathlib import Path 

17 

18from rhiza_hooks._repo import find_repo_root 

19 

20# Markers used to identify the section to update in README 

21START_MARKER = "<!-- MAKE_HELP_START -->" 

22END_MARKER = "<!-- MAKE_HELP_END -->" 

23 

24 

25def get_make_help_output() -> str | None: 

26 """Run 'make help' and capture the output. 

27 

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 

50 

51 

52def update_readme_with_help(readme_path: Path, help_output: str) -> bool: 

53 r"""Update README.md with the make help output. 

54 

55 Args: 

56 readme_path: Path to the README.md file. 

57 help_output: The output from 'make help'. 

58 

59 Returns: 

60 True if the file was modified, False otherwise. 

61 

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?": 

65 

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 

73 

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: 

77 

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 --> 

91 

92 Re-running with the same help output changes nothing, so the hook converges 

93 instead of failing every commit: 

94 

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 

103 

104 content = readme_path.read_text(encoding="utf-8") 

105 

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 

113 

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}" 

126 

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) 

133 

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 

142 

143 return False 

144 

145 

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 

150 

151 repo_root = find_repo_root() 

152 readme_path = repo_root / "README.md" 

153 

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 

159 

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 

163 

164 return 0 

165 

166 

167def _run() -> None: 

168 """Entry point: delegate to :func:`main` and exit with its return code.""" 

169 sys.exit(main()) 

170 

171 

172if __name__ == "__main__": # pragma: no cover # pragma: no mutate 

173 _run()