What you're trying to do is a little inadvised; the entire point of attrs classes is for all the fields to be enumerated in advance. If you stick arbitrary attributes on instances, you have to use non-slot classes, your helper functions like __repr__
and __eq__
won't work properly (the extra attributes will be ignored), and as you correctly concluded cattrs cannot help you with type conversions (since it has nowhere to actually find the types).
That said, I have rewritten your example to move the logic from the class into a converter, which I find more elegant.
from typing import Any
from attr import define, fields
from cattr.gen import make_dict_structure_fn
from cattr.preconf.json import make_converter
@define(slots=False)
class ClassWithExtras:
foo: int
converter = make_converter()
def make_structure(cl):
# First we generate what cattrs would have used by default.
default_structure = make_dict_structure_fn(cl, converter)
# We generate a set of known attribute names to use later.
attribute_names = {a.name for a in fields(cl)}
# Now we wrap this in a function of our own making.
def structure(val: dict[str, Any], _):
res = default_structure(val)
# `res` is an instance of `cl` now, so we just stick
# the missing attributes on it now.
for k in val.keys() - attribute_names:
setattr(res, k, val[k])
return res
return structure
converter.register_structure_hook_factory(
lambda cls: issubclass(cls, ClassWithExtras), make_structure
)
structured = converter.structure({"foo": "2", "bar": 5}, ClassWithExtras)
assert structured.foo == 2
assert structured.bar == 5
This essentially does what your example does, just using cattrs instead of attrs.
Now, I also have a counter proposal. Let's say instead of sticking the extra attributes directly on the class, we gather them up into a dictionary and stick that dictionary into a regular field. Here's the entire example, rewritten:
from typing import Any
from attr import define, fields
from cattr.gen import make_dict_structure_fn
from cattr.preconf.json import make_converter
@define
class ClassWithExtras:
foo: int
extras: dict[str, Any]
converter = make_converter()
def make_structure(cl):
# First we generate what cattrs would have used by default.
default_structure = make_dict_structure_fn(cl, converter)
# We generate a set of known attribute names to use later.
attribute_names = {a.name for a in fields(cl)}
# Now we wrap this in a function of our own making.
def structure(val: dict[str, Any], _):
val["extras"] = {k: val[k] for k in val.keys() - attribute_names}
res = default_structure(val)
return res
return structure
converter.register_structure_hook_factory(
lambda cls: issubclass(cls, ClassWithExtras), make_structure
)
structured = converter.structure({"foo": "2", "bar": 5}, ClassWithExtras)
assert structured.foo == 2
assert structured.extras["bar"] == 5
assert structured == ClassWithExtras(2, {"bar": 5})