Search code examples
pythonpython-attrs

Correct backwards-compatible way to migrate to modern attrs/cattrs style?


Question

I have a set of projects that I wrote starting in early 2020 using attrs v19.3.0 and cattrs for serialization/deserialization. What is the correct, fully backwards-compatible way to migrate classes in these projects from the old @attr.s/attr.ib style to the modern style with @define and @frozen?

Explanation of the Problem

My assumption has been that I can mix-and-match old and new style classes, and that there's always a way to get equivalent functionality with the new annotations. I have been converting one class at a time, checking for unit test failures and MyPy warnings, etc. after each conversion.

I've now moved to the most complicated object hierarchy in this code, which also uses cattrs for serialization/deserialization. I can't find a backwards-compatible solution. As soon as I convert even one class in the module, my test suite immediately fails with cattr-related errors.

Minimal Test Case

I originally showed some of the actual code here, but that was just too confusing to be useful. I've now narrowed the problem down to a small test case.

Here is the old-style class with @attr.s and attr.ib. With this code, the test case passes.

from __future__ import annotations

from typing import Dict
import attr
import cattrs

converter = cattrs.Converter()

@attr.s
class Game:
    players = attr.ib(type=Dict[str, str])
    def copy(self) -> Game:
        return converter.structure(converter.unstructure(self), Game)

class TestGame:
    def test_copy(self):
        game = Game(players={"key" : "value"})
        copy = game.copy()
        assert copy == game and copy is not game

This is the new-style class with the equivalent test case:

from __future__ import annotations

from typing import Dict
import attrs
import cattrs

converter = cattrs.Converter()

@attrs.define
class Game:
    players: Dict[str, str]
    def copy(self) -> Game:
        return converter.structure(converter.unstructure(self), Game)

class TestGame:
    def test_copy(self):
        game = Game(players={"key" : "value"})
        copy = game.copy()
        assert copy == game and copy is not game

This fails with the following error:

test_sample.py:15 (TestGame.test_copy)
self = <tests.test_sample.TestGame object at 0x108ab2490>

    def test_copy(self):
        game = Game(players={"key" : "value"})
>       copy = game.copy()

test_sample.py:18: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
test_sample.py:13: in copy
    return converter.structure(converter.unstructure(self), Game)
../.venv/lib/python3.9/site-packages/cattrs/converters.py:281: in structure
    return self._structure_func.dispatch(cl)(obj, cl)
../.venv/lib/python3.9/site-packages/cattrs/converters.py:446: in structure_attrs_fromdict
    conv_obj[name] = self._structure_attribute(a, val)
../.venv/lib/python3.9/site-packages/cattrs/converters.py:422: in _structure_attribute
    return self._structure_func.dispatch(type_)(value, type_)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <cattrs.converters.Converter object at 0x108a9d6d0>, _ = {'key': 'value'}
cl = 'Dict[str, str]'

    def _structure_error(self, _, cl):
        """At the bottom of the condition stack, we explode if we can't handle it."""
        msg = "Unsupported type: {0!r}. Register a structure hook for " "it.".format(cl)
>       raise StructureHandlerNotFoundError(msg, type_=cl)
E       cattrs.errors.StructureHandlerNotFoundError: Unsupported type: 'Dict[str, str]'. Register a structure hook for it.

../.venv/lib/python3.9/site-packages/cattrs/converters.py:344: StructureHandlerNotFoundError

It seems like what is happening is that cattrs doesn't understand that the players field is a mapping-type field for the new-style class.


Solution

  • It looks like you're hitting this issue. There are two ways of resolving it:

    1. Don't use from __future__ import annotations. Your code becomes:

      import attrs
      import cattrs
      
      converter = cattrs.Converter()
      
      
      @attrs.define
      class Game:
          players: dict[str, str]
      
          def copy(self) -> "Game":
              return converter.structure(converter.unstructure(self), Game)
      
      
      class TestGame:
          def test_copy(self):
              game = Game(players={"key": "value"})
              copy = game.copy()
              assert copy == game and copy is not game
      

      This is ugly and is likly to break in the future in any case.

    2. Use GenConverter instead of Converter:

      from __future__ import annotations
      
      import attrs
      import cattrs
      
      converter = cattrs.GenConverter()
      
      
      @attrs.define
      class Game:
          players: dict[str, str]
      
          def copy(self) -> Game:
              return converter.structure(converter.unstructure(self), Game)
      
      
      class TestGame:
          def test_copy(self):
              game = Game(players={"key": "value"})
              copy = game.copy()
              assert copy == game and copy is not game
      

      This is probably the correct solution.

    Your sample code passes its test with both versions of the code presented in this answer (when tested under Python 3.10.4).

    (You'll note I'm using dict here instead of typing.Dict; that's just a personal preference and the code works either way.)