Don't evaluate annotations when inspecting signatures (#7818)

Since Python 3.14 (PEP 649) annotations are evaluated lazily, so
inspect.signature() raises NameError for callables whose annotations
reference names imported only under TYPE_CHECKING. This broke
middleware registration (via argument_is_required()) and custom stats
collectors (via _warn_spider_arg) for user code with such annotations.

Use annotation_format=Format.FORWARDREF on 3.14+: parameter names,
kinds and defaults are unchanged, and unresolvable annotations become
ForwardRef proxies instead of raising.

Resolves #7796.
This commit is contained in:
Janit Rajkarnikar 2026-07-30 10:18:45 -05:00 committed by GitHub
parent f02a99fe71
commit 3fc7148c5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 70 additions and 5 deletions

View File

@ -10,6 +10,7 @@ from twisted.internet.defer import Deferred, maybeDeferred
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils.asyncio import run_in_thread
from scrapy.utils.defer import deferred_from_coro
from scrapy.utils.python import _signature
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Callable, Coroutine
@ -109,7 +110,7 @@ def _warn_spider_arg(
):
"""Decorator to warn if a ``spider`` argument is passed to a function."""
sig = inspect.signature(func)
sig = _signature(func)
def check_args(*args: _P.args, **kwargs: _P.kwargs) -> None:
bound = sig.bind(*args, **kwargs)

View File

@ -178,11 +178,30 @@ def binary_is_text(data: bytes) -> bool:
return all(c not in _BINARYCHARS for c in data)
# PEP 649 (Python 3.14+) made annotation evaluation lazy, so inspect.signature()
# can raise NameError for names imported only under TYPE_CHECKING. We only need
# parameter names, kinds and defaults, so leave such annotations as ForwardRefs.
if sys.version_info >= (3, 14):
from annotationlib import Format
def _signature(func: Callable[..., Any]) -> inspect.Signature:
return inspect.signature(func, annotation_format=Format.FORWARDREF)
else:
def _signature(func: Callable[..., Any]) -> inspect.Signature:
return inspect.signature(func)
def get_func_args_dict(
func: Callable[..., Any], stripself: bool = False
) -> Mapping[str, inspect.Parameter]:
"""Return the argument dict of a callable object.
Annotations are not evaluated, so on Python 3.14 and later the ``annotation``
attribute of the returned parameters may be a ``ForwardRef`` instead of the
resolved type.
.. versionadded:: 2.14
"""
if not callable(func):
@ -190,7 +209,7 @@ def get_func_args_dict(
args: Mapping[str, inspect.Parameter]
try:
sig = inspect.signature(func)
sig = _signature(func)
except ValueError:
return {}

View File

@ -1,7 +1,8 @@
from __future__ import annotations
import sys
import warnings
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
import pytest
from twisted.internet.defer import Deferred
@ -12,7 +13,7 @@ from scrapy.utils.defer import maybe_deferred_to_future
from tests.utils.decorators import coroutine_test
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Callable
class TestDeprecated:
@ -77,6 +78,31 @@ class TestWarnSpiderArg:
):
assert parse("response", spider="spider") == "response"
@pytest.mark.skipif(
sys.version_info < (3, 14),
reason="annotations are only lazily evaluated since Python 3.14 (PEP 649)",
)
def test_sync_warns_with_unresolvable_annotations(self):
# dont_inherit=True, or the module's future import stringizes the annotations
namespace: dict[str, Any] = {}
exec( # pylint: disable=exec-used
compile(
"def parse(response: OnlyAtTypeCheckingTime,"
" spider: OnlyAtTypeCheckingTime | None = None): return response",
"<test>",
"exec",
dont_inherit=True,
),
namespace,
)
parse_func: Callable[..., str] = namespace["parse"]
parse = _warn_spider_arg(parse_func)
with pytest.warns(
ScrapyDeprecationWarning, match=r"Passing a 'spider' argument"
):
assert parse("response", spider="spider") == "response"
def test_sync_no_warning_without_spider_arg(self):
@_warn_spider_arg
def parse(response: str, spider: str | None = None) -> str:

View File

@ -4,7 +4,7 @@ import functools
import operator
import platform
import sys
from typing import TYPE_CHECKING, TypeVar
from typing import TYPE_CHECKING, Any, TypeVar
import pytest
@ -190,6 +190,25 @@ def test_get_func_args():
]
@pytest.mark.skipif(
sys.version_info < (3, 14),
reason="annotations are only lazily evaluated since Python 3.14 (PEP 649)",
)
def test_get_func_args_unresolvable_annotations():
# dont_inherit=True, or the module's future import stringizes the annotations
namespace: dict[str, Any] = {}
exec( # pylint: disable=exec-used
compile(
"def f(a: OnlyAtTypeCheckingTime, b: int = 1) -> OnlyAtTypeCheckingTime: pass",
"<test>",
"exec",
dont_inherit=True,
),
namespace,
)
assert get_func_args(namespace["f"]) == ["a", "b"]
@pytest.mark.parametrize(
("value", "expected"),
[