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
« 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."""
4from __future__ import annotations
6import argparse
7import sys
8from pathlib import Path
10import yaml
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}
22def _normalize_config(config: dict) -> dict:
23 """Normalize configuration by replacing aliases with canonical keys.
25 Args:
26 config: Raw configuration dictionary
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
39def _load_config(filepath: Path) -> dict | list[str]:
40 """Load configuration from YAML file.
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}"]
53 if config is None:
54 return ["Configuration file is empty"]
56 if not isinstance(config, dict):
57 return ["Configuration must be a YAML mapping"]
59 return config
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
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
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 []
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
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
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
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
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
147def validate_rhiza_config(filepath: Path) -> list[str]:
148 """Validate a rhiza configuration file.
150 Args:
151 filepath: Path to the .rhiza/template.yml file
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
161 # Validate unknown keys on raw config (before normalization)
162 errors = []
163 errors.extend(_validate_unknown_keys(raw_config))
165 # Normalize aliases for subsequent validation
166 config = _normalize_config(raw_config)
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))
177 return errors
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)
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
200 return retval
203if __name__ == "__main__":
204 sys.exit(main())