11from __future__ import annotations
22
3- from typing import TYPE_CHECKING , Any , Literal , TypeVar
3+ from typing import TYPE_CHECKING
4+
5+ # ruff: noqa: N806
6+ from narwhals ._plan ._meta import ImmutableMeta
47
58if TYPE_CHECKING :
6- from collections .abc import Iterator
7- from typing import Any , Callable
8-
9- from typing_extensions import Never , Self , dataclass_transform
10-
11- else :
12- # https://docs.python.org/3/library/typing.html#typing.dataclass_transform
13- def dataclass_transform (
14- * ,
15- eq_default : bool = True ,
16- order_default : bool = False ,
17- kw_only_default : bool = False ,
18- frozen_default : bool = False ,
19- field_specifiers : tuple [type [Any ] | Callable [..., Any ], ...] = (),
20- ** kwargs : Any ,
21- ) -> Callable [[T ], T ]:
22- def decorator (cls_or_fn : T ) -> T :
23- cls_or_fn .__dataclass_transform__ = {
24- "eq_default" : eq_default ,
25- "order_default" : order_default ,
26- "kw_only_default" : kw_only_default ,
27- "frozen_default" : frozen_default ,
28- "field_specifiers" : field_specifiers ,
29- "kwargs" : kwargs ,
30- }
31- return cls_or_fn
32-
33- return decorator
34-
35-
36- T = TypeVar ("T" )
37- _IMMUTABLE_HASH_NAME : Literal ["__immutable_hash_value__" ] = "__immutable_hash_value__"
38-
39-
40- @dataclass_transform (kw_only_default = True , frozen_default = True )
41- class Immutable :
9+ from collections .abc import Iterable , Iterator
10+ from typing import Any , ClassVar , Final
11+
12+ from typing_extensions import Never , Self
13+
14+
15+ _HASH_NAME : Final = "__immutable_hash_value__"
16+
17+
18+ class Immutable (metaclass = ImmutableMeta ):
4219 """A poor man's frozen dataclass.
4320
4421 - Keyword-only constructor (IDE supported)
@@ -49,40 +26,43 @@ class Immutable:
4926 [`copy.replace`]: https://docs.python.org/3.13/library/copy.html#copy.replace
5027 """
5128
52- __slots__ = (_IMMUTABLE_HASH_NAME ,)
53- __immutable_hash_value__ : int
29+ __slots__ = (_HASH_NAME ,)
30+ if not TYPE_CHECKING :
31+ # NOTE: Trying to avoid this being added to synthesized `__init__`
32+ # Seems to be the only difference when decorating the metaclass
33+ __immutable_hash_value__ : int
5434
55- @property
56- def __immutable_keys__ (self ) -> Iterator [str ]:
57- slots : tuple [str , ...] = self .__slots__
58- for name in slots :
59- if name != _IMMUTABLE_HASH_NAME :
60- yield name
35+ __immutable_keys__ : ClassVar [tuple [str , ...]]
6136
6237 @property
6338 def __immutable_values__ (self ) -> Iterator [Any ]:
39+ """Override to configure hash seed."""
40+ getattr_ = getattr
6441 for name in self .__immutable_keys__ :
65- yield getattr (self , name )
42+ yield getattr_ (self , name )
6643
6744 @property
6845 def __immutable_items__ (self ) -> Iterator [tuple [str , Any ]]:
46+ getattr_ = getattr
6947 for name in self .__immutable_keys__ :
70- yield name , getattr (self , name )
48+ yield name , getattr_ (self , name )
7149
7250 @property
7351 def __immutable_hash__ (self ) -> int :
74- if hasattr (self , _IMMUTABLE_HASH_NAME ):
75- return self .__immutable_hash_value__
76- hash_value = hash ((self .__class__ , * self .__immutable_values__ ))
77- object .__setattr__ (self , _IMMUTABLE_HASH_NAME , hash_value )
78- return self .__immutable_hash_value__
52+ HASH = _HASH_NAME
53+ if hasattr (self , HASH ):
54+ hash_value : int = getattr (self , HASH )
55+ else :
56+ hash_value = hash ((self .__class__ , * self .__immutable_values__ ))
57+ object .__setattr__ (self , HASH , hash_value )
58+ return hash_value
7959
8060 def __setattr__ (self , name : str , value : Never ) -> Never :
8161 msg = f"{ type (self ).__name__ !r} is immutable, { name !r} cannot be set."
8262 raise AttributeError (msg )
8363
8464 def __replace__ (self , ** changes : Any ) -> Self :
85- """https://docs.python.org/3.13/library/copy.html#copy.replace""" # noqa: D415
65+ """https://docs.python.org/3.13/library/copy.html#copy.replace. """
8666 if len (changes ) == 1 :
8767 # The most common case is a single field replacement.
8868 # Iff that field happens to be equal, we can noop, preserving the current object's hash.
@@ -96,13 +76,6 @@ def __replace__(self, **changes: Any) -> Self:
9676 changes [name ] = value_current
9777 return type (self )(** changes )
9878
99- def __init_subclass__ (cls , * args : Any , ** kwds : Any ) -> None :
100- super ().__init_subclass__ (* args , ** kwds )
101- if cls .__slots__ :
102- ...
103- else :
104- cls .__slots__ = ()
105-
10679 def __hash__ (self ) -> int :
10780 return self .__immutable_hash__
10881
@@ -111,35 +84,26 @@ def __eq__(self, other: object) -> bool:
11184 return True
11285 if type (self ) is not type (other ):
11386 return False
87+ getattr_ = getattr
11488 return all (
115- getattr (self , key ) == getattr (other , key ) for key in self .__immutable_keys__
89+ getattr_ (self , key ) == getattr_ (other , key ) for key in self .__immutable_keys__
11690 )
11791
11892 def __str__ (self ) -> str :
11993 fields = ", " .join (f"{ _field_str (k , v )} " for k , v in self .__immutable_items__ )
12094 return f"{ type (self ).__name__ } ({ fields } )"
12195
12296 def __init__ (self , ** kwds : Any ) -> None :
123- required : set [str ] = set (self .__immutable_keys__ )
124- if not required and not kwds :
125- # NOTE: Fastpath for empty slots
126- ...
127- elif required == set (kwds ):
128- for name , value in kwds .items ():
129- object .__setattr__ (self , name , value )
130- elif missing := required .difference (kwds ):
131- msg = (
132- f"{ type (self ).__name__ !r} requires attributes { sorted (required )!r} , \n "
133- f"but missing values for { sorted (missing )!r} "
134- )
135- raise TypeError (msg )
136- else :
137- extra = set (kwds ).difference (required )
138- msg = (
139- f"{ type (self ).__name__ !r} only supports attributes { sorted (required )!r} , \n "
140- f"but got unknown arguments { sorted (extra )!r} "
141- )
142- raise TypeError (msg )
97+ if (keys := self .__immutable_keys__ ) or kwds :
98+ required = set (keys )
99+ if required == kwds .keys ():
100+ object__setattr__ = object .__setattr__
101+ for name , value in kwds .items ():
102+ object__setattr__ (self , name , value )
103+ elif missing := required .difference (kwds ):
104+ raise _init_missing_error (self , required , missing )
105+ else :
106+ raise _init_extra_error (self , required , set (kwds ).difference (required ))
143107
144108
145109def _field_str (name : str , value : Any ) -> str :
@@ -149,3 +113,23 @@ def _field_str(name: str, value: Any) -> str:
149113 if isinstance (value , str ):
150114 return f"{ name } ={ value !r} "
151115 return f"{ name } ={ value } "
116+
117+
118+ def _init_missing_error (
119+ obj : object , required : Iterable [str ], missing : Iterable [str ]
120+ ) -> TypeError :
121+ msg = (
122+ f"{ type (obj ).__name__ !r} requires attributes { sorted (required )!r} , \n "
123+ f"but missing values for { sorted (missing )!r} "
124+ )
125+ return TypeError (msg )
126+
127+
128+ def _init_extra_error (
129+ obj : object , required : Iterable [str ], extra : Iterable [str ]
130+ ) -> TypeError :
131+ msg = (
132+ f"{ type (obj ).__name__ !r} only supports attributes { sorted (required )!r} , \n "
133+ f"but got unknown arguments { sorted (extra )!r} "
134+ )
135+ return TypeError (msg )
0 commit comments