python-attrs / cattrs

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

Tagged Union: How to do structure/unstructure hook(s) for particular member type? #498

Closed kkg-else42 closed 5 months ago

kkg-else42 commented 5 months ago

Hey there,

I've been using tagged_union strategy for a while now. Always with the defaults -- nothing special so far.

But now I have the need for a customized structuring/destructuring of one particular member of the union. All other members should continue to be processed with defaults.

It would be great if you could jumpstart my thoughts with a little example how this could be achieved.

Tinche commented 5 months ago

Sure. Which version of Python are you on?

kkg-else42 commented 5 months ago

It's 3.11

Tinche commented 5 months ago

Ok, so this is the starting situation:

from attrs import define

from cattrs import Converter
from cattrs.strategies import configure_tagged_union

@define
class A:
    a: int

@define
class B:
    b: str

c = Converter()
configure_tagged_union(A | B, c)

but now you need to override the hook for B to be something else:

def structure_b(val, type):
    return B(val["c"])  # Arbitrary, could be anything

The simplest solution is to override the hook for B before you configure the tagged union:

c = Converter()
c.register_structure_hook(B, structure_b)
configure_tagged_union(A | B, c)

print(c.structure({"_type": "B", "c": "a_string"}, A | B))

This will also change how B is structured in all other contexts. If that's a problem you can have a separate converter just for this structuring; converters are cheap.

Another solution would be to use a NewType. In this case, you hook your custom structure function to the NewType instead of the original class, and your union contains the NewType. At runtime, it'll be an instance of B. You also need to override the tag_generator.


from typing import NewType

NewTypeB = NewType("NewTypeB", B)

c = Converter()
c.register_structure_hook(NewTypeB, structure_b)
configure_tagged_union(A | NewTypeB, c, tag_generator={A: "A", NewTypeB: "B"}.get)

print(c.structure({"_type": "B", "c": "a_string"}, A | NewTypeB))

A third solution is you apply a little pre-processing to the tagged union hook. Getting the hook involves accessing a private member (Converter._union_struct_registry) but not to worry - this is going to be a public API in the next release (called get_structure_hook).

c = Converter()
configure_tagged_union(A | B, c)
existing_hook = c._union_struct_registry[A | B]

def preprocess(value, type):
    if value["_type"] == "B":
        return structure_b(value, type)
    return existing_hook(value, type)

c.register_structure_hook(A | B, preprocess)  # Overwrite with our own

print(c.structure({"_type": "B", "c": "a_string"}, A | B))

All of these solve the issue, I think.

kkg-else42 commented 5 months ago

The simplest solution is to override the hook for B before you configure the tagged union:

Wow, just consider the order. It can be so easy if you know what you're doing. What a great tool! (I wish I had asked a few days earlier.) It might be good to include this information in the docs. ;-)

A third solution is you apply a little pre-processing to the tagged union hook. Getting the hook involves accessing a private member (Converter._union_struct_registry) but not to worry - this is going to be a public API in the next release (called get_structure_hook).

In my own limited solution attempts, I had exactly this feeling: Somehow I have to get into this (without really knowing how). Good to know that there will soon be an official way to do this.

All of these solve the issue, I think.

Yes and yes and yes. Thank you very much for your help and work! Greatly appreciated!