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
?
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.
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.
It looks like you're hitting this issue. There are two ways of resolving it:
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.
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.)