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

119 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-08 07:00 +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# Alternative key names 

16KEY_ALIASES = { 

17 "repository": "template-repository", 

18 "ref": "template-branch", 

19 "profiles": "templates", 

20} 

21 

22 

23def _normalize_config(config: dict) -> dict: 

24 """Normalize configuration by replacing aliases with canonical keys. 

25 

26 Args: 

27 config: Raw configuration dictionary 

28 

29 Returns: 

30 Normalized configuration with aliases replaced 

31 """ 

32 normalized = {} 

33 for key, value in config.items(): 

34 # Replace alias with canonical name if it exists 

35 canonical_key = KEY_ALIASES.get(key, key) 

36 normalized[canonical_key] = value 

37 return normalized 

38 

39 

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

41 """Load configuration from YAML file. 

42 

43 Returns: 

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

45 """ 

46 try: 

47 with filepath.open() as f: 

48 config = yaml.safe_load(f) 

49 except yaml.YAMLError as e: 

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

51 except FileNotFoundError: 

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

53 

54 if config is None: 

55 return ["Configuration file is empty"] 

56 

57 if not isinstance(config, dict): 

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

59 

60 return config 

61 

62 

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

64 """Validate required keys are present.""" 

65 errors = [] 

66 for key in REQUIRED_KEYS: 

67 if key not in config: 

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

69 return errors 

70 

71 

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

73 """Check for unknown keys.""" 

74 errors = [] 

75 # Accept both canonical keys and their aliases 

76 all_valid_keys = VALID_KEYS | set(KEY_ALIASES.keys()) 

77 for key in config: 

78 if key not in all_valid_keys: 

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

80 return errors 

81 

82 

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

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

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

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

87 return [] 

88 

89 

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

91 """Validate template-repository field.""" 

92 errors = [] 

93 if "template-repository" in config: 

94 repo = config["template-repository"] 

95 if not isinstance(repo, str): 

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

97 elif "/" not in repo: 

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

99 return errors 

100 

101 

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

103 """Validate template-branch field.""" 

104 errors = [] 

105 if "template-branch" in config: 

106 branch = config["template-branch"] 

107 if not isinstance(branch, str): 

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

109 elif not branch: 

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

111 return errors 

112 

113 

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

115 """Validate include field.""" 

116 errors = [] 

117 if "include" in config: 

118 include = config["include"] 

119 if not isinstance(include, list): 

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

121 elif not include: 

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

123 return errors 

124 

125 

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

127 """Validate templates field.""" 

128 errors = [] 

129 if "templates" in config: 

130 templates = config["templates"] 

131 if not isinstance(templates, list): 

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

133 elif not templates: 

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

135 return errors 

136 

137 

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

139 """Validate exclude field.""" 

140 errors = [] 

141 if "exclude" in config: 

142 exclude = config["exclude"] 

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

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

145 return errors 

146 

147 

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

149 """Validate a rhiza configuration file. 

150 

151 Args: 

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

153 

154 Returns: 

155 List of error messages (empty if valid) 

156 """ 

157 # Load configuration 

158 raw_config = _load_config(filepath) 

159 if isinstance(raw_config, list): 

160 return raw_config 

161 

162 # Validate unknown keys on raw config (before normalization) 

163 errors = [] 

164 errors.extend(_validate_unknown_keys(raw_config)) 

165 

166 # Normalize aliases for subsequent validation 

167 config = _normalize_config(raw_config) 

168 

169 # Validate all aspects 

170 errors.extend(_validate_required_keys(config)) 

171 errors.extend(_validate_include_or_templates(config)) 

172 errors.extend(_validate_template_repository(config)) 

173 errors.extend(_validate_template_branch(config)) 

174 errors.extend(_validate_include_field(config)) 

175 errors.extend(_validate_templates_field(config)) 

176 errors.extend(_validate_exclude_field(config)) 

177 

178 return errors 

179 

180 

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

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

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

184 parser.add_argument( 

185 "filenames", 

186 nargs="*", 

187 help="Filenames to check", 

188 ) 

189 args = parser.parse_args(argv) 

190 

191 retval = 0 

192 for filename in args.filenames: 

193 filepath = Path(filename) 

194 errors = validate_rhiza_config(filepath) 

195 if errors: 

196 print(f"{filename}:") 

197 for error in errors: 

198 print(f" - {error}") 

199 retval = 1 

200 

201 return retval 

202 

203 

204if __name__ == "__main__": 

205 sys.exit(main())