This is the first article in my series on the inner workings of attrs.
Attrs is a Python library for defining classes a different (much better) way. The docs can be found at attrs.readthedocs.org and are pretty good; they explain how and why you should use attrs. And as a Python developer in 2017 you should be using attrs.
This attrs series is about how attrs works under the hood, so go read the docs and use it somewhere first. It'll make following along much easier. The source code is available on GitHub; at this time it's not a particularly large codebase at around 900 lines of non-test code. (Django, as an example, currently has around 76000 lines of Python.)
Here's the simplest useful class the attrs way:
@attr.s
class C:
a = attr.ib()
(I'm omitting boilerplate like imports and using Python 3.6+.)
This will get you a class with a single attribute and the most common boilerplate (__init__
, __repr__
, ...) generated and ready to be used. But what's actually happening here?
Let's take a look at this class without the attr.s
decorator applied. (Leaving it out is an error and won't get you a working class, we're doing it now to take a look under the hood.)
class C:
a = attr.ib()
So this is just a class with a single class (i.e. not instance) attribute, assigned the value of whatever the attr.ib()
function returns. attr.ib
is just a reference to the attr._make.attr
function, which is a fairly thin wrapper around the attr._make._CountingAttr
class.
This is a private class (as the leading underscore suggests) that holds the intermediate attribute state until the attr.s
class decorator comes along and does something with it.
>>> C.a
_CountingAttr(counter=8, _default=NOTHING, repr=True, cmp=True, hash=None, init=True, metadata={})
The counter is a global variable that gets incremented and assigned to _CountingAttr
instances when they're created. It's there so you can count on the consistent ordering of attributes:
@attr.s
class D:
a = attr.ib()
b = attr.ib()
c = attr.ib()
d = attr.ib()
>>> [a.name for a in attr.fields(D)]
['a', 'b', 'c', 'd'] # Note the ordering.
Attrs has relatively recently added a new way of defining attribute defaults:
@attr.s
class E:
a = attr.ib()
@a.default
def a_default(self):
return 1
As you might guess by now, default
is just a _CountingAttr
method that updates its internal state. (It's also the reason the field on CountingAttr
instances is called _default
and not default
.)
attr.s
is a class decorator that gathers up these _CountingAttr
s and converts them into attr.Attribute
s, which are public and immutable, before generating all the other methods. The Attribute
s get put into a tuple at C.__attrs_attrs__
, and this tuple is what you get when you call attr.fields(C)
. If you want to inspect an attribute, fetch it using attr.fields(C).a
and not C.a
. C.a
is deprecated and scheduled to be removed soon, and doesn't work on slot classes anyway.
Now, armed with this knowledge, you can customize your attributes before they get transformed and the other boilerplate methods get generated.
You'll also need some courage, since _CountingAttr
s are a private implementation detail and might work differently in the next release of attrs. Attribute
s are safe to use and follow the usual deprecation period; ideally you should apply your customizations after the application of attr.s
. I've chosen an example that's much easier to implement before attr.s
.
As an exercise, let's code up a class decorator that will set all your attribute defaults to None
if no other default was set (no default set is indicated by the _default
field having the sentinel value attr.NOTHING
). We just need to iterate over all _CountingAttr
s and change their _default
fields.
from attr import NOTHING
from attr._make import _CountingAttr
def add_defaults(cl):
for obj in cl.__dict__.values():
if not isinstance(obj, _CountingAttr) or obj._default is not NOTHING:
continue
obj._default = None
return cl
Example usage:
@attr.s
@add_defaults
class C:
a = attr.ib(default=5)
b = attr.ib()
>>> C()
C(a=5, b=None)