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

119 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-02 06:48 +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} 

20 

21 

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

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

24 

25 Args: 

26 config: Raw configuration dictionary 

27 

28 Returns: 

29 Normalized configuration with aliases replaced 

30 """ 

31 normalized = {} 

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

33 # Replace alias with canonical name if it exists 

34 canonical_key = KEY_ALIASES.get(key, key) 

35 normalized[canonical_key] = value 

36 return normalized 

37 

38 

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

40 """Load configuration from YAML file. 

41 

42 Returns: 

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

44 """ 

45 try: 

46 with filepath.open() as f: 

47 config = yaml.safe_load(f) 

48 except yaml.YAMLError as e: 

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

50 except FileNotFoundError: 

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

52 

53 if config is None: 

54 return ["Configuration file is empty"] 

55 

56 if not isinstance(config, dict): 

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

58 

59 return config 

60 

61 

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

63 """Validate required keys are present.""" 

64 errors = [] 

65 for key in REQUIRED_KEYS: 

66 if key not in config: 

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

68 return errors 

69 

70 

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

72 """Check for unknown keys.""" 

73 errors = [] 

74 # Accept both canonical keys and their aliases 

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

76 for key in config: 

77 if key not in all_valid_keys: 

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

79 return errors 

80 

81 

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

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

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

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

86 return [] 

87 

88 

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

90 """Validate template-repository field.""" 

91 errors = [] 

92 if "template-repository" in config: 

93 repo = config["template-repository"] 

94 if not isinstance(repo, str): 

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

96 elif "/" not in repo: 

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

98 return errors 

99 

100 

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

102 """Validate template-branch field.""" 

103 errors = [] 

104 if "template-branch" in config: 

105 branch = config["template-branch"] 

106 if not isinstance(branch, str): 

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

108 elif not branch: 

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

110 return errors 

111 

112 

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

114 """Validate include field.""" 

115 errors = [] 

116 if "include" in config: 

117 include = config["include"] 

118 if not isinstance(include, list): 

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

120 elif not include: 

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

122 return errors 

123 

124 

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

126 """Validate templates field.""" 

127 errors = [] 

128 if "templates" in config: 

129 templates = config["templates"] 

130 if not isinstance(templates, list): 

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

132 elif not templates: 

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

134 return errors 

135 

136 

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

138 """Validate exclude field.""" 

139 errors = [] 

140 if "exclude" in config: 

141 exclude = config["exclude"] 

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

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

144 return errors 

145 

146 

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

148 """Validate a rhiza configuration file. 

149 

150 Args: 

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

152 

153 Returns: 

154 List of error messages (empty if valid) 

155 """ 

156 # Load configuration 

157 raw_config = _load_config(filepath) 

158 if isinstance(raw_config, list): 

159 return raw_config 

160 

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

162 errors = [] 

163 errors.extend(_validate_unknown_keys(raw_config)) 

164 

165 # Normalize aliases for subsequent validation 

166 config = _normalize_config(raw_config) 

167 

168 # Validate all aspects 

169 errors.extend(_validate_required_keys(config)) 

170 errors.extend(_validate_include_or_templates(config)) 

171 errors.extend(_validate_template_repository(config)) 

172 errors.extend(_validate_template_branch(config)) 

173 errors.extend(_validate_include_field(config)) 

174 errors.extend(_validate_templates_field(config)) 

175 errors.extend(_validate_exclude_field(config)) 

176 

177 return errors 

178 

179 

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

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

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

183 parser.add_argument( 

184 "filenames", 

185 nargs="*", 

186 help="Filenames to check", 

187 ) 

188 args = parser.parse_args(argv) 

189 

190 retval = 0 

191 for filename in args.filenames: 

192 filepath = Path(filename) 

193 errors = validate_rhiza_config(filepath) 

194 if errors: 

195 print(f"{filename}:") 

196 for error in errors: 

197 print(f" - {error}") 

198 retval = 1 

199 

200 return retval 

201 

202 

203if __name__ == "__main__": 

204 sys.exit(main())