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

Fix validation_alias behavior with model_construct for AliasChoices and AliasPath #9223

Merged
merged 7 commits into from Apr 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
21 changes: 20 additions & 1 deletion pydantic/aliases.py
Expand Up @@ -2,7 +2,9 @@
from __future__ import annotations

import dataclasses
from typing import Callable, Literal
from typing import Any, Callable, Literal

from pydantic_core import PydanticUndefined

from ._internal import _internal_dataclass

Expand Down Expand Up @@ -32,6 +34,23 @@ def convert_to_aliases(self) -> list[str | int]:
"""
return self.path

def search_dict_for_path(self, d: dict) -> Any:
"""Searches a dictionary for the path specified by the alias.

Returns:
The value at the specified path, or `PydanticUndefined` if the path is not found.
"""
v = d
for k in self.path:
if isinstance(v, str):
# disallow indexing into a str, like for AliasPath('x', 0) and x='abc'
return PydanticUndefined
try:
v = v[k]
except (KeyError, IndexError, TypeError):
return PydanticUndefined
return v


@dataclasses.dataclass(**_internal_dataclass.slots_true)
class AliasChoices:
Expand Down
37 changes: 28 additions & 9 deletions pydantic/main.py
Expand Up @@ -27,6 +27,7 @@
_utils,
)
from ._migration import getattr_migration
from .aliases import AliasChoices, AliasPath
from .annotated_handlers import GetCoreSchemaHandler, GetJsonSchemaHandler
from .config import ConfigDict
from .errors import PydanticUndefinedAnnotation, PydanticUserError
Expand Down Expand Up @@ -197,7 +198,7 @@ def model_fields_set(self) -> set[str]:
return self.__pydantic_fields_set__

@classmethod
def model_construct(cls: type[Model], _fields_set: set[str] | None = None, **values: Any) -> Model:
def model_construct(cls: type[Model], _fields_set: set[str] | None = None, **values: Any) -> Model: # noqa: C901
"""Creates a new instance of the `Model` class with validated data.

Creates a new model setting `__dict__` and `__pydantic_fields_set__` from trusted or pre-validated data.
Expand Down Expand Up @@ -225,14 +226,32 @@ def model_construct(cls: type[Model], _fields_set: set[str] | None = None, **val
if field.alias is not None and field.alias in values:
fields_values[name] = values.pop(field.alias)
fields_set.add(name)
elif field.validation_alias is not None and field.validation_alias in values:
fields_values[name] = values.pop(field.validation_alias)
fields_set.add(name)
elif name in values:
fields_values[name] = values.pop(name)
fields_set.add(name)
elif not field.is_required():
fields_values[name] = field.get_default(call_default_factory=True)

if (name not in fields_set) and (field.validation_alias is not None):
validation_aliases: list[str | AliasPath] = (
field.validation_alias.choices
if isinstance(field.validation_alias, AliasChoices)
else [field.validation_alias]
)

for alias in validation_aliases:
if isinstance(alias, str) and alias in values:
fields_values[name] = values.pop(alias)
fields_set.add(name)
break
elif isinstance(alias, AliasPath):
value = alias.search_dict_for_path(values)
if value is not PydanticUndefined:
fields_values[name] = value
fields_set.add(name)
break

if name not in fields_set:
if name in values:
fields_values[name] = values.pop(name)
fields_set.add(name)
elif not field.is_required():
fields_values[name] = field.get_default(call_default_factory=True)
if _fields_set is None:
_fields_set = fields_set

Expand Down
28 changes: 27 additions & 1 deletion tests/test_construction.py
Expand Up @@ -4,7 +4,7 @@
import pytest
from pydantic_core import PydanticUndefined, ValidationError

from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, PydanticDeprecatedSince20
from pydantic import AliasChoices, AliasPath, BaseModel, ConfigDict, Field, PrivateAttr, PydanticDeprecatedSince20


class Model(BaseModel):
Expand Down Expand Up @@ -561,3 +561,29 @@ class MyModel(BaseModel):

assert m._a == 'a'
assert '_a' in m.__pydantic_private__


def test_model_construct_with_alias_choices() -> None:
class MyModel(BaseModel):
a: str = Field(validation_alias=AliasChoices('aaa', 'AAA'))

assert MyModel.model_construct(a='a_value').a == 'a_value'
assert MyModel.model_construct(aaa='a_value').a == 'a_value'
assert MyModel.model_construct(AAA='a_value').a == 'a_value'


def test_model_construct_with_alias_path() -> None:
class MyModel(BaseModel):
a: str = Field(validation_alias=AliasPath('aaa', 'AAA'))

assert MyModel.model_construct(a='a_value').a == 'a_value'
assert MyModel.model_construct(aaa={'AAA': 'a_value'}).a == 'a_value'


def test_model_construct_with_alias_choices_and_path() -> None:
class MyModel(BaseModel):
a: str = Field(validation_alias=AliasChoices('aaa', AliasPath('AAA', 'aaa')))

assert MyModel.model_construct(a='a_value').a == 'a_value'
assert MyModel.model_construct(aaa='a_value').a == 'a_value'
assert MyModel.model_construct(AAA={'aaa': 'a_value'}).a == 'a_value'