Repository: pydantic/pydantic
Pull request: Fix forward ref in generic
Fixes #6503

skip change file check

Selected Reviewer: @samuelcolvin

Problem statement:
Forward reference in typing.Type["..."] errors with `TypeError: 'ForwardRef' object cannot be converted to 'PyType'`
### Initial Checks

- [X] I confirm that I'm using Pydantic V2 installed directly from the `main` branch, or equivalent

### Description

When a pydantic model (or any other class) is forward referenced inside `tp.Type["Bar"]` pydantic errors. 

When the whole type is forward referenced, i.e. `"tp.Type[Bar]"`, there is no problem. 

This used to work in V1

```
Traceback (most recent call last):
  File "/tmp/ipykernel_2231448/1094217750.py", line 6, in <module>
    class Foo(BaseModel):
  File "venv/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 172, in __new__
    complete_model_class(
  File "venv/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 438, in complete_model_class
    cls.__pydantic_validator__ = SchemaValidator(simplified_core_schema, core_config)
                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.SchemaError: Error building "model" validator:
  SchemaError: Error building "model-fields" validator:
  SchemaError: Field "bar":
  SchemaError: Error building "is-subclass" validator:
  TypeError: 'ForwardRef' object cannot be converted to 'PyType'
```

### Example Code

```Python
import typing as tp
from pydantic import BaseModel


class Foo(BaseModel):
    bar: tp.Type["Bar"] # "tp.Type[Bar]" works fine


class Bar(BaseModel):
    pass

Foo.model_rebuild()

foo = Foo(bar=Bar)
```


### Python, Pydantic & OS Version

```Text
pydantic version: 2.0.2
        pydantic-core version: 2.1.2 release build profile
                 install path: venv/lib/python3.11/site-packages/pydantic
               python version: 3.11.4 (main, Jun  7 2023, 12:45:48) [GCC 11.3.0]
                     platform: Linux-5.19.0-46-generic-x86_64-with-glibc2.35
     optional deps. installed: ['devtools', 'email-validator', 'typing-extensions']
```


Selected Assignee: @adriangb

Hints from issue discussion:

Commit message: Handle tuple case

Patch to review:
diff --git a/pydantic/_internal/_generate_schema.py b/pydantic/_internal/_generate_schema.py
index 865b130ef0d..d5e259e0604 100644
--- a/pydantic/_internal/_generate_schema.py
+++ b/pydantic/_internal/_generate_schema.py
@@ -15,7 +15,20 @@
 from itertools import chain
 from operator import attrgetter
 from types import FunctionType, LambdaType, MethodType
-from typing import TYPE_CHECKING, Any, Callable, Dict, ForwardRef, Iterable, Iterator, Mapping, TypeVar, Union, cast
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Callable,
+    Dict,
+    ForwardRef,
+    Iterable,
+    Iterator,
+    Mapping,
+    TypeVar,
+    Union,
+    cast,
+    overload,
+)
 from warnings import warn
 
 from pydantic_core import CoreSchema, PydanticUndefined, core_schema
@@ -328,7 +341,10 @@ def _model_schema(self, cls: type[BaseModel]) -> core_schema.CoreSchema:
                             raise PydanticSchemaGenerationError(
                                 'The type annotation for `__pydantic_extra__` must be `Dict[str, ...]`'
                             )
-                        extra_items_type = get_args(cls.__annotations__['__pydantic_extra__'])[1]
+                        extra_items_type = self._get_args_resolving_forward_refs(
+                            cls.__annotations__['__pydantic_extra__'],
+                            required=True,
+                        )[1]
                         if extra_items_type is not Any:
                             extra_validator = self.generate_schema(extra_items_type)
                             break
@@ -441,6 +457,50 @@ def _generate_schema_from_property(self, obj: Any, source: Any) -> core_schema.C
             return core_schema.definition_reference_schema(ref)
         return schema
 
+    def _resolve_forward_ref(self, obj: Any) -> Any:
+        # we assume that types_namespace has the target of forward references in its scope,
+        # but this could fail, for example, if calling Validator on an imported type which contains
+        # forward references to other types only defined in the module from which it was imported
+        # `Validator(SomeImportedTypeAliasWithAForwardReference)`
+        # or the equivalent for BaseModel
+        # class Model(BaseModel):
+        #   x: SomeImportedTypeAliasWithAForwardReference
+        try:
+            obj = _typing_extra.evaluate_fwd_ref(obj, globalns=self.types_namespace)
+        except NameError as e:
+            raise PydanticUndefinedAnnotation.from_name_error(e) from e
+
+        # if obj is still a ForwardRef, it means we can't evaluate it, raise PydanticUndefinedAnnotation
+        if isinstance(obj, ForwardRef):
+            raise PydanticUndefinedAnnotation(obj.__forward_arg__, f'Unable to evaluate forward reference {obj}')
+
+        if self.typevars_map:
+            obj = replace_types(obj, self.typevars_map)
+
+        return obj
+
+    @overload
+    def _get_args_resolving_forward_refs(self, obj: Any, required: Literal[True]) -> tuple[Any, ...]:
+        ...
+
+    @overload
+    def _get_args_resolving_forward_refs(self, obj: Any) -> tuple[Any, ...] | None:
+        ...
+
+    def _get_args_resolving_forward_refs(self, obj: Any, required: bool = False) -> tuple[Any, ...] | None:
+        args = get_args(obj)
+        if args:
+            args = tuple([self._resolve_forward_ref(a) if isinstance(a, ForwardRef) else a for a in args])
+        elif required:  # pragma: no cover
+            raise TypeError(f'Expected {obj} to have generic parameters but it had none')
+        return args  # type: ignore
+
+    def _get_first_arg_or_any(self, obj: Any) -> Any:
+        args = self._get_args_resolving_forward_refs(obj)
+        if not args:
+            return Any
+        return args[0]
+
     def _generate_schema(self, obj: Any) -> core_schema.CoreSchema:  # noqa: C901
         """Recursively generate a pydantic-core schema for any supported python type."""
         if isinstance(obj, dict):
@@ -451,26 +511,7 @@ def _generate_schema(self, obj: Any) -> core_schema.CoreSchema:  # noqa: C901
             obj = ForwardRef(obj)
 
         if isinstance(obj, ForwardRef):
-            # we assume that types_namespace has the target of forward references in its scope,
-            # but this could fail, for example, if calling Validator on an imported type which contains
-            # forward references to other types only defined in the module from which it was imported
-            # `Validator(SomeImportedTypeAliasWithAForwardReference)`
-            # or the equivalent for BaseModel
-            # class Model(BaseModel):
-            #   x: SomeImportedTypeAliasWithAForwardReference
-            try:
-                obj = _typing_extra.evaluate_fwd_ref(obj, globalns=self.types_namespace)
-            except NameError as e:
-                raise PydanticUndefinedAnnotation.from_name_error(e) from e
-
-            # if obj is still a ForwardRef, it means we can't evaluate it, raise PydanticUndefinedAnnotation
-            if isinstance(obj, ForwardRef):
-                raise PydanticUndefinedAnnotation(obj.__forward_arg__, f'Unable to evaluate forward reference {obj}')
-
-            if self.typevars_map:
-                obj = replace_types(obj, self.typevars_map)
-
-            return self.generate_schema(obj)
+            return self.generate_schema(self._resolve_forward_ref(obj))
 
         from ..main import BaseModel
 
@@ -515,7 +556,12 @@ def _generate_schema(self, obj: Any) -> core_schema.CoreSchema:  # noqa: C901
         elif is_finalvar(obj):
             if obj is Final:
                 return core_schema.any_schema()
-            return self.generate_schema(get_args(obj)[0])
+            return self.generate_schema(
+                self._get_args_resolving_forward_refs(
+                    obj,
+                    required=True,
+                )[0]
+            )
         elif isinstance(obj, (FunctionType, LambdaType, MethodType, partial)):
             return self._callable_schema(obj)
         elif inspect.isclass(obj) and issubclass(obj, Enum):
@@ -737,7 +783,7 @@ def json_schema_update_func(schema: CoreSchemaOrField, handler: GetJsonSchemaHan
 
     def _union_schema(self, union_type: Any) -> core_schema.CoreSchema:
         """Generate schema for a Union."""
-        args = get_args(union_type)
+        args = self._get_args_resolving_forward_refs(union_type, required=True)
         choices: list[core_schema.CoreSchema] = []
         nullable = False
         for arg in args:
@@ -830,10 +876,16 @@ def _typed_dict_schema(self, typed_dict_cls: Any, origin: Any) -> core_schema.Co
 
                 if get_origin(annotation) == _typing_extra.Required:
                     required = True
-                    annotation = get_args(annotation)[0]
+                    annotation = self._get_args_resolving_forward_refs(
+                        annotation,
+                        required=True,
+                    )[0]
                 elif get_origin(annotation) == _typing_extra.NotRequired:
                     required = False
-                    annotation = get_args(annotation)[0]
+                    annotation = self._get_args_resolving_forward_refs(
+                        annotation,
+                        required=True,
+                    )[0]
 
                 field_info = FieldInfo.from_annotation(annotation)
                 fields[field_name] = self._generate_td_field_schema(
@@ -919,9 +971,9 @@ 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)
+        params = self._get_args_resolving_forward_refs(tuple_type)
 
-        if typevars_map:
+        if typevars_map and params:
             params = tuple(replace_types(param, typevars_map) for param in params)
 
         # NOTE: subtle difference: `tuple[()]` gives `params=()`, whereas `typing.Tuple[()]` gives `params=((),)`
@@ -959,7 +1011,7 @@ def _type_schema(self) -> core_schema.CoreSchema:
 
     def _subclass_schema(self, type_: Any) -> core_schema.CoreSchema:
         """Generate schema for a Type, e.g. `Type[int]`."""
-        type_param = get_first_arg(type_)
+        type_param = self._get_first_arg_or_any(type_)
         if type_param == Any:
             return self._type_schema()
         elif isinstance(type_param, typing.TypeVar):
@@ -976,7 +1028,7 @@ def _subclass_schema(self, type_: Any) -> core_schema.CoreSchema:
 
     def _sequence_schema(self, sequence_type: Any) -> core_schema.CoreSchema:
         """Generate schema for a Sequence, e.g. `Sequence[int]`."""
-        item_type = get_first_arg(sequence_type)
+        item_type = self._get_first_arg_or_any(sequence_type)
 
         def json_schema_func(_schema: CoreSchemaOrField, handler: GetJsonSchemaHandler) -> JsonSchemaValue:
             items_schema = self._generate_schema(item_type)
@@ -998,7 +1050,7 @@ def json_schema_func(_schema: CoreSchemaOrField, handler: GetJsonSchemaHandler)
 
     def _iterable_schema(self, type_: Any) -> core_schema.GeneratorSchema:
         """Generate a schema for an `Iterable`."""
-        item_type = get_first_arg(type_)
+        item_type = self._get_first_arg_or_any(type_)
 
         return core_schema.generator_schema(self.generate_schema(item_type))
 
@@ -1015,7 +1067,10 @@ def _pattern_schema(self, pattern_type: Any) -> core_schema.CoreSchema:
                 _validators.pattern_either_validator, serialization=ser, metadata=metadata
             )
 
-        param = get_args(pattern_type)[0]
+        param = self._get_args_resolving_forward_refs(
+            pattern_type,
+            required=True,
+        )[0]
         if param == str:
             return core_schema.no_info_plain_validator_function(
                 _validators.pattern_str_validator, serialization=ser, metadata=metadata
@@ -1206,7 +1261,10 @@ def set_computed_field_metadata(schema: CoreSchemaOrField, handler: GetJsonSchem
 
     def _annotated_schema(self, annotated_type: Any) -> core_schema.CoreSchema:
         """Generate schema for an Annotated type, e.g. `Annotated[int, Field(...)]` or `Annotated[int, Gt(0)]`."""
-        source_type, *annotations = get_args(annotated_type)
+        source_type, *annotations = self._get_args_resolving_forward_refs(
+            annotated_type,
+            required=True,
+        )
         schema = self._apply_annotations(source_type, annotations)
         # put the default validator last so that TypeAdapter.get_default_value() works
         # even if there are function validators involved
@@ -1640,14 +1698,6 @@ def wrap_default(field_info: FieldInfo, schema: core_schema.CoreSchema) -> core_
         return schema
 
 
-def get_first_arg(type_: Any) -> Any:
-    """Get the first argument from a typing object, e.g. `List[int]` -> `int`, or `Any` if no argument."""
-    try:
-        return get_args(type_)[0]
-    except IndexError:
-        return Any
-
-
 def _extract_get_pydantic_json_schema(tp: Any, schema: CoreSchema) -> GetJsonSchemaFunction | None:
     """Extract `__get_pydantic_json_schema__` from a type, handling the deprecated `__modify_schema__`."""
     js_modify_function = getattr(tp, '__get_pydantic_json_schema__', None)
diff --git a/tests/test_forward_ref.py b/tests/test_forward_ref.py
index 591dce8824a..633308ac1e3 100644
--- a/tests/test_forward_ref.py
+++ b/tests/test_forward_ref.py
@@ -1,7 +1,7 @@
 import dataclasses
 import re
 import sys
-from typing import Optional, Tuple
+from typing import Any, Optional, Tuple
 
 import pytest
 
@@ -997,3 +997,26 @@ class allOfExpressions_(BaseModel):
     models = [allOfExpressions_, Expressions_]
     for m in models:
         m.model_rebuild(_types_namespace=types_namespace)
+
+
+def test_forward_ref_in_generic(create_module: Any) -> None:
+    """https://github.com/pydantic/pydantic/issues/6503"""
+
+    @create_module
+    def module():
+        import typing as tp
+
+        from pydantic import BaseModel
+
+        class Foo(BaseModel):
+            x: tp.Dict['tp.Type[Bar]', tp.Type['Bar']]
+
+        class Bar(BaseModel):
+            pass
+
+        Foo.model_rebuild()
+
+    Foo = module.Foo
+    Bar = module.Bar
+
+    assert Foo(x={Bar: Bar}).x[Bar] is Bar


Code review comments:
diff_hunk: @@ -997,3 +997,26 @@ class allOfExpressions_(BaseModel):
     models = [allOfExpressions_, Expressions_]
     for m in models:
         m.model_rebuild(_types_namespace=types_namespace)
+
+
+def test_forward_ref_in_generic(create_module: Any) -> None:
+    """https://github.com/pydantic/pydantic/issues/6503"""
+
+    @create_module
+    def module():
+        import typing as tp
+
+        from pydantic import BaseModel
+
+        class Foo(BaseModel):
+            x: tp.Dict['tp.Type[Bar]', tp.Type['Bar']]
+
+        class Bar(BaseModel):
+            pass
+
+        Foo.model_rebuild()
+
path: tests/test_forward_ref.py
text: @user1:
```suggestion
```
Isn't necessary due to automated rebuild, probably worth removing to make sure that doesn't stop working in the future.

diff_hunk: @@ -997,3 +997,26 @@ class allOfExpressions_(BaseModel):
     models = [allOfExpressions_, Expressions_]
     for m in models:
         m.model_rebuild(_types_namespace=types_namespace)
+
+
+def test_forward_ref_in_generic(create_module: Any) -> None:
+    """https://github.com/pydantic/pydantic/issues/6503"""
+
+    @create_module
+    def module():
+        import typing as tp
+
+        from pydantic import BaseModel
+
+        class Foo(BaseModel):
+            x: tp.Dict['tp.Type[Bar]', tp.Type['Bar']]
+
+        class Bar(BaseModel):
+            pass
+
+        Foo.model_rebuild()
+
+    Foo = module.Foo
+    Bar = module.Bar
+
+    assert Foo(x={Bar: Bar}).x[Bar] is Bar
path: tests/test_forward_ref.py
text: @user2:
```suggestion
    assert Foo(x={Bar: Bar}).x[Bar] is Bar


def test_forward_ref_in_generic_separate_modules(create_module: Any) -> None:
    """https://github.com/pydantic/pydantic/issues/6503"""

    @user1
    def module_1():
        import typing as tp

        from pydantic import BaseModel

        class Foo(BaseModel):
            x: tp.Dict['tp.Type[Bar]', tp.Type['Bar']]

    @user1
    def module_2():
        from pydantic import BaseModel

        class Bar(BaseModel):
            pass

    Foo = module_1.Foo
    Bar = module_2.Bar

    Foo.model_rebuild(_types_namespace={'tp': typing, 'Bar': Bar})

    assert Foo(x={Bar: Bar}).x[Bar] is Bar
```

(Also need to add `import typing` at the top of the file.)

This test makes sure it also works when the types aren't in the same module. I think it's worth adding but defer to you.