attrs I: The Basics

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 _CountingAttrs and converts them into attr.Attributes, which are public and immutable, before generating all the other methods. The Attributes 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 _CountingAttrs are a private implementation detail and might work differently in the next release of attrs. Attributes 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 _CountingAttrs 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)