Coverage for src / rhiza_tools / commands / update_readme.py: 100%

101 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-23 01:10 +0000

1"""Command to update README.md with the current output from `make help`. 

2 

3This module provides functionality to automatically synchronize the README.md 

4file with the current Makefile help output, keeping documentation up to date. 

5 

6Example: 

7 Update README with make help:: 

8 

9 from rhiza_tools.commands.update_readme import update_readme_command 

10 update_readme_command() 

11 

12 Preview changes with dry run:: 

13 

14 update_readme_command(dry_run=True) 

15""" 

16 

17import re 

18import subprocess # nosec B404 

19from pathlib import Path 

20 

21import typer 

22 

23from rhiza_tools import console 

24 

25 

26def _get_make_help_output() -> str: 

27 """Generate the help output from Makefile. 

28 

29 Runs `make help` and returns the output with ANSI codes stripped and 

30 make directory messages filtered out. 

31 

32 Returns: 

33 The help output as a clean string without ANSI codes or directory messages. 

34 

35 Raises: 

36 typer.Exit: If make command is not found or execution fails. 

37 

38 Example: 

39 >>> help_output = _get_make_help_output() # doctest: +SKIP 

40 >>> print(help_output) # doctest: +SKIP 

41 install Install dependencies using uv 

42 test Run tests with pytest 

43 ... 

44 """ 

45 try: 

46 # Run make help and capture output 

47 result = subprocess.run( # nosec B603 B607 

48 ["make", "help"], # noqa: S607 

49 capture_output=True, 

50 text=True, 

51 check=False, # Don't raise on non-zero exit 

52 ) 

53 

54 # Get stdout and filter it 

55 output = result.stdout 

56 

57 # Strip ANSI color codes (escape sequences) 

58 # Pattern matches: ESC [ <numbers/semicolons> m 

59 output = re.sub(r"\x1b\[[0-9;]*m", "", output) 

60 

61 # Filter out make's directory change messages 

62 lines = output.split("\n") 

63 filtered_lines = [] 

64 for line in lines: 

65 # Skip lines starting with "make[" or containing directory messages 

66 if line.startswith("make[") or "Entering directory" in line or "Leaving directory" in line: 

67 continue 

68 filtered_lines.append(line) 

69 

70 return "\n".join(filtered_lines) 

71 

72 except FileNotFoundError: 

73 console.error("make command not found") 

74 raise typer.Exit(code=1) from None 

75 except Exception as e: 

76 console.error(f"Failed to run 'make help': {e}") 

77 raise typer.Exit(code=1) from None 

78 

79 

80def _read_readme_content(readme_path: Path) -> str: 

81 """Read content from README file. 

82 

83 Args: 

84 readme_path: Path to the README.md file. 

85 

86 Returns: 

87 The content of the README file. 

88 

89 Raises: 

90 typer.Exit: If README cannot be read. 

91 """ 

92 try: 

93 return readme_path.read_text() 

94 except FileNotFoundError: 

95 console.error(f"README file not found: {readme_path}") 

96 raise typer.Exit(code=1) from None 

97 except Exception as e: 

98 console.error(f"Failed to read README: {e}") 

99 raise typer.Exit(code=1) from None 

100 

101 

102def _write_readme_content(readme_path: Path, content: str) -> None: 

103 """Write content to README file. 

104 

105 Args: 

106 readme_path: Path to the README.md file. 

107 content: The content to write. 

108 

109 Raises: 

110 typer.Exit: If README cannot be written. 

111 """ 

112 try: 

113 readme_path.write_text(content) 

114 except Exception as e: 

115 console.error(f"Failed to write README: {e}") 

116 raise typer.Exit(code=1) from None 

117 

118 

119def _replace_code_block_content(lines: list[str], start_idx: int, help_output: str) -> tuple[list[str], int]: 

120 """Replace content within a code block after the marker. 

121 

122 Args: 

123 lines: List of lines from the README. 

124 start_idx: Index where the code block should start. 

125 help_output: The new content to insert. 

126 

127 Returns: 

128 Tuple of (new lines to add, next index to process). 

129 Returns empty list and same index if code fence not found. 

130 """ 

131 new_lines = [] 

132 i = start_idx 

133 

134 # Skip empty line if present 

135 if i < len(lines) and lines[i].strip() == "": 

136 new_lines.append(lines[i]) 

137 i += 1 

138 

139 # Check for opening code fence 

140 if i >= len(lines) or lines[i].strip() != "```makefile": 

141 return [], start_idx 

142 

143 new_lines.append(lines[i]) 

144 i += 1 

145 

146 # Add the new help output 

147 new_lines.append(help_output) 

148 

149 # Skip old content until we find the closing fence 

150 while i < len(lines) and lines[i].strip() != "```": 

151 i += 1 

152 

153 # Add the closing fence if found 

154 if i < len(lines): 

155 new_lines.append(lines[i]) 

156 i += 1 

157 

158 return new_lines, i 

159 

160 

161def _update_readme_with_help(readme_path: Path, help_output: str) -> bool: 

162 """Update README.md with new help output. 

163 

164 Searches for the marker line and updates the code block that follows it 

165 with the new help output. 

166 

167 Args: 

168 readme_path: Path to the README.md file. 

169 help_output: The help output to insert. 

170 

171 Returns: 

172 True if the README was updated, False if the marker was not found. 

173 

174 Raises: 

175 typer.Exit: If README cannot be read or written. 

176 

177 Example: 

178 >>> from pathlib import Path 

179 >>> updated = _update_readme_with_help( # doctest: +SKIP 

180 ... Path("README.md"), 

181 ... "install Install dependencies" 

182 ... ) 

183 >>> print(updated) # doctest: +SKIP 

184 True 

185 """ 

186 content = _read_readme_content(readme_path) 

187 marker = "Run `make help` to see all available targets:" 

188 

189 if marker not in content: 

190 console.info("No help section marker found in README.md - skipping update") 

191 return False 

192 

193 lines = content.split("\n") 

194 new_lines = [] 

195 i = 0 

196 pattern_found = False 

197 

198 while i < len(lines): 

199 line = lines[i] 

200 

201 if line.strip() == marker: 

202 new_lines.append(line) 

203 i += 1 

204 

205 block_lines, new_idx = _replace_code_block_content(lines, i, help_output) 

206 if block_lines: 

207 new_lines.extend(block_lines) 

208 i = new_idx 

209 pattern_found = True 

210 else: 

211 console.warning("Help section marker found but no code fence follows - skipping update") 

212 else: 

213 new_lines.append(line) 

214 i += 1 

215 

216 if not pattern_found: 

217 console.info("Help section not properly formatted in README.md - skipping update") 

218 return False 

219 

220 _write_readme_content(readme_path, "\n".join(new_lines)) 

221 return True 

222 

223 

224def update_readme_command(dry_run: bool = False) -> None: 

225 """Update README.md with the current output from `make help`. 

226 

227 This command synchronizes the README.md file with the current Makefile help 

228 output by finding the marker line and updating the code block that follows. 

229 

230 Args: 

231 dry_run: If True, only show what would be done without making changes. 

232 

233 Raises: 

234 typer.Exit: If README.md is not found or cannot be accessed. 

235 

236 Example: 

237 Update README:: 

238 

239 update_readme_command() 

240 

241 Preview changes:: 

242 

243 update_readme_command(dry_run=True) 

244 """ 

245 readme_path = Path("README.md") 

246 

247 if not readme_path.exists(): 

248 console.error("README.md not found in current directory") 

249 raise typer.Exit(code=1) 

250 

251 # Get the help output 

252 console.info("Generating help output from Makefile...") 

253 help_output = _get_make_help_output() 

254 

255 if dry_run: 

256 console.info("DRY RUN: Would update README.md with the following content:") 

257 console.info("-" * 50) 

258 console.info(help_output) 

259 console.info("-" * 50) 

260 return 

261 

262 # Update the README 

263 console.info("Updating README.md...") 

264 updated = _update_readme_with_help(readme_path, help_output) 

265 

266 if updated: 

267 console.success("README.md updated with current 'make help' output") 

268 else: 

269 console.info("README.md was not modified (no marker found)")