Source code for typed_settings._core

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

import logging
import os
from contextlib import contextmanager
from pathlib import Path
from types import MappingProxyType
from typing import (
    Generator,
    Generic,
    Iterable,
    List,
    Optional,
    Sequence,
    Set,
    Type,
    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,
    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] if oinfo.cls: with _set_context(meta): try: if oinfo.converter: converted_value = oinfo.converter(value) else: converted_value = state.converter.structure(value, oinfo.cls) 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 else: converted_value = value 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
@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)