pyspry

Type stubs for the pyspry library.

 1# noqa: D200,D212,D400,D415
 2"""
 3.. include:: ../../README.md
 4"""  # noqa: RST499
 5# stdlib
 6import logging
 7
 8# local
 9from pyspry.base import Settings
10from pyspry.nested_dict import NestedDict
11
12__all__ = ["__version__", "NestedDict", "Settings"]
13
14__version__ = "1.0.2"
15
16_logger = logging.getLogger(__name__)
17_logger.debug(
18    "the following classes are exposed for this package's public API: %s",
19    ",".join([Settings.__name__, NestedDict.__name__]),
20)
__version__ = '1.0.2'
class NestedDict(collections.abc.MutableMapping):
 19class NestedDict(MutableMapping):  # type: ignore[type-arg]
 20    """Traverse nested data structures.
 21
 22    # Usage
 23
 24    >>> d = NestedDict(
 25    ...     {
 26    ...         "PARAM_A": "a",
 27    ...         "PARAM_B": 0,
 28    ...         "SUB": {"A": 1, "B": ["1", "2", "3"]},
 29    ...         "list": [{"A": 0, "B": 1}, {"a": 0, "b": 1}],
 30    ...         "deeply": {"nested": {"dict": {"ionary": {"zero": 0}}}},
 31    ...         "strings": ["should", "also", "work"]
 32    ...     }
 33    ... )
 34
 35    Simple keys work just like standard dictionaries:
 36
 37    >>> d["PARAM_A"], d["PARAM_B"]
 38    ('a', 0)
 39
 40    Nested containers are converted to `NestedDict` objects:
 41
 42    >>> d["SUB"]
 43    NestedDict({'A': 1, 'B': NestedDict({'0': '1', '1': '2', '2': '3'})})
 44
 45    >>> d["SUB_B"]
 46    NestedDict({'0': '1', '1': '2', '2': '3'})
 47
 48    Nested containers can be accessed by appending the nested key name to the parent key name:
 49
 50    >>> d["SUB_A"] == d["SUB"]["A"]
 51    True
 52
 53    >>> d["SUB_A"]
 54    1
 55
 56    >>> d["deeply_nested_dict_ionary_zero"]
 57    0
 58
 59    List indices can be accessed too:
 60
 61    >>> d["SUB_B_0"], d["SUB_B_1"]
 62    ('1', '2')
 63
 64    Similarly, the `in` operator also traverses nesting:
 65
 66    >>> "SUB_B_0" in d
 67    True
 68    """
 69
 70    __data: dict[str, typing.Any]
 71    __is_list: bool
 72    sep = "_"
 73
 74    def __init__(
 75        self, *args: typing.Mapping[str, typing.Any] | list[typing.Any], **kwargs: typing.Any
 76    ) -> None:
 77        """Similar to the `dict` signature, accept a single optional positional argument."""
 78        if len(args) > 1:
 79            raise TypeError(f"expected at most 1 argument, got {len(args)}")
 80        self.__is_list = False
 81        structured_data: dict[str, typing.Any] = {}
 82
 83        if args:
 84            data = args[0]
 85            if isinstance(data, dict):
 86                structured_data = self._ensure_structure(data)
 87            elif isinstance(data, list):
 88                self.__is_list = True
 89                structured_data = self._ensure_structure(dict(enumerate(data)))
 90            elif isinstance(data, self.__class__):
 91                self.__is_list = data.is_list
 92                structured_data = dict(data)
 93            else:
 94                raise TypeError(f"expected dict or list, got {type(data)}")
 95
 96        restructured = self._ensure_structure(kwargs)
 97        structured_data.update(restructured)
 98
 99        self.__data = structured_data
100        self.squash()
101
102    def __contains__(self, key: typing.Any) -> bool:
103        """Check if `self.__data` provides the specified key.
104
105        Also consider nesting when evaluating the condition, i.e.
106
107        >>> example = NestedDict({"KEY": {"SUB": {"NAME": "test"}}})
108        >>> "KEY_SUB" in example
109        True
110        >>> "KEY_SUB_NAME" in example
111        True
112
113        >>> "KEY_MISSING" in example
114        False
115        """
116        if key in self.__data:
117            return True
118        for k, value in self.__data.items():
119            if key.startswith(f"{k}{self.sep}") and self.maybe_strip(k, key) in value:
120                return True
121        return False
122
123    def __delitem__(self, key: str) -> None:
124        """Delete the object with the specified key from the internal data structure."""
125        del self.__data[key]
126
127    def __getitem__(self, key: str) -> typing.Any:
128        """Traverse nesting according to the `NestedDict.sep` property."""
129        try:
130            return self.get_first_match(key)
131        except ValueError:
132            pass
133
134        try:
135            return self.__data[key]
136        except KeyError:
137            pass
138        raise KeyError(key)
139
140    def __ior__(self, other: typing.Mapping[str, typing.Any] | list[typing.Any]) -> NestedDict:
141        """Override settings in this object with settings from the specified object.
142
143        >>> example = NestedDict({"KEY": {"SUB": {"NAME": "test", "OTHER": 99}}})
144        >>> example |= NestedDict({"KEY_SUB_NAME": "test2"})
145        >>> example.serialize()
146        {'KEY': {'SUB': {'NAME': 'test2', 'OTHER': 99}}}
147
148        >>> example = NestedDict(["A", "B", "C"])
149        >>> example |= NestedDict(["D", "E"])
150        >>> example.serialize()
151        ['D', 'E']
152        """
153        converted = NestedDict(other)
154        self.maybe_merge(converted, self)
155
156        if converted.is_list:
157            self._reduce(self, converted)
158        return self
159
160    def __iter__(self) -> typing.Iterator[typing.Any]:
161        """Return an iterator from the internal data structure."""
162        return iter(self.__data)
163
164    def __len__(self) -> int:
165        """Proxy the `__len__` method of the `__data` attribute."""
166        return len(self.__data)
167
168    def __or__(self, other: typing.Mapping[str, typing.Any] | list[typing.Any]) -> NestedDict:
169        """Override the bitwise `or` operator to support merging `NestedDict` objects.
170
171        >>> ( NestedDict({"A": {"B": 0}}) | {"A_B": 1} ).serialize()
172        {'A': {'B': 1}}
173
174        >>> NestedDict({"A": 0}) | [0, 1]
175        Traceback (most recent call last):
176        ...
177        TypeError: cannot merge [0, 1] (list: True) with NestedDict({'A': 0}) (list: False)
178
179        >>> NestedDict([0, {"A": 1}]) | [1, {"B": 2}]
180        NestedDict({'0': 1, '1': NestedDict({'A': 1, 'B': 2})})
181        """
182        if self.is_list ^ (converted := NestedDict(other)).is_list:
183            raise TypeError(
184                f"cannot merge {other} (list: {converted.is_list}) with {self} (list: "
185                f"{self.is_list})"
186            )
187
188        if converted.is_list:
189            self.maybe_merge(converted, merged := NestedDict(self))
190            self._reduce(merged, converted)
191            return merged
192
193        assert isinstance(other, Mapping)
194
195        return NestedDict({**self.__data, **other})
196
197    def __repr__(self) -> str:
198        """Use a `str` representation similar to `dict`, but wrap it in the class name."""
199        return f"{self.__class__.__name__}({repr(self.__data)})"
200
201    def __ror__(self, other: MutableMapping[str, typing.Any] | list[typing.Any]) -> NestedDict:
202        """Cast the other object to a `NestedDict` when needed.
203
204        >>> {"A": 0, "B": 1} | NestedDict({"A": 2})
205        NestedDict({'A': 2, 'B': 1})
206
207        >>> merged_lists = ["A", "B", "C"]  | NestedDict([1, 2])
208        >>> merged_lists.serialize()
209        [1, 2]
210        """
211        if not isinstance(other, (dict, list)):
212            raise TypeError(
213                f"unsupported operand type(s) for |: '{type(other)}' and '{self.__class__}'"
214            )
215        return NestedDict(other) | self
216
217    def __setitem__(self, name: str, value: typing.Any) -> None:
218        """Similar to `__getitem__`, traverse nesting at `NestedDict.sep` in the key."""
219        for data_key, data_val in list(self.__data.items()):
220            if data_key == name:
221                if not self.maybe_merge(value, data_val):
222                    self.__data[name] = value
223                return
224
225            if name.startswith(f"{data_key}{self.sep}"):
226                one_level_down = {self.maybe_strip(data_key, name): value}
227                if not self.maybe_merge(one_level_down, data_val):
228                    continue
229                self.__data.pop(name, None)
230                return
231
232        self.__data[name] = value
233
234    @classmethod
235    def _ensure_structure(
236        cls, data: typing.Mapping[typing.Any, typing.Any]
237    ) -> dict[str, typing.Any]:
238        out: dict[str, typing.Any] = {}
239        for key, maybe_nested in list(data.items()):
240            k = str(key)
241            if isinstance(maybe_nested, (dict, list)):
242                out[k] = NestedDict(maybe_nested)
243            else:
244                out[k] = maybe_nested
245        return out
246
247    @staticmethod
248    def _reduce(
249        base: typing.MutableMapping[str, typing.Any],
250        incoming: typing.Mapping[str, typing.Any],
251    ) -> None:
252        """Delete keys from `base` that are not present in `incoming`."""
253        for key_to_remove in set(base).difference(incoming):
254            del base[key_to_remove]
255
256    def get_first_match(self, nested_name: str) -> typing.Any:
257        """Traverse nested settings to retrieve the value of `nested_name`.
258
259        Args:
260            nested_name (builtins.str): the key to break across the nested data structure
261
262        Returns:
263            `typing.Any`: the value retrieved from this object or a nested object
264
265        Raises:
266            builtins.ValueError: `nested_name` does not correctly identify a key in this object
267                or any of its child objects
268        """  # noqa: DAR401, DAR402
269        matching_keys = sorted(
270            [
271                (key, self.maybe_strip(key, nested_name))
272                for key in self.__data
273                if str(nested_name).startswith(key)
274            ],
275            key=lambda match: len(match[0]) if match else 0,
276        )
277
278        for key, remainder in matching_keys:
279            nested_obj = self.__data[key]
280            if key == remainder:
281                return nested_obj
282
283            try:
284                return nested_obj[remainder]
285            except (KeyError, TypeError):
286                pass
287
288        raise ValueError("no match found")
289
290    @property
291    def is_list(self) -> bool:
292        """Return `True` if the internal data structure is a `list`.
293
294        >>> NestedDict([1, 2, 3]).is_list
295        True
296
297        >>> NestedDict({"A": 0}).is_list
298        False
299        """
300        return self.__is_list
301
302    def keys(self) -> typing.KeysView[typing.Any]:
303        """Flatten the nested dictionary to collect the full list of keys.
304
305        >>> example = NestedDict({"KEY": {"SUB": {"NAME": "test", "OTHER": 1}}})
306        >>> list(example.keys())
307        ['KEY', 'KEY_SUB', 'KEY_SUB_NAME', 'KEY_SUB_OTHER']
308        """
309        return NestedKeysView(self, sep=self.sep)
310
311    @classmethod
312    def maybe_merge(
313        cls,
314        incoming: Mapping[str, typing.Any] | typing.Any,
315        target: MutableMapping[str, typing.Any],
316    ) -> bool:
317        """If the given objects are both `typing.Mapping` subclasses, merge them.
318
319        Also check if the `target` object is an instance of this class. If it is, and if it's based
320        on a list, reduce the result to remove list elements that are not present in `incoming`.
321
322        >>> example = NestedDict({"key": [1, 2, 3], "other": "val"})
323        >>> NestedDict.maybe_merge(NestedDict({"key": [4, 5]}), example)
324        True
325        >>> example.serialize()
326        {'key': [4, 5], 'other': 'val'}
327
328        Args:
329            incoming (typing.Mapping[builtins.str, typing.Any] | typing.Any): test this object to
330                verify it is a `typing.Mapping`
331            target (typing.MutableMapping[builtins.str, typing.Any]): update this
332                `typing.MutableMapping` with the `incoming` mapping
333
334        Returns:
335            builtins.bool: the two `typing.Mapping` objects were merged
336        """
337        if not hasattr(incoming, "items") or not incoming.items():
338            return False
339
340        for k, v in incoming.items():
341            if k not in target:
342                target[k] = v
343                continue
344
345            if not cls.maybe_merge(v, target[k]):
346                target[k] = v
347            elif hasattr(target[k], "is_list") and target[k].is_list:
348                cls._reduce(target[k], v)
349
350        return True
351
352    @classmethod
353    def maybe_strip(cls, prefix: str, from_: str) -> str:
354        """Remove the specified prefix from the given string (if present)."""
355        return from_[len(prefix) + 1 :] if from_.startswith(f"{prefix}{cls.sep}") else from_
356
357    def serialize(self, strip_prefix: str = "") -> dict[str, typing.Any] | list[typing.Any]:
358        """Convert the `NestedDict` back to a `dict` or `list`."""
359        return (
360            [
361                item.serialize() if isinstance(item, self.__class__) else item
362                for item in self.__data.values()
363            ]
364            if self.__is_list
365            else {
366                self.maybe_strip(strip_prefix, key): (
367                    value.serialize() if isinstance(value, self.__class__) else value
368                )
369                for key, value in self.__data.items()
370            }
371        )
372
373    def squash(self) -> None:
374        """Collapse all nested keys in the given dictionary.
375
376        >>> sample = {"A": {"B": {"C": 0}, "B_D": 2}, "A_THING": True, "A_B_C": 1, "N_KEYS": 0}
377        >>> nested = NestedDict(sample)
378        >>> nested.squash()
379        >>> nested.serialize()
380        {'A': {'B': {'C': 1, 'D': 2}, 'THING': True}, 'N_KEYS': 0}
381        """
382        for key, value in list(self.__data.items()):
383            if isinstance(value, NestedDict):
384                value.squash()
385            self.__data.pop(key)
386            try:
387                self[key] = value
388            except AttributeError:
389                self.__data[key] = value

Traverse nested data structures.

Usage

>>> d = NestedDict(
...     {
...         "PARAM_A": "a",
...         "PARAM_B": 0,
...         "SUB": {"A": 1, "B": ["1", "2", "3"]},
...         "list": [{"A": 0, "B": 1}, {"a": 0, "b": 1}],
...         "deeply": {"nested": {"dict": {"ionary": {"zero": 0}}}},
...         "strings": ["should", "also", "work"]
...     }
... )

Simple keys work just like standard dictionaries:

>>> d["PARAM_A"], d["PARAM_B"]
('a', 0)

Nested containers are converted to NestedDict objects:

>>> d["SUB"]
NestedDict({'A': 1, 'B': NestedDict({'0': '1', '1': '2', '2': '3'})})
>>> d["SUB_B"]
NestedDict({'0': '1', '1': '2', '2': '3'})

Nested containers can be accessed by appending the nested key name to the parent key name:

>>> d["SUB_A"] == d["SUB"]["A"]
True
>>> d["SUB_A"]
1
>>> d["deeply_nested_dict_ionary_zero"]
0

List indices can be accessed too:

>>> d["SUB_B_0"], d["SUB_B_1"]
('1', '2')

Similarly, the in operator also traverses nesting:

>>> "SUB_B_0" in d
True
NestedDict(*args: Union[Mapping[str, Any], list[Any]], **kwargs: Any)
 74    def __init__(
 75        self, *args: typing.Mapping[str, typing.Any] | list[typing.Any], **kwargs: typing.Any
 76    ) -> None:
 77        """Similar to the `dict` signature, accept a single optional positional argument."""
 78        if len(args) > 1:
 79            raise TypeError(f"expected at most 1 argument, got {len(args)}")
 80        self.__is_list = False
 81        structured_data: dict[str, typing.Any] = {}
 82
 83        if args:
 84            data = args[0]
 85            if isinstance(data, dict):
 86                structured_data = self._ensure_structure(data)
 87            elif isinstance(data, list):
 88                self.__is_list = True
 89                structured_data = self._ensure_structure(dict(enumerate(data)))
 90            elif isinstance(data, self.__class__):
 91                self.__is_list = data.is_list
 92                structured_data = dict(data)
 93            else:
 94                raise TypeError(f"expected dict or list, got {type(data)}")
 95
 96        restructured = self._ensure_structure(kwargs)
 97        structured_data.update(restructured)
 98
 99        self.__data = structured_data
100        self.squash()

Similar to the dict signature, accept a single optional positional argument.

sep = '_'
def get_first_match(self, nested_name: str) -> Any:
256    def get_first_match(self, nested_name: str) -> typing.Any:
257        """Traverse nested settings to retrieve the value of `nested_name`.
258
259        Args:
260            nested_name (builtins.str): the key to break across the nested data structure
261
262        Returns:
263            `typing.Any`: the value retrieved from this object or a nested object
264
265        Raises:
266            builtins.ValueError: `nested_name` does not correctly identify a key in this object
267                or any of its child objects
268        """  # noqa: DAR401, DAR402
269        matching_keys = sorted(
270            [
271                (key, self.maybe_strip(key, nested_name))
272                for key in self.__data
273                if str(nested_name).startswith(key)
274            ],
275            key=lambda match: len(match[0]) if match else 0,
276        )
277
278        for key, remainder in matching_keys:
279            nested_obj = self.__data[key]
280            if key == remainder:
281                return nested_obj
282
283            try:
284                return nested_obj[remainder]
285            except (KeyError, TypeError):
286                pass
287
288        raise ValueError("no match found")

Traverse nested settings to retrieve the value of nested_name.

Arguments:
  • nested_name (builtins.str): the key to break across the nested data structure
Returns:

typing.Any: the value retrieved from this object or a nested object

Raises:
  • builtins.ValueError: nested_name does not correctly identify a key in this object or any of its child objects
is_list: bool
290    @property
291    def is_list(self) -> bool:
292        """Return `True` if the internal data structure is a `list`.
293
294        >>> NestedDict([1, 2, 3]).is_list
295        True
296
297        >>> NestedDict({"A": 0}).is_list
298        False
299        """
300        return self.__is_list

Return True if the internal data structure is a list.

>>> NestedDict([1, 2, 3]).is_list
True
>>> NestedDict({"A": 0}).is_list
False
def keys(self) -> KeysView[Any]:
302    def keys(self) -> typing.KeysView[typing.Any]:
303        """Flatten the nested dictionary to collect the full list of keys.
304
305        >>> example = NestedDict({"KEY": {"SUB": {"NAME": "test", "OTHER": 1}}})
306        >>> list(example.keys())
307        ['KEY', 'KEY_SUB', 'KEY_SUB_NAME', 'KEY_SUB_OTHER']
308        """
309        return NestedKeysView(self, sep=self.sep)

Flatten the nested dictionary to collect the full list of keys.

>>> example = NestedDict({"KEY": {"SUB": {"NAME": "test", "OTHER": 1}}})
>>> list(example.keys())
['KEY', 'KEY_SUB', 'KEY_SUB_NAME', 'KEY_SUB_OTHER']
@classmethod
def maybe_merge( cls, incoming: collections.abc.Mapping[str, typing.Any] | typing.Any, target: collections.abc.MutableMapping[str, typing.Any]) -> bool:
311    @classmethod
312    def maybe_merge(
313        cls,
314        incoming: Mapping[str, typing.Any] | typing.Any,
315        target: MutableMapping[str, typing.Any],
316    ) -> bool:
317        """If the given objects are both `typing.Mapping` subclasses, merge them.
318
319        Also check if the `target` object is an instance of this class. If it is, and if it's based
320        on a list, reduce the result to remove list elements that are not present in `incoming`.
321
322        >>> example = NestedDict({"key": [1, 2, 3], "other": "val"})
323        >>> NestedDict.maybe_merge(NestedDict({"key": [4, 5]}), example)
324        True
325        >>> example.serialize()
326        {'key': [4, 5], 'other': 'val'}
327
328        Args:
329            incoming (typing.Mapping[builtins.str, typing.Any] | typing.Any): test this object to
330                verify it is a `typing.Mapping`
331            target (typing.MutableMapping[builtins.str, typing.Any]): update this
332                `typing.MutableMapping` with the `incoming` mapping
333
334        Returns:
335            builtins.bool: the two `typing.Mapping` objects were merged
336        """
337        if not hasattr(incoming, "items") or not incoming.items():
338            return False
339
340        for k, v in incoming.items():
341            if k not in target:
342                target[k] = v
343                continue
344
345            if not cls.maybe_merge(v, target[k]):
346                target[k] = v
347            elif hasattr(target[k], "is_list") and target[k].is_list:
348                cls._reduce(target[k], v)
349
350        return True

If the given objects are both typing.Mapping subclasses, merge them.

Also check if the target object is an instance of this class. If it is, and if it's based on a list, reduce the result to remove list elements that are not present in incoming.

>>> example = NestedDict({"key": [1, 2, 3], "other": "val"})
>>> NestedDict.maybe_merge(NestedDict({"key": [4, 5]}), example)
True
>>> example.serialize()
{'key': [4, 5], 'other': 'val'}
Arguments:
Returns:

builtins.bool: the two typing.Mapping objects were merged

@classmethod
def maybe_strip(cls, prefix: str, from_: str) -> str:
352    @classmethod
353    def maybe_strip(cls, prefix: str, from_: str) -> str:
354        """Remove the specified prefix from the given string (if present)."""
355        return from_[len(prefix) + 1 :] if from_.startswith(f"{prefix}{cls.sep}") else from_

Remove the specified prefix from the given string (if present).

def serialize(self, strip_prefix: str = '') -> dict[str, typing.Any] | list[typing.Any]:
357    def serialize(self, strip_prefix: str = "") -> dict[str, typing.Any] | list[typing.Any]:
358        """Convert the `NestedDict` back to a `dict` or `list`."""
359        return (
360            [
361                item.serialize() if isinstance(item, self.__class__) else item
362                for item in self.__data.values()
363            ]
364            if self.__is_list
365            else {
366                self.maybe_strip(strip_prefix, key): (
367                    value.serialize() if isinstance(value, self.__class__) else value
368                )
369                for key, value in self.__data.items()
370            }
371        )

Convert the NestedDict back to a dict or list.

def squash(self) -> None:
373    def squash(self) -> None:
374        """Collapse all nested keys in the given dictionary.
375
376        >>> sample = {"A": {"B": {"C": 0}, "B_D": 2}, "A_THING": True, "A_B_C": 1, "N_KEYS": 0}
377        >>> nested = NestedDict(sample)
378        >>> nested.squash()
379        >>> nested.serialize()
380        {'A': {'B': {'C': 1, 'D': 2}, 'THING': True}, 'N_KEYS': 0}
381        """
382        for key, value in list(self.__data.items()):
383            if isinstance(value, NestedDict):
384                value.squash()
385            self.__data.pop(key)
386            try:
387                self[key] = value
388            except AttributeError:
389                self.__data[key] = value

Collapse all nested keys in the given dictionary.

>>> sample = {"A": {"B": {"C": 0}, "B_D": 2}, "A_THING": True, "A_B_C": 1, "N_KEYS": 0}
>>> nested = NestedDict(sample)
>>> nested.squash()
>>> nested.serialize()
{'A': {'B': {'C': 1, 'D': 2}, 'THING': True}, 'N_KEYS': 0}
Inherited Members
collections.abc.MutableMapping
pop
popitem
clear
update
setdefault
collections.abc.Mapping
get
items
values
class Settings(builtins.module):
128class Settings(types.ModuleType):
129    """Store settings from environment variables and a config file.
130
131    # Usage
132
133    >>> settings = Settings.load(config_path, prefix="APP_NAME")
134    >>> settings.APP_NAME_EXAMPLE_PARAM
135    'a string!'
136
137    ## Environment Variables
138
139    Monkeypatch an environment variable for this test:
140
141    >>> getfixture("monkey_example_param")  # use an env var to override the above setting
142    {'APP_NAME_EXAMPLE_PARAM': 'monkeypatched!'}
143
144    Setting an environment variable (above) can override specific settings values:
145
146    >>> settings = Settings.load(config_path, prefix="APP_NAME")
147    >>> settings.APP_NAME_EXAMPLE_PARAM
148    'monkeypatched!'
149
150    ## JSON Values
151
152    Environment variables in JSON format are parsed:
153
154    >>> list(settings.APP_NAME_ATTR_A)
155    [1, 2, 3]
156
157    >>> getfixture("monkey_attr_a")    # override an environment variable
158    {'APP_NAME_ATTR_A': '[4, 5, 6]'}
159
160    >>> settings = Settings.load(config_path, prefix="APP_NAME")    # and reload the settings
161    >>> list(settings.APP_NAME_ATTR_A)
162    [4, 5, 6]
163
164    To list all settings, use the built-in `dir()` function:
165
166    >>> dir(settings)
167    ['ATTR_A', 'ATTR_A_0', 'ATTR_A_1', 'ATTR_A_2', 'ATTR_B', 'ATTR_B_K', 'EXAMPLE_PARAM']
168
169    """  # noqa: F821
170
171    __config: NestedDict
172    """Store the config file contents as a `NestedDict` object."""
173
174    prefix: str
175    """Only load settings whose names start with this prefix."""
176
177    module_container: ModuleContainer | type[Null] = Null
178    """This property is set by the `Settings.bootstrap()` method and removed by
179    `Settings.restore()`"""
180
181    def __init__(self, config: dict[str, Any], environ: dict[str, str], prefix: str) -> None:
182        """Deserialize all JSON-encoded environment variables during initialization.
183
184        Args:
185            config (builtins.dict[builtins.str, typing.Any]): the values loaded from a JSON/YAML
186                file
187            environ (builtins.dict[builtins.str, typing.Any]): override config settings with these
188                environment variables
189            prefix (builtins.str): insert / strip this prefix when needed
190
191        The `prefix` is automatically added when accessing attributes:
192
193        >>> settings = Settings({"APP_NAME_EXAMPLE_PARAM": 0}, {}, prefix="APP_NAME")
194        >>> settings.APP_NAME_EXAMPLE_PARAM == settings.EXAMPLE_PARAM == 0
195        True
196        """  # noqa: RST203
197        self.__config = NestedDict(config)
198        env: dict[str, Any] = {}
199        for key, value in environ.items():
200            try:
201                parsed = json.loads(value)
202            except json.JSONDecodeError:
203                # the value must just be a simple string
204                parsed = value
205
206            if isinstance(parsed, (dict, list)):
207                env[key] = NestedDict(parsed)
208            else:
209                env[key] = parsed
210
211        self.__config |= NestedDict(env)
212        self.prefix = prefix
213
214    def __contains__(self, obj: Any) -> bool:
215        """Check the merged `NestedDict` config for a setting with the given name.
216
217        Keys must be strings to avoid unexpected behavior.
218
219        >>> settings = Settings({20: "oops", "20": "okay"}, environ={}, prefix="")
220        >>> "20" in settings
221        True
222        >>> 20 in settings
223        False
224        """
225        if not isinstance(obj, str):
226            return False
227        return self.maybe_add_prefix(obj) in self.__config
228
229    def __dir__(self) -> Iterable[str]:
230        """Return a set of the names of all settings provided by this object."""
231        return {self.__config.maybe_strip(self.prefix, key) for key in self.__config.keys()}.union(
232            self.__config.maybe_strip(self.prefix, key) for key in self.__config
233        )
234
235    def __getattr__(self, name: str) -> Any:
236        """Prioritize retrieving values from environment variables, falling back to the file config.
237
238        Args:
239            name (str): the name of the setting to retrieve
240
241        Returns:
242            `Any`: the value of the setting
243        """
244        try:
245            return self.__getattr_override(name)
246        except (AttributeError, TypeError):
247            return self.__getattr_base(name)
248
249    def __getattr_base(self, name: str) -> Any:
250        try:
251            return super().__getattribute__(name)
252        except AttributeError:
253            pass
254
255        try:
256            return getattr(self.module_container.module, name)
257        except AttributeError:
258            pass
259
260        raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
261
262    def __getattr_override(self, name: str) -> Any:
263        attr_name = self.maybe_add_prefix(name)
264
265        try:
266            attr_val = self.__config[attr_name]
267        except KeyError as e:
268            raise AttributeError(
269                f"'{self.__class__.__name__}' object has no attribute '{attr_name}'"
270            ) from e
271
272        return (
273            attr_val.serialize(strip_prefix=self.prefix)
274            if isinstance(attr_val, NestedDict)
275            else attr_val
276        )
277
278    def bootstrap(self, module_name: str) -> types.ModuleType | None:
279        """Store the named module object, replacing it with `self` to bootstrap the import mechanic.
280
281        This object will replace the named module in `sys.modules`.
282
283        Args:
284            module_name (builtins.str): the name of the module to replace
285
286        Returns:
287            typing.Optional[types.ModuleType]: the module object that was replaced, or `None` if the
288                module wasn't already in `sys.modules`
289        """
290        logger.info("replacing module '%s' with self", module_name)
291        try:
292            replaced_module = sys.modules[module_name]
293        except KeyError:
294            replaced_module = None
295        self.module_container = ModuleContainer(name=module_name, module=replaced_module)
296        sys.modules[module_name] = self
297        return replaced_module
298
299    @classmethod
300    def load(cls, file_path: Path | str, prefix: str | None = None) -> Settings:
301        """Load the specified configuration file and environment variables.
302
303        Args:
304            file_path (pathlib.Path | builtins.str): the path to the config file to load
305            prefix (typing.Optional[builtins.str]): if provided, parse all env variables containing
306                this prefix
307
308        Returns:
309            pyspry.base.Settings: the `Settings` object loaded from file with environment variable
310                overrides
311        """  # noqa: RST301
312        with Path(file_path).open("r", encoding="UTF-8") as f:
313            config_data = {
314                str(key): value
315                for key, value in yaml.safe_load(f).items()
316                if not prefix or str(key).startswith(f"{prefix}{NestedDict.sep}")
317            }
318
319        if prefix:
320            environ = {
321                key: value
322                for key, value in os.environ.items()
323                if key.startswith(f"{prefix}{NestedDict.sep}")
324            }
325        else:
326            environ = {}
327
328        return cls(config_data, environ, prefix or "")
329
330    def maybe_add_prefix(self, name: str) -> str:
331        """If the given name is missing the prefix configured for these settings, insert it.
332
333        Args:
334            name (builtins.str): the attribute / key name to massage
335
336        Returns:
337            builtins.str: the name with the prefix inserted `iff` the prefix was missing
338        """
339        if not name.startswith(self.prefix):
340            return f"{self.prefix}{self.__config.sep}{name}"
341        return name
342
343    def restore(self) -> types.ModuleType | None:
344        """Remove `self` from `sys.modules` and restore the module that was bootstrapped.
345
346        When a module is bootstrapped, it is replaced by a `Settings` object:
347
348        >>> type(sys.modules["pyspry.settings"])
349        <class 'pyspry.base.Settings'>
350
351        Calling this method reverts the bootstrapping:
352
353        >>> mod = settings.restore()
354        >>> type(sys.modules["pyspry.settings"])
355        <class 'module'>
356
357        >>> mod is sys.modules["pyspry.settings"]
358        True
359        """  # noqa: F821
360        if self.module_container is Null:
361            return None
362
363        module_container: ModuleContainer = self.module_container  # type: ignore[assignment]
364
365        module_name, module = module_container.name, module_container.module
366        self.module_container = Null
367
368        logger.info("restoring '%s' and removing self from `sys.modules`", module_name)
369
370        if not module:
371            del sys.modules[module_name]
372        else:
373            sys.modules[module_name] = module
374
375        return module

Store settings from environment variables and a config file.

Usage

>>> settings = Settings.load(config_path, prefix="APP_NAME")
>>> settings.APP_NAME_EXAMPLE_PARAM
'a string!'

Environment Variables

Monkeypatch an environment variable for this test:

>>> getfixture("monkey_example_param")  # use an env var to override the above setting
{'APP_NAME_EXAMPLE_PARAM': 'monkeypatched!'}

Setting an environment variable (above) can override specific settings values:

>>> settings = Settings.load(config_path, prefix="APP_NAME")
>>> settings.APP_NAME_EXAMPLE_PARAM
'monkeypatched!'

JSON Values

Environment variables in JSON format are parsed:

>>> list(settings.APP_NAME_ATTR_A)
[1, 2, 3]
>>> getfixture("monkey_attr_a")    # override an environment variable
{'APP_NAME_ATTR_A': '[4, 5, 6]'}
>>> settings = Settings.load(config_path, prefix="APP_NAME")    # and reload the settings
>>> list(settings.APP_NAME_ATTR_A)
[4, 5, 6]

To list all settings, use the built-in dir() function:

>>> dir(settings)
['ATTR_A', 'ATTR_A_0', 'ATTR_A_1', 'ATTR_A_2', 'ATTR_B', 'ATTR_B_K', 'EXAMPLE_PARAM']
Settings()
181    def __init__(self, config: dict[str, Any], environ: dict[str, str], prefix: str) -> None:
182        """Deserialize all JSON-encoded environment variables during initialization.
183
184        Args:
185            config (builtins.dict[builtins.str, typing.Any]): the values loaded from a JSON/YAML
186                file
187            environ (builtins.dict[builtins.str, typing.Any]): override config settings with these
188                environment variables
189            prefix (builtins.str): insert / strip this prefix when needed
190
191        The `prefix` is automatically added when accessing attributes:
192
193        >>> settings = Settings({"APP_NAME_EXAMPLE_PARAM": 0}, {}, prefix="APP_NAME")
194        >>> settings.APP_NAME_EXAMPLE_PARAM == settings.EXAMPLE_PARAM == 0
195        True
196        """  # noqa: RST203
197        self.__config = NestedDict(config)
198        env: dict[str, Any] = {}
199        for key, value in environ.items():
200            try:
201                parsed = json.loads(value)
202            except json.JSONDecodeError:
203                # the value must just be a simple string
204                parsed = value
205
206            if isinstance(parsed, (dict, list)):
207                env[key] = NestedDict(parsed)
208            else:
209                env[key] = parsed
210
211        self.__config |= NestedDict(env)
212        self.prefix = prefix

Deserialize all JSON-encoded environment variables during initialization.

Arguments:
  • config (builtins.dict[builtins.str, typing.Any]): the values loaded from a JSON/YAML file
  • environ (builtins.dict[builtins.str, typing.Any]): override config settings with these environment variables
  • prefix (builtins.str): insert / strip this prefix when needed

The prefix is automatically added when accessing attributes:

>>> settings = Settings({"APP_NAME_EXAMPLE_PARAM": 0}, {}, prefix="APP_NAME")
>>> settings.APP_NAME_EXAMPLE_PARAM == settings.EXAMPLE_PARAM == 0
True
prefix: str

Only load settings whose names start with this prefix.

module_container: pyspry.base.ModuleContainer | type[pyspry.base.Null] = Null

This property is set by the Settings.bootstrap() method and removed by Settings.restore()

def bootstrap(self, module_name: str) -> module | None:
278    def bootstrap(self, module_name: str) -> types.ModuleType | None:
279        """Store the named module object, replacing it with `self` to bootstrap the import mechanic.
280
281        This object will replace the named module in `sys.modules`.
282
283        Args:
284            module_name (builtins.str): the name of the module to replace
285
286        Returns:
287            typing.Optional[types.ModuleType]: the module object that was replaced, or `None` if the
288                module wasn't already in `sys.modules`
289        """
290        logger.info("replacing module '%s' with self", module_name)
291        try:
292            replaced_module = sys.modules[module_name]
293        except KeyError:
294            replaced_module = None
295        self.module_container = ModuleContainer(name=module_name, module=replaced_module)
296        sys.modules[module_name] = self
297        return replaced_module

Store the named module object, replacing it with self to bootstrap the import mechanic.

This object will replace the named module in sys.modules.

Arguments:
  • module_name (builtins.str): the name of the module to replace
Returns:

typing.Optional[types.ModuleType]: the module object that was replaced, or None if the module wasn't already in sys.modules

@classmethod
def load( cls, file_path: pathlib.Path | str, prefix: str | None = None) -> pyspry.Settings:
299    @classmethod
300    def load(cls, file_path: Path | str, prefix: str | None = None) -> Settings:
301        """Load the specified configuration file and environment variables.
302
303        Args:
304            file_path (pathlib.Path | builtins.str): the path to the config file to load
305            prefix (typing.Optional[builtins.str]): if provided, parse all env variables containing
306                this prefix
307
308        Returns:
309            pyspry.base.Settings: the `Settings` object loaded from file with environment variable
310                overrides
311        """  # noqa: RST301
312        with Path(file_path).open("r", encoding="UTF-8") as f:
313            config_data = {
314                str(key): value
315                for key, value in yaml.safe_load(f).items()
316                if not prefix or str(key).startswith(f"{prefix}{NestedDict.sep}")
317            }
318
319        if prefix:
320            environ = {
321                key: value
322                for key, value in os.environ.items()
323                if key.startswith(f"{prefix}{NestedDict.sep}")
324            }
325        else:
326            environ = {}
327
328        return cls(config_data, environ, prefix or "")

Load the specified configuration file and environment variables.

Arguments:
  • file_path (pathlib.Path | builtins.str): the path to the config file to load
  • prefix (typing.Optional[builtins.str]): if provided, parse all env variables containing this prefix
Returns:

Settings: the Settings object loaded from file with environment variable overrides

def maybe_add_prefix(self, name: str) -> str:
330    def maybe_add_prefix(self, name: str) -> str:
331        """If the given name is missing the prefix configured for these settings, insert it.
332
333        Args:
334            name (builtins.str): the attribute / key name to massage
335
336        Returns:
337            builtins.str: the name with the prefix inserted `iff` the prefix was missing
338        """
339        if not name.startswith(self.prefix):
340            return f"{self.prefix}{self.__config.sep}{name}"
341        return name

If the given name is missing the prefix configured for these settings, insert it.

Arguments:
  • name (builtins.str): the attribute / key name to massage
Returns:

builtins.str: the name with the prefix inserted iff the prefix was missing

def restore(self) -> module | None:
343    def restore(self) -> types.ModuleType | None:
344        """Remove `self` from `sys.modules` and restore the module that was bootstrapped.
345
346        When a module is bootstrapped, it is replaced by a `Settings` object:
347
348        >>> type(sys.modules["pyspry.settings"])
349        <class 'pyspry.base.Settings'>
350
351        Calling this method reverts the bootstrapping:
352
353        >>> mod = settings.restore()
354        >>> type(sys.modules["pyspry.settings"])
355        <class 'module'>
356
357        >>> mod is sys.modules["pyspry.settings"]
358        True
359        """  # noqa: F821
360        if self.module_container is Null:
361            return None
362
363        module_container: ModuleContainer = self.module_container  # type: ignore[assignment]
364
365        module_name, module = module_container.name, module_container.module
366        self.module_container = Null
367
368        logger.info("restoring '%s' and removing self from `sys.modules`", module_name)
369
370        if not module:
371            del sys.modules[module_name]
372        else:
373            sys.modules[module_name] = module
374
375        return module

Remove self from sys.modules and restore the module that was bootstrapped.

When a module is bootstrapped, it is replaced by a Settings object:

>>> type(sys.modules["pyspry.settings"])
<class 'Settings'>

Calling this method reverts the bootstrapping:

>>> mod = settings.restore()
>>> type(sys.modules["pyspry.settings"])
<class 'module'>
>>> mod is sys.modules["pyspry.settings"]
True