38
38
import dataclasses
39
39
import functools
40
40
import typing as t
41
+ from typing import (
42
+ Any ,
43
+ Callable ,
44
+ Protocol ,
45
+ TypeVar ,
46
+ runtime_checkable ,
47
+ )
41
48
42
- from typing_extensions import dataclass_transform
49
+ # Type definitions for better type hints
50
+ T = TypeVar ("T" , bound = type )
43
51
44
- # Type definition for better hints
45
- T = t .TypeVar ("T" )
52
+
53
+ @runtime_checkable
54
+ class SealableProtocol (Protocol ):
55
+ """Protocol defining the interface for sealable objects."""
56
+
57
+ _sealed : bool
58
+
59
+ def seal (self , deep : bool = False ) -> None :
60
+ """Seal the object to prevent further modifications.
61
+
62
+ Parameters
63
+ ----------
64
+ deep : bool, optional
65
+ If True, recursively seal any nested sealable objects, by default False
66
+ """
67
+ ...
68
+
69
+ @classmethod
70
+ def is_sealable (cls ) -> bool :
71
+ """Check if this class is sealable.
72
+
73
+ Returns
74
+ -------
75
+ bool
76
+ True if the class is sealable, False otherwise
77
+ """
78
+ ...
79
+
80
+
81
+ class Sealable :
82
+ """Base class for sealable objects.
83
+
84
+ This class provides the basic implementation of the SealableProtocol,
85
+ which can be used for explicit inheritance to create sealable classes.
86
+
87
+ Attributes
88
+ ----------
89
+ _sealed : bool
90
+ Whether the object is sealed or not
91
+ """
92
+
93
+ _sealed : bool = False
94
+
95
+ def seal (self , deep : bool = False ) -> None :
96
+ """Seal the object to prevent further modifications.
97
+
98
+ Parameters
99
+ ----------
100
+ deep : bool, optional
101
+ If True, recursively seal any nested sealable objects, by default False
102
+ """
103
+ # Basic implementation that can be overridden by subclasses
104
+ object .__setattr__ (self , "_sealed" , True )
105
+
106
+ @classmethod
107
+ def is_sealable (cls ) -> bool :
108
+ """Check if this class is sealable.
109
+
110
+ Returns
111
+ -------
112
+ bool
113
+ Always returns True for Sealable and its subclasses
114
+ """
115
+ return True
46
116
47
117
48
118
def mutable_field (
49
- factory : t . Callable [[], t . Any ] = list ,
50
- ) -> dataclasses .Field [t . Any ]:
119
+ factory : Callable [[], Any ] = list ,
120
+ ) -> dataclasses .Field [Any ]:
51
121
"""Create a field that is mutable during initialization but immutable after sealing.
52
122
53
123
Parameters
@@ -66,8 +136,8 @@ def mutable_field(
66
136
67
137
68
138
def mutable_during_init (
69
- field_method : t . Callable [[], T ] | None = None ,
70
- ) -> t . Any : # mypy doesn't handle complex return types well here
139
+ field_method : Callable [[], T ] | None = None ,
140
+ ) -> Any : # mypy doesn't handle complex return types well here
71
141
"""Mark a field as mutable during initialization but immutable after sealing.
72
142
73
143
This decorator applies to a method that returns the field's default value.
@@ -160,16 +230,7 @@ def mutable_during_init(
160
230
)
161
231
162
232
163
- # Protocol for classes with seal method
164
- class _Sealable (t .Protocol ):
165
- """Protocol for classes with seal method."""
166
-
167
- def seal (self ) -> None :
168
- """Seal the object to prevent further modifications."""
169
- ...
170
-
171
-
172
- def is_sealable (cls_or_obj : t .Any ) -> bool :
233
+ def is_sealable (cls_or_obj : Any ) -> bool :
173
234
"""Check if a class or object is sealable.
174
235
175
236
Parameters
@@ -186,7 +247,7 @@ def is_sealable(cls_or_obj: t.Any) -> bool:
186
247
--------
187
248
>>> from dataclasses import dataclass
188
249
>>> from libtmux._internal.frozen_dataclass_sealable import (
189
- ... frozen_dataclass_sealable, is_sealable
250
+ ... frozen_dataclass_sealable, is_sealable, Sealable, SealableProtocol
190
251
... )
191
252
192
253
>>> # Regular class is not sealable
@@ -207,19 +268,61 @@ def is_sealable(cls_or_obj: t.Any) -> bool:
207
268
False
208
269
>>> is_sealable(None)
209
270
False
271
+
272
+ >>> # Classes explicitly inheriting from Sealable are sealable
273
+ >>> @dataclass
274
+ ... class ExplicitSealable(Sealable):
275
+ ... value: int
276
+
277
+ >>> is_sealable(ExplicitSealable)
278
+ True
279
+ >>> explicit = ExplicitSealable(value=42)
280
+ >>> is_sealable(explicit)
281
+ True
282
+
283
+ >>> # Classes decorated with frozen_dataclass_sealable are sealable
284
+ >>> @frozen_dataclass_sealable
285
+ ... class DecoratedSealable:
286
+ ... value: int
287
+
288
+ >>> is_sealable(DecoratedSealable)
289
+ True
290
+ >>> decorated = DecoratedSealable(value=42)
291
+ >>> is_sealable(decorated)
292
+ True
293
+
294
+ >>> # Classes that implement SealableProtocol are sealable
295
+ >>> class CustomSealable:
296
+ ... _sealed = False
297
+ ... def seal(self, deep=False):
298
+ ... self._sealed = True
299
+ ... @classmethod
300
+ ... def is_sealable(cls):
301
+ ... return True
302
+
303
+ >>> is_sealable(CustomSealable)
304
+ True
305
+ >>> custom = CustomSealable()
306
+ >>> is_sealable(custom)
307
+ True
210
308
"""
211
- # If it's a class, check if it has a seal method
309
+ # Check if the object is an instance of SealableProtocol
310
+ if isinstance (cls_or_obj , SealableProtocol ):
311
+ return True
312
+
313
+ # If it's a class, check if it's a subclass of Sealable or has a seal method
212
314
if isinstance (cls_or_obj , type ):
315
+ # Check if it's a subclass of Sealable
316
+ if issubclass (cls_or_obj , Sealable ):
317
+ return True
318
+ # For backward compatibility, check if it has a seal method
213
319
return hasattr (cls_or_obj , "seal" ) and callable (cls_or_obj .seal )
214
320
215
321
# If it's an instance, check if it has a seal method
216
322
return hasattr (cls_or_obj , "seal" ) and callable (cls_or_obj .seal )
217
323
218
324
219
- @dataclass_transform (frozen_default = True )
220
- def frozen_dataclass_sealable (
221
- cls : type | None = None , / , ** kwargs : t .Any
222
- ) -> t .Callable [[type ], type ] | type :
325
+ def frozen_dataclass_sealable (cls : type ) -> type :
223
326
"""Create a dataclass that is immutable, with field-level mutability control.
224
327
225
328
Enhances the standard dataclass with:
@@ -231,15 +334,13 @@ def frozen_dataclass_sealable(
231
334
232
335
Parameters
233
336
----------
234
- cls : type, optional
235
- The class to decorate, by default None
236
- **kwargs : dict
237
- Additional arguments passed to dataclasses.dataclass
337
+ cls : type
338
+ The class to decorate
238
339
239
340
Returns
240
341
-------
241
- type or callable
242
- A decorated class with immutability features, or a decorator function if cls is None
342
+ type
343
+ The decorated class with immutability features
243
344
244
345
Examples
245
346
--------
@@ -358,11 +459,10 @@ def frozen_dataclass_sealable(
358
459
Error: AttributeError
359
460
"""
360
461
# Support both @frozen_dataclass_sealable and @frozen_dataclass_sealable() usage
361
- if cls is None :
362
- return t .cast (
363
- t .Callable [[type ], type ],
364
- functools .partial (frozen_dataclass_sealable , ** kwargs ),
365
- )
462
+ # This branch is for direct decorator usage: @frozen_dataclass_sealable
463
+ if not isinstance (cls , type ):
464
+ err_msg = "Expected a class when calling frozen_dataclass_sealable directly"
465
+ raise TypeError (err_msg )
366
466
367
467
# From here, we know cls is not None, so we can safely use cls.__name__
368
468
class_name = cls .__name__
@@ -372,22 +472,7 @@ def frozen_dataclass_sealable(
372
472
# Our custom __setattr__ and __delattr__ will handle immutability
373
473
if not dataclasses .is_dataclass (cls ):
374
474
# Explicitly set frozen=False to preserve inheritance flexibility
375
- kwargs_copy = kwargs .copy ()
376
- if "frozen" in kwargs_copy :
377
- del kwargs_copy ["frozen" ]
378
- cls = dataclasses .dataclass (frozen = False , ** kwargs_copy )(cls )
379
- elif kwargs .get ("frozen" , False ):
380
- # If the class is already a dataclass and frozen=True was specified,
381
- # warn the user
382
- import warnings
383
-
384
- warnings .warn (
385
- f"Class { class_name } specified frozen=True which contradicts the "
386
- "purpose of frozen_dataclass_sealable. "
387
- "The custom implementation will override this." ,
388
- UserWarning ,
389
- stacklevel = 2 ,
390
- )
475
+ cls = dataclasses .dataclass (frozen = False )(cls )
391
476
392
477
# Store the original __post_init__ if it exists
393
478
original_post_init = getattr (cls , "__post_init__" , None )
@@ -413,7 +498,7 @@ def frozen_dataclass_sealable(
413
498
mutable_fields .add (name )
414
499
415
500
# Custom attribute setting implementation
416
- def custom_setattr (self : t . Any , name : str , value : t . Any ) -> None :
501
+ def custom_setattr (self : Any , name : str , value : Any ) -> None :
417
502
# Allow setting private attributes always
418
503
if name .startswith ("_" ):
419
504
object .__setattr__ (self , name , value )
@@ -440,7 +525,7 @@ def custom_setattr(self: t.Any, name: str, value: t.Any) -> None:
440
525
raise AttributeError (error_msg )
441
526
442
527
# Custom attribute deletion implementation
443
- def custom_delattr (self : t . Any , name : str ) -> None :
528
+ def custom_delattr (self : Any , name : str ) -> None :
444
529
if name .startswith ("_" ):
445
530
object .__delattr__ (self , name )
446
531
return
@@ -454,7 +539,7 @@ def custom_delattr(self: t.Any, name: str) -> None:
454
539
raise AttributeError (error_msg )
455
540
456
541
# Custom initialization to set initial attribute values
457
- def custom_init (self : t . Any , * args : t . Any , ** kwargs : t . Any ) -> None :
542
+ def custom_init (self : Any , * args : Any , ** kwargs : Any ) -> None :
458
543
# Set the initializing flag
459
544
object .__setattr__ (self , "_initializing" , True )
460
545
object .__setattr__ (self , "_sealed" , False )
@@ -501,7 +586,6 @@ def custom_init(self: t.Any, *args: t.Any, **kwargs: t.Any) -> None:
501
586
base_fields = set ()
502
587
503
588
# Skip the current class in the MRO (it's the first one)
504
- assert cls is not None , "cls should not be None here - this is a mypy guard"
505
589
for base_cls in cls .__mro__ [1 :]:
506
590
if hasattr (base_cls , "__dataclass_fields__" ):
507
591
for name in base_cls .__dataclass_fields__ :
@@ -520,7 +604,6 @@ def custom_init(self: t.Any, *args: t.Any, **kwargs: t.Any) -> None:
520
604
521
605
# Initialize base classes first
522
606
# Skip the current class in the MRO (it's the first one)
523
- assert cls is not None , "cls should not be None here - this is a mypy guard"
524
607
for base_cls in cls .__mro__ [1 :]:
525
608
base_init = getattr (base_cls , "__init__" , None )
526
609
if (
@@ -555,10 +638,12 @@ def custom_init(self: t.Any, *args: t.Any, **kwargs: t.Any) -> None:
555
638
# Automatically seal if no mutable fields are defined
556
639
# But ONLY for classes that don't have any fields marked mutable_during_init
557
640
if not mutable_fields :
558
- seal (self )
641
+ seal_method = getattr (self , "seal" , None )
642
+ if seal_method and callable (seal_method ):
643
+ seal_method ()
559
644
560
- # Method to explicitly seal the object
561
- def seal (self : t . Any , deep : bool = False ) -> None :
645
+ # Define methods that will be attached to the class
646
+ def seal_method (self : Any , deep : bool = False ) -> None :
562
647
"""Seal the object to prevent further modifications.
563
648
564
649
Parameters
@@ -574,21 +659,12 @@ def seal(self: t.Any, deep: bool = False) -> None:
574
659
for field_obj in dataclasses .fields (self ):
575
660
field_value = getattr (self , field_obj .name , None )
576
661
# Check if the field value is sealable
577
- from libtmux ._internal .frozen_dataclass_sealable import is_sealable
578
-
579
662
if field_value is not None and is_sealable (field_value ):
580
663
# Seal the nested object
581
664
field_value .seal (deep = True )
582
665
583
- # Add custom methods to the class
584
- cls .__setattr__ = custom_setattr # type: ignore
585
- cls .__delattr__ = custom_delattr # type: ignore
586
- cls .__init__ = custom_init # type: ignore
587
- cls .seal = seal # type: ignore
588
-
589
- # Add a class method to check if the class is sealable
590
- @classmethod
591
- def is_sealable (cls ) -> bool :
666
+ # Define the is_sealable class method
667
+ def is_sealable_class_method (cls_param : type ) -> bool :
592
668
"""Check if this class is sealable.
593
669
594
670
Returns
@@ -598,6 +674,11 @@ def is_sealable(cls) -> bool:
598
674
"""
599
675
return True
600
676
601
- cls .is_sealable = is_sealable # type: ignore
677
+ # Add custom methods to the class
678
+ cls .__setattr__ = custom_setattr # type: ignore
679
+ cls .__delattr__ = custom_delattr # type: ignore
680
+ cls .__init__ = custom_init # type: ignore
681
+ cls .seal = seal_method # type: ignore
682
+ cls .is_sealable = classmethod (is_sealable_class_method ) # type: ignore
602
683
603
684
return cls
0 commit comments