Python’s attrs Overview

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 attrslibrary? 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 x = attr.ib(default=[]) will set 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.

Leave a Reply

Your email address will not be published. Required fields are marked *