From 6e5a1363cc354748c68cb177ecb2d28d2a3e910f Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Tue, 1 Oct 2019 17:22:05 +0100 Subject: [PATCH] V0.32 merge (#852) * fix(validate-assignment): do not validate extra fields when `vaidate_assignment` is on * Update history * Fix `value` vs. `value_` * Add tests for `value` vs `value_` case * uprev * Fix generic required (#742) * Fix required fields on GenericModel * lint * version up * __post_init__ with inheritance, fix #739 (#740) * Fix custom Schema on GenericModel fields (#754) * Fix custom Schema on GenericModel fields * Add PR# * uprev * Fix error messages for Literal types with multiple allowed values (#770) * Fix error messages for Literal types with multiple allowed values * Incorporate feedback * update history --- HISTORY.rst | 12 ++++++++- changes/770-dmontagu.rst | 1 + pydantic/dataclasses.py | 17 ++++++++---- pydantic/fields.py | 8 ++---- pydantic/generics.py | 2 +- pydantic/main.py | 16 +++++------ pydantic/schema.py | 27 ++++++++++++++++--- tests/test_dataclasses.py | 57 ++++++++++++++++++++++++++++++++++++++- tests/test_generics.py | 36 ++++++++++++++++++++++++- tests/test_types.py | 12 +++------ tests/test_validators.py | 27 +++++++++++++++++++ 11 files changed, 179 insertions(+), 36 deletions(-) create mode 100644 changes/770-dmontagu.rst diff --git a/HISTORY.rst b/HISTORY.rst index 09b20e1..7632f8a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,7 +3,17 @@ History ------- -v0.32 (2019-08-08) +v0.32.2 (2019-08-17) +.................... +* fix ``__post_init__`` usage with dataclass inheritance, fix #739 by @samuelcolvin +* fix required fields validation on GenericModels classes, #742 by @amitbl +* fix defining custom ``Schema`` on ``GenericModel`` fields, #754 by @amitbl + +v0.32.1 (2019-08-08) +.................... +* do not validate extra fields when ``validate_assignment`` is on, #724 by @YaraslauZhylko + +v0.32 (2019-08-06) .................. * add model name to ``ValidationError`` error message, #676 by @dmontagu * **breaking change**: remove ``__getattr__`` and rename ``__values__`` to ``__dict__`` on ``BaseModel``, diff --git a/changes/770-dmontagu.rst b/changes/770-dmontagu.rst new file mode 100644 index 0000000..1ee2864 --- /dev/null +++ b/changes/770-dmontagu.rst @@ -0,0 +1 @@ +fix error messages for ``Literal`` types with multiple allowed values diff --git a/pydantic/dataclasses.py b/pydantic/dataclasses.py index cdffe64..535eff4 100644 --- a/pydantic/dataclasses.py +++ b/pydantic/dataclasses.py @@ -41,10 +41,12 @@ def _get_validators(cls: Type['DataclassType']) -> Generator[Any, None, None]: def setattr_validate_assignment(self: 'DataclassType', name: str, value: Any) -> None: if self.__initialised__: d = dict(self.__dict__) - d.pop(name) - value, error_ = self.__pydantic_model__.__fields__[name].validate(value, d, loc=name, cls=self.__class__) - if error_: - raise ValidationError([error_], type(self)) + d.pop(name, None) + known_field = self.__pydantic_model__.__fields__.get(name, None) + if known_field: + value, error_ = known_field.validate(value, d, loc=name, cls=self.__class__) + if error_: + raise ValidationError([error_], type(self)) object.__setattr__(self, name, value) @@ -60,9 +62,12 @@ def _process_class( config: Type['BaseConfig'], ) -> 'DataclassType': post_init_original = getattr(_cls, '__post_init__', None) - post_init_post_parse = getattr(_cls, '__post_init_post_parse__', None) if post_init_original and post_init_original.__name__ == '_pydantic_post_init': post_init_original = None + if not post_init_original: + post_init_original = getattr(_cls, '__post_init_original__', None) + + post_init_post_parse = getattr(_cls, '__post_init_post_parse__', None) def _pydantic_post_init(self: 'DataclassType', *initvars: Any) -> None: if post_init_original is not None: @@ -91,6 +96,8 @@ def _process_class( cls.__initialised__ = False cls.__validate__ = classmethod(_validate_dataclass) cls.__get_validators__ = classmethod(_get_validators) + if post_init_original: + cls.__post_init_original__ = post_init_original if cls.__pydantic_model__.__config__.validate_assignment and not frozen: cls.__setattr__ = setattr_validate_assignment diff --git a/pydantic/fields.py b/pydantic/fields.py index 79e5876..8075174 100644 --- a/pydantic/fields.py +++ b/pydantic/fields.py @@ -23,7 +23,7 @@ from .class_validators import Validator, make_generic_validator, prep_validators from .error_wrappers import ErrorWrapper from .errors import NoneIsNotAllowedError from .types import Json, JsonWrapper -from .typing import AnyType, Callable, ForwardRef, display_as_type, is_literal_type, literal_values +from .typing import AnyType, Callable, ForwardRef, display_as_type, is_literal_type from .utils import lenient_issubclass, sequence_like from .validators import constant_validator, dict_validator, find_validators, validate_json @@ -311,11 +311,7 @@ class ModelField: # python 3.7 only, Pattern is a typing object but without sub fields return if is_literal_type(self.type_): - values = literal_values(self.type_) - if len(values) > 1: - self.type_ = Union[tuple(Literal[value] for value in values)] - else: - return + return origin = getattr(self.type_, '__origin__', None) if origin is None: # field is not "typing" object eg. Union, Dict, List etc. diff --git a/pydantic/generics.py b/pydantic/generics.py index 5b34296..47c3088 100644 --- a/pydantic/generics.py +++ b/pydantic/generics.py @@ -43,7 +43,7 @@ class GenericModel(BaseModel): model_name = concrete_name(cls, params) validators = gather_all_validators(cls) fields: Dict[str, Tuple[Type[Any], Any]] = { - k: (v, cls.__fields__[k].default) for k, v in concrete_type_hints.items() if k in cls.__fields__ + k: (v, cls.__fields__[k].field_info) for k, v in concrete_type_hints.items() if k in cls.__fields__ } created_model = create_model( model_name=model_name, diff --git a/pydantic/main.py b/pydantic/main.py index 2767f74..cf0f1db 100644 --- a/pydantic/main.py +++ b/pydantic/main.py @@ -257,15 +257,13 @@ class BaseModel(metaclass=ModelMetaclass): elif not self.__config__.allow_mutation: raise TypeError(f'"{self.__class__.__name__}" is immutable and does not support item assignment') elif self.__config__.validate_assignment: - value_, error_ = self.fields[name].validate(value, self.dict(exclude={name}), loc=name) - if error_: - raise ValidationError([error_], type(self)) - else: - self.__dict__[name] = value_ - self.__fields_set__.add(name) - else: - self.__dict__[name] = value - self.__fields_set__.add(name) + known_field = self.fields.get(name, None) + if known_field: + value, error_ = known_field.validate(value, self.dict(exclude={name}), loc=name) + if error_: + raise ValidationError([error_], type(self)) + self.__dict__[name] = value + self.__fields_set__.add(name) def __getstate__(self) -> 'DictAny': return {'__dict__': self.__dict__, '__fields_set__': self.__fields_set__} diff --git a/pydantic/schema.py b/pydantic/schema.py index d37b355..c5fa81e 100644 --- a/pydantic/schema.py +++ b/pydantic/schema.py @@ -37,7 +37,7 @@ from .types import ( conlist, constr, ) -from .typing import is_callable_type, is_literal_type, is_new_type, literal_values, new_type_supertype +from .typing import Literal, is_callable_type, is_literal_type, is_new_type, literal_values, new_type_supertype from .utils import lenient_issubclass if TYPE_CHECKING: # pragma: no cover @@ -639,8 +639,16 @@ def field_singleton_schema( # noqa: C901 (ignore complexity) if is_new_type(field_type): field_type = new_type_supertype(field_type) if is_literal_type(field_type): - # If there were multiple literal values, field.sub_fields would not be falsy - literal_value = literal_values(field_type)[0] + values = literal_values(field_type) + if len(values) > 1: + return field_schema( + multivalue_literal_field_for_schema(values, field), + by_alias=by_alias, + model_name_map=model_name_map, + ref_prefix=ref_prefix, + known_models=known_models, + ) + literal_value = values[0] field_type = type(literal_value) f_schema['const'] = literal_value if issubclass(field_type, Enum): @@ -688,6 +696,19 @@ def field_singleton_schema( # noqa: C901 (ignore complexity) raise ValueError(f'Value not declarable with JSON Schema, field: {field}') +def multivalue_literal_field_for_schema(values: Tuple[Any, ...], field: ModelField) -> ModelField: + return ModelField( + name=field.name, + type_=Union[tuple(Literal[value] for value in values)], + class_validators=field.class_validators, + model_config=field.model_config, + default=field.default, + required=field.required, + alias=field.alias, + field_info=field.field_info, + ) + + def encode_default(dft: Any) -> Any: if isinstance(dft, (int, float, str)): return dft diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py index 626e626..2bc6401 100644 --- a/tests/test_dataclasses.py +++ b/tests/test_dataclasses.py @@ -6,7 +6,7 @@ from typing import ClassVar, FrozenSet, Optional import pytest import pydantic -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, ValidationError, validator def test_simple(): @@ -93,6 +93,42 @@ def test_not_validate_assignment(): assert d.a == '7' +def test_validate_assignment_value_change(): + class Config: + validate_assignment = True + + @pydantic.dataclasses.dataclass(config=Config, frozen=False) + class MyDataclass: + a: int + + @validator('a') + def double_a(cls, v): + return v * 2 + + d = MyDataclass(2) + assert d.a == 4 + + d.a = 3 + assert d.a == 6 + + +def test_validate_assignment_extra(): + class Config: + validate_assignment = True + + @pydantic.dataclasses.dataclass(config=Config, frozen=False) + class MyDataclass: + a: int + + d = MyDataclass(1) + assert d.a == 1 + + d.extra_field = 1.23 + assert d.extra_field == 1.23 + d.extra_field = 'bye' + assert d.extra_field == 'bye' + + def test_post_init(): post_init_called = False @@ -500,3 +536,22 @@ def test_frozenset_field(): object_under_test = TestFrozenSet(set=test_set) assert object_under_test.set == test_set + + +def test_inheritance_post_init(): + post_init_called = False + + @pydantic.dataclasses.dataclass + class Base: + a: int + + def __post_init__(self): + nonlocal post_init_called + post_init_called = True + + @pydantic.dataclasses.dataclass + class Child(Base): + b: int + + Child(a=1, b=2) + assert post_init_called diff --git a/tests/test_generics.py b/tests/test_generics.py index 0a3fe86..126184f 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -4,7 +4,7 @@ from typing import Any, ClassVar, Dict, Generic, List, Optional, TypeVar, Union import pytest -from pydantic import BaseModel, ValidationError, root_validator, validator +from pydantic import BaseModel, Field, ValidationError, root_validator, validator from pydantic.generics import GenericModel, _generic_types_cache skip_36 = pytest.mark.skipif(sys.version_info < (3, 7), reason='generics only supported for python 3.7 and above') @@ -387,3 +387,37 @@ def test_complex_nesting(): item = [{1: 'a', 'a': 'a'}] model = MyModel[str](item=item) assert model.item == item + + +@skip_36 +def test_required_value(): + T = TypeVar('T') + + class MyModel(GenericModel, Generic[T]): + a: int + + with pytest.raises(ValidationError) as exc_info: + MyModel[int]() + assert exc_info.value.errors() == [{'loc': ('a',), 'msg': 'field required', 'type': 'value_error.missing'}] + + +@skip_36 +def test_optional_value(): + T = TypeVar('T') + + class MyModel(GenericModel, Generic[T]): + a: Optional[int] = 1 + + model = MyModel[int]() + assert model.dict() == {'a': 1} + + +@skip_36 +def test_custom_schema(): + T = TypeVar('T') + + class MyModel(GenericModel, Generic[T]): + a: int = Field(1, description='Custom') + + schema = MyModel[int].schema() + assert schema['properties']['a'].get('description') == 'Custom' diff --git a/tests/test_types.py b/tests/test_types.py index 7271886..c0a11db 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1788,16 +1788,10 @@ def test_literal_multiple(): assert exc_info.value.errors() == [ { 'loc': ('a_or_b',), - 'msg': "unexpected value; permitted: 'a'", + 'msg': "unexpected value; permitted: 'a', 'b'", 'type': 'value_error.const', - 'ctx': {'given': 'c', 'permitted': ('a',)}, - }, - { - 'loc': ('a_or_b',), - 'msg': "unexpected value; permitted: 'b'", - 'type': 'value_error.const', - 'ctx': {'given': 'c', 'permitted': ('b',)}, - }, + 'ctx': {'given': 'c', 'permitted': ('a', 'b')}, + } ] diff --git a/tests/test_validators.py b/tests/test_validators.py index e40e6c6..9073a49 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -123,6 +123,7 @@ def test_validate_pre_error(): class ValidateAssignmentModel(BaseModel): a: int = 4 b: str = ... + c: int = 0 @validator('b') def b_length(cls, v, values, **kwargs): @@ -130,8 +131,13 @@ class ValidateAssignmentModel(BaseModel): raise ValueError('b too short') return v + @validator('c') + def double_c(cls, v): + return v * 2 + class Config: validate_assignment = True + extra = Extra.allow def test_validating_assignment_ok(): @@ -148,6 +154,27 @@ def test_validating_assignment_fail(): p.b = 'x' +def test_validating_assignment_value_change(): + p = ValidateAssignmentModel(b='hello', c=2) + assert p.c == 4 + + p = ValidateAssignmentModel(b='hello') + assert p.c == 0 + p.c = 3 + assert p.c == 6 + + +def test_validating_assignment_extra(): + p = ValidateAssignmentModel(b='hello', extra_field=1.23) + assert p.extra_field == 1.23 + + p = ValidateAssignmentModel(b='hello') + p.extra_field = 1.23 + assert p.extra_field == 1.23 + p.extra_field = 'bye' + assert p.extra_field == 'bye' + + def test_validating_assignment_dict(): with pytest.raises(ValidationError) as exc_info: ValidateAssignmentModel(a='x', b='xx')