Coverage for src/rhiza_hooks/check_template_bundles.py: 100%
93 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"""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.
14The implementation is split across focused modules — :mod:`rhiza_hooks._bundles_fetch`
15(obtaining a document), :mod:`rhiza_hooks._bundles_validate` (structural checks),
16and :mod:`rhiza_hooks._bundles_config` (reading ``.rhiza/template.yml``). This
17module is the CLI/orchestration layer and re-exports those helpers so
18``rhiza_hooks.check_template_bundles`` remains the single public import surface.
20Exit codes:
21 0 - Validation passed
22 1 - Validation failed
23"""
25from __future__ import annotations
27import argparse
28import io
29import sys
30from pathlib import Path
31from typing import Any
33from rhiza_hooks import _bundles_validate, _repo
34from rhiza_hooks._bundles_config import _get_config_data
35from rhiza_hooks._bundles_fetch import (
36 _FETCH_ATTEMPTS,
37 _FETCH_TIMEOUT_SECONDS,
38 _fetch_remote_bundles,
39)
42def _get_config_path(args: argparse.Namespace) -> Path:
43 """Get the configuration file path from arguments or default location."""
44 if args.filenames:
45 return Path(args.filenames[0])
46 return _repo.find_repo_root() / ".rhiza" / "template.yml"
49def _load_and_validate_config(config_path: Path) -> tuple[dict[str, Any], set[str]] | None:
50 """Load and validate configuration file.
52 Returns:
53 (config, templates_set) if validation succeeds, otherwise ``None``
54 """
55 config = _get_config_data(config_path)
56 if config is None:
57 print(f"Could not load configuration from {config_path}, skipping validation")
58 return None
60 templates_to_check = config.get("templates")
61 if templates_to_check is None or not isinstance(templates_to_check, list):
62 print(f"No templates field in {config_path}, skipping bundle validation")
63 return None
65 templates_set: set[str] = {str(t) for t in templates_to_check}
66 return config, templates_set
69def _report_errors(header: str, errors: list[str]) -> None:
70 """Print a failure ``header`` followed by each error as a bullet."""
71 print(header)
72 for error in errors:
73 print(f" - {error}")
76def _validate_remote_bundles(
77 template_repo: str,
78 template_branch: str,
79 templates_set: set[str],
80 attempts: int = _FETCH_ATTEMPTS,
81 timeout: float = _FETCH_TIMEOUT_SECONDS,
82) -> tuple[dict[Any, Any] | None, list[str]]:
83 """Fetch and validate remote bundles.
85 Returns:
86 Tuple of (bundles_data, errors) or (None, errors) if fetch fails
87 """
88 print(f"Fetching template bundles from {template_repo} (branch: {template_branch})")
89 print(f"Checking templates: {', '.join(sorted(templates_set))}")
91 fetched = _fetch_remote_bundles(template_repo, template_branch, attempts=attempts, timeout=timeout)
92 if fetched.data is None:
93 _report_errors("\n✗ Failed to fetch template bundles:", fetched.errors)
94 return None, fetched.errors
96 # data is narrowed to dict[Any, Any] by the `is None` guard above.
97 data = fetched.data
99 # Validate top-level structure
100 errors = _bundles_validate._validate_top_level_fields(data)
101 if errors:
102 _report_errors("\n✗ Template bundles validation failed:", errors)
103 return None, errors
105 bundles = data.get("bundles", {})
106 if not isinstance(bundles, dict):
107 errors = ["'bundles' must be a dictionary"]
108 _report_errors("\n✗ Template bundles validation failed:", errors)
109 return None, errors
111 return data, []
114def _validate_templates_in_bundles(templates_set: set[str], bundles: dict[Any, Any], config_path: Path) -> list[str]:
115 """Validate that requested templates exist in the remote bundles and are well-formed."""
116 return _bundles_validate._validate_selected_bundles(
117 templates_set,
118 bundles,
119 lambda t: f"Template '{t}' specified in {config_path} not found in remote bundles",
120 )
123def _parse_args(argv: list[str] | None) -> argparse.Namespace:
124 """Build the argument parser, then parse and validate ``argv``."""
125 parser = argparse.ArgumentParser(description="Validate template-bundles.yml from remote template repository")
126 parser.add_argument(
127 "filenames",
128 nargs="*",
129 help="Filenames to check (should be .rhiza/template.yml)",
130 )
131 parser.add_argument(
132 "--offline",
133 action="store_true",
134 help="Skip the remote bundles fetch (e.g. for offline commits) and pass",
135 )
136 parser.add_argument(
137 "--retries",
138 type=int,
139 default=_FETCH_ATTEMPTS - 1,
140 help="Number of retries for transient network failures, after the initial attempt (default: %(default)s)",
141 )
142 parser.add_argument(
143 "--timeout",
144 type=float,
145 default=_FETCH_TIMEOUT_SECONDS,
146 help="Per-request network timeout in seconds (default: %(default)s)",
147 )
148 args = parser.parse_args(argv)
150 if args.retries < 0:
151 parser.error("--retries must be non-negative")
152 if args.timeout <= 0:
153 parser.error("--timeout must be positive")
155 return args
158def _ensure_utf8_stdout() -> None:
159 """Reconfigure stdout to UTF-8 so the ✓/✗ status glyphs never crash a non-UTF-8 console."""
160 if isinstance(sys.stdout, io.TextIOWrapper):
161 sys.stdout.reconfigure(encoding="utf-8", errors="replace")
164def _run_remote_validation(
165 config: dict[str, Any],
166 templates_set: set[str],
167 config_path: Path,
168 retries: int,
169 timeout: float,
170) -> int:
171 """Fetch remote bundles and validate the requested templates; return an exit code."""
172 template_repo = config.get("template-repository")
173 template_branch = config.get("template-branch")
174 if not template_repo or not template_branch:
175 print(f"Missing template-repository or template-branch in {config_path}")
176 return 1
178 data, _fetch_errors = _validate_remote_bundles(
179 template_repo,
180 template_branch,
181 templates_set,
182 attempts=retries + 1,
183 timeout=timeout,
184 )
185 if data is None:
186 return 1
188 bundles = data.get("bundles", {})
189 errors = _validate_templates_in_bundles(templates_set, bundles, config_path)
190 if errors:
191 _report_errors("\n✗ Template bundles validation failed:", errors)
192 return 1
194 print("✓ Template bundles validation passed!")
195 return 0
198def main(argv: list[str] | None = None) -> int:
199 """Main entry point."""
200 _ensure_utf8_stdout()
202 args = _parse_args(argv)
204 if args.offline:
205 print("Offline mode: skipping remote template bundles validation")
206 return 0
208 config_path = _get_config_path(args)
210 # Load and validate configuration. A None result means validation was
211 # skipped (missing config or no templates field); both cases pass.
212 result = _load_and_validate_config(config_path)
213 if result is None:
214 return 0
215 config, templates_set = result
217 return _run_remote_validation(config, templates_set, config_path, args.retries, args.timeout)
220if __name__ == "__main__": # pragma: no mutate
221 sys.exit(main())