python-attrs/cattrs

TypedDict unstructuring skips value unions

salotz opened this issue · 4 comments

  • cattrs version:
cattrs==23.2.3
attrs
  • Python version: 3.11.8
  • Operating System: Linux

Description

Special union hooks in TypeDict values are skipped. If you register a handler for a union in general it will not be run. See minimal repro below.

Repro

from typing import TypedDict
import attrs
from cattrs import Converter
from cattrs.strategies import configure_tagged_union

@attrs.define
class A:
    val: int

@attrs.define
class B:
    val: int

converter = Converter()

configure_tagged_union(A | B, converter)

assert converter.unstructure(
    A(1),
    A,
) == {"val" : 1}

assert converter.unstructure(
    A(1),
    A | B,
) == {
    "_type" : "A",
    "val" : 1,
}


class MyTaggedUnionDict(TypedDict):
    options: A | B

# THIS SHOULD NOT PASS
assert converter.unstructure(
    MyTaggedUnionDict(options=A(1)),
) == {
    "options" : {
        "val" : 1,
    },
}
    
# THIS RAISES AssertionError
assert converter.unstructure(
    MyTaggedUnionDict(options=A(1)),
) == {
    "options" : {
        "_type" : "A",
        "val" : 1,
    },
}, "Unstructured value has no type tag."

Any workaround would be appreciated! Not sure how to do it myself.

Here's the thing: typed dicts are what's called a structural type.

When you type

MyTaggedUnionDict(options=A(1))

that's actually equivalent to

{
    "options": A(1)
}

and cattrs can't tell this is supposed to be a MyTaggedUnionDict and not an ordinary dictionary.

So you can explicitly tell it:

converter.unstructure(
    MyTaggedUnionDict(options=A(1)),
    unstructure_as=MyTaggedUnionDict
)

Here's the fixed code:

from typing import TypedDict

import attrs

from cattrs import Converter
from cattrs.strategies import configure_tagged_union


@attrs.define
class A:
    val: int


@attrs.define
class B:
    val: int


converter = Converter()

configure_tagged_union(A | B, converter)

assert converter.unstructure(A(1), A) == {"val": 1}

assert converter.unstructure(A(1), A | B) == {"_type": "A", "val": 1}


class MyTaggedUnionDict(TypedDict):
    options: A | B


# THIS SHOULD NOT PASS
assert converter.unstructure({"options": A(1)}, unstructure_as=MyTaggedUnionDict) != {
    "options": {"val": 1}
}

# THIS RAISES AssertionError
assert converter.unstructure({"options": A(1)}, unstructure_as=MyTaggedUnionDict) == {
    "options": {"_type": "A", "val": 1}
}, "Unstructured value has no type tag."

Closing unless you have other questions ;)

@Tinche Makes sense! I guess I knew all those pieces but wasn't putting it together.

I was trying to repro a more complex issue, so I'll see if this was the issue or not