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
2 changes: 2 additions & 0 deletions benedict/dicts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ def merge(self, other: Mapping[str, _V], *args: Any, **kwargs: Any) -> None:
If overwrite is False, existing values will not be overwritten.
If concat is True, list values will be concatenated together.
"""
# merge writes into existing nested dicts, check the frozen state here
self._check_frozen()
others = [other] + list(args)
for other in others:
keypath_util.check_keys(other, self._keypath_separator)
Expand Down
4 changes: 4 additions & 0 deletions benedict/dicts/keylist/keylist_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ def _contains_bystreys(self, keys: Sequence[_K]) -> bool:
return False

def __delitem__(self, key: _K | list[Any] | tuple[str | int, ...]) -> None:
# keylist deletion acts on a nested parent, check the frozen state here
self._check_frozen()
if type_util.is_list(key):
self._delitem_bystreys(key)
return
Expand Down Expand Up @@ -85,6 +87,8 @@ def _get_bystreys(self, keys: Any, default: _T | _V | None = None) -> Any:
return default

def pop(self, key: _K | list[str], *args: Any) -> Any: # type: ignore[override]
# keylist pop acts on a nested parent, check the frozen state here
self._check_frozen()
if type_util.is_list(key):
return self._pop_bystreys(key, *args)
return super().pop(key, *args)
Expand Down
98 changes: 98 additions & 0 deletions tests/dicts/test_benedict_freeze.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import copy
import unittest

from benedict import benedict
Expand Down Expand Up @@ -40,6 +41,19 @@ def test_freeze_prevents_delitem(self) -> None:
with self.assertRaises(TypeError):
del b["a"]

def test_freeze_prevents_delitem_keypath(self) -> None:
for separator in (".", "/"):
for key in (f"a{separator}b", ["a", "b"], "values[0]", ["values", 0]):
with self.subTest(separator=separator, key=key):
b = benedict(
{"a": {"b": 1}, "values": [1, 2]},
keypath_separator=separator,
).freeze()
with self.assertRaisesRegex(TypeError, "frozen"):
del b[key]
self.assertEqual(b, {"a": {"b": 1}, "values": [1, 2]})
self.assertTrue(b.frozen)

def test_freeze_prevents_clear(self) -> None:
b = benedict({"a": 1})
b.freeze()
Expand All @@ -52,6 +66,90 @@ def test_freeze_prevents_pop(self) -> None:
with self.assertRaises(TypeError):
b.pop("a")

def test_freeze_prevents_pop_keypath(self) -> None:
for separator in (".", "/"):
for key in (f"a{separator}b", ["a", "b"], "values[0]", ["values", 0]):
for args in ((), (None,), ("fallback",)):
with self.subTest(separator=separator, key=key, args=args):
b = benedict(
{"a": {"b": 1}, "values": [1, 2]},
keypath_separator=separator,
).freeze()
with self.assertRaisesRegex(TypeError, "frozen"):
b.pop(key, *args)
self.assertEqual(b, {"a": {"b": 1}, "values": [1, 2]})
self.assertTrue(b.frozen)

def test_freeze_prevents_pop_missing_keypath_with_default(self) -> None:
for key in ("missing", "a.missing", "missing.path", "values[5]"):
for default in (None, "fallback"):
with self.subTest(key=key, default=default):
b = benedict({"a": {"b": 1}, "values": [1, 2]}).freeze()
with self.assertRaisesRegex(TypeError, "frozen"):
b.pop(key, default)
self.assertEqual(b, {"a": {"b": 1}, "values": [1, 2]})

def test_keypath_deletion_when_mutable(self) -> None:
cases = (
(".", "a.b", {"a": {"b": 1}}, {"a": {}}),
("/", "a/b", {"a": {"b": 1}}, {"a": {}}),
(".", ["a", "b"], {"a": {"b": 1}}, {"a": {}}),
(".", "values[0]", {"values": [1, 2]}, {"values": [2]}),
(".", ["values", 0], {"values": [1, 2]}, {"values": [2]}),
)
for unfreeze in (False, True):
for operation in ("del", "pop"):
for separator, key, source, expected in cases:
with self.subTest(unfreeze=unfreeze, operation=operation, key=key):
b = benedict(copy.deepcopy(source), keypath_separator=separator)
if unfreeze:
b.freeze().unfreeze()
if operation == "del":
del b[key]
else:
self.assertEqual(b.pop(key, "fallback"), 1)
self.assertEqual(b, expected)
missing_key = f"missing{separator}path"
self.assertIsNone(b.pop(missing_key, None))
self.assertEqual(b.pop(missing_key, "fallback"), "fallback")
with self.assertRaises(KeyError):
b.pop(missing_key)

def test_freeze_allows_direct_nested_mutation(self) -> None:
b = benedict({"a": {"b": 1}, "values": [1, 2]}).freeze()
child = b["a"]
self.assertFalse(child.frozen)
child["c"] = 2
del child["b"]
self.assertEqual(child.pop("c"), 2)
self.assertEqual(b["values"].pop(0), 1)
self.assertEqual(b, {"a": {}, "values": [2]})
self.assertTrue(b.frozen)

def test_freeze_prevents_merge(self) -> None:
b = benedict({"a": {"b": 1}}).freeze()
for other in ({"a": {"c": 2}}, {"d": 3}):
with self.subTest(other=other):
with self.assertRaisesRegex(TypeError, "frozen"):
b.merge(other)
self.assertEqual(b, {"a": {"b": 1}})

def test_freeze_prevents_keylist_deletion_on_base_classes(self) -> None:
from benedict.dicts.keylist import KeylistDict
from benedict.dicts.keypath import KeypathDict

for cls in (KeylistDict, KeypathDict):
for operation in ("del", "pop"):
with self.subTest(cls=cls.__name__, operation=operation):
d = cls({"a": {"b": 1}})
d.freeze()
with self.assertRaisesRegex(TypeError, "frozen"):
if operation == "del":
del d[["a", "b"]]
else:
d.pop(["a", "b"])
self.assertEqual(d, {"a": {"b": 1}})

def test_freeze_prevents_update(self) -> None:
b = benedict({"a": 1})
b.freeze()
Expand Down
Loading