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

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 if START_MARKER not in content or END_MARKER not in content: 

108 # No markers, nothing to update 

109 return False 

110 

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

123 

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) 

130 

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 

139 

140 return False 

141 

142 

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 

147 

148 repo_root = find_repo_root() 

149 readme_path = repo_root / "README.md" 

150 

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 

156 

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 

160 

161 return 0 

162 

163 

164def _run() -> None: 

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

166 sys.exit(main()) 

167 

168 

169if __name__ == "__main__": # pragma: no cover 

170 _run()