diff --git a/benedict/dicts/__init__.py b/benedict/dicts/__init__.py index 9db46be1..6a74adfc 100644 --- a/benedict/dicts/__init__.py +++ b/benedict/dicts/__init__.py @@ -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) diff --git a/benedict/dicts/keylist/keylist_dict.py b/benedict/dicts/keylist/keylist_dict.py index 3e330f45..84c2fcc9 100644 --- a/benedict/dicts/keylist/keylist_dict.py +++ b/benedict/dicts/keylist/keylist_dict.py @@ -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 @@ -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) diff --git a/tests/dicts/test_benedict_freeze.py b/tests/dicts/test_benedict_freeze.py index d81237e1..3f5f6cb7 100644 --- a/tests/dicts/test_benedict_freeze.py +++ b/tests/dicts/test_benedict_freeze.py @@ -1,3 +1,4 @@ +import copy import unittest from benedict import benedict @@ -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() @@ -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()