|
10 | 10 | import dataclasses
|
11 | 11 | import functools
|
12 | 12 | import typing as t
|
| 13 | +from dataclasses import field |
13 | 14 |
|
14 | 15 | from typing_extensions import dataclass_transform
|
15 | 16 |
|
16 | 17 | _T = t.TypeVar("_T")
|
17 | 18 |
|
18 | 19 |
|
| 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 | + |
19 | 57 | @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. |
22 | 60 |
|
23 | 61 | This decorator:
|
24 | 62 | 1) Applies dataclasses.dataclass(frozen=False) to preserve normal dataclass
|
25 | 63 | 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 |
28 | 68 |
|
29 | 69 | Parameters
|
30 | 70 | ----------
|
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 |
33 | 76 |
|
34 | 77 | Returns
|
35 | 78 | -------
|
36 | 79 | Type[_T]
|
37 |
| - The processed class with immutability enforced at runtime |
| 80 | + The processed class with controlled immutability enforced at runtime |
38 | 81 |
|
39 | 82 | Examples
|
40 | 83 | --------
|
41 |
| - Basic usage: |
| 84 | + Basic usage with explicit sealing: |
42 | 85 |
|
43 | 86 | >>> @frozen_dataclass
|
44 | 87 | ... class User:
|
45 | 88 | ... id: int
|
46 | 89 | ... name: str
|
| 90 | + ... friends: list[str] = mutable_during_init(default_factory=list) |
47 | 91 | >>> 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 |
51 | 95 | Traceback (most recent call last):
|
52 | 96 | ...
|
53 | 97 | 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: |
54 | 105 |
|
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 |
56 | 132 |
|
57 |
| - >>> user._cache = {"logged_in": True} |
58 |
| - >>> user._cache |
59 |
| - {'logged_in': True} |
| 133 | + Auto-sealing after initialization: |
60 | 134 |
|
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: |
62 | 150 |
|
63 | 151 | >>> @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} |
94 | 159 |
|
95 |
| - Security consideration - modifying the _frozen flag: |
| 160 | + CAUTION: The implementation allows setting _sealed=False to bypass immutability: |
96 | 161 |
|
97 | 162 | >>> @frozen_dataclass
|
98 | 163 | ... class SecureData:
|
99 | 164 | ... secret: str
|
100 | 165 | >>> 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 |
109 | 170 | >>> data.secret
|
110 | 171 | 'hacked'
|
111 | 172 | """
|
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 |
135 | 261 | error_msg = f"{cls.__name__} is immutable: cannot modify field '{name}'"
|
136 | 262 | raise AttributeError(error_msg)
|
137 | 263 |
|
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) |
140 | 273 |
|
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): |
145 | 274 | error_msg = f"{cls.__name__} is immutable: cannot delete field '{name}'"
|
146 | 275 | raise AttributeError(error_msg)
|
147 | 276 |
|
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 |
150 | 285 |
|
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 |
155 | 289 |
|
156 |
| - return cls |
| 290 | + return wrap(cls) |
0 commit comments