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

1#!/usr/bin/env python3 

2"""Validate template-bundles.yml structure and consistency. 

3 

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 

10 

11The script reads .rhiza/template.yml to find the template repository, 

12then fetches template-bundles.yml from that remote repository. 

13 

14Exit codes: 

15 0 - Validation passed 

16 1 - Validation failed 

17""" 

18 

19from __future__ import annotations 

20 

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 

29 

30import yaml 

31 

32 

33def _load_yaml_file(bundles_path: Path) -> tuple[bool, dict[Any, Any] | list[str]]: 

34 """Load and parse YAML file. 

35 

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}"] 

41 

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}"] 

47 

48 if data is None: 

49 return False, ["Template bundles file is empty"] 

50 

51 return True, data 

52 

53 

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 

62 

63 

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 = [] 

71 

72 if not isinstance(bundle_config, dict): 

73 errors.append(f"Bundle '{bundle_name}' must be a dictionary") 

74 return errors 

75 

76 # Check required fields 

77 if "description" not in bundle_config: 

78 errors.append(f"Bundle '{bundle_name}' missing 'description'") 

79 

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") 

84 

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}'") 

93 

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}'") 

101 

102 return errors 

103 

104 

105def _validate_examples(examples: Any, bundle_names: set[str]) -> list[str]: 

106 """Validate examples section.""" 

107 errors = [] 

108 

109 if not isinstance(examples, dict): 

110 errors.append("'examples' must be a dictionary") 

111 return errors 

112 

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}'") 

122 

123 return errors 

124 

125 

126def _validate_metadata(metadata: dict[Any, Any], bundles: dict[Any, Any]) -> list[str]: 

127 """Validate metadata section.""" 

128 errors = [] 

129 

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 ) 

137 

138 return errors 

139 

140 

141def find_repo_root() -> Path: 

142 """Find the repository root directory. 

143 

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() 

153 

154 

155def _get_config_data(config_path: Path) -> dict[str, Any] | None: 

156 """Get the configuration from .rhiza/template.yml. 

157 

158 Args: 

159 config_path: Path to .rhiza/template.yml 

160 

161 Returns: 

162 Configuration dictionary, or None if file not found or invalid 

163 """ 

164 if not config_path.exists(): 

165 return None 

166 

167 try: 

168 with open(config_path) as f: 

169 config = yaml.safe_load(f) 

170 except yaml.YAMLError: 

171 return None 

172 

173 if not isinstance(config, dict): 

174 return None 

175 

176 return config 

177 

178 

179def _get_templates_from_config(config_path: Path) -> set[str] | None: 

180 """Get the list of templates from .rhiza/template.yml. 

181 

182 Args: 

183 config_path: Path to .rhiza/template.yml 

184 

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 

191 

192 templates = config.get("templates") 

193 if templates is None: 

194 return None 

195 

196 if not isinstance(templates, list): 

197 return None 

198 

199 return set(templates) 

200 

201 

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. 

204 

205 Args: 

206 repo: GitHub repository in 'owner/repo' format 

207 branch: Branch name 

208 

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" 

214 

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."] 

219 

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}"] 

231 

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}"] 

236 

237 if data is None: 

238 return False, ["Remote template bundles file is empty"] 

239 

240 if not isinstance(data, dict): 

241 return False, ["Remote template bundles must be a dictionary"] 

242 

243 return True, data 

244 

245 

246def validate_template_bundles(bundles_path: Path, templates_to_check: set[str] | None = None) -> tuple[bool, list[str]]: 

247 """Validate template bundles configuration. 

248 

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. 

252 

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) 

261 

262 # Type narrowing: when success is True, data_or_errors is dict[Any, Any] 

263 data = cast(dict[Any, Any], data_or_errors) 

264 

265 # Validate top-level fields 

266 errors = _validate_top_level_fields(data) 

267 if errors: 

268 return False, errors 

269 

270 # Validate bundles section 

271 bundles = data.get("bundles", {}) 

272 if not isinstance(bundles, dict): 

273 return False, ["'bundles' must be a dictionary"] 

274 

275 bundle_names = set(bundles.keys()) 

276 

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") 

282 

283 # Determine which bundles to validate 

284 bundles_to_validate = templates_to_check if templates_to_check is not None else bundle_names 

285 

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)) 

291 

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)) 

295 

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)) 

299 

300 return len(errors) == 0, errors 

301 

302 

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" 

308 

309 

310def _load_and_validate_config(config_path: Path) -> tuple[dict[str, Any] | None, set[str] | None]: 

311 """Load and validate configuration file. 

312 

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 

320 

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 

325 

326 return config, set(templates_to_check) 

327 

328 

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. 

333 

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))}") 

339 

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 

347 

348 data = cast(dict[Any, Any], data_or_errors) 

349 

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 

357 

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"] 

363 

364 return data, [] 

365 

366 

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()) 

371 

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") 

376 

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)) 

382 

383 return errors 

384 

385 

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) 

397 

398 # Get configuration path 

399 config_path = _get_config_path(args) 

400 

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 

405 

406 # Get template repository and branch 

407 template_repo = config.get("template-repository") 

408 template_branch = config.get("template-branch") 

409 

410 if not template_repo or not template_branch: 

411 print(f"Missing template-repository or template-branch in {config_path}") 

412 return 1 

413 

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 

418 

419 # Validate templates 

420 bundles = data.get("bundles", {}) 

421 errors = _validate_templates_in_bundles(templates_set, bundles, config_path) 

422 

423 if errors: 

424 print("\n✗ Template bundles validation failed:") 

425 for error in errors: 

426 print(f" - {error}") 

427 return 1 

428 

429 print("✓ Template bundles validation passed!") 

430 return 0 

431 

432 

433if __name__ == "__main__": 

434 sys.exit(main())