Skip to content
Merged
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
59 changes: 53 additions & 6 deletions benedict/dicts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
import re
from collections.abc import (
Callable,
Collection,
ItemsView,
Iterable,
Iterator,
Mapping,
Sequence,
ValuesView,
)
from typing import (
Any,
Expand Down Expand Up @@ -41,6 +44,7 @@
from benedict.core import unflatten as _unflatten
from benedict.core import unique as _unique
from benedict.core.traverse import TraverseCallback
from benedict.dicts.base import BaseDict
from benedict.dicts.io import IODict
from benedict.dicts.keyattr import KeyattrDict
from benedict.dicts.keylist import KeylistDict
Expand Down Expand Up @@ -223,9 +227,8 @@ def invert(self, flat: bool = False) -> Self:
"""
return cast("Self", _invert(self, flat))

def items(self) -> Iterator[tuple[_KPT, Any]]: # type: ignore[override]
for key, value in super().items():
yield (key, self._cast(value)) # type: ignore[misc]
def items(self) -> ItemsView[_KPT, Any]: # type: ignore[override]
return _BenedictItemsView(self)

def items_sorted_by_keys(self, reverse: bool = False) -> list[tuple[Any, Any]]:
"""
Expand Down Expand Up @@ -368,9 +371,53 @@ def unique(self) -> None:
"""
_unique(self)

def values(self) -> Iterator[Any]: # type: ignore[override]
for value in super().values():
yield self._cast(value)
def values(self) -> ValuesView[Any]: # type: ignore[override]
return _BenedictValuesView(self)


class _BenedictItemsView(ItemsView[Any, Any]):
"""
Items view (like dict.items()) that casts nested values on iteration.
Membership uses the stored items, keys are not parsed as keypaths.
"""

_mapping: benedict[Any, Any]

def __contains__(self, item: object) -> bool:
items: Collection[object] = BaseDict.items(self._mapping)
return item in items

def __iter__(self) -> Iterator[tuple[Any, Any]]:
cast_value = self._mapping._cast
for key, value in BaseDict.items(self._mapping):
yield key, cast_value(value)

def __reversed__(self) -> Iterator[tuple[Any, Any]]:
mapping = self._mapping
for key in BaseDict.__reversed__(mapping):
yield key, mapping._cast(BaseDict.__getitem__(mapping, key))


class _BenedictValuesView(ValuesView[Any]):
"""
Values view (like dict.values()) that casts nested values on iteration.
"""

_mapping: benedict[Any, Any]

def __contains__(self, value: object) -> bool:
# compare the stored values, without casting each of them
return value in BaseDict.values(self._mapping)

def __iter__(self) -> Iterator[Any]:
cast_value = self._mapping._cast
for value in BaseDict.values(self._mapping):
yield cast_value(value)

def __reversed__(self) -> Iterator[Any]:
mapping = self._mapping
for key in BaseDict.__reversed__(mapping):
yield mapping._cast(BaseDict.__getitem__(mapping, key))


# fix benedict json dumps support - #57 #59 #61
Expand Down
114 changes: 114 additions & 0 deletions tests/github/test_issue_0636.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import unittest
from collections.abc import ItemsView, ValuesView
from unittest.mock import patch

from benedict import benedict


class github_issue_0636_test_case(unittest.TestCase):
"""
This class describes a github issue 0636 test case.
https://github.com/fabiocaccamo/python-benedict/issues/636

To run this specific test:
- Run python -m unittest tests.github.test_issue_0636
"""

def test_items_and_values_are_sized_views(self) -> None:
# serializers such as toml-rs call len() on mapping.items()
d = benedict({"foo": [{"bar": "baz"}], "nested": {"value": 1}})
items = d.items()
values = d.values()
self.assertIsInstance(items, ItemsView)
self.assertIsInstance(values, ValuesView)
self.assertEqual(len(items), 2)
self.assertEqual(len(values), 2)

def test_nested_items_are_sized_views(self) -> None:
d = benedict({"foo": [{"bar": "baz"}]})
nested = d["foo"][0]
self.assertIsInstance(nested, benedict)
self.assertEqual(len(nested.items()), 1)
self.assertEqual(list(nested.items()), [("bar", "baz")])

def test_items_and_values_still_cast_nested_dicts(self) -> None:
source = {"nested": {"value": 1}}
d = benedict(source)
((key, value),) = d.items()
self.assertEqual(key, "nested")
self.assertIsInstance(value, benedict)
self.assertIsInstance(list(d.values())[0], benedict)
# casting keeps the pointer to the original dict
value["value"] = 2
self.assertEqual(source["nested"]["value"], 2)

def test_items_and_values_reflect_mutations(self) -> None:
d = benedict({"a": 1})
items = d.items()
values = d.values()
d["b"] = {"c": 2}
del d["a"]
self.assertEqual(list(d.keys()), ["b"])
self.assertEqual(list(items), [("b", {"c": 2})])
self.assertEqual(list(values), [{"c": 2}])
self.assertEqual(len(items), 1)

def test_items_membership(self) -> None:
d = benedict({"a": {"b": 1}})
self.assertIn(("a", {"b": 1}), d.items())
self.assertIn({"b": 1}, d.values())
self.assertNotIn(("a", {"b": 2}), d.items())

def test_items_membership_does_not_resolve_keypaths(self) -> None:
# like dict.items(), membership only matches actual keys
for d in (
benedict({"a": {"b": 1}, "l": [1, 2], "x": 1}),
benedict(a={"b": 1}, l=[1, 2], x=1),
):
with self.subTest(pointer=d.dict() is not d):
self.assertIn(("x", 1), d.items())
self.assertNotIn(("a.b", 1), d.items())
self.assertNotIn(("l[0]", 1), d.items())
with self.assertRaises(TypeError):
# unhashable key (keylist), same as dict.items()
_ = (["a", "b"], 1) in d.items()
self.assertNotIn(("missing", 1), d.items())
self.assertNotIn("x", d.items())

def test_items_and_values_are_reversible(self) -> None:
for d in (
benedict({"a": 1, "b": {"c": 2}}),
benedict(a=1, b={"c": 2}),
):
with self.subTest(pointer=d.dict() is not d):
self.assertEqual(list(reversed(d.items())), [("b", {"c": 2}), ("a", 1)])
self.assertEqual(list(reversed(d.values())), [{"c": 2}, 1])
self.assertIsInstance(next(reversed(d.values())), benedict)

def test_values_membership(self) -> None:
for d in (
benedict({"a": {"b": 1}, "x": 1}),
benedict(a={"b": 1}, x=1),
):
with self.subTest(pointer=d.dict() is not d):
self.assertIn(1, d.values())
self.assertIn({"b": 1}, d.values())
self.assertIn(benedict({"b": 1}), d.values())
self.assertNotIn({"b": 2}, d.values())

def test_membership_does_not_cast_values(self) -> None:
# membership compares the stored values, casting each one is not needed
d = benedict({f"k{i}": {"v": i} for i in range(10)})
with patch.object(benedict, "_cast", side_effect=AssertionError("cast")):
self.assertIn({"v": 9}, d.values())
self.assertIn(("k9", {"v": 9}), d.items())
self.assertNotIn({"v": 10}, d.values())

def test_views_use_stored_keys_not_keypaths(self) -> None:
# a stored key like "x[0]" must not be parsed as a keypath with index
for d in (benedict({"x[0]": 1, "y": 2}), benedict(**{"x[0]": 1, "y": 2})):
with self.subTest(pointer=d.dict() is not d):
self.assertEqual(list(d.items()), [("x[0]", 1), ("y", 2)])
self.assertEqual(list(reversed(d.items())), [("y", 2), ("x[0]", 1)])
self.assertEqual(list(reversed(d.values())), [2, 1])
self.assertIn(("x[0]", 1), d.items())
Loading