Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NG: make frozen classes comfortably subclassable #687

Merged
merged 4 commits into from Sep 5, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/687.change.rst
@@ -0,0 +1,2 @@
The ergonomics of creating frozen classes using ``@define(frozen=True)`` and sub=classing frozen classes has been improved:
you don't have to set ``on_setattr=None`` anymore.
40 changes: 33 additions & 7 deletions src/attr/_next_gen.py
Expand Up @@ -10,7 +10,7 @@
from attr.exceptions import UnannotatedAttributeError

from . import setters
from ._make import NOTHING, attrib, attrs
from ._make import NOTHING, _frozen_setattrs, attrib, attrs


def define(
Expand All @@ -32,10 +32,10 @@ def define(
order=False,
auto_detect=True,
getstate_setstate=None,
on_setattr=setters.validate,
on_setattr=None,
):
r"""
The only behavioral difference is the handling of the *auto_attribs*
The only behavioral differences are the handling of the *auto_attribs*
option:

:param Optional[bool] auto_attribs: If set to `True` or `False`, it behaves
Expand All @@ -46,6 +46,7 @@ def define(
2. Otherwise it assumes *auto_attribs=False* and tries to collect
`attr.ib`\ s.

and that mutable classes (``frozen=False``) validate on ``__setattr__``.

.. versionadded:: 20.1.0
"""
Expand Down Expand Up @@ -73,11 +74,36 @@ def do_it(cls, auto_attribs):
on_setattr=on_setattr,
)

if auto_attribs is not None:
return do_it(maybe_cls, auto_attribs)

def wrap(cls):
# Making this a wrapper ensures this code runs during class creation.
"""
Making this a wrapper ensures this code runs during class creation.

We also ensure that frozen-ness of classes is inherited.
"""
nonlocal frozen, on_setattr

had_on_setattr = on_setattr not in (None, setters.NO_OP)

# By default, mutable classes validate on setattr.
if frozen is False and on_setattr is None:
on_setattr = setters.validate

# However, if we subclass a frozen class, we inherit the immutability
# and disable on_setattr.
for base_cls in cls.__bases__:
if base_cls.__setattr__ is _frozen_setattrs:
if had_on_setattr:
raise ValueError(
"Frozen classes can't use on_setattr "
"(frozen-ness was inherited)."
)

on_setattr = setters.NO_OP
break

if auto_attribs is not None:
return do_it(cls, auto_attribs)

try:
return do_it(cls, True)
except UnannotatedAttributeError:
Expand Down
65 changes: 65 additions & 0 deletions tests/test_next_gen.py
Expand Up @@ -2,6 +2,8 @@
Python 3-only integration tests for provisional next generation APIs.
"""

import re

import pytest

import attr
Expand Down Expand Up @@ -173,3 +175,66 @@ def __eq__(self, o):

with pytest.raises(ValueError):
C() == C()

def test_subclass_frozen(self):
"""
It's possible to subclass an `attr.frozen` class and the frozen-ness is
inherited.
"""

@attr.frozen
class A:
a: int

@attr.frozen
class B(A):
b: int

@attr.define(on_setattr=attr.setters.NO_OP)
class C(B):
c: int

assert B(1, 2) == B(1, 2)
assert C(1, 2, 3) == C(1, 2, 3)

with pytest.raises(attr.exceptions.FrozenInstanceError):
A(1).a = 1

with pytest.raises(attr.exceptions.FrozenInstanceError):
B(1, 2).a = 1

with pytest.raises(attr.exceptions.FrozenInstanceError):
B(1, 2).b = 2

with pytest.raises(attr.exceptions.FrozenInstanceError):
C(1, 2, 3).c = 3

def test_catches_frozen_on_setattr(self):
"""
Passing frozen=True and on_setattr hooks is caught, even if the
immutability is inherited.
"""

@attr.define(frozen=True)
class A:
pass

with pytest.raises(
ValueError, match="Frozen classes can't use on_setattr."
):

@attr.define(frozen=True, on_setattr=attr.setters.validate)
class B:
pass

with pytest.raises(
ValueError,
match=re.escape(
"Frozen classes can't use on_setattr "
"(frozen-ness was inherited)."
),
):

@attr.define(on_setattr=attr.setters.validate)
class C(A):
pass