Coverage for src / rhiza_hooks / check_template_bundles.py: 100%
229 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"""Validate template-bundles.yml structure and consistency.
4This script validates the template bundles configuration file to ensure:
51. Valid YAML syntax
62. Required fields are present
73. Bundle dependencies reference existing bundles
84. File paths follow expected patterns
95. Examples reference valid bundles
11The script reads .rhiza/template.yml to find the template repository,
12then fetches template-bundles.yml from that remote repository.
14Exit codes:
15 0 - Validation passed
16 1 - Validation failed
17"""
19from __future__ import annotations
21import argparse
22import io
23import sys
24from pathlib import Path
25from typing import Any, cast
26from urllib.error import HTTPError, URLError
27from urllib.parse import urlparse
28from urllib.request import urlopen
30import yaml
33def _load_yaml_file(bundles_path: Path) -> tuple[bool, dict[Any, Any] | list[str]]:
34 """Load and parse YAML file.
36 Returns:
37 Tuple of (success, data_or_errors)
38 """
39 if not bundles_path.exists():
40 return False, [f"Template bundles file not found: {bundles_path}"]
42 try:
43 with open(bundles_path) as f:
44 data = yaml.safe_load(f)
45 except yaml.YAMLError as e:
46 return False, [f"Invalid YAML: {e}"]
48 if data is None:
49 return False, ["Template bundles file is empty"]
51 return True, data
54def _validate_top_level_fields(data: dict[Any, Any]) -> list[str]:
55 """Validate required top-level fields."""
56 errors = []
57 required_fields = {"version", "bundles"}
58 for field in required_fields:
59 if field not in data:
60 errors.append(f"Missing required field: {field}")
61 return errors
64def _validate_bundle_structure(
65 bundle_name: str,
66 bundle_config: Any,
67 bundle_names: set[str],
68) -> list[str]:
69 """Validate a single bundle's structure and dependencies."""
70 errors = []
72 if not isinstance(bundle_config, dict):
73 errors.append(f"Bundle '{bundle_name}' must be a dictionary")
74 return errors
76 # Check required fields
77 if "description" not in bundle_config:
78 errors.append(f"Bundle '{bundle_name}' missing 'description'")
80 if "files" not in bundle_config:
81 errors.append(f"Bundle '{bundle_name}' missing 'files'")
82 elif not isinstance(bundle_config["files"], list):
83 errors.append(f"Bundle '{bundle_name}' 'files' must be a list")
85 # Validate dependencies
86 if "requires" in bundle_config:
87 if not isinstance(bundle_config["requires"], list):
88 errors.append(f"Bundle '{bundle_name}' 'requires' must be a list")
89 else:
90 for dep in bundle_config["requires"]:
91 if dep not in bundle_names:
92 errors.append(f"Bundle '{bundle_name}' requires non-existent bundle '{dep}'")
94 if "recommends" in bundle_config:
95 if not isinstance(bundle_config["recommends"], list):
96 errors.append(f"Bundle '{bundle_name}' 'recommends' must be a list")
97 else:
98 for dep in bundle_config["recommends"]:
99 if dep not in bundle_names:
100 errors.append(f"Bundle '{bundle_name}' recommends non-existent bundle '{dep}'")
102 return errors
105def _validate_examples(examples: Any, bundle_names: set[str]) -> list[str]:
106 """Validate examples section."""
107 errors = []
109 if not isinstance(examples, dict):
110 errors.append("'examples' must be a dictionary")
111 return errors
113 for example_name, example_config in examples.items():
114 if "templates" in example_config:
115 if not isinstance(example_config["templates"], list):
116 errors.append(f"Example '{example_name}' 'templates' must be a list")
117 else:
118 for template in example_config["templates"]:
119 # core is auto-included, we don't validate it
120 if template != "core" and template not in bundle_names:
121 errors.append(f"Example '{example_name}' references non-existent bundle '{template}'")
123 return errors
126def _validate_metadata(metadata: dict[Any, Any], bundles: dict[Any, Any]) -> list[str]:
127 """Validate metadata section."""
128 errors = []
130 if "total_bundles" in metadata:
131 expected_count = len(bundles)
132 actual_count = metadata["total_bundles"]
133 if actual_count != expected_count:
134 errors.append(
135 f"Metadata 'total_bundles' ({actual_count}) doesn't match actual bundle count ({expected_count})"
136 )
138 return errors
141def find_repo_root() -> Path:
142 """Find the repository root directory.
144 Returns:
145 Path to the repository root
146 """
147 current = Path.cwd()
148 while current != current.parent:
149 if (current / ".git").exists():
150 return current
151 current = current.parent
152 return Path.cwd()
155def _get_config_data(config_path: Path) -> dict[str, Any] | None:
156 """Get the configuration from .rhiza/template.yml.
158 Args:
159 config_path: Path to .rhiza/template.yml
161 Returns:
162 Configuration dictionary, or None if file not found or invalid
163 """
164 if not config_path.exists():
165 return None
167 try:
168 with open(config_path) as f:
169 config = yaml.safe_load(f)
170 except yaml.YAMLError:
171 return None
173 if not isinstance(config, dict):
174 return None
176 return config
179def _get_templates_from_config(config_path: Path) -> set[str] | None:
180 """Get the list of templates from .rhiza/template.yml.
182 Args:
183 config_path: Path to .rhiza/template.yml
185 Returns:
186 Set of template names, or None if templates field doesn't exist or file not found
187 """
188 config = _get_config_data(config_path)
189 if config is None:
190 return None
192 templates = config.get("templates")
193 if templates is None:
194 return None
196 if not isinstance(templates, list):
197 return None
199 return set(templates)
202def _fetch_remote_bundles(repo: str, branch: str) -> tuple[bool, dict[Any, Any] | list[str]]:
203 """Fetch template-bundles.yml from a remote GitHub repository.
205 Args:
206 repo: GitHub repository in 'owner/repo' format
207 branch: Branch name
209 Returns:
210 Tuple of (success, data_or_errors)
211 """
212 # Construct GitHub raw content URL
213 url = f"https://raw.githubusercontent.com/{repo}/{branch}/.rhiza/template-bundles.yml"
215 # Validate URL scheme for security (bandit B310)
216 parsed = urlparse(url)
217 if parsed.scheme != "https":
218 return False, [f"Invalid URL scheme: {parsed.scheme}. Only https is allowed."]
220 try:
221 with urlopen(url, timeout=10) as response: # noqa: S310 # nosec B310
222 content = response.read()
223 except HTTPError as e:
224 if e.code == 404:
225 return False, [f"Template bundles file not found in repository {repo} (branch: {branch})"]
226 return False, [f"HTTP error fetching template bundles: {e.code} {e.reason}"]
227 except URLError as e:
228 return False, [f"Error fetching template bundles from {url}: {e.reason}"]
229 except TimeoutError:
230 return False, [f"Timeout fetching template bundles from {url}"]
232 try:
233 data = yaml.safe_load(content)
234 except yaml.YAMLError as e:
235 return False, [f"Invalid YAML in remote template bundles: {e}"]
237 if data is None:
238 return False, ["Remote template bundles file is empty"]
240 if not isinstance(data, dict):
241 return False, ["Remote template bundles must be a dictionary"]
243 return True, data
246def validate_template_bundles(bundles_path: Path, templates_to_check: set[str] | None = None) -> tuple[bool, list[str]]:
247 """Validate template bundles configuration.
249 Args:
250 bundles_path: Path to template-bundles.yml
251 templates_to_check: Optional set of template names to validate. If None, validate all.
253 Returns:
254 Tuple of (success, error_messages)
255 """
256 # Load YAML file
257 success, data_or_errors = _load_yaml_file(bundles_path)
258 if not success:
259 # Type narrowing: when success is False, data_or_errors is list[str]
260 return False, cast(list[str], data_or_errors)
262 # Type narrowing: when success is True, data_or_errors is dict[Any, Any]
263 data = cast(dict[Any, Any], data_or_errors)
265 # Validate top-level fields
266 errors = _validate_top_level_fields(data)
267 if errors:
268 return False, errors
270 # Validate bundles section
271 bundles = data.get("bundles", {})
272 if not isinstance(bundles, dict):
273 return False, ["'bundles' must be a dictionary"]
275 bundle_names = set(bundles.keys())
277 # If templates_to_check is specified, verify they exist
278 if templates_to_check is not None:
279 for template in templates_to_check:
280 if template not in bundle_names:
281 errors.append(f"Template '{template}' specified in .rhiza/template.yml not found in bundles")
283 # Determine which bundles to validate
284 bundles_to_validate = templates_to_check if templates_to_check is not None else bundle_names
286 # Validate each bundle
287 for bundle_name in bundles_to_validate:
288 if bundle_name in bundles:
289 bundle_config = bundles[bundle_name]
290 errors.extend(_validate_bundle_structure(bundle_name, bundle_config, bundle_names))
292 # Validate examples section (only if validating all bundles)
293 if templates_to_check is None and "examples" in data:
294 errors.extend(_validate_examples(data["examples"], bundle_names))
296 # Validate metadata if present (only if validating all bundles)
297 if templates_to_check is None and "metadata" in data:
298 errors.extend(_validate_metadata(data["metadata"], bundles))
300 return len(errors) == 0, errors
303def _get_config_path(args: argparse.Namespace) -> Path:
304 """Get the configuration file path from arguments or default location."""
305 if args.filenames:
306 return Path(args.filenames[0])
307 return find_repo_root() / ".rhiza" / "template.yml"
310def _load_and_validate_config(config_path: Path) -> tuple[dict[str, Any] | None, set[str] | None]:
311 """Load and validate configuration file.
313 Returns:
314 Tuple of (config, templates_set) or (None, None) if validation fails
315 """
316 config = _get_config_data(config_path)
317 if config is None:
318 print(f"Could not load configuration from {config_path}, skipping validation")
319 return None, None
321 templates_to_check = config.get("templates")
322 if templates_to_check is None or not isinstance(templates_to_check, list):
323 print(f"No templates field in {config_path}, skipping bundle validation")
324 return None, None
326 return config, set(templates_to_check)
329def _validate_remote_bundles(
330 template_repo: str, template_branch: str, templates_set: set[str], config_path: Path
331) -> tuple[dict[Any, Any] | None, list[str]]:
332 """Fetch and validate remote bundles.
334 Returns:
335 Tuple of (bundles_data, errors) or (None, errors) if fetch fails
336 """
337 print(f"Fetching template bundles from {template_repo} (branch: {template_branch})")
338 print(f"Checking templates: {', '.join(sorted(templates_set))}")
340 success, data_or_errors = _fetch_remote_bundles(template_repo, template_branch)
341 if not success:
342 print("\n✗ Failed to fetch template bundles:")
343 errors = cast(list[str], data_or_errors)
344 for error in errors:
345 print(f" - {error}")
346 return None, errors
348 data = cast(dict[Any, Any], data_or_errors)
350 # Validate top-level structure
351 errors = _validate_top_level_fields(data)
352 if errors:
353 print("\n✗ Template bundles validation failed:")
354 for error in errors:
355 print(f" - {error}")
356 return None, errors
358 bundles = data.get("bundles", {})
359 if not isinstance(bundles, dict):
360 print("\n✗ Template bundles validation failed:")
361 print(" - 'bundles' must be a dictionary")
362 return None, ["'bundles' must be a dictionary"]
364 return data, []
367def _validate_templates_in_bundles(templates_set: set[str], bundles: dict[Any, Any], config_path: Path) -> list[str]:
368 """Validate that requested templates exist and have valid structure."""
369 errors = []
370 bundle_names = set(bundles.keys())
372 # Check if templates exist
373 for template in templates_set:
374 if template not in bundle_names:
375 errors.append(f"Template '{template}' specified in {config_path} not found in remote bundles")
377 # Validate structure of each template
378 for template in templates_set:
379 if template in bundles:
380 bundle_config = bundles[template]
381 errors.extend(_validate_bundle_structure(template, bundle_config, bundle_names))
383 return errors
386def main(argv: list[str] | None = None) -> int:
387 """Main entry point."""
388 if isinstance(sys.stdout, io.TextIOWrapper):
389 sys.stdout.reconfigure(encoding="utf-8", errors="replace")
390 parser = argparse.ArgumentParser(description="Validate template-bundles.yml from remote template repository")
391 parser.add_argument(
392 "filenames",
393 nargs="*",
394 help="Filenames to check (should be .rhiza/template.yml)",
395 )
396 args = parser.parse_args(argv)
398 # Get configuration path
399 config_path = _get_config_path(args)
401 # Load and validate configuration
402 config, templates_set = _load_and_validate_config(config_path)
403 if config is None or templates_set is None:
404 return 0
406 # Get template repository and branch
407 template_repo = config.get("template-repository")
408 template_branch = config.get("template-branch")
410 if not template_repo or not template_branch:
411 print(f"Missing template-repository or template-branch in {config_path}")
412 return 1
414 # Fetch and validate remote bundles
415 data, _fetch_errors = _validate_remote_bundles(template_repo, template_branch, templates_set, config_path)
416 if data is None:
417 return 1
419 # Validate templates
420 bundles = data.get("bundles", {})
421 errors = _validate_templates_in_bundles(templates_set, bundles, config_path)
423 if errors:
424 print("\n✗ Template bundles validation failed:")
425 for error in errors:
426 print(f" - {error}")
427 return 1
429 print("✓ Template bundles validation passed!")
430 return 0
433if __name__ == "__main__":
434 sys.exit(main())