Source code for typed_settings._core

"""
Core functionality for loading settings.
"""

import logging
import os
from collections.abc import Generator, Iterable, Sequence
from contextlib import contextmanager
from pathlib import Path
from types import MappingProxyType
from typing import (
    Any,
    Generic,
    Optional,
    Union,
)

from . import cls_utils, dict_utils
from .converters import Converter, default_converter
from .exceptions import InvalidSettingsError
from .loaders import EnvLoader, FileLoader, Loader, TomlFormat, _DefaultsLoader
from .processors import Processor
from .types import (
    AUTO,
    ST,
    LoadedSettings,
    LoaderMeta,
    MergedSettings,
    OptionDict,
    OptionInfo,
    OptionList,
    SettingsDict,
    _Auto,
)


__all__ = [
    "LOGGER",
    "SettingsState",
    "default_loaders",
    "load",
    "load_settings",
    "convert",
]


LOGGER = logging.getLogger("typed-settings")


[docs] class SettingsState(Generic[ST]): """ A representation of Typed Settings' internal state and configuration. """ def __init__( self, settings_cls: type[ST], loaders: Sequence[Loader], processors: Sequence[Processor], converter: Converter, base_dir: Path, ) -> None: self._cls = settings_cls self._options = tuple(cls_utils.deep_options(settings_cls)) self._options_by_name = MappingProxyType({o.path: o for o in self._options}) self._loaders = loaders self._processors = processors self._converter = converter self._base_dir = base_dir @property def settings_class(self) -> type[ST]: """ The user's settings class. """ return self._cls @property def options(self) -> OptionList: """ All options the settings class (and nested sub classes) define. """ return self._options @property def options_by_path(self) -> OptionDict: """ All options the settings class (and nested sub classes) define, mapped by their dotted path (e.g., `nested_cls.option_name`). """ return self._options_by_name @property def loaders(self) -> list[Loader]: """ A copy of the list of all configured settings loaders. """ return list(self._loaders) @property def processors(self) -> list[Processor]: """ A copy of the list of all configured post processors. """ return list(self._processors) @property def converter(self) -> Converter: """ The configured converter. """ return self._converter @property def cwd(self) -> Path: """ The current working directory. """ return self._base_dir
[docs] def default_loaders( appname: str, config_files: Iterable[Union[str, Path]] = (), *, config_file_section: Union[str, _Auto] = AUTO, config_files_var: Union[None, str, _Auto] = AUTO, env_prefix: Union[None, str, _Auto] = AUTO, env_nested_delimiter: str = "_", ) -> list[Loader]: """ Return a list of default settings loaders that are used by :func:`load()`. These loaders are: #. A :class:`.FileLoader` loader configured with the :class:`.TomlFormat` #. An :class:`.EnvLoader` The :class:`.FileLoader` will load files from *config_files* and from the environment variable *config_files_var*. Args: appname: Your application's name -- used to derive defaults for the remaining args. config_files: Load settings from these files. The last one has the highest precedence. config_file_section: Name of your app's section in the config file. By default, use *appname* (in lower case and with "_" replaced by "-". config_files_var: Load list of settings files from this environment variable. By default, use :code:`{APPNAME}_SETTINGS`. Multiple paths have to be separated by ":". The last file has the highest precedence. All files listed in this var have higher precedence than files from *config_files*. Set to ``None`` to disable this feature. env_prefix: Load settings from environment variables with this prefix. By default, use *APPNAME_*. Set to ``None`` to disable loading env vars. env_nested_delimiter: Delimiter for concatenating attribute names of nested classes in env. var. names. Return: A list of :class:`.Loader` instances. """ loaders: list[Loader] = [] section = ( appname.lower().replace("_", "-") if isinstance(config_file_section, _Auto) else config_file_section ) var_name = ( f"{appname.upper()}_SETTINGS".replace("-", "_") if isinstance(config_files_var, _Auto) else config_files_var ) loaders.append( FileLoader( files=config_files, env_var=var_name, formats={"*.toml": TomlFormat(section)}, ) ) if env_prefix is None: LOGGER.debug("Loading settings from env vars is disabled.") else: prefix = ( f"{appname.upper()}_".replace("-", "_") if isinstance(env_prefix, _Auto) else env_prefix ) loaders.append(EnvLoader(prefix=prefix, nested_delimiter=env_nested_delimiter)) return loaders
[docs] def load( cls: type[ST], appname: str, config_files: Iterable[Union[str, Path]] = (), *, config_file_section: Union[str, _Auto] = AUTO, config_files_var: Union[None, str, _Auto] = AUTO, env_prefix: Union[None, str, _Auto] = AUTO, env_nested_delimiter: str = "_", base_dir: Path = Path(), ) -> ST: """ Load settings for *appname* and return an instance of *cls*. This function is a shortcut for :func:`load_settings()` with :func:`default_loaders()`. Settings are loaded from *config_files* and from the files specified via the *config_files_var* environment variable. Settings can also be overridden via environment variables named like the corresponding setting and prefixed with *env_prefix*. Settings precedence (from lowest to highest priority): - Default value from *cls* - First file from *config_files* - ... - Last file from *config_files* - First file from *config_files_var* - ... - Last file from *config_files_var* - Environment variable :code:`{env_prefix}_{SETTING}` Config files (both, explicitly specified, and loaded from an environment variable) are optional by default. You can prepend an ``!`` to their path to mark them as mandatory (e.g., `!/etc/credentials.toml`). An error is raised if a mandatory file does not exist. Args: cls: Attrs class with default settings. appname: Your application's name. Used to derive defaults for the remaining args. config_files: Load settings from these files. config_file_section: Name of your app's section in the config file. By default, use *appname* (in lower case and with "_" replaced by "-". config_files_var: Load list of settings files from this environment variable. By default, use :code:`{APPNAME}_SETTINGS`. Multiple paths have to be separated by ":". The last file has the highest precedence. All files listed in this var have higher precedence than files from *config_files*. Set to ``None`` to disable this feature. env_prefix: Load settings from environment variables with this prefix. By default, use *APPNAME_*. Set to ``None`` to disable loading env vars. env_nested_delimiter: Delimiter for concatenating attribute names of nested classes in env. var. names. base_dir: Base directory for resolving relative paths in default option values. Return: An instance of *cls* populated with settings from settings files and environment variables. Raise: UnknownFormatError: When no :class:`~typed_settings.loaders.FileFormat` is configured for a loaded file. ConfigFileNotFoundError: If *path* does not exist. ConfigFileLoadError: If *path* cannot be read/loaded/decoded. InvalidOptionsError: If invalid settings have been found. InvalidValueError: If a value cannot be converted to the correct type. .. versionchanged:: 23.1.0 Added the *base_dir* argument """ loaders = default_loaders( appname=appname, config_files=config_files, config_file_section=config_file_section, config_files_var=config_files_var, env_prefix=env_prefix, env_nested_delimiter=env_nested_delimiter, ) converter = default_converter() state = SettingsState(cls, loaders, [], converter, base_dir) settings = _load_settings(state) return convert(settings, state)
[docs] def load_settings( cls: type[ST], loaders: Sequence[Loader], *, processors: Sequence[Processor] = (), converter: Optional[Converter] = None, base_dir: Path = Path(), ) -> ST: """ Load settings defined by the class *cls* and return an instance of it. Args: cls: Attrs class with options (and default values). loaders: A list of settings :class:`.Loader`'s. processors: A list of settings :class:`.Processor`'s. converter: An optional :class:`.Converter` used for converting option values to the required type. By default, :func:`.default_converter()` is used. base_dir: Base directory for resolving relative paths in default option values. Return: An instance of *cls* populated with settings from the defined loaders. Raise: TsError: Depending on the configured loaders, any subclass of this exception. .. versionchanged:: 23.0.0 Made *converter* a keyword-only argument .. versionchanged:: 23.0.0 Added the *processors* argument .. versionchanged:: 23.1.0 Added the *base_dir* argument """ if converter is None: converter = default_converter() state = SettingsState(cls, loaders, processors, converter, base_dir=base_dir) settings = _load_settings(state) return convert(settings, state)
def _load_settings(state: SettingsState) -> MergedSettings: """ Loads settings for *options* and returns them as dict. This function makes it easier to extend settings since it returns a dict that can easily be updated. """ loaders = [_DefaultsLoader(state.cwd), *state.loaders] loaded_settings: list[LoadedSettings] = [] for loader in loaders: result = loader(state.settings_class, state.options) if isinstance(result, LoadedSettings): loaded_settings.append(result) else: loaded_settings.extend(result) merged_settings = dict_utils.merge_settings(state.options, loaded_settings) # Get a "dict view" to merged settings and update the merged_settings afterwards # without changing the LoaderMeta for each setting settings_dict = dict_utils.flat2nested(merged_settings) for processor in state.processors: settings_dict = processor(settings_dict, state.settings_class, state.options) merged_settings = dict_utils.update_settings(merged_settings, settings_dict) return merged_settings
[docs] def convert( merged_settings: MergedSettings, state: SettingsState[ST], ) -> ST: """ Create an instance of *cls* from the settings in *merged_settings*. Args: merged_settings: The loaded and merged settings by settings name. state: The state and configuration for this run. Return: An instance of *cls*. Raise: InvalidSettingsError: If an instance of *cls* cannot be created for the given settings. """ settings_dict: SettingsDict = {} errors: list[str] = [] loaded_settings_paths: set[str] = set() oi_by_path = state.options_by_path for path, (value, meta) in merged_settings.items(): oinfo = oi_by_path[path] try: converted_value = convert_value(oinfo, value, meta, state.converter) except Exception as e: errors.append( f"Could not convert value {value!r} for option " f"{path!r} from loader {meta.name}: {e!r}" ) continue dict_utils.set_path(settings_dict, path, converted_value) loaded_settings_paths.add(path) for option_info in state.options: if option_info.path in loaded_settings_paths: continue if option_info.has_default: continue errors.append(f"No value set for required option {option_info.path!r}") try: settings = state.converter.structure(settings_dict, state.settings_class) except Exception as e: errors.append(f"Could not convert loaded settings: {e!r}") if errors: errs = "".join(f"\n- {e}" for e in errors) raise InvalidSettingsError( f"{len(errors)} errors occured while converting the loaded option values " f"to an instance of {state.settings_class.__name__!r}:{errs}" ) return settings
def convert_value( oinfo: OptionInfo, value: Any, meta: LoaderMeta, converter: Converter ) -> Any: """ Convert the value for an option to the designated type. Args: oinfo: Metadata for the option. value: The value to be converted. meta: Metadata for the loader that loaded *value*. converter: The converter to use. Return: The converted value. Raise: Exception: If the value cannot be converted. """ if oinfo.cls: with _set_context(meta): if oinfo.converter: converted_value = oinfo.converter(value) else: converted_value = converter.structure(value, oinfo.cls) else: converted_value = value return converted_value @contextmanager def _set_context(meta: LoaderMeta) -> Generator[None, None, None]: """ Set the context for converting option values from a given loader. Currently only chagnes the cwd to :attr:`.LoaderMeta.cwd`. Args: meta: A loaders meta data Return: A context manager (that yields ``None``) """ old_cwd = os.getcwd() os.chdir(meta.base_dir) try: yield finally: os.chdir(old_cwd)