Coverage for sygaldry / resolver.py: 81%

212 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-20 01:40 -0400

1from __future__ import annotations 

2 

3__author__ = "Rohan B. Dalton" 

4 

5import inspect 

6import warnings 

7from dataclasses import dataclass 

8from typing import Any, Iterable, Optional 

9 

10from .cache import Instances 

11from .errors import ( 

12 CircularReferenceError, 

13 ConfigReferenceError, 

14 ConstructorError, 

15 ImportResolutionError, 

16 ResolutionError, 

17 ValidationError, 

18) 

19from .types import RESERVED_KEYS, import_dotted_path 

20 

21 

22@dataclass(frozen=True) 

23class Reference: 

24 """ 

25 Reference to a dotted config path. 

26 """ 

27 

28 path: str 

29 

30 

31@dataclass(frozen=True) 

32class ConstructorSpec: 

33 """ 

34 Constructor specification for a component. 

35 """ 

36 

37 target: str 

38 args: tuple[Any, ...] 

39 kwargs: dict[str, Any] 

40 instance: Optional[str] 

41 

42 

43@dataclass(frozen=True) 

44class Node: 

45 """ 

46 Intermediate node representation for optional IR usage. 

47 """ 

48 

49 name: str 

50 children: tuple[str, ...] 

51 bindings: dict[str, Any] 

52 

53 

54def resolve_config( 

55 config: dict[str, Any], 

56 *, 

57 file_path: Optional[str] = None, 

58 cache: Optional[Instances] = None, 

59 transient: bool = False, 

60) -> dict[str, Any]: 

61 """ 

62 Resolve a config mapping into an instantiated object graph. 

63 

64 :param config: Configuration mapping. 

65 :param file_path: Source config path for context. 

66 :param cache: Optional instance cache. 

67 :param transient: If True, bypass caching. 

68 :type config: dict[str, Any] 

69 :type file_path: str | None 

70 :type cache: Instances | None 

71 :type transient: bool 

72 :returns: Resolved configuration mapping. 

73 :rtype: dict[str, Any] 

74 """ 

75 resolver = _Resolver(config, file_path=file_path, cache=cache, transient=transient) 

76 return resolver.resolve() 

77 

78 

79class _Resolver: 

80 """ 

81 Resolver that walks and instantiates config values. 

82 """ 

83 

84 def __init__( 

85 self, 

86 config: dict[str, Any], 

87 *, 

88 file_path: Optional[str], 

89 cache: Optional[Instances], 

90 transient: bool, 

91 ) -> None: 

92 """ 

93 Initialize the resolver. 

94 

95 :param config: Configuration mapping. 

96 :param file_path: Source config path for context. 

97 :param cache: Optional instance cache. 

98 :param transient: If True, bypass caching. 

99 :type config: dict[str, Any] 

100 :type file_path: str | None 

101 :type cache: Instances | None 

102 :type transient: bool 

103 """ 

104 self._config = config 

105 self._file_path = file_path 

106 self._cache = cache or Instances() 

107 self._transient = transient 

108 self._resolved_top: dict[str, Any] = dict() 

109 self._resolving_top: list[str] = list() 

110 

111 def resolve(self) -> dict[str, Any]: 

112 """ 

113 Validate and resolve the full config mapping. 

114 

115 :returns: Resolved configuration mapping. 

116 :rtype: dict[str, Any] 

117 """ 

118 self._validate_schema(self._config, path=[]) 

119 self._validate_refs(self._config) 

120 return self._resolve_value(self._config, path=[]) 

121 

122 def _validate_schema(self, value: Any, *, path: list[str]) -> None: 

123 """ 

124 Validate schema rules for reserved keys. 

125 

126 :param value: Value to validate. 

127 :param path: Dotted path context. 

128 :type value: object 

129 :type path: list[str] 

130 :raises ValidationError: On schema violations. 

131 """ 

132 if isinstance(value, dict): 

133 if "_ref" in value and len(value) != 1: 

134 raise ValidationError( 

135 "_ref must be the only key in its mapping.", 

136 file_path=self._file_path, 

137 config_path=".".join(path), 

138 ) 

139 if "_type" in value and "_func" in value: 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true

140 raise ValidationError( 

141 "_type and _func are mutually exclusive.", 

142 file_path=self._file_path, 

143 config_path=".".join(path), 

144 ) 

145 if "_instance" in value and "_type" not in value: 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true

146 raise ValidationError( 

147 "_instance is only valid with _type.", 

148 file_path=self._file_path, 

149 config_path=".".join(path), 

150 ) 

151 for key, child in value.items(): 

152 next_path = path + [str(key)] 

153 self._validate_schema(child, path=next_path) 

154 elif isinstance(value, list): 

155 for idx, child in enumerate(value): 

156 self._validate_schema(child, path=path + [str(idx)]) 

157 elif isinstance(value, tuple): 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true

158 for idx, child in enumerate(value): 

159 self._validate_schema(child, path=path + [str(idx)]) 

160 

161 def _validate_refs(self, config: dict[str, Any]) -> None: 

162 """ 

163 Validate that all _ref targets exist in the config tree. 

164 

165 For plain config paths (no ``_type`` at intermediate levels), 

166 validates the full dotted path through the raw config dicts. 

167 Stops validation at dict boundaries since deeper segments 

168 may be attribute accesses on constructed objects. 

169 

170 :param config: Configuration mapping. 

171 :type config: dict[str, Any] 

172 :raises ConfigReferenceError: If any target is missing. 

173 """ 

174 for ref_path, ref_key in _collect_refs(config): 

175 top = ref_key.split(".", 1)[0] 

176 if top not in config: 

177 raise ConfigReferenceError( 

178 f"Reference target '{ref_key}' not found.", 

179 file_path=self._file_path, 

180 config_path=ref_path, 

181 ) 

182 current = config[top] 

183 segments = ref_key.split(".")[1:] 

184 checked = top 

185 for segment in segments: 

186 if not isinstance(current, dict): 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true

187 break 

188 if "_type" in current or "_func" in current: 

189 break 

190 if segment not in current: 190 ↛ 197line 190 didn't jump to line 197 because the condition on line 190 was always true

191 raise ConfigReferenceError( 

192 f"Reference target '{ref_key}' not found " 

193 f"('{segment}' missing under '{checked}').", 

194 file_path=self._file_path, 

195 config_path=ref_path, 

196 ) 

197 current = current[segment] 

198 checked = f"{checked}.{segment}" 

199 

200 def _resolve_value(self, value: Any, *, path: list[str]) -> Any: 

201 """ 

202 Resolve any value recursively. 

203 

204 :param value: Value to resolve. 

205 :param path: Dotted path context. 

206 :type value: object 

207 :type path: list[str] 

208 :returns: Resolved value. 

209 :rtype: object 

210 """ 

211 if isinstance(value, dict): 

212 return self._resolve_dict(value, path=path) 

213 if isinstance(value, list): 

214 return [ 

215 self._resolve_value(item, path=path + [str(idx)]) 

216 for idx, item in enumerate(value) 

217 ] 

218 if isinstance(value, tuple): 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true

219 return tuple( 

220 self._resolve_value(item, path=path + [str(idx)]) 

221 for idx, item in enumerate(value) 

222 ) 

223 return value 

224 

225 def _resolve_dict(self, value: dict[str, Any], *, path: list[str]) -> Any: 

226 """ 

227 Resolve a dict, applying reserved-key rules. 

228 

229 :param value: Mapping to resolve. 

230 :param path: Dotted path context. 

231 :type value: dict[str, Any] 

232 :type path: list[str] 

233 :returns: Resolved mapping or constructed object. 

234 :rtype: object 

235 """ 

236 if "_ref" in value: 

237 return self._resolve_ref(value["_ref"], path) 

238 if "_type" in value: 

239 return self._resolve_type(value, path) 

240 if "_func" in value: 

241 return self._resolve_func(value, path) 

242 if value.get("_deep") is False: 

243 return {k: v for k, v in value.items() if k != "_deep"} 

244 if "_entries" in value: 

245 return self._resolve_entries(value, path) 

246 

247 resolved: dict[str, Any] = dict() 

248 for key, child in value.items(): 

249 resolved[key] = self._resolve_value(child, path=path + [str(key)]) 

250 return resolved 

251 

252 def _resolve_ref(self, ref: Any, path: list[str]) -> Any: 

253 """ 

254 Resolve a ``_ref`` mapping. 

255 

256 :param ref: Reference value. 

257 :param path: Dotted path context. 

258 :type ref: object 

259 :type path: list[str] 

260 :returns: Resolved referenced object or attribute. 

261 :rtype: object 

262 :raises ConfigReferenceError: If the ref is invalid or missing. 

263 """ 

264 if not isinstance(ref, str) or not ref: 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true

265 raise ConfigReferenceError( 

266 "_ref must be a non-empty string.", 

267 file_path=self._file_path, 

268 config_path=".".join(path), 

269 ) 

270 top, _, remainder = ref.partition(".") 

271 target = self._resolve_top_level(top, path) 

272 if remainder: 

273 for attr in remainder.split("."): 

274 try: 

275 target = getattr(target, attr) 

276 except AttributeError as exc: 

277 raise ConfigReferenceError( 

278 f"Attribute '{attr}' not found on reference '{top}'.", 

279 file_path=self._file_path, 

280 config_path=".".join(path), 

281 ) from exc 

282 return target 

283 

284 def _resolve_top_level(self, key: str, path: list[str]) -> Any: 

285 """ 

286 Resolve a top-level config entry, with cycle detection. 

287 

288 :param key: Top-level key to resolve. 

289 :param path: Dotted path context. 

290 :type key: str 

291 :type path: list[str] 

292 :returns: Resolved value. 

293 :rtype: object 

294 :raises CircularReferenceError: If a cycle is detected. 

295 """ 

296 if key in self._resolved_top: 

297 return self._resolved_top[key] 

298 if key in self._resolving_top: 

299 chain = " -> ".join(self._resolving_top + [key]) 

300 raise CircularReferenceError( 

301 f"Circular reference detected: {chain}", 

302 file_path=self._file_path, 

303 config_path=".".join(path), 

304 ) 

305 if key not in self._config: 305 ↛ 306line 305 didn't jump to line 306 because the condition on line 305 was never true

306 raise ConfigReferenceError( 

307 f"Reference target '{key}' not found.", 

308 file_path=self._file_path, 

309 config_path=".".join(path), 

310 ) 

311 self._resolving_top.append(key) 

312 resolved = self._resolve_value(self._config[key], path=[key]) 

313 self._resolving_top.pop() 

314 self._resolved_top[key] = resolved 

315 return resolved 

316 

317 def _resolve_entries(self, value: dict[str, Any], path: list[str]) -> dict[Any, Any]: 

318 """ 

319 Resolve a mapping encoded via ``_entries``. 

320 

321 :param value: Mapping containing ``_entries`` list. 

322 :param path: Dotted path context. 

323 :type value: dict[str, Any] 

324 :type path: list[str] 

325 :returns: Resolved mapping. 

326 :rtype: dict[Any, Any] 

327 :raises ResolutionError: If entries are malformed. 

328 """ 

329 entries = value.get("_entries") 

330 if not isinstance(entries, list): 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true

331 raise ResolutionError( 

332 "_entries must be a list.", 

333 file_path=self._file_path, 

334 config_path=".".join(path), 

335 ) 

336 if len(value.keys() - {"_entries"}) != 0: 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true

337 raise ResolutionError( 

338 "_entries cannot be combined with other keys.", 

339 file_path=self._file_path, 

340 config_path=".".join(path), 

341 ) 

342 resolved: dict[Any, Any] = dict() 

343 for idx, entry in enumerate(entries): 

344 if not isinstance(entry, dict) or "_key" not in entry or "_value" not in entry: 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true

345 raise ResolutionError( 

346 "Each _entries item must contain _key and _value.", 

347 file_path=self._file_path, 

348 config_path=".".join(path + [str(idx)]), 

349 ) 

350 key = self._resolve_value(entry["_key"], path=path + [str(idx), "_key"]) 

351 val = self._resolve_value(entry["_value"], path=path + [str(idx), "_value"]) 

352 try: 

353 hash(key) 

354 except Exception as exc: 

355 raise ResolutionError( 

356 "Resolved _entries keys must be hashable.", 

357 file_path=self._file_path, 

358 config_path=".".join(path + [str(idx), "_key"]), 

359 ) from exc 

360 if key in resolved: 360 ↛ 361line 360 didn't jump to line 361 because the condition on line 360 was never true

361 raise ResolutionError( 

362 f"Duplicate _entries key '{key}'.", 

363 file_path=self._file_path, 

364 config_path=".".join(path + [str(idx), "_key"]), 

365 ) 

366 resolved[key] = val 

367 return resolved 

368 

369 def _resolve_func(self, value: dict[str, Any], path: list[str]) -> Any: 

370 """ 

371 Resolve a ``_func`` mapping to a callable. 

372 

373 :param value: Mapping containing ``_func``. 

374 :param path: Dotted path context. 

375 :type value: dict[str, Any] 

376 :type path: list[str] 

377 :returns: Imported callable. 

378 :rtype: object 

379 :raises ValidationError: If extra keys accompany ``_func``. 

380 """ 

381 extras = set(value) - {"_func"} 

382 if extras: 

383 raise ValidationError( 

384 f"_func does not accept other keys, found: {sorted(extras)}.", 

385 file_path=self._file_path, 

386 config_path=".".join(path), 

387 ) 

388 func_path = value.get("_func") 

389 try: 

390 return import_dotted_path( 

391 func_path, 

392 file_path=self._file_path, 

393 config_path=".".join(path), 

394 ) 

395 except ImportResolutionError: 

396 raise 

397 

398 def _resolve_type(self, value: dict[str, Any], path: list[str]) -> Any: 

399 """ 

400 Resolve a ``_type`` mapping into an instance. 

401 

402 :param value: Mapping containing ``_type`` and constructor data. 

403 :param path: Dotted path context. 

404 :type value: dict[str, Any] 

405 :type path: list[str] 

406 :returns: Constructed instance (possibly cached). 

407 :rtype: object 

408 """ 

409 type_path = value.get("_type") 

410 args = value.get("_args", []) 

411 instance = value.get("_instance") 

412 if not isinstance(args, list): 412 ↛ 413line 412 didn't jump to line 413 because the condition on line 412 was never true

413 raise ResolutionError( 

414 "_args must be a list.", 

415 file_path=self._file_path, 

416 config_path=".".join(path), 

417 ) 

418 

419 kwargs = {k: v for k, v in value.items() if k not in RESERVED_KEYS} 

420 resolved_args = tuple(self._resolve_value(arg, path=path + ["_args"]) for arg in args) 

421 resolved_kwargs = { 

422 key: self._resolve_value(val, path=path + [key]) for key, val in kwargs.items() 

423 } 

424 

425 try: 

426 target = import_dotted_path( 

427 type_path, 

428 file_path=self._file_path, 

429 config_path=".".join(path), 

430 ) 

431 except ImportResolutionError: 

432 raise 

433 

434 resolved_kwargs = _validate_signature( 

435 target, 

436 resolved_args, 

437 resolved_kwargs, 

438 file_path=self._file_path, 

439 config_path=".".join(path), 

440 ) 

441 

442 def factory() -> Any: 

443 try: 

444 return target(*resolved_args, **resolved_kwargs) 

445 except Exception as exc: # noqa: BLE001 

446 raise ConstructorError( 

447 f"Failed to construct '{type_path}'.", 

448 file_path=self._file_path, 

449 config_path=".".join(path), 

450 ) from exc 

451 

452 return self._cache.get_or_create( 

453 type_path, 

454 instance, 

455 resolved_args, 

456 resolved_kwargs, 

457 factory, 

458 transient=self._transient, 

459 file_path=self._file_path, 

460 config_path=".".join(path), 

461 ) 

462 

463 

464def _collect_refs( 

465 value: Any, *, path: Optional[list[str]] = None 

466) -> Iterable[tuple[str, str]]: 

467 """ 

468 Collect ``_ref`` occurrences from a config tree. 

469 

470 :param value: Root value to inspect. 

471 :param path: Current path stack. 

472 :type value: object 

473 :type path: list[str] | None 

474 :returns: Iterable of (config_path, ref_target) pairs. 

475 :rtype: collections.abc.Iterable[tuple[str, str]] 

476 """ 

477 path = path or list() 

478 if isinstance(value, dict): 

479 if "_ref" in value: 

480 yield ".".join(path), value["_ref"] 

481 return 

482 for key, child in value.items(): 

483 yield from _collect_refs(child, path=path + [str(key)]) 

484 elif isinstance(value, list): 

485 for idx, child in enumerate(value): 

486 yield from _collect_refs(child, path=path + [str(idx)]) 

487 elif isinstance(value, tuple): 487 ↛ 488line 487 didn't jump to line 488 because the condition on line 487 was never true

488 for idx, child in enumerate(value): 

489 yield from _collect_refs(child, path=path + [str(idx)]) 

490 

491 

492def _validate_signature( 

493 target: Any, 

494 args: tuple[Any, ...], 

495 kwargs: dict[str, Any], 

496 *, 

497 file_path: Optional[str], 

498 config_path: str, 

499) -> dict[str, Any]: 

500 """ 

501 Validate constructor signature and filter extra kwargs. 

502 

503 :param target: Callable to validate. 

504 :param args: Positional arguments. 

505 :param kwargs: Keyword arguments. 

506 :param file_path: Source config path for context. 

507 :param config_path: Dotted config path for context. 

508 :type target: object 

509 :type args: tuple[Any, ...] 

510 :type kwargs: dict[str, Any] 

511 :type file_path: str | None 

512 :type config_path: str 

513 :returns: Possibly filtered kwargs. 

514 :rtype: dict[str, Any] 

515 :raises ConstructorError: If required parameters are missing. 

516 """ 

517 try: 

518 signature = inspect.signature(target) 

519 except (TypeError, ValueError): 

520 return kwargs 

521 

522 has_var_kw = any(p.kind == p.VAR_KEYWORD for p in signature.parameters.values()) 

523 if not has_var_kw: 523 ↛ 538line 523 didn't jump to line 538 because the condition on line 523 was always true

524 allowed = { 

525 name 

526 for name, param in signature.parameters.items() 

527 if param.kind in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY) 

528 } 

529 extras = set(kwargs) - allowed 

530 if extras: 530 ↛ 531line 530 didn't jump to line 531 because the condition on line 530 was never true

531 warnings.warn( 

532 f"Extra kwargs for '{getattr(target, '__name__', target)}': {sorted(extras)}", 

533 RuntimeWarning, 

534 stacklevel=2, 

535 ) 

536 kwargs = {k: v for k, v in kwargs.items() if k not in extras} 

537 

538 try: 

539 bound = signature.bind_partial(*args, **kwargs) 

540 except TypeError as exc: 

541 raise ConstructorError( 

542 f"Constructor signature mismatch for '{getattr(target, '__name__', target)}': {exc}.", 

543 file_path=file_path, 

544 config_path=config_path, 

545 ) from exc 

546 

547 missing = list() 

548 for name, param in signature.parameters.items(): 

549 if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD): 549 ↛ 550line 549 didn't jump to line 550 because the condition on line 549 was never true

550 continue 

551 if param.default is not param.empty: 

552 continue 

553 if name not in bound.arguments: 553 ↛ 554line 553 didn't jump to line 554 because the condition on line 553 was never true

554 missing.append(name) 

555 

556 if missing: 556 ↛ 557line 556 didn't jump to line 557 because the condition on line 556 was never true

557 raise ConstructorError( 

558 f"Missing required parameters: {missing}.", 

559 file_path=file_path, 

560 config_path=config_path, 

561 ) 

562 return kwargs 

563 

564 

565if __name__ == "__main__": 565 ↛ 566line 565 didn't jump to line 566 because the condition on line 565 was never true

566 pass