The attrs
package is designed to help Python developers keep their code clean and fast. It uses decorators to modify Python class __slots__
and __init__
functionality. __init__
is straight forward if you’ve worked with Python classes. __slots__
on the other hand is a littler trickier. Let’s give a small example. Say you create an object to hold data:
class Point(object):
def __init__(self, x, y):
self.x = x
self.y = y
The downside of using this class is that it inherits from the object class so by using the Point
class, we are creating full objects that allocate wasted memory. Regular Python objects store all attributes in the __dict__
attribute.
p = Point(1, 2)
p.__dict__ # {'x': 1, 'y': 2}
This allows you to store any number of attributes as you want but the draw back is it can be expensive memory wise as you need to store the object, it’s keys, value references, etc. __slots__
allows you to tell Python what attributes will only be allowed for a class. This saves Python a ton f work in making sure new attributes can be added. This is what namedtuple
does under the hood to be efficient. To use __slots__
we set it to a tuple of attribute strings so if we only want the x
and y
attributes in our Point
class example. With this setup, if we try to set any extra attributes then Python will throw an error.
class Point(object):
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
So what dopes all of this have to do with attrs
library? Well, the attrs
library handles all of this boilerplate as well as a few other features, under the hood for you. We can create lightweight objects using attrs
just like we did manually with __slots__.
The two functions we will look at are the attr.s
class decorator and attr.ib
function. Using attr.s
will allow the Python class to be transformed to a __slots__
based object. We then define attributes with attr.ib
. The Point
class above can be rewritten as
@attr.s(slots=True)
class Point(object):
self.x = attr.ib()
self.y = attr.ib()
We can pass a number of options to attr.ib
to make this class even simpler. The kw_only
argument forces you to pass keyword arguments only when instantiating the class. The default
argument sets a default value if nothing is passed for that argument. We can also pass converter
argument to automatically convert the incoming value of an argument.
@attr.s
class Point(object):
self.x = attr.ib(default=0, converter=int)
self.y = attr.ib(kw_only=True, default=0, converter=int)
The class above will convert both x
and y
to integers and set the default to 0
if no value is passed in. The y
argument will also need to be passed by keyword. Point(y=2)
will set x=0
and y=2
. We can pass kw_only=True
to attr.s
as well to force this setting on all attributes via @attr.s(kw_only=True)
. The default value can be a little tricky. We can pass in immutable built in Python types such as str
and int
but to use mutable types will require us to use the attr.Factory
function. Using
will set x = attr.ib(default=[])
x
for all instances created as if it was a class attribute. In this example demo.foo
is using a shared list object but demo.bar
is not so new class objects don’t share values:
@attr.s
class Demo:
foo = attr.ib(default=[])
bar = attr.ib(default=attr.Factory(list))
d1 = Demo()
d1.foo, d1.bar # ([], [])
d1.foo.append('d1'), d1.bar.append('d1') # (None, None)
d1.foo, d1.bar
(['d1'], ['d1'])
d2 = Demo()
d2.foo, d2.bar # (['d1'], []) <- d2.foo already has a value on a new object!
The attrs
library also gives us some conversion tools. The __repr__
method is defined by default to show all attributes of a class
@attr.s
class Point(object):
self.x = attr.ib()
self.y = attr.ib()
p = Point(1,2)
print(p) # Point(x=1, y=2)
Printing the object will give usPoint(x=1, y=2)
instead of the cryptic <__main__.Point object at 0x104bac5f7>
. We can also pass in a attr
based class into attr.asdict
to convert the object into a dict
. attr.asdict(p)
will give us {'x': 1, 'y': 2}
. We can also freeze all attributes so they cannot be changed via attr.s(frozen=True)
.
There are a few other lesser used functions of attr
but one more I wanted to go over is validators. We can create custom validators for attributes passed into a class. This code makes sure x
is less than 5 or it will throw a ValueError
on class instantiation.
@attr.s
class Point(object):
x = attr.ib()
y = attr.ib()
@x.validator
def check(self, attribute, value):
if value > 5:
raise ValueError("x must be smaller than 5")
Hopefully this article helped you have a clearer view of what attr
tries to solve. You can read about other functionality of attr
on its website here.