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
« 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`.
3This module provides functionality to automatically synchronize the README.md
4file with the current Makefile help output, keeping documentation up to date.
6Example:
7 Update README with make help::
9 from rhiza_tools.commands.update_readme import update_readme_command
10 update_readme_command()
12 Preview changes with dry run::
14 update_readme_command(dry_run=True)
15"""
17import re
18import subprocess # nosec B404
19from pathlib import Path
21import typer
23from rhiza_tools import console
26def _get_make_help_output() -> str:
27 """Generate the help output from Makefile.
29 Runs `make help` and returns the output with ANSI codes stripped and
30 make directory messages filtered out.
32 Returns:
33 The help output as a clean string without ANSI codes or directory messages.
35 Raises:
36 typer.Exit: If make command is not found or execution fails.
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 )
54 # Get stdout and filter it
55 output = result.stdout
57 # Strip ANSI color codes (escape sequences)
58 # Pattern matches: ESC [ <numbers/semicolons> m
59 output = re.sub(r"\x1b\[[0-9;]*m", "", output)
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)
70 return "\n".join(filtered_lines)
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
80def _read_readme_content(readme_path: Path) -> str:
81 """Read content from README file.
83 Args:
84 readme_path: Path to the README.md file.
86 Returns:
87 The content of the README file.
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
102def _write_readme_content(readme_path: Path, content: str) -> None:
103 """Write content to README file.
105 Args:
106 readme_path: Path to the README.md file.
107 content: The content to write.
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
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.
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.
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
134 # Skip empty line if present
135 if i < len(lines) and lines[i].strip() == "":
136 new_lines.append(lines[i])
137 i += 1
139 # Check for opening code fence
140 if i >= len(lines) or lines[i].strip() != "```makefile":
141 return [], start_idx
143 new_lines.append(lines[i])
144 i += 1
146 # Add the new help output
147 new_lines.append(help_output)
149 # Skip old content until we find the closing fence
150 while i < len(lines) and lines[i].strip() != "```":
151 i += 1
153 # Add the closing fence if found
154 if i < len(lines):
155 new_lines.append(lines[i])
156 i += 1
158 return new_lines, i
161def _update_readme_with_help(readme_path: Path, help_output: str) -> bool:
162 """Update README.md with new help output.
164 Searches for the marker line and updates the code block that follows it
165 with the new help output.
167 Args:
168 readme_path: Path to the README.md file.
169 help_output: The help output to insert.
171 Returns:
172 True if the README was updated, False if the marker was not found.
174 Raises:
175 typer.Exit: If README cannot be read or written.
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:"
189 if marker not in content:
190 console.info("No help section marker found in README.md - skipping update")
191 return False
193 lines = content.split("\n")
194 new_lines = []
195 i = 0
196 pattern_found = False
198 while i < len(lines):
199 line = lines[i]
201 if line.strip() == marker:
202 new_lines.append(line)
203 i += 1
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
216 if not pattern_found:
217 console.info("Help section not properly formatted in README.md - skipping update")
218 return False
220 _write_readme_content(readme_path, "\n".join(new_lines))
221 return True
224def update_readme_command(dry_run: bool = False) -> None:
225 """Update README.md with the current output from `make help`.
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.
230 Args:
231 dry_run: If True, only show what would be done without making changes.
233 Raises:
234 typer.Exit: If README.md is not found or cannot be accessed.
236 Example:
237 Update README::
239 update_readme_command()
241 Preview changes::
243 update_readme_command(dry_run=True)
244 """
245 readme_path = Path("README.md")
247 if not readme_path.exists():
248 console.error("README.md not found in current directory")
249 raise typer.Exit(code=1)
251 # Get the help output
252 console.info("Generating help output from Makefile...")
253 help_output = _get_make_help_output()
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
262 # Update the README
263 console.info("Updating README.md...")
264 updated = _update_readme_with_help(readme_path, help_output)
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)")