Coverage for sygaldry / artificery.py: 88%

279 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-10 13:02 -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 _maybe_download, 

28 load_config, 

29) 

30from .types import RESERVED_KEYS, import_dotted_path 

31 

32 

33@dataclass(frozen=True) 

34class Reference: 

35 """ 

36 Reference to a dotted config path. 

37 """ 

38 

39 path: str 

40 

41 

42@dataclass(frozen=True) 

43class ConstructorSpec: 

44 """ 

45 Constructor specification for a component. 

46 """ 

47 

48 target: str 

49 args: tuple[Any, ...] 

50 kwargs: dict[str, Any] 

51 instance: str | None 

52 

53 

54@dataclass(frozen=True) 

55class Node: 

56 """ 

57 Intermediate node representation for optional IR usage. 

58 """ 

59 

60 name: str 

61 children: tuple[str, ...] 

62 bindings: dict[str, Any] 

63 

64 

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

66 """ 

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

68 

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

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

71 :param value: Value to set. 

72 :type data: dict 

73 :type dotted_path: str 

74 :type value: object 

75 """ 

76 segments = dotted_path.split(".") 

77 current = data 

78 for segment in segments[:-1]: 

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

80 current[segment] = dict() 

81 current = current[segment] 

82 current[segments[-1]] = value 

83 

84 

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

86 """ 

87 Load a config file without resolving components. 

88 

89 :param path: Path to a YAML or TOML config file, or an HTTP(S) URL. 

90 :type path: str | pathlib.Path 

91 :returns: Parsed and interpolated mapping. 

92 :rtype: dict[str, Any] 

93 """ 

94 return load_config(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, or an HTTP(S) URL. 

107 :type path: str | pathlib.Path 

108 """ 

109 self._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 if isinstance(path, str) else 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 local = _maybe_download(path) 

250 resolved_path = local.expanduser().resolve() 

251 visited: set[Path] = set() 

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

253 merged = _deep_merge(merged, data) 

254 return merged 

255 

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

257 """ 

258 Copy values within the config for each use mapping. 

259 

260 :param config: Merged raw config. 

261 :type config: dict[str, Any] 

262 :returns: Modified config. 

263 :rtype: dict[str, Any] 

264 """ 

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

266 value = _get_by_path(config, source_path) 

267 if value is _MISSING: 

268 available = sorted(config.keys()) 

269 raise ConfigReferenceError( 

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

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

272 file_path=self._source_label, 

273 config_path=target_path, 

274 ) 

275 _set_by_path(config, target_path, value) 

276 return config 

277 

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

279 """ 

280 Apply dotted-path overrides to the config. 

281 

282 :param config: Merged raw config. 

283 :type config: dict[str, Any] 

284 :returns: Modified config. 

285 :rtype: dict[str, Any] 

286 """ 

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

288 _set_by_path(config, key, value) 

289 return config 

290 

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

292 """ 

293 Validate and resolve the full config mapping into objects. 

294 

295 :returns: Resolved configuration mapping. 

296 :rtype: dict[str, Any] 

297 """ 

298 active = self.config 

299 self._active_config = active 

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

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

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

303 self._validate_refs(active) 

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

305 

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

307 """ 

308 Validate schema rules for reserved keys. 

309 

310 :param value: Value to validate. 

311 :param path: Dotted path context. 

312 :type value: object 

313 :type path: list[str] 

314 :raises ValidationError: On schema violations. 

315 """ 

316 if isinstance(value, dict): 

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

318 raise ValidationError( 

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

320 file_path=self._source_label, 

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

322 ) 

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

324 raise ValidationError( 

325 "_type and _func are mutually exclusive.", 

326 file_path=self._source_label, 

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

328 ) 

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

330 raise ValidationError( 

331 "_instance is only valid with _type.", 

332 file_path=self._source_label, 

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

334 ) 

335 

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

337 next_path = path + [str(key)] 

338 self._validate_schema(child, path=next_path) 

339 

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

341 for idx, child in enumerate(value): 

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

343 

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

345 """ 

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

347 

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

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

350 Stops validation at dict boundaries since deeper segments 

351 may be attribute accesses on constructed objects. 

352 

353 :param config: Configuration mapping. 

354 :type config: dict[str, Any] 

355 :raises ConfigReferenceError: If any target is missing. 

356 """ 

357 for ref_path, ref_key in _collect_refs(config): 

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

359 if top not in config: 

360 raise ConfigReferenceError( 

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

362 file_path=self._source_label, 

363 config_path=ref_path, 

364 ) 

365 current = config[top] 

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

367 checked = top 

368 for segment in segments: 

369 if not isinstance(current, dict) or "_type" in current or "_func" in current: 

370 break 

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

372 raise ConfigReferenceError( 

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

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

375 file_path=self._source_label, 

376 config_path=ref_path, 

377 ) 

378 else: 

379 current = current[segment] 

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

381 

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

383 """ 

384 Resolve any value recursively. 

385 

386 :param value: Value to resolve. 

387 :param path: Dotted path context. 

388 :type value: object 

389 :type path: list[str] 

390 :returns: Resolved value. 

391 :rtype: object 

392 """ 

393 if isinstance(value, dict): 

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

395 elif isinstance(value, list): 

396 return [ 

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

398 for idx, item in enumerate(value) 

399 ] 

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

401 return tuple( 

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

403 for idx, item in enumerate(value) 

404 ) 

405 else: 

406 return value 

407 

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

409 """ 

410 Resolve a dict, applying reserved-key rules. 

411 

412 :param value: Mapping to resolve. 

413 :param path: Dotted path context. 

414 :type value: dict[str, Any] 

415 :type path: list[str] 

416 :returns: Resolved mapping or constructed object. 

417 :rtype: object 

418 """ 

419 if "_ref" in value: 

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

421 elif "_type" in value: 

422 return self._resolve_type(value, path) 

423 elif "_func" in value: 

424 return self._resolve_func(value, path) 

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

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

427 elif "_entries" in value: 

428 return self._resolve_entries(value, path) 

429 else: 

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

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

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

433 return resolved 

434 

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

436 """ 

437 Resolve a ``_ref`` mapping. 

438 

439 :param ref: Reference value. 

440 :param path: Dotted path context. 

441 :type ref: object 

442 :type path: list[str] 

443 :returns: Resolved referenced object or attribute. 

444 :rtype: object 

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

446 """ 

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

448 raise ConfigReferenceError( 

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

450 file_path=self._source_label, 

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

452 ) 

453 else: 

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

455 target = self._resolve_top_level(top, path) 

456 if remainder: 

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

458 try: 

459 target = getattr(target, attr) 

460 except AttributeError as exc: 

461 raise ConfigReferenceError( 

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

463 file_path=self._source_label, 

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

465 ) from exc 

466 return target 

467 

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

469 """ 

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

471 

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

473 :param path: Dotted path context. 

474 :type key: str 

475 :type path: list[str] 

476 :returns: Resolved value. 

477 :rtype: object 

478 :raises CircularReferenceError: If a cycle is detected. 

479 """ 

480 if key in self._resolved_top: 

481 return self._resolved_top[key] 

482 elif key in self._resolving_top: 

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

484 raise CircularReferenceError( 

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

486 file_path=self._source_label, 

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

488 ) 

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

490 raise ConfigReferenceError( 

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

492 file_path=self._source_label, 

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

494 ) 

495 else: 

496 self._resolving_top.append(key) 

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

498 self._resolving_top.pop() 

499 self._resolved_top[key] = resolved 

500 return resolved 

501 

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

503 """ 

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

505 

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

507 :param path: Dotted path context. 

508 :type value: dict[str, Any] 

509 :type path: list[str] 

510 :returns: Resolved mapping. 

511 :rtype: dict[Any, Any] 

512 :raises ResolutionError: If entries are malformed. 

513 """ 

514 entries = value.get("_entries") 

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

516 raise ResolutionError( 

517 "_entries must be a list.", 

518 file_path=self._source_label, 

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

520 ) 

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

522 raise ResolutionError( 

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

524 file_path=self._source_label, 

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

526 ) 

527 else: 

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

529 for idx, entry in enumerate(entries): 

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

531 raise ResolutionError( 

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

533 file_path=self._source_label, 

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

535 ) 

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

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

538 try: 

539 hash(key) 

540 except Exception as exc: 

541 raise ResolutionError( 

542 "Resolved _entries keys must be hashable.", 

543 file_path=self._source_label, 

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

545 ) from exc 

546 

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

548 raise ResolutionError( 

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

550 file_path=self._source_label, 

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

552 ) 

553 else: 

554 resolved[key] = val 

555 return resolved 

556 

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

558 """ 

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

560 

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

562 :param path: Dotted path context. 

563 :type value: dict[str, Any] 

564 :type path: list[str] 

565 :returns: Imported callable. 

566 :rtype: object 

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

568 """ 

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

570 if extras: 

571 raise ValidationError( 

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

573 file_path=self._source_label, 

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

575 ) 

576 else: 

577 func_path = value.get("_func") 

578 try: 

579 return import_dotted_path( 

580 func_path, 

581 file_path=self._source_label, 

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

583 ) 

584 except ImportResolutionError: 

585 raise 

586 

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

588 """ 

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

590 

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

592 :param path: Dotted path context. 

593 :type value: dict[str, Any] 

594 :type path: list[str] 

595 :returns: Constructed instance (possibly cached). 

596 :rtype: object 

597 """ 

598 type_path = value.get("_type") 

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

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

601 instance = value.get("_instance") 

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

603 raise ResolutionError( 

604 "_args must be a list.", 

605 file_path=self._source_label, 

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

607 ) 

608 elif not isinstance(extra_kwargs, dict): 

609 raise ResolutionError( 

610 "_kwargs must be a mapping.", 

611 file_path=self._source_label, 

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

613 ) 

614 else: 

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

616 resolved_args = tuple( 

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

618 ) 

619 resolved_kwargs = { 

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

621 } 

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

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

624 

625 target = import_dotted_path( 

626 type_path, 

627 file_path=self._source_label, 

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

629 ) 

630 

631 resolved_kwargs = _validate_signature( 

632 target, 

633 resolved_args, 

634 resolved_kwargs, 

635 file_path=self._source_label, 

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

637 ) 

638 

639 def factory() -> Any: 

640 try: 

641 return target(*resolved_args, **resolved_kwargs) 

642 except Exception as exc: # noqa: BLE001 

643 raise ConstructorError( 

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

645 file_path=self._source_label, 

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

647 ) from exc 

648 

649 instance = self._cache.get_or_create( 

650 type_path, 

651 instance, 

652 resolved_args, 

653 resolved_kwargs, 

654 factory, 

655 transient=self._transient, 

656 file_path=self._source_label, 

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

658 ) 

659 return instance 

660 

661 

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

663 """ 

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

665 

666 :param value: Root value to inspect. 

667 :param path: Current path stack. 

668 :type value: object 

669 :type path: list[str] | None 

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

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

672 """ 

673 path = path or list() 

674 if isinstance(value, dict): 

675 if "_ref" in value: 

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

677 return 

678 else: 

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

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

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

682 for idx, child in enumerate(value): 

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

684 

685 

686def _validate_signature( 

687 target: Any, 

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

689 kwargs: dict[str, Any], 

690 *, 

691 file_path: str | None, 

692 config_path: str, 

693) -> dict[str, Any]: 

694 """ 

695 Validate constructor signature and filter extra kwargs. 

696 

697 :param target: Callable to validate. 

698 :param args: Positional arguments. 

699 :param kwargs: Keyword arguments. 

700 :param file_path: Source config path for context. 

701 :param config_path: Dotted config path for context. 

702 :type target: object 

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

704 :type kwargs: dict[str, Any] 

705 :type file_path: str | None 

706 :type config_path: str 

707 :returns: Possibly filtered kwargs. 

708 :rtype: dict[str, Any] 

709 :raises ConstructorError: If required parameters are missing. 

710 """ 

711 try: 

712 signature = inspect.signature(target) 

713 except (TypeError, ValueError): 

714 return kwargs 

715 

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

717 if not has_var_kw: 

718 allowed = { 

719 name 

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

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

722 } 

723 extras = set(kwargs) - allowed 

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

725 warnings.warn( 

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

727 RuntimeWarning, 

728 stacklevel=2, 

729 ) 

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

731 

732 try: 

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

734 except TypeError as exc: 

735 raise ConstructorError( 

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

737 file_path=file_path, 

738 config_path=config_path, 

739 ) from exc 

740 

741 missing = list() 

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

743 if ( 

744 param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD) 

745 or param.default is not param.empty 

746 ): 

747 continue 

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

749 missing.append(name) 

750 

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

752 raise ConstructorError( 

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

754 file_path=file_path, 

755 config_path=config_path, 

756 ) 

757 return kwargs 

758 

759 

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

761 pass