"""Dict utility functions."""
import typing as _t
from warnings import warn as _warn
from ..action_level import ActionLevel as _ActionLevel
from ..misc import get_by_full_name as _get_by_full_name
from ..misc import tname as _tname
KT = _t.TypeVar("KT", bound=_t.Hashable)
"""Key type."""
VT = _t.TypeVar("VT")
"""Value type."""
HVT = _t.TypeVar("HVT", bound=_t.Hashable)
"""Hashable value type."""
OKT = _t.TypeVar("OKT", bound=_t.Hashable)
"""Outer key type."""
[docs]
def compute_if_absent(d: dict[KT, VT], key: KT, func: _t.Callable[[KT], VT] | None = None) -> VT:
"""Compute and store `key` using `func` if `key` is not in the dict.
Args:
d: A dict.
key: The key to get.
func: A function to call for missing keys. Perform regular ``__getitem__`` call if ``None``.
Returns:
The value of `k` in `d`.
"""
if not (func is None or key in d):
d[key] = func(key)
return d[key]
[docs]
def reverse_dict(d: _t.Mapping[KT, HVT], duplicate_key_action: _ActionLevel.ParseType = "raise") -> dict[HVT, KT]:
"""Swap keys and values.
Args:
d: A dict to reverse.
duplicate_key_action: Action to take if the return dict has key collisions in the reversed dict, i.e. there are
duplicate values in `d`. Set to `ignore` to allow.
Returns:
A reversed copy of `d`.
Examples:
Reversing a dict with two elements.
>>> reverse_dict({"A": 0, "B": 1})
{0: 'A', 1: 'B'}
Raises:
ValueError: If there are duplicate values in `d` and ``duplicate_key_action='raise'``.
"""
retval = {value: key for key, value in d.items()}
action_level = _ActionLevel.verify(duplicate_key_action)
if action_level is not _ActionLevel.IGNORE and len(d) != len(retval):
msg = (
f"Duplicate values in {d}; cannot reverse. Original dict has size {len(d)} != {len(retval)}."
f"\nHint: Set duplicate_key_action='ignore' to allow."
)
if action_level is _ActionLevel.WARN:
_warn(msg, stacklevel=2)
else:
raise ValueError(msg) # pragma: no cover
return retval
[docs]
def flatten_dict(
d: dict[KT, _t.Any],
join_string: str = ".",
filter_predicate: _t.Callable[[KT, _t.Any], bool] = lambda *_: True,
string_fn: _t.Callable[[KT], str] | str | None = str,
) -> dict[str, _t.Any]:
"""Flatten a nested dictionary.
This process is partially (or fully; depends on `d` and arguments) reversible, see :func:`unflatten_dict`.
Args:
d: A dict to flatten. Keys must be strings.
join_string: Joiner for nested keys.
filter_predicate: A callable ``(key, value) -> should_keep``. Default always returns ``True``.
string_fn: A callable which takes a non-string key value and converts into to a string. If ``None``, a type
error will be raised for non-string keys. Default is :py:class:`str(key) <str>`. Pass a string to resolve
using :func:`.get_by_full_name`.
Returns:
A flattened version of `d`.
Examples:
Flattening a shallow nested dict.
>>> flatten_dict({"foo": 0, "bar": {"foo": 1, "bar": 2}})
{'foo': 0, 'bar.foo': 1, 'bar.bar': 2}
"""
if string_fn is None:
def no_string_fn(key: _t.Hashable) -> str:
raise TypeError(f"Cannot convert {key=} with string_fn=None.")
string_fn = no_string_fn
elif isinstance(string_fn, str):
string_fn = _t.cast(_t.Callable[[KT], str], _get_by_full_name(string_fn))
retval: dict[str, _t.Any] = {}
_flatten_inner(d, retval, [], join_string, filter_predicate, string_fn)
return retval
def _flatten_inner(
d: dict[KT, _t.Any],
flattened: dict[str, _t.Any],
parents: list[str],
join_string: str,
filter_predicate: _t.Callable[[KT, _t.Any], bool],
string_fn: _t.Callable[[KT], str],
) -> None:
for key, value in d.items():
if not filter_predicate(key, value):
continue
str_key = key if isinstance(key, str) else string_fn(key)
key_hierarchy = [*parents, str_key]
if isinstance(value, dict):
_flatten_inner(
value,
flattened,
key_hierarchy,
join_string,
filter_predicate,
string_fn,
)
else:
flat_key = join_string.join(key_hierarchy)
flattened[flat_key] = value
[docs]
def unflatten_dict(
d: dict[str | tuple[str, ...], _t.Any],
join_string: str = ".",
) -> dict[str, dict[str, _t.Any] | _t.Any]:
"""Unflatten a flat dictionary.
This process is reversible, see :func:`flatten_dict`.
Args:
d: A flat dict to unflatten. Keys must be ``str`` or ``tuple``.
join_string: Joiner for flattened keys. Ignored if `d` has ``tuple``-keys.
Returns:
A nested version of `d`.
Examples:
Unflatten a flat dict.
>>> unflatten_dict({"foo": 0, "bar.foo": 1, "bar.bar": 2})
{'foo': 0, 'bar': {'foo': 1, 'bar': 2}}
Tuple keys are also supported, including mixed key types.
>>> unflatten_dict({"foo": 0, ("bar", "foo"): 1, "bar.bar": 2})
{'foo': 0, 'bar': {'foo': 1, 'bar': 2}}
"""
ret: dict[str, dict[str, _t.Any] | _t.Any] = {}
for key, value in d.items():
parts: _t.Sequence[str]
if isinstance(key, str):
parts = key.split(join_string)
else:
parts = key
final = len(parts) - 1
current = ret
for i, p in enumerate(parts):
if i == final:
current[p] = value
else:
if p not in current:
current[p] = {}
current = current[p]
return ret
[docs]
class InheritedKeysDict(_t.Mapping[OKT, dict[KT, VT]]):
"""A nested dictionary that returns default-backed child dictionaries.
The length of an ``InheritedKeysDict`` is equal to the number of specific outer keys, and is considered ``True``
when cast to bool if there are shared and/or specific keys present.
Args:
default: Shared (fallback) mappings for all contexts.
specific: Context-specific mappings, backed by the default fallback mappings.
Examples:
A short demonstration.
>>> shared = {0: "fallback-for-0", 1: "fallback-for-1"}
>>> specific = {
... "ctx0": {0: "c0-v0"},
... "ctx1": {0: "c1-v0", 1: "c1-v1", 2: "c1-v2"},
... }
>>> ikd = InheritedKeysDict(default=shared, specific=specific)
>>> ikd
InheritedKeysDict(default={0: 'fallback-for-0', 1: 'fallback-for-1'}, specific={'ctx0': {0: 'c0-v0'},
'ctx1': {0: 'c1-v0', 1: 'c1-v1', 2: 'c1-v2'}})
The value of key `0` is inherited for `'ctx0'`. The `'ctx1'`-context
defines all shared keys, as well as a unique key.
>>> ikd["ctx0"]
{0: 'c0-v0', 1: 'fallback-for-1'}
>>> ikd["ctx1"]
{0: 'c1-v0', 1: 'c1-v1', 2: 'c1-v2'}
The ``InheritedKeysDict.__contains__``-method is ``True`` for all keys.
Unknown keys simply return the default values. This will be an empty
if no specific keys are specified.
>>> "unseen-key" in ikd
True
>>> ikd["unseen-key"]
{0: 'fallback-for-0', 1: 'fallback-for-1'}
The length of `ikd` is equal to the number of specific contexts (two in this case).
"""
def __init__(
self,
specific: dict[OKT, dict[KT, VT]] | None = None,
default: dict[KT, VT] | None = None,
) -> None:
self._specific = specific or {}
self._default = default or {}
def __getitem__(self, context: OKT) -> dict[KT, VT]:
if not self:
raise KeyError(context)
specific = self._specific.get(context, {})
return {**self._default, **specific}
def __repr__(self) -> str:
default = self._default
specific = self._specific
return f"{_tname(self)}({default=}, {specific=})"
def __eq__(self, other: _t.Any) -> bool:
if not isinstance(other, InheritedKeysDict):
return False
return self._default == other._default and self._specific == other._specific
def __bool__(self) -> bool:
return bool(self._default or self._specific)
def __len__(self) -> int:
return len(self._specific)
def __iter__(self) -> _t.Iterator[OKT]:
yield from self._specific
[docs]
def copy(self) -> "InheritedKeysDict[OKT, KT, VT]":
"""Make a copy of this ``InheritedKeysDict``."""
return InheritedKeysDict(specific=self._specific.copy(), default=self._default.copy())
[docs]
@classmethod
def make(cls, arg: "MakeType[OKT, KT, VT]") -> "InheritedKeysDict[OKT, KT, VT]":
"""Create instance from a mapping.
The given argument must be on the format::
{
"default": {key: value},
"specific": {
ctx0: {key: value},
ctx1: {key: value},
...
ctxN: {key: value},
}
}
No other top-level keys are accepted, but neither `default` nor `context-specific` are required.
Args:
arg: Input to make an instance from.
Returns:
A new instance.
Raises:
ValueError: If there are any keys other than 'default' and 'context-specific' present in `mapping`.
"""
if isinstance(arg, InheritedKeysDict): # pragma: no cover
return arg
default: dict[KT, VT] | None = arg.pop("default", None)
specific: dict[OKT, dict[KT, VT]] | None = arg.pop("specific", None)
if arg: # pragma: no cover
raise ValueError(f"Unknown keys: {list(arg)}")
return InheritedKeysDict(default=default, specific=specific)
class _MakeDict(_t.TypedDict, _t.Generic[OKT, KT, VT], total=False):
default: dict[KT, VT]
specific: dict[OKT, dict[KT, VT]]
MakeType = InheritedKeysDict[OKT, KT, VT] | _MakeDict[OKT, KT, VT]
"""Valid input types for making the :meth:`InheritedKeysDict.make` function."""