Skip to content

Commit dbb25ce

Browse files
authored
Add support for __attrs_init_subclass__ (#1321)
* Add support for __attrs_init_subclass__ * Fix test docstring * Fix import * Add versionadded * Invert logic and add example * Explain behavior in API docs * Move to narrative docs * Link * once is enough * why hide * endash * better phrasing
1 parent fd7538f commit dbb25ce

8 files changed

Lines changed: 105 additions & 30 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ class SomeClass:
133133

134134
On the tin, *attrs* might remind you of `dataclasses` (and indeed, `dataclasses` [are a descendant](https://hynek.me/articles/import-attrs/) of *attrs*).
135135
In practice it does a lot more and is more flexible.
136-
For instance, it allows you to define [special handling of NumPy arrays for equality checks](https://www.attrs.org/en/stable/comparison.html#customization), allows more ways to [plug into the initialization process](https://www.attrs.org/en/stable/init.html#hooking-yourself-into-initialization), and allows for stepping through the generated methods using a debugger.
136+
For instance, it allows you to define [special handling of NumPy arrays for equality checks](https://www.attrs.org/en/stable/comparison.html#customization), allows more ways to [plug into the initialization process](https://www.attrs.org/en/stable/init.html#hooking-yourself-into-initialization), has a replacement for `__init_subclass__`, and allows for stepping through the generated methods using a debugger.
137137

138138
For more details, please refer to our [comparison page](https://www.attrs.org/en/stable/why.html#data-classes), but generally speaking, we are more likely to commit crimes against nature to make things work that one would expect to work, but that are quite complicated in practice.
139139

changelog.d/1321.change.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
If a class has an *inherited* method called `__attrs_init_subclass__`, it is now called once the class is done assembling.
2+
3+
This is a replacement for Python's `__init_subclass__` and useful for registering classes, and similar.

docs/examples.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,21 @@ False
677677

678678
## Other Goodies
679679

680+
When building systems that have something resembling a plugin interface, you may want to have a registry of all classes that implement a certain interface:
681+
682+
```{doctest}
683+
>>> REGISTRY = []
684+
>>> class Base: # does NOT have to be an attrs class!
685+
... @classmethod
686+
... def __attrs_init_subclass__(cls):
687+
... REGISTRY.append(cls)
688+
>>> @define
689+
... class Impl(Base):
690+
... pass
691+
>>> REGISTRY
692+
[<class 'Impl'>]
693+
```
694+
680695
Sometimes you may want to create a class programmatically.
681696
*attrs* gives you {func}`attrs.make_class` for that:
682697

docs/init.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,3 +546,38 @@ class APIClient:
546546
```
547547

548548
This makes the class more testable.
549+
550+
(init-subclass)=
551+
552+
## *attrs* and `__init_subclass__`
553+
554+
{meth}`object.__init_subclass__` is a special method that is called when a subclass of the class that defined it is created.
555+
556+
For example:
557+
558+
```{doctest}
559+
>>> class Base:
560+
... @classmethod
561+
... def __init_subclass__(cls):
562+
... print(f"Base has been subclassed by {cls}.")
563+
>>> class Derived(Base):
564+
... pass
565+
Base has been subclassed by <class 'Derived'>.
566+
```
567+
568+
Unfortunately, a class decorator-based approach like *attrs* (or `dataclasses`) doesn't play well with `__init_subclass__`.
569+
With {term}`dict classes`, it is run *before* the class has been processed by *attrs* and in the case of {term}`slotted classes`, where *attrs* has to *replace* the original class, `__init_subclass__` is called *twice*: once for the original class and once for the *attrs* class.
570+
571+
To alleviate this, *attrs* provides `__attrs_init_subclass__` which is also called once the class is done assembling.
572+
The base class doesn't even have to be an *attrs* class:
573+
574+
```{doctest}
575+
>>> class Base:
576+
... @classmethod
577+
... def __attrs_init_subclass__(cls):
578+
... print(f"Base has been subclassed by attrs {cls}.")
579+
>>> @define
580+
... class Derived(Base):
581+
... pass
582+
Base has been subclassed by attrs <class 'Derived'>.
583+
```

docs/why.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Nevertheless, there are still reasons to prefer *attrs* over Data Classes.
1515
Whether they're relevant to *you* depends on your circumstances:
1616

1717
- Data Classes are *intentionally* less powerful than *attrs*.
18-
There is a long list of features that were sacrificed for the sake of simplicity and while the most obvious ones are validators, converters, {ref}`equality customization <custom-comparison>`, or {doc}`extensibility <extending>` in general, it permeates throughout all APIs.
18+
There is a long list of features that were sacrificed for the sake of simplicity and while the most obvious ones are validators, converters, [equality customization](custom-comparison), a solution to the [`__init_subclass__` problem](init-subclass), or {doc}`extensibility <extending>` in general -- it permeates throughout all APIs.
1919

2020
On the other hand, Data Classes currently do not offer any significant feature that *attrs* doesn't already have.
2121

src/attr/_make.py

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import abc
56
import contextlib
67
import copy
78
import enum
@@ -683,34 +684,28 @@ def __init__(
683684
def __repr__(self):
684685
return f"<_ClassBuilder(cls={self._cls.__name__})>"
685686

686-
if PY_3_10_PLUS:
687-
import abc
688-
689-
def build_class(self):
690-
"""
691-
Finalize class based on the accumulated configuration.
692-
693-
Builder cannot be used after calling this method.
694-
"""
695-
if self._slots is True:
696-
return self._create_slots_class()
697-
698-
return self.abc.update_abstractmethods(
699-
self._patch_original_class()
700-
)
701-
702-
else:
687+
def build_class(self):
688+
"""
689+
Finalize class based on the accumulated configuration.
703690
704-
def build_class(self):
705-
"""
706-
Finalize class based on the accumulated configuration.
691+
Builder cannot be used after calling this method.
692+
"""
693+
if self._slots is True:
694+
cls = self._create_slots_class()
695+
else:
696+
cls = self._patch_original_class()
697+
if PY_3_10_PLUS:
698+
cls = abc.update_abstractmethods(cls)
707699

708-
Builder cannot be used after calling this method.
709-
"""
710-
if self._slots is True:
711-
return self._create_slots_class()
700+
# The method gets only called if it's not inherited from a base class.
701+
# _has_own_attribute does NOT work properly for classmethods.
702+
if (
703+
getattr(cls, "__attrs_init_subclass__", None)
704+
and "__attrs_init_subclass__" not in cls.__dict__
705+
):
706+
cls.__attrs_init_subclass__()
712707

713-
return self._patch_original_class()
708+
return cls
714709

715710
def _patch_original_class(self):
716711
"""
@@ -1269,10 +1264,12 @@ def attrs(
12691264
*unsafe_hash* as an alias for *hash* (for :pep:`681` compliance).
12701265
.. deprecated:: 24.1.0 *repr_ns*
12711266
.. versionchanged:: 24.1.0
1272-
12731267
Instances are not compared as tuples of attributes anymore, but using a
12741268
big ``and`` condition. This is faster and has more correct behavior for
12751269
uncomparable values like `math.nan`.
1270+
.. versionadded:: 24.1.0
1271+
If a class has an *inherited* classmethod called
1272+
``__attrs_init_subclass__``, it is executed after the class is created.
12761273
"""
12771274
if repr_ns is not None:
12781275
import warnings

src/attr/_next_gen.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ def define(
5050
:term:`fields <field>` specified using :doc:`type annotations <types>`,
5151
`field()` calls, or the *these* argument.
5252
53+
Since *attrs* patches or replaces an existing class, you cannot use
54+
`object.__init_subclass__` with *attrs* classes, because it runs too early.
55+
As a replacement, you can define ``__attrs_init_subclass__`` on your class.
56+
It will be called by *attrs* classes that subclass it after they're
57+
created. See also :ref:`init-subclass`.
58+
5359
Args:
5460
slots (bool):
5561
Create a :term:`slotted class <slotted classes>` that's more
@@ -308,10 +314,12 @@ def define(
308314
.. versionadded:: 22.2.0
309315
*unsafe_hash* as an alias for *hash* (for :pep:`681` compliance).
310316
.. versionchanged:: 24.1.0
311-
312317
Instances are not compared as tuples of attributes anymore, but using a
313318
big ``and`` condition. This is faster and has more correct behavior for
314319
uncomparable values like `math.nan`.
320+
.. versionadded:: 24.1.0
321+
If a class has an *inherited* classmethod called
322+
``__attrs_init_subclass__``, it is executed after the class is created.
315323
316324
.. note::
317325

tests/test_functional.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
End-to-end tests.
55
"""
66

7-
87
import inspect
98
import pickle
109

@@ -744,3 +743,21 @@ class Hashable:
744743
pass
745744

746745
assert hash(Hashable())
746+
747+
def test_init_subclass(self, slots):
748+
"""
749+
__attrs_init_subclass__ is called on subclasses.
750+
"""
751+
REGISTRY = []
752+
753+
@attr.s(slots=slots)
754+
class Base:
755+
@classmethod
756+
def __attrs_init_subclass__(cls):
757+
REGISTRY.append(cls)
758+
759+
@attr.s(slots=slots)
760+
class ToRegister(Base):
761+
pass
762+
763+
assert [ToRegister] == REGISTRY

0 commit comments

Comments
 (0)