Skip to content

Commit de0dbfd

Browse files
committed
feat(frozen_dataclass): implement enhanced frozen_dataclass with post-init mutability and explicit sealing
1 parent ceb2d17 commit de0dbfd

File tree

1 file changed

+225
-91
lines changed

1 file changed

+225
-91
lines changed

src/libtmux/_internal/frozen_dataclass.py

Lines changed: 225 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -10,147 +10,281 @@
1010
import dataclasses
1111
import functools
1212
import typing as t
13+
from dataclasses import field
1314

1415
from typing_extensions import dataclass_transform
1516

1617
_T = t.TypeVar("_T")
1718

1819

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+
1957
@dataclass_transform(frozen_default=True)
20-
def frozen_dataclass(cls: type[_T]) -> type[_T]:
21-
"""Create a dataclass that's effectively immutable but inherits from non-frozen.
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.
2260
2361
This decorator:
2462
1) Applies dataclasses.dataclass(frozen=False) to preserve normal dataclass
2563
generation
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
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
2868
2969
Parameters
3070
----------
31-
cls : Type[_T]
32-
The class to convert to a frozen-like dataclass
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
3376
3477
Returns
3578
-------
3679
Type[_T]
37-
The processed class with immutability enforced at runtime
80+
The processed class with controlled immutability enforced at runtime
3881
3982
Examples
4083
--------
41-
Basic usage:
84+
Basic usage with explicit sealing:
4285
4386
>>> @frozen_dataclass
4487
... class User:
4588
... id: int
4689
... name: str
90+
... friends: list[str] = mutable_during_init(default_factory=list)
4791
>>> user = User(id=1, name="Alice")
48-
>>> user.name
49-
'Alice'
50-
>>> user.name = "Bob"
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
5195
Traceback (most recent call last):
5296
...
5397
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:
54105
55-
Mutating internal attributes (_-prefixed):
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
56132
57-
>>> user._cache = {"logged_in": True}
58-
>>> user._cache
59-
{'logged_in': True}
133+
Auto-sealing after initialization:
60134
61-
Nested mutable fields limitation:
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
148+
149+
Internal attributes are always mutable:
62150
63151
>>> @frozen_dataclass
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'
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}
94159
95-
Security consideration - modifying the _frozen flag:
160+
CAUTION: The implementation allows setting _sealed=False to bypass immutability:
96161
97162
>>> @frozen_dataclass
98163
... class SecureData:
99164
... secret: str
100165
>>> data = SecureData(secret="password123")
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
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
109170
>>> data.secret
110171
'hacked'
111172
"""
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
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
135261
error_msg = f"{cls.__name__} is immutable: cannot modify field '{name}'"
136262
raise AttributeError(error_msg)
137263

138-
# Allow the assignment
139-
object.__setattr__(self, name, value)
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)
140273

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):
145274
error_msg = f"{cls.__name__} is immutable: cannot delete field '{name}'"
146275
raise AttributeError(error_msg)
147276

148-
# Allow the deletion
149-
object.__delattr__(self, name)
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
150285

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
286+
# Support both @frozen_dataclass and @frozen_dataclass(...) syntax
287+
if cls is None:
288+
return wrap
155289

156-
return cls
290+
return wrap(cls)

0 commit comments

Comments
 (0)