diff --git a/changes/2073-PrettyWood.md b/changes/2073-PrettyWood.md new file mode 100644 index 0000000..397fc78 --- /dev/null +++ b/changes/2073-PrettyWood.md @@ -0,0 +1 @@ +fix: keep the order of the fields when `validate_assignment` is set \ No newline at end of file diff --git a/pydantic/main.py b/pydantic/main.py index 22a18dd..3d57b1b 100644 --- a/pydantic/main.py +++ b/pydantic/main.py @@ -384,14 +384,14 @@ class BaseModel(Representation, metaclass=ModelMetaclass): known_field = self.__fields__.get(name, None) if known_field: - original_value = self.__dict__.pop(name) - try: - value, error_ = known_field.validate(value, self.__dict__, loc=name, cls=self.__class__) - if error_: - raise ValidationError([error_], self.__class__) - except Exception: - self.__dict__[name] = original_value - raise + # We want to + # - make sure validators are called without the current value for this field inside `values` + # - keep other values (e.g. submodels) untouched (using `BaseModel.dict()` will change them into dicts) + # - keep the order of the fields + dict_without_original_value = {k: v for k, v in self.__dict__.items() if k != name} + value, error_ = known_field.validate(value, dict_without_original_value, loc=name, cls=self.__class__) + if error_: + raise ValidationError([error_], self.__class__) else: new_values[name] = value diff --git a/tests/test_validators.py b/tests/test_validators.py index 9097c17..62a68b0 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -5,7 +5,7 @@ from typing import Dict, List, Optional, Tuple import pytest -from pydantic import BaseModel, ConfigError, Extra, ValidationError, errors, validator +from pydantic import BaseModel, ConfigError, Extra, Field, ValidationError, errors, validator from pydantic.class_validators import make_generic_validator, root_validator from pydantic.typing import Literal @@ -1162,7 +1162,8 @@ def test_field_that_is_being_validated_is_excluded_from_validator_values(mocker) class Model(BaseModel): foo: str - bar: str + bar: str = Field(alias='pika') + baz: str class Config: validate_assignment = True @@ -1170,12 +1171,27 @@ def test_field_that_is_being_validated_is_excluded_from_validator_values(mocker) @validator('foo') def validate_foo(cls, v, values): check_values({**values}) + return v - model = Model(foo='foo_value', bar='bar_value') + @validator('bar') + def validate_bar(cls, v, values): + check_values({**values}) + return v + + model = Model(foo='foo_value', pika='bar_value', baz='baz_value') check_values.reset_mock() + assert list(dict(model).items()) == [('foo', 'foo_value'), ('bar', 'bar_value'), ('baz', 'baz_value')] + model.foo = 'new_foo_value' - check_values.assert_called_once_with({'bar': 'bar_value'}) + check_values.assert_called_once_with({'bar': 'bar_value', 'baz': 'baz_value'}) + check_values.reset_mock() + + model.bar = 'new_bar_value' + check_values.assert_called_once_with({'foo': 'new_foo_value', 'baz': 'baz_value'}) + + # ensure field order is the same + assert list(dict(model).items()) == [('foo', 'new_foo_value'), ('bar', 'new_bar_value'), ('baz', 'baz_value')] def test_exceptions_in_field_validators_restore_original_field_value():