Coverage for src / rhiza / models / bundle.py: 100%

187 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-07-16 12:51 +0000

1"""Bundle models for Rhiza configuration.""" 

2 

3from dataclasses import dataclass, field 

4from pathlib import PurePosixPath 

5from typing import Any 

6 

7from rhiza.models._base import YamlSerializable 

8from rhiza.models._git.helpers import _normalize_to_list 

9 

10 

11def _ensure_safe_bundle_path(value: str) -> None: 

12 """Reject a bundle path that could escape the project directory. 

13 

14 ``template.yml`` is effectively untrusted input (it is fetched from the 

15 template repository), and a remapped ``dest`` is joined onto the target 

16 directory to decide where a file is written. An absolute path, a Windows 

17 drive letter, or a ``..`` traversal component could therefore write outside 

18 the project. This validates both ``source`` and ``dest`` at the trust 

19 boundary so no such path can reach the copy step. 

20 

21 Args: 

22 value: A ``source`` or ``dest`` path from bundle config. 

23 

24 Raises: 

25 ValueError: If *value* is absolute, uses a drive letter, or contains a 

26 ``..`` traversal component. 

27 """ 

28 # Normalise separators so a Windows-style path cannot slip past the checks. 

29 normalized = value.replace("\\", "/") 

30 pure = PurePosixPath(normalized) 

31 has_drive = len(normalized) >= 2 and normalized[0].isalpha() and normalized[1] == ":" 

32 if pure.is_absolute() or has_drive or ".." in pure.parts: 

33 raise ValueError( # noqa: TRY003 

34 f"Unsafe bundle path {value!r}: paths must be relative to the project " 

35 "root (no absolute paths, drive letters, or '..' traversal)." 

36 ) 

37 

38 

39@dataclass(frozen=True) 

40class BundleFileEntry: 

41 """A file entry in a bundle, with optional source→destination path remapping. 

42 

43 When ``source`` and ``dest`` differ, the file is read from ``source`` in the 

44 template repository but written to ``dest`` in the downstream project. 

45 

46 Attributes: 

47 source: Path of the file in the template repository. 

48 dest: Path where the file should be placed in the downstream project. 

49 """ 

50 

51 source: str 

52 dest: str 

53 

54 def __post_init__(self) -> None: 

55 """Validate that both paths stay within the project (no escape).""" 

56 _ensure_safe_bundle_path(self.source) 

57 _ensure_safe_bundle_path(self.dest) 

58 

59 @property 

60 def is_remapped(self) -> bool: 

61 """True when the destination path differs from the source path.""" 

62 return self.source != self.dest 

63 

64 @classmethod 

65 def from_config_entry(cls, entry: "str | dict[str, str]") -> "BundleFileEntry": 

66 """Parse a file entry from a YAML config value (string or dict). 

67 

68 Args: 

69 entry: Either a plain path string or a ``{source, dest}`` dict. 

70 

71 Returns: 

72 A :class:`BundleFileEntry` instance. 

73 

74 Raises: 

75 TypeError: If the entry is a dict without a ``source`` key. 

76 """ 

77 if isinstance(entry, str): 

78 return cls(source=entry, dest=entry) 

79 if not isinstance(entry, dict) or "source" not in entry: 

80 raise TypeError( # noqa: TRY003 

81 f"File entry must be a string or a dict with a 'source' key, got: {entry!r}" 

82 ) 

83 source = entry["source"] 

84 dest = entry.get("dest", source) 

85 return cls(source=source, dest=dest) 

86 

87 def to_config_entry(self) -> "str | dict[str, str]": 

88 """Serialize back to the YAML config representation.""" 

89 if not self.is_remapped: 

90 return self.source 

91 return {"source": self.source, "dest": self.dest} 

92 

93 def remap_expanded_path(self, expanded_source: str) -> str: 

94 """Map an expanded source path to its destination path. 

95 

96 Handles both exact file matches and directory-prefix matches. 

97 

98 Args: 

99 expanded_source: A source path produced by expanding this entry. 

100 

101 Returns: 

102 The corresponding destination path. 

103 """ 

104 if not self.is_remapped: 

105 return expanded_source 

106 if expanded_source == self.source: 

107 return self.dest 

108 src_prefix = self.source.rstrip("/") + "/" 

109 if expanded_source.startswith(src_prefix): 

110 dest_prefix = self.dest.rstrip("/") + "/" 

111 return dest_prefix + expanded_source[len(src_prefix) :] 

112 return expanded_source 

113 

114 

115@dataclass(frozen=True, kw_only=True) 

116class ProfileDefinition: 

117 """Represents a single profile from template-bundles.yml. 

118 

119 Attributes: 

120 description: Human-readable description of the profile. 

121 bundles: List of bundle names included in this profile. 

122 """ 

123 

124 description: str = "" 

125 bundles: list[str] = field(default_factory=list) 

126 

127 

128@dataclass(frozen=True, kw_only=True) 

129class BundleDefinition: 

130 """Represents a single bundle from template-bundles.yml. 

131 

132 Attributes: 

133 description: Human-readable description of the bundle. 

134 files: Explicit file entries (legacy format only — new bundles own files via 

135 their ``bundles/<name>/`` directory in the template repository). 

136 requires: List of bundle names that this bundle requires. 

137 recommends: List of bundle names that this bundle recommends (soft deps). 

138 standalone: Whether this bundle is standalone (no dependencies). 

139 required: Whether this bundle is mandatory (always included). 

140 notes: Free-form notes for maintainers (not synced to downstream projects). 

141 """ 

142 

143 description: str 

144 standalone: bool = True 

145 required: bool = False 

146 files: list[BundleFileEntry] = field(default_factory=list) 

147 requires: list[str] = field(default_factory=list) 

148 recommends: list[str] = field(default_factory=list) 

149 notes: str = "" 

150 

151 

152def _parse_bundle_files(raw_files: Any) -> list[BundleFileEntry]: 

153 """Coerce a bundle's raw ``files`` field into a list of file entries. 

154 

155 Args: 

156 raw_files: The raw value of the ``files`` key (list, string, or absent). 

157 

158 Returns: 

159 Parsed file entries; an empty list when *raw_files* is neither a list 

160 nor a string. 

161 """ 

162 if isinstance(raw_files, list): 

163 return [BundleFileEntry.from_config_entry(e) for e in raw_files] 

164 if isinstance(raw_files, str): 

165 return [BundleFileEntry.from_config_entry(e) for e in _normalize_to_list(raw_files)] 

166 return [] 

167 

168 

169def _parse_bundle_definitions(bundles_config: Any) -> dict[str, BundleDefinition]: 

170 """Parse the ``bundles`` mapping of a config dict into definitions. 

171 

172 Args: 

173 bundles_config: The raw ``bundles`` value from the configuration. 

174 

175 Returns: 

176 Mapping of bundle name to :class:`BundleDefinition`. 

177 

178 Raises: 

179 TypeError: If *bundles_config* or any entry is not a dictionary. 

180 """ 

181 if not isinstance(bundles_config, dict): 

182 msg = "Bundles must be a dictionary" 

183 raise TypeError(msg) 

184 

185 bundles: dict[str, BundleDefinition] = {} 

186 for bundle_name, bundle_data in bundles_config.items(): 

187 if not isinstance(bundle_data, dict): 

188 msg = f"Bundle '{bundle_name}' must be a dictionary" 

189 raise TypeError(msg) 

190 bundles[bundle_name] = BundleDefinition( 

191 description=bundle_data.get("description", ""), 

192 files=_parse_bundle_files(bundle_data.get("files")), 

193 requires=_normalize_to_list(bundle_data.get("requires")), 

194 recommends=_normalize_to_list(bundle_data.get("recommends")), 

195 standalone=bundle_data.get("standalone", True), 

196 required=bool(bundle_data.get("required", False)), 

197 notes=bundle_data.get("notes") or "", 

198 ) 

199 return bundles 

200 

201 

202def _parse_profile_definitions(profiles_config: Any) -> dict[str, ProfileDefinition]: 

203 """Parse the ``profiles`` mapping of a config dict into definitions. 

204 

205 Args: 

206 profiles_config: The raw ``profiles`` value from the configuration 

207 (``None`` is treated as an empty mapping). 

208 

209 Returns: 

210 Mapping of profile name to :class:`ProfileDefinition`. 

211 

212 Raises: 

213 TypeError: If *profiles_config* or any entry is not a dictionary. 

214 """ 

215 if profiles_config is None: 

216 profiles_config = {} 

217 elif not isinstance(profiles_config, dict): 

218 msg = "Profiles must be a dictionary" 

219 raise TypeError(msg) 

220 

221 profiles: dict[str, ProfileDefinition] = {} 

222 for profile_name, profile_data in profiles_config.items(): 

223 if not isinstance(profile_data, dict): 

224 msg = f"Profile '{profile_name}' must be a dictionary" 

225 raise TypeError(msg) 

226 profiles[profile_name] = ProfileDefinition( 

227 description=profile_data.get("description", ""), 

228 bundles=_normalize_to_list(profile_data.get("bundles")), 

229 ) 

230 return profiles 

231 

232 

233@dataclass(frozen=True, kw_only=True) 

234class RhizaBundles(YamlSerializable): 

235 """Represents the structure of template-bundles.yml. 

236 

237 Attributes: 

238 version: Optional version string of the bundles configuration format. 

239 bundles: Dictionary mapping bundle names to their definitions. 

240 """ 

241 

242 version: str | None = None 

243 bundles: dict[str, BundleDefinition] = field(default_factory=dict) 

244 profiles: dict[str, ProfileDefinition] = field(default_factory=dict) 

245 

246 @staticmethod 

247 def _bundle_to_entry(bundle: BundleDefinition) -> dict[str, Any]: 

248 """Serialise a single bundle definition into its config dict, omitting falsy fields.""" 

249 entry: dict[str, Any] = {"description": bundle.description} 

250 if bundle.required: 

251 entry["required"] = bundle.required 

252 if bundle.standalone: 

253 entry["standalone"] = bundle.standalone 

254 if bundle.requires: 

255 entry["requires"] = bundle.requires 

256 if bundle.recommends: 

257 entry["recommends"] = bundle.recommends 

258 if bundle.files: 

259 entry["files"] = [f.to_config_entry() for f in bundle.files] 

260 if bundle.notes: 

261 entry["notes"] = bundle.notes 

262 return entry 

263 

264 @staticmethod 

265 def _profile_to_entry(profile: ProfileDefinition) -> dict[str, Any]: 

266 """Serialise a single profile definition into its config dict.""" 

267 entry: dict[str, Any] = {} 

268 if profile.description: 

269 entry["description"] = profile.description 

270 entry["bundles"] = profile.bundles 

271 return entry 

272 

273 @property 

274 def config(self) -> dict[str, Any]: 

275 """Return the bundles' current state as a configuration dictionary.""" 

276 config: dict[str, Any] = {} 

277 

278 if self.version is not None: 

279 config["version"] = self.version 

280 

281 config["bundles"] = {name: self._bundle_to_entry(bundle) for name, bundle in self.bundles.items()} 

282 

283 if self.profiles: 

284 config["profiles"] = {name: self._profile_to_entry(profile) for name, profile in self.profiles.items()} 

285 

286 return config 

287 

288 @classmethod 

289 def from_config(cls, config: dict[str, Any]) -> "RhizaBundles": 

290 """Create a RhizaBundles instance from a configuration dictionary. 

291 

292 Args: 

293 config: Dictionary containing bundles configuration. 

294 

295 Returns: 

296 A new RhizaBundles instance. 

297 

298 Raises: 

299 TypeError: If bundle data has invalid types. 

300 """ 

301 version = config.get("version") 

302 bundles = _parse_bundle_definitions(config.get("bundles", {})) 

303 profiles = _parse_profile_definitions(config.get("profiles", {})) 

304 return cls(version=version, bundles=bundles, profiles=profiles) 

305 

306 def _resolve_bundle_order(self, bundle_names: list[str], *, strict: bool) -> list[str]: 

307 """Return *bundle_names* and their ``requires`` dependencies in topological order. 

308 

309 Args: 

310 bundle_names: Bundle names to resolve. 

311 strict: When True, raise ``ValueError`` for unknown bundles or 

312 circular dependencies; when False, silently skip them. 

313 

314 Returns: 

315 Dependency-first ordering of the resolved bundle names. 

316 

317 Raises: 

318 ValueError: If ``strict`` and a bundle is missing or forms a cycle. 

319 """ 

320 order: list[str] = [] 

321 resolved: set[str] = set() 

322 resolving: set[str] = set() 

323 

324 def _collect(name: str) -> None: 

325 """Recursively resolve a single bundle's dependencies in topological order.""" 

326 if name not in self.bundles: 

327 if strict: 

328 msg = f"Bundle '{name}' does not exist" 

329 raise ValueError(msg) 

330 return 

331 if name in resolving: 

332 if strict: 

333 msg = f"Circular dependency detected for bundle '{name}'" 

334 raise ValueError(msg) 

335 return 

336 if name in resolved: 

337 return 

338 

339 resolving.add(name) 

340 for dependency in self.bundles[name].requires: 

341 _collect(dependency) 

342 resolving.remove(name) 

343 resolved.add(name) 

344 order.append(name) 

345 

346 for name in bundle_names: 

347 _collect(name) 

348 return order 

349 

350 def resolve_to_paths(self, bundle_names: list[str]) -> list[str]: 

351 """Convert bundle names to deduplicated file paths. 

352 

353 Args: 

354 bundle_names: List of bundle names to resolve. 

355 

356 Returns: 

357 Deduplicated list of file paths from all bundles and their dependencies. 

358 

359 Raises: 

360 ValueError: If a bundle doesn't exist or circular dependency detected. 

361 """ 

362 paths: list[str] = [] 

363 seen: set[str] = set() 

364 

365 def _add(path: str) -> None: 

366 """Append a path once, tracking it in the dedup set.""" 

367 if path not in seen: 

368 paths.append(path) 

369 seen.add(path) 

370 

371 for bundle_name in self._resolve_bundle_order(bundle_names, strict=True): 

372 bundle = self.bundles[bundle_name] 

373 if bundle.files: 

374 for entry in bundle.files: 

375 _add(entry.source) 

376 else: 

377 _add(f"bundles/{bundle_name}/") 

378 

379 return paths 

380 

381 def resolve_to_path_map(self, bundle_names: list[str]) -> dict[str, str]: 

382 """Return a source→destination mapping for all remapped file entries. 

383 

384 Plain (non-remapped) entries are excluded — callers can assume an 

385 absent key means ``dest == source``. 

386 

387 Args: 

388 bundle_names: List of bundle names to resolve (dependencies included). 

389 

390 Returns: 

391 Dict mapping source path → destination path for remapped entries only. 

392 """ 

393 path_map: dict[str, str] = {} 

394 resolved = self.resolve_to_paths(bundle_names) 

395 resolved_set = set(resolved) 

396 

397 for bundle_name in self._resolve_bundle_order(bundle_names, strict=False): 

398 bundle = self.bundles[bundle_name] 

399 if bundle.files: 

400 for entry in bundle.files: 

401 if entry.source in resolved_set and entry.is_remapped: 

402 path_map[entry.source] = entry.dest 

403 else: 

404 path_map[f"bundles/{bundle_name}/"] = "" 

405 

406 return path_map 

407 

408 def resolve_profile_to_paths(self, profile_name: str) -> list[str]: 

409 """Resolve a profile name to deduplicated file paths. 

410 

411 Args: 

412 profile_name: Name of the profile to resolve. 

413 

414 Returns: 

415 Deduplicated list of file paths from all bundles in the profile. 

416 

417 Raises: 

418 ValueError: If the profile doesn't exist or a referenced bundle doesn't exist. 

419 """ 

420 if profile_name not in self.profiles: 

421 msg = f"Profile '{profile_name}' does not exist" 

422 raise ValueError(msg) 

423 return self.resolve_to_paths(self.profiles[profile_name].bundles)