Coverage for src/rhiza_hooks/_bundles_validate.py: 100%

103 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-30 04:36 +0000

1#!/usr/bin/env python3 

2"""Structural validation of a template-bundles document. 

3 

4These functions operate on an already-loaded mapping (see 

5:mod:`rhiza_hooks._bundles_fetch` for how the document is obtained) and report 

6problems as lists of human-readable error strings. They never perform I/O, 

7except :func:`validate_template_bundles`, which is a thin convenience wrapper 

8that loads a local file and then validates it. 

9""" 

10 

11from __future__ import annotations 

12 

13from collections.abc import Callable 

14from pathlib import Path 

15from typing import Any 

16 

17from rhiza_hooks._bundles_fetch import load_local_bundles 

18 

19 

20def _not_a_known_bundle(value: Any, bundle_names: set[Any]) -> bool: 

21 """Return True if ``value`` is not a declared bundle name. 

22 

23 A plain ``value not in bundle_names`` raises ``TypeError`` when ``value`` is 

24 unhashable (e.g. a list or dict produced by malformed YAML). Such a value 

25 can never be a bundle name, so it is reported as unknown rather than crashing. 

26 """ 

27 try: 

28 return value not in bundle_names 

29 except TypeError: 

30 return True 

31 

32 

33def _validate_top_level_fields(data: dict[Any, Any]) -> list[str]: 

34 """Validate required top-level fields.""" 

35 errors = [] 

36 required_fields = {"version", "bundles"} 

37 for field in required_fields: 

38 if field not in data: 

39 errors.append(f"Missing required field: {field}") 

40 return errors 

41 

42 

43def _validate_dep_list(bundle_name: str, field: str, deps: Any, bundle_names: set[str]) -> list[str]: 

44 """Validate one dependency list (``requires`` / ``recommends``) of a bundle. 

45 

46 The ``field`` name doubles as the verb in the "non-existent bundle" message 

47 (e.g. ``requires``/``recommends``), so the two call sites share this logic. 

48 """ 

49 if not isinstance(deps, list): 

50 return [f"Bundle '{bundle_name}' '{field}' must be a list"] 

51 return [ 

52 f"Bundle '{bundle_name}' {field} non-existent bundle '{dep}'" 

53 for dep in deps 

54 if _not_a_known_bundle(dep, bundle_names) 

55 ] 

56 

57 

58def _validate_required_bundle_fields(bundle_name: str, bundle_config: dict[Any, Any]) -> list[str]: 

59 """Validate a bundle's required fields: a ``description`` and a list ``files``.""" 

60 errors = [] 

61 if "description" not in bundle_config: 

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

63 if "files" not in bundle_config: 

64 errors.append(f"Bundle '{bundle_name}' missing 'files'") 

65 elif not isinstance(bundle_config["files"], list): 

66 errors.append(f"Bundle '{bundle_name}' 'files' must be a list") 

67 return errors 

68 

69 

70def _validate_bundle_structure( 

71 bundle_name: str, 

72 bundle_config: Any, 

73 bundle_names: set[str], 

74) -> list[str]: 

75 """Validate a single bundle's structure and dependencies.""" 

76 if not isinstance(bundle_config, dict): 

77 return [f"Bundle '{bundle_name}' must be a dictionary"] 

78 

79 errors = _validate_required_bundle_fields(bundle_name, bundle_config) 

80 

81 # Validate dependencies 

82 for field in ("requires", "recommends"): 

83 if field in bundle_config: 

84 errors.extend(_validate_dep_list(bundle_name, field, bundle_config[field], bundle_names)) 

85 

86 return errors 

87 

88 

89def _is_unknown_example_template(template: Any, bundle_names: set[str]) -> bool: 

90 """True if an example template is neither ``core`` (auto-included) nor a known bundle.""" 

91 return template != "core" and _not_a_known_bundle(template, bundle_names) 

92 

93 

94def _validate_example_templates(example_name: str, templates: Any, bundle_names: set[str]) -> list[str]: 

95 """Validate one example's ``templates`` value: a list of known bundle names.""" 

96 if not isinstance(templates, list): 

97 return [f"Example '{example_name}' 'templates' must be a list"] 

98 return [ 

99 f"Example '{example_name}' references non-existent bundle '{template}'" 

100 for template in templates 

101 if _is_unknown_example_template(template, bundle_names) 

102 ] 

103 

104 

105def _validate_example(example_name: str, example_config: Any, bundle_names: set[str]) -> list[str]: 

106 """Validate a single example entry.""" 

107 if not isinstance(example_config, dict): 

108 return [f"Example '{example_name}' must be a dictionary"] 

109 if "templates" not in example_config: 

110 return [] 

111 return _validate_example_templates(example_name, example_config["templates"], bundle_names) 

112 

113 

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

115 """Validate examples section.""" 

116 if not isinstance(examples, dict): 

117 return ["'examples' must be a dictionary"] 

118 

119 errors = [] 

120 for example_name, example_config in examples.items(): 

121 errors.extend(_validate_example(example_name, example_config, bundle_names)) 

122 return errors 

123 

124 

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

126 """Validate metadata section.""" 

127 errors = [] 

128 

129 if not isinstance(metadata, dict): 

130 errors.append("'metadata' must be a dictionary") 

131 return errors 

132 

133 if "total_bundles" in metadata: 

134 expected_count = len(bundles) 

135 actual_count = metadata["total_bundles"] 

136 if actual_count != expected_count: 

137 errors.append( 

138 f"Metadata 'total_bundles' ({actual_count}) doesn't match actual bundle count ({expected_count})" 

139 ) 

140 

141 return errors 

142 

143 

144def _validate_selected_bundles( 

145 templates: set[str], 

146 bundles: dict[Any, Any], 

147 missing_message: Callable[[str], str], 

148) -> list[str]: 

149 """Validate that each requested template exists in ``bundles`` and is well-formed. 

150 

151 Shared by the local-file and remote-fetch paths; they differ only in the 

152 "not found" wording, supplied via ``missing_message``. 

153 

154 Args: 

155 templates: Template names requested in the rhiza config. 

156 bundles: The ``bundles`` mapping from a template-bundles document. 

157 missing_message: Builds the error string for a template absent from ``bundles``. 

158 

159 Returns: 

160 List of error messages (empty if every requested template is valid). 

161 """ 

162 errors: list[str] = [] 

163 bundle_names: set[str] = set(bundles.keys()) 

164 

165 for template in templates: 

166 if template not in bundle_names: 

167 errors.append(missing_message(template)) 

168 

169 for template in templates: 

170 if template in bundles: 

171 errors.extend(_validate_bundle_structure(template, bundles[template], bundle_names)) 

172 

173 return errors 

174 

175 

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

177 """Validate template bundles configuration. 

178 

179 Args: 

180 bundles_path: Path to template-bundles.yml 

181 templates_to_check: Optional set of template names to validate. If None, validate all. 

182 

183 Returns: 

184 Tuple of (success, error_messages) 

185 """ 

186 # Load YAML file 

187 loaded = load_local_bundles(bundles_path) 

188 if loaded.data is None: 

189 return False, loaded.errors 

190 # data is narrowed to dict[Any, Any] by the `is None` guard above. 

191 data = loaded.data 

192 

193 # Validate top-level fields 

194 errors = _validate_top_level_fields(data) 

195 if errors: 

196 return False, errors 

197 

198 # Validate bundles section 

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

200 if not isinstance(bundles, dict): 

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

202 

203 if templates_to_check is not None: 

204 # Validate only the requested subset (existence + structure). 

205 errors.extend( 

206 _validate_selected_bundles( 

207 templates_to_check, 

208 bundles, 

209 lambda t: f"Template '{t}' specified in .rhiza/template.yml not found in bundles", 

210 ) 

211 ) 

212 else: 

213 errors.extend(_validate_all_bundles(data, bundles)) 

214 

215 return len(errors) == 0, errors 

216 

217 

218def _validate_all_bundles(data: dict[Any, Any], bundles: dict[Any, Any]) -> list[str]: 

219 """Validate every declared bundle, plus the examples and metadata sections.""" 

220 bundle_names: set[str] = {str(k) for k in bundles} 

221 

222 errors: list[str] = [] 

223 for bundle_name in bundle_names: 

224 errors.extend(_validate_bundle_structure(bundle_name, bundles[bundle_name], bundle_names)) 

225 if "examples" in data: 

226 errors.extend(_validate_examples(data["examples"], bundle_names)) 

227 if "metadata" in data: 

228 errors.extend(_validate_metadata(data["metadata"], bundles)) 

229 return errors