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
« 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.
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.
9To Do:
10 Add Tests for all types and `dict` methods
12"""
14from __future__ import annotations
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
36import bunches
37import wonka
39from . import options, utilities
41GenericDict: TypeAlias = MutableMapping[Hashable, Any]
42GenericList: TypeAlias = MutableSequence[Any]
45@dataclasses.dataclass
46class Settings(wonka.Sourcerer, bunches.Dictionary):
47 """Loads and stores configuration settings.
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).
56 Currently, supported file extensions are:
58 * `ini`, `json`, `py`, `toml`, `yaml`, and `yml`.
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.
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:
74 ```py
75 Settings.create(
76 'configuration.toml',
77 parameters = {'name': 'Project Settings'},
78 parse_float = decimal.Decimal)
79 ```
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).
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`.
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.
98 """
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'}
107 """ Class Methods """
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.
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).
127 Returns:
128 A `Settings` instance derived from `source`.
130 Raises:
131 TypeError: if `source` is not a `str`, `pathlib.Path`, or `dict`-
132 like object.
134 """
135 message = (
136 'The first positional argument must be a str, Path, or mapping')
137 raise TypeError(message)
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.
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.
158 Returns:
159 A `Settings` instance derived from `source`.
161 """
162 parameters = parameters or {}
163 return cls(source, **parameters)
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.
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).
179 Returns:
180 A `Settings` instance derived from `source`.
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.
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
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.
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`).
208 Returns:
209 A `Settings` instance derived from `source`.
211 Raises:
212 FileNotFoundError: if the `source` path does not correspond to a
213 file.
215 """
216 path = utilities._pathlibify(source)
217 import configparser
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)
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.
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`.
246 Returns:
247 A `Settings` instance derived from `source`.
249 Raises:
250 FileNotFoundError: if the `source` path does not correspond to a
251 file.
253 """
254 path = utilities._pathlibify(source)
255 import json
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)
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.
274 The settings are read from the module-level variable named in
275 `options._MODULE_SETTINGS_ATTRIBUTE` ('settings' by default).
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`.
282 Returns:
283 A `Settings` instance derived from `source`.
285 Raises:
286 FileNotFoundError: if the `source` path does not correspond to a
287 file.
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)
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.
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`).
315 Returns:
316 A `Settings` instance derived from `source`.
318 Raises:
319 FileNotFoundError: if the `source` path does not correspond to a
320 file.
322 """
323 path = utilities._pathlibify(source)
324 import tomllib
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)
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.
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.
347 Returns:
348 A `Settings` instance derived from `source`.
350 Raises:
351 FileNotFoundError: if the `source` path does not correspond to a
352 file.
354 """
355 path = utilities._pathlibify(source)
356 import yaml
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)
368 """ Public Methods """
370 def add( # type: ignore[override]
371 self,
372 key: Hashable,
373 value: GenericDict) -> None:
374 """Adds `key` and `value` to `contents`.
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`.
380 Args:
381 key: name of key to store `value`.
382 value: values to be stored.
384 Raises:
385 TypeError: if `key` isn't hashable.
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
398 def delete(
399 self,
400 key: Hashable,
401 return_error: bool = options._ALWAYS_RETURN_ERROR) -> None:
402 """Deletes `key` in `contents`.
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.
409 Raises:
410 KeyError: if `key` is not in `contents` and `return_error` is
411 True.
412 TypeError: if `key` isn't hashable.
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
426 def get(self, key: Hashable, default: Any | None = None) -> Any:
427 """Returns value in `contents` or default options.
429 Args:
430 key: key for value in `contents`.
431 default: default value to return if `key` is not found in
432 `contents`.
434 Returns:
435 Value matching key in `contents` or a default value.
437 Raises:
438 KeyError: if `key` is not in `contents` and `default` and the
439 `default_factory` attribute are both `None`.
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
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`.
461 Each key/value pair in the chosen sections is added to `instance` as
462 an attribute. Sections missing from `contents` are skipped.
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).
474 Returns:
475 Instance with modifications made.
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
491 def items(self) -> ItemsView: # type: ignore[override]
492 """Emulates python dict `items` method.
494 Returns:
495 The `ItemsView` of `contents`.
497 """
498 return self.contents.items()
500 def keys(self) -> KeysView: # type: ignore[override]
501 """Emulates python dict `keys` method.
503 Returns:
504 The `KeysView` of `contents`.
506 """
507 return self.contents.keys()
509 def update(self, item: GenericDict) -> Self: # type: ignore[override]
510 """Updates `contents` using the `add` method for key/value pairs.
512 Args:
513 item: a `dict`-like object to add to `contents`.
515 Returns:
516 This instance with `item` added.
518 """
519 for key, value in item.items():
520 self.add(key, value)
521 return self
523 def values(self) -> ValuesView: # type: ignore[override]
524 """Emulates python dict `values` method.
526 Returns:
527 The `ValuesView` of `contents`.
529 """
530 return self.contents.values()
532 """ Private Methods """
534 @classmethod
535 def _get_loader(cls, path: pathlib.Path) -> tuple[str, MethodType]:
536 """Returns the file type and loading method for `path`.
538 Args:
539 path: path to a settings file.
541 Returns:
542 The file type for the extension of `path` and the class method
543 that loads that file type.
545 Raises:
546 FileNotFoundError: if `path` does not correspond to a file.
547 TypeError: if the extension of `path` is not supported.
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)
563 @classmethod
564 def _infer_types(cls, contents: GenericDict) -> GenericDict:
565 """Converts stored values to appropriate datatypes.
567 Args:
568 contents: a `dict` to reparse.
570 Returns:
571 A `dict` with the nested values converted to the appropriate
572 datatypes.
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
586 """ Dunder Methods """
588 def __add__(self, other: GenericDict) -> Self:
589 """Returns a new instance that combines this instance and `other`.
591 Neither this instance nor `other` is changed. Use "+=" to add `other`
592 to this instance in place.
594 Args:
595 other: `dict`-like object to add to a copy of this instance using
596 the `update` method.
598 Returns:
599 A new instance with the contents of this instance and `other`.
601 """
602 combined = copy.deepcopy(self)
603 combined.update(other)
604 return combined
606 def __iadd__(self, other: GenericDict) -> Self:
607 """Adds `other` to `contents` in place using the `update` method.
609 Args:
610 other: `dict`-like object to add to `contents`.
612 Returns:
613 This instance with `other` added.
615 """
616 self.update(other)
617 return self
619 def __delitem__(self, item: Hashable) -> None:
620 """Deletes `item` from `contents`.
622 Args:
623 item: item or key to delete in `contents`.
625 Raises:
626 KeyError: if `item` is not in `contents` and
627 `options._ALWAYS_RETURN_ERROR` is True.
629 """
630 self.delete(item)
631 return
633 def __getitem__(self, key: Hashable) -> Any:
634 """Returns value for `key` in `contents`.
636 Args:
637 key: key in `contents` for which a value is sought.
639 Returns:
640 Value stored in `contents`.
642 Raises:
643 KeyError: if `key` is not in `contents`.
645 """
646 return self.contents[key]
648 def __setitem__(self, key: Hashable, value: Any) -> None:
649 """Sets `key` in `contents` to `value`.
651 Args:
652 key: key to set in `contents`.
653 value: value to be paired with `key` in `contents`.
655 """
656 self.contents[key] = value
657 return
659 def __iter__(self) -> Iterator[Any]:
660 """Returns iterator of `contents`.
662 Returns:
663 An iterator over the keys of `contents`.
665 """
666 return iter(self.contents)
668 def __len__(self) -> int:
669 """Returns length of `contents`.
671 Returns:
672 The number of keys in `contents`.
674 """
675 return len(self.contents)