Source code for edri.utility.normalized_default_dict

from collections import defaultdict
from collections.abc import Callable
from typing import Any, Self, TypeVar, overload, cast

__all__ = ["NormalizedDefaultDict"]


_T = TypeVar("_T")


_MISSING = object()


[docs] class NormalizedDefaultDict[VT](defaultdict[str, VT]): """ A ``defaultdict`` variant that normalizes keys before dictionary operations. ``NormalizedDefaultDict`` behaves like :class:`collections.defaultdict`, but normalizes keys consistently before storing, retrieving, checking membership, deleting, and updating values. This is useful when keys may come from user input, external APIs, configuration files, event names, headers, or other sources where the same logical key may appear in different forms. By default, keys must be strings and are normalized using :meth:`str.lower`. A custom normalization function can be supplied via the ``normalization`` keyword argument. Unlike a plain ``defaultdict``, lookup operations such as ``d["KEY"]`` and ``d["key"]`` refer to the same stored value when the default normalization is used. :type VT: The value type stored in the dictionary. :param default_factory: A callable used to create default values for missing keys, exactly like :class:`collections.defaultdict`. If ``None``, missing keys raise :class:`KeyError`. :type default_factory: Callable[[], VT] | None :param args: Optional initial data passed as a mapping or iterable of key-value pairs. All keys from the initial data are normalized before being stored. :type args: Any :param normalization: A callable that receives the original lookup key and returns its normalized string representation. If omitted, the default normalization requires a string key and returns ``key.lower()``. :type normalization: Callable[[Any], str] | None :param kwargs: Additional initial key-value pairs. Keyword names are normalized before being stored. :type kwargs: VT :raises TypeError: If ``normalization`` is not callable and not ``None``. :raises TypeError: If the default normalization is used with a non-string key. :raises KeyError: If a key is missing and ``default_factory`` is ``None``. :example: >>> d = NormalizedDefaultDict(set) >>> d["EVENT_TYPE"].add("connection") >>> d["event_type"] {'connection'} >>> "EVENT_TYPE" in d True >>> "event_type" in d True :example: >>> d = NormalizedDefaultDict( ... list, ... normalization=lambda key: str(key).strip().lower(), ... ) >>> d[" User-ID "].append(123) >>> d["user-id"] [123] :note: The normalized key is what is actually stored internally. For example, with the default normalization, assigning ``d["FOO"] = value`` stores the key as ``"foo"``. :note: The :meth:`get` method does not create a default value for missing keys. This matches standard :class:`dict` behavior and avoids triggering :meth:`collections.defaultdict.__missing__`. :warning: The normalization function should be deterministic and should preferably be idempotent. In other words, normalizing the same key multiple times should return the same result. """ _normalization: Callable[[Any], str] def __init__( self, default_factory: Callable[[], VT] | None = None, /, *args: Any, normalization: Callable[[Any], str] | None = None, **kwargs: VT, ) -> None: """ Initialize a normalized defaultdict. Initial data may be provided either as a mapping, an iterable of key-value pairs, keyword arguments, or a combination of these. All initial keys are normalized through :meth:`update`. :param default_factory: Callable used to create values for missing keys. If ``None``, missing keys raise :class:`KeyError`. :param args: Optional initial mapping or iterable of key-value pairs. :param normalization: Optional key normalization function. If omitted, keys must be strings and are lowercased. :param kwargs: Additional initial values provided as keyword arguments. :raises TypeError: If ``normalization`` is neither callable nor ``None``. """ if normalization is None: self._normalization = self._default_normalization elif callable(normalization): self._normalization = normalization else: raise TypeError("normalization must be callable or None") super().__init__(default_factory) if args or kwargs: self.update(*args, **kwargs) @staticmethod def _default_normalization(key: Any) -> str: """ Normalize a key using the default normalization strategy. The default strategy accepts only string keys and converts them to lowercase. :param key: Key to normalize. :return: Lowercased version of ``key``. :raises TypeError: If ``key`` is not a string. """ if not isinstance(key, str): raise TypeError("default normalization only accepts str keys") return key.lower() def _normalize(self, key: Any) -> str: """ Normalize a key using the configured normalization function. This helper centralizes key normalization so all overridden dictionary operations behave consistently. :param key: Original key supplied by the caller. :return: Normalized key used internally for storage and lookup. """ return self._normalization(key) def __setitem__(self, key: Any, value: VT) -> None: """ Store ``value`` under the normalized form of ``key``. :param key: Key to normalize and store. :param value: Value to associate with the normalized key. """ dict.__setitem__(self, self._normalize(key), value) def __getitem__(self, key: Any) -> VT: """ Return the value associated with the normalized form of ``key``. If the normalized key is missing and ``default_factory`` is set, a new default value is created, stored, and returned. :param key: Key to normalize and retrieve. :return: Value associated with the normalized key. :raises KeyError: If the normalized key is missing and ``default_factory`` is ``None``. """ return dict.__getitem__(self, self._normalize(key)) def __missing__(self, key: str) -> VT: """ Create, store, and return a default value for a missing normalized key. This method is called by :meth:`__getitem__` after the lookup key has already been normalized. :param key: Normalized key that is missing from the dictionary. :return: Newly created default value. :raises KeyError: If ``default_factory`` is ``None``. """ if self.default_factory is None: raise KeyError(key) value = self.default_factory() dict.__setitem__(self, key, value) return value def __contains__(self, key: object) -> bool: """ Return whether the normalized form of ``key`` exists in the dictionary. :param key: Key to normalize before checking membership. :return: ``True`` if the normalized key exists, otherwise ``False``. """ return dict.__contains__(self, self._normalize(key)) def __delitem__(self, key: Any) -> None: """ Delete the value associated with the normalized form of ``key``. :param key: Key to normalize before deletion. :raises KeyError: If the normalized key does not exist. """ dict.__delitem__(self, self._normalize(key)) @overload def get(self, key: Any, default: None = ..., /) -> VT | None: ... @overload def get(self, key: Any, default: VT, /) -> VT: ... @overload def get(self, key: Any, default: _T, /) -> VT | _T: ...
[docs] def get(self, key: Any, default: Any = None, /) -> Any: """ Return the value for ``key`` if present, otherwise return ``default``. The key is normalized before lookup. Unlike :meth:`__getitem__`, this method does not call :meth:`__missing__` and therefore does not create a default value as a side effect. :param key: Key to normalize before lookup. :param default: Value returned when the normalized key is not present. :return: Stored value or ``default``. """ return dict.get(self, self._normalize(key), default)
@overload def pop(self, key: Any) -> VT: ... @overload def pop(self, key: Any, default: VT) -> VT: ... @overload def pop(self, key: Any, default: _T) -> VT | _T: ...
[docs] def pop(self, key: Any, default: Any = _MISSING) -> Any: """ Remove and return the value associated with the normalized form of ``key``. If ``default`` is provided and the normalized key is missing, ``default`` is returned instead of raising :class:`KeyError`. :param key: Key to normalize before removal. :param default: Optional fallback value returned when the key is missing. :return: Removed value, or ``default`` if provided and the key is missing. :raises KeyError: If the normalized key is missing and no ``default`` is provided. """ normalized_key = self._normalize(key) if default is _MISSING: return dict.pop(self, normalized_key) return dict.pop(self, normalized_key, default)
@overload def setdefault(self, key: Any) -> VT | None: ... @overload def setdefault(self, key: Any, default: VT) -> VT: ...
[docs] def setdefault(self, key: Any, default: Any = None) -> Any: """ Return the value for ``key``, inserting ``default`` if the key is missing. The key is normalized before lookup or insertion. :param key: Key to normalize before lookup or insertion. :param default: Value to store if the normalized key is missing. :return: Existing value if present, otherwise ``default``. """ normalized_key = self._normalize(key) if dict.__contains__(self, normalized_key): return dict.__getitem__(self, normalized_key) dict.__setitem__(self, normalized_key, default) return default
[docs] def update(self, *args: Any, **kwargs: VT) -> None: """ Update the dictionary with normalized keys. Accepts the same forms as :meth:`dict.update`: either one mapping, one iterable of key-value pairs, keyword arguments, or a combination of one positional source and keyword arguments. :param args: Optional mapping or iterable of key-value pairs. :param kwargs: Additional key-value pairs. :raises TypeError: If more than one positional argument is provided. """ if len(args) > 1: raise TypeError(f"update expected at most 1 positional argument, got {len(args)}") if args: other = args[0] if hasattr(other, "keys"): for key in other.keys(): self[key] = other[key] else: for key, value in other: self[key] = value for key, value in kwargs.items(): self[key] = value
[docs] def copy(self) -> Self: """ Return a shallow copy of this normalized defaultdict. The returned object preserves the same ``default_factory`` and normalization function. Stored keys are already normalized, so they are copied directly without re-normalization. :return: Shallow copy of this instance. """ copied = type(self)( self.default_factory, normalization=self._normalization, ) for key, value in self.items(): dict.__setitem__(copied, key, value) return copied