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

85 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 .rhiza/template.yml is valid and well-formed.""" 

3 

4from __future__ import annotations 

5 

6import argparse 

7import sys 

8from dataclasses import dataclass 

9from pathlib import Path 

10from typing import Any 

11 

12from rhiza_hooks._config_schema import KEY_ALIASES, REQUIRED_KEYS, VALID_KEYS, normalize_config 

13from rhiza_hooks._yaml import YamlError, YamlFailure, load_yaml_mapping 

14 

15 

16def _load_config(filepath: Path) -> dict[str, Any] | list[str]: 

17 """Load configuration from YAML file. 

18 

19 Returns: 

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

21 """ 

22 result = load_yaml_mapping(filepath) 

23 if not isinstance(result, YamlFailure): 

24 return result 

25 

26 messages = { 

27 YamlError.NOT_FOUND: f"File not found: {filepath}", 

28 YamlError.INVALID: f"Invalid YAML: {result.detail}", 

29 YamlError.EMPTY: "Configuration file is empty", 

30 YamlError.NOT_MAPPING: "Configuration must be a YAML mapping", 

31 } 

32 return [messages[result.kind]] 

33 

34 

35def _validate_required_keys(config: dict[str, Any]) -> list[str]: 

36 """Validate required keys are present. 

37 

38 >>> _validate_required_keys({"template-repository": "jebel-quant/rhiza", "template-branch": "v1.3.3"}) 

39 [] 

40 >>> _validate_required_keys({"template-repository": "jebel-quant/rhiza"}) 

41 ['Missing required key: template-branch'] 

42 

43 Aliases are resolved before this runs (see :func:`_config_schema.normalize_config`), 

44 so a config spelling the key ``ref:`` reaches here already canonical. 

45 """ 

46 errors = [] 

47 for key in REQUIRED_KEYS: 

48 if key not in config: 

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

50 return errors 

51 

52 

53def _validate_unknown_keys(config: dict[str, Any]) -> list[str]: 

54 """Check for unknown keys. 

55 

56 >>> _validate_unknown_keys({"template-repository": "o/r", "template-branch": "v1"}) 

57 [] 

58 >>> _validate_unknown_keys({"template-repository": "o/r", "tempalte-branch": "v1"}) 

59 ['Unknown key: tempalte-branch'] 

60 

61 Alias spellings are accepted here as well as canonical ones, so validation 

62 does not depend on having normalized first: 

63 

64 >>> _validate_unknown_keys({"repository": "o/r", "ref": "v1", "profiles": ["core"]}) 

65 [] 

66 """ 

67 errors = [] 

68 # Accept both canonical keys and their aliases 

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

70 for key in config: 

71 if key not in all_valid_keys: 

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

73 return errors 

74 

75 

76def _validate_include_or_templates(config: dict[str, Any]) -> list[str]: 

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

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

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

80 return [] 

81 

82 

83def _validate_template_repository(config: dict[str, Any]) -> list[str]: 

84 """Validate template-repository field.""" 

85 errors = [] 

86 if "template-repository" in config: 

87 repo = config["template-repository"] 

88 if not isinstance(repo, str): 

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

90 elif "/" not in repo: 

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

92 return errors 

93 

94 

95@dataclass(frozen=True) 

96class _FieldRule: 

97 """Declarative rule for a presence-conditional field. 

98 

99 When the field is present its value must be an instance of one of ``types``, 

100 otherwise ``type_error`` is reported. If ``empty_error`` is set, a falsy value 

101 (empty list, empty string) is rejected with that message. ``exclude`` permits 

102 ``None`` by including ``type(None)`` in its ``types``. 

103 """ 

104 

105 key: str 

106 types: tuple[type, ...] 

107 type_error: str 

108 empty_error: str | None = None 

109 

110 

111# Per-field rules sharing the "optional, type-checked, optionally non-empty" shape. 

112# template-repository is validated separately because it needs an owner/repo format check. 

113_FIELD_RULES: tuple[_FieldRule, ...] = ( 

114 _FieldRule("template-branch", (str,), "template-branch must be a string", "template-branch cannot be empty"), 

115 _FieldRule("include", (list,), "include must be a list", "include list cannot be empty"), 

116 _FieldRule("templates", (list,), "templates must be a list", "templates list cannot be empty"), 

117 _FieldRule("exclude", (list, type(None)), "exclude must be a list or null"), 

118 # Type-checked but not enumerated: see the note on OPTIONAL_KEYS in _config_schema. 

119 # This catches `language: [go]` and an empty value; it does not police the spelling, 

120 # because this package is pinned and upstream may add a layer at any time. 

121 _FieldRule("language", (str,), "language must be a string", "language cannot be empty"), 

122) 

123 

124 

125def _validate_field(config: dict[str, Any], rule: _FieldRule) -> list[str]: 

126 """Validate a single field against its rule (a no-op when the field is absent).""" 

127 if rule.key not in config: 

128 return [] 

129 value = config[rule.key] 

130 if not isinstance(value, rule.types): 

131 return [rule.type_error] 

132 if rule.empty_error is not None and not value: 

133 return [rule.empty_error] 

134 return [] 

135 

136 

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

138 """Validate a rhiza configuration file. 

139 

140 Args: 

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

142 

143 Returns: 

144 List of error messages (empty if valid) 

145 """ 

146 # Load configuration 

147 raw_config = _load_config(filepath) 

148 if isinstance(raw_config, list): 

149 return raw_config 

150 

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

152 errors = [] 

153 errors.extend(_validate_unknown_keys(raw_config)) 

154 

155 # Normalize aliases for subsequent validation 

156 config = normalize_config(raw_config) 

157 

158 # Validate all aspects 

159 errors.extend(_validate_required_keys(config)) 

160 errors.extend(_validate_include_or_templates(config)) 

161 errors.extend(_validate_template_repository(config)) 

162 for rule in _FIELD_RULES: 

163 errors.extend(_validate_field(config, rule)) 

164 

165 return errors 

166 

167 

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

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

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

171 parser.add_argument( 

172 "filenames", 

173 nargs="*", 

174 help="Filenames to check", 

175 ) 

176 args = parser.parse_args(argv) 

177 

178 retval = 0 

179 for filename in args.filenames: 

180 filepath = Path(filename) 

181 errors = validate_rhiza_config(filepath) 

182 if errors: 

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

184 for error in errors: 

185 print(f" - {error}", file=sys.stderr) 

186 retval = 1 

187 

188 return retval 

189 

190 

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

192 sys.exit(main())