Repository: pydantic/pydantic
Pull request: `validate_call` type params fix
Closes https://github.com/pydantic/pydantic/issues/9536
Fix https://github.com/pydantic/pydantic/issues/9744

Problem statement:
"NameError: name 'T' is not defined" when using "from __future__ import annotations" with generic function
### Initial Checks

- [X] I confirm that I'm using Pydantic V2

### Description

Pydantic gives the below error when using **from __future__ import annotations** with generic functions

```
C:\code\venv\Scripts\python.exe C:\code\devtools\foo.py 
Traceback (most recent call last):
  File "C:\code\devtools\foo.py", line 8, in <module>
    @pydantic.validate_call
     ^^^^^^^^^^^^^^^^^^^^^^
  File "C:\code\venv\Lib\site-packages\pydantic\validate_call_decorator.py", line 66, in validate_call
    return validate(func)
           ^^^^^^^^^^^^^^
  File "C:\code\venv\Lib\site-packages\pydantic\validate_call_decorator.py", line 55, in validate
    validate_call_wrapper = _validate_call.ValidateCallWrapper(function, config, validate_return)
                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\code\venv\Lib\site-packages\pydantic\_internal\_validate_call.py", line 42, in __init__
    schema = gen_schema.clean_schema(gen_schema.generate_schema(function))
                                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\code\venv\Lib\site-packages\pydantic\_internal\_generate_schema.py", line 502, in generate_schema
    schema = self._generate_schema_inner(obj)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\code\venv\Lib\site-packages\pydantic\_internal\_generate_schema.py", line 758, in _generate_schema_inner
    return self.match_type(obj)
           ^^^^^^^^^^^^^^^^^^^^
  File "C:\code\venv\Lib\site-packages\pydantic\_internal\_generate_schema.py", line 825, in match_type
    return self._callable_schema(obj)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\code\venv\Lib\site-packages\pydantic\_internal\_generate_schema.py", line 1608, in _callable_schema
    type_hints = _typing_extra.get_function_type_hints(function)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\code\venv\Lib\site-packages\pydantic\_internal\_typing_extra.py", line 309, in get_function_type_hints
    type_hints[name] = eval_type_backport(value, globalns, types_namespace)
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\code\venv\Lib\site-packages\pydantic\_internal\_typing_extra.py", line 255, in eval_type_backport
    return typing._eval_type(  # type: ignore
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Program Files\Python312\Lib\typing.py", line 400, in _eval_type
    return t._evaluate(globalns, localns, recursive_guard)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Program Files\Python312\Lib\typing.py", line 907, in _evaluate
    eval(self.__forward_code__, globalns, localns),
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<string>", line 1, in <module>
NameError: name 'T' is not defined

Process finished with exit code 1
```

The code works when I remove **from __future__ import annotations** from the code

### Example Code

```Python
from __future__ import annotations

import typing

import pydantic


@pydantic.validate_call
def max[T](args: typing.Iterable[T]) -> T:
    return sorted(args, reverse=True)[0]

print(max([1,2, 10, 5]))
```


### Python, Pydantic & OS Version

```Text
python -c "import pydantic.version; print(pydantic.version.version_info())"
             pydantic version: 2.7.2
        pydantic-core version: 2.18.3
          pydantic-core build: profile=release pgo=true
                 install path: C:\code\venv\Lib\site-packages\pydantic
               python version: 3.12.1 (tags/v3.12.1:2305ca5, Dec  7 2023, 22:03:25) [MSC v.1937 64 bit (AMD64)]
                     platform: Windows-10-10.0.19044-SP0
             related packages: typing_extensions-4.12.0
                       commit: unknown
```


`validate_call` doesn't use types from local scope
### Initial Checks

- [X] I confirm that I'm using Pydantic V2

### Description

`@pydantic.validate_call` only uses types from the global namespace, not locals. `BaseModel` doesn't have this problem.

### Example Code

```Python
from __future__ import annotations

import pydantic


class M1(pydantic.BaseModel):
    x: int


M = M1


def foo():
    class M2(pydantic.BaseModel):
        y: str

    M = M2

    class Outer(pydantic.BaseModel):
        m: M

    print(repr(Outer(m={'y': 'abc'})))  # Outer(m=M2(y='abc'))

    @pydantic.validate_call
    def bar(m: M) -> M:
        return m

    print(repr(bar({'x': 1})))  # M1(x=1)


foo()
```


### Python, Pydantic & OS Version

```Text
pydantic version: 2.8.0a1
        pydantic-core version: 2.19.0
          pydantic-core build: profile=release pgo=true
                 install path: /Users/alex/work/pydantic/pydantic
               python version: 3.12.4 (main, Jun 24 2024, 21:15:39) [Clang 15.0.0 (clang-1500.3.9.4)]
                     platform: macOS-14.5-arm64-arm-64bit
             related packages: pydantic-settings-2.3.4 pydantic-extra-types-2.8.2 typing_extensions-4.12.2 mypy-1.1.1
                       commit: 20b9176f
```

Hints from issue discussion:
@hb2638,

Thanks for reporting this issue. @alexmojaki, any idea what's going on here?
This is not uniquely a pydantic problem, and it's not related to `eval_type_backport`:

```python
from __future__ import annotations

import typing

def foo[T]() -> T: ...

print(typing.get_type_hints(foo))
```

```pytb
Traceback (most recent call last):
  File "test.py", line 7, in <module>
    print(typing.get_type_hints(foo))
          ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "typing.py", line 2281, in get_type_hints
    hints[name] = _eval_type(value, globalns, localns)
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "typing.py", line 414, in _eval_type
    return t._evaluate(globalns, localns, recursive_guard)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "typing.py", line 924, in _evaluate
    eval(self.__forward_code__, globalns, localns),
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<string>", line 1, in <module>
NameError: name 'T' is not defined
```

The good news is that this was fixed in https://github.com/python/cpython/pull/118009 (which apparently will be released tomorrow) and we may be able to mimic the fix.

The bad news is that the fix shows the signature of `_eval_type` changing which may cause bigger breakages.
OK, `_eval_type` was thankfully made backward-compatible in https://github.com/python/cpython/issues/118418. I've confirmed that the pydantic tests still pass on the latest unreleased Python 3.12.

https://github.com/python/cpython/pull/118695 adds a deprecation warning to Python 3.13 which will be raised from `_eval_type` if we don't start passing a `type_params` value. We should probably add Python 3.13 to CI here.

`eval_type_backport` (primarily the pydantic `_typing_extras` function, but also eventually the package) should start accepting `type_params` and passing it to `_eval_type`. Everything that calls `eval_type_backport` needs to figure out an appropriate value, something like `getattr(obj, "__type_params__", ())`. That should actually fix this issue as well as prevent the warning in 3.13.
> `eval_type_backport` (primarily the pydantic `_typing_extras` function, but also eventually the package) should start accepting `type_params` and passing it to `_eval_type`. Everything that calls `eval_type_backport` needs to figure out an appropriate value, something like `getattr(obj, "__type_params__", ())`.

Here's a start to that: https://github.com/pydantic/pydantic/compare/alex/type-params

This makes the following pass:

```python
from __future__ import annotations

import pydantic


@pydantic.validate_call(validate_return=False)
def my_max[T](args: list[T]) -> T:
    return 3


assert my_max(['abc']) == 3
```

However with `validate_return=True` we get this error:

```pytb
Traceback (most recent call last):
  File "/Users/alex/work/pydantic/pydantic/_internal/_generate_schema.py", line 695, in _resolve_forward_ref
    obj = _typing_extra.eval_type_backport(obj, globalns=self._types_namespace)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/alex/work/pydantic/pydantic/_internal/_typing_extra.py", line 262, in eval_type_backport
    return typing._eval_type(  # type: ignore
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/alex/.pyenv/versions/3.12.4/lib/python3.12/typing.py", line 415, in _eval_type
    return t._evaluate(globalns, localns, type_params, recursive_guard=recursive_guard)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/alex/.pyenv/versions/3.12.4/lib/python3.12/typing.py", line 938, in _evaluate
    eval(self.__forward_code__, globalns, locals_to_pass),
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<string>", line 1, in <module>
NameError: name 'T' is not defined

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/alex/Library/Application Support/JetBrains/PyCharm2024.1/scratches/scratch_26.py", line 6, in <module>
    @pydantic.validate_call(validate_return=True)
     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/alex/work/pydantic/pydantic/validate_call_decorator.py", line 54, in validate
    validate_call_wrapper = _validate_call.ValidateCallWrapper(function, config, validate_return)
                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/alex/work/pydantic/pydantic/_internal/_validate_call.py", line 59, in __init__
    schema = gen_schema.clean_schema(gen_schema.generate_schema(return_type))
                                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/alex/work/pydantic/pydantic/_internal/_generate_schema.py", line 504, in generate_schema
    schema = self._generate_schema_inner(obj)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/alex/work/pydantic/pydantic/_internal/_generate_schema.py", line 749, in _generate_schema_inner
    return self.generate_schema(self._resolve_forward_ref(obj))
                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/alex/work/pydantic/pydantic/_internal/_generate_schema.py", line 697, in _resolve_forward_ref
    raise PydanticUndefinedAnnotation.from_name_error(e) from e
pydantic.errors.PydanticUndefinedAnnotation: name 'T' is not defined
```

`_resolve_forward_ref` needs to get `type_params` from somewhere to pass to `eval_type_backport`, but the function object is all the way back in `ValidateCallWrapper.__init__` and not passed to `GenerateSchema` so it looks like it might be a lot of work to rewire things to pass it through.

Here's an alternative that works with `validate_return=True`: https://github.com/pydantic/pydantic/compare/alex/type-params2

There the line `namespace = {param.__name__: param for param in type_params} | namespace` is mimicking `typing.ForwardRef._evaluate`. It feels hacky and risky to do this ourselves rather than to let Python take care of it, so I'm not sure we should, but it looks like it might make things a lot easier. We'd still have to perform this logic in other places though.

(it was at this point that I noticed we don't seem to be passing all the correct namespaces anyway: https://github.com/pydantic/pydantic/issues/9744)

Note that even with the old syntax, we don't seem to validate generics properly anyway, e.g:

```python
from __future__ import annotations

import typing

import pydantic

T = typing.TypeVar('T')


@pydantic.validate_call(validate_return=True)
def my_max(args: list[T]) -> T:
    return 3


# I expect this to raise an error because T is both str and int, but nothing happens
assert my_max(['abc']) == 3
```

I know it's good to not raise the original NameError but this would be more compelling if building the correct schema did the correct thing.

Commit message: remove change to string annotation

Patch to review:
diff --git a/pydantic/_internal/_typing_extra.py b/pydantic/_internal/_typing_extra.py
index 43bcc07b354..4526cf29e73 100644
--- a/pydantic/_internal/_typing_extra.py
+++ b/pydantic/_internal/_typing_extra.py
@@ -307,6 +307,7 @@ def get_function_type_hints(
 
     globalns = add_module_globals(function)
     type_hints = {}
+    type_params: tuple[Any] = getattr(function, '__type_params__', ())  # type: ignore
     for name, value in annotations.items():
         if include_keys is not None and name not in include_keys:
             continue
@@ -315,7 +316,7 @@ def get_function_type_hints(
         elif isinstance(value, str):
             value = _make_forward_ref(value)
 
-        type_hints[name] = eval_type_backport(value, globalns, types_namespace)
+        type_hints[name] = eval_type_backport(value, globalns, types_namespace, type_params)
 
     return type_hints
 
diff --git a/pydantic/_internal/_validate_call.py b/pydantic/_internal/_validate_call.py
index 664c0630133..3fae2d10e42 100644
--- a/pydantic/_internal/_validate_call.py
+++ b/pydantic/_internal/_validate_call.py
@@ -23,7 +23,13 @@ class ValidateCallWrapper:
         '__dict__',  # required for __module__
     )
 
-    def __init__(self, function: Callable[..., Any], config: ConfigDict | None, validate_return: bool):
+    def __init__(
+        self,
+        function: Callable[..., Any],
+        config: ConfigDict | None,
+        validate_return: bool,
+        namespace: dict[str, Any] | None,
+    ):
         if isinstance(function, partial):
             func = function.func
             schema_type = func
@@ -36,7 +42,16 @@ def __init__(self, function: Callable[..., Any], config: ConfigDict | None, vali
             self.__qualname__ = function.__qualname__
             self.__module__ = function.__module__
 
-        namespace = _typing_extra.add_module_globals(function, None)
+        global_ns = _typing_extra.add_module_globals(function, None)
+        # TODO: this is a bit of a hack, we should probably have a better way to handle this
+        # specifically, we shouldn't be pumping the namespace full of type_params
+        # when we take namespace and type_params arguments in eval_type_backport
+        type_params = getattr(schema_type, '__type_params__', ())
+        namespace = {
+            **{param.__name__: param for param in type_params},
+            **(global_ns or {}),
+            **(namespace or {}),
+        }
         config_wrapper = ConfigWrapper(config)
         gen_schema = _generate_schema.GenerateSchema(config_wrapper, namespace)
         schema = gen_schema.clean_schema(gen_schema.generate_schema(function))
diff --git a/pydantic/validate_call_decorator.py b/pydantic/validate_call_decorator.py
index afa109c211b..5314c9207c1 100644
--- a/pydantic/validate_call_decorator.py
+++ b/pydantic/validate_call_decorator.py
@@ -5,7 +5,7 @@
 import functools
 from typing import TYPE_CHECKING, Any, Callable, TypeVar, overload
 
-from ._internal import _validate_call
+from ._internal import _typing_extra, _validate_call
 
 __all__ = ('validate_call',)
 
@@ -46,12 +46,14 @@ def validate_call(
     Returns:
         The decorated function.
     """
+    local_ns = _typing_extra.parent_frame_namespace()
 
     def validate(function: AnyCallableT) -> AnyCallableT:
         if isinstance(function, (classmethod, staticmethod)):
             name = type(function).__name__
             raise TypeError(f'The `@{name}` decorator should be applied after `@validate_call` (put `@{name}` on top)')
-        validate_call_wrapper = _validate_call.ValidateCallWrapper(function, config, validate_return)
+
+        validate_call_wrapper = _validate_call.ValidateCallWrapper(function, config, validate_return, local_ns)
 
         @functools.wraps(function)
         def wrapper_function(*args, **kwargs):
diff --git a/tests/test_validate_call.py b/tests/test_validate_call.py
index f4d466ff23e..e27d7db5354 100644
--- a/tests/test_validate_call.py
+++ b/tests/test_validate_call.py
@@ -1,6 +1,7 @@
 import asyncio
 import inspect
 import re
+import sys
 from datetime import datetime, timezone
 from functools import partial
 from typing import Any, List, Tuple
@@ -803,3 +804,44 @@ def foo(bar: 'list[int | str]') -> 'list[int | str]':
             'input': {'not a str or int'},
         },
     ]
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason='requires Python 3.12+ for PEP 695 syntax with generics')
+def test_validate_call_with_pep_695_syntax() -> None:
+    globs = {}
+    exec(
+        """
+from typing import Iterable
+from pydantic import validate_call
+
+@validate_call
+def max[T](args: Iterable[T]) -> T:
+    return sorted(args, reverse=True)[0]
+        """,
+        globs,
+    )
+    max = globs['max']
+    assert len(max.__type_params__) == 1
+    assert max([1, 2, 10, 5]) == 10
+
+    with pytest.raises(ValidationError):
+        max(1)
+
+
+def test_uses_local_ns():
+    class M1(BaseModel):
+        x: int
+
+    M = M1  # noqa: F841
+
+    def foo():
+        class M2(BaseModel):
+            y: str
+
+        M = M2
+
+        @validate_call
+        def bar(m: M) -> M:
+            return m
+
+        assert bar({'y': 'using m2'}) == M2(y='using m2')


Code review comments:
diff_hunk: @@ -803,3 +804,44 @@ def foo(bar: 'list[int | str]') -> 'list[int | str]':
             'input': {'not a str or int'},
         },
     ]
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason='requires Python 3.12+ for PEP 695 syntax with generics')
+def test_validate_call_with_pep_695_syntax() -> None:
+    globs = {}
+    exec(
+        """
+from typing import Iterable
+from pydantic import validate_call
+
+@validate_call
+def max[T](args: Iterable[T]) -> T:
+    return sorted(args, reverse=True)[0]
+        """,
+        globs,
+    )
+    max = globs['max']
+    assert len(max.__type_params__) == 1
+    assert max([1, 2, 10, 5]) == 10
+
+    with pytest.raises(ValidationError):
+        max(1)
+
+
+def test_uses_local_ns():
+    class M1(BaseModel):
+        x: int
+
+    M = M1  # noqa: F841
path: tests/test_validate_call.py
text: @user1:
please also add an `M` at the global level