From 5ca37717c243b65a58ce9aa6a6a094e4a8d64ff5 Mon Sep 17 00:00:00 2001 From: helo060228 Date: Fri, 16 Oct 2026 13:51:06 +0800 Subject: [PATCH 1/2] Honor freezing when deleting or popping a keypath from a `benedict` instance. --- CHANGELOG.md | 3 ++ benedict/dicts/__init__.py | 5 ++ tests/dicts/test_benedict_freeze.py | 74 +++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2059fc5e..3e78b529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +- Honor freezing when deleting or popping a keypath from a `benedict` instance. + ## [0.40.0](https://github.com/fabiocaccamo/python-benedict/releases/tag/0.40.0) - 2026-10-13 - Add `Python 3.15` support. - Drop `Python 3.10` support. diff --git a/benedict/dicts/__init__.py b/benedict/dicts/__init__.py index 9db46be1..c9b44034 100644 --- a/benedict/dicts/__init__.py +++ b/benedict/dicts/__init__.py @@ -93,6 +93,10 @@ def __deepcopy__(self, memo: dict[int, Any]) -> Self: obj.freeze() return obj + def __delitem__(self, key: object) -> None: + self._check_frozen() + super().__delitem__(key) + def __getitem__(self, key: _KPT) -> Any: # type: ignore[override] return self._cast(super().__getitem__(key)) @@ -293,6 +297,7 @@ def nest( return _nest(self[key], id_key, parent_id_key, children_key) def pop(self, key: _KPT, *args: Any) -> _V: # type: ignore[override] + self._check_frozen() return cast("_V", self._cast(super().pop(key, *args))) def remove( diff --git a/tests/dicts/test_benedict_freeze.py b/tests/dicts/test_benedict_freeze.py index d81237e1..60a94bb6 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,66 @@ 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_update(self) -> None: b = benedict({"a": 1}) b.freeze() From edd4218800ac4bf4f074fa3e061ee46fc5d78468 Mon Sep 17 00:00:00 2001 From: Fabio Caccamo Date: Tue, 20 Oct 2026 10:43:53 +0200 Subject: [PATCH 2/2] Move frozen checks to `KeylistDict`, honor freezing in `merge` and remove changelog entry. --- CHANGELOG.md | 3 --- benedict/dicts/__init__.py | 7 ++----- benedict/dicts/keylist/keylist_dict.py | 4 ++++ tests/dicts/test_benedict_freeze.py | 24 ++++++++++++++++++++++++ 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e78b529..2059fc5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] -- Honor freezing when deleting or popping a keypath from a `benedict` instance. - ## [0.40.0](https://github.com/fabiocaccamo/python-benedict/releases/tag/0.40.0) - 2026-10-13 - Add `Python 3.15` support. - Drop `Python 3.10` support. diff --git a/benedict/dicts/__init__.py b/benedict/dicts/__init__.py index c9b44034..6a74adfc 100644 --- a/benedict/dicts/__init__.py +++ b/benedict/dicts/__init__.py @@ -93,10 +93,6 @@ def __deepcopy__(self, memo: dict[int, Any]) -> Self: obj.freeze() return obj - def __delitem__(self, key: object) -> None: - self._check_frozen() - super().__delitem__(key) - def __getitem__(self, key: _KPT) -> Any: # type: ignore[override] return self._cast(super().__getitem__(key)) @@ -271,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) @@ -297,7 +295,6 @@ def nest( return _nest(self[key], id_key, parent_id_key, children_key) def pop(self, key: _KPT, *args: Any) -> _V: # type: ignore[override] - self._check_frozen() return cast("_V", self._cast(super().pop(key, *args))) def remove( 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 60a94bb6..3f5f6cb7 100644 --- a/tests/dicts/test_benedict_freeze.py +++ b/tests/dicts/test_benedict_freeze.py @@ -126,6 +126,30 @@ def test_freeze_allows_direct_nested_mutation(self) -> None: 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()