Skip to content

Commit e578704

Browse files
committed
Add freeze / unfreeze methods support to make a benedict instance immutable. #71
1 parent 5066b04 commit e578704

File tree

5 files changed

+362
-10
lines changed

5 files changed

+362
-10
lines changed

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,8 @@ Here are the details of the supported formats, operations and extra options docs
294294
- [`filter`](#filter)
295295
- [`find`](#find)
296296
- [`flatten`](#flatten)
297+
- [`freeze`](#freeze)
298+
- [`frozen`](#frozen)
297299
- [`groupby`](#groupby)
298300
- [`invert`](#invert)
299301
- [`items_sorted_by_keys`](#items_sorted_by_keys)
@@ -311,6 +313,7 @@ Here are the details of the supported formats, operations and extra options docs
311313
- [`swap`](#swap)
312314
- [`traverse`](#traverse)
313315
- [`unflatten`](#unflatten)
316+
- [`unfreeze`](#unfreeze)
314317
- [`unique`](#unique)
315318

316319
- **I/O methods**
@@ -426,6 +429,22 @@ f = d.find(keys, default=0)
426429
f = d.flatten(separator="_")
427430
```
428431

432+
#### `freeze`
433+
434+
```python
435+
# Make the dict immutable: any attempt to modify it will raise a TypeError.
436+
# Only top-level keys are frozen; nested dicts are not affected.
437+
d.freeze()
438+
```
439+
440+
#### `frozen`
441+
442+
```python
443+
# Return True if the dict is frozen (immutable), False otherwise.
444+
if d.frozen:
445+
...
446+
```
447+
429448
#### `groupby`
430449

431450
```python
@@ -564,6 +583,13 @@ d.traverse(f)
564583
u = d.unflatten(separator="_")
565584
```
566585

586+
#### `unfreeze`
587+
588+
```python
589+
# Make the dict mutable again after a freeze() call.
590+
d.unfreeze()
591+
```
592+
567593
#### `unique`
568594

569595
```python

benedict/dicts/__init__.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,15 @@ def __deepcopy__(self, memo: dict[int, Any]) -> Self:
8888
)
8989
for key, value in self.items():
9090
obj[key] = _clone(value, memo=memo)
91+
if self._frozen:
92+
obj.freeze()
9193
return obj
9294

9395
def __getitem__(self, key: _KPT) -> Any: # type: ignore[override]
9496
return self._cast(super().__getitem__(key))
9597

9698
def __setitem__(self, key: _KPT, value: _V) -> None: # type: ignore[override]
99+
self._check_frozen()
97100
super().__setitem__(key, self._cast(value))
98101

99102
def _cast(self, value: Any) -> Any:
@@ -133,7 +136,17 @@ def copy(self) -> Self:
133136
"""
134137
Creates and return a copy of the current instance (shallow copy).
135138
"""
136-
return cast("Self", self._cast(super().copy()))
139+
obj_type = type(self)
140+
obj = obj_type(
141+
keyattr_enabled=self._keyattr_enabled,
142+
keyattr_dynamic=self._keyattr_dynamic,
143+
keypath_separator=self._keypath_separator,
144+
)
145+
for key, value in self.items():
146+
obj[key] = value
147+
if self._frozen:
148+
obj.freeze()
149+
return obj
137150

138151
def deepcopy(self) -> Self:
139152
"""

benedict/dicts/base/base_dict.py

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919

2020

2121
class BaseDict(dict[_K, _V]):
22-
_dict: dict[_K, _V] | None = None
22+
_dict: dict[_K, _V] | None
23+
_frozen: bool
2324

2425
@classmethod
2526
def _get_dict_or_value(cls, value: Any) -> Any:
@@ -32,11 +33,19 @@ def _get_dict_or_value(cls, value: Any) -> Any:
3233
value[key] = key_val
3334
return value
3435

36+
def __new__(cls, *args: Any, **kwargs: Any) -> Self:
37+
obj = super().__new__(cls)
38+
# bypass subclass __setattr__ (e.g. KeyattrDict) which may access _dict before it exists
39+
object.__setattr__(obj, "_dict", None)
40+
object.__setattr__(obj, "_frozen", False)
41+
return obj
42+
3543
def __init__(self, *args: Any, **kwargs: Any) -> None:
36-
self._dict = None
3744
if len(args) == 1 and isinstance(args[0], Mapping):
38-
self._dict = self._get_dict_or_value(args[0])
39-
super().__init__(self._dict)
45+
# bypass subclass __setattr__ (e.g. KeyattrDict) which may treat _dict as a key
46+
d = self._get_dict_or_value(args[0])
47+
object.__setattr__(self, "_dict", d)
48+
super().__init__(d)
4049
return
4150
super().__init__(*args, **kwargs)
4251

@@ -54,9 +63,12 @@ def __deepcopy__(self, memo: dict[int, Any]) -> Self:
5463
obj = self.__class__()
5564
for key, value in self.items():
5665
obj[key] = _clone(value, memo=memo)
66+
if self._frozen:
67+
obj.freeze()
5768
return obj
5869

5970
def __delitem__(self, key: _K) -> None:
71+
self._check_frozen()
6072
if self._dict is not None:
6173
del self._dict[key]
6274
return
@@ -73,6 +85,7 @@ def __getitem__(self, key: _K) -> _V:
7385
return super().__getitem__(key)
7486

7587
def __ior__(self, other: Any) -> Self: # type: ignore[misc,override]
88+
self._check_frozen()
7689
if self._dict is not None:
7790
return cast("Self", self._dict.__ior__(other))
7891
return super().__ior__(other)
@@ -98,6 +111,7 @@ def __repr__(self) -> str:
98111
return super().__repr__()
99112

100113
def __setitem__(self, key: _K, value: _V) -> None:
114+
self._check_frozen()
101115
value = self._get_dict_or_value(value)
102116
if self._dict is not None:
103117
is_dict_item = key in self._dict and isinstance(self._dict[key], dict)
@@ -114,24 +128,46 @@ def __setitem__(self, key: _K, value: _V) -> None:
114128
super().__setitem__(key, value)
115129

116130
def __setstate__(self, state: Mapping[str, Any]) -> None:
117-
self._dict = state["_dict"]
118-
self._dict = state["_dict"]
131+
object.__setattr__(self, "_dict", state.get("_dict", None))
132+
object.__setattr__(self, "_frozen", state.get("_frozen", False))
119133

120134
def __str__(self) -> str:
121135
if self._dict is not None:
122136
return str(self._dict)
123137
return super().__str__()
124138

139+
def _check_frozen(self) -> None:
140+
if self._frozen:
141+
raise TypeError(
142+
f"{self.__class__.__name__!r} object is frozen and cannot be modified."
143+
)
144+
145+
@property
146+
def frozen(self) -> bool:
147+
return self._frozen
148+
149+
def freeze(self) -> Self:
150+
object.__setattr__(self, "_frozen", True)
151+
return self
152+
153+
def unfreeze(self) -> Self:
154+
object.__setattr__(self, "_frozen", False)
155+
return self
156+
125157
def clear(self) -> None:
158+
self._check_frozen()
126159
if self._dict is not None:
127160
self._dict.clear()
128161
return
129162
super().clear()
130163

131164
def copy(self) -> Self:
132-
if self._dict is not None:
133-
return cast("Self", self._dict.copy())
134-
return cast("Self", super().copy())
165+
obj = self.__class__()
166+
for key, value in self.items():
167+
obj[key] = value
168+
if self._frozen:
169+
obj.freeze()
170+
return obj
135171

136172
def dict(self) -> Self:
137173
if self._dict is not None:
@@ -154,18 +190,21 @@ def keys(self) -> KeysView[_K]: # type: ignore[override]
154190
return super().keys()
155191

156192
def pop(self, key: _K, *args: Any) -> _V:
193+
self._check_frozen()
157194
if self._dict is not None:
158195
return self._dict.pop(key, *args) # type: ignore[no-any-return]
159196
return super().pop(key, *args) # type: ignore[no-any-return]
160197

161198
def setdefault(self, key: _K, default: _V | None = None) -> _V:
199+
self._check_frozen()
162200
default = self._get_dict_or_value(default)
163201
assert default is not None
164202
if self._dict is not None:
165203
return self._dict.setdefault(key, default)
166204
return super().setdefault(key, default)
167205

168206
def update(self, other: Any) -> None:
207+
self._check_frozen()
169208
other = self._get_dict_or_value(other)
170209
if self._dict is not None:
171210
self._dict.update(other)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
from __future__ import annotations
2+
3+
import copy
4+
import unittest
5+
6+
from benedict.dicts.base import BaseDict
7+
8+
9+
class base_dict_freeze_test_case(unittest.TestCase):
10+
def test_freeze(self) -> None:
11+
b = BaseDict({"a": 1})
12+
self.assertFalse(b.frozen)
13+
b.freeze()
14+
self.assertTrue(b.frozen)
15+
16+
def test_freeze_returns_self(self) -> None:
17+
b = BaseDict({"a": 1})
18+
self.assertIs(b.freeze(), b)
19+
20+
def test_freeze_prevents_setitem(self) -> None:
21+
b = BaseDict({"a": 1})
22+
b.freeze()
23+
with self.assertRaises(TypeError):
24+
b["a"] = 2
25+
with self.assertRaises(TypeError):
26+
b["b"] = 3
27+
28+
def test_freeze_prevents_delitem(self) -> None:
29+
b = BaseDict({"a": 1})
30+
b.freeze()
31+
with self.assertRaises(TypeError):
32+
del b["a"]
33+
34+
def test_freeze_prevents_clear(self) -> None:
35+
b = BaseDict({"a": 1})
36+
b.freeze()
37+
with self.assertRaises(TypeError):
38+
b.clear()
39+
40+
def test_freeze_prevents_pop(self) -> None:
41+
b = BaseDict({"a": 1})
42+
b.freeze()
43+
with self.assertRaises(TypeError):
44+
b.pop("a")
45+
46+
def test_freeze_prevents_setdefault(self) -> None:
47+
b = BaseDict({"a": 1})
48+
b.freeze()
49+
with self.assertRaises(TypeError):
50+
b.setdefault("b", 2)
51+
52+
def test_freeze_prevents_update(self) -> None:
53+
b = BaseDict({"a": 1})
54+
b.freeze()
55+
with self.assertRaises(TypeError):
56+
b.update({"b": 2})
57+
58+
def test_freeze_allows_read(self) -> None:
59+
b = BaseDict({"a": 1, "b": 2})
60+
b.freeze()
61+
self.assertEqual(b["a"], 1)
62+
self.assertIn("a", b)
63+
self.assertEqual(list(b.keys()), ["a", "b"])
64+
65+
def test_unfreeze(self) -> None:
66+
b = BaseDict({"a": 1})
67+
b.freeze()
68+
self.assertTrue(b.frozen)
69+
b.unfreeze()
70+
self.assertFalse(b.frozen)
71+
72+
def test_unfreeze_returns_self(self) -> None:
73+
b = BaseDict({"a": 1})
74+
self.assertIs(b.freeze().unfreeze(), b)
75+
76+
def test_unfreeze_allows_setitem(self) -> None:
77+
b = BaseDict({"a": 1})
78+
b.freeze()
79+
b.unfreeze()
80+
b["a"] = 2
81+
self.assertEqual(b["a"], 2)
82+
83+
def test_freeze_does_not_propagate_to_nested_dict(self) -> None:
84+
# freeze() only blocks top-level mutations (aligned with frozendict/MappingProxyType).
85+
# nested dicts are not frozen.
86+
b = BaseDict({"a": 1, "b": {"c": 2}})
87+
b.freeze()
88+
self.assertTrue(b.frozen)
89+
with self.assertRaises(TypeError):
90+
b["a"] = 99
91+
92+
def test_freeze_does_not_propagate_to_dict_in_list(self) -> None:
93+
inner = BaseDict({"b": 1})
94+
b = BaseDict()
95+
super(BaseDict, b).__setitem__("a", [inner]) # type: ignore[call-arg]
96+
b.freeze()
97+
# top-level is frozen
98+
self.assertTrue(b.frozen)
99+
# nested BaseDict is NOT frozen (no deep propagation)
100+
self.assertFalse(inner.frozen)
101+
102+
def test_unfreeze_allows_setitem_after_freeze(self) -> None:
103+
b = BaseDict({"a": 1})
104+
b.freeze()
105+
self.assertTrue(b.frozen)
106+
b.unfreeze()
107+
self.assertFalse(b.frozen)
108+
b["a"] = 2
109+
self.assertEqual(b["a"], 2)
110+
111+
def test_copy_of_frozen_is_frozen(self) -> None:
112+
b = BaseDict({"a": 1})
113+
b.freeze()
114+
c = b.copy()
115+
self.assertTrue(c.frozen)
116+
with self.assertRaises(TypeError):
117+
c["a"] = 2
118+
119+
def test_copy_of_unfrozen_is_not_frozen(self) -> None:
120+
b = BaseDict({"a": 1})
121+
c = b.copy()
122+
self.assertFalse(c.frozen)
123+
c["a"] = 2 # must not raise
124+
125+
def test_deepcopy_of_frozen_is_frozen(self) -> None:
126+
b = BaseDict({"a": 1})
127+
b.freeze()
128+
c = copy.deepcopy(b)
129+
self.assertTrue(c.frozen)
130+
with self.assertRaises(TypeError):
131+
c["a"] = 2
132+
133+
def test_deepcopy_of_unfrozen_is_not_frozen(self) -> None:
134+
b = BaseDict({"a": 1})
135+
c = copy.deepcopy(b)
136+
self.assertFalse(c.frozen)
137+
c["a"] = 2 # must not raise

0 commit comments

Comments
 (0)