Coverage for src/configuraptor/core.py: 100%

253 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2026-05-01 14:18 +0200

1""" 

2Contains most of the loading logic. 

3""" 

4 

5import dataclasses as dc 

6import io 

7import os 

8import typing 

9import warnings 

10from pathlib import Path 

11from typing import Any, Type 

12 

13import requests 

14from dotenv import dotenv_values as _dotenv_values 

15from dotenv import find_dotenv 

16 

17from . import loaders 

18from .abs import DEFAULT_ENV_SETTING, AnyType, C, T, T_data, Type_C, UseEnvSetting 

19from .alias import Alias, has_alias 

20from .binary_config import BinaryConfig 

21from .errors import ( 

22 ConfigErrorCouldNotConvert, 

23 ConfigErrorInvalidType, 

24 ConfigErrorMissingKey, 

25 FailedToLoad, 

26) 

27from .helpers import ( 

28 all_annotations, 

29 camel_to_snake, 

30 check_type, 

31 dataclass_field, 

32 expand_env_vars_into_toml_values, 

33 find_pyproject_toml, 

34 is_custom_class, 

35 is_optional, 

36 is_parameterized, 

37 is_union, 

38) 

39from .postpone import Postponed 

40from .type_converters import CONVERTERS 

41 

42 

43def _data_for_nested_key(key: str, raw: dict[str, typing.Any]) -> dict[str, typing.Any]: 

44 """ 

45 If a key contains a dot, traverse the raw dict until the right key was found. 

46 

47 Example: 

48 key = some.nested.key 

49 raw = {"some": {"nested": {"key": {"with": "data"}}}} 

50 -> {"with": "data"} 

51 """ 

52 parts = key.split(".") 

53 while parts: 

54 key = parts.pop(0) 

55 if key not in raw: 

56 return {} 

57 

58 raw = raw[key] 

59 

60 return raw 

61 

62 

63def _guess_key(clsname: str) -> str: 

64 """ 

65 If no key is manually defined for `load_into`, \ 

66 the class' name is converted to snake_case to use as the default key. 

67 """ 

68 return camel_to_snake(clsname) 

69 

70 

71def _from_mock_url(url: str) -> str: 

72 """ 

73 Pytest only: when starting a url with mock:// it is expected to just be json afterwards. 

74 """ 

75 return url.removeprefix("mock://") 

76 

77 

78def guess_filetype_for_url(url: str, response: requests.Response = None) -> str: 

79 """ 

80 Based on the url (which may have an extension) and the requests response \ 

81 (which may have a content-type), try to guess the right filetype (-> loader, e.g. json or yaml). 

82 

83 Falls back to JSON if none can be found. 

84 """ 

85 url = url.split("?")[0] 

86 if url_extension := os.path.splitext(url)[1].lower(): 

87 return url_extension.strip(".") 

88 

89 if response and (content_type_header := response.headers.get("content-type", "").split(";")[0].strip()): 

90 content_type = content_type_header.split("/")[-1] 

91 if content_type != "plain": 

92 return content_type 

93 

94 # If both methods fail, default to JSON 

95 return "json" 

96 

97 

98def from_url(url: str, _dummy: bool = False) -> tuple[io.BytesIO, str]: 

99 """ 

100 Load data as bytes into a file-like object and return the file type. 

101 

102 This can be used by __load_data: 

103 > loader = loaders.get(filetype) 

104 > # dev/null exists but always returns b'' 

105 > data = loader(contents, Path("/dev/null")) 

106 """ 

107 if url.startswith("mock://"): 

108 data = _from_mock_url(url) 

109 resp = None 

110 elif _dummy: 

111 resp = None 

112 data = "{}" 

113 else: 

114 ssl_verify = os.getenv("SSL_VERIFY", "1") == "1" 

115 

116 resp = requests.get(url, timeout=10, verify=ssl_verify) 

117 data = resp.text 

118 

119 filetype = guess_filetype_for_url(url, resp) 

120 return io.BytesIO(data.encode()), filetype 

121 

122 

123def dotenv_values() -> dict[str, str | None]: 

124 """Wrapper around dotenv.dotenv_values that uses .env in cwd.""" 

125 return _dotenv_values(dotenv_path=find_dotenv(usecwd=True)) 

126 

127 

128def apply_env(data: dict[str, typing.Any], use_env: UseEnvSetting) -> None: 

129 """ 

130 Apply the desired env-setting logic on data. 

131 """ 

132 match use_env: 

133 case "yes": 

134 env = dotenv_values() | os.environ 

135 case "inverse": 

136 env = os.environ | dotenv_values() 

137 case "dotenv": 

138 env = dotenv_values() 

139 case "environ": 

140 env = {**os.environ} 

141 case _: # pragma: no cover 

142 return 

143 

144 expand_env_vars_into_toml_values(data, env) 

145 

146 

147def _load_data( 

148 data: T_data, 

149 key: str = None, 

150 classname: str = None, 

151 lower_keys: bool = False, 

152 allow_types: tuple[type, ...] = (dict,), 

153 strict: bool = False, 

154 use_env: UseEnvSetting = DEFAULT_ENV_SETTING, 

155) -> dict[str, typing.Any]: 

156 """ 

157 Tries to load the right data from a filename/path or dict, based on a manual key or a classname. 

158 

159 E.g. class Tool will be mapped to key tool. 

160 It also deals with nested keys (tool.extra -> {"tool": {"extra": ...}} 

161 """ 

162 if isinstance(data, bytes): 

163 # instantly return, don't modify 

164 # bytes as inputs -> bytes as output 

165 # but since `T_data` is re-used, that's kind of hard to type for mypy. 

166 return data # type: ignore 

167 

168 if isinstance(data, list): 

169 if not data: 

170 raise ValueError("Empty list passed!") 

171 

172 final_data: dict[str, typing.Any] = {} 

173 for source in data: 

174 final_data |= load_data( 

175 source, 

176 key=key, 

177 classname=classname, 

178 lower_keys=True, 

179 allow_types=allow_types, 

180 strict=strict, 

181 use_env=use_env, 

182 ) 

183 

184 return final_data 

185 

186 if isinstance(data, str): 

187 if data.startswith(("http://", "https://", "mock://")): 

188 contents, filetype = from_url(data) 

189 

190 loader = loaders.get(filetype) 

191 # dev/null exists but always returns b'' 

192 data = loader(contents, Path("/dev/null")) 

193 else: 

194 data = Path(data) 

195 

196 if isinstance(data, Path): 

197 with data.open("rb") as f: 

198 loader = loaders.get(data.suffix or data.name) 

199 data = loader(f, data.resolve()) 

200 

201 if not data: 

202 return {} 

203 

204 if key is None: 

205 # try to guess key by grabbing the first one or using the class name 

206 if len(data) == 1: 

207 key = next(iter(data.keys())) 

208 elif classname is not None: 

209 key = _guess_key(classname) 

210 

211 if key: 

212 data = _data_for_nested_key(key, data) 

213 

214 if not data: 

215 raise ValueError("No data found!") 

216 

217 if not isinstance(data, allow_types): 

218 raise ValueError(f"Data should be one of {allow_types} but it is {type(data)}!") 

219 

220 if lower_keys and isinstance(data, dict): 

221 data = {k.lower(): v for k, v in data.items()} 

222 

223 if use_env != "no" and isinstance(data, dict): 

224 apply_env(data, use_env) 

225 

226 return typing.cast(dict[str, typing.Any], data) 

227 

228 

229def load_data( 

230 data: T_data, 

231 key: str = None, 

232 classname: str = None, 

233 lower_keys: bool = False, 

234 allow_types: tuple[type, ...] = (dict,), 

235 strict: bool = False, 

236 use_env: UseEnvSetting = DEFAULT_ENV_SETTING, 

237) -> dict[str, typing.Any]: 

238 """ 

239 Wrapper around __load_data that retries with key="" if anything goes wrong. 

240 """ 

241 if data is None: 

242 # try to load pyproject.toml 

243 data = find_pyproject_toml() 

244 

245 try: 

246 return _load_data( 

247 data, 

248 key, 

249 classname, 

250 lower_keys=lower_keys, 

251 allow_types=allow_types, 

252 strict=strict, 

253 use_env=use_env, 

254 ) 

255 except Exception as e: 

256 # sourcery skip: remove-unnecessary-else, simplify-empty-collection-comparison, swap-if-else-branches 

257 # @sourcery: `key != ""` is NOT the same as `not key` 

258 if key != "": 

259 # try again with key "" 

260 return load_data( 

261 data, 

262 "", 

263 classname, 

264 lower_keys=lower_keys, 

265 allow_types=allow_types, 

266 strict=strict, 

267 use_env=use_env, 

268 ) 

269 elif strict: 

270 raise FailedToLoad(data) from e 

271 else: 

272 # e.g. if settings are to be loaded via a URL that is unavailable or returns invalid json 

273 warnings.warn(f"Data ('{data!r}') could not be loaded", source=e, category=UserWarning) 

274 return {} 

275 

276 

277F = typing.TypeVar("F") 

278 

279 

280def convert_between(from_value: F, from_type: Type[F], to_type: Type[T]) -> T: 

281 """ 

282 Convert a value between types. 

283 """ 

284 if converter := CONVERTERS.get((from_type, to_type)): 

285 return typing.cast(T, converter(from_value)) 

286 

287 # default: just convert type: 

288 return to_type(from_value) # type: ignore 

289 

290 

291def check_and_convert_type(value: Any, _type: Type[T], convert_types: bool, key: str = "variable") -> T: 

292 """ 

293 Checks if the given value matches the specified type. If it does, the value is returned as is. 

294 

295 Args: 

296 value (Any): The value to be checked and potentially converted. 

297 _type (Type[T]): The expected type for the value. 

298 convert_types (bool): If True, allows type conversion if the types do not match. 

299 key (str, optional): The name or key associated with the variable (used in error messages). 

300 Defaults to "variable". 

301 

302 Returns: 

303 T: The value, potentially converted to the expected type. 

304 

305 Raises: 

306 ConfigErrorInvalidType: If the type does not match, and type conversion is not allowed. 

307 ConfigErrorCouldNotConvert: If type conversion fails. 

308 """ 

309 if check_type(value, _type): 

310 # type matches 

311 return value 

312 

313 if isinstance(value, Alias): 

314 if is_optional(_type): 

315 return typing.cast(T, None) 

316 else: 

317 # unresolved alias, error should've already been thrown for parent but lets do it again: 

318 raise ConfigErrorInvalidType(value.to, value=value, expected_type=_type) 

319 

320 if not convert_types: 

321 # type does not match and should not be converted 

322 raise ConfigErrorInvalidType(key, value=value, expected_type=_type) 

323 

324 # else: type does not match, try to convert it 

325 try: 

326 return convert_between(value, type(value), _type) 

327 except (TypeError, ValueError) as e: 

328 raise ConfigErrorCouldNotConvert(type(value), _type, value) from e 

329 

330 

331def ensure_types( 

332 data: dict[str, T], 

333 annotations: dict[str, type[T]], 

334 convert_types: bool = False, 

335) -> dict[str, T | None]: 

336 """ 

337 Make sure all values in 'data' are in line with the ones stored in 'annotations'. 

338 

339 If an annotated key in missing from data, it will be filled with None for convenience. 

340 

341 TODO: python 3.11 exception groups to throw multiple errors at once! 

342 """ 

343 # custom object to use instead of None, since typing.Optional can be None! 

344 # cast to T to make mypy happy 

345 notfound = typing.cast(T, object()) 

346 

347 final: dict[str, T | None] = {} 

348 for key, _type in annotations.items(): 

349 compare = data.get(key, notfound) 

350 if compare is notfound: # pragma: nocover 

351 warnings.warn("This should not happen since `load_recursive` already fills `data` based on `annotations`") 

352 # skip! 

353 continue 

354 

355 if isinstance(compare, Postponed): 

356 # don't do anything with this item! 

357 continue 

358 

359 if isinstance(compare, Alias): 

360 related_data = data.get(compare.to, notfound) 

361 if related_data is not notfound: 

362 if isinstance(related_data, Postponed): 

363 # also continue alias for postponed items 

364 continue 

365 

366 # original key set, update alias 

367 compare = related_data 

368 

369 compare = check_and_convert_type(compare, _type, convert_types, key) 

370 

371 final[key] = compare 

372 

373 return final 

374 

375 

376def convert_key(key: str) -> str: 

377 """ 

378 Replaces '-' and '.' in keys with '_' so it can be mapped to the Config properties. 

379 """ 

380 return key.replace("-", "_").replace(".", "_") 

381 

382 

383def convert_config(items: dict[str, T]) -> dict[str, T]: 

384 """ 

385 Converts the config dict (from toml) or 'overwrites' dict in two ways. 

386 

387 1. removes any items where the value is None, since in that case the default should be used; 

388 2. replaces '-' and '.' in keys with '_' so it can be mapped to the Config properties. 

389 """ 

390 return {convert_key(k): v for k, v in items.items() if v is not None} 

391 

392 

393def load_recursive( 

394 cls: AnyType, 

395 data: dict[str, T], 

396 annotations: dict[str, AnyType], 

397 convert_types: bool = False, 

398) -> dict[str, T]: 

399 """ 

400 For all annotations (recursively gathered from parents with `all_annotations`), \ 

401 try to resolve the tree of annotations. 

402 

403 Uses `load_into_recurse`, not itself directly. 

404 

405 Example: 

406 class First: 

407 key: str 

408 

409 class Second: 

410 other: First 

411 

412 # step 1 

413 cls = Second 

414 data = {"second": {"other": {"key": "anything"}}} 

415 annotations: {"other": First} 

416 

417 # step 1.5 

418 data = {"other": {"key": "anything"} 

419 annotations: {"other": First} 

420 

421 # step 2 

422 cls = First 

423 data = {"key": "anything"} 

424 annotations: {"key": str} 

425 

426 

427 TODO: python 3.11 exception groups to throw multiple errors at once! 

428 """ 

429 updated = {} 

430 

431 for _key, _type in annotations.items(): 

432 if _key in data: 

433 value: typing.Any = data[_key] # value can change so define it as any instead of T 

434 if is_parameterized(_type): 

435 origin = typing.get_origin(_type) 

436 arguments = typing.get_args(_type) 

437 if origin is list and arguments and is_custom_class(arguments[0]): 

438 subtype = arguments[0] 

439 value = [_load_into_recurse(subtype, subvalue, convert_types=convert_types) for subvalue in value] 

440 

441 elif origin is dict and arguments and is_custom_class(arguments[1]): 

442 # e.g. dict[str, Point] 

443 subkeytype, subvaluetype = arguments 

444 # subkey(type) is not a custom class, so don't try to convert it: 

445 value = { 

446 subkey: _load_into_recurse(subvaluetype, subvalue, convert_types=convert_types) 

447 for subkey, subvalue in value.items() 

448 } 

449 # elif origin is dict: 

450 # keep data the same 

451 elif is_union(_type) and arguments: 

452 for arg in arguments: 

453 if is_custom_class(arg): 

454 value = _load_into_recurse(arg, value, convert_types=convert_types) 

455 

456 elif is_custom_class(_type): 

457 # type must be C (custom class) at this point; includes dataclass but not optional[cls] 

458 value = _load_into_recurse( 

459 # make mypy and pycharm happy by telling it _type is of type C... 

460 # actually just passing _type as first arg! 

461 typing.cast(Type_C[typing.Any], _type), 

462 value, 

463 convert_types=convert_types, 

464 ) 

465 

466 # else: normal value, don't change 

467 

468 elif value := has_alias(cls, _key, data): 

469 # value updated by alias 

470 ... 

471 elif _key in cls.__dict__: 

472 # property has default, use that instead. 

473 value = cls.__dict__[_key] 

474 elif is_optional(_type): 

475 # type is optional and not found in __dict__ -> default is None 

476 value = None 

477 elif dc.is_dataclass(cls) and (field := dataclass_field(cls, _key)) and field.default_factory is not dc.MISSING: 

478 # could have a default factory 

479 # todo: do something with field.default? 

480 value = field.default_factory() 

481 elif is_custom_class(_type) and isinstance(_type, type) and issubclass(_type, Defaultable): 

482 value = _type.default() 

483 else: 

484 raise ConfigErrorMissingKey(_key, cls, _type) 

485 

486 updated[_key] = value 

487 

488 return updated 

489 

490 

491def check_and_convert_data( 

492 cls: typing.Type[C], 

493 data: dict[str, typing.Any], 

494 _except: typing.Iterable[str], 

495 strict: bool = True, 

496 convert_types: bool = False, 

497) -> dict[str, typing.Any]: 

498 """ 

499 Based on class annotations, this prepares the data for `load_into_recurse`. 

500 

501 1. convert config-keys to python compatible config_keys 

502 2. loads custom class type annotations with the same logic (see also `load_recursive`) 

503 3. ensures the annotated types match the actual types after loading the config file. 

504 """ 

505 annotations = all_annotations(cls, _except=_except) 

506 

507 to_load = convert_config(data) 

508 to_load = load_recursive(cls, to_load, annotations, convert_types=convert_types) 

509 

510 if strict: 

511 to_load = ensure_types(to_load, annotations, convert_types=convert_types) 

512 

513 return to_load 

514 

515 

516T_init_list = list[typing.Any] 

517T_init_dict = dict[str, typing.Any] 

518T_init = tuple[T_init_list, T_init_dict] | T_init_list | T_init_dict | None 

519 

520 

521@typing.no_type_check # (mypy doesn't understand 'match' fully yet) 

522def _split_init(init: T_init) -> tuple[T_init_list, T_init_dict]: 

523 """ 

524 Accept a tuple, a dict or a list of (arg, kwarg), {kwargs: ...}, [args] respectively and turn them all into a tuple. 

525 """ 

526 if not init: 

527 return [], {} 

528 

529 args: T_init_list = [] 

530 kwargs: T_init_dict = {} 

531 match init: 

532 case (args, kwargs): 

533 return args, kwargs 

534 case [*args]: 

535 return args, {} 

536 case {**kwargs}: 

537 return [], kwargs 

538 case _: 

539 raise ValueError("Init must be either a tuple of list and dict, a list or a dict.") 

540 

541 

542def _load_into_recurse( 

543 cls: typing.Type[C], 

544 data: dict[str, typing.Any] | bytes, 

545 init: T_init = None, 

546 strict: bool = True, 

547 convert_types: bool = False, 

548) -> C: 

549 """ 

550 Loads an instance of `cls` filled with `data`. 

551 

552 Uses `load_recursive` to load any fillable annotated properties (see that method for an example). 

553 `init` can be used to optionally pass extra __init__ arguments. \ 

554 NOTE: This will overwrite a config key with the same name! 

555 """ 

556 init_args, init_kwargs = _split_init(init) 

557 

558 if isinstance(data, bytes) or issubclass(cls, BinaryConfig): 

559 if not isinstance(data, (bytes, dict)): # pragma: no cover 

560 raise NotImplementedError("BinaryConfig can only deal with `bytes` or a dict of bytes as input.") 

561 elif not issubclass(cls, BinaryConfig): # pragma: no cover 

562 raise NotImplementedError("Only BinaryConfig can be used with `bytes` (or a dict of bytes) as input.") 

563 

564 inst = typing.cast(C, cls._parse_into(data)) 

565 elif dc.is_dataclass(cls): 

566 to_load = check_and_convert_data(cls, data, init_kwargs.keys(), strict=strict, convert_types=convert_types) 

567 if init: 

568 raise ValueError("Init is not allowed for dataclasses!") 

569 

570 # ensure mypy inst is an instance of the cls type (and not a fictuous `DataclassInstance`) 

571 inst = typing.cast(C, cls(**to_load)) 

572 elif isinstance(data, cls): 

573 # already the right type! (e.g. Pathlib) 

574 inst = typing.cast(C, data) 

575 else: 

576 inst = cls(*init_args, **init_kwargs) 

577 to_load = check_and_convert_data(cls, data, inst.__dict__.keys(), strict=strict, convert_types=convert_types) 

578 inst.__dict__.update(**to_load) 

579 

580 return inst 

581 

582 

583def _load_into_instance( 

584 inst: C, 

585 cls: typing.Type[C], 

586 data: dict[str, typing.Any], 

587 init: T_init = None, 

588 strict: bool = True, 

589 convert_types: bool = False, 

590) -> C: 

591 """ 

592 Similar to `load_into_recurse` but uses an existing instance of a class (so after __init__) \ 

593 and thus does not support init. 

594 

595 """ 

596 if init is not None: 

597 raise ValueError("Can not init an existing instance!") 

598 

599 existing_data = inst.__dict__ 

600 

601 to_load = check_and_convert_data( 

602 cls, 

603 data, 

604 _except=existing_data.keys(), 

605 strict=strict, 

606 convert_types=convert_types, 

607 ) 

608 

609 inst.__dict__.update(**to_load) 

610 

611 return inst 

612 

613 

614def load_into_class( 

615 cls: typing.Type[C], 

616 data: T_data, 

617 /, 

618 key: str = None, 

619 init: T_init = None, 

620 strict: bool = True, 

621 lower_keys: bool = False, 

622 convert_types: bool = False, 

623 use_env: UseEnvSetting = DEFAULT_ENV_SETTING, 

624) -> C: 

625 """ 

626 Shortcut for _load_data + load_into_recurse. 

627 """ 

628 allow_types = (dict, bytes) if issubclass(cls, BinaryConfig) else (dict,) 

629 to_load = load_data( 

630 data, 

631 key, 

632 cls.__name__, 

633 lower_keys=lower_keys, 

634 allow_types=allow_types, 

635 strict=strict, 

636 use_env=use_env, 

637 ) 

638 return _load_into_recurse(cls, to_load, init=init, strict=strict, convert_types=convert_types) 

639 

640 

641def load_into_instance( 

642 inst: C, 

643 data: T_data, 

644 /, 

645 key: str = None, 

646 init: T_init = None, 

647 strict: bool = True, 

648 lower_keys: bool = False, 

649 convert_types: bool = False, 

650 use_env: UseEnvSetting = DEFAULT_ENV_SETTING, 

651) -> C: 

652 """ 

653 Shortcut for _load_data + load_into_existing. 

654 """ 

655 cls = inst.__class__ 

656 allow_types = (dict, bytes) if issubclass(cls, BinaryConfig) else (dict,) 

657 to_load = load_data( 

658 data, 

659 key, 

660 cls.__name__, 

661 lower_keys=lower_keys, 

662 allow_types=allow_types, 

663 strict=strict, 

664 use_env=use_env, 

665 ) 

666 return _load_into_instance(inst, cls, to_load, init=init, strict=strict, convert_types=convert_types) 

667 

668 

669def load_into( 

670 cls: typing.Type[C], 

671 data: T_data = None, 

672 /, 

673 key: str = None, 

674 init: T_init = None, 

675 strict: bool = True, 

676 lower_keys: bool = False, 

677 convert_types: bool = False, 

678 use_env: UseEnvSetting = DEFAULT_ENV_SETTING, 

679) -> C: 

680 """ 

681 Load your config into a class (instance). 

682 

683 Supports both a class or an instance as first argument, but that's hard to explain to mypy, so officially only 

684 classes are supported, and if you want to `load_into` an instance, you should use `load_into_instance`. 

685 

686 Args: 

687 cls: either a class or an existing instance of that class. 

688 data: can be a dictionary or a path to a file to load (as pathlib.Path or str) 

689 key: optional (nested) dictionary key to load data from (e.g. 'tool.su6.specific') 

690 init: optional data to pass to your cls' __init__ method (only if cls is not an instance already) 

691 strict: enable type checks or allow anything? 

692 lower_keys: should the config keys be lowercased? (for .env) 

693 convert_types: should the types be converted to the annotated type if not yet matching? (for .env) 

694 use_env: Controls how ${VAR} placeholders are resolved. 

695 Determines which sources are consulted and in what order: 

696 

697 - "yes" (default): OS environment → .env 

698 - "inverse": .env → OS environment 

699 - "dotenv": .env only 

700 - "environ": OS environment only 

701 - "no": no interpolation 

702 """ 

703 result: C 

704 

705 if not isinstance(cls, type): 

706 # would not be supported according to mypy, but you can still load_into(instance) 

707 result = load_into_instance( 

708 cls, 

709 data, 

710 key=key, 

711 init=init, 

712 strict=strict, 

713 lower_keys=lower_keys, 

714 convert_types=convert_types, 

715 use_env=use_env, 

716 ) 

717 else: 

718 # get instance of cls() 

719 result = load_into_class( 

720 cls, 

721 data, 

722 key=key, 

723 init=init, 

724 strict=strict, 

725 lower_keys=lower_keys, 

726 convert_types=convert_types, 

727 use_env=use_env, 

728 ) 

729 

730 post_init = getattr(result, "__post_init__", None) 

731 if callable(post_init) and not dc.is_dataclass(result): 

732 post_init() 

733 

734 return result 

735 

736 

737class Defaultable: 

738 """ 

739 Explicit opt-in for classes that can construct a default instance. 

740 """ 

741 

742 @classmethod 

743 def default(cls) -> typing.Self: 

744 """ 

745 Return a default instance of `cls`. 

746 """ 

747 return load_into(cls, {})