From 710250d7959901aa7fff94a407f4751a7f594ec9 Mon Sep 17 00:00:00 2001 From: Paul Kagiri Date: Wed, 6 May 2026 12:13:18 +0300 Subject: [PATCH] fix(util): preserve lru cache decorator types --- docs/_newsfragments/2629.newandimproved.rst | 2 ++ falcon/http_error.py | 3 +- falcon/http_status.py | 3 +- falcon/media/handlers.py | 6 ++-- falcon/response.py | 3 +- falcon/testing/client.py | 8 +++-- falcon/util/misc.py | 33 +++++++++++++++++---- 7 files changed, 42 insertions(+), 16 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..deb32165f --- /dev/null +++ b/docs/_newsfragments/2629.newandimproved.rst @@ -0,0 +1,2 @@ +Internal LRU cache decorators now preserve wrapped callable signatures for +static type checking. 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..2f321cdf6 100644 --- a/falcon/media/handlers.py +++ b/falcon/media/handlers.py @@ -56,6 +56,8 @@ def _raise(self, *args: Any, **kwargs: Any) -> NoReturn: class ResolverMethod(Protocol): + def cache_clear(self) -> None: ... + @overload def __call__( self, media_type: str | None, default: str, raise_not_found: Literal[False] @@ -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 314a1aac0..2e21a5555 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/testing/client.py b/falcon/testing/client.py index 55212d814..da21ad974 100644 --- a/falcon/testing/client.py +++ b/falcon/testing/client.py @@ -953,9 +953,11 @@ async def _simulate_request_asgi( while not resp_event_collector.status: await asyncio.sleep(0) + status = resp_event_collector.status + assert status is not None return StreamedResult( resp_event_collector.body_chunks, - code_to_http_status(resp_event_collector.status), + code_to_http_status(status), resp_event_collector.headers, task_req, req_event_emitter, @@ -963,9 +965,11 @@ async def _simulate_request_asgi( req_event_emitter.disconnect() await task_req + final_status = resp_event_collector.status + assert final_status is not None return Result( resp_event_collector.body_chunks, - code_to_http_status(resp_event_collector.status), + code_to_http_status(final_status), resp_event_collector.headers, ) diff --git a/falcon/util/misc.py b/falcon/util/misc.py index d6d301a6e..15af9cfae 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -33,7 +33,7 @@ import os import os.path import re -from typing import Any, Callable +from typing import Any, Callable, cast, TYPE_CHECKING import unicodedata from falcon import status_codes @@ -44,6 +44,23 @@ # public Falcon interface. from .deprecation import deprecated +if TYPE_CHECKING: + from typing import ParamSpec, Protocol, TypeVar + + _P = ParamSpec('_P') + _R_co = TypeVar('_R_co', covariant=True) + + class _CallableWithCacheClear(Protocol[_P, _R_co]): + cache_clear: Callable[[], None] + + def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R_co: ... + + class _LruCacheFactory(Protocol): + def __call__( + self, maxsize: int + ) -> Callable[[Callable[_P, _R_co]], _CallableWithCacheClear[_P, _R_co]]: ... + + try: from falcon.cyutil.misc import encode_items_to_latin1 as _cy_encode_items_to_latin1 except ImportError: @@ -103,23 +120,27 @@ # the nocover pragma here. def _lru_cache_nop( maxsize: int, -) -> Callable[[Callable[..., Any]], Callable[..., Any]]: # pragma: nocover - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: +) -> Callable[ + [Callable[_P, _R_co]], _CallableWithCacheClear[_P, _R_co] +]: # pragma: nocover + def decorator(func: Callable[_P, _R_co]) -> _CallableWithCacheClear[_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 + cached_func = cast('_CallableWithCacheClear[_P, _R_co]', func) + cached_func.cache_clear = lambda: None - return func + return cached_func return decorator # PERF(kgriffs): Using lru_cache is slower on PyPy when the wrapped # function is just doing a few non-IO operations. +_lru_cache_for_simple_logic: _LruCacheFactory if PYPY: _lru_cache_for_simple_logic = _lru_cache_nop # pragma: nocover else: - _lru_cache_for_simple_logic = functools.lru_cache + _lru_cache_for_simple_logic = cast('_LruCacheFactory', functools.lru_cache) def is_python_func(func: Callable[..., Any] | Any) -> bool: