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

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

28 

29import yaml 

30 

31 

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

33 """Load and parse YAML file. 

34 

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

40 

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

46 

47 if data is None: 

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

49 

50 return True, data 

51 

52 

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 

61 

62 

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

70 

71 if not isinstance(bundle_config, dict): 

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

73 return errors 

74 

75 # Check required fields 

76 if "description" not in bundle_config: 

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

78 

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

83 

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

92 

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

100 

101 return errors 

102 

103 

104def _validate_examples(examples: dict[Any, Any] | object, bundle_names: set[str]) -> list[str]: 

105 """Validate examples section.""" 

106 errors = [] 

107 

108 if not isinstance(examples, dict): 

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

110 return errors 

111 

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

121 

122 return errors 

123 

124 

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

126 """Validate metadata section.""" 

127 errors = [] 

128 

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 ) 

136 

137 return errors 

138 

139 

140def find_repo_root() -> Path: 

141 """Find the repository root directory. 

142 

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

152 

153 

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

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

156 

157 Args: 

158 config_path: Path to .rhiza/template.yml 

159 

160 Returns: 

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

162 """ 

163 if not config_path.exists(): 

164 return None 

165 

166 try: 

167 with open(config_path) as f: 

168 config = yaml.safe_load(f) 

169 except yaml.YAMLError: 

170 return None 

171 

172 if not isinstance(config, dict): 

173 return None 

174 

175 return config 

176 

177 

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

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

180 

181 Args: 

182 config_path: Path to .rhiza/template.yml 

183 

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 

190 

191 templates = config.get("templates") 

192 if templates is None: 

193 return None 

194 

195 if not isinstance(templates, list): 

196 return None 

197 

198 return set(templates) 

199 

200 

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. 

203 

204 Args: 

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

206 branch: Branch name 

207 

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" 

213 

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

218 

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

230 

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

235 

236 if data is None: 

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

238 

239 if not isinstance(data, dict): 

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

241 

242 return True, data 

243 

244 

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

246 """Validate template bundles configuration. 

247 

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. 

251 

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 

261 

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 

265 

266 # Validate top-level fields 

267 errors = _validate_top_level_fields(data) 

268 if errors: 

269 return False, errors 

270 

271 # Validate bundles section 

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

273 if not isinstance(bundles, dict): 

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

275 

276 bundle_names = set(bundles.keys()) 

277 

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

283 

284 # Determine which bundles to validate 

285 bundles_to_validate = templates_to_check if templates_to_check is not None else bundle_names 

286 

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

292 

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

296 

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

300 

301 return len(errors) == 0, errors 

302 

303 

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" 

309 

310 

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

312 """Load and validate configuration file. 

313 

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 

321 

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 

326 

327 return config, set(templates_to_check) 

328 

329 

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. 

334 

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

340 

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 

348 

349 assert isinstance(data_or_errors, dict) 

350 data = data_or_errors 

351 

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 

359 

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

365 

366 return data, [] 

367 

368 

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

373 

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

378 

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

384 

385 return errors 

386 

387 

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) 

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