Pydantic: Attribute always has first type in Union [bug]

Created on 1 Nov 2020  路  7Comments  路  Source: samuelcolvin/pydantic

Checks

  • [x] I added a descriptive title to this issue
  • [x] I have searched (google, github) for similar issues and couldn't find anything
  • [x] I have read and followed the docs and still think this is a bug

Bug

Output of python -c "import pydantic.utils; print(pydantic.utils.version_info())":

             pydantic version: 1.7.1
            pydantic compiled: True
                 install path: /home/max/.pyenv/versions/3.8.5/lib/python3.8/site-packages/pydantic
               python version: 3.8.5 (default, Aug  7 2020, 10:30:17)  [GCC 9.3.0]
                     platform: Linux-5.4.0-7634-generic-x86_64-with-glibc2.29
     optional deps. installed: ['typing-extensions']

Reproduction steps

from pydantic import BaseModel
from typing import Union

class Foo(BaseModel):
    pass
class Bar(BaseModel):
    pass
class Test(BaseModel):
    test: Union[Foo, Bar]

print("Should be Foo:", Test(test=Foo()))
print("Should be Bar:", Test(test=Bar()))
print("Most certainly should be Bar:", type(Test(test=Bar()).test))

Result:

Should be Foo: test=Foo()
Should be Bar: test=Foo()
Most certainly should be Bar: <class '__main__.Foo'>
bug

Most helpful comment

I've done a quick POC of what could be done to make it work
I used typingx library to have an isinstance function that supports generic types

from types import new_class
from typing import *

from typingx import isinstancex
from pydantic import BaseModel

T = TypeVar("T")


def _display_type(v: Any) -> str:
    try:
        return v.__name__
    except AttributeError:
        # happens with typing objects
        return str(v).replace("typing.", "")


class Strict(Generic[T]):
    __typelike__: T

    @classmethod
    def __class_getitem__(cls, typelike: T) -> T:
        new_cls = new_class(
            f"Strict[{_display_type(typelike)}]",
            (cls,),
            {},
            lambda ns: ns.update({"__typelike__": typelike}),
        )
        return cast(T, new_cls)

    @classmethod
    def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]:
        yield cls.validate

    @classmethod
    def validate(cls, value: Any) -> T:
        if not isinstancex(value, cls.__typelike__):
            raise TypeError(f"{value!r} is not of valid type")
        return value


class Foo(BaseModel):
    pass


class Bar(BaseModel):
    pass


class Test(BaseModel):
    test: Strict[Union[Foo, Bar]]


class Pika(BaseModel):
    x: Strict[Union[Dict[str, str], List[Tuple[str, str]]]]


assert type(Test(test=Foo()).test) is Foo
assert type(Test(test=Bar()).test) is Bar
assert isinstance(Test(test=Bar()).test, Bar) is True
assert isinstance(Test(test=Bar()).test, Foo) is False
assert Pika(x={"a": "b"}).x == {"a": "b"}
assert Pika(x=[("a", "b")]).x == [("a", "b")]

Cheers

All 7 comments

Hello @InnovativeInventor
This is a known issue (see #619 or #1423), which I'm planning to work on (for v1.8 hopefully).
Have a good day

@PrettyWood thanks for letting me know! Keep up the good work in this project!

Also, just to double check, even when accessing the attribute, I get:

print("Most certainly should be Bar:", type(Test(test=Bar()).test))

to be:

Most certainly should be Bar: <class '__main__.Foo'>

Perhaps I missed this, but I couldn't find an example of this happening in the issues that you linked (although I think the problems they describe are mostly the same as the issue that I reported).

Also, here are a few more examples:

print("Should be true:", isinstance(Test(test=Bar()).test, Bar))
print("Should be false:", isinstance(Test(test=Bar()).test, Foo))

Output:

Should be true: False
Should be false: True

Hey @InnovativeInventor
Yes it's because the order of the union is Union[Foo, Bar] so it first tries to coerce it to Foo and succeeds.
So you end up with a Foo instance. If you change the order of the union Union[Bar, Foo] you'll get the opposite.

Awesome, thanks for clarifying!

I've done a quick POC of what could be done to make it work
I used typingx library to have an isinstance function that supports generic types

from types import new_class
from typing import *

from typingx import isinstancex
from pydantic import BaseModel

T = TypeVar("T")


def _display_type(v: Any) -> str:
    try:
        return v.__name__
    except AttributeError:
        # happens with typing objects
        return str(v).replace("typing.", "")


class Strict(Generic[T]):
    __typelike__: T

    @classmethod
    def __class_getitem__(cls, typelike: T) -> T:
        new_cls = new_class(
            f"Strict[{_display_type(typelike)}]",
            (cls,),
            {},
            lambda ns: ns.update({"__typelike__": typelike}),
        )
        return cast(T, new_cls)

    @classmethod
    def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]:
        yield cls.validate

    @classmethod
    def validate(cls, value: Any) -> T:
        if not isinstancex(value, cls.__typelike__):
            raise TypeError(f"{value!r} is not of valid type")
        return value


class Foo(BaseModel):
    pass


class Bar(BaseModel):
    pass


class Test(BaseModel):
    test: Strict[Union[Foo, Bar]]


class Pika(BaseModel):
    x: Strict[Union[Dict[str, str], List[Tuple[str, str]]]]


assert type(Test(test=Foo()).test) is Foo
assert type(Test(test=Bar()).test) is Bar
assert isinstance(Test(test=Bar()).test, Bar) is True
assert isinstance(Test(test=Bar()).test, Foo) is False
assert Pika(x={"a": "b"}).x == {"a": "b"}
assert Pika(x=[("a", "b")]).x == [("a", "b")]

Cheers

Wow @PrettyWood, thanks so much! Seriously thanks a lot!

Was this page helpful?
0 / 5 - 0 ratings