diff --git a/_python_utils_tests/test_containers.py b/_python_utils_tests/test_containers.py index f1c6527..c64191d 100644 --- a/_python_utils_tests/test_containers.py +++ b/_python_utils_tests/test_containers.py @@ -1209,3 +1209,63 @@ def test_unique_list_failed_assignment_takes_membership_back() -> None: assert 1 in values values.append(3) assert values == [1, 2, 3] + + +def test_lazy_casted_dict_get_casts_values_without_caching() -> None: + """Get uses the same key and value casts as indexed reads.""" + calls: list[str] = [] + + def cast(value: str) -> int: + """Record each lazy value conversion.""" + calls.append(value) + return int(value) + + values: containers.LazyCastedDict[int, int] = containers.LazyCastedDict( + int, cast + ) + values['1'] = '2' + assert values.get('1') == 2 + assert values.get(1) == 2 + assert calls == ['2', '2'] + assert raw_items(values) == {1: '2'} + assert values.get('3', 'fallback') == 'fallback' + + +def test_lazy_casted_dict_get_defaults_without_value_cast() -> None: + """Return missing defaults unchanged and support disabled casts.""" + values: containers.LazyCastedDict[int, int] = containers.LazyCastedDict( + int, int + ) + assert values.get(1) is None + fallback = object() + assert values.get(1, fallback) is fallback + plain: containers.LazyCastedDict[str, str] = containers.LazyCastedDict() + plain['key'] = 'value' + assert plain.get('key') == 'value' + assert plain.get('missing') is None + + +def test_lazy_casted_dict_get_does_not_hide_cast_errors() -> None: + """A KeyError raised by a value cast is not a missing-key result.""" + + def cast(value: str) -> int: + """Reject values with a distinguishable conversion error.""" + raise KeyError(value) + + values: containers.LazyCastedDict[str, int] = containers.LazyCastedDict( + None, cast + ) + values['key'] = 'bad value' + with pytest.raises(KeyError, match='bad value'): + values.get('key', 0) + + +def test_lazy_casted_dict_get_casts_key_once() -> None: + """A non-idempotent key cast runs once per get call.""" + key_cast = CountingCast() + values: containers.LazyCastedDict[int, typing.Any] = ( + containers.LazyCastedDict(key_cast, int) + ) + dict[int, typing.Any].__setitem__(values, 2, '3') + assert values.get(1) == 3 + assert key_cast.calls == 1 diff --git a/python_utils/containers.py b/python_utils/containers.py index 48bc14e..40e8047 100644 --- a/python_utils/containers.py +++ b/python_utils/containers.py @@ -73,6 +73,7 @@ KT = typing.TypeVar('KT') #: A type alias for a type that can be used as a value in a dictionary. VT = typing.TypeVar('VT') +GD = typing.TypeVar('GD') #: A type alias for a dictionary with keys of type KT and values of type VT. DT = dict[KT, VT] #: A type alias for the casted type of a dictionary key. @@ -453,6 +454,8 @@ class LazyCastedDict(CastedDictBase[KT, VT]): [(1, 2), (3, 4), (5, 6), (7, 8)] >>> d[3] 4 + >>> d.get('3') + 4 # Casts are optional and can be disabled by passing None as the cast >>> d = LazyCastedDict() @@ -507,6 +510,34 @@ def __getitem__(self, key: typing.Any) -> VT: return value + @typing.overload + def get(self, key: typing.Any, default: None = None) -> VT | None: + """Return a cast value or ``None`` when the key is absent.""" + + @typing.overload + def get(self, key: typing.Any, default: VT) -> VT: + """Return a cast value or a default of the same type.""" + + @typing.overload + def get(self, key: typing.Any, default: GD) -> VT | GD: + """Return a cast value or an arbitrary default unchanged.""" + + def get(self, key: typing.Any, default: typing.Any = None) -> typing.Any: + """Read a lazily cast value, returning ``default`` for missing keys. + + Conversion errors propagate just as they do for indexed reads. A + missing default is not converted or stored. + """ + if self._key_cast is not None: + key = self._key_cast(key) + try: + value = super().__getitem__(key) + except KeyError: + return default + if self._value_cast is not None: + value = self._value_cast(value) + return value + def items( # type: ignore[override] self, ) -> collections.abc.Generator[tuple[KT, VT], None, None]: