diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ae883..40f6b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Fix `TypeError: unsupported callable` when decorating a function whose parameter is + annotated with a name imported only under `if TYPE_CHECKING:`, on Python 3.14 where + annotations are evaluated lazily (PEP 649) (#315) +- Detect `respx_mock` and `route` parameters declared on functions wrapped with + `functools.wraps` by following the `__wrapped__` chain when introspecting decorated + callables (#315) + ## [0.23.1] - 2026-04-08 ### Fixed diff --git a/respx/mocks.py b/respx/mocks.py index ae0dad7..f3ede97 100644 --- a/respx/mocks.py +++ b/respx/mocks.py @@ -8,6 +8,7 @@ import httpx from respx.patterns import parse_url +from respx.utils import get_arg_spec from .models import AllMockedAssertionError, PassThrough from .transports import TryTransport @@ -172,7 +173,7 @@ def mock(cls, spec): # Prevent mocking mock return spec - argspec = inspect.getfullargspec(spec) + argspec = get_arg_spec(spec) def mock(self, *args, **kwargs): kwargs = cls._merge_args_and_kwargs(argspec, args, kwargs) diff --git a/respx/models.py b/respx/models.py index 7585171..0ff83e3 100644 --- a/respx/models.py +++ b/respx/models.py @@ -16,7 +16,7 @@ import httpx -from respx.utils import SetCookie +from respx.utils import SetCookie, get_arg_spec from .patterns import M, Pattern from .types import ( @@ -330,7 +330,7 @@ def _call_side_effect( self, effect: CallableSideEffect, request: httpx.Request, **kwargs: Any ) -> RouteResultTypes: # Add route kwarg if the side effect wants it - argspec = inspect.getfullargspec(effect) + argspec = get_arg_spec(effect) if "route" in kwargs: warn(f"Matched context contains reserved word `route`: {self.pattern!r}") if "route" in argspec.args: diff --git a/respx/router.py b/respx/router.py index 449a5a4..b645e04 100644 --- a/respx/router.py +++ b/respx/router.py @@ -31,6 +31,7 @@ ) from .patterns import Pattern, merge_patterns, parse_url_patterns from .types import DefaultType, ResolvedResponseTypes, RouteResultTypes, URLPatternTypes +from .utils import get_arg_spec Default = NewType("Default", object) DEFAULT = Default(...) @@ -394,7 +395,7 @@ def __call__( # Determine if decorated function needs a `respx_mock` instance is_async = inspect.iscoroutinefunction(func) - argspec = inspect.getfullargspec(func) + argspec = get_arg_spec(func) needs_mock_reference = "respx_mock" in argspec.args if needs_mock_reference: diff --git a/respx/utils.py b/respx/utils.py index aa59488..842d0d6 100644 --- a/respx/utils.py +++ b/respx/utils.py @@ -1,9 +1,12 @@ import email +import inspect +import sys from collections import defaultdict from datetime import datetime from email.message import Message from typing import ( Any, + Callable, Dict, Iterable, List, @@ -155,3 +158,45 @@ def __new__( ) self = super().__new__(cls, "Set-Cookie", string) return self + + +class ArgSpec(NamedTuple): + args: List[str] + defaults: Optional[Tuple[Any, ...]] + + +def get_arg_spec(func: Callable[..., Any]) -> ArgSpec: + """ + Return the positional parameter names and defaults of a callable. + + Since PEP 649 (Python 3.14) annotations are evaluated lazily, and + ``inspect.getfullargspec`` forces that evaluation. A parameter annotated + with a name only imported under ``if TYPE_CHECKING:`` then raises + ``NameError``, which ``getfullargspec`` re-raises as + ``TypeError: unsupported callable``. On Python 3.14+ we therefore read the + signature with ``ForwardRef`` placeholders, which never evaluates the + annotations. + """ + kwargs: Dict[str, Any] = {} + if sys.version_info >= (3, 14): # pragma: no cover + import annotationlib + + kwargs["annotation_format"] = annotationlib.Format.FORWARDREF + + signature = inspect.signature(func, **kwargs) + positional_kinds = ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) + args = [ + name + for name, parameter in signature.parameters.items() + if parameter.kind in positional_kinds + ] + defaults = tuple( + parameter.default + for parameter in signature.parameters.values() + if parameter.kind in positional_kinds + and parameter.default is not inspect.Parameter.empty + ) + return ArgSpec(args, defaults or None) diff --git a/tests/test_mock.py b/tests/test_mock.py index 6f1118c..03612e4 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -1,4 +1,6 @@ +import functools import socket +import sys from contextlib import ExitStack as does_not_raise from unittest import mock @@ -152,6 +154,58 @@ def test(respx_mock): test() +def test_decorator_follows_wrapped_function(): + router = respx.mock() + + def passthrough(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + @router + @passthrough + def test(respx_mock): + assert respx_mock is router + + test() + + +@pytest.mark.skipif( + sys.version_info < (3, 14), + reason="Deferred annotations (PEP 649) only apply on Python 3.14+. On older " + "versions a bare annotation referencing a TYPE_CHECKING-only name raises " + "NameError at function definition time, so the scenario cannot be reproduced.", +) +def test_decorating_with_type_checking_only_annotation(): + # Regression for https://github.com/lundberg/respx -- decorating a function + # whose parameter is annotated with a name imported only under + # `if TYPE_CHECKING:`. On Python 3.14 (PEP 649) the annotation is evaluated + # lazily; `inspect.getfullargspec` used to force that evaluation and raise + # `TypeError: unsupported callable` (root cause `NameError`). + # + # The function is built via `exec` so this test module still imports on + # Python < 3.14, where the bare annotation would otherwise raise NameError + # at definition time. + source = ( + "from typing import TYPE_CHECKING\n" + "if TYPE_CHECKING:\n" + " import this_module_is_only_imported_for_typing as typing_only\n" + "def func(arg: typing_only.Thing = None, respx_mock=None):\n" + " return arg, respx_mock\n" + ) + namespace: dict = {} + exec(compile(source, __file__, "exec"), namespace) + func = namespace["func"] + + decorated = respx.mock(func) + + # `respx_mock` is detected and injected despite the unresolved annotation, + # and the annotated parameter's default is preserved. + assert decorated() == (None, respx.mock) + + def test_local_decorator_without_reference(): router = respx.mock() route = router.get("https://foo.bar/") % 202 diff --git a/tests/test_utils.py b/tests/test_utils.py index ea9c365..7928c3d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,7 @@ +import functools from datetime import datetime, timezone -from respx.utils import SetCookie +from respx.utils import SetCookie, get_arg_spec class TestSetCookie: @@ -31,3 +32,35 @@ def test_can_render_all_attributes(self) -> None: "Partitioned" ), ) + + +class TestGetArgSpec: + def test_returns_positional_args_and_defaults(self) -> None: + def func(a, b, c=3, *args, d, e=5, **kwargs): # pragma: no cover + ... + + arg_spec = get_arg_spec(func) + assert arg_spec.args == ["a", "b", "c"] + assert arg_spec.defaults == (3,) + + def test_returns_none_defaults_without_defaults(self) -> None: + def func(a, b): # pragma: no cover + ... + + arg_spec = get_arg_spec(func) + assert arg_spec.args == ["a", "b"] + assert arg_spec.defaults is None + + def test_follows_wrapped_chain(self) -> None: + def inner(a, b, respx_mock=None): # pragma: no cover + ... + + @functools.wraps(inner) + def wrapper(*args, **kwargs): # pragma: no cover + ... + + # Follows __wrapped__ (inspect.signature's default) so respx detects + # respx_mock / route declared on a wrapped function. + arg_spec = get_arg_spec(wrapper) + assert arg_spec.args == ["a", "b", "respx_mock"] + assert arg_spec.defaults == (None,)