python-attrs / cattrs

Composable custom class converters for attrs, dataclasses and friends.
https://catt.rs
MIT License
829 stars 113 forks source link

`make_unstructure_dict_unstructure_fn` does not honor `use_class_methods` #566

Open salotz opened 4 months ago

salotz commented 4 months ago

From comments in #558. Custom metamethods and make_unstructure_dict_unstructure_fn cannot be used together:

import cattrs
from cattrs.strategies import use_class_methods
import attrs

# Use a set of metamethods
@attrs.define
class Thing:
    a: int

    def _unstructure(self):
        return {"a" : str(self.a)}

    @classmethod
    def _structure(cls, val):
        return cls(a=int(val["a"]))

conv = cattrs.Converter()
use_class_methods(
    conv,
    "_structure",
    "_unstructure",
)

assert conv.unstructure(Thing(1)) == {
    "a" : "1",
}

def tag_attrs_hook_factory(cl):

    base_hook = cattrs.gen.make_dict_unstructure_fn(cl, conv)

    def hook(instance):

        unstruct = base_hook(instance)
        unstruct["_type"] = type(instance).__name__

        return unstruct
    return hook

tagging_conv_a = cattrs.Converter()
tagging_conv_a.register_unstructure_hook_factory(attrs.has, tag_attrs_hook_factory)
use_class_methods(
    tagging_conv_a,
    "_structure",
    "_unstructure",
)

# THIS IS WRONG. Does not have the tag
assert tagging_conv_a.unstructure(Thing(1)) == {
    "a" : "1",
}

tagging_conv_b = cattrs.Converter()
use_class_methods(
    tagging_conv_b,
    "_structure",
    "_unstructure",
)
tagging_conv_b.register_unstructure_hook_factory(attrs.has, tag_attrs_hook_factory)

# THIS IS WRONG. Does not convert the sub value correctly via the metamethod
assert tagging_conv_b.unstructure(Thing(1)) == {
    "_type" : "Thing",
    "a" : 1,
}

What I tried initially was to just inject the converter so it dispatches to the metamethods properly. But this causes infinite recursion, e.g.:

def tag_attrs_hook_factory(cl):

    converter = ... # via closure

    def hook(instance, converter):

        unstruct = converter.unstructure(instance)
        unstruct["_type"] = type(instance).__name__

        return unstruct
    return hook

I think that make_dict_unstructure_fn and friends should honor the metamethods for the passed in converter and break recursion. I'm not sure if this makes sense though... In any case this is a difficult case to handle generally.