Coverage for src/bobbie/base.py: 92%

208 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-10-30 23:03 +0000

1"""Base classes for loading and storing configuration options. 

2 

3Contents: 

4 GenericDict: type alias for a mutable mapping with hashable keys. 

5 GenericList: type alias for a mutable sequence. 

6 Settings: loads and stores configuration settings with easy-to-use parser 

7 and viewers for accessing those settings. 

8 

9To Do: 

10 Add Tests for all types and `dict` methods 

11 

12""" 

13 

14from __future__ import annotations 

15 

16import contextlib 

17import copy 

18import dataclasses 

19import functools 

20import importlib 

21import importlib.util 

22import pathlib 

23from collections.abc import ( 

24 Hashable, 

25 ItemsView, 

26 Iterator, 

27 KeysView, 

28 MutableMapping, 

29 MutableSequence, 

30 Sequence, 

31 ValuesView, 

32) 

33from types import MethodType 

34from typing import Any, ClassVar, Self, TypeAlias, cast 

35 

36import bunches 

37import wonka 

38 

39from . import options, utilities 

40 

41GenericDict: TypeAlias = MutableMapping[Hashable, Any] 

42GenericList: TypeAlias = MutableSequence[Any] 

43 

44 

45@dataclasses.dataclass 

46class Settings(wonka.Sourcerer, bunches.Dictionary): 

47 """Loads and stores configuration settings. 

48 

49 This class has an interface of an ordinary `dict`. It also adds 

50 class and instance methods that are helpful for project settings, including 

51 loading directly from numberous file types, easy injection 

52 of settings data as attributes into objects, and smart automatic type 

53 conversion from files when appropriate (depending on whether the file type 

54 supports typing). 

55 

56 Currently, supported file extensions are: 

57 

58 * `ini`, `json`, `py`, `toml`, `yaml`, and `yml`. 

59 

60 The best way to create a `Settings` instance is to call `Settings.create` 

61 and pass as the first argument either: 

62 1) a `pathlib` or `str` path to a compatible file (including a Python 

63 module); or 

64 2) a `dict` or `dict`-like object. 

65 

66 Any other arguments that you want passed to `Settings` (such as `name`) 

67 should be passed to the `parameters` argument. Any additional kwargs that 

68 you pass will be relayed to the constructor used by `bobbie`. For example, 

69 in Python 3.11, the builtin `tomllib` library to parse `toml` files. If you 

70 want to change the float parser to `decimal.Decimal` when loading settings 

71 from a ".toml" file and name your `Settings` instance "Project Settings", 

72 you should do this: 

73 

74 ```py 

75 Settings.create( 

76 'configuration.toml', 

77 parameters = {'name': 'Project Settings'}, 

78 parse_float = decimal.Decimal) 

79 ``` 

80 

81 You may also instance `Settings` directly, like a normal class. However, 

82 doing so precludes the ability to relay additional keyword arguments to the 

83 constructor used by `bobbie` (`parse_float` in the above example). 

84 

85 Args: 

86 contents: configuration options. Defaults to an empty `dict`. 

87 default_factory: default value to return or default callable to use to 

88 create the default value. 

89 name: the `str` name of `Settings`. The top-level of a `Settings` need 

90 not have any name, but may include one for use by custom parsers. 

91 Defaults to `None`. 

92 

93 Attributes: 

94 sources: `dict` with keys that are types and values are substrings of 

95 the names of methods to call when the key type is passed to the 

96 `create` method. 

97 

98 """ 

99 

100 contents: GenericDict = dataclasses.field(default_factory = dict) 

101 name: str | None = None 

102 sources: ClassVar[MutableMapping[type[Any], str]] = { 

103 pathlib.Path: 'file', 

104 str: 'file', 

105 MutableMapping: 'dict'} 

106 

107 """ Class Methods """ 

108 

109 @functools.singledispatchmethod 

110 @classmethod 

111 def create( 

112 cls, 

113 source: Any, 

114 /, 

115 parameters: MutableMapping[str, Any] | None = None, 

116 **kwargs: Any) -> Settings: 

117 """Calls appropriate class method to create an instance. 

118 

119 Args: 

120 source: path to a file or `dict` with data to store in a `Settings` 

121 instance. 

122 parameters: additional parameters and arguments to pass to the 

123 created `Settings` instance. Defaults to None. 

124 **kwargs: additional parameters and arguments to pass to the 

125 constructor used by `bobbie` (such as encoding arguments). 

126 

127 Returns: 

128 A `Settings` instance derived from `source`. 

129 

130 Raises: 

131 TypeError: if `source` is not a `str`, `pathlib.Path`, or `dict`- 

132 like object. 

133 

134 """ 

135 message = ( 

136 'The first positional argument must be a str, Path, or mapping') 

137 raise TypeError(message) 

138 

139 @create.register(MutableMapping) 

140 @classmethod 

141 def from_dict( 

142 cls, 

143 source: GenericDict, 

144 /, 

145 parameters: MutableMapping[str, Any] | None = None, 

146 **kwargs: Any) -> Settings: 

147 """Creates a `Settings` instance from a `dict`-like object. 

148 

149 Args: 

150 source: `dict`-like object with settings to store in a `Settings` 

151 instance. 

152 parameters: additional parameters and arguments to pass to the 

153 created `Settings` instance. Defaults to None. 

154 **kwargs: any additional keyword arguments are ignored by this 

155 constructer method. They are only accepted to ensure 

156 compatibility with dispatching from the `create` method. 

157 

158 Returns: 

159 A `Settings` instance derived from `source`. 

160 

161 """ 

162 parameters = parameters or {} 

163 return cls(source, **parameters) 

164 

165 @create.register(str | pathlib.Path) 

166 @classmethod 

167 def from_file( 

168 cls, 

169 source: str | pathlib.Path, 

170 /, 

171 **kwargs: Any) -> Settings: 

172 """Creates a `Settings` instance from a file path. 

173 

174 Args: 

175 source: path to file with data to store in a `Settings` instance. 

176 **kwargs: additional parameters and arguments to pass to the 

177 constructor used by `bobbie` (such as file encoding arguments). 

178 

179 Returns: 

180 A `Settings` instance derived from `source`. 

181 

182 Raises: 

183 FileNotFoundError: if the `source` path does not correspond to a 

184 file. 

185 TypeError: if no constructor method is found for the passed file 

186 type. 

187 

188 """ 

189 path = utilities._pathlibify(source) 

190 file_type, loader = cls._get_loader(path) 

191 try: 

192 settings: Settings = loader(path, **kwargs) 

193 except AttributeError as error: 

194 message = f'Loading from {file_type} file is not supported' 

195 raise TypeError(message) from error 

196 return settings 

197 

198 @classmethod 

199 def from_ini(cls, source: pathlib.Path | str, **kwargs: Any) -> Settings: 

200 """Creates a `Settings` instance from a file path to an `ini` file. 

201 

202 Args: 

203 source: path to file with data to store in a `Settings` instance. 

204 **kwargs: additional parameters and arguments to pass to the 

205 `configparser.ConfigParser` constructor (such as 

206 `interpolation`). 

207 

208 Returns: 

209 A `Settings` instance derived from `source`. 

210 

211 Raises: 

212 FileNotFoundError: if the `source` path does not correspond to a 

213 file. 

214 

215 """ 

216 path = utilities._pathlibify(source) 

217 import configparser 

218 

219 if not path.is_file(): 

220 message = f'settings file {path} not found' 

221 raise FileNotFoundError(message) 

222 # Interpolation is turned off by default so that values such as '%.4f' 

223 # are not treated as `configparser` interpolation syntax. 

224 kwargs.setdefault('interpolation', None) 

225 parser = configparser.ConfigParser(**kwargs) 

226 parser.optionxform = lambda option: option # type: ignore 

227 parser.read(path, encoding = 'utf-8') 

228 # Uses `dict` types so that the implicit "DEFAULT" section is not 

229 # included as a section of its own (its values are already part of 

230 # every other section). 

231 contents: GenericDict = { 

232 name: dict(parser[name]) for name in parser.sections()} 

233 if options._INFER_TYPES['ini']: 233 ↛ 235line 233 didn't jump to line 235 because the condition on line 233 was always true

234 contents = cls._infer_types(contents) 

235 return cls(contents) 

236 

237 @classmethod 

238 def from_json(cls, source: pathlib.Path | str, **kwargs: Any) -> Settings: 

239 """Creates a `Settings` instance from a file path to a `json` file. 

240 

241 Args: 

242 source: path to file with data to store in a `Settings` instance. 

243 **kwargs: additional parameters and arguments to pass to 

244 `json.load`. 

245 

246 Returns: 

247 A `Settings` instance derived from `source`. 

248 

249 Raises: 

250 FileNotFoundError: if the `source` path does not correspond to a 

251 file. 

252 

253 """ 

254 path = utilities._pathlibify(source) 

255 import json 

256 

257 try: 

258 with open(path) as settings_file: 

259 contents = json.load(settings_file, **kwargs) 

260 except FileNotFoundError as error: 

261 message = f'settings file {path} not found' 

262 raise FileNotFoundError(message) from error 

263 if options._INFER_TYPES['json']: 263 ↛ 265line 263 didn't jump to line 265 because the condition on line 263 was always true

264 contents = cls._infer_types(contents) 

265 return cls(contents) 

266 

267 @classmethod 

268 def from_module( 

269 cls, 

270 source: pathlib.Path | str, 

271 **kwargs: Any) -> Settings: 

272 """Creates a `Settings` instance from a file path to a Python module. 

273 

274 The settings are read from the module-level variable named in 

275 `options._MODULE_SETTINGS_ATTRIBUTE` ('settings' by default). 

276 

277 Args: 

278 source: path to file with data to store in a `Settings` instance. 

279 **kwargs: additional parameters and arguments to pass to 

280 `importlib.util.spec_from_file_location`. 

281 

282 Returns: 

283 A `Settings` instance derived from `source`. 

284 

285 Raises: 

286 FileNotFoundError: if the `source` path does not correspond to a 

287 file. 

288 

289 """ 

290 path = utilities._pathlibify(source) 

291 try: 

292 specer = importlib.util.spec_from_file_location 

293 import_path = specer(path.name, path, **kwargs) 

294 import_module = importlib.util.module_from_spec( 

295 import_path) # type: ignore 

296 import_path.loader.exec_module(import_module) # type: ignore 

297 contents = getattr( 

298 import_module, options._MODULE_SETTINGS_ATTRIBUTE) 

299 except FileNotFoundError as error: 

300 message = f'settings file {path} not found' 

301 raise FileNotFoundError(message) from error 

302 if options._INFER_TYPES['module']: 302 ↛ 303line 302 didn't jump to line 303 because the condition on line 302 was never true

303 contents = cls._infer_types(contents) 

304 return cls(contents) 

305 

306 @classmethod 

307 def from_toml(cls, source: pathlib.Path | str, **kwargs: Any) -> Settings: 

308 """Creates a `Settings` instance from a file path to a `toml` file. 

309 

310 Args: 

311 source: path to file with data to store in a `Settings` instance. 

312 **kwargs: additional parameters and arguments to pass to 

313 `tomllib.load` (such as `parse_float`). 

314 

315 Returns: 

316 A `Settings` instance derived from `source`. 

317 

318 Raises: 

319 FileNotFoundError: if the `source` path does not correspond to a 

320 file. 

321 

322 """ 

323 path = utilities._pathlibify(source) 

324 import tomllib 

325 

326 try: 

327 with open(path, 'rb') as settings_file: 

328 contents = cast( 

329 'GenericDict', tomllib.load(settings_file, **kwargs)) 

330 except FileNotFoundError as error: 

331 message = f'settings file {path} not found' 

332 raise FileNotFoundError(message) from error 

333 if options._INFER_TYPES['toml']: 333 ↛ 334line 333 didn't jump to line 334 because the condition on line 333 was never true

334 contents = cls._infer_types(contents) 

335 return cls(contents) 

336 

337 @classmethod 

338 def from_yaml(cls, source: pathlib.Path | str, **kwargs: Any) -> Settings: 

339 """Creates a `Settings` instance from a file path to a `yaml` file. 

340 

341 Args: 

342 source: path to file with data to store in a `Settings` instance. 

343 **kwargs: accepted for compatibility with the other loaders. They 

344 are not used because `yaml.safe_load` takes no other 

345 arguments. 

346 

347 Returns: 

348 A `Settings` instance derived from `source`. 

349 

350 Raises: 

351 FileNotFoundError: if the `source` path does not correspond to a 

352 file. 

353 

354 """ 

355 path = utilities._pathlibify(source) 

356 import yaml 

357 

358 try: 

359 with open(path) as config: 

360 contents = yaml.safe_load(config) 

361 except FileNotFoundError as error: 

362 message = f'settings file {path} not found' 

363 raise FileNotFoundError(message) from error 

364 if options._INFER_TYPES['yaml']: 364 ↛ 365line 364 didn't jump to line 365 because the condition on line 364 was never true

365 contents = cls._infer_types(contents) 

366 return cls(contents) 

367 

368 """ Public Methods """ 

369 

370 def add( # type: ignore[override] 

371 self, 

372 key: Hashable, 

373 value: GenericDict) -> None: 

374 """Adds `key` and `value` to `contents`. 

375 

376 If `key` is already a key in `contents`, the contents associated with 

377 that key are updated. If `key` doesn't exist, a new key/value pair is 

378 added to `contents`. 

379 

380 Args: 

381 key: name of key to store `value`. 

382 value: values to be stored. 

383 

384 Raises: 

385 TypeError: if `key` isn't hashable. 

386 

387 """ 

388 try: 

389 self[key].update(value) 

390 except KeyError: 

391 try: 

392 self[key] = value 

393 except TypeError as error: 

394 message = 'The key must be hashable' 

395 raise TypeError(message) from error 

396 return 

397 

398 def delete( 

399 self, 

400 key: Hashable, 

401 return_error: bool = options._ALWAYS_RETURN_ERROR) -> None: 

402 """Deletes `key` in `contents`. 

403 

404 Args: 

405 key: key in `contents` to delete the key/value pair. 

406 return_error: returns error if `key` is missing. Defaults to the 

407 global setting stored in options._ALWAYS_RETURN_ERROR. 

408 

409 Raises: 

410 KeyError: if `key` is not in `contents` and `return_error` is 

411 True. 

412 TypeError: if `key` isn't hashable. 

413 

414 """ 

415 try: 

416 del self.contents[key] 

417 except KeyError as error: 

418 if return_error: 

419 message = f'{key} is not found in the settings' 

420 raise KeyError(message) from error 

421 except TypeError as error: 

422 message = 'The key must be hashable' 

423 raise TypeError(message) from error 

424 return 

425 

426 def get(self, key: Hashable, default: Any | None = None) -> Any: 

427 """Returns value in `contents` or default options. 

428 

429 Args: 

430 key: key for value in `contents`. 

431 default: default value to return if `key` is not found in 

432 `contents`. 

433 

434 Returns: 

435 Value matching key in `contents` or a default value. 

436 

437 Raises: 

438 KeyError: if `key` is not in `contents` and `default` and the 

439 `default_factory` attribute are both `None`. 

440 

441 """ 

442 try: 

443 return self[key] 

444 except (KeyError, TypeError) as error: 

445 if default is not None: 

446 return default 

447 if self.default_factory is None: 

448 raise KeyError(f'{key} is not in the Settings') from error 

449 try: 

450 return self.default_factory() 

451 except TypeError: 

452 return self.default_factory 

453 

454 def inject( 

455 self, 

456 instance: object, 

457 sections: Sequence[str] | str | None = None, 

458 overwrite: bool | None = None) -> object: 

459 """Injects appropriate items into `instance` from `contents`. 

460 

461 Each key/value pair in the chosen sections is added to `instance` as 

462 an attribute. Sections missing from `contents` are skipped. 

463 

464 Args: 

465 instance: class instance to be modified. 

466 sections: other section(s) in `contents` to add to `instance`. 

467 Defaults to None (which will result in all sections being 

468 injected). 

469 overwrite: whether to overwrite a local attribute in `instance` if 

470 there are existing values stored in that attribute. Defaults to 

471 None (which will result in the default value in 

472 `_OVERWRITE_ATTRIBUTES` will be used). 

473 

474 Returns: 

475 Instance with modifications made. 

476 

477 """ 

478 overwrite = ( 

479 options._OVERWRITE_ATTRIBUTES if overwrite is None else overwrite) 

480 names = self.keys() if sections is None else sections 

481 for section in utilities._iterify(names): 

482 with contextlib.suppress(KeyError): 

483 for key, value in self.contents[section].items(): 

484 if ( 

485 not hasattr(instance, key) 

486 or not getattr(instance, key) 

487 or overwrite): 

488 setattr(instance, key, value) 

489 return instance 

490 

491 def items(self) -> ItemsView: # type: ignore[override] 

492 """Emulates python dict `items` method. 

493 

494 Returns: 

495 The `ItemsView` of `contents`. 

496 

497 """ 

498 return self.contents.items() 

499 

500 def keys(self) -> KeysView: # type: ignore[override] 

501 """Emulates python dict `keys` method. 

502 

503 Returns: 

504 The `KeysView` of `contents`. 

505 

506 """ 

507 return self.contents.keys() 

508 

509 def update(self, item: GenericDict) -> Self: # type: ignore[override] 

510 """Updates `contents` using the `add` method for key/value pairs. 

511 

512 Args: 

513 item: a `dict`-like object to add to `contents`. 

514 

515 Returns: 

516 This instance with `item` added. 

517 

518 """ 

519 for key, value in item.items(): 

520 self.add(key, value) 

521 return self 

522 

523 def values(self) -> ValuesView: # type: ignore[override] 

524 """Emulates python dict `values` method. 

525 

526 Returns: 

527 The `ValuesView` of `contents`. 

528 

529 """ 

530 return self.contents.values() 

531 

532 """ Private Methods """ 

533 

534 @classmethod 

535 def _get_loader(cls, path: pathlib.Path) -> tuple[str, MethodType]: 

536 """Returns the file type and loading method for `path`. 

537 

538 Args: 

539 path: path to a settings file. 

540 

541 Returns: 

542 The file type for the extension of `path` and the class method 

543 that loads that file type. 

544 

545 Raises: 

546 FileNotFoundError: if `path` does not correspond to a file. 

547 TypeError: if the extension of `path` is not supported. 

548 

549 """ 

550 if path.is_file(): 

551 extension = path.suffix[1:] 

552 try: 

553 file_type = options._FILE_EXTENSIONS[extension] 

554 except KeyError as error: 

555 message = f'Loading from {extension} file is not supported' 

556 raise TypeError(message) from error 

557 loader = getattr(cls, options._LOAD_METHOD(file_type)) 

558 return file_type, loader 

559 else: 

560 message = f'settings file {path} not found' 

561 raise FileNotFoundError(message) 

562 

563 @classmethod 

564 def _infer_types(cls, contents: GenericDict) -> GenericDict: 

565 """Converts stored values to appropriate datatypes. 

566 

567 Args: 

568 contents: a `dict` to reparse. 

569 

570 Returns: 

571 A `dict` with the nested values converted to the appropriate 

572 datatypes. 

573 

574 """ 

575 new_contents: GenericDict = {} 

576 for key, value in contents.items(): 

577 if isinstance(value, MutableMapping): 577 ↛ 583line 577 didn't jump to line 583 because the condition on line 577 was always true

578 inner_bundle = { 

579 inner_key: options._TYPER(inner_value) 

580 for inner_key, inner_value in value.items()} 

581 new_contents[key] = inner_bundle 

582 else: 

583 new_contents[key] = options._TYPER(value) 

584 return new_contents 

585 

586 """ Dunder Methods """ 

587 

588 def __add__(self, other: GenericDict) -> Self: 

589 """Returns a new instance that combines this instance and `other`. 

590 

591 Neither this instance nor `other` is changed. Use "+=" to add `other` 

592 to this instance in place. 

593 

594 Args: 

595 other: `dict`-like object to add to a copy of this instance using 

596 the `update` method. 

597 

598 Returns: 

599 A new instance with the contents of this instance and `other`. 

600 

601 """ 

602 combined = copy.deepcopy(self) 

603 combined.update(other) 

604 return combined 

605 

606 def __iadd__(self, other: GenericDict) -> Self: 

607 """Adds `other` to `contents` in place using the `update` method. 

608 

609 Args: 

610 other: `dict`-like object to add to `contents`. 

611 

612 Returns: 

613 This instance with `other` added. 

614 

615 """ 

616 self.update(other) 

617 return self 

618 

619 def __delitem__(self, item: Hashable) -> None: 

620 """Deletes `item` from `contents`. 

621 

622 Args: 

623 item: item or key to delete in `contents`. 

624 

625 Raises: 

626 KeyError: if `item` is not in `contents` and 

627 `options._ALWAYS_RETURN_ERROR` is True. 

628 

629 """ 

630 self.delete(item) 

631 return 

632 

633 def __getitem__(self, key: Hashable) -> Any: 

634 """Returns value for `key` in `contents`. 

635 

636 Args: 

637 key: key in `contents` for which a value is sought. 

638 

639 Returns: 

640 Value stored in `contents`. 

641 

642 Raises: 

643 KeyError: if `key` is not in `contents`. 

644 

645 """ 

646 return self.contents[key] 

647 

648 def __setitem__(self, key: Hashable, value: Any) -> None: 

649 """Sets `key` in `contents` to `value`. 

650 

651 Args: 

652 key: key to set in `contents`. 

653 value: value to be paired with `key` in `contents`. 

654 

655 """ 

656 self.contents[key] = value 

657 return 

658 

659 def __iter__(self) -> Iterator[Any]: 

660 """Returns iterator of `contents`. 

661 

662 Returns: 

663 An iterator over the keys of `contents`. 

664 

665 """ 

666 return iter(self.contents) 

667 

668 def __len__(self) -> int: 

669 """Returns length of `contents`. 

670 

671 Returns: 

672 The number of keys in `contents`. 

673 

674 """ 

675 return len(self.contents)