Source code for typed_settings.dict_utils

"""
Utility functions for working settings dicts and serilizing nested settings.
"""

from collections.abc import Generator, Sequence
from typing import (
    Any,
    get_args,
)

from .cls_utils import deep_options, handler_exists
from .types import (
    LoadedSettings,
    LoadedValue,
    MergedSettings,
    OptionList,
    SettingsDict,
)


__all__ = [
    "iter_settings",
    "get_path",
    "set_path",
    "merge_settings",
    "update_settings",
    "flat2nested",
]


def is_mutable_sequence(val: Any) -> bool:
    return (
        hasattr(val, "__iter__")
        and hasattr(val, "__getitem__")
        and hasattr(val, "__setitem__")
    )


[docs] def iter_settings( dct: SettingsDict, options: OptionList ) -> Generator[tuple[str, Any], None, None]: """ Iterate over the (possibly nested) options dict *dct* and yield *(option_path, value)* tuples. Args: dct: The dict of settings as returned by a loader. options: The list of all available options for a settings class. Return: A generator yield *(opton_path, value)* tuples. """ for option in options: try: option_value = get_path(dct, option.path) if is_mutable_sequence(option_value) and not isinstance( option_value, (str, bytes) ): # only sub iterate in if declaration and actual value are lists args = get_args(option.cls) if args != () and handler_exists(args[0]): # Recurse if "list[NestedSettings]" is detected and if # NestedSettings is, e.g., an attrs class. sub_options = deep_options(args[0]) for idx, sub_dct in enumerate(option_value): for path, value in iter_settings(sub_dct, sub_options): yield f"{option.path}.{idx}.{path}", value else: # list of scalars for idx, value in enumerate(option_value): yield f"{option.path}.{idx}", value else: yield option.path, option_value except (KeyError, IndexError): continue
[docs] def get_path(dct: SettingsDict, path: str) -> Any: """ Performs a nested dict lookup for *path* and returns the result. Calling ``get_path(dct, "a.b")`` is equivalent to ``dict["a"]["b"]``. If a part of the path is a non-negative integer, it is treated as list index. Calling ``get_path(dct, "a.0.b")`` is therefore equivalent to ``dct["a"][0]["b"]``. Args: dct: The source dict path: The path to look up. It consists of the dot-separated nested keys. Returns: The looked up value. Raises: KeyError: if a key in *path* does not exist. IndexError: if a index in *path* is out of range. """ for part in path.split("."): if part.isnumeric(): dct = dct[int(part)] # type: ignore[index] else: dct = dct[part] return dct
[docs] def set_path(dct: SettingsDict, path: str, val: Any) -> None: """ Sets a value to a nested dict and automatically creates missing dicts should they not exist. Calling ``set_path(dct, "a.b", 3)`` is equivalent to ``dict["a"]["b"] = 3``. If a part of the path is a non-negative integer, it is treated as list index. Calling ``set_path(dct, "a.0.b", 3)`` is therefore equivalent to ``dct["a"][0]["b"] = 3``. Args: dct: The dict that should contain the value path: The (nested) path, a dot-separated concatenation of keys. val: The value to set Raises: IndexError: if a index in *path* is out of range. """ *parts, key = path.split(".") for part in parts: if part.isnumeric(): dct = dct[int(part)] # type: ignore[index] else: dct = dct.setdefault(part, {}) if key.isnumeric(): key = int(key) # type: ignore[assignment] dct[key] = val
[docs] def merge_settings( options: OptionList, settings: Sequence[LoadedSettings] ) -> MergedSettings: """ Merge a sequence of settings dicts to a flat dict that maps option paths to the corresponding option values. Args: options: The list of all available options. settings: A sequence of loaded settings. Return: A dict that maps option paths to :class:`.LoadedValue` instances. The simplified input settings look like this:: [ ("loader a", {"spam": 1, "eggs": True}), ("loader b", {"spam": 2, "nested": {"x": "test"}}), ] The simpliefied output looks like this:: { "spam": ("loader b", 2), "eggs": ("loader a", True), "nested.x": ("loader b", "test"), } """ rsettings = settings[::-1] merged_settings: MergedSettings = {} for option_info in options: for loaded_settings in rsettings: try: value = get_path(loaded_settings.settings, option_info.path) except KeyError: pass else: merged_settings[option_info.path] = LoadedValue( value, loaded_settings.meta ) break return merged_settings
[docs] def update_settings( merged_settings: MergedSettings, settings: SettingsDict ) -> MergedSettings: """ Return a copy of *merged_settings* updated with the values from *settings*. The loader meta data is not changed. Args: merged_settings: The merged settnigs dict to be updated. settings: The settings dict with additional values. Return: A copy of the input merged settings updated with the values from *settings*. """ updated: MergedSettings = {} for path, (value, meta) in merged_settings.items(): try: value = get_path(settings, path) except KeyError: pass updated[path] = LoadedValue(value, meta) return updated
[docs] def flat2nested(merged_settings: MergedSettings) -> SettingsDict: """ Convert the flat *merged_settings* to a nested settings dict. """ settings: SettingsDict = {} for path, loaded_value in merged_settings.items(): set_path(settings, path, loaded_value.value) return settings