python-attrs / cattrs

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

Subclass disambiguation for nested structures. #535

Open isohedronpipeline opened 2 months ago

isohedronpipeline commented 2 months ago

I'm using attrs 23.2.0, if that matters.

Description

Hi! I am having trouble adding the special _type key to the unstructured data to inform the structurer how to deal with subtypes. It seems to work for the top level structure, but not a second level structure that also has subtypes.

Any help would be appreciated :-)

What I Did

This is some example code:

from enum import Enum
from attrs import define, field
import cattr
from cattrs.strategies import configure_tagged_union, include_subclasses

from typing import ClassVar, List

class Material(Enum):
    WOOD = "wood"
    PLASTIC = "plastic"

@define
class Toy:
    material: ClassVar[Material]

    name: str

@define
class Lego(Toy):
    material = Material.PLASTIC

@define
class Train(Toy):
    material = Material.WOOD

@define
class ToyBox:
    size: ClassVar[int]

    material: Material
    contents: List[Toy] = field(factory=list)

    def add_toy(self, toy):
        if len(self.contents) >= self.size:
            raise ValueError("ToyBox is full")
        self.contents.append(toy)

@define
class SmallToyBox(ToyBox):
    size = 5

@define
class LargeToyBox(ToyBox):
    size = 10

c = cattr.Converter()
include_subclasses(ToyBox, c, union_strategy=configure_tagged_union)
include_subclasses(Toy, c, union_strategy=configure_tagged_union)

box = SmallToyBox(material=Material.WOOD)
box.add_toy(Lego("space"))
box.add_toy(Lego("house"))
box.add_toy(Train("stream"))
box.add_toy(Train("electric"))

unstructured = c.unstructure(box)

import pprint
pprint.pprint(unstructured)

And I get the following:

{'_type': 'SmallToyBox',
 'contents': [{'name': 'space'},
              {'name': 'house'},
              {'name': 'stream'},
              {'name': 'electric'}],
 'material': 'wood'}

which, as you can see, is not including the _type argument in the nested data structure Toy that needs to deal with subtypes.

A possibly related issue is that I want to be able to string together multiple hooks together. Specifically I want to also include this: hook = make_dict_unstructure_fn(Toy, c, _cattrs_omit_if_default=True) in addition to supporting subclasses.

How can I do that?

Thanks!

isohedronpipeline commented 2 months ago

Another thing I tried was:

_cattr_converter.register_unstructure_hook(
    Toy,
    lambda o: {"_type": type(o).__name__, **_cattr_converter.unstructure(o)},
)

but that caused an infinite loop.

Tinche commented 2 months ago

Hi,

sorry for the delayed response, I was on vacation.

These strategies are somewhat stateful since a lot of them do some of the work at configuration time, rather than structure/unstructure time. This means the order in which they are applied matters.

I tried switching the order of the hooks here:

c = cattr.Converter()
include_subclasses(Toy, c, union_strategy=configure_tagged_union)
include_subclasses(ToyBox, c, union_strategy=configure_tagged_union)

Doing this, the output now is:

{'_type': 'SmallToyBox',
 'contents': [{'_type': 'Lego', 'name': 'space'},
              {'_type': 'Lego', 'name': 'house'},
              {'_type': 'Train', 'name': 'stream'},
              {'_type': 'Train', 'name': 'electric'}],
 'material': 'wood'}

That that look like what you were going for?

isohedronpipeline commented 2 months ago

Yes! Thank you!

I hope you had a good holiday :-)

Can you explain why/how it works by registering Toy prior to ToyBox? It seems a little magical and I'd love to know how that works in case I encounter a simlar issue in the future.

Also, how might I include the hook = make_dict_unstructure_fn(Toy, c, _cattrs_omit_if_default=True) logic in addition to the subclass logic? I can put together some example code of what I'm looking for if you want. Let me know if you'd prefer that to be a separate issue too.

Thanks again, Tin!