Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion respx/mocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions respx/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion respx/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(...)
Expand Down Expand Up @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions respx/utils.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
54 changes: 54 additions & 0 deletions tests/test_mock.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import functools
import socket
import sys
from contextlib import ExitStack as does_not_raise
from unittest import mock

Expand Down Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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,)