Repository: pydantic/pydantic

Issue:
Wrong validation error when using `NamedTuple` with Generics in Model
### Initial Checks

- [X] I confirm that I'm using Pydantic V2 installed directly from the `main` branch, or equivalent

### Description

Hi, 

I'm encountering a strange validation error when I use a `NamedTuple` combined with a Generic inside a Pydantic BaseModel.

When I run the attached example code, it leads to the following validation error:

`pydantic_core._pydantic_core.ValidationError: 1 validation error for A
p_value
  Tuple should have at most 1 item after validation, not 2 [type=too_long, input_value=(1, True), input_type=tuple]`

I have searched the documentation and found no reason that this should not be possible. However, when I remove the type parameter `[float]` from the declaration of `p_value` in `class A`, it runs without any validation errors. Unfortunately, this results in loss of validation information for the `value` field of `ProtectableValue`.

### Example Code

```Python
from typing import Generic, TypeVar, NamedTuple
from pydantic import BaseModel

T = TypeVar('T')

class ProtectableValue(NamedTuple, Generic[T]):
    value: T
    is_protected: bool

class A(BaseModel):
    p_value: ProtectableValue[float]


A.model_validate({'p_value': (1,True)})
```


### Python, Pydantic & OS Version

```Text
pydantic version: 2.0
        pydantic-core version: 2.0.1 release build profile
               python version: 3.11.3 (main, Jun  5 2023, 09:32:32) [GCC 13.1.1 20230429]
                     platform: Linux-6.4.1-arch1-1-x86_64-with-glibc2.37
     optional deps. installed: ['email-validator', 'typing-extensions']
```


Selected Assignee: @samuelcolvin


Discussion:

Resolving patch:
diff --git a/pydantic/_internal/_generate_schema.py b/pydantic/_internal/_generate_schema.py
--- a/pydantic/_internal/_generate_schema.py
+++ b/pydantic/_internal/_generate_schema.py
@@ -499,7 +499,7 @@ def _generate_schema(self, obj: Any) -> core_schema.CoreSchema:  # noqa: C901
         elif is_typeddict(obj):
             return self._typed_dict_schema(obj, None)
         elif _typing_extra.is_namedtuple(obj):
-            return self._namedtuple_schema(obj)
+            return self._namedtuple_schema(obj, None)
         elif _typing_extra.is_new_type(obj):
             # NewType, can't use isinstance because it fails <3.7
             return self.generate_schema(obj.__supertype__)
@@ -542,6 +542,8 @@ def _generate_schema(self, obj: Any) -> core_schema.CoreSchema:  # noqa: C901
         # to resolve by modifying the value returned by `Generic.__class_getitem__`, but that is a dangerous game.
         if _typing_extra.is_dataclass(origin):
             return self._dataclass_schema(obj, origin)
+        if _typing_extra.is_namedtuple(origin):
+            return self._namedtuple_schema(obj, origin)
 
         from_property = self._generate_schema_from_property(origin, obj)
         if from_property is not None:
@@ -841,20 +843,36 @@ def _typed_dict_schema(self, typed_dict_cls: Any, origin: Any) -> core_schema.Co
             self.defs.definitions[typed_dict_ref] = schema
             return core_schema.definition_reference_schema(typed_dict_ref)
 
-    def _namedtuple_schema(self, namedtuple_cls: Any) -> core_schema.CallSchema:
+    def _namedtuple_schema(self, namedtuple_cls: Any, origin: Any) -> core_schema.CoreSchema:
         """Generate schema for a NamedTuple."""
-        annotations: dict[str, Any] = get_type_hints_infer_globalns(
-            namedtuple_cls, include_extras=True, localns=self.types_namespace
-        )
-        if not annotations:
-            # annotations is empty, happens if namedtuple_cls defined via collections.namedtuple(...)
-            annotations = {k: Any for k in namedtuple_cls._fields}
+        with self.defs.get_schema_or_ref(namedtuple_cls) as (namedtuple_ref, maybe_schema):
+            if maybe_schema is not None:
+                return maybe_schema
+            typevars_map = get_standard_typevars_map(namedtuple_cls)
+            if origin is not None:
+                namedtuple_cls = origin
 
-        arguments_schema = core_schema.arguments_schema(
-            [self._generate_parameter_schema(field_name, annotation) for field_name, annotation in annotations.items()],
-            metadata=build_metadata_dict(js_prefer_positional_arguments=True),
-        )
-        return core_schema.call_schema(arguments_schema, namedtuple_cls)
+            annotations: dict[str, Any] = get_type_hints_infer_globalns(
+                namedtuple_cls, include_extras=True, localns=self.types_namespace
+            )
+            if not annotations:
+                # annotations is empty, happens if namedtuple_cls defined via collections.namedtuple(...)
+                annotations = {k: Any for k in namedtuple_cls._fields}
+
+            if typevars_map:
+                annotations = {
+                    field_name: replace_types(annotation, typevars_map)
+                    for field_name, annotation in annotations.items()
+                }
+
+            arguments_schema = core_schema.arguments_schema(
+                [
+                    self._generate_parameter_schema(field_name, annotation)
+                    for field_name, annotation in annotations.items()
+                ],
+                metadata=build_metadata_dict(js_prefer_positional_arguments=True),
+            )
+            return core_schema.call_schema(arguments_schema, namedtuple_cls, ref=namedtuple_ref)
 
     def _generate_parameter_schema(
         self,
@@ -888,7 +906,12 @@ def _generate_parameter_schema(
 
     def _tuple_schema(self, tuple_type: Any) -> core_schema.CoreSchema:
         """Generate schema for a Tuple, e.g. `tuple[int, str]` or `tuple[int, ...]`."""
+        typevars_map = get_standard_typevars_map(tuple_type)
         params = get_args(tuple_type)
+
+        if typevars_map:
+            params = tuple(replace_types(param, typevars_map) for param in params)
+
         # NOTE: subtle difference: `tuple[()]` gives `params=()`, whereas `typing.Tuple[()]` gives `params=((),)`
         # This is only true for <3.11, on Python 3.11+ `typing.Tuple[()]` gives `params=()`
         if not params:
diff --git a/pydantic/_internal/_generics.py b/pydantic/_internal/_generics.py
--- a/pydantic/_internal/_generics.py
+++ b/pydantic/_internal/_generics.py
@@ -229,6 +229,8 @@ def get_standard_typevars_map(cls: type[Any]) -> dict[TypeVarType, Any] | None:
     origin = get_origin(cls)
     if origin is None:
         return None
+    if not hasattr(origin, '__parameters__'):
+        return None
 
     # In this case, we know that cls is a _GenericAlias, and origin is the generic type
     # So it is safe to access cls.__args__ and origin.__parameters__


Test patch:
diff --git a/tests/test_generics.py b/tests/test_generics.py
--- a/tests/test_generics.py
+++ b/tests/test_generics.py
@@ -19,6 +19,7 @@
     Iterable,
     List,
     Mapping,
+    NamedTuple,
     Optional,
     Sequence,
     Set,
@@ -1190,6 +1191,9 @@ def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHa
     class CustomTuple(Tuple[T]):
         pass
 
+    class CustomLongTuple(Tuple[T, VT]):
+        pass
+
     class Model(BaseModel, Generic[T, KT, VT]):
         counter_field: CustomCounter[T]
         default_dict_field: CustomDefaultDict[KT, VT]
@@ -1202,6 +1206,7 @@ class Model(BaseModel, Generic[T, KT, VT]):
         ordered_dict_field: CustomOrderedDict[KT, VT]
         set_field: CustomSet[T]
         tuple_field: CustomTuple[T]
+        long_tuple_field: CustomLongTuple[T, VT]
 
     assert replace_types(Model, {T: bool, KT: str, VT: int}) == Model[bool, str, int]
     assert replace_types(Model[T, KT, VT], {T: bool, KT: str, VT: int}) == Model[bool, str, int]
@@ -1219,6 +1224,7 @@ class Model(BaseModel, Generic[T, KT, VT]):
         ordered_dict_field=OrderedDict([('a', 1)]),
         set_field={True, False},
         tuple_field=(True,),
+        long_tuple_field=(True, 42),
     )
 
     # The following assertions are just to document the current behavior, and should
@@ -1234,6 +1240,7 @@ class Model(BaseModel, Generic[T, KT, VT]):
     assert type(m.ordered_dict_field) is CustomOrderedDict
     assert type(m.set_field) is CustomSet
     assert type(m.tuple_field) is tuple
+    assert type(m.long_tuple_field) is tuple
 
     assert m.model_dump() == {
         'counter_field': {False: 1, True: 1},
@@ -1247,6 +1254,7 @@ class Model(BaseModel, Generic[T, KT, VT]):
         'ordered_dict_field': {'a': 1},
         'set_field': {False, True},
         'tuple_field': (True,),
+        'long_tuple_field': (True, 42),
     }
 
 
@@ -2446,3 +2454,27 @@ class VariadicToolbox(BaseModel, Generic[ComponentVar, Unpack[NumberOfComponents
     with pytest.raises(TypeError):
         # Should raise error because integer 5 does not meet the bound requirements of ComponentVar
         MyToolboxClass(manual_toolset=(sa, sb, 5))
+
+
+@pytest.mark.skipif(
+    sys.version_info < (3, 11),
+    reason=(
+        'Multiple inheritance with NamedTuple and the corresponding type annotations'
+        " aren't supported before Python 3.11"
+    ),
+)
+def test_generic_namedtuple():
+    T = TypeVar('T')
+
+    class FlaggedValue(NamedTuple, Generic[T]):
+        value: T
+        flag: bool
+
+    class Model(BaseModel):
+        f_value: FlaggedValue[float]
+
+    assert Model(f_value=(1, True)).model_dump() == {'f_value': (1, True)}
+    with pytest.raises(ValidationError):
+        Model(f_value=(1, 'abc'))
+    with pytest.raises(ValidationError):
+        Model(f_value=('abc', True))
diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py
--- a/tests/test_json_schema.py
+++ b/tests/test_json_schema.py
@@ -615,9 +615,9 @@ class Model(BaseModel):
 @pytest.mark.parametrize(
     'field_type,extra_props',
     [
-        (tuple, {'items': {}}),
-        (Tuple, {'items': {}}),
-        (
+        pytest.param(tuple, {'items': {}}, id='tuple'),
+        pytest.param(Tuple, {'items': {}}, id='Tuple'),
+        pytest.param(
             Tuple[str, int, Union[str, int, float], float],
             {
                 'prefixItems': [
@@ -629,13 +629,11 @@ class Model(BaseModel):
                 'minItems': 4,
                 'maxItems': 4,
             },
+            id='Tuple[str, int, Union[str, int, float], float]',
         ),
-        (Tuple[str], {'prefixItems': [{'type': 'string'}], 'minItems': 1, 'maxItems': 1}),
-        (Tuple[()], {'maxItems': 0, 'minItems': 0}),
-        (
-            Tuple[str, ...],
-            {'items': {'type': 'string'}, 'type': 'array'},
-        ),
+        pytest.param(Tuple[str], {'prefixItems': [{'type': 'string'}], 'minItems': 1, 'maxItems': 1}, id='Tuple[str]'),
+        pytest.param(Tuple[()], {'maxItems': 0, 'minItems': 0}, id='Tuple[()]'),
+        pytest.param(Tuple[str, ...], {'items': {'type': 'string'}, 'type': 'array'}, id='Tuple[str, ...]'),
     ],
 )
 def test_tuple(field_type, extra_props):
@@ -2746,18 +2744,17 @@ class LocationBase(BaseModel):
     assert LocationBase(coords=Coordinates(1, 2)).coords == Coordinates(1, 2)
 
     assert LocationBase.model_json_schema() == {
-        'title': 'LocationBase',
-        'type': 'object',
-        'properties': {
-            'coords': {
-                'title': 'Coords',
-                'default': [34, 42],
-                'type': 'array',
-                'prefixItems': [{'title': 'X', 'type': 'number'}, {'title': 'Y', 'type': 'number'}],
-                'minItems': 2,
+        '$defs': {
+            'Coordinates': {
                 'maxItems': 2,
+                'minItems': 2,
+                'prefixItems': [{'title': 'X', 'type': 'number'}, {'title': 'Y', 'type': 'number'}],
+                'type': 'array',
             }
         },
+        'properties': {'coords': {'allOf': [{'$ref': '#/$defs/Coordinates'}], 'default': [34, 42]}},
+        'title': 'LocationBase',
+        'type': 'object',
     }
 
 
@@ -2777,16 +2774,15 @@ class Location(BaseModel):
         coords: CustomCoordinates = CustomCoordinates(34, 42)
 
     assert Location.model_json_schema() == {
-        'properties': {
-            'coords': {
+        '$defs': {
+            'CustomCoordinates': {
                 'additionalProperties': False,
                 'properties': {'x': {'title': 'X', 'type': 'number'}, 'y': {'title': 'Y', 'type': 'number'}},
                 'required': ['x', 'y'],
-                'title': 'Coords',
                 'type': 'object',
-                'default': [34, 42],
             }
         },
+        'properties': {'coords': {'allOf': [{'$ref': '#/$defs/CustomCoordinates'}], 'default': [34, 42]}},
         'title': 'Location',
         'type': 'object',
     }
diff --git a/tests/test_types_namedtuple.py b/tests/test_types_namedtuple.py
--- a/tests/test_types_namedtuple.py
+++ b/tests/test_types_namedtuple.py
@@ -1,9 +1,10 @@
 from collections import namedtuple
-from typing import NamedTuple, Tuple
+from typing import Generic, NamedTuple, Optional, Tuple, TypeVar
 
 import pytest
+from typing_extensions import NamedTuple as TypingExtensionsNamedTuple
 
-from pydantic import BaseModel, ConfigDict, PositiveInt, ValidationError
+from pydantic import BaseModel, ConfigDict, PositiveInt, TypeAdapter, ValidationError
 from pydantic.errors import PydanticSchemaGenerationError
 
 
@@ -66,36 +67,29 @@ class Model(BaseModel):
     assert Model.model_json_schema() == {
         'title': 'Model',
         'type': 'object',
-        'properties': {
-            'pos1': {
-                'title': 'Pos1',
-                'type': 'array',
-                'prefixItems': [
-                    {'title': 'X', 'type': 'integer'},
-                    {'title': 'Y', 'type': 'integer'},
-                ],
-                'minItems': 2,
+        '$defs': {
+            'Position1': {
                 'maxItems': 2,
-            },
-            'pos2': {
-                'title': 'Pos2',
-                'type': 'array',
-                'prefixItems': [
-                    {'title': 'X'},
-                    {'title': 'Y'},
-                ],
                 'minItems': 2,
+                'prefixItems': [{'title': 'X', 'type': 'integer'}, {'title': 'Y', 'type': 'integer'}],
+                'type': 'array',
+            },
+            'Position2': {
                 'maxItems': 2,
+                'minItems': 2,
+                'prefixItems': [{'title': 'X'}, {'title': 'Y'}],
+                'type': 'array',
             },
+        },
+        'properties': {
+            'pos1': {'$ref': '#/$defs/Position1'},
+            'pos2': {'$ref': '#/$defs/Position2'},
             'pos3': {
+                'maxItems': 2,
+                'minItems': 2,
+                'prefixItems': [{'type': 'integer'}, {'type': 'integer'}],
                 'title': 'Pos3',
                 'type': 'array',
-                'prefixItems': [
-                    {'type': 'integer'},
-                    {'type': 'integer'},
-                ],
-                'minItems': 2,
-                'maxItems': 2,
             },
         },
         'required': ['pos1', 'pos2', 'pos3'],
@@ -162,3 +156,46 @@ class Model(BaseModel):
 
         class ModelNoArbitraryTypes(BaseModel):
             x: Tup
+
+
+def test_recursive_namedtuple():
+    class MyNamedTuple(NamedTuple):
+        x: int
+        y: Optional['MyNamedTuple']
+
+    ta = TypeAdapter(MyNamedTuple)
+    assert ta.validate_python({'x': 1, 'y': {'x': 2, 'y': None}}) == (1, (2, None))
+
+    with pytest.raises(ValidationError) as exc_info:
+        ta.validate_python({'x': 1, 'y': {'x': 2, 'y': {'x': 'a', 'y': None}}})
+    assert exc_info.value.errors(include_url=False) == [
+        {
+            'input': 'a',
+            'loc': ('y', 'y', 'x'),
+            'msg': 'Input should be a valid integer, unable to parse string as an ' 'integer',
+            'type': 'int_parsing',
+        }
+    ]
+
+
+def test_recursive_generic_namedtuple():
+    # Need to use TypingExtensionsNamedTuple to make it work with Python <3.11
+    T = TypeVar('T')
+
+    class MyNamedTuple(TypingExtensionsNamedTuple, Generic[T]):
+        x: T
+        y: Optional['MyNamedTuple[T]']
+
+    ta = TypeAdapter(MyNamedTuple[int])
+    assert ta.validate_python({'x': 1, 'y': {'x': 2, 'y': None}}) == (1, (2, None))
+
+    with pytest.raises(ValidationError) as exc_info:
+        ta.validate_python({'x': 1, 'y': {'x': 2, 'y': {'x': 'a', 'y': None}}})
+    assert exc_info.value.errors(include_url=False) == [
+        {
+            'input': 'a',
+            'loc': ('y', 'y', 'x'),
+            'msg': 'Input should be a valid integer, unable to parse string as an ' 'integer',
+            'type': 'int_parsing',
+        }
+    ]