Structure when input is already partially structured
- cattrs version: 0.9.0
- Python version: 3.7.5
- Operating System: Mac OSX
Description
I am attempting to structure a blob of data into a recursive structure, where part of the blob has already been structured. When it reaches a key that already has a structured value, it errors out. After reading through the documentation it's unclear how to get around this issue.
What I would expect to happen is if a given key already has (valid) structured data, it includes it as is, rather than attempting to (and failing to) structure it.
I'm not sure if I am missing an obvious workaround (converter or hook?), or if this would require a PR to run a check of some sort before attempting to structure a sub-element.
What I Did
This is a minimal example:
from attr import dataclass
from cattr import structure, unstructure
@dataclass(auto_attribs=True)
class B:
name: str
@dataclass(auto_attribs=True)
class A:
name: str
subber: B
dx = {
"name": "alphas",
"subber": {
"name": "subclass"
}
}
a = structure(dx, A)
print(a)
b = structure(dx.get("subber"), B)
print(b)
dy = {
"name": "tau",
"subber": unstructure(b)
}
a2 = structure(dy, A)
print(a2)
dz = {
"name": "omega",
"subber": b
}
a3 = structure(dz, A)
The above produces the following output:
A(name='alphas', subber=B(name='subclass'))
B(name='subclass')
A(name='tau', subber=B(name='subclass'))
Traceback (most recent call last):
File "/Users/nford/Library/Preferences/IntelliJIdea2019.2/scratches/dc.py", line 46, in <module>
a3 = structure(dz, A)
File "/Users/nford/echelon/rolevp/echelon/.venv/lib/python3.7/site-packages/cattr/converters.py", line 178, in structure
return self._structure_func.dispatch(cl)(obj, cl)
File "/Users/nford/echelon/rolevp/echelon/.venv/lib/python3.7/site-packages/cattr/converters.py", line 298, in structure_attrs_fromdict
conv_obj[name] = dispatch(type_)(val, type_)
File "/Users/nford/echelon/rolevp/echelon/.venv/lib/python3.7/site-packages/cattr/converters.py", line 285, in structure_attrs_fromdict
conv_obj = obj.copy() # Dict of converted parameters.
AttributeError: 'B' object has no attribute 'copy'
But what I am hoping to achieve is:
A(name='alpha', subber=B(name='subclass'))
B(name='subclass')
A(name='tau', subber=B(name='subclass'))
A(name='omega', subber=B(name='subclass'))
Hi nathanielford, I faced this same issue and was able to solve it by doing a simple subclass of Converter and overriding the structure_attrs_fromdict method. You could override the structure_attrs_fromtuple method too, if you needed it:
from typing import Mapping, Type, Any
import cattr
class PartialConverter(cattr.Converter):
"""
A subclass of Converter that lets you structure data that is already partially structured.
Usage:
PartialConverter().structure(partly_structured_data, FooClass)
"""
def structure_attrs_fromdict(self, obj: Mapping, cl: Type) -> Any:
if hasattr(obj, "__attrs_attrs__"):
# Don't structure the object if it is already an attrs class instance.
return obj
else:
return super().structure_attrs_fromdict(obj, cl)
It passes for your example above:
from attr import dataclass
# Re-direct the structure/unstructure methods to the PartialConverter:
partial_converter = PartialConverter()
structure = partial_converter.structure
unstructure = partial_converter.unstructure
@dataclass(auto_attribs=True)
class B:
name: str
@dataclass(auto_attribs=True)
class A:
name: str
subber: B
dx = {
"name": "alphas",
"subber": {
"name": "subclass"
}
}
a = structure(dx, A)
print(a)
b = structure(dx.get("subber"), B)
print(b)
dy = {
"name": "tau",
"subber": unstructure(b)
}
a2 = structure(dy, A)
print(a2)
dz = {
"name": "omega",
"subber": b
}
a3 = structure(dz, A)
Which outputs:
A(name='alphas', subber=B(name='subclass'))
B(name='subclass')
A(name='tau', subber=B(name='subclass'))
I tend to write a lot of documentation, so here's a version of PartialConverter with more verbose documentation:
class PartialConverter(cattr.Converter):
"""
A subclass of Converter that lets you structure data that is already partially structured.
"""
def structure_attrs_fromdict(self, obj: Mapping, cl: Type) -> Any:
"""
Instantiate an attrs class from a mapping (dict) of primitives and/or attrs classes.
The parent version of this method can only structure primitive data (i.e. int, str, bool, list, etc.),
whereas this version can also structure data that contains attrs classes. For example:
partly_structured = {"leader": Person(name="foo")}
cattr.structure_attrs_fromdict(partly_structured, Team) # AttributeError:
# 'Person' object has no attribute 'copy'
PartialConverter().structure_attrs_fromdict(partly_structured, Team) # Team(leader=Person("foo"))
:param obj: A dictionary that maps strings to Python primitives (int, str, list, etc.) and/or attrs classes.
Note: the type hint is "Mapping" instead of "Dict" because this is what the parent method uses.
:param cl: The attrs class type to structure the data into (i.e. a Python class that is decorated with @attrs).
:return: An attrs class of the specified type, with the data contained in the provided object.
"""
if hasattr(obj, "__attrs_attrs__"):
# Don't structure the object if it is already an attrs class instance.
return obj
else:
return super().structure_attrs_fromdict(obj, cl)
This was super useful! I had started down a similar path, and this pushed me to a good solution. Thanks for posting it!
You're very welcome! I'm so glad I could share.