Coverage for sygaldry / artificery.py: 87%

283 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-01 00:01 -0400

1from __future__ import annotations 

2 

3__author__ = "Rohan B. Dalton" 

4 

5import inspect 

6import warnings 

7from collections.abc import Iterable 

8from dataclasses import dataclass 

9from pathlib import Path 

10from typing import Any, Optional 

11 

12from .cache import Instances 

13from .errors import ( 

14 CircularReferenceError, 

15 ConfigReferenceError, 

16 ConstructorError, 

17 ImportResolutionError, 

18 ResolutionError, 

19 ValidationError, 

20) 

21from .loader import ( 

22 _MISSING, 

23 _deep_merge, 

24 _get_by_path, 

25 _interpolate_config, 

26 _load_with_includes, 

27 load_config, 

28) 

29from .types import RESERVED_KEYS, import_dotted_path 

30 

31 

32@dataclass(frozen=True) 

33class Reference: 

34 """ 

35 Reference to a dotted config path. 

36 """ 

37 

38 path: str 

39 

40 

41@dataclass(frozen=True) 

42class ConstructorSpec: 

43 """ 

44 Constructor specification for a component. 

45 """ 

46 

47 target: str 

48 args: tuple[Any, ...] 

49 kwargs: dict[str, Any] 

50 instance: str | None 

51 

52 

53@dataclass(frozen=True) 

54class Node: 

55 """ 

56 Intermediate node representation for optional IR usage. 

57 """ 

58 

59 name: str 

60 children: tuple[str, ...] 

61 bindings: dict[str, Any] 

62 

63 

64def _set_by_path(data: dict[str, Any], dotted_path: str, value: Any) -> None: 

65 """ 

66 Set a nested value by dotted path, creating intermediate dicts. 

67 

68 :param data: Root mapping to modify in-place. 

69 :param dotted_path: Dotted key path (e.g. ``"db.host"``). 

70 :param value: Value to set. 

71 :type data: dict 

72 :type dotted_path: str 

73 :type value: object 

74 """ 

75 segments = dotted_path.split(".") 

76 current = data 

77 for segment in segments[:-1]: 

78 if segment not in current or not isinstance(current[segment], dict): 

79 current[segment] = dict() 

80 current = current[segment] 

81 current[segments[-1]] = value 

82 

83 

84def load_config_file(path: str | Path) -> dict[str, Any]: 

85 """ 

86 Load a config file without resolving components. 

87 

88 :param path: Path to a YAML or TOML config file. 

89 :type path: str | pathlib.Path 

90 :returns: Parsed and interpolated mapping. 

91 :rtype: dict[str, Any] 

92 """ 

93 file_path = Path(path) 

94 return load_config(file_path) 

95 

96 

97class ArtificeryLoader: 

98 """ 

99 Lightweight loader wrapper for config files. 

100 """ 

101 

102 def __init__(self, path: str | Path) -> None: 

103 """ 

104 Initialize the loader. 

105 

106 :param path: Path to a YAML or TOML config file. 

107 :type path: str | pathlib.Path 

108 """ 

109 self._path = Path(path) 

110 

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

112 """ 

113 Load and return the config mapping. 

114 

115 :returns: Parsed and interpolated configuration mapping. 

116 :rtype: dict[str, Any] 

117 """ 

118 return load_config(self._path) 

119 

120 

121def resolve_config( 

122 config: dict[str, Any], 

123 *, 

124 file_path: str | None = None, 

125 cache: Instances | None = None, 

126 transient: bool = False, 

127) -> dict[str, Any]: 

128 """ 

129 Resolve a config mapping into an instantiated object graph. 

130 

131 :param config: Configuration mapping. 

132 :param file_path: Source config path for context. 

133 :param cache: Optional instance cache. 

134 :param transient: If True, bypass caching. 

135 :type config: dict[str, Any] 

136 :type file_path: str | None 

137 :type cache: Instances | None 

138 :type transient: bool 

139 :returns: Resolved configuration mapping. 

140 :rtype: dict[str, Any] 

141 """ 

142 

143 artificery = Artificery(config=config, source=file_path, cache=cache, transient=transient) 

144 resolved = artificery.resolve() 

145 return resolved 

146 

147 

148class Artificery: 

149 """ 

150 Component factory that loads, merges, and resolves config into objects. 

151 """ 

152 

153 def __init__( 

154 self, 

155 *paths: str | Path, 

156 config: dict[str, Any] | None = None, 

157 source: str | None = None, 

158 overrides: dict[str, Any] | None = None, 

159 uses: dict[str, str] | None = None, 

160 cache: Instances | None = None, 

161 transient: bool = False, 

162 ) -> None: 

163 """ 

164 Initialize the Artificery. 

165 

166 :param paths: Config file paths to load and deep-merge in order. 

167 :param config: Pre-loaded configuration mapping. 

168 :param source: Label for error messages (auto-derived from paths if omitted). 

169 :param overrides: Dotted-path overrides applied after merging, before interpolation. 

170 :param uses: Dotted-path mappings (target -> source) copied within the merged config. 

171 :param cache: Optional instance cache. 

172 :param transient: If True, bypass caching. 

173 :type paths: str | pathlib.Path 

174 :type config: dict | None 

175 :type source: str | None 

176 :type overrides: dict[str, Any] | None 

177 :type uses: dict[str, str] | None 

178 :type cache: Instances | None 

179 :type transient: bool 

180 :raises ValueError: If no config source is provided. 

181 """ 

182 if not paths and config is None: 

183 raise ValueError("Artificery requires at least one path or a config dict.") 

184 

185 self._active_config: dict[str, Any] = dict() 

186 self._cache = cache or Instances() 

187 self._overrides = overrides or dict() 

188 self._paths = tuple(Path(path) for path in paths) 

189 self._prepared: dict[str, Any] | None = None 

190 self._raw_config = config 

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

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

193 self._source = source 

194 self._uses = uses or dict() 

195 self._transient = transient 

196 

197 @property 

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

199 """ 

200 The loaded, merged, and interpolated configuration (before resolution). 

201 

202 :returns: Prepared configuration mapping. 

203 :rtype: dict[str, Any] 

204 """ 

205 if not self._prepared: 

206 self._prepared = self._prepare() 

207 return self._prepared 

208 

209 @property 

210 def _source_label(self) -> str | None: 

211 """ 

212 Derive a source label for error messages. 

213 """ 

214 if self._source is not None: 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true

215 return self._source 

216 elif self._paths: 

217 return str(self._paths[-1]) 

218 else: 

219 return None 

220 

221 def _prepare(self) -> dict[str, Any]: 

222 """ 

223 Load, merge, apply overrides, and interpolate the config. 

224 

225 :returns: Prepared configuration mapping. 

226 :rtype: dict[str, Any] 

227 """ 

228 if self._paths: 

229 merged = self._load_and_merge() 

230 if self._raw_config is not None: 230 ↛ 231line 230 didn't jump to line 231 because the condition on line 230 was never true

231 merged = _deep_merge(merged, self._raw_config) 

232 merged = self._apply_uses(merged) 

233 merged = self._apply_overrides(merged) 

234 return _interpolate_config(merged, file_path=self._source_label) 

235 elif self._raw_config is not None: 235 ↛ 238line 235 didn't jump to line 238 because the condition on line 235 was always true

236 return self._raw_config 

237 else: 

238 raise ValueError("Artificery has no config source.") 

239 

240 def _load_and_merge(self) -> dict[str, Any]: 

241 """ 

242 Load multiple config files and deep-merge in order. 

243 

244 :returns: Merged raw config mapping. 

245 :rtype: dict[str, Any] 

246 """ 

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

248 for path in self._paths: 

249 resolved_path = path.expanduser().resolve() 

250 visited: set[Path] = set() 

251 data = _load_with_includes(resolved_path, ancestors=[], visited=visited) 

252 merged = _deep_merge(merged, data) 

253 return merged 

254 

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

256 """ 

257 Copy values within the config for each use mapping. 

258 

259 :param config: Merged raw config. 

260 :type config: dict[str, Any] 

261 :returns: Modified config. 

262 :rtype: dict[str, Any] 

263 """ 

264 for target_path, source_path in self._uses.items(): 

265 value = _get_by_path(config, source_path) 

266 if value is _MISSING: 

267 available = sorted(config.keys()) 

268 raise ConfigReferenceError( 

269 f"Use source '{source_path}' not found. " 

270 f"Available top-level keys: {available}", 

271 file_path=self._source_label, 

272 config_path=target_path, 

273 ) 

274 _set_by_path(config, target_path, value) 

275 return config 

276 

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

278 """ 

279 Apply dotted-path overrides to the config. 

280 

281 :param config: Merged raw config. 

282 :type config: dict[str, Any] 

283 :returns: Modified config. 

284 :rtype: dict[str, Any] 

285 """ 

286 for key, value in self._overrides.items(): 

287 _set_by_path(config, key, value) 

288 return config 

289 

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

291 """ 

292 Validate and resolve the full config mapping into objects. 

293 

294 :returns: Resolved configuration mapping. 

295 :rtype: dict[str, Any] 

296 """ 

297 active = self.config 

298 self._active_config = active 

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

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

301 self._validate_schema(active, path=[]) 

302 self._validate_refs(active) 

303 return self._resolve_value(active, path=[]) 

304 

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

306 """ 

307 Validate schema rules for reserved keys. 

308 

309 :param value: Value to validate. 

310 :param path: Dotted path context. 

311 :type value: object 

312 :type path: list[str] 

313 :raises ValidationError: On schema violations. 

314 """ 

315 if isinstance(value, dict): 

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

317 raise ValidationError( 

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

319 file_path=self._source_label, 

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

321 ) 

322 elif "_type" in value and "_func" in value: 322 ↛ 323line 322 didn't jump to line 323 because the condition on line 322 was never true

323 raise ValidationError( 

324 "_type and _func are mutually exclusive.", 

325 file_path=self._source_label, 

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

327 ) 

328 elif "_instance" in value and "_type" not in value: 328 ↛ 329line 328 didn't jump to line 329 because the condition on line 328 was never true

329 raise ValidationError( 

330 "_instance is only valid with _type.", 

331 file_path=self._source_label, 

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

333 ) 

334 

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

336 next_path = path + [str(key)] 

337 self._validate_schema(child, path=next_path) 

338 

339 elif isinstance(value, list) or isinstance(value, tuple): 

340 for idx, child in enumerate(value): 

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

342 

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

344 """ 

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

346 

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

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

349 Stops validation at dict boundaries since deeper segments 

350 may be attribute accesses on constructed objects. 

351 

352 :param config: Configuration mapping. 

353 :type config: dict[str, Any] 

354 :raises ConfigReferenceError: If any target is missing. 

355 """ 

356 for ref_path, ref_key in _collect_refs(config): 

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

358 if top not in config: 

359 raise ConfigReferenceError( 

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

361 file_path=self._source_label, 

362 config_path=ref_path, 

363 ) 

364 current = config[top] 

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

366 checked = top 

367 for segment in segments: 

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

369 break 

370 elif "_type" in current or "_func" in current: 

371 break 

372 elif segment not in current: 372 ↛ 380line 372 didn't jump to line 380 because the condition on line 372 was always true

373 raise ConfigReferenceError( 

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

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

376 file_path=self._source_label, 

377 config_path=ref_path, 

378 ) 

379 else: 

380 current = current[segment] 

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

382 

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

384 """ 

385 Resolve any value recursively. 

386 

387 :param value: Value to resolve. 

388 :param path: Dotted path context. 

389 :type value: object 

390 :type path: list[str] 

391 :returns: Resolved value. 

392 :rtype: object 

393 """ 

394 if isinstance(value, dict): 

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

396 elif isinstance(value, list): 

397 return [ 

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

399 for idx, item in enumerate(value) 

400 ] 

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

402 return tuple( 

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

404 for idx, item in enumerate(value) 

405 ) 

406 else: 

407 return value 

408 

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

410 """ 

411 Resolve a dict, applying reserved-key rules. 

412 

413 :param value: Mapping to resolve. 

414 :param path: Dotted path context. 

415 :type value: dict[str, Any] 

416 :type path: list[str] 

417 :returns: Resolved mapping or constructed object. 

418 :rtype: object 

419 """ 

420 if "_ref" in value: 

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

422 elif "_type" in value: 

423 return self._resolve_type(value, path) 

424 elif "_func" in value: 

425 return self._resolve_func(value, path) 

426 elif value.get("_deep") is False: 

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

428 elif "_entries" in value: 

429 return self._resolve_entries(value, path) 

430 else: 

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

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

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

434 return resolved 

435 

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

437 """ 

438 Resolve a ``_ref`` mapping. 

439 

440 :param ref: Reference value. 

441 :param path: Dotted path context. 

442 :type ref: object 

443 :type path: list[str] 

444 :returns: Resolved referenced object or attribute. 

445 :rtype: object 

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

447 """ 

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

449 raise ConfigReferenceError( 

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

451 file_path=self._source_label, 

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

453 ) 

454 else: 

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

456 target = self._resolve_top_level(top, path) 

457 if remainder: 

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

459 try: 

460 target = getattr(target, attr) 

461 except AttributeError as exc: 

462 raise ConfigReferenceError( 

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

464 file_path=self._source_label, 

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

466 ) from exc 

467 return target 

468 

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

470 """ 

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

472 

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

474 :param path: Dotted path context. 

475 :type key: str 

476 :type path: list[str] 

477 :returns: Resolved value. 

478 :rtype: object 

479 :raises CircularReferenceError: If a cycle is detected. 

480 """ 

481 if key in self._resolved_top: 

482 return self._resolved_top[key] 

483 elif key in self._resolving_top: 

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

485 raise CircularReferenceError( 

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

487 file_path=self._source_label, 

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

489 ) 

490 elif key not in self._active_config: 490 ↛ 491line 490 didn't jump to line 491 because the condition on line 490 was never true

491 raise ConfigReferenceError( 

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

493 file_path=self._source_label, 

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

495 ) 

496 else: 

497 self._resolving_top.append(key) 

498 resolved = self._resolve_value(self._active_config[key], path=[key]) 

499 self._resolving_top.pop() 

500 self._resolved_top[key] = resolved 

501 return resolved 

502 

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

504 """ 

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

506 

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

508 :param path: Dotted path context. 

509 :type value: dict[str, Any] 

510 :type path: list[str] 

511 :returns: Resolved mapping. 

512 :rtype: dict[Any, Any] 

513 :raises ResolutionError: If entries are malformed. 

514 """ 

515 entries = value.get("_entries") 

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

517 raise ResolutionError( 

518 "_entries must be a list.", 

519 file_path=self._source_label, 

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

521 ) 

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

523 raise ResolutionError( 

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

525 file_path=self._source_label, 

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

527 ) 

528 else: 

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

530 for idx, entry in enumerate(entries): 

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

532 raise ResolutionError( 

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

534 file_path=self._source_label, 

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

536 ) 

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

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

539 try: 

540 hash(key) 

541 except Exception as exc: 

542 raise ResolutionError( 

543 "Resolved _entries keys must be hashable.", 

544 file_path=self._source_label, 

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

546 ) from exc 

547 

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

549 raise ResolutionError( 

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

551 file_path=self._source_label, 

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

553 ) 

554 else: 

555 resolved[key] = val 

556 return resolved 

557 

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

559 """ 

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

561 

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

563 :param path: Dotted path context. 

564 :type value: dict[str, Any] 

565 :type path: list[str] 

566 :returns: Imported callable. 

567 :rtype: object 

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

569 """ 

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

571 if extras: 

572 raise ValidationError( 

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

574 file_path=self._source_label, 

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

576 ) 

577 else: 

578 func_path = value.get("_func") 

579 try: 

580 return import_dotted_path( 

581 func_path, 

582 file_path=self._source_label, 

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

584 ) 

585 except ImportResolutionError: 

586 raise 

587 

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

589 """ 

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

591 

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

593 :param path: Dotted path context. 

594 :type value: dict[str, Any] 

595 :type path: list[str] 

596 :returns: Constructed instance (possibly cached). 

597 :rtype: object 

598 """ 

599 type_path = value.get("_type") 

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

601 extra_kwargs = value.get("_kwargs", {}) 

602 instance = value.get("_instance") 

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

604 raise ResolutionError( 

605 "_args must be a list.", 

606 file_path=self._source_label, 

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

608 ) 

609 elif not isinstance(extra_kwargs, dict): 

610 raise ResolutionError( 

611 "_kwargs must be a mapping.", 

612 file_path=self._source_label, 

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

614 ) 

615 else: 

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

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

618 resolved_kwargs = { 

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

620 } 

621 for key, val in extra_kwargs.items(): 

622 resolved_kwargs[key] = self._resolve_value(val, path=path + ["_kwargs", key]) 

623 

624 target = import_dotted_path( 

625 type_path, 

626 file_path=self._source_label, 

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

628 ) 

629 

630 resolved_kwargs = _validate_signature( 

631 target, 

632 resolved_args, 

633 resolved_kwargs, 

634 file_path=self._source_label, 

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

636 ) 

637 

638 def factory() -> Any: 

639 try: 

640 return target(*resolved_args, **resolved_kwargs) 

641 except Exception as exc: # noqa: BLE001 

642 raise ConstructorError( 

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

644 file_path=self._source_label, 

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

646 ) from exc 

647 

648 instance = self._cache.get_or_create( 

649 type_path, 

650 instance, 

651 resolved_args, 

652 resolved_kwargs, 

653 factory, 

654 transient=self._transient, 

655 file_path=self._source_label, 

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

657 ) 

658 return instance 

659 

660 

661def _collect_refs(value: Any, *, path: list[str] | None = None) -> Iterable[tuple[str, str]]: 

662 """ 

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

664 

665 :param value: Root value to inspect. 

666 :param path: Current path stack. 

667 :type value: object 

668 :type path: list[str] | None 

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

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

671 """ 

672 path = path or list() 

673 if isinstance(value, dict): 

674 if "_ref" in value: 

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

676 return 

677 else: 

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

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

680 elif isinstance(value, list) or isinstance(value, tuple): 

681 for idx, child in enumerate(value): 

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

683 

684 

685def _validate_signature( 

686 target: Any, 

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

688 kwargs: dict[str, Any], 

689 *, 

690 file_path: str | None, 

691 config_path: str, 

692) -> dict[str, Any]: 

693 """ 

694 Validate constructor signature and filter extra kwargs. 

695 

696 :param target: Callable to validate. 

697 :param args: Positional arguments. 

698 :param kwargs: Keyword arguments. 

699 :param file_path: Source config path for context. 

700 :param config_path: Dotted config path for context. 

701 :type target: object 

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

703 :type kwargs: dict[str, Any] 

704 :type file_path: str | None 

705 :type config_path: str 

706 :returns: Possibly filtered kwargs. 

707 :rtype: dict[str, Any] 

708 :raises ConstructorError: If required parameters are missing. 

709 """ 

710 try: 

711 signature = inspect.signature(target) 

712 except (TypeError, ValueError): 

713 return kwargs 

714 

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

716 if not has_var_kw: 

717 allowed = { 

718 name 

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

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

721 } 

722 extras = set(kwargs) - allowed 

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

724 warnings.warn( 

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

726 RuntimeWarning, 

727 stacklevel=2, 

728 ) 

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

730 

731 try: 

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

733 except TypeError as exc: 

734 raise ConstructorError( 

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

736 file_path=file_path, 

737 config_path=config_path, 

738 ) from exc 

739 

740 missing = list() 

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

742 if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD): 

743 continue 

744 elif param.default is not param.empty: 

745 continue 

746 elif name not in bound.arguments: 746 ↛ 747line 746 didn't jump to line 747 because the condition on line 746 was never true

747 missing.append(name) 

748 

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

750 raise ConstructorError( 

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

752 file_path=file_path, 

753 config_path=config_path, 

754 ) 

755 return kwargs 

756 

757 

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

759 pass