Coverage for src/rhiza_hooks/_bundles_validate.py: 100%
103 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-30 04:36 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-30 04:36 +0000
1#!/usr/bin/env python3
2"""Structural validation of a template-bundles document.
4These functions operate on an already-loaded mapping (see
5:mod:`rhiza_hooks._bundles_fetch` for how the document is obtained) and report
6problems as lists of human-readable error strings. They never perform I/O,
7except :func:`validate_template_bundles`, which is a thin convenience wrapper
8that loads a local file and then validates it.
9"""
11from __future__ import annotations
13from collections.abc import Callable
14from pathlib import Path
15from typing import Any
17from rhiza_hooks._bundles_fetch import load_local_bundles
20def _not_a_known_bundle(value: Any, bundle_names: set[Any]) -> bool:
21 """Return True if ``value`` is not a declared bundle name.
23 A plain ``value not in bundle_names`` raises ``TypeError`` when ``value`` is
24 unhashable (e.g. a list or dict produced by malformed YAML). Such a value
25 can never be a bundle name, so it is reported as unknown rather than crashing.
26 """
27 try:
28 return value not in bundle_names
29 except TypeError:
30 return True
33def _validate_top_level_fields(data: dict[Any, Any]) -> list[str]:
34 """Validate required top-level fields."""
35 errors = []
36 required_fields = {"version", "bundles"}
37 for field in required_fields:
38 if field not in data:
39 errors.append(f"Missing required field: {field}")
40 return errors
43def _validate_dep_list(bundle_name: str, field: str, deps: Any, bundle_names: set[str]) -> list[str]:
44 """Validate one dependency list (``requires`` / ``recommends``) of a bundle.
46 The ``field`` name doubles as the verb in the "non-existent bundle" message
47 (e.g. ``requires``/``recommends``), so the two call sites share this logic.
48 """
49 if not isinstance(deps, list):
50 return [f"Bundle '{bundle_name}' '{field}' must be a list"]
51 return [
52 f"Bundle '{bundle_name}' {field} non-existent bundle '{dep}'"
53 for dep in deps
54 if _not_a_known_bundle(dep, bundle_names)
55 ]
58def _validate_required_bundle_fields(bundle_name: str, bundle_config: dict[Any, Any]) -> list[str]:
59 """Validate a bundle's required fields: a ``description`` and a list ``files``."""
60 errors = []
61 if "description" not in bundle_config:
62 errors.append(f"Bundle '{bundle_name}' missing 'description'")
63 if "files" not in bundle_config:
64 errors.append(f"Bundle '{bundle_name}' missing 'files'")
65 elif not isinstance(bundle_config["files"], list):
66 errors.append(f"Bundle '{bundle_name}' 'files' must be a list")
67 return errors
70def _validate_bundle_structure(
71 bundle_name: str,
72 bundle_config: Any,
73 bundle_names: set[str],
74) -> list[str]:
75 """Validate a single bundle's structure and dependencies."""
76 if not isinstance(bundle_config, dict):
77 return [f"Bundle '{bundle_name}' must be a dictionary"]
79 errors = _validate_required_bundle_fields(bundle_name, bundle_config)
81 # Validate dependencies
82 for field in ("requires", "recommends"):
83 if field in bundle_config:
84 errors.extend(_validate_dep_list(bundle_name, field, bundle_config[field], bundle_names))
86 return errors
89def _is_unknown_example_template(template: Any, bundle_names: set[str]) -> bool:
90 """True if an example template is neither ``core`` (auto-included) nor a known bundle."""
91 return template != "core" and _not_a_known_bundle(template, bundle_names)
94def _validate_example_templates(example_name: str, templates: Any, bundle_names: set[str]) -> list[str]:
95 """Validate one example's ``templates`` value: a list of known bundle names."""
96 if not isinstance(templates, list):
97 return [f"Example '{example_name}' 'templates' must be a list"]
98 return [
99 f"Example '{example_name}' references non-existent bundle '{template}'"
100 for template in templates
101 if _is_unknown_example_template(template, bundle_names)
102 ]
105def _validate_example(example_name: str, example_config: Any, bundle_names: set[str]) -> list[str]:
106 """Validate a single example entry."""
107 if not isinstance(example_config, dict):
108 return [f"Example '{example_name}' must be a dictionary"]
109 if "templates" not in example_config:
110 return []
111 return _validate_example_templates(example_name, example_config["templates"], bundle_names)
114def _validate_examples(examples: Any, bundle_names: set[str]) -> list[str]:
115 """Validate examples section."""
116 if not isinstance(examples, dict):
117 return ["'examples' must be a dictionary"]
119 errors = []
120 for example_name, example_config in examples.items():
121 errors.extend(_validate_example(example_name, example_config, bundle_names))
122 return errors
125def _validate_metadata(metadata: Any, bundles: dict[Any, Any]) -> list[str]:
126 """Validate metadata section."""
127 errors = []
129 if not isinstance(metadata, dict):
130 errors.append("'metadata' must be a dictionary")
131 return errors
133 if "total_bundles" in metadata:
134 expected_count = len(bundles)
135 actual_count = metadata["total_bundles"]
136 if actual_count != expected_count:
137 errors.append(
138 f"Metadata 'total_bundles' ({actual_count}) doesn't match actual bundle count ({expected_count})"
139 )
141 return errors
144def _validate_selected_bundles(
145 templates: set[str],
146 bundles: dict[Any, Any],
147 missing_message: Callable[[str], str],
148) -> list[str]:
149 """Validate that each requested template exists in ``bundles`` and is well-formed.
151 Shared by the local-file and remote-fetch paths; they differ only in the
152 "not found" wording, supplied via ``missing_message``.
154 Args:
155 templates: Template names requested in the rhiza config.
156 bundles: The ``bundles`` mapping from a template-bundles document.
157 missing_message: Builds the error string for a template absent from ``bundles``.
159 Returns:
160 List of error messages (empty if every requested template is valid).
161 """
162 errors: list[str] = []
163 bundle_names: set[str] = set(bundles.keys())
165 for template in templates:
166 if template not in bundle_names:
167 errors.append(missing_message(template))
169 for template in templates:
170 if template in bundles:
171 errors.extend(_validate_bundle_structure(template, bundles[template], bundle_names))
173 return errors
176def validate_template_bundles(bundles_path: Path, templates_to_check: set[str] | None = None) -> tuple[bool, list[str]]:
177 """Validate template bundles configuration.
179 Args:
180 bundles_path: Path to template-bundles.yml
181 templates_to_check: Optional set of template names to validate. If None, validate all.
183 Returns:
184 Tuple of (success, error_messages)
185 """
186 # Load YAML file
187 loaded = load_local_bundles(bundles_path)
188 if loaded.data is None:
189 return False, loaded.errors
190 # data is narrowed to dict[Any, Any] by the `is None` guard above.
191 data = loaded.data
193 # Validate top-level fields
194 errors = _validate_top_level_fields(data)
195 if errors:
196 return False, errors
198 # Validate bundles section
199 bundles = data.get("bundles", {})
200 if not isinstance(bundles, dict):
201 return False, ["'bundles' must be a dictionary"]
203 if templates_to_check is not None:
204 # Validate only the requested subset (existence + structure).
205 errors.extend(
206 _validate_selected_bundles(
207 templates_to_check,
208 bundles,
209 lambda t: f"Template '{t}' specified in .rhiza/template.yml not found in bundles",
210 )
211 )
212 else:
213 errors.extend(_validate_all_bundles(data, bundles))
215 return len(errors) == 0, errors
218def _validate_all_bundles(data: dict[Any, Any], bundles: dict[Any, Any]) -> list[str]:
219 """Validate every declared bundle, plus the examples and metadata sections."""
220 bundle_names: set[str] = {str(k) for k in bundles}
222 errors: list[str] = []
223 for bundle_name in bundle_names:
224 errors.extend(_validate_bundle_structure(bundle_name, bundles[bundle_name], bundle_names))
225 if "examples" in data:
226 errors.extend(_validate_examples(data["examples"], bundle_names))
227 if "metadata" in data:
228 errors.extend(_validate_metadata(data["metadata"], bundles))
229 return errors