Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Discriminated Unions with callable Discriminator fail to create a class dynamically with Optional annotation #9321

Closed
1 task done
chiselko6 opened this issue Apr 26, 2024 · 5 comments
Assignees
Labels
Milestone

Comments

@chiselko6
Copy link

Initial Checks

  • I confirm that I'm using Pydantic V2

Description

In our code we need to convert existing pydantic BaseModels into new classes with optional fields dynamically. We utilize pydantic.create_model for that wrapping field annotations with Optional. This works for fine for fields being discriminated unions defined as Field(discriminator="<property>"), but fails for ones defined as Discriminator(callable) with corresponding Tag annotation.

In the provided example I built new-style and old-style discriminated unions side-by-side for comparison. The error the new-style discriminated union raises is the following, as if NoneType messes up with the inner union:

Traceback (most recent call last):
  File "/Users/chiselko6/dev/pydantic_discriminator/test.py", line 77, in <module>
    create_model(
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/main.py", line 1550, in create_model
    return meta(
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/_internal/_model_construction.py", line 202, in __new__
    complete_model_class(
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/_internal/_model_construction.py", line 539, in complete_model_class
    schema = cls.__get_pydantic_core_schema__(cls, handler)
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/main.py", line 626, in __get_pydantic_core_schema__
    return handler(source)
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/_internal/_schema_generation_shared.py", line 82, in __call__
    schema = self._handler(source_type)
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/_internal/_generate_schema.py", line 502, in generate_schema
    schema = self._generate_schema_inner(obj)
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/_internal/_generate_schema.py", line 753, in _generate_schema_inner
    return self._model_schema(obj)
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/_internal/_generate_schema.py", line 580, in _model_schema
    {k: self._generate_md_field_schema(k, v, decorators) for k, v in fields.items()},
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/_internal/_generate_schema.py", line 580, in <dictcomp>
    {k: self._generate_md_field_schema(k, v, decorators) for k, v in fields.items()},
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/_internal/_generate_schema.py", line 916, in _generate_md_field_schema
    common_field = self._common_field_schema(name, field_info, decorators)
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/_internal/_generate_schema.py", line 1081, in _common_field_schema
    schema = self._apply_annotations(
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/_internal/_generate_schema.py", line 1820, in _apply_annotations
    schema = get_inner_schema(source_type)
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/_internal/_schema_generation_shared.py", line 82, in __call__
    schema = self._handler(source_type)
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/_internal/_generate_schema.py", line 1902, in new_handler
    schema = metadata_get_schema(source, get_inner_schema)
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/types.py", line 2827, in __get_pydantic_core_schema__
    return self._convert_schema(original_schema)
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic/types.py", line 2848, in _convert_schema
    raise PydanticUserError(
pydantic.errors.PydanticUserError: `Tag` not provided for choice {'type': 'nullable', 'schema': {'type': 'tagged-union', 'choices': {'int': {'type': 'definition-ref', 'schema_ref': '__main__.ValueInt:140660602191040', 'metadata': {'pydantic.internal.tagged_union_tag': 'int'}}, 'str': {'type': 'definition-ref', 'schema_ref': '__main__.ValueStr:140660580617136', 'metadata': {'pydantic.internal.tagged_union_tag': 'str'}}}, 'discriminator': <function model_x_discriminator at 0x7fee190baf70>}} used with `Discriminator`

For further information visit https://errors.pydantic.dev/2.7/u/callable-discriminator-no-tag

Example Code

from typing import Any, Literal, Optional, Union

from pydantic import BaseModel, Discriminator, Field, Tag, create_model
from typing_extensions import Annotated

# Definitions

def model_x_discriminator(v: Any) -> str:
    if isinstance(v, int):
        return "int"
    if isinstance(v, str):
        return "str"
    raise ValueError()


class ValueInt(BaseModel):
    type: Literal["int"] = "int"
    x: int


class ValueStr(BaseModel):
    type: Literal["str"] = "str"
    x: str


# old-style discriminator by class property

OldDiscriminatedType = Annotated[
    Union[ValueInt, ValueStr],
    Field(discriminator="type"),
]


class OldDiscriminatedModel(BaseModel):
    value: OldDiscriminatedType


class OldDiscriminatedModelOptional(BaseModel):
    value: Optional[OldDiscriminatedType]


old_value_field_info = OldDiscriminatedModel.model_fields["value"]
print(
    create_model(
        "OldDiscriminatedModelOptionalTest",
        __base__=OldDiscriminatedModel,
        **{
            "value": (
                Optional[OldDiscriminatedType],
                old_value_field_info,
            )
        },
    )
)
# outputs a new class


# new-style Discriminator and Tag

NewDiscriminatedType = Annotated[
    Union[
        Annotated[ValueInt, Tag("int")],
        Annotated[ValueStr, Tag("str")],
    ],
    Discriminator(model_x_discriminator),
]


class NewDiscriminatedModel(BaseModel):
    value: NewDiscriminatedType


class NewDiscriminatedModelOptional(BaseModel):
    value: Optional[NewDiscriminatedType]


new_value_field_info = NewDiscriminatedModel.model_fields["value"]
print(
    create_model(
        "NewDiscriminatedModelOptionalTest",
        __base__=NewDiscriminatedModel,
        **{
            "value": (
                Optional[NewDiscriminatedType],
                new_value_field_info,
            )
        },
    )
)
# raises an error

Python, Pydantic & OS Version

$ python3 -c "import pydantic.version; print(pydantic.version.version_info())"
             pydantic version: 2.7.1
        pydantic-core version: 2.18.2
          pydantic-core build: profile=release pgo=false
                 install path: /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/pydantic
               python version: 3.9.0 (v3.9.0:9cf6752276, Oct  5 2020, 11:29:23)  [Clang 6.0 (clang-600.0.57)]
                     platform: macOS-10.16-x86_64-i386-64bit
             related packages: typing_extensions-4.11.0
                       commit: unknown
@chiselko6 chiselko6 added bug V2 Bug related to Pydantic V2 pending Awaiting a response / confirmation labels Apr 26, 2024
@sydney-runkle
Copy link
Member

@chiselko6,

Thanks for the detailed description and reproducible code snippet. Definitely looks like a bug. Can be a bit difficult to track down with our complicated discriminator application logic, but I'll look into a fix for this in 2.8!

@sydney-runkle sydney-runkle removed the pending Awaiting a response / confirmation label Apr 28, 2024
@sydney-runkle sydney-runkle modified the milestones: 2.7 fixes, v2.8.0 Apr 28, 2024
@sydney-runkle
Copy link
Member

If anyone wants to take a stab in the meantime, be my guest :).

@sydney-runkle sydney-runkle self-assigned this Jun 7, 2024
@sydney-runkle
Copy link
Member

Hi!

I was able to look into this further - I think it's actually just a syntax fix when specifying the default value for the fields in the create_model call. The following works as expected:

from typing import Any, Literal, Optional, Union

from pydantic import BaseModel, Discriminator, Field, Tag, create_model
from typing_extensions import Annotated

# Definitions

def model_x_discriminator(v: Any) -> str:
    if isinstance(v, int):
        return "int"
    if isinstance(v, str):
        return "str"
    raise ValueError()


class ValueInt(BaseModel):
    type: Literal["int"] = "int"
    x: int


class ValueStr(BaseModel):
    type: Literal["str"] = "str"
    x: str


# old-style discriminator by class property

OldDiscriminatedType = Annotated[
    Union[ValueInt, ValueStr],
    Field(discriminator="type"),
]


class OldDiscriminatedModel(BaseModel):
    value: OldDiscriminatedType


class OldDiscriminatedModelOptional(BaseModel):
    value: Optional[OldDiscriminatedType]


print(
    create_model(
        "OldDiscriminatedModelOptionalTest",
        __base__=OldDiscriminatedModel,
        value=(Optional[OldDiscriminatedType], ...),
    )
)
# outputs a new class


# new-style Discriminator and Tag

NewDiscriminatedType = Annotated[
    Union[
        Annotated[ValueInt, Tag("int")],
        Annotated[ValueStr, Tag("str")],
    ],
    Discriminator(model_x_discriminator),
]


class NewDiscriminatedModel(BaseModel):
    value: NewDiscriminatedType


class NewDiscriminatedModelOptional(BaseModel):
    value: Optional[NewDiscriminatedType]


new_value_field_info = NewDiscriminatedModel.model_fields["value"]
print(
    create_model(
        "NewDiscriminatedModelOptionalTest",
        __base__=NewDiscriminatedModel,
        value=(Optional[NewDiscriminatedType], ...),
    )
)
# returns a class

The ... is used to specify that the field is required and that there's no default value. You actually have all of the type annotation info that you need already attached to the type, so no need to pull the info from the model_fields of other models. Hope this helps, and feel free to ping me if you have any other questions!

@sydney-runkle sydney-runkle added question and removed bug V2 Bug related to Pydantic V2 labels Jun 7, 2024
@chiselko6
Copy link
Author

Hi @sydney-runkle!

Thank you for the response! I was using existing field info in order to preserve original setup (say, it's configured as Field(le=3), so I don't want to lose this constraint). Going further, if I needed to add a default None in addition to make the field optional, I would come up with something like this - here I am using FieldInfo.merge_field_infos to merge the new default value:

from typing import Any, Literal, Optional, Union

from pydantic import BaseModel, Discriminator, Field, Tag, create_model
from pydantic.fields import FieldInfo
from typing_extensions import Annotated


def model_x_discriminator(v: Any) -> str:
    if isinstance(v, int):
        return "int"
    if isinstance(v, str):
        return "str"
    raise ValueError()


class ValueInt(BaseModel):
    type: Literal["int"] = "int"
    x: int


class ValueStr(BaseModel):
    type: Literal["str"] = "str"
    x: str


NewDiscriminatedType = Annotated[
    Union[
        Annotated[ValueInt, Tag("int")],
        Annotated[ValueStr, Tag("str")],
    ],
    Discriminator(model_x_discriminator),
]


class NewDiscriminatedModel(BaseModel):
    value: NewDiscriminatedType


class NewDiscriminatedModelOptional(BaseModel):
    value: Optional[NewDiscriminatedType] = Field(default=None)


OldDiscriminatedType = Annotated[
    Union[ValueInt, ValueStr],
    Field(discriminator="type"),
]


class OldDiscriminatedModel(BaseModel):
    value: OldDiscriminatedType


class OldDiscriminatedModelOptional(BaseModel):
    value: Optional[OldDiscriminatedType] = Field(default=None)


new_value_field_info = NewDiscriminatedModel.model_fields["value"]
old_value_field_info = OldDiscriminatedModel.model_fields["value"]
print(
    create_model(
        "OldDiscriminatedModelOptionalTest",
        __base__=OldDiscriminatedModel,
        **{
            "value": (
                Optional[OldDiscriminatedType],
                FieldInfo.merge_field_infos(old_value_field_info, Field(default=None)),
            )
        },
    )
)
print(
    create_model(
        "NewDiscriminatedModelOptionalTest",
        __base__=NewDiscriminatedModel,
        **{
            "value": (
                Optional[NewDiscriminatedType],
                FieldInfo.merge_field_infos(new_value_field_info, Field(default=None)),
            )
        },
    )
)

All works well for the old discriminator, however, creating a type based on new discriminator throws the same error. Could you confirm if this is a bug or still a syntax issue again?

Thank you.

@sydney-runkle
Copy link
Member

Hi @chiselko6,

Good question. I believe that error message is a reflection of the fact that you're using a field info new_value_field_info that's attached to a type annotation of a not-optional NewDiscriminatedType. If you do the following, things work as expected:

new_value_field_info = NewDiscriminatedModelOptional.model_fields["value"]

NewDiscriminatedModelOptionalTest = create_model(
    "NewDiscriminatedModelOptionalTest",
    __base__=NewDiscriminatedModel,
    value=(Optional[NewDiscriminatedType], FieldInfo.merge_field_infos(new_value_field_info, Field(default=ValueInt(x=1))))
)
print(NewDiscriminatedModelOptionalTest)

print(repr(NewDiscriminatedModelOptionalTest()))
#> NewDiscriminatedModelOptionalTest(value=ValueInt(type='int', x=1)

In general, I think I'd advise against reusing field infos like this, but if you have to, I'd recommend keeping the type annotations the same when you reuse! You can also use annotated types for constraint reuse!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants