Common usage examples

This section covers common use examples of cattrs features.

Using Pendulum for Dates and Time

To use the excellent Pendulum library for datetimes, we need to register structuring and unstructuring hooks for it.

First, we need to decide on the unstructured representation of a datetime instance. Since all our datetimes will use the UTC time zone, we decide to use the UNIX epoch timestamp as our unstructured representation.

Define a class using Pendulum’s DateTime:

import pendulum
from pendulum import DateTime

@attr.s
class MyRecord:
    a_string: str = attr.ib()
    a_datetime: DateTime = attr.ib()

Next, we register hooks for the DateTime class on a new Converter instance.

converter = Converter()

converter.register_unstructure_hook(DateTime, lambda dt: dt.timestamp())

converter.register_structure_hook(DateTime, lambda ts, _: pendulum.from_timestamp(ts))

And we can proceed with unstructuring and structuring instances of MyRecord.

>>> my_record = MyRecord('test', pendulum.datetime(2018, 7, 28, 18, 24))
>>> my_record
MyRecord(a_string='test', a_datetime=DateTime(2018, 7, 28, 18, 24, 0, tzinfo=Timezone('UTC')))

>>> converter.unstructure(my_record)
{'a_string': 'test', 'a_datetime': 1532802240.0}

>>> converter.structure({'a_string': 'test', 'a_datetime': 1532802240.0}, MyRecord)
MyRecord(a_string='test', a_datetime=DateTime(2018, 7, 28, 18, 24, 0, tzinfo=Timezone('UTC')))

After a while, we realize we will need our datetimes to have timezone information. We decide to switch to using the ISO 8601 format for our unstructured datetime instances.

>>> converter = cattr.Converter()
>>> converter.register_unstructure_hook(DateTime, lambda dt: dt.to_iso8601_string())
>>> converter.register_structure_hook(DateTime, lambda isostring, _: pendulum.parse(isostring))

>>> my_record = MyRecord('test', pendulum.datetime(2018, 7, 28, 18, 24, tz='Europe/Paris'))
>>> my_record
MyRecord(a_string='test', a_datetime=DateTime(2018, 7, 28, 18, 24, 0, tzinfo=Timezone('Europe/Paris')))

>>> converter.unstructure(my_record)
{'a_string': 'test', 'a_datetime': '2018-07-28T18:24:00+02:00'}

>>> converter.structure({'a_string': 'test', 'a_datetime': '2018-07-28T18:24:00+02:00'}, MyRecord)
MyRecord(a_string='test', a_datetime=DateTime(2018, 7, 28, 18, 24, 0, tzinfo=Timezone('+02:00')))

Using factory hooks

For this example, let’s assume you have some attrs classes with snake case attributes, and you want to un/structure them as camel case.

Warning

A simpler and better approach to this problem is to simply make your class attributes camel case. However, this is a good example of the power of hook factories and cattrs’ component-based design.

Here’s our simple data model:

@define
class Inner:
    a_snake_case_int: int
    a_snake_case_float: float
    a_snake_case_str: str

@define
class Outer:
    a_snake_case_inner: Inner

Let’s examine our options one by one, starting with the simplest: writing manual un/structuring hooks.

We just write the code by hand and register it:

def unstructure_inner(inner):
    return {
        "aSnakeCaseInt": inner.a_snake_case_int,
        "aSnakeCaseFloat": inner.a_snake_case_float,
        "aSnakeCaseStr": inner.a_snake_case_str
    }

converter.register_unstructure_hook(Inner, unstructure_inner)

(Let’s skip the other unstructure hook and 2 structure hooks due to verbosity.)

This will get us where we want to go, but the drawbacks are immediately obvious: we’d need to write a ton of code ourselves, wasting effort, increasing our maintenance burden and risking bugs. Obviously this won’t do.

Why write code when we can write code to write code for us? In this case this code has already been written for you. cattrs contains a module, cattr.gen, with functions to automatically generate hooks exactly like this. These functions also take parameters to customize the generated hooks.

We can generate and register the renaming hooks we need:

from cattr.gen import make_dict_unstructure_fn, override

converter.register_unstructure_hook(
    Inner,
    make_dict_unstructure_fn(
        Inner,
        converter,
        a_snake_case_int=override(rename="aSnakeCaseInt"),
        a_snake_case_float=override(rename="aSnakeCaseFloat"),
        a_snake_case_str=override(rename="aSnakeCaseStr"),
    )
)

(Again skipping the other hooks due to verbosity.)

This is still too verbose and manual for our tastes, so let’s automate it further. We need a way to convert snake case identifiers to camel case, so let’s grab one from Stack Overflow:

def to_camel_case(snake_str: str) -> str:
    components = snake_str.split("_")
    return components[0] + "".join(x.title() for x in components[1:])

We can combine this with attr.fields to save us some typing:

from attr import fields
from cattr.gen import make_dict_unstructure_fn, override

converter.register_unstructure_hook(
    Inner,
    make_dict_unstructure_fn(
        Inner,
        converter,
        **{a.name: override(rename=to_camel_case(a.name)) for a in fields(Inner)}
    )
)

converter.register_unstructure_hook(
    Outer,
    make_dict_unstructure_fn(
        Outer,
        converter,
        **{a.name: override(rename=to_camel_case(a.name)) for a in fields(Outer)}
    )
)

(Skipping the structuring hooks due to verbosity.)

Now we’re getting somewhere, but we still need to do this for each class separately. The final step is using hook factories instead of hooks directly.

Hook factories are functions that return hooks. They are also registered using predicates instead of being attached to classes directly, like normal un/structure hooks. Predicates are functions that given a type return a boolean whether they handle it.

We want our hook factories to trigger for all attrs classes, so we need a predicate to recognize whether a type is an attrs class. Luckily, attrs comes with attr.has, which is exactly this.

As the final step, we can combine all of this into two hook factories:

from attr import has, fields
from cattr.gen import make_dict_unstructure_fn, make_dict_structure_fn, override

def to_camel_case_unstructure(cls):
    return make_dict_unstructure_fn(
        cls,
        converter,
        **{
            a.name: override(rename=to_camel_case(a.name))
            for a in fields(cls)
        }
    )

def to_camel_case_structure(cls):
    return make_dict_structure_fn(
        cls,
        converter,
        **{
            a.name: override(rename=to_camel_case(a.name))
            for a in fields(cls)
        }
    )

converter.register_unstructure_hook_factory(
    has, to_camel_case_unstructure
)
converter.register_structure_hook_factory(
    has, to_camel_case_structure
)

The converter instance will now un/structure every attrs class to camel case. Nothing has been omitted from this final example; it’s complete.