pyspry.base

Define the base Settings class.

  1"""Define the base `Settings` class."""
  2from __future__ import annotations
  3
  4# stdlib
  5import json
  6import logging
  7import os
  8import sys
  9import types
 10from dataclasses import dataclass
 11from pathlib import Path
 12from typing import Any, Iterable
 13
 14# third party
 15import yaml
 16
 17# local
 18from pyspry.nested_dict import NestedDict
 19
 20__all__ = ["ModuleContainer", "Null", "Settings"]
 21
 22logger = logging.getLogger(__name__)
 23
 24
 25class NullMeta(type):
 26    """Classes using this ``metaclass`` return themselves for every operation / interaction."""
 27
 28    def _null_operator(cls, *__o: Any) -> NullMeta:
 29        return cls
 30
 31    __add__ = _null_operator
 32
 33    def __bool__(cls) -> bool:
 34        # noqa: D105  # docstring -> noise for this method
 35        return bool(None)
 36
 37    def __call__(cls, *args: Any, **kwargs: Any) -> NullMeta:
 38        # noqa: D102  # docstring -> noise for this method
 39        return cls
 40
 41    __div__ = _null_operator
 42
 43    def __eq__(cls, __o: object) -> bool:
 44        """Check ``cls`` for equivalence, as well as ``None``."""
 45        return __o is cls or __o is None
 46
 47    def __getattr__(cls, __name: str) -> NullMeta:
 48        """Unless `__name` starts with `_`, return the `NullMeta` class instance.
 49
 50        The check for a `_` prefix allows Python's internal mechanics (such as the `__dict__`
 51        or `__doc__` attributes) to function correctly.
 52        """
 53        if __name.startswith("_"):
 54            return super().__getattribute__(__name)  # type: ignore[no-any-return]
 55        return cls._null_operator(__name)
 56
 57    __getitem__ = _null_operator
 58    __mod__ = _null_operator
 59    __mul__ = _null_operator
 60    __or__ = _null_operator  # type: ignore[assignment]
 61    __radd__ = _null_operator
 62    __rmod__ = _null_operator
 63    __rmul__ = _null_operator
 64    __rsub__ = _null_operator
 65    __rtruediv__ = _null_operator
 66    __sub__ = _null_operator
 67    __truediv__ = _null_operator
 68
 69    def __new__(cls: type, name: str, bases: tuple[type], dct: dict[str, Any]) -> Any:
 70        """Create new `class` instances from this `metaclass`."""
 71        return super().__new__(cls, name, bases, dct)  # type: ignore[misc]
 72
 73    def __repr__(cls) -> str:
 74        # noqa: D105  # docstring -> noise for this method
 75        return "Null"
 76
 77
 78class Null(metaclass=NullMeta):
 79    """Define a class which returns itself for all interactions.
 80
 81    >>> Null == None, Null is None
 82    (True, False)
 83
 84    >>> for result in [
 85    ...     Null(),
 86    ...     Null[0],
 87    ...     Null["any-key"],
 88    ...     Null.any_attr,
 89    ...     Null().any_attr,
 90    ...     Null + 5,
 91    ...     Null - 5,
 92    ...     Null * 5,
 93    ...     Null / 5,
 94    ...     Null % 5,
 95    ...     5 + Null,
 96    ...     5 - Null,
 97    ...     5 * Null,
 98    ...     5 / Null,
 99    ...     5 % Null,
100    ... ]:
101    ...     assert result is Null, result
102
103    >>> str(Null)
104    'Null'
105
106    >>> bool(Null)
107    False
108
109    Null is always false-y:
110
111    >>> Null or "None"
112    'None'
113    """
114
115
116@dataclass
117class ModuleContainer:
118    """Pair the instance of a module with its name."""
119
120    name: str
121    """Absolute import path of the module, e.g. `pyspry.settings`."""
122
123    module: types.ModuleType | None
124    """The module pulled from `sys.modules`, or `None` if it hadn't already been imported."""
125
126
127class Settings(types.ModuleType):
128    """Store settings from environment variables and a config file.
129
130    # Usage
131
132    >>> settings = Settings.load(config_path, prefix="APP_NAME")
133    >>> settings.APP_NAME_EXAMPLE_PARAM
134    'a string!'
135
136    ## Environment Variables
137
138    Monkeypatch an environment variable for this test:
139
140    >>> getfixture("monkey_example_param")  # use an env var to override the above setting
141    {'APP_NAME_EXAMPLE_PARAM': 'monkeypatched!'}
142
143    Setting an environment variable (above) can override specific settings values:
144
145    >>> settings = Settings.load(config_path, prefix="APP_NAME")
146    >>> settings.APP_NAME_EXAMPLE_PARAM
147    'monkeypatched!'
148
149    ## JSON Values
150
151    Environment variables in JSON format are parsed:
152
153    >>> list(settings.APP_NAME_ATTR_A)
154    [1, 2, 3]
155
156    >>> getfixture("monkey_attr_a")    # override an environment variable
157    {'APP_NAME_ATTR_A': '[4, 5, 6]'}
158
159    >>> settings = Settings.load(config_path, prefix="APP_NAME")    # and reload the settings
160    >>> list(settings.APP_NAME_ATTR_A)
161    [4, 5, 6]
162
163    To list all settings, use the built-in `dir()` function:
164
165    >>> dir(settings)
166    ['ATTR_A', 'ATTR_A_0', 'ATTR_A_1', 'ATTR_A_2', 'ATTR_B', 'ATTR_B_K', 'EXAMPLE_PARAM']
167
168    """  # noqa: F821
169
170    __config: NestedDict
171    """Store the config file contents as a `NestedDict` object."""
172
173    prefix: str
174    """Only load settings whose names start with this prefix."""
175
176    module_container: ModuleContainer | type[Null] = Null
177    """This property is set by the `Settings.bootstrap()` method and removed by
178    `Settings.restore()`"""
179
180    def __init__(self, config: dict[str, Any], environ: dict[str, str], prefix: str) -> None:
181        """Deserialize all JSON-encoded environment variables during initialization.
182
183        Args:
184            config (builtins.dict[builtins.str, typing.Any]): the values loaded from a JSON/YAML
185                file
186            environ (builtins.dict[builtins.str, typing.Any]): override config settings with these
187                environment variables
188            prefix (builtins.str): insert / strip this prefix when needed
189
190        The `prefix` is automatically added when accessing attributes:
191
192        >>> settings = Settings({"APP_NAME_EXAMPLE_PARAM": 0}, {}, prefix="APP_NAME")
193        >>> settings.APP_NAME_EXAMPLE_PARAM == settings.EXAMPLE_PARAM == 0
194        True
195        """  # noqa: RST203
196        self.__config = NestedDict(config)
197        env: dict[str, Any] = {}
198        for key, value in environ.items():
199            try:
200                parsed = json.loads(value)
201            except json.JSONDecodeError:
202                # the value must just be a simple string
203                parsed = value
204
205            if isinstance(parsed, (dict, list)):
206                env[key] = NestedDict(parsed)
207            else:
208                env[key] = parsed
209
210        self.__config |= NestedDict(env)
211        self.prefix = prefix
212
213    def __contains__(self, obj: Any) -> bool:
214        """Check the merged `NestedDict` config for a setting with the given name.
215
216        Keys must be strings to avoid unexpected behavior.
217
218        >>> settings = Settings({20: "oops", "20": "okay"}, environ={}, prefix="")
219        >>> "20" in settings
220        True
221        >>> 20 in settings
222        False
223        """
224        if not isinstance(obj, str):
225            return False
226        return self.maybe_add_prefix(obj) in self.__config
227
228    def __dir__(self) -> Iterable[str]:
229        """Return a set of the names of all settings provided by this object."""
230        return {self.__config.maybe_strip(self.prefix, key) for key in self.__config.keys()}.union(
231            self.__config.maybe_strip(self.prefix, key) for key in self.__config
232        )
233
234    def __getattr__(self, name: str) -> Any:
235        """Prioritize retrieving values from environment variables, falling back to the file config.
236
237        Args:
238            name (str): the name of the setting to retrieve
239
240        Returns:
241            `Any`: the value of the setting
242        """
243        try:
244            return self.__getattr_override(name)
245        except (AttributeError, TypeError):
246            return self.__getattr_base(name)
247
248    def __getattr_base(self, name: str) -> Any:
249        try:
250            return super().__getattribute__(name)
251        except AttributeError:
252            pass
253
254        try:
255            return getattr(self.module_container.module, name)
256        except AttributeError:
257            pass
258
259        raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
260
261    def __getattr_override(self, name: str) -> Any:
262        attr_name = self.maybe_add_prefix(name)
263
264        try:
265            attr_val = self.__config[attr_name]
266        except KeyError as e:
267            raise AttributeError(
268                f"'{self.__class__.__name__}' object has no attribute '{attr_name}'"
269            ) from e
270
271        return (
272            attr_val.serialize(strip_prefix=self.prefix)
273            if isinstance(attr_val, NestedDict)
274            else attr_val
275        )
276
277    def bootstrap(self, module_name: str) -> types.ModuleType | None:
278        """Store the named module object, replacing it with `self` to bootstrap the import mechanic.
279
280        This object will replace the named module in `sys.modules`.
281
282        Args:
283            module_name (builtins.str): the name of the module to replace
284
285        Returns:
286            typing.Optional[types.ModuleType]: the module object that was replaced, or `None` if the
287                module wasn't already in `sys.modules`
288        """
289        logger.info("replacing module '%s' with self", module_name)
290        try:
291            replaced_module = sys.modules[module_name]
292        except KeyError:
293            replaced_module = None
294        self.module_container = ModuleContainer(name=module_name, module=replaced_module)
295        sys.modules[module_name] = self
296        return replaced_module
297
298    @classmethod
299    def load(cls, file_path: Path | str, prefix: str | None = None) -> Settings:
300        """Load the specified configuration file and environment variables.
301
302        Args:
303            file_path (pathlib.Path | builtins.str): the path to the config file to load
304            prefix (typing.Optional[builtins.str]): if provided, parse all env variables containing
305                this prefix
306
307        Returns:
308            pyspry.base.Settings: the `Settings` object loaded from file with environment variable
309                overrides
310        """  # noqa: RST301
311        with Path(file_path).open("r", encoding="UTF-8") as f:
312            config_data = {
313                str(key): value
314                for key, value in yaml.safe_load(f).items()
315                if not prefix or str(key).startswith(f"{prefix}{NestedDict.sep}")
316            }
317
318        if prefix:
319            environ = {
320                key: value
321                for key, value in os.environ.items()
322                if key.startswith(f"{prefix}{NestedDict.sep}")
323            }
324        else:
325            environ = {}
326
327        return cls(config_data, environ, prefix or "")
328
329    def maybe_add_prefix(self, name: str) -> str:
330        """If the given name is missing the prefix configured for these settings, insert it.
331
332        Args:
333            name (builtins.str): the attribute / key name to massage
334
335        Returns:
336            builtins.str: the name with the prefix inserted `iff` the prefix was missing
337        """
338        if not name.startswith(self.prefix):
339            return f"{self.prefix}{self.__config.sep}{name}"
340        return name
341
342    def restore(self) -> types.ModuleType | None:
343        """Remove `self` from `sys.modules` and restore the module that was bootstrapped.
344
345        When a module is bootstrapped, it is replaced by a `Settings` object:
346
347        >>> type(sys.modules["pyspry.settings"])
348        <class 'pyspry.base.Settings'>
349
350        Calling this method reverts the bootstrapping:
351
352        >>> mod = settings.restore()
353        >>> type(sys.modules["pyspry.settings"])
354        <class 'module'>
355
356        >>> mod is sys.modules["pyspry.settings"]
357        True
358        """  # noqa: F821
359        if self.module_container is Null:
360            return None
361
362        module_container: ModuleContainer = self.module_container  # type: ignore[assignment]
363
364        module_name, module = module_container.name, module_container.module
365        self.module_container = Null
366
367        logger.info("restoring '%s' and removing self from `sys.modules`", module_name)
368
369        if not module:
370            del sys.modules[module_name]
371        else:
372            sys.modules[module_name] = module
373
374        return module
375
376
377logger.debug("successfully imported %s", __name__)
@dataclass
class ModuleContainer:
117@dataclass
118class ModuleContainer:
119    """Pair the instance of a module with its name."""
120
121    name: str
122    """Absolute import path of the module, e.g. `pyspry.settings`."""
123
124    module: types.ModuleType | None
125    """The module pulled from `sys.modules`, or `None` if it hadn't already been imported."""

Pair the instance of a module with its name.

ModuleContainer(name: str, module: module | None)
name: str

Absolute import path of the module, e.g. pyspry.settings.

module: module | None

The module pulled from sys.modules, or None if it hadn't already been imported.

class Null:
 79class Null(metaclass=NullMeta):
 80    """Define a class which returns itself for all interactions.
 81
 82    >>> Null == None, Null is None
 83    (True, False)
 84
 85    >>> for result in [
 86    ...     Null(),
 87    ...     Null[0],
 88    ...     Null["any-key"],
 89    ...     Null.any_attr,
 90    ...     Null().any_attr,
 91    ...     Null + 5,
 92    ...     Null - 5,
 93    ...     Null * 5,
 94    ...     Null / 5,
 95    ...     Null % 5,
 96    ...     5 + Null,
 97    ...     5 - Null,
 98    ...     5 * Null,
 99    ...     5 / Null,
100    ...     5 % Null,
101    ... ]:
102    ...     assert result is Null, result
103
104    >>> str(Null)
105    'Null'
106
107    >>> bool(Null)
108    False
109
110    Null is always false-y:
111
112    >>> Null or "None"
113    'None'
114    """

Define a class which returns itself for all interactions.

>>> Null == None, Null is None
(True, False)
>>> for result in [
...     Null(),
...     Null[0],
...     Null["any-key"],
...     Null.any_attr,
...     Null().any_attr,
...     Null + 5,
...     Null - 5,
...     Null * 5,
...     Null / 5,
...     Null % 5,
...     5 + Null,
...     5 - Null,
...     5 * Null,
...     5 / Null,
...     5 % Null,
... ]:
...     assert result is Null, result
>>> str(Null)
'Null'
>>> bool(Null)
False

Null is always false-y:

>>> Null or "None"
'None'
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(config: dict[str, typing.Any], environ: dict[str, str], prefix: str)
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: ModuleContainer | type[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) -> 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