Skip to content

Commit 504eefe

Browse files
authored
NG: make frozen classes comfortably subclassable (#687)
* NG: make frozen classes comfortably subclassable * Add newsfragment * This ain't markdown * Address review
1 parent 4875590 commit 504eefe

File tree

3 files changed

+100
-7
lines changed

3 files changed

+100
-7
lines changed

changelog.d/687.change.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
The ergonomics of creating frozen classes using ``@define(frozen=True)`` and sub=classing frozen classes has been improved:
2+
you don't have to set ``on_setattr=None`` anymore.

src/attr/_next_gen.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from attr.exceptions import UnannotatedAttributeError
1111

1212
from . import setters
13-
from ._make import NOTHING, attrib, attrs
13+
from ._make import NOTHING, _frozen_setattrs, attrib, attrs
1414

1515

1616
def define(
@@ -32,10 +32,10 @@ def define(
3232
order=False,
3333
auto_detect=True,
3434
getstate_setstate=None,
35-
on_setattr=setters.validate,
35+
on_setattr=None,
3636
):
3737
r"""
38-
The only behavioral difference is the handling of the *auto_attribs*
38+
The only behavioral differences are the handling of the *auto_attribs*
3939
option:
4040
4141
:param Optional[bool] auto_attribs: If set to `True` or `False`, it behaves
@@ -46,6 +46,7 @@ def define(
4646
2. Otherwise it assumes *auto_attribs=False* and tries to collect
4747
`attr.ib`\ s.
4848
49+
and that mutable classes (``frozen=False``) validate on ``__setattr__``.
4950
5051
.. versionadded:: 20.1.0
5152
"""
@@ -73,11 +74,36 @@ def do_it(cls, auto_attribs):
7374
on_setattr=on_setattr,
7475
)
7576

76-
if auto_attribs is not None:
77-
return do_it(maybe_cls, auto_attribs)
78-
7977
def wrap(cls):
80-
# Making this a wrapper ensures this code runs during class creation.
78+
"""
79+
Making this a wrapper ensures this code runs during class creation.
80+
81+
We also ensure that frozen-ness of classes is inherited.
82+
"""
83+
nonlocal frozen, on_setattr
84+
85+
had_on_setattr = on_setattr not in (None, setters.NO_OP)
86+
87+
# By default, mutable classes validate on setattr.
88+
if frozen is False and on_setattr is None:
89+
on_setattr = setters.validate
90+
91+
# However, if we subclass a frozen class, we inherit the immutability
92+
# and disable on_setattr.
93+
for base_cls in cls.__bases__:
94+
if base_cls.__setattr__ is _frozen_setattrs:
95+
if had_on_setattr:
96+
raise ValueError(
97+
"Frozen classes can't use on_setattr "
98+
"(frozen-ness was inherited)."
99+
)
100+
101+
on_setattr = setters.NO_OP
102+
break
103+
104+
if auto_attribs is not None:
105+
return do_it(cls, auto_attribs)
106+
81107
try:
82108
return do_it(cls, True)
83109
except UnannotatedAttributeError:

tests/test_next_gen.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
Python 3-only integration tests for provisional next generation APIs.
33
"""
44

5+
import re
6+
57
import pytest
68

79
import attr
@@ -173,3 +175,66 @@ def __eq__(self, o):
173175

174176
with pytest.raises(ValueError):
175177
C() == C()
178+
179+
def test_subclass_frozen(self):
180+
"""
181+
It's possible to subclass an `attr.frozen` class and the frozen-ness is
182+
inherited.
183+
"""
184+
185+
@attr.frozen
186+
class A:
187+
a: int
188+
189+
@attr.frozen
190+
class B(A):
191+
b: int
192+
193+
@attr.define(on_setattr=attr.setters.NO_OP)
194+
class C(B):
195+
c: int
196+
197+
assert B(1, 2) == B(1, 2)
198+
assert C(1, 2, 3) == C(1, 2, 3)
199+
200+
with pytest.raises(attr.exceptions.FrozenInstanceError):
201+
A(1).a = 1
202+
203+
with pytest.raises(attr.exceptions.FrozenInstanceError):
204+
B(1, 2).a = 1
205+
206+
with pytest.raises(attr.exceptions.FrozenInstanceError):
207+
B(1, 2).b = 2
208+
209+
with pytest.raises(attr.exceptions.FrozenInstanceError):
210+
C(1, 2, 3).c = 3
211+
212+
def test_catches_frozen_on_setattr(self):
213+
"""
214+
Passing frozen=True and on_setattr hooks is caught, even if the
215+
immutability is inherited.
216+
"""
217+
218+
@attr.define(frozen=True)
219+
class A:
220+
pass
221+
222+
with pytest.raises(
223+
ValueError, match="Frozen classes can't use on_setattr."
224+
):
225+
226+
@attr.define(frozen=True, on_setattr=attr.setters.validate)
227+
class B:
228+
pass
229+
230+
with pytest.raises(
231+
ValueError,
232+
match=re.escape(
233+
"Frozen classes can't use on_setattr "
234+
"(frozen-ness was inherited)."
235+
),
236+
):
237+
238+
@attr.define(on_setattr=attr.setters.validate)
239+
class C(A):
240+
pass

0 commit comments

Comments
 (0)