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

110 statements  

« prev     ^ index     » next       coverage.py v7.13.3, created at 2026-02-08 08:53 +0000

1#!/usr/bin/env python3 

2"""Check that .rhiza/template.yml is valid and well-formed.""" 

3 

4from __future__ import annotations 

5 

6import argparse 

7import sys 

8from pathlib import Path 

9 

10import yaml 

11 

12REQUIRED_KEYS = {"template-repository", "template-branch"} 

13OPTIONAL_KEYS = {"include", "exclude", "templates"} 

14VALID_KEYS = REQUIRED_KEYS | OPTIONAL_KEYS 

15 

16 

17def _load_config(filepath: Path) -> dict | list[str]: 

18 """Load configuration from YAML file. 

19 

20 Returns: 

21 Config dict on success, or list of error messages on failure 

22 """ 

23 try: 

24 with filepath.open() as f: 

25 config = yaml.safe_load(f) 

26 except yaml.YAMLError as e: 

27 return [f"Invalid YAML: {e}"] 

28 except FileNotFoundError: 

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

30 

31 if config is None: 

32 return ["Configuration file is empty"] 

33 

34 if not isinstance(config, dict): 

35 return ["Configuration must be a YAML mapping"] 

36 

37 return config 

38 

39 

40def _validate_required_keys(config: dict) -> list[str]: 

41 """Validate required keys are present.""" 

42 errors = [] 

43 for key in REQUIRED_KEYS: 

44 if key not in config: 

45 errors.append(f"Missing required key: {key}") 

46 return errors 

47 

48 

49def _validate_unknown_keys(config: dict) -> list[str]: 

50 """Check for unknown keys.""" 

51 errors = [] 

52 for key in config: 

53 if key not in VALID_KEYS: 

54 errors.append(f"Unknown key: {key}") 

55 return errors 

56 

57 

58def _validate_include_or_templates(config: dict) -> list[str]: 

59 """Ensure at least one of 'include' or 'templates' is present.""" 

60 if "include" not in config and "templates" not in config: 

61 return ["At least one of 'include' or 'templates' must be present"] 

62 return [] 

63 

64 

65def _validate_template_repository(config: dict) -> list[str]: 

66 """Validate template-repository field.""" 

67 errors = [] 

68 if "template-repository" in config: 

69 repo = config["template-repository"] 

70 if not isinstance(repo, str): 

71 errors.append("template-repository must be a string") 

72 elif "/" not in repo: 

73 errors.append(f"template-repository should be in 'owner/repo' format, got: {repo}") 

74 return errors 

75 

76 

77def _validate_template_branch(config: dict) -> list[str]: 

78 """Validate template-branch field.""" 

79 errors = [] 

80 if "template-branch" in config: 

81 branch = config["template-branch"] 

82 if not isinstance(branch, str): 

83 errors.append("template-branch must be a string") 

84 elif not branch: 

85 errors.append("template-branch cannot be empty") 

86 return errors 

87 

88 

89def _validate_include_field(config: dict) -> list[str]: 

90 """Validate include field.""" 

91 errors = [] 

92 if "include" in config: 

93 include = config["include"] 

94 if not isinstance(include, list): 

95 errors.append("include must be a list") 

96 elif not include: 

97 errors.append("include list cannot be empty") 

98 return errors 

99 

100 

101def _validate_templates_field(config: dict) -> list[str]: 

102 """Validate templates field.""" 

103 errors = [] 

104 if "templates" in config: 

105 templates = config["templates"] 

106 if not isinstance(templates, list): 

107 errors.append("templates must be a list") 

108 elif not templates: 

109 errors.append("templates list cannot be empty") 

110 return errors 

111 

112 

113def _validate_exclude_field(config: dict) -> list[str]: 

114 """Validate exclude field.""" 

115 errors = [] 

116 if "exclude" in config: 

117 exclude = config["exclude"] 

118 if exclude is not None and not isinstance(exclude, list): 

119 errors.append("exclude must be a list or null") 

120 return errors 

121 

122 

123def validate_rhiza_config(filepath: Path) -> list[str]: 

124 """Validate a rhiza configuration file. 

125 

126 Args: 

127 filepath: Path to the .rhiza/template.yml file 

128 

129 Returns: 

130 List of error messages (empty if valid) 

131 """ 

132 # Load configuration 

133 config = _load_config(filepath) 

134 if isinstance(config, list): 

135 return config 

136 

137 # Validate all aspects 

138 errors = [] 

139 errors.extend(_validate_required_keys(config)) 

140 errors.extend(_validate_unknown_keys(config)) 

141 errors.extend(_validate_include_or_templates(config)) 

142 errors.extend(_validate_template_repository(config)) 

143 errors.extend(_validate_template_branch(config)) 

144 errors.extend(_validate_include_field(config)) 

145 errors.extend(_validate_templates_field(config)) 

146 errors.extend(_validate_exclude_field(config)) 

147 

148 return errors 

149 

150 

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

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

153 parser = argparse.ArgumentParser(description="Validate .rhiza/template.yml configuration") 

154 parser.add_argument( 

155 "filenames", 

156 nargs="*", 

157 help="Filenames to check", 

158 ) 

159 args = parser.parse_args(argv) 

160 

161 retval = 0 

162 for filename in args.filenames: 

163 filepath = Path(filename) 

164 errors = validate_rhiza_config(filepath) 

165 if errors: 

166 print(f"{filename}:") 

167 for error in errors: 

168 print(f" - {error}") 

169 retval = 1 

170 

171 return retval 

172 

173 

174if __name__ == "__main__": 

175 sys.exit(main())