Skip to content

WrapValidator annotation changes smart union score #11752

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

Open
1 task done
weiliddat opened this issue Apr 14, 2025 · 4 comments · May be fixed by pydantic/pydantic-core#1700
Open
1 task done

WrapValidator annotation changes smart union score #11752

weiliddat opened this issue Apr 14, 2025 · 4 comments · May be fixed by pydantic/pydantic-core#1700
Assignees
Labels
bug V2 Bug related to Pydantic V2

Comments

@weiliddat
Copy link

Initial Checks

  • I confirm that I'm using Pydantic V2

Description

When Pydantic matches against a union type (in the default smart mode), adding a WrapValidator seems to change its preferred union member type.

Validating a field with a union of two similar models (see code below) without WrapValidator will use the leftmost union member. However, adding a "dummy" WrapValidator causes Pydantic to choose the right union member instead.

Using other validators like AfterValidator doesn't seem to show this issue.

Example Code

from typing import Annotated, Literal

from pydantic import BaseModel, Field, WrapValidator


class ObjectPayload(BaseModel):
    name: str


class AddMutation(BaseModel):
    type: Literal["add"] = Field("add")
    # passes if you replace WrapValidator with AfterValidator or remove it
    payload: Annotated[ObjectPayload, WrapValidator(lambda v, nxt: nxt(v))] = Field()


class UpdateMutation(BaseModel):
    type: Literal["update"] = Field("update")
    payload: ObjectPayload = Field()


class ObjectUpdate(BaseModel):
    # passes if you use Field(union_mode="left_to_right")
    mutation: AddMutation | UpdateMutation = Field()


def test_union():
    obj = ObjectUpdate.model_validate(
        {
            "mutation": {
                "type": "add",
                "payload": {"name": "John Doe"},
            }
        }
    )
    assert obj.mutation.type == "add"
    assert obj.mutation.payload.name == "John Doe"

    obj = ObjectUpdate.model_validate(
        {
            "mutation": {
                "type": "update",
                "payload": {"name": "John Doe"},
            }
        }
    )
    assert obj.mutation.type == "update"
    assert obj.mutation.payload.name == "John Doe"

    obj = ObjectUpdate.model_validate(
        {
            "mutation": {
                "payload": {"name": "John Doe"},
            }
        }
    )
    assert obj.mutation.type == "add"
    assert obj.mutation.payload.name == "John Doe"

Python, Pydantic & OS Version

pydantic version: 2.11.2
        pydantic-core version: 2.33.1
          pydantic-core build: profile=release pgo=false
                 install path: /Users/chiawei.ong/dev/pL/product-api/.direnv/python-3.12.10/lib/python3.12/site-packages/pydantic
               python version: 3.12.10 (main, Apr  8 2025, 11:35:47) [Clang 16.0.0 (clang-1600.0.26.6)]
                     platform: macOS-15.4-arm64-arm-64bit
             related packages: mypy-1.15.0 pydantic-extra-types-2.10.3 typing_extensions-4.13.1
                       commit: unknown
@weiliddat weiliddat added bug V2 Bug related to Pydantic V2 pending Is unconfirmed labels Apr 14, 2025
@weiliddat
Copy link
Author

I'd be happy to look into this on pydantic-core if it's not intended to work this way. Lemme know!

@Viicos
Copy link
Member

Viicos commented Apr 15, 2025

In smart mode, we count the number of fields that successfully validate for each union member. For instance, when validating:

{
    "type": "update",
    "payload": {"name": "John Doe"},
}

against UpdateMutation:

class ObjectPayload(BaseModel):
    name: str

class UpdateMutation(BaseModel):
    type: Literal["update"] = Field("update")
    payload: ObjectPayload = Field()

that number equals 3: one for UpdateMutation.type, one for UpdateMutation.payload, and one for UpdateMutation.payload.name.

Looks like the wrap validator creates a new internal validation state (where that number of validated fields is tracked), so the counter is reset when validating ObjectPayload.

@Viicos Viicos removed the pending Is unconfirmed label Apr 15, 2025
@weiliddat
Copy link
Author

It sounds like it's not intended? Should I take a stab at this?

@Viicos
Copy link
Member

Viicos commented Apr 17, 2025

It sounds like it's not intended? Should I take a stab at this?

Indeed not intended. Thanks, go ahead! It should be pretty simple to fix. We already do the correct logic for exactness, and should do the same with fields_set_count:

In InternalValidator::validate(), we save the exactness from the state after validation. The exactness is then reported in the "parent" validation state in FunctionWrapValidator::validate(). Same logic will have to be applied for field_set_count (this time by using the ValidationState::add_fields_set() method to increment field_set_count).

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

Successfully merging a pull request may close this issue.

2 participants