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
« 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."""
4from __future__ import annotations
6import argparse
7import sys
8from dataclasses import dataclass
9from pathlib import Path
10from typing import Any
12from rhiza_hooks._config_schema import KEY_ALIASES, REQUIRED_KEYS, VALID_KEYS, normalize_config
13from rhiza_hooks._yaml import YamlError, YamlFailure, load_yaml_mapping
16def _load_config(filepath: Path) -> dict[str, Any] | list[str]:
17 """Load configuration from YAML file.
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
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]]
35def _validate_required_keys(config: dict[str, Any]) -> list[str]:
36 """Validate required keys are present.
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']
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
53def _validate_unknown_keys(config: dict[str, Any]) -> list[str]:
54 """Check for unknown keys.
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']
61 Alias spellings are accepted here as well as canonical ones, so validation
62 does not depend on having normalized first:
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
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 []
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
95@dataclass(frozen=True)
96class _FieldRule:
97 """Declarative rule for a presence-conditional field.
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 """
105 key: str
106 types: tuple[type, ...]
107 type_error: str
108 empty_error: str | None = None
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)
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 []
137def validate_rhiza_config(filepath: Path) -> list[str]:
138 """Validate a rhiza configuration file.
140 Args:
141 filepath: Path to the .rhiza/template.yml file
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
151 # Validate unknown keys on raw config (before normalization)
152 errors = []
153 errors.extend(_validate_unknown_keys(raw_config))
155 # Normalize aliases for subsequent validation
156 config = normalize_config(raw_config)
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))
165 return errors
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)
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
188 return retval
191if __name__ == "__main__": # pragma: no mutate
192 sys.exit(main())