"""
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