Coverage for src/rhiza_hooks/update_readme_help.py: 100%

48 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-30 04:36 +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}") 

41 return None 

42 except subprocess.TimeoutExpired: 

43 print("Error: 'make help' timed out") 

44 return None 

45 except FileNotFoundError: 

46 print("Error: 'make' command not found") 

47 return None 

48 else: 

49 return result.stdout 

50 

51 

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

53 """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 if not readme_path.exists(): 

63 print(f"Warning: {readme_path} not found, skipping update") 

64 return False 

65 

66 content = readme_path.read_text() 

67 

68 # Check if markers exist 

69 # pragma below: equivalent mutant — with only one marker present the substitution 

70 # pattern (START.*?END) cannot match either way, so `or`->`and` changes no observable 

71 # behaviour (return value and file contents are identical). 

72 if START_MARKER not in content or END_MARKER not in content: # pragma: no mutate 

73 # No markers, nothing to update 

74 return False 

75 

76 # Build the new content between markers 

77 new_section = f"{START_MARKER}\n```\n{help_output}```\n{END_MARKER}" 

78 

79 # Replace the content between markers 

80 pattern = re.compile( 

81 re.escape(START_MARKER) + r".*?" + re.escape(END_MARKER), 

82 re.DOTALL, 

83 ) 

84 new_content = pattern.sub(new_section, content) 

85 

86 if new_content != content: 

87 readme_path.write_text(new_content) 

88 print(f"Updated {readme_path} with make help output") 

89 return True 

90 

91 return False 

92 

93 

94def main(argv: list[str] | None = None) -> int: 

95 """Execute the script.""" 

96 # This hook doesn't use filenames, it always operates on the repo root 

97 _ = argv # Unused # pragma: no mutate # equivalent: value is never read 

98 

99 repo_root = find_repo_root() 

100 readme_path = repo_root / "README.md" 

101 

102 help_output = get_make_help_output() 

103 if help_output is None: 

104 # If make help fails, we don't fail the hook 

105 # This allows the hook to be used in repos without a Makefile 

106 return 0 

107 

108 if update_readme_with_help(readme_path, help_output): 

109 # File was modified, fail so pre-commit knows to re-stage 

110 return 1 

111 

112 return 0 

113 

114 

115def _run() -> None: 

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

117 sys.exit(main()) 

118 

119 

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

121 _run()