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
« 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."""
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 "profiles": "templates",
20}
23def _normalize_config(config: dict) -> dict:
24 """Normalize configuration by replacing aliases with canonical keys.
26 Args:
27 config: Raw configuration dictionary
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
40def _load_config(filepath: Path) -> dict | list[str]:
41 """Load configuration from YAML file.
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}"]
54 if config is None:
55 return ["Configuration file is empty"]
57 if not isinstance(config, dict):
58 return ["Configuration must be a YAML mapping"]
60 return config
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
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
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 []
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
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
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
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
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
148def validate_rhiza_config(filepath: Path) -> list[str]:
149 """Validate a rhiza configuration file.
151 Args:
152 filepath: Path to the .rhiza/template.yml file
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
162 # Validate unknown keys on raw config (before normalization)
163 errors = []
164 errors.extend(_validate_unknown_keys(raw_config))
166 # Normalize aliases for subsequent validation
167 config = _normalize_config(raw_config)
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))
178 return errors
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)
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
201 return retval
204if __name__ == "__main__":
205 sys.exit(main())