Coverage for src / rhiza_hooks / check_template_bundles.py: 100%
229 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-08 08:53 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-08 08:53 +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 sys
23from pathlib import Path
24from typing import Any
25from urllib.error import HTTPError, URLError
26from urllib.parse import urlparse
27from urllib.request import urlopen
29import yaml
32def _load_yaml_file(bundles_path: Path) -> tuple[bool, dict[Any, Any] | list[str]]:
33 """Load and parse YAML file.
35 Returns:
36 Tuple of (success, data_or_errors)
37 """
38 if not bundles_path.exists():
39 return False, [f"Template bundles file not found: {bundles_path}"]
41 try:
42 with open(bundles_path) as f:
43 data = yaml.safe_load(f)
44 except yaml.YAMLError as e:
45 return False, [f"Invalid YAML: {e}"]
47 if data is None:
48 return False, ["Template bundles file is empty"]
50 return True, data
53def _validate_top_level_fields(data: dict[Any, Any]) -> list[str]:
54 """Validate required top-level fields."""
55 errors = []
56 required_fields = {"version", "bundles"}
57 for field in required_fields:
58 if field not in data:
59 errors.append(f"Missing required field: {field}")
60 return errors
63def _validate_bundle_structure(
64 bundle_name: str,
65 bundle_config: dict[Any, Any] | object,
66 bundle_names: set[str],
67) -> list[str]:
68 """Validate a single bundle's structure and dependencies."""
69 errors = []
71 if not isinstance(bundle_config, dict):
72 errors.append(f"Bundle '{bundle_name}' must be a dictionary")
73 return errors
75 # Check required fields
76 if "description" not in bundle_config:
77 errors.append(f"Bundle '{bundle_name}' missing 'description'")
79 if "files" not in bundle_config:
80 errors.append(f"Bundle '{bundle_name}' missing 'files'")
81 elif not isinstance(bundle_config["files"], list):
82 errors.append(f"Bundle '{bundle_name}' 'files' must be a list")
84 # Validate dependencies
85 if "requires" in bundle_config:
86 if not isinstance(bundle_config["requires"], list):
87 errors.append(f"Bundle '{bundle_name}' 'requires' must be a list")
88 else:
89 for dep in bundle_config["requires"]:
90 if dep not in bundle_names:
91 errors.append(f"Bundle '{bundle_name}' requires non-existent bundle '{dep}'")
93 if "recommends" in bundle_config:
94 if not isinstance(bundle_config["recommends"], list):
95 errors.append(f"Bundle '{bundle_name}' 'recommends' must be a list")
96 else:
97 for dep in bundle_config["recommends"]:
98 if dep not in bundle_names:
99 errors.append(f"Bundle '{bundle_name}' recommends non-existent bundle '{dep}'")
101 return errors
104def _validate_examples(examples: dict[Any, Any] | object, bundle_names: set[str]) -> list[str]:
105 """Validate examples section."""
106 errors = []
108 if not isinstance(examples, dict):
109 errors.append("'examples' must be a dictionary")
110 return errors
112 for example_name, example_config in examples.items():
113 if "templates" in example_config:
114 if not isinstance(example_config["templates"], list):
115 errors.append(f"Example '{example_name}' 'templates' must be a list")
116 else:
117 for template in example_config["templates"]:
118 # core is auto-included, we don't validate it
119 if template != "core" and template not in bundle_names:
120 errors.append(f"Example '{example_name}' references non-existent bundle '{template}'")
122 return errors
125def _validate_metadata(metadata: dict[Any, Any], bundles: dict[Any, Any]) -> list[str]:
126 """Validate metadata section."""
127 errors = []
129 if "total_bundles" in metadata:
130 expected_count = len(bundles)
131 actual_count = metadata["total_bundles"]
132 if actual_count != expected_count:
133 errors.append(
134 f"Metadata 'total_bundles' ({actual_count}) doesn't match actual bundle count ({expected_count})"
135 )
137 return errors
140def find_repo_root() -> Path:
141 """Find the repository root directory.
143 Returns:
144 Path to the repository root
145 """
146 current = Path.cwd()
147 while current != current.parent:
148 if (current / ".git").exists():
149 return current
150 current = current.parent
151 return Path.cwd()
154def _get_config_data(config_path: Path) -> dict[str, Any] | None:
155 """Get the configuration from .rhiza/template.yml.
157 Args:
158 config_path: Path to .rhiza/template.yml
160 Returns:
161 Configuration dictionary, or None if file not found or invalid
162 """
163 if not config_path.exists():
164 return None
166 try:
167 with open(config_path) as f:
168 config = yaml.safe_load(f)
169 except yaml.YAMLError:
170 return None
172 if not isinstance(config, dict):
173 return None
175 return config
178def _get_templates_from_config(config_path: Path) -> set[str] | None:
179 """Get the list of templates from .rhiza/template.yml.
181 Args:
182 config_path: Path to .rhiza/template.yml
184 Returns:
185 Set of template names, or None if templates field doesn't exist or file not found
186 """
187 config = _get_config_data(config_path)
188 if config is None:
189 return None
191 templates = config.get("templates")
192 if templates is None:
193 return None
195 if not isinstance(templates, list):
196 return None
198 return set(templates)
201def _fetch_remote_bundles(repo: str, branch: str) -> tuple[bool, dict[Any, Any] | list[str]]:
202 """Fetch template-bundles.yml from a remote GitHub repository.
204 Args:
205 repo: GitHub repository in 'owner/repo' format
206 branch: Branch name
208 Returns:
209 Tuple of (success, data_or_errors)
210 """
211 # Construct GitHub raw content URL
212 url = f"https://raw.githubusercontent.com/{repo}/{branch}/.rhiza/template-bundles.yml"
214 # Validate URL scheme for security (bandit B310)
215 parsed = urlparse(url)
216 if parsed.scheme != "https":
217 return False, [f"Invalid URL scheme: {parsed.scheme}. Only https is allowed."]
219 try:
220 with urlopen(url, timeout=10) as response: # nosec B310
221 content = response.read()
222 except HTTPError as e:
223 if e.code == 404:
224 return False, [f"Template bundles file not found in repository {repo} (branch: {branch})"]
225 return False, [f"HTTP error fetching template bundles: {e.code} {e.reason}"]
226 except URLError as e:
227 return False, [f"Error fetching template bundles from {url}: {e.reason}"]
228 except TimeoutError:
229 return False, [f"Timeout fetching template bundles from {url}"]
231 try:
232 data = yaml.safe_load(content)
233 except yaml.YAMLError as e:
234 return False, [f"Invalid YAML in remote template bundles: {e}"]
236 if data is None:
237 return False, ["Remote template bundles file is empty"]
239 if not isinstance(data, dict):
240 return False, ["Remote template bundles must be a dictionary"]
242 return True, data
245def validate_template_bundles(bundles_path: Path, templates_to_check: set[str] | None = None) -> tuple[bool, list[str]]:
246 """Validate template bundles configuration.
248 Args:
249 bundles_path: Path to template-bundles.yml
250 templates_to_check: Optional set of template names to validate. If None, validate all.
252 Returns:
253 Tuple of (success, error_messages)
254 """
255 # Load YAML file
256 success, data_or_errors = _load_yaml_file(bundles_path)
257 if not success:
258 # Type narrowing: when success is False, data_or_errors is list[str]
259 assert isinstance(data_or_errors, list)
260 return False, data_or_errors
262 # Type narrowing: when success is True, data_or_errors is dict[Any, Any]
263 assert isinstance(data_or_errors, dict)
264 data = data_or_errors
266 # Validate top-level fields
267 errors = _validate_top_level_fields(data)
268 if errors:
269 return False, errors
271 # Validate bundles section
272 bundles = data.get("bundles", {})
273 if not isinstance(bundles, dict):
274 return False, ["'bundles' must be a dictionary"]
276 bundle_names = set(bundles.keys())
278 # If templates_to_check is specified, verify they exist
279 if templates_to_check is not None:
280 for template in templates_to_check:
281 if template not in bundle_names:
282 errors.append(f"Template '{template}' specified in .rhiza/template.yml not found in bundles")
284 # Determine which bundles to validate
285 bundles_to_validate = templates_to_check if templates_to_check is not None else bundle_names
287 # Validate each bundle
288 for bundle_name in bundles_to_validate:
289 if bundle_name in bundles:
290 bundle_config = bundles[bundle_name]
291 errors.extend(_validate_bundle_structure(bundle_name, bundle_config, bundle_names))
293 # Validate examples section (only if validating all bundles)
294 if templates_to_check is None and "examples" in data:
295 errors.extend(_validate_examples(data["examples"], bundle_names))
297 # Validate metadata if present (only if validating all bundles)
298 if templates_to_check is None and "metadata" in data:
299 errors.extend(_validate_metadata(data["metadata"], bundles))
301 return len(errors) == 0, errors
304def _get_config_path(args: argparse.Namespace) -> Path:
305 """Get the configuration file path from arguments or default location."""
306 if args.filenames:
307 return Path(args.filenames[0])
308 return find_repo_root() / ".rhiza" / "template.yml"
311def _load_and_validate_config(config_path: Path) -> tuple[dict[str, Any] | None, set[str] | None]:
312 """Load and validate configuration file.
314 Returns:
315 Tuple of (config, templates_set) or (None, None) if validation fails
316 """
317 config = _get_config_data(config_path)
318 if config is None:
319 print(f"Could not load configuration from {config_path}, skipping validation")
320 return None, None
322 templates_to_check = config.get("templates")
323 if templates_to_check is None or not isinstance(templates_to_check, list):
324 print(f"No templates field in {config_path}, skipping bundle validation")
325 return None, None
327 return config, set(templates_to_check)
330def _validate_remote_bundles(
331 template_repo: str, template_branch: str, templates_set: set[str], config_path: Path
332) -> tuple[dict[Any, Any] | None, list[str]]:
333 """Fetch and validate remote bundles.
335 Returns:
336 Tuple of (bundles_data, errors) or (None, errors) if fetch fails
337 """
338 print(f"Fetching template bundles from {template_repo} (branch: {template_branch})")
339 print(f"Checking templates: {', '.join(sorted(templates_set))}")
341 success, data_or_errors = _fetch_remote_bundles(template_repo, template_branch)
342 if not success:
343 print("\n✗ Failed to fetch template bundles:")
344 assert isinstance(data_or_errors, list)
345 for error in data_or_errors:
346 print(f" - {error}")
347 return None, data_or_errors
349 assert isinstance(data_or_errors, dict)
350 data = data_or_errors
352 # Validate top-level structure
353 errors = _validate_top_level_fields(data)
354 if errors:
355 print("\n✗ Template bundles validation failed:")
356 for error in errors:
357 print(f" - {error}")
358 return None, errors
360 bundles = data.get("bundles", {})
361 if not isinstance(bundles, dict):
362 print("\n✗ Template bundles validation failed:")
363 print(" - 'bundles' must be a dictionary")
364 return None, ["'bundles' must be a dictionary"]
366 return data, []
369def _validate_templates_in_bundles(templates_set: set[str], bundles: dict[Any, Any], config_path: Path) -> list[str]:
370 """Validate that requested templates exist and have valid structure."""
371 errors = []
372 bundle_names = set(bundles.keys())
374 # Check if templates exist
375 for template in templates_set:
376 if template not in bundle_names:
377 errors.append(f"Template '{template}' specified in {config_path} not found in remote bundles")
379 # Validate structure of each template
380 for template in templates_set:
381 if template in bundles:
382 bundle_config = bundles[template]
383 errors.extend(_validate_bundle_structure(template, bundle_config, bundle_names))
385 return errors
388def main(argv: list[str] | None = None) -> int:
389 """Main entry point."""
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())