From 1dc294015d414f8e5a302103a751e7f587be431d Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Tue, 10 Jul 2018 18:45:15 +0100 Subject: [PATCH] cleaning up _populate_sub_fields, support tuples (#227) * cleaning up _populate_sub_fields * support tuples, fix #12 * fix, history and docs * rename _create_sub_type --- HISTORY.rst | 1 + docs/examples/ex_typing.py | 8 ++- pydantic/errors.py | 8 +++ pydantic/fields.py | 114 ++++++++++++++++++++++--------------- tests/test_complex.py | 94 +++++++++++++++++++++++------- 5 files changed, 159 insertions(+), 66 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 40d093a..e4674ca 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -7,6 +7,7 @@ v0.12.0 (2018-XX-XX) .................... * add ``by_alias`` argument in ``.dict()`` and ``.json()`` model methods #205 * add Json type support #214 +* support tuples #227 v0.11.2 (2018-07-05) .................... diff --git a/docs/examples/ex_typing.py b/docs/examples/ex_typing.py index 2255040..d774da3 100644 --- a/docs/examples/ex_typing.py +++ b/docs/examples/ex_typing.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional, Union, Set +from typing import Dict, List, Optional, Set, Tuple, Union from pydantic import BaseModel @@ -7,6 +7,9 @@ class Model(BaseModel): simple_list: list = None list_of_ints: List[int] = None + simple_tuple: tuple = None + tuple_of_different_types: Tuple[int, float, str, bool] = None + simple_dict: dict = None dict_str_float: Dict[str, float] = None @@ -23,3 +26,6 @@ print(Model(list_of_ints=['1', '2', '3']).list_of_ints) # > [1, 2, 3] print(Model(simple_dict={'a': 1, b'b': 2}).simple_dict) # > {'a': 1, b'b': 2} print(Model(dict_str_float={'a': 1, b'b': 2}).dict_str_float) # > {'a': 1.0, 'b': 2.0} + +print(Model(simple_tuple=[1, 2, 3, 4]).simple_tuple) # > (1, 2, 3, 4) +print(Model(tuple_of_different_types=[1, 2, 3, 4]).tuple_of_different_types) # > (1, 2.0, '3', True) diff --git a/pydantic/errors.py b/pydantic/errors.py index 6faa247..7f51fd6 100644 --- a/pydantic/errors.py +++ b/pydantic/errors.py @@ -111,6 +111,14 @@ class TupleError(PydanticTypeError): msg_template = 'value is not a valid tuple' +class TupleLengthError(PydanticValueError): + code = 'tuple.length' + msg_template = 'wrong tuple length {actual_length}, expected {expected_length}' + + def __init__(self, *, actual_length: int, expected_length: int) -> None: + super().__init__(actual_length=actual_length, expected_length=expected_length) + + class AnyStrMinLengthError(PydanticValueError): code = 'any_str.min_length' msg_template = 'ensure this value has at least {limit_value} characters' diff --git a/pydantic/fields.py b/pydantic/fields.py index 7783de7..075ae0b 100644 --- a/pydantic/fields.py +++ b/pydantic/fields.py @@ -1,6 +1,6 @@ import inspect from enum import Enum, IntEnum -from typing import Any, Callable, List, Mapping, NamedTuple, Set, Type, Union +from typing import Any, Callable, List, Mapping, NamedTuple, Set, Tuple, Type, Union from . import errors as errors_ from .error_wrappers import ErrorWrapper @@ -23,6 +23,7 @@ class Shape(IntEnum): LIST = 2 SET = 3 MAPPING = 4 + TUPLE = 5 class Validator(NamedTuple): @@ -49,7 +50,7 @@ class Schema: class Field: __slots__ = ( - 'type_', 'key_type_', 'sub_fields', 'key_field', 'validators', 'whole_pre_validators', 'whole_post_validators', + 'type_', 'sub_fields', 'key_field', 'validators', 'whole_pre_validators', 'whole_post_validators', 'default', 'required', 'model_config', 'name', 'alias', '_schema', 'validate_always', 'allow_none', 'shape', 'class_validators', 'parse_json' ) @@ -69,7 +70,6 @@ class Field: self.name: str = name self.alias: str = alias or name self.type_: type = type_ - self.key_type_: type = None self.class_validators = class_validators or [] self.validate_always: bool = False self.sub_fields: List[Field] = None @@ -171,16 +171,17 @@ class Field: self.allow_none = True else: types_.append(type_) - self.sub_fields = [self.__class__( - type_=t, - class_validators=self.class_validators, - default=self.default, - required=self.required, - allow_none=self.allow_none, - name=f'{self.name}_{display_as_type(t)}', - model_config=self.model_config, - ) for t in types_] - elif issubclass(origin, List): + self.sub_fields = [self._create_sub_type(t, f'{self.name}_{display_as_type(t)}') for t in types_] + return + + if issubclass(origin, Tuple): + self.shape = Shape.TUPLE + self.sub_fields = [ + self._create_sub_type(t, f'{self.name}_{i}') for i, t in enumerate(self.type_.__args__) + ] + return + + if issubclass(origin, List): self.type_ = self.type_.__args__[0] self.shape = Shape.LIST elif issubclass(origin, Set): @@ -188,30 +189,24 @@ class Field: self.shape = Shape.SET else: assert issubclass(origin, Mapping) - self.key_type_ = self.type_.__args__[0] + self.key_field = self._create_sub_type(self.type_.__args__[0], 'key_' + self.name) self.type_ = self.type_.__args__[1] self.shape = Shape.MAPPING - self.key_field = self.__class__( - type_=self.key_type_, - class_validators=self.class_validators, - default=self.default, - required=self.required, - allow_none=self.allow_none, - name=f'key_{self.name}', - model_config=self.model_config, - ) - if not self.sub_fields and _get_type_origin(self.type_): + if _get_type_origin(self.type_): # type_ has been refined eg. as the type of a List and sub_fields needs to be populated - self.sub_fields = [self.__class__( - type_=self.type_, - class_validators=self.class_validators, - default=self.default, - required=self.required, - allow_none=self.allow_none, - name=f'_{self.name}', - model_config=self.model_config, - )] + self.sub_fields = [self._create_sub_type(self.type_, '_' + self.name)] + + def _create_sub_type(self, type_, name): + return self.__class__( + type_=type_, + name=name, + class_validators=self.class_validators, + default=self.default, + required=self.required, + allow_none=self.allow_none, + model_config=self.model_config, + ) def _populate_validators(self): if not self.sub_fields: @@ -259,15 +254,13 @@ class Field: v, errors = self._validate_singleton(v, values, loc, cls) elif self.shape is Shape.MAPPING: v, errors = self._validate_mapping(v, values, loc, cls) + elif self.shape is Shape.TUPLE: + v, errors = self._validate_tuple(v, values, loc, cls) else: # list or set - if list_like(v): - v, errors = self._validate_sequence(v, values, loc, cls) - if not errors and self.shape is Shape.SET: - v = set(v) - else: - e = errors_.ListError() if self.shape is Shape.LIST else errors_.SetError() - errors = ErrorWrapper(e, loc=loc, config=self.model_config) + v, errors = self._validate_list_set(v, values, loc, cls) + if not errors and self.shape is Shape.SET: + v = set(v) if not errors and self.whole_post_validators: v, errors = self._apply_validators(v, values, loc, cls, self.whole_post_validators) @@ -279,22 +272,51 @@ class Field: except (ValueError, TypeError) as exc: return v, ErrorWrapper(exc, loc=loc, config=self.model_config) - def _validate_sequence(self, v, values, loc, cls): - result, errors = [], [] + def _validate_list_set(self, v, values, loc, cls): + if not list_like(v): + e = errors_.ListError() if self.shape is Shape.LIST else errors_.SetError() + return v, ErrorWrapper(e, loc=loc, config=self.model_config) + result, errors = [], [] for i, v_ in enumerate(v): v_loc = *loc, i - single_result, single_errors = self._validate_singleton(v_, values, v_loc, cls) - if single_errors: - errors.append(single_errors) + r, e = self._validate_singleton(v_, values, v_loc, cls) + if e: + errors.append(e) else: - result.append(single_result) + result.append(r) if errors: return v, errors else: return result, None + def _validate_tuple(self, v, values, loc, cls): + e = None + if not list_like(v): + e = errors_.TupleError() + else: + actual_length, expected_length = len(v), len(self.sub_fields) + if actual_length != expected_length: + e = errors_.TupleLengthError(actual_length=actual_length, expected_length=expected_length) + + if e: + return v, ErrorWrapper(e, loc=loc, config=self.model_config) + + result, errors = [], [] + for i, (v_, field) in enumerate(zip(v, self.sub_fields)): + v_loc = *loc, i + r, e = field.validate(v_, values, loc=v_loc, cls=cls) + if e: + errors.append(e) + else: + result.append(r) + + if errors: + return v, errors + else: + return tuple(result), None + def _validate_mapping(self, v, values, loc, cls): try: v_iter = dict_validator(v) diff --git a/tests/test_complex.py b/tests/test_complex.py index 3cf1699..99244da 100644 --- a/tests/test_complex.py +++ b/tests/test_complex.py @@ -1,7 +1,7 @@ import re from decimal import Decimal from enum import Enum -from typing import Any, Dict, List, Set, Union +from typing import Any, Dict, List, Set, Tuple, Union import pytest @@ -223,24 +223,80 @@ def test_dict_key_error(): ] -# TODO re-add when implementing better model validators -# def test_all_model_validator(): -# class OverModel(BaseModel): -# a: int = ... -# -# def validate_a_pre(self, v): -# return f'{v}1' -# -# def validate_a(self, v): -# assert isinstance(v, int) -# return f'{v}_main' -# -# def validate_a_post(self, v): -# assert isinstance(v, str) -# return f'{v}_post' -# -# m = OverModel(a=1) -# assert m.a == '11_main_post' +def test_tuple(): + class Model(BaseModel): + v: Tuple[int, float, bool] + + m = Model(v=[1.2, '2.2', 'true']) + assert m.v == (1, 2.2, True) + + +def test_tuple_more(): + class Model(BaseModel): + simple_tuple: tuple = None + tuple_of_different_types: Tuple[int, float, str, bool] = None + + m = Model(simple_tuple=[1, 2, 3, 4], tuple_of_different_types=[1, 2, 3, 4]) + assert m.dict() == {'simple_tuple': (1, 2, 3, 4), 'tuple_of_different_types': (1, 2.0, '3', True)} + + +def test_tuple_length_error(): + class Model(BaseModel): + v: Tuple[int, float, bool] + + with pytest.raises(ValidationError) as exc_info: + Model(v=[1, 2]) + assert exc_info.value.errors() == [ + { + 'loc': ('v',), + 'msg': 'wrong tuple length 2, expected 3', + 'type': 'value_error.tuple.length', + 'ctx': { + 'actual_length': 2, + 'expected_length': 3, + }, + }, + ] + + +def test_tuple_invalid(): + class Model(BaseModel): + v: Tuple[int, float, bool] + + with pytest.raises(ValidationError) as exc_info: + Model(v='xxx') + assert exc_info.value.errors() == [ + { + 'loc': ('v',), + 'msg': 'value is not a valid tuple', + 'type': 'type_error.tuple', + }, + ] + + +def test_tuple_value_error(): + class Model(BaseModel): + v: Tuple[int, float, Decimal] + + with pytest.raises(ValidationError) as exc_info: + Model(v=['x', 'y', 'x']) + assert exc_info.value.errors() == [ + { + 'loc': ('v', 0), + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', + }, + { + 'loc': ('v', 1), + 'msg': 'value is not a valid float', + 'type': 'type_error.float', + }, + { + 'loc': ('v', 2), + 'msg': 'value is not a valid decimal', + 'type': 'type_error.decimal', + }, + ] def test_recursive_list():