Repository: pydantic/pydantic

Issue:
support typing.Tuple
allow typed tuples. Note: variable length tuples use the ellipsis - need to make sure this doesn't interfere with required fields.
support typing.Tuple
allow typed tuples. Note: variable length tuples use the ellipsis - need to make sure this doesn't interfere with required fields.


Discussion:
as wiht #14 need benchmarks to make sure this doesn't slow things down.
as wiht #14 need benchmarks to make sure this doesn't slow things down.

Resolving patch:
diff --git a/docs/examples/ex_typing.py b/docs/examples/ex_typing.py
--- 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 @@ class Model(BaseModel):
 
 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
--- 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
--- 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 @@ def __init__(self, default, *, alias=None, title=None, choice_names=None, **extr
 
 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 @@ def __init__(
         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 @@ def _populate_sub_fields(self):
                     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 @@ def _populate_sub_fields(self):
             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 @@ def validate(self, v, values, *, loc, cls=None):  # noqa: C901 (ignore complexit
             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)
@@ -281,22 +274,51 @@ def _validate_json(self, v, loc):
         except 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)


Test patch:
diff --git a/tests/test_complex.py b/tests/test_complex.py
--- 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 @@ class Model(BaseModel):
     ]
 
 
-# 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():