Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions _python_utils_tests/test_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 31 additions & 0 deletions python_utils/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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]:
Expand Down