|
10 | 10 | import dataclasses
|
11 | 11 | import functools
|
12 | 12 | import typing as t
|
13 |
| -from dataclasses import field |
14 | 13 |
|
15 | 14 | from typing_extensions import dataclass_transform
|
16 | 15 |
|
17 | 16 | _T = t.TypeVar("_T")
|
18 | 17 |
|
19 | 18 |
|
20 |
| -def mutable_during_init( |
21 |
| - default: t.Any = None, *, default_factory: t.Callable[[], t.Any] | None = None |
22 |
| -) -> dataclasses.Field[t.Any]: |
23 |
| - """Create a field that can be modified during initialization phase. |
24 |
| -
|
25 |
| - This field can be modified after __init__ but before the object is sealed. |
26 |
| - This is useful for establishing circular references between objects. |
27 |
| -
|
28 |
| - Parameters |
29 |
| - ---------- |
30 |
| - default : Any, optional |
31 |
| - Default value for the field |
32 |
| - default_factory : callable, optional |
33 |
| - A function that returns the default value |
34 |
| -
|
35 |
| - Returns |
36 |
| - ------- |
37 |
| - dataclasses.Field |
38 |
| - A field with metadata indicating it's mutable during initialization |
39 |
| -
|
40 |
| - Examples |
41 |
| - -------- |
42 |
| - >>> @frozen_dataclass |
43 |
| - ... class Node: |
44 |
| - ... name: str |
45 |
| - ... parent: Node | None = mutable_during_init(None) |
46 |
| - ... children: list[Node] = mutable_during_init(default_factory=list) |
47 |
| - """ |
48 |
| - metadata = {"mutable_during_init": True} |
49 |
| - result: dataclasses.Field[t.Any] |
50 |
| - if default_factory is not None: |
51 |
| - result = field(default_factory=default_factory, metadata=metadata) |
52 |
| - else: |
53 |
| - result = field(default=default, metadata=metadata) |
54 |
| - return result |
55 |
| - |
56 |
| - |
57 | 19 | @dataclass_transform(frozen_default=True)
|
58 |
| -def frozen_dataclass(cls: type[_T] | None = None, *, auto_seal: bool = False) -> t.Any: |
59 |
| - """Create a dataclass that's effectively immutable but with controlled mutability. |
| 20 | +def frozen_dataclass(cls: type[_T]) -> type[_T]: |
| 21 | + """Create a dataclass that's effectively immutable but inherits from non-frozen. |
60 | 22 |
|
61 | 23 | This decorator:
|
62 | 24 | 1) Applies dataclasses.dataclass(frozen=False) to preserve normal dataclass
|
63 | 25 | generation
|
64 |
| - 2) Identifies fields marked as mutable_during_init via field metadata |
65 |
| - 3) Allows controlled mutation of those fields after __init__ until sealed |
66 |
| - 4) Provides a seal() method to make the object completely immutable |
67 |
| - 5) Tells type-checkers that the resulting class should be treated as frozen |
| 26 | + 2) Overrides __setattr__ and __delattr__ to block changes post-init |
| 27 | + 3) Tells type-checkers that the resulting class should be treated as frozen |
68 | 28 |
|
69 | 29 | Parameters
|
70 | 30 | ----------
|
71 |
| - cls : Type[_T], optional |
72 |
| - The class to convert to a controlled-frozen dataclass |
73 |
| - auto_seal : bool, default=False |
74 |
| - If True, automatically seal the object after __post_init__ |
75 |
| - If False, the user must call seal() explicitly when done with initialization |
| 31 | + cls : Type[_T] |
| 32 | + The class to convert to a frozen-like dataclass |
76 | 33 |
|
77 | 34 | Returns
|
78 | 35 | -------
|
79 | 36 | Type[_T]
|
80 |
| - The processed class with controlled immutability enforced at runtime |
| 37 | + The processed class with immutability enforced at runtime |
81 | 38 |
|
82 | 39 | Examples
|
83 | 40 | --------
|
84 |
| - Basic usage with explicit sealing: |
| 41 | + Basic usage: |
85 | 42 |
|
86 | 43 | >>> @frozen_dataclass
|
87 | 44 | ... class User:
|
88 | 45 | ... id: int
|
89 | 46 | ... name: str
|
90 |
| - ... friends: list[str] = mutable_during_init(default_factory=list) |
91 | 47 | >>> user = User(id=1, name="Alice")
|
92 |
| - >>> user.friends.append("Bob") # Modifying the list contents (always allowed) |
93 |
| - >>> user.friends = ["Bob", "Charlie"] # Allowed for mutable_during_init fields |
94 |
| - >>> user.name = "Alice Smith" # Raises error - normal fields can't be modified |
| 48 | + >>> user.name |
| 49 | + 'Alice' |
| 50 | + >>> user.name = "Bob" |
95 | 51 | Traceback (most recent call last):
|
96 | 52 | ...
|
97 | 53 | AttributeError: User is immutable: cannot modify field 'name'
|
98 |
| - >>> user.seal() # Finalize the object, making it completely immutable |
99 |
| - >>> user.friends = ["Dave"] # Now raises error after sealing |
100 |
| - Traceback (most recent call last): |
101 |
| - ... |
102 |
| - AttributeError: User is sealed: cannot modify any fields |
103 |
| -
|
104 |
| - Circular references with explicit sealing: |
105 | 54 |
|
106 |
| - >>> @frozen_dataclass |
107 |
| - ... class Node: |
108 |
| - ... name: str |
109 |
| - ... parent: 'Node | None' = mutable_during_init(None) |
110 |
| - ... children: list['Node'] = mutable_during_init(default_factory=list) |
111 |
| - >>> root = Node(name="root") |
112 |
| - >>> child1 = Node(name="child1") |
113 |
| - >>> child2 = Node(name="child2") |
114 |
| - >>> # Establish the parent-child relationships |
115 |
| - >>> child1.parent = root |
116 |
| - >>> child2.parent = root |
117 |
| - >>> root.children.append(child1) |
118 |
| - >>> root.children.append(child2) |
119 |
| - >>> # Once relationships are established, seal all nodes |
120 |
| - >>> root.seal() |
121 |
| - >>> child1.seal() |
122 |
| - >>> child2.seal() |
123 |
| - >>> # Now the structure is immutable |
124 |
| - >>> child3 = Node(name="child3") |
125 |
| - >>> root.children.append(child3) # List contents are still mutable |
126 |
| - >>> child3.parent = root # OK - child3 is not sealed yet |
127 |
| - >>> try: |
128 |
| - ... root.name = "ROOT" # Error - root is sealed |
129 |
| - ... except AttributeError: |
130 |
| - ... print("Cannot modify sealed object") |
131 |
| - Cannot modify sealed object |
132 |
| -
|
133 |
| - Auto-sealing after initialization: |
| 55 | + Mutating internal attributes (_-prefixed): |
134 | 56 |
|
135 |
| - >>> @frozen_dataclass(auto_seal=True) |
136 |
| - ... class Config: |
137 |
| - ... values: dict[str, str] = field(default_factory=dict) |
138 |
| - ... def __post_init__(self): |
139 |
| - ... self.values["timestamp"] = "now" |
140 |
| - ... # Object will be auto-sealed after __post_init__ |
141 |
| - >>> conf = Config() |
142 |
| - >>> conf.values["key"] = "value" # Dict contents are still mutable |
143 |
| - >>> try: |
144 |
| - ... conf.values = {} # Error - object was auto-sealed |
145 |
| - ... except AttributeError: |
146 |
| - ... print("Cannot modify sealed object") |
147 |
| - Cannot modify sealed object |
| 57 | + >>> user._cache = {"logged_in": True} |
| 58 | + >>> user._cache |
| 59 | + {'logged_in': True} |
148 | 60 |
|
149 |
| - Internal attributes are always mutable: |
| 61 | + Nested mutable fields limitation: |
150 | 62 |
|
151 | 63 | >>> @frozen_dataclass
|
152 |
| - ... class Cache: |
153 |
| - ... name: str |
154 |
| - >>> cache = Cache(name="mycache") |
155 |
| - >>> cache.seal() |
156 |
| - >>> cache._internal = {"data": 123} # Private attributes are allowed |
157 |
| - >>> cache._internal |
158 |
| - {'data': 123} |
| 64 | + ... class Container: |
| 65 | + ... items: list[int] |
| 66 | + >>> c = Container(items=[1, 2]) |
| 67 | + >>> c.items.append(3) # allowed; mutable field itself isn't protected |
| 68 | + >>> c.items |
| 69 | + [1, 2, 3] |
| 70 | + >>> # For deep immutability, use immutable collections (tuple, frozenset) |
| 71 | + >>> @frozen_dataclass |
| 72 | + ... class ImmutableContainer: |
| 73 | + ... items: tuple[int, ...] = (1, 2) |
| 74 | + >>> ic = ImmutableContainer() |
| 75 | + >>> ic.items |
| 76 | + (1, 2) |
| 77 | +
|
| 78 | + Inheritance from mutable base classes: |
| 79 | +
|
| 80 | + >>> import dataclasses |
| 81 | + >>> @dataclasses.dataclass |
| 82 | + ... class MutableBase: |
| 83 | + ... value: int |
| 84 | + >>> @frozen_dataclass |
| 85 | + ... class ImmutableSub(MutableBase): |
| 86 | + ... pass |
| 87 | + >>> obj = ImmutableSub(42) |
| 88 | + >>> obj.value |
| 89 | + 42 |
| 90 | + >>> obj.value = 100 |
| 91 | + Traceback (most recent call last): |
| 92 | + ... |
| 93 | + AttributeError: ImmutableSub is immutable: cannot modify field 'value' |
159 | 94 |
|
160 |
| - CAUTION: The implementation allows setting _sealed=False to bypass immutability: |
| 95 | + Security consideration - modifying the _frozen flag: |
161 | 96 |
|
162 | 97 | >>> @frozen_dataclass
|
163 | 98 | ... class SecureData:
|
164 | 99 | ... secret: str
|
165 | 100 | >>> data = SecureData(secret="password123")
|
166 |
| - >>> data.seal() |
167 |
| - >>> # CAUTION: This bypasses the immutability protection |
168 |
| - >>> data._frozen = False # Intentionally bypassing immutability |
169 |
| - >>> data.secret = "hacked" # Works because object is no longer sealed |
| 101 | + >>> data.secret = "hacked" |
| 102 | + Traceback (most recent call last): |
| 103 | + ... |
| 104 | + AttributeError: SecureData is immutable: cannot modify field 'secret' |
| 105 | + >>> # CAUTION: The _frozen attribute can be modified to bypass immutability |
| 106 | + >>> # protection. This is a known limitation of this implementation |
| 107 | + >>> data._frozen = False # intentionally bypassing immutability |
| 108 | + >>> data.secret = "hacked" # now works because object is no longer frozen |
170 | 109 | >>> data.secret
|
171 | 110 | 'hacked'
|
172 | 111 | """
|
173 |
| - |
174 |
| - def wrap(cls: type[_T]) -> type[_T]: |
175 |
| - # A. Convert to a dataclass with frozen=False |
176 |
| - cls = dataclasses.dataclass(cls) |
177 |
| - |
178 |
| - # B. Collect fields marked as mutable during initialization |
179 |
| - mutable_during_init_fields = set() |
180 |
| - for name, field_obj in getattr(cls, "__dataclass_fields__", {}).items(): |
181 |
| - if field_obj.metadata.get("mutable_during_init", False): |
182 |
| - mutable_during_init_fields.add(name) |
183 |
| - |
184 |
| - # Store on class for runtime checks |
185 |
| - setattr(cls, "_mutable_during_init_fields", mutable_during_init_fields) |
186 |
| - |
187 |
| - # C. Add state tracking attributes |
188 |
| - cls.__annotations__["_initializing"] = bool |
189 |
| - cls.__annotations__["_sealed"] = bool |
190 |
| - cls.__annotations__["_frozen"] = bool # For backward compatibility |
191 |
| - setattr(cls, "_initializing", True) |
192 |
| - setattr(cls, "_sealed", False) |
193 |
| - setattr(cls, "_frozen", True) # Backward compatibility |
194 |
| - |
195 |
| - # Save original methods |
196 |
| - original_init = cls.__init__ |
197 |
| - original_post_init = getattr(cls, "__post_init__", None) |
198 |
| - |
199 |
| - # D. Custom __init__ to set initial state |
200 |
| - @functools.wraps(original_init) |
201 |
| - def __init__(self: t.Any, *args: t.Any, **kwargs: t.Any) -> None: |
202 |
| - # Set initializing state |
203 |
| - object.__setattr__(self, "_initializing", True) |
204 |
| - object.__setattr__(self, "_sealed", False) |
205 |
| - object.__setattr__(self, "_frozen", False) |
206 |
| - |
207 |
| - # Call original __init__ |
208 |
| - original_init(self, *args, **kwargs) |
209 |
| - |
210 |
| - # After __init__, only mutable_during_init fields can be changed |
211 |
| - object.__setattr__(self, "_initializing", False) |
212 |
| - object.__setattr__(self, "_frozen", True) |
213 |
| - |
214 |
| - # If there's a post_init, it will be called separately |
215 |
| - |
216 |
| - # E. Custom __post_init__ to potentially auto-seal |
217 |
| - def __post_init__(self: t.Any) -> None: |
218 |
| - if original_post_init: |
219 |
| - original_post_init(self) |
220 |
| - |
221 |
| - if auto_seal: |
222 |
| - self.seal() |
223 |
| - |
224 |
| - # F. Add seal method |
225 |
| - def seal(self: t.Any) -> None: |
226 |
| - """Finalize this object, making it completely immutable.""" |
227 |
| - object.__setattr__(self, "_sealed", True) |
228 |
| - |
229 |
| - # G. Custom attribute setting to enforce rules |
230 |
| - def __setattr__(self: t.Any, name: str, value: t.Any) -> None: |
231 |
| - # Allow setting during initialization phase |
232 |
| - if getattr(self, "_initializing", True): |
233 |
| - object.__setattr__(self, name, value) |
234 |
| - return |
235 |
| - |
236 |
| - # Backward compatibility: if _frozen was explicitly set to False, |
237 |
| - # allow mutations |
238 |
| - if ( |
239 |
| - name not in ("_frozen", "_sealed") |
240 |
| - and getattr(self, "_frozen", True) is False |
241 |
| - ): |
242 |
| - object.__setattr__(self, name, value) |
243 |
| - return |
244 |
| - |
245 |
| - # Always allow internal attributes |
246 |
| - if name.startswith("_"): |
247 |
| - object.__setattr__(self, name, value) |
248 |
| - return |
249 |
| - |
250 |
| - # If sealed, no attributes can be modified |
251 |
| - if getattr(self, "_sealed", False): |
252 |
| - error_msg = f"{cls.__name__} is sealed: cannot modify any fields" |
253 |
| - raise AttributeError(error_msg) |
254 |
| - |
255 |
| - # If not sealed but past init, only specially marked fields can be changed |
256 |
| - if name in getattr(self, "_mutable_during_init_fields", set()): |
257 |
| - object.__setattr__(self, name, value) |
258 |
| - return |
259 |
| - |
260 |
| - # Otherwise, field cannot be modified |
| 112 | + # A. Convert to a dataclass with frozen=False |
| 113 | + cls = dataclasses.dataclass(cls) |
| 114 | + |
| 115 | + # B. Explicitly annotate and initialize the `_frozen` attribute for static analysis |
| 116 | + cls.__annotations__["_frozen"] = bool |
| 117 | + setattr(cls, "_frozen", False) |
| 118 | + |
| 119 | + # Save the original __init__ to use in our hooks |
| 120 | + original_init = cls.__init__ |
| 121 | + |
| 122 | + # C. Create a new __init__ that will call the original and then set _frozen flag |
| 123 | + @functools.wraps(original_init) |
| 124 | + def __init__(self: t.Any, *args: t.Any, **kwargs: t.Any) -> None: |
| 125 | + # Call the original __init__ |
| 126 | + original_init(self, *args, **kwargs) |
| 127 | + # Set the _frozen flag to make object immutable |
| 128 | + object.__setattr__(self, "_frozen", True) |
| 129 | + |
| 130 | + # D. Custom attribute assignment method |
| 131 | + def __setattr__(self: t.Any, name: str, value: t.Any) -> None: |
| 132 | + # If _frozen is set and we're trying to set a field, block it |
| 133 | + if getattr(self, "_frozen", False) and not name.startswith("_"): |
| 134 | + # Allow mutation of private (_-prefixed) attributes after initialization |
261 | 135 | error_msg = f"{cls.__name__} is immutable: cannot modify field '{name}'"
|
262 | 136 | raise AttributeError(error_msg)
|
263 | 137 |
|
264 |
| - # H. Custom attribute deletion to prevent deletion |
265 |
| - def __delattr__(self: t.Any, name: str) -> None: |
266 |
| - if name.startswith("_") and not getattr(self, "_sealed", False): |
267 |
| - object.__delattr__(self, name) |
268 |
| - return |
269 |
| - |
270 |
| - if getattr(self, "_sealed", False): |
271 |
| - error_msg = f"{cls.__name__} is sealed: cannot delete any fields" |
272 |
| - raise AttributeError(error_msg) |
| 138 | + # Allow the assignment |
| 139 | + object.__setattr__(self, name, value) |
273 | 140 |
|
| 141 | + # E. Custom attribute deletion method |
| 142 | + def __delattr__(self: t.Any, name: str) -> None: |
| 143 | + # If we're frozen, block deletion |
| 144 | + if getattr(self, "_frozen", False): |
274 | 145 | error_msg = f"{cls.__name__} is immutable: cannot delete field '{name}'"
|
275 | 146 | raise AttributeError(error_msg)
|
276 | 147 |
|
277 |
| - # I. Inject methods |
278 |
| - setattr(cls, "__init__", __init__) |
279 |
| - setattr(cls, "__post_init__", __post_init__) |
280 |
| - setattr(cls, "seal", seal) |
281 |
| - setattr(cls, "__setattr__", __setattr__) |
282 |
| - setattr(cls, "__delattr__", __delattr__) |
283 |
| - |
284 |
| - return cls |
| 148 | + # Allow the deletion |
| 149 | + object.__delattr__(self, name) |
285 | 150 |
|
286 |
| - # Support both @frozen_dataclass and @frozen_dataclass(...) syntax |
287 |
| - if cls is None: |
288 |
| - return wrap |
| 151 | + # F. Inject methods into the class (using setattr to satisfy mypy) |
| 152 | + setattr(cls, "__init__", __init__) # Sets _frozen flag post-initialization |
| 153 | + setattr(cls, "__setattr__", __setattr__) # Blocks attribute modification post-init |
| 154 | + setattr(cls, "__delattr__", __delattr__) # Blocks attribute deletion post-init |
289 | 155 |
|
290 |
| - return wrap(cls) |
| 156 | + return cls |
0 commit comments