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

42 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 06:14 +0000

1#!/usr/bin/env python3 

2"""Check that Makefile contains expected targets for rhiza projects. 

3 

4A catch-all rule satisfies every recommended target at once. Since rhiza v1.4.0 the 

5root Makefile is a ``rhiza-task`` shim that defines only ``help`` and forwards every 

6other goal through ``%:``, so ``make test`` works with no ``test:`` rule in sight; 

7reporting ``fmt``, ``install`` and ``test`` missing there is a false report on a 

8Makefile that is correct, and under ``--strict`` it blocks the commit. The rule is 

9applied by :meth:`rhiza_hooks._makefile.MakefileTargets.defines`, shared with 

10``check-workflow-make-targets`` so the two agree on what "defined" means. 

11""" 

12 

13from __future__ import annotations 

14 

15import argparse 

16import sys 

17from pathlib import Path 

18 

19from rhiza_hooks._makefile import extract_targets 

20 

21# Common targets expected in rhiza-based projects 

22RECOMMENDED_TARGETS = { 

23 "install", 

24 "test", 

25 "fmt", 

26 "help", 

27} 

28 

29 

30def check_makefile(filepath: Path, recommended: set[str] = RECOMMENDED_TARGETS) -> list[str]: 

31 """Check a Makefile for recommended targets. 

32 

33 Args: 

34 filepath: Path to the Makefile 

35 recommended: Target names that must be present (defaults to RECOMMENDED_TARGETS) 

36 

37 Returns: 

38 List of warning messages (empty if all recommended targets exist) 

39 """ 

40 warnings: list[str] = [] 

41 

42 try: 

43 content = filepath.read_text(encoding="utf-8") 

44 except FileNotFoundError: 

45 return [f"File not found: {filepath}"] 

46 

47 targets = extract_targets(content) 

48 

49 # Only check the main Makefile for recommended targets 

50 if filepath.name == "Makefile": 

51 missing = {target for target in recommended if not targets.defines(target)} 

52 if missing: 

53 warnings.append(f"Missing recommended targets: {', '.join(sorted(missing))}") 

54 

55 return warnings 

56 

57 

58def resolve_recommended_targets(targets: list[str] | None, extra_targets: list[str] | None) -> set[str]: 

59 """Build the effective set of required targets from the CLI options. 

60 

61 Args: 

62 targets: Values of ``--target``. When non-empty they *replace* the defaults. 

63 extra_targets: Values of ``--extend-target``, always *added* to the active set. 

64 

65 Returns: 

66 The set of target names a Makefile is expected to define. 

67 

68 With neither option, the built-in defaults apply: 

69 

70 >>> sorted(resolve_recommended_targets(None, None)) 

71 ['fmt', 'help', 'install', 'test'] 

72 

73 ``--target`` *replaces* them, so a project with its own vocabulary is not 

74 also held to the defaults: 

75 

76 >>> sorted(resolve_recommended_targets(["build"], None)) 

77 ['build'] 

78 

79 ``--extend-target`` *adds* to whichever set is active: 

80 

81 >>> sorted(resolve_recommended_targets(None, ["deploy"])) 

82 ['deploy', 'fmt', 'help', 'install', 'test'] 

83 >>> sorted(resolve_recommended_targets(["build"], ["deploy"])) 

84 ['build', 'deploy'] 

85 """ 

86 base = set(targets) if targets else set(RECOMMENDED_TARGETS) 

87 return base | set(extra_targets or []) 

88 

89 

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

91 """Main entry point for the hook.""" 

92 parser = argparse.ArgumentParser(description="Check Makefile for recommended targets") 

93 parser.add_argument( 

94 "filenames", 

95 nargs="*", 

96 help="Filenames to check", 

97 ) 

98 parser.add_argument( 

99 "--strict", 

100 action="store_true", 

101 help="Exit with error if recommended targets are missing", 

102 ) 

103 parser.add_argument( 

104 "--target", 

105 action="append", 

106 metavar="NAME", 

107 help="Required target name; repeatable. When given, replaces the default set.", 

108 ) 

109 parser.add_argument( 

110 "--extend-target", 

111 action="append", 

112 metavar="NAME", 

113 help="Extra required target name; repeatable. Added on top of the active set.", 

114 ) 

115 args = parser.parse_args(argv) 

116 

117 recommended = resolve_recommended_targets(args.target, args.extend_target) 

118 

119 retval = 0 

120 for filename in args.filenames: 

121 filepath = Path(filename) 

122 warnings = check_makefile(filepath, recommended) 

123 if warnings: 

124 print(f"{filename}:", file=sys.stderr) 

125 for warning in warnings: 

126 print(f" - {warning}", file=sys.stderr) 

127 if args.strict: 

128 retval = 1 

129 

130 return retval 

131 

132 

133if __name__ == "__main__": # pragma: no mutate 

134 sys.exit(main())