From cafee80775b613046c29c71a6d9afee77c005fcb Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Fri, 14 Aug 2026 16:08:47 +0000 Subject: [PATCH 1/4] feat(typing): use ParamSpec to type LRU cache decorators (#2629) Introduce _LruCacheWrapper protocol typed with ParamSpec and covariant return TypeVar to preserve argument signatures, return types, and cache_clear() on LRU cache decorators. Remove obsolete type: ignore comments in HTTPError, HTTPStatus, Response, and Handlers. Signed-off-by: Alex Chen --- docs/_newsfragments/2629.newandimproved.rst | 3 ++ falcon/_typing.py | 21 ++++++++++ falcon/http_error.py | 3 +- falcon/http_status.py | 3 +- falcon/media/handlers.py | 6 ++- falcon/response.py | 3 +- falcon/util/misc.py | 45 +++++++++++++++++---- tests/test_typing.py | 31 ++++++++++++++ 8 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 docs/_newsfragments/2629.newandimproved.rst diff --git a/docs/_newsfragments/2629.newandimproved.rst b/docs/_newsfragments/2629.newandimproved.rst new file mode 100644 index 000000000..ad0116488 --- /dev/null +++ b/docs/_newsfragments/2629.newandimproved.rst @@ -0,0 +1,3 @@ +Internal typing for LRU cache decorators now uses :class:`typing.ParamSpec` +and a typed wrapper protocol, preserving function argument signatures, return +types, and the ``cache_clear()`` method. diff --git a/falcon/_typing.py b/falcon/_typing.py index 56d66b6f1..68829e0cb 100644 --- a/falcon/_typing.py +++ b/falcon/_typing.py @@ -35,6 +35,20 @@ Union, ) +if sys.version_info >= (3, 10): + from typing import Concatenate as Concatenate + from typing import ParamSpec as ParamSpec + + _P = ParamSpec('_P') +else: + try: + from typing_extensions import Concatenate as Concatenate + from typing_extensions import ParamSpec as ParamSpec + + _P = ParamSpec('_P') + except ImportError: # pragma: nocover + _P = TypeVar('_P') # type: ignore[assignment] + # NOTE(vytas): Mypy still struggles to handle a conditional import in the EAFP # fashion, so we branch on Py version instead (which it does understand). if sys.version_info >= (3, 11): @@ -60,9 +74,16 @@ class _Unset(Enum): _T = TypeVar('_T') +_R_co = TypeVar('_R_co', covariant=True) _UNSET = _Unset.UNSET UnsetOr = Union[Literal[_Unset.UNSET], _T] + +class _LruCacheWrapper(Protocol[_P, _R_co]): + def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R_co: ... + def cache_clear(self) -> None: ... + + # NOTE(vytas,jap): TypeVar's "default" argument is only available on 3.13+. if sys.version_info >= (3, 13): _ExcT = TypeVar('_ExcT', bound=Exception, default=Exception) diff --git a/falcon/http_error.py b/falcon/http_error.py index 140a64d96..07e999715 100644 --- a/falcon/http_error.py +++ b/falcon/http_error.py @@ -161,8 +161,7 @@ def status_code(self) -> int: """HTTP status code normalized from the ``status`` argument passed to the initializer. """ # noqa: D205 - # TODO(0xMattB): Modify decorator to return proper type (see gh #2629). - return misc.http_status_to_code(self.status) # type: ignore[no-any-return] + return misc.http_status_to_code(self.status) def to_dict( self, obj_type: type[MutableMapping[str, str | int | None | Link]] = dict diff --git a/falcon/http_status.py b/falcon/http_status.py index 82e2b6dfe..de32b25e8 100644 --- a/falcon/http_status.py +++ b/falcon/http_status.py @@ -67,5 +67,4 @@ def __init__( @property def status_code(self) -> int: """HTTP status code normalized from :attr:`status`.""" - # TODO(0xMattB): Modify decorator to return proper type (see PR #2629). - return http_status_to_code(self.status) # type: ignore[no-any-return] + return http_status_to_code(self.status) diff --git a/falcon/media/handlers.py b/falcon/media/handlers.py index 85081863e..51bc3bf12 100644 --- a/falcon/media/handlers.py +++ b/falcon/media/handlers.py @@ -73,6 +73,8 @@ def __call__( self, media_type: str | None, default: str, raise_not_found: bool = True ) -> tuple[None, None, None] | _ResolverMethodReturnTuple: ... + def cache_clear(self) -> None: ... + class Handlers(UserDict[str, BaseHandler]): """A :class:`dict`-like object that manages Internet media type handlers.""" @@ -98,14 +100,14 @@ def __setitem__(self, key: str, value: BaseHandler) -> None: # NOTE(kgriffs): When the mapping changes, we do not want to use a # cached handler from the previous mapping, in case it was # replaced. - self._resolve.cache_clear() # type: ignore[attr-defined] + self._resolve.cache_clear() def __delitem__(self, key: str) -> None: super().__delitem__(key) # NOTE(kgriffs): Similar to __setitem__(), we need to avoid resolving # to a cached handler that was removed. - self._resolve.cache_clear() # type: ignore[attr-defined] + self._resolve.cache_clear() def _create_resolver(self) -> ResolverMethod: # PERF(kgriffs): Under PyPy the LRU is relatively expensive as compared diff --git a/falcon/response.py b/falcon/response.py index edfd304bc..0c3d02c12 100644 --- a/falcon/response.py +++ b/falcon/response.py @@ -199,8 +199,7 @@ def status_code(self) -> int: if resp.status_code >= 400: log.warning(f'returning error response: {resp.status_code}') """ - # TODO(0xMattB): Modify decorator to return proper type (see gh #2629). - return http_status_to_code(self.status) # type: ignore[no-any-return] + return http_status_to_code(self.status) @status_code.setter def status_code(self, value: int) -> None: diff --git a/falcon/util/misc.py b/falcon/util/misc.py index d6d301a6e..b05b743c2 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -33,10 +33,13 @@ import os import os.path import re -from typing import Any, Callable +from typing import Any, Callable, cast, overload, TYPE_CHECKING import unicodedata from falcon import status_codes +from falcon._typing import _LruCacheWrapper +from falcon._typing import _P +from falcon._typing import _R_co from falcon.constants import PYPY from falcon.uri import encode_value @@ -101,22 +104,50 @@ # NOTE(kgriffs,vytas): This is tested in the PyPy gate but we do not want devs # to have to install PyPy to check coverage on their workstations, so we use # the nocover pragma here. +@overload def _lru_cache_nop( - maxsize: int, -) -> Callable[[Callable[..., Any]], Callable[..., Any]]: # pragma: nocover - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + maxsize: Callable[_P, _R_co], +) -> _LruCacheWrapper[_P, _R_co]: ... # pragma: nocover + + +@overload +def _lru_cache_nop( + maxsize: int | None = 128, typed: bool = False +) -> Callable[ + [Callable[_P, _R_co]], _LruCacheWrapper[_P, _R_co] +]: ... # pragma: nocover + + +def _lru_cache_nop(maxsize: Any = 128, typed: bool = False) -> Any: # pragma: nocover + def decorator(func: Callable[_P, _R_co]) -> _LruCacheWrapper[_P, _R_co]: # NOTE(kgriffs): Partially emulate the lru_cache protocol; only add # cache_info() later if/when it becomes necessary. - func.cache_clear = lambda: None # type: ignore + func.cache_clear = lambda: None # type: ignore[attr-defined] - return func + return cast(_LruCacheWrapper[_P, _R_co], func) + + if callable(maxsize): + return decorator(maxsize) return decorator # PERF(kgriffs): Using lru_cache is slower on PyPy when the wrapped # function is just doing a few non-IO operations. -if PYPY: +if TYPE_CHECKING: + + @overload + def _lru_cache_for_simple_logic( + maxsize: Callable[_P, _R_co], + ) -> _LruCacheWrapper[_P, _R_co]: ... + + @overload + def _lru_cache_for_simple_logic( + maxsize: int | None = 128, typed: bool = False + ) -> Callable[[Callable[_P, _R_co]], _LruCacheWrapper[_P, _R_co]]: ... + + def _lru_cache_for_simple_logic(maxsize: Any = 128, typed: bool = False) -> Any: ... +elif PYPY: _lru_cache_for_simple_logic = _lru_cache_nop # pragma: nocover else: _lru_cache_for_simple_logic = functools.lru_cache diff --git a/tests/test_typing.py b/tests/test_typing.py index 436a7dfe7..4053f65ed 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -6,7 +6,9 @@ import falcon import falcon.asgi +import falcon.media import falcon.testing +import falcon.util.misc @dataclass @@ -220,3 +222,32 @@ def _exercise_app(app: falcon.App[Any, Any]) -> None: 'fancy': True, 'title': '404 Not Found', } + + +def test_lru_cache_typing() -> None: + @falcon.util.misc._lru_cache_for_simple_logic(maxsize=16) + def add(a: int, b: int) -> int: + return a + b + + @falcon.util.misc._lru_cache_nop(maxsize=16) + def concat(a: str, b: str) -> str: + return a + b + + res_add: int = add(1, 2) + assert res_add == 3 + add.cache_clear() + + res_concat: str = concat('x', 'y') + assert res_concat == 'xy' + concat.cache_clear() + + code: int = falcon.http_status_to_code(falcon.HTTP_200) + assert code == 200 + falcon.http_status_to_code.cache_clear() + + status: str = falcon.code_to_http_status(200) + assert status == falcon.HTTP_200 + falcon.code_to_http_status.cache_clear() + + handlers = falcon.media.Handlers() + handlers._resolve.cache_clear() From b203c97739078ce1a6d5f71a4f62caef5d20a777 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Fri, 14 Aug 2026 16:51:58 +0000 Subject: [PATCH 2/4] fix(testing): raise a clear error if the ASGI app emits no response status The stricter _LruCacheWrapper typing surfaced that ASGIResponseEventCollector.status may still be None at this point; guard it explicitly as done in the lifespan/conductor path. Signed-off-by: Alex Chen --- falcon/testing/client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/falcon/testing/client.py b/falcon/testing/client.py index 3259be5f8..d50d6dab9 100644 --- a/falcon/testing/client.py +++ b/falcon/testing/client.py @@ -962,6 +962,12 @@ async def _simulate_request_asgi( req_event_emitter.disconnect() await task_req + + if resp_event_collector.status is None: + # NOTE(AlexChen): The app is expected to emit `http.response.start` + # prior to completing the request. + raise ConnectionError('The app did not return a response status.') + return Result( resp_event_collector.body_chunks, code_to_http_status(resp_event_collector.status), From 3c3b3949b6d483f973b1ac823dc4f5854c5ded46 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Fri, 14 Aug 2026 17:07:21 +0000 Subject: [PATCH 3/4] test(asgi): cover the missing-response-status guard in TestClient Signed-off-by: Alex Chen --- tests/asgi/test_testing_asgi.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/asgi/test_testing_asgi.py b/tests/asgi/test_testing_asgi.py index 67db94cc2..981fb060a 100644 --- a/tests/asgi/test_testing_asgi.py +++ b/tests/asgi/test_testing_asgi.py @@ -155,6 +155,28 @@ def test_immediate_disconnect(): client.simulate_get('/', asgi_disconnect_ttl=0) +async def test_app_returns_no_response_status(): + from falcon.asgi_spec import ScopeType + + async def silent_app(scope, receive, send): + if scope['type'] == ScopeType.LIFESPAN: + while True: + event = await receive() + if event['type'] == 'lifespan.startup': + await send({'type': 'lifespan.startup.complete'}) + elif event['type'] == 'lifespan.shutdown': + await send({'type': 'lifespan.shutdown.complete'}) + return + + return + + conductor = testing.ASGIConductor(silent_app) + + async with conductor: + with pytest.raises(ConnectionError): + await conductor.simulate_get('/') + + @pytest.mark.parametrize( 'path, expected', [ From a0a78e613bf08d44ec450658bf0b7310b803848f Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sun, 16 Aug 2026 09:02:32 +0000 Subject: [PATCH 4/4] fix(testing): use RuntimeError for missing ASGI response status Also remove the newsfragment since this is an internal typing improvement not directly facing the framework user. Signed-off-by: Alex Chen --- docs/_newsfragments/2629.newandimproved.rst | 3 --- falcon/testing/client.py | 2 +- tests/asgi/test_testing_asgi.py | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) delete mode 100644 docs/_newsfragments/2629.newandimproved.rst diff --git a/docs/_newsfragments/2629.newandimproved.rst b/docs/_newsfragments/2629.newandimproved.rst deleted file mode 100644 index ad0116488..000000000 --- a/docs/_newsfragments/2629.newandimproved.rst +++ /dev/null @@ -1,3 +0,0 @@ -Internal typing for LRU cache decorators now uses :class:`typing.ParamSpec` -and a typed wrapper protocol, preserving function argument signatures, return -types, and the ``cache_clear()`` method. diff --git a/falcon/testing/client.py b/falcon/testing/client.py index d50d6dab9..f5dba71d7 100644 --- a/falcon/testing/client.py +++ b/falcon/testing/client.py @@ -966,7 +966,7 @@ async def _simulate_request_asgi( if resp_event_collector.status is None: # NOTE(AlexChen): The app is expected to emit `http.response.start` # prior to completing the request. - raise ConnectionError('The app did not return a response status.') + raise RuntimeError('The app did not return a response status.') return Result( resp_event_collector.body_chunks, diff --git a/tests/asgi/test_testing_asgi.py b/tests/asgi/test_testing_asgi.py index 981fb060a..478dae1c2 100644 --- a/tests/asgi/test_testing_asgi.py +++ b/tests/asgi/test_testing_asgi.py @@ -173,7 +173,7 @@ async def silent_app(scope, receive, send): conductor = testing.ASGIConductor(silent_app) async with conductor: - with pytest.raises(ConnectionError): + with pytest.raises(RuntimeError): await conductor.simulate_get('/')