From c9eb608a524854da9736b29246be55f1badbd6c1 Mon Sep 17 00:00:00 2001 From: jkmnt Date: Tue, 15 Oct 2024 19:40:58 +0300 Subject: [PATCH 01/12] small changes --- falcon/__init__.py | 10 ++++------ falcon/_typing.py | 4 +++- falcon/app_helpers.py | 21 +++++++++++++++------ falcon/hooks.py | 8 ++++---- falcon/inspect.py | 2 +- falcon/testing/helpers.py | 10 ++++------ 6 files changed, 31 insertions(+), 24 deletions(-) diff --git a/falcon/__init__.py b/falcon/__init__.py index 55ea9cde4..67189d73d 100644 --- a/falcon/__init__.py +++ b/falcon/__init__.py @@ -23,8 +23,6 @@ app = falcon.App() """ -import logging as _logging - __all__ = ( # API interface 'API', @@ -639,10 +637,10 @@ from falcon.util import wrap_sync_to_async from falcon.util import wrap_sync_to_async_unsafe +# NOTE(jkmnt): Moved logger to leaf module to avoid possible circular imports. +# the _logging symbol is reexported too - maybe it was used by test or smth. +from falcon.logger import _logger, logging as _logging + # Package version from falcon.version import __version__ # NOQA: F401 -# NOTE(kgriffs): Only to be used internally on the rare occasion that we -# need to log something that we can't communicate any other way. -_logger = _logging.getLogger('falcon') -_logger.addHandler(_logging.NullHandler()) diff --git a/falcon/_typing.py b/falcon/_typing.py index d82a5bac5..7c6b0b498 100644 --- a/falcon/_typing.py +++ b/falcon/_typing.py @@ -164,6 +164,9 @@ async def __call__( AsgiProcessResponseMethod = Callable[ ['AsgiRequest', 'AsgiResponse', Resource, bool], Awaitable[None] ] +AsgiProcessStartupMethod = Callable[[Dict[str, Any], 'AsgiEvent'], Awaitable[None]] +AsgiProcessShutdownMethod = Callable[[Dict[str, Any], 'AsgiEvent'], Awaitable[None]] + AsgiProcessRequestWsMethod = Callable[['AsgiRequest', 'WebSocket'], Awaitable[None]] AsgiProcessResourceWsMethod = Callable[ ['AsgiRequest', 'WebSocket', Resource, Dict[str, Any]], Awaitable[None] @@ -173,7 +176,6 @@ async def __call__( Tuple[Callable[[], Awaitable[None]], Literal[True]], ] - # Routing MethodDict = Union[ diff --git a/falcon/app_helpers.py b/falcon/app_helpers.py index 1248d280b..5b09fa893 100644 --- a/falcon/app_helpers.py +++ b/falcon/app_helpers.py @@ -17,7 +17,7 @@ from __future__ import annotations from inspect import iscoroutinefunction -from typing import IO, Iterable, List, Literal, Optional, overload, Tuple, Union +from typing import Any, Awaitable, Callable, Iterable, List, Literal, Optional, overload, Tuple, Union from falcon import util from falcon._typing import AsgiProcessRequestMethod as APRequest @@ -34,6 +34,7 @@ from falcon.errors import HTTPError from falcon.request import Request from falcon.response import Response +from falcon.typing import ReadableIO from falcon.util.sync import _wrap_non_coroutine_unsafe __all__ = ( @@ -367,7 +368,7 @@ class CloseableStreamIterator: block_size (int): Number of bytes to read per iteration. """ - def __init__(self, stream: IO[bytes], block_size: int) -> None: + def __init__(self, stream: ReadableIO, block_size: int) -> None: self._stream = stream self._block_size = block_size @@ -383,7 +384,15 @@ def __next__(self) -> bytes: return data def close(self) -> None: - try: - self._stream.close() - except (AttributeError, TypeError): - pass + close_maybe(self._stream) + + +def close_maybe(stream: Any): + close: Callable[[], None] | None = getattr(stream, 'close', None) + if close: + close() + +async def async_close_maybe(stream: Any): + close: Callable[[], Awaitable[None]] | None = getattr(stream, 'close', None) + if close: + await close() \ No newline at end of file diff --git a/falcon/hooks.py b/falcon/hooks.py index dc2afbd0f..116b65e84 100644 --- a/falcon/hooks.py +++ b/falcon/hooks.py @@ -233,7 +233,7 @@ def _wrap_with_after( async_responder = cast('AsgiResponderMethod', responder) @wraps(async_responder) - async def do_after( + async def do_after_async( self: Resource, req: asgi.Request, resp: asgi.Response, @@ -246,7 +246,7 @@ async def do_after( await async_responder(self, req, resp, **kwargs) await async_action(req, resp, self, *action_args, **action_kwargs) - do_after_responder = cast('AsgiResponderMethod', do_after) + do_after_responder = cast('AsgiResponderMethod', do_after_async) else: sync_action = cast('SyncAfterFn', action) sync_responder = cast('ResponderMethod', responder) @@ -294,7 +294,7 @@ def _wrap_with_before( async_responder = cast('AsgiResponderMethod', responder) @wraps(async_responder) - async def do_before( + async def do_before_async( self: Resource, req: asgi.Request, resp: asgi.Response, @@ -307,7 +307,7 @@ async def do_before( await async_action(req, resp, self, kwargs, *action_args, **action_kwargs) await async_responder(self, req, resp, **kwargs) - do_before_responder = cast('AsgiResponderMethod', do_before) + do_before_responder = cast('AsgiResponderMethod', do_before_async) else: sync_action = cast('SyncBeforeFn', action) sync_responder = cast('ResponderMethod', responder) diff --git a/falcon/inspect.py b/falcon/inspect.py index 00b840d68..06dbd3185 100644 --- a/falcon/inspect.py +++ b/falcon/inspect.py @@ -248,7 +248,7 @@ def _traverse(roots: List[CompiledRouterNode], parent: str) -> None: 'info will always be a string' ) method_info = RouteMethodInfo( - method, source_info, real_func.__name__, internal + method, source_info, getattr(real_func, '__name__', '?'), internal ) methods.append(method_info) source_info, class_name = _get_source_info_and_name(root.resource) diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index 97392d57a..c8d192f8f 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -69,6 +69,7 @@ from falcon.util import code_to_http_status from falcon.util import uri from falcon.util.mediatypes import parse_header +from falcon.app_helpers import close_maybe # NOTE(kgriffs): Changed in 3.0 from 'curl/7.24.0 (x86_64-apple-darwin12.0)' DEFAULT_UA = 'falcon-client/' + falcon.__version__ @@ -162,15 +163,13 @@ def __init__( elif not isinstance(body, bytes): body = body.encode() - body = memoryview(body) - if disconnect_at is None: disconnect_at = time.time() + 30 if chunk_size is None: chunk_size = 4096 - self._body: Optional[memoryview] = body + self._body = memoryview(body) self._chunk_size = chunk_size self._emit_empty_chunks = True self._disconnect_at = disconnect_at @@ -1411,8 +1410,7 @@ def wrapper() -> Iterator[bytes]: for item in iterable: yield item finally: - if hasattr(iterable, 'close'): - iterable.close() + close_maybe(iterable) wrapped = wrapper() head: Tuple[bytes, ...] @@ -1528,7 +1526,7 @@ def _fixup_http_version(http_version: str) -> str: def _make_cookie_values(cookies: CookieArg) -> str: return '; '.join( [ - '{}={}'.format(key, cookie.value if hasattr(cookie, 'value') else cookie) + '{}={}'.format(key, getattr(cookie, 'value', cookie)) for key, cookie in cookies.items() ] ) From 52606467c3942a46162a82b65a9f0c2c2db1e341 Mon Sep 17 00:00:00 2001 From: jkmnt Date: Tue, 15 Oct 2024 19:43:07 +0300 Subject: [PATCH 02/12] few errors less --- falcon/asgi/app.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py index f3b637802..f9cd1f271 100644 --- a/falcon/asgi/app.py +++ b/falcon/asgi/app.py @@ -60,6 +60,7 @@ from falcon.errors import CompatibilityError from falcon.errors import HTTPBadRequest from falcon.errors import WebSocketDisconnected +from falcon.errors import HTTPInternalServerError from falcon.http_error import HTTPError from falcon.http_status import HTTPStatus from falcon.media.multipart import MultipartFormHandler @@ -68,6 +69,7 @@ from falcon.util.sync import _should_wrap_non_coroutines from falcon.util.sync import _wrap_non_coroutine_unsafe from falcon.util.sync import wrap_sync_to_async +from falcon.logger import _logger from ._asgi_helpers import _validate_asgi_scope from ._asgi_helpers import _wrap_asgi_coroutine_func @@ -1185,7 +1187,7 @@ async def _http_status_handler( # type: ignore[override] self._compose_status_response(req, resp, status) elif ws: code = http_status_to_ws_code(status.status_code) - falcon._logger.error( + _logger.error( '[FALCON] HTTPStatus %s raised while handling WebSocket. ' 'Closing with code %s', status, @@ -1207,7 +1209,7 @@ async def _http_error_handler( # type: ignore[override] self._compose_error_response(req, resp, error) elif ws: code = http_status_to_ws_code(error.status_code) - falcon._logger.error( + _logger.error( '[FALCON] HTTPError %s raised while handling WebSocket. ' 'Closing with code %s', error, @@ -1225,10 +1227,10 @@ async def _python_error_handler( # type: ignore[override] params: Dict[str, Any], ws: Optional[WebSocket] = None, ) -> None: - falcon._logger.error('[FALCON] Unhandled exception in ASGI app', exc_info=error) + _logger.error('[FALCON] Unhandled exception in ASGI app', exc_info=error) if resp: - self._compose_error_response(req, resp, falcon.HTTPInternalServerError()) + self._compose_error_response(req, resp, HTTPInternalServerError()) elif ws: await self._ws_cleanup_on_error(ws) else: @@ -1244,7 +1246,7 @@ async def _ws_disconnected_error_handler( ) -> None: assert resp is None assert ws is not None - falcon._logger.debug( + _logger.debug( '[FALCON] WebSocket client disconnected with code %i', error.code ) await self._ws_cleanup_on_error(ws) @@ -1323,7 +1325,7 @@ async def _ws_cleanup_on_error(self, ws: WebSocket) -> None: if 'invalid close code' in str(ex).lower(): await ws.close(_FALLBACK_WS_ERROR_CODE) else: - falcon._logger.warning( + _logger.warning( ( '[FALCON] Attempt to close web connection cleanly ' 'failed due to raised error.' From f992c0c94d03d8cec03bec4c20e1d693556d114a Mon Sep 17 00:00:00 2001 From: jkmnt Date: Tue, 15 Oct 2024 19:53:02 +0300 Subject: [PATCH 03/12] - another few errors gone --- falcon/asgi/app.py | 19 +++++++++++-------- falcon/asgi/ws.py | 2 +- falcon/routing/compiled.py | 4 +++- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py index f9cd1f271..4dade057f 100644 --- a/falcon/asgi/app.py +++ b/falcon/asgi/app.py @@ -42,6 +42,8 @@ from falcon import routing from falcon._typing import _UNSET from falcon._typing import AsgiErrorHandler +from falcon._typing import AsgiProcessStartupMethod +from falcon._typing import AsgiProcessShutdownMethod from falcon._typing import AsgiReceive from falcon._typing import AsgiResponderCallable from falcon._typing import AsgiResponderWsCallable @@ -53,6 +55,7 @@ from falcon.app_helpers import AsyncPreparedMiddlewareWsResult from falcon.app_helpers import prepare_middleware from falcon.app_helpers import prepare_middleware_ws +from falcon.app_helpers import async_close_maybe from falcon.asgi_spec import AsgiSendMsg from falcon.asgi_spec import EventType from falcon.asgi_spec import WSCloseCode @@ -787,8 +790,7 @@ async def watch_disconnect() -> None: } ) finally: - if hasattr(stream, 'close'): - await stream.close() + await async_close_maybe(stream) else: # NOTE(kgriffs): Works for both async generators and iterators try: @@ -827,8 +829,7 @@ async def watch_disconnect() -> None: # NOTE(vytas): This could be DRYed with the above identical # twoliner in a one large block, but OTOH we would be # unable to reuse the current try.. except. - if hasattr(stream, 'close'): - await stream.close() + await async_close_maybe(stream) await send(_EVT_RESP_EOF) @@ -1077,9 +1078,10 @@ async def _call_lifespan_handlers( return for handler in self._unprepared_middleware: - if hasattr(handler, 'process_startup'): + process_startup: AsgiProcessStartupMethod | None = getattr(handler, 'process_startup', None) + if process_startup: try: - await handler.process_startup(scope, event) + await process_startup(scope, event) except Exception: await send( { @@ -1093,9 +1095,10 @@ async def _call_lifespan_handlers( elif event['type'] == 'lifespan.shutdown': for handler in reversed(self._unprepared_middleware): - if hasattr(handler, 'process_shutdown'): + process_shutdown = getattr(handler, 'process_shutdown', None) + if process_shutdown: try: - await handler.process_shutdown(scope, event) + await process_shutdown(scope, event) except Exception: await send( { diff --git a/falcon/asgi/ws.py b/falcon/asgi/ws.py index 03800682f..372a3f5b8 100644 --- a/falcon/asgi/ws.py +++ b/falcon/asgi/ws.py @@ -628,7 +628,7 @@ class WebSocketOptions: @classmethod def _init_default_close_reasons(cls) -> Dict[int, str]: - reasons = dict(cls._STANDARD_CLOSE_REASONS) + reasons: dict[int, str] = dict(cls._STANDARD_CLOSE_REASONS) for status_constant in dir(status_codes): if 'HTTP_100' <= status_constant < 'HTTP_599': status_line = getattr(status_codes, status_constant) diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py index 836288780..961233d81 100644 --- a/falcon/routing/compiled.py +++ b/falcon/routing/compiled.py @@ -570,6 +570,8 @@ def _generate_ast( # noqa: C901 # return the relevant information. resource_idx = len(return_values) return_values.append(node) + else: + resource_idx = None assert not (consume_multiple_segments and node.children) @@ -583,7 +585,7 @@ def _generate_ast( # noqa: C901 fast_return, ) - if node.resource is None: + if resource_idx is None: if fast_return: parent.append_child(_CxReturnNone()) else: From 885cb16ed9505211133883cc63cac6a7343b249a Mon Sep 17 00:00:00 2001 From: jkmnt Date: Tue, 15 Oct 2024 23:05:30 +0300 Subject: [PATCH 04/12] no errors in basic mode --- falcon/_typing.py | 5 ++- falcon/app.py | 13 ++++-- falcon/app_helpers.py | 6 +-- falcon/asgi/app.py | 6 ++- falcon/asgi/ws.py | 9 +--- falcon/errors.py | 84 +++++++++++++++++++------------------- falcon/media/base.py | 4 +- falcon/response.py | 11 +++-- falcon/response_helpers.py | 8 ++++ falcon/testing/helpers.py | 2 +- falcon/util/__init__.py | 2 +- falcon/util/misc.py | 2 +- 12 files changed, 83 insertions(+), 69 deletions(-) diff --git a/falcon/_typing.py b/falcon/_typing.py index 7c6b0b498..4e6e12dc7 100644 --- a/falcon/_typing.py +++ b/falcon/_typing.py @@ -104,6 +104,9 @@ async def __call__( HeaderMapping = Mapping[str, str] HeaderIter = Iterable[Tuple[str, str]] HeaderArg = Union[HeaderMapping, HeaderIter] + +NarrowHeaderArg = Union[Dict[str,str], List[Tuple[str, str]]] + ResponseStatus = Union[http.HTTPStatus, str, int] StoreArg = Optional[Dict[str, Any]] Resource = object @@ -192,7 +195,7 @@ def __call__( # Media class SerializeSync(Protocol): - def __call__(self, media: Any, content_type: Optional[str] = ...) -> bytes: ... + def __call__(self, media: object, content_type: Optional[str] = ...) -> bytes: ... DeserializeSync = Callable[[bytes], Any] diff --git a/falcon/app.py b/falcon/app.py index f74083693..59253014a 100644 --- a/falcon/app.py +++ b/falcon/app.py @@ -1237,6 +1237,13 @@ def _get_body( # NOTE(kgriffs): Heuristic to quickly check if stream is # file-like. Not perfect, but should be good enough until # proven otherwise. + # + # TODO(jkmnt): The checks like these are a perfect candidates for the Python 3.13 TypeIs guard. + # The TypeGuard of Python 3.10+ seems to fit too, though it narrows type only for the 'if' branch. + # Something like: + # def is_readable_io(stream) -> TypeIs[ReadableStream]: + # return hasattr(stream, 'read') + # if hasattr(stream, 'read'): if wsgi_file_wrapper is not None: # TODO(kgriffs): Make block size configurable at the @@ -1251,7 +1258,7 @@ def _get_body( self._STREAM_BLOCK_SIZE, ) else: - iterable = stream + iterable = cast(Iterable[bytes], stream) return iterable, None @@ -1259,9 +1266,9 @@ def _get_body( def _update_sink_and_static_routes(self) -> None: if self._sink_before_static_route: - self._sink_and_static_routes = tuple(self._sinks + self._static_routes) # type: ignore[operator] + self._sink_and_static_routes = (*self._sinks, *self._static_routes) else: - self._sink_and_static_routes = tuple(self._static_routes + self._sinks) # type: ignore[operator] + self._sink_and_static_routes = (*self._static_routes, *self._sinks) # TODO(myusko): This class is a compatibility alias, and should be removed diff --git a/falcon/app_helpers.py b/falcon/app_helpers.py index 5b09fa893..3687256f9 100644 --- a/falcon/app_helpers.py +++ b/falcon/app_helpers.py @@ -386,13 +386,13 @@ def __next__(self) -> bytes: def close(self) -> None: close_maybe(self._stream) - -def close_maybe(stream: Any): +# TODO(jkmnt): Move these to some other module, they don't belong here +def close_maybe(stream: Any) -> None: close: Callable[[], None] | None = getattr(stream, 'close', None) if close: close() -async def async_close_maybe(stream: Any): +async def async_close_maybe(stream: Any) -> None: close: Callable[[], Awaitable[None]] | None = getattr(stream, 'close', None) if close: await close() \ No newline at end of file diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py index 4dade057f..ef95218c0 100644 --- a/falcon/asgi/app.py +++ b/falcon/asgi/app.py @@ -773,10 +773,11 @@ async def watch_disconnect() -> None: # (c) async iterator # - if hasattr(stream, 'read'): + read_meth: Callable[[int], Awaitable[bytes]] | None = getattr(stream, 'read') + if read_meth: try: while True: - data = await stream.read(self._STREAM_BLOCK_SIZE) + data = await read_meth(self._STREAM_BLOCK_SIZE) if data == b'': break else: @@ -1006,6 +1007,7 @@ async def handle(req, resp, ex, params): 'The handler must be an awaitable coroutine function in order ' 'to be used safely with an ASGI app.' ) + assert handler handler_callable: AsgiErrorHandler = handler exception_tuple: Tuple[type[BaseException], ...] diff --git a/falcon/asgi/ws.py b/falcon/asgi/ws.py index 372a3f5b8..988c71d3d 100644 --- a/falcon/asgi/ws.py +++ b/falcon/asgi/ws.py @@ -19,6 +19,7 @@ from falcon.asgi_spec import WSCloseCode from falcon.constants import WebSocketPayloadType from falcon.util import misc +from falcon.response_helpers import _headers_to_items __all__ = ('WebSocket',) @@ -210,15 +211,9 @@ async def accept( 'does not support accept headers.' ) - header_items = getattr(headers, 'items', None) - if callable(header_items): - headers_iterable: Iterable[tuple[str, str]] = header_items() - else: - headers_iterable = headers # type: ignore[assignment] - event['headers'] = parsed_headers = [ (name.lower().encode('ascii'), value.encode('ascii')) - for name, value in headers_iterable + for name, value in _headers_to_items(headers) ] for name, __ in parsed_headers: diff --git a/falcon/errors.py b/falcon/errors.py index f47e82b07..d31450bd4 100644 --- a/falcon/errors.py +++ b/falcon/errors.py @@ -45,7 +45,7 @@ def on_get(self, req, resp): from falcon.util.misc import dt_to_http if TYPE_CHECKING: - from falcon._typing import HeaderArg + from falcon._typing import NarrowHeaderArg from falcon.typing import Headers @@ -270,7 +270,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ) -> None: super().__init__( @@ -350,7 +350,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, challenges: Optional[Iterable[str]] = None, **kwargs: HTTPErrorKeywordArguments, ): @@ -425,7 +425,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -493,7 +493,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -614,7 +614,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): headers = _load_headers(headers) @@ -682,7 +682,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -752,7 +752,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -828,7 +828,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -889,7 +889,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -951,7 +951,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1026,7 +1026,7 @@ def __init__( title: Optional[str] = None, description: Optional[str] = None, retry_after: RetryAfter = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ) -> None: super().__init__( @@ -1104,7 +1104,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1166,7 +1166,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1242,7 +1242,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): headers = _load_headers(headers) @@ -1309,7 +1309,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1368,7 +1368,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1426,7 +1426,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1492,7 +1492,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1563,7 +1563,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, retry_after: RetryAfter = None, **kwargs: HTTPErrorKeywordArguments, ): @@ -1629,7 +1629,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1701,7 +1701,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1759,7 +1759,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1824,7 +1824,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1882,7 +1882,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1956,7 +1956,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, retry_after: RetryAfter = None, **kwargs: HTTPErrorKeywordArguments, ): @@ -2016,7 +2016,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -2080,7 +2080,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -2142,7 +2142,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -2201,7 +2201,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -2272,7 +2272,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -2328,7 +2328,7 @@ def __init__( msg: str, header_name: str, *, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): description = 'The value provided for the "{0}" header is invalid. {1}' @@ -2384,7 +2384,7 @@ def __init__( self, header_name: str, *, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): description = 'The "{0}" header is required.' @@ -2444,7 +2444,7 @@ def __init__( msg: str, param_name: str, *, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ) -> None: description = 'The "{0}" parameter is invalid. {1}' @@ -2502,7 +2502,7 @@ def __init__( self, param_name: str, *, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ) -> None: description = 'The "{0}" parameter is required.' @@ -2601,7 +2601,7 @@ class MediaMalformedError(HTTPBadRequest): """ def __init__( - self, media_type: str, **kwargs: Union[HeaderArg, HTTPErrorKeywordArguments] + self, media_type: str, **kwargs: Union[NarrowHeaderArg, HTTPErrorKeywordArguments] ): super().__init__( title='Invalid {0}'.format(media_type), @@ -2672,7 +2672,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[HeaderArg] = None, + headers: Optional[NarrowHeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ) -> None: super().__init__( @@ -2701,13 +2701,13 @@ class MultipartParseError(MediaMalformedError): """ # NOTE(caselit): remove the description @property in MediaMalformedError - description = None + description = None # pyright: ignore[reportAssignmentType, reportGeneralTypeIssues] def __init__( self, *, description: Optional[str] = None, - **kwargs: Union[HeaderArg, HTTPErrorKeywordArguments], + **kwargs: Union[NarrowHeaderArg, HTTPErrorKeywordArguments], ) -> None: HTTPBadRequest.__init__( self, @@ -2722,7 +2722,7 @@ def __init__( # ----------------------------------------------------------------------------- -def _load_headers(headers: Optional[HeaderArg]) -> Headers: +def _load_headers(headers: Optional[NarrowHeaderArg]) -> Headers: """Transform the headers to dict.""" if headers is None: return {} @@ -2732,9 +2732,9 @@ def _load_headers(headers: Optional[HeaderArg]) -> Headers: def _parse_retry_after( - headers: Optional[HeaderArg], + headers: Optional[NarrowHeaderArg], retry_after: RetryAfter, -) -> Optional[HeaderArg]: +) -> Optional[NarrowHeaderArg]: """Set the Retry-After to the headers when required.""" if retry_after is None: return headers diff --git a/falcon/media/base.py b/falcon/media/base.py index 0d80611f3..daa8e1040 100644 --- a/falcon/media/base.py +++ b/falcon/media/base.py @@ -28,7 +28,7 @@ class BaseHandler(metaclass=abc.ABCMeta): """Override to provide a synchronous deserialization method that takes a byte string.""" - def serialize(self, media: object, content_type: str) -> bytes: + def serialize(self, media: object, content_type: Optional[str] = None) -> bytes: """Serialize the media object on a :any:`falcon.Response`. By default, this method raises an instance of @@ -51,7 +51,7 @@ def serialize(self, media: object, content_type: str) -> bytes: Returns: bytes: The resulting serialized bytes from the input object. """ - if MEDIA_JSON in content_type: + if content_type is not None and MEDIA_JSON in content_type: raise NotImplementedError( 'The JSON media handler requires the sync interface to be ' "implemented even in ASGI applications, because it's used " diff --git a/falcon/response.py b/falcon/response.py index 209f6f0d0..b0ce42010 100644 --- a/falcon/response.py +++ b/falcon/response.py @@ -22,6 +22,7 @@ import mimetypes from typing import ( Any, + Callable, ClassVar, Dict, Iterable, @@ -29,6 +30,7 @@ Mapping, NoReturn, Optional, + cast, overload, Tuple, Type, @@ -39,6 +41,7 @@ from falcon._typing import _UNSET from falcon._typing import RangeSetHeader from falcon._typing import UnsetOr +from falcon._typing import HeaderIter from falcon.constants import _DEFAULT_STATIC_MEDIA_TYPES from falcon.constants import DEFAULT_MEDIA_TYPE from falcon.errors import HeaderNotSupported @@ -48,6 +51,7 @@ from falcon.response_helpers import _format_header_value_list from falcon.response_helpers import _format_range from falcon.response_helpers import _header_property +from falcon.response_helpers import _headers_to_items from falcon.response_helpers import _is_ascii_encodable from falcon.typing import Headers from falcon.typing import ReadableIO @@ -826,16 +830,11 @@ def set_headers( or ``Iterable[[str, str]]``. """ - header_items = getattr(headers, 'items', None) - - if callable(header_items): - headers = header_items() - # NOTE(kgriffs): We can't use dict.update because we have to # normalize the header names. _headers = self._headers - for name, value in headers: # type: ignore[misc] + for name, value in _headers_to_items(headers): # NOTE(kgriffs): uwsgi fails with a TypeError if any header # is not a str, so do the conversion here. It's actually # faster to not do an isinstance check. str() will encode diff --git a/falcon/response_helpers.py b/falcon/response_helpers.py index 71d397ec5..87f481e59 100644 --- a/falcon/response_helpers.py +++ b/falcon/response_helpers.py @@ -19,6 +19,8 @@ from typing import Any, Callable, Iterable, Optional, TYPE_CHECKING from falcon._typing import RangeSetHeader +from falcon._typing import HeaderArg +from falcon._typing import HeaderIter from falcon.util import uri from falcon.util.misc import secure_filename @@ -145,3 +147,9 @@ def _is_ascii_encodable(s: str) -> bool: # NOTE(tbug): s is probably not a string type return False return True + +def _headers_to_items(headers: HeaderArg) -> HeaderIter: + header_items: Callable[[], HeaderIter] | None = getattr(headers, 'items', None) + if callable(header_items): + return header_items() + return headers # type: ignore[return-value] \ No newline at end of file diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index c8d192f8f..df23e3391 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -169,7 +169,7 @@ def __init__( if chunk_size is None: chunk_size = 4096 - self._body = memoryview(body) + self._body: Optional[memoryview] = memoryview(body) self._chunk_size = chunk_size self._emit_empty_chunks = True self._disconnect_at = disconnect_at diff --git a/falcon/util/__init__.py b/falcon/util/__init__.py index a52a197cd..129d6c3b5 100644 --- a/falcon/util/__init__.py +++ b/falcon/util/__init__.py @@ -70,7 +70,7 @@ from falcon.util.reader import BufferedReader as _PyBufferedReader # NOQA try: - from falcon.cyutil.reader import BufferedReader as _CyBufferedReader + from falcon.cyutil.reader import BufferedReader as _CyBufferedReader # pyright: ignore[reportMissingImports] except ImportError: _CyBufferedReader = None diff --git a/falcon/util/misc.py b/falcon/util/misc.py index ae17c12a1..5dd58a8f8 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -42,7 +42,7 @@ from .deprecation import deprecated try: - from falcon.cyutil.misc import encode_items_to_latin1 as _cy_encode_items_to_latin1 + from falcon.cyutil.misc import encode_items_to_latin1 as _cy_encode_items_to_latin1 # pyright: ignore[reportMissingImports] except ImportError: _cy_encode_items_to_latin1 = None From 7483edc543cdf52b3bccfbde38c7b930bb0ba106 Mon Sep 17 00:00:00 2001 From: jkmnt Date: Wed, 16 Oct 2024 01:40:17 +0300 Subject: [PATCH 05/12] small fix after running tests --- falcon/asgi/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py index ef95218c0..2a8397df4 100644 --- a/falcon/asgi/app.py +++ b/falcon/asgi/app.py @@ -773,7 +773,7 @@ async def watch_disconnect() -> None: # (c) async iterator # - read_meth: Callable[[int], Awaitable[bytes]] | None = getattr(stream, 'read') + read_meth: Callable[[int], Awaitable[bytes]] | None = getattr(stream, 'read', None) if read_meth: try: while True: From 8db1d96ddb4bac02baf524a55220843548caf5bb Mon Sep 17 00:00:00 2001 From: jkmnt Date: Wed, 16 Oct 2024 01:45:40 +0300 Subject: [PATCH 06/12] few more changes --- falcon/media/msgpack.py | 2 +- falcon/media/multipart.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/falcon/media/msgpack.py b/falcon/media/msgpack.py index 5b8c587c9..95931811d 100644 --- a/falcon/media/msgpack.py +++ b/falcon/media/msgpack.py @@ -72,7 +72,7 @@ async def deserialize_async( ) -> Any: return self._deserialize(await stream.read()) - def serialize(self, media: Any, content_type: Optional[str]) -> bytes: + def serialize(self, media: Any, content_type: Optional[str] = None) -> bytes: return self._pack(media) async def serialize_async(self, media: Any, content_type: Optional[str]) -> bytes: diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py index 99cd8785e..cf1aba289 100644 --- a/falcon/media/multipart.py +++ b/falcon/media/multipart.py @@ -549,7 +549,7 @@ async def deserialize_async( stream, content_type, content_length, form_cls=self._ASGI_MULTIPART_FORM ) - def serialize(self, media: object, content_type: str) -> NoReturn: + def serialize(self, media: object, content_type: Optional[str] = None) -> NoReturn: raise NotImplementedError('multipart form serialization unsupported') From ce15bfcc1ada33de306d39981c7198bf86792712 Mon Sep 17 00:00:00 2001 From: jkmnt Date: Wed, 16 Oct 2024 13:30:03 +0300 Subject: [PATCH 07/12] Changed iterable of tuples in NarrowHeaderArg to sequence of tuples. Ruff run. --- falcon/__init__.py | 10 +++++----- falcon/_typing.py | 3 ++- falcon/app.py | 6 +++--- falcon/app_helpers.py | 21 +++++++++++++++++---- falcon/asgi/app.py | 24 ++++++++++++++---------- falcon/asgi/ws.py | 4 ++-- falcon/errors.py | 6 ++++-- falcon/inspect.py | 5 ++++- falcon/response.py | 3 --- falcon/response_helpers.py | 7 ++++--- falcon/routing/compiled.py | 2 +- falcon/testing/helpers.py | 2 +- falcon/util/__init__.py | 4 +++- falcon/util/misc.py | 4 +++- 14 files changed, 63 insertions(+), 38 deletions(-) diff --git a/falcon/__init__.py b/falcon/__init__.py index 67189d73d..cdc19f4dc 100644 --- a/falcon/__init__.py +++ b/falcon/__init__.py @@ -407,6 +407,11 @@ from falcon.hooks import before from falcon.http_error import HTTPError from falcon.http_status import HTTPStatus + +# NOTE(jkmnt): Moved logger to leaf module to avoid possible circular imports. +# the _logging symbol is reexported too - maybe it was used by test or smth. +from falcon.logger import _logger +from falcon.logger import logging as _logging from falcon.middleware import CORSMiddleware from falcon.redirects import HTTPFound from falcon.redirects import HTTPMovedPermanently @@ -637,10 +642,5 @@ from falcon.util import wrap_sync_to_async from falcon.util import wrap_sync_to_async_unsafe -# NOTE(jkmnt): Moved logger to leaf module to avoid possible circular imports. -# the _logging symbol is reexported too - maybe it was used by test or smth. -from falcon.logger import _logger, logging as _logging - # Package version from falcon.version import __version__ # NOQA: F401 - diff --git a/falcon/_typing.py b/falcon/_typing.py index 4e6e12dc7..946bf2c02 100644 --- a/falcon/_typing.py +++ b/falcon/_typing.py @@ -32,6 +32,7 @@ Optional, Pattern, Protocol, + Sequence, Tuple, TYPE_CHECKING, TypeVar, @@ -105,7 +106,7 @@ async def __call__( HeaderIter = Iterable[Tuple[str, str]] HeaderArg = Union[HeaderMapping, HeaderIter] -NarrowHeaderArg = Union[Dict[str,str], List[Tuple[str, str]]] +NarrowHeaderArg = Union[Mapping[str, str], Sequence[Tuple[str, str]]] ResponseStatus = Union[http.HTTPStatus, str, int] StoreArg = Optional[Dict[str, Any]] diff --git a/falcon/app.py b/falcon/app.py index 59253014a..d48243a11 100644 --- a/falcon/app.py +++ b/falcon/app.py @@ -1237,9 +1237,9 @@ def _get_body( # NOTE(kgriffs): Heuristic to quickly check if stream is # file-like. Not perfect, but should be good enough until # proven otherwise. - # - # TODO(jkmnt): The checks like these are a perfect candidates for the Python 3.13 TypeIs guard. - # The TypeGuard of Python 3.10+ seems to fit too, though it narrows type only for the 'if' branch. + # TODO(jkmnt): The checks like these are a perfect candidates for the + # Python 3.13 TypeIs guard. The TypeGuard of Python 3.10+ seems to fit too, + # though it narrows type only for the 'if' branch. # Something like: # def is_readable_io(stream) -> TypeIs[ReadableStream]: # return hasattr(stream, 'read') diff --git a/falcon/app_helpers.py b/falcon/app_helpers.py index 3687256f9..5972054ed 100644 --- a/falcon/app_helpers.py +++ b/falcon/app_helpers.py @@ -17,7 +17,18 @@ from __future__ import annotations from inspect import iscoroutinefunction -from typing import Any, Awaitable, Callable, Iterable, List, Literal, Optional, overload, Tuple, Union +from typing import ( + Any, + Awaitable, + Callable, + Iterable, + List, + Literal, + Optional, + overload, + Tuple, + Union, +) from falcon import util from falcon._typing import AsgiProcessRequestMethod as APRequest @@ -386,13 +397,15 @@ def __next__(self) -> bytes: def close(self) -> None: close_maybe(self._stream) + # TODO(jkmnt): Move these to some other module, they don't belong here def close_maybe(stream: Any) -> None: - close: Callable[[], None] | None = getattr(stream, 'close', None) + close: Optional[Callable[[], None]] = getattr(stream, 'close', None) if close: close() + async def async_close_maybe(stream: Any) -> None: - close: Callable[[], Awaitable[None]] | None = getattr(stream, 'close', None) + close: Optional[Callable[[], Awaitable[None]]] = getattr(stream, 'close', None) if close: - await close() \ No newline at end of file + await close() diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py index 2a8397df4..0613032de 100644 --- a/falcon/asgi/app.py +++ b/falcon/asgi/app.py @@ -42,8 +42,8 @@ from falcon import routing from falcon._typing import _UNSET from falcon._typing import AsgiErrorHandler -from falcon._typing import AsgiProcessStartupMethod from falcon._typing import AsgiProcessShutdownMethod +from falcon._typing import AsgiProcessStartupMethod from falcon._typing import AsgiReceive from falcon._typing import AsgiResponderCallable from falcon._typing import AsgiResponderWsCallable @@ -51,28 +51,28 @@ from falcon._typing import AsgiSinkCallable from falcon._typing import SinkPrefix import falcon.app +from falcon.app_helpers import async_close_maybe from falcon.app_helpers import AsyncPreparedMiddlewareResult from falcon.app_helpers import AsyncPreparedMiddlewareWsResult from falcon.app_helpers import prepare_middleware from falcon.app_helpers import prepare_middleware_ws -from falcon.app_helpers import async_close_maybe from falcon.asgi_spec import AsgiSendMsg from falcon.asgi_spec import EventType from falcon.asgi_spec import WSCloseCode from falcon.constants import MEDIA_JSON from falcon.errors import CompatibilityError from falcon.errors import HTTPBadRequest -from falcon.errors import WebSocketDisconnected from falcon.errors import HTTPInternalServerError +from falcon.errors import WebSocketDisconnected from falcon.http_error import HTTPError from falcon.http_status import HTTPStatus +from falcon.logger import _logger from falcon.media.multipart import MultipartFormHandler from falcon.util import get_argnames from falcon.util.misc import is_python_func from falcon.util.sync import _should_wrap_non_coroutines from falcon.util.sync import _wrap_non_coroutine_unsafe from falcon.util.sync import wrap_sync_to_async -from falcon.logger import _logger from ._asgi_helpers import _validate_asgi_scope from ._asgi_helpers import _wrap_asgi_coroutine_func @@ -773,7 +773,9 @@ async def watch_disconnect() -> None: # (c) async iterator # - read_meth: Callable[[int], Awaitable[bytes]] | None = getattr(stream, 'read', None) + read_meth: Optional[Callable[[int], Awaitable[bytes]]] = getattr( + stream, 'read', None + ) if read_meth: try: while True: @@ -1080,7 +1082,9 @@ async def _call_lifespan_handlers( return for handler in self._unprepared_middleware: - process_startup: AsgiProcessStartupMethod | None = getattr(handler, 'process_startup', None) + process_startup: Optional[AsgiProcessStartupMethod] = getattr( + handler, 'process_startup', None + ) if process_startup: try: await process_startup(scope, event) @@ -1097,7 +1101,9 @@ async def _call_lifespan_handlers( elif event['type'] == 'lifespan.shutdown': for handler in reversed(self._unprepared_middleware): - process_shutdown = getattr(handler, 'process_shutdown', None) + process_shutdown: Optional[AsgiProcessShutdownMethod] = getattr( + handler, 'process_shutdown', None + ) if process_shutdown: try: await process_shutdown(scope, event) @@ -1251,9 +1257,7 @@ async def _ws_disconnected_error_handler( ) -> None: assert resp is None assert ws is not None - _logger.debug( - '[FALCON] WebSocket client disconnected with code %i', error.code - ) + _logger.debug('[FALCON] WebSocket client disconnected with code %i', error.code) await self._ws_cleanup_on_error(ws) if TYPE_CHECKING: diff --git a/falcon/asgi/ws.py b/falcon/asgi/ws.py index 988c71d3d..6f8ba7191 100644 --- a/falcon/asgi/ws.py +++ b/falcon/asgi/ws.py @@ -5,7 +5,7 @@ from enum import auto from enum import Enum import re -from typing import Any, Deque, Dict, Iterable, Mapping, Optional, Tuple, Union +from typing import Any, Deque, Dict, Mapping, Optional, Tuple, Union from falcon import errors from falcon import media @@ -18,8 +18,8 @@ from falcon.asgi_spec import EventType from falcon.asgi_spec import WSCloseCode from falcon.constants import WebSocketPayloadType -from falcon.util import misc from falcon.response_helpers import _headers_to_items +from falcon.util import misc __all__ = ('WebSocket',) diff --git a/falcon/errors.py b/falcon/errors.py index d31450bd4..6c647ba05 100644 --- a/falcon/errors.py +++ b/falcon/errors.py @@ -2601,7 +2601,9 @@ class MediaMalformedError(HTTPBadRequest): """ def __init__( - self, media_type: str, **kwargs: Union[NarrowHeaderArg, HTTPErrorKeywordArguments] + self, + media_type: str, + **kwargs: Union[NarrowHeaderArg, HTTPErrorKeywordArguments], ): super().__init__( title='Invalid {0}'.format(media_type), @@ -2701,7 +2703,7 @@ class MultipartParseError(MediaMalformedError): """ # NOTE(caselit): remove the description @property in MediaMalformedError - description = None # pyright: ignore[reportAssignmentType, reportGeneralTypeIssues] + description = None # pyright: ignore[reportAssignmentType, reportGeneralTypeIssues] def __init__( self, diff --git a/falcon/inspect.py b/falcon/inspect.py index 06dbd3185..7cb749d0d 100644 --- a/falcon/inspect.py +++ b/falcon/inspect.py @@ -248,7 +248,10 @@ def _traverse(roots: List[CompiledRouterNode], parent: str) -> None: 'info will always be a string' ) method_info = RouteMethodInfo( - method, source_info, getattr(real_func, '__name__', '?'), internal + method, + source_info, + getattr(real_func, '__name__', '?'), + internal, ) methods.append(method_info) source_info, class_name = _get_source_info_and_name(root.resource) diff --git a/falcon/response.py b/falcon/response.py index b0ce42010..cbf92295a 100644 --- a/falcon/response.py +++ b/falcon/response.py @@ -22,7 +22,6 @@ import mimetypes from typing import ( Any, - Callable, ClassVar, Dict, Iterable, @@ -30,7 +29,6 @@ Mapping, NoReturn, Optional, - cast, overload, Tuple, Type, @@ -41,7 +39,6 @@ from falcon._typing import _UNSET from falcon._typing import RangeSetHeader from falcon._typing import UnsetOr -from falcon._typing import HeaderIter from falcon.constants import _DEFAULT_STATIC_MEDIA_TYPES from falcon.constants import DEFAULT_MEDIA_TYPE from falcon.errors import HeaderNotSupported diff --git a/falcon/response_helpers.py b/falcon/response_helpers.py index 87f481e59..1564fcc71 100644 --- a/falcon/response_helpers.py +++ b/falcon/response_helpers.py @@ -18,9 +18,9 @@ from typing import Any, Callable, Iterable, Optional, TYPE_CHECKING -from falcon._typing import RangeSetHeader from falcon._typing import HeaderArg from falcon._typing import HeaderIter +from falcon._typing import RangeSetHeader from falcon.util import uri from falcon.util.misc import secure_filename @@ -148,8 +148,9 @@ def _is_ascii_encodable(s: str) -> bool: return False return True + def _headers_to_items(headers: HeaderArg) -> HeaderIter: - header_items: Callable[[], HeaderIter] | None = getattr(headers, 'items', None) + header_items: Optional[Callable[[], HeaderIter]] = getattr(headers, 'items', None) if callable(header_items): return header_items() - return headers # type: ignore[return-value] \ No newline at end of file + return headers # type: ignore[return-value] diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py index 961233d81..06d19576b 100644 --- a/falcon/routing/compiled.py +++ b/falcon/routing/compiled.py @@ -435,7 +435,7 @@ def _generate_ast( # noqa: C901 nodes: List[CompiledRouterNode], parent: _CxParent, return_values: List[CompiledRouterNode], - patterns: List[Pattern], + patterns: List[Pattern[str]], params_stack: List[_CxElement], level: int = 0, fast_return: bool = True, diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index df23e3391..3b0ef50fe 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -59,6 +59,7 @@ from falcon._typing import CookieArg from falcon._typing import HeaderArg from falcon._typing import ResponseStatus +from falcon.app_helpers import close_maybe import falcon.asgi from falcon.asgi_spec import AsgiEvent from falcon.asgi_spec import EventType @@ -69,7 +70,6 @@ from falcon.util import code_to_http_status from falcon.util import uri from falcon.util.mediatypes import parse_header -from falcon.app_helpers import close_maybe # NOTE(kgriffs): Changed in 3.0 from 'curl/7.24.0 (x86_64-apple-darwin12.0)' DEFAULT_UA = 'falcon-client/' + falcon.__version__ diff --git a/falcon/util/__init__.py b/falcon/util/__init__.py index 129d6c3b5..b6852e36b 100644 --- a/falcon/util/__init__.py +++ b/falcon/util/__init__.py @@ -70,7 +70,9 @@ from falcon.util.reader import BufferedReader as _PyBufferedReader # NOQA try: - from falcon.cyutil.reader import BufferedReader as _CyBufferedReader # pyright: ignore[reportMissingImports] + from falcon.cyutil.reader import ( # pyright: ignore[reportMissingImports] + BufferedReader as _CyBufferedReader, + ) except ImportError: _CyBufferedReader = None diff --git a/falcon/util/misc.py b/falcon/util/misc.py index 5dd58a8f8..c42dbb2b4 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -42,7 +42,9 @@ from .deprecation import deprecated try: - from falcon.cyutil.misc import encode_items_to_latin1 as _cy_encode_items_to_latin1 # pyright: ignore[reportMissingImports] + from falcon.cyutil.misc import ( # pyright: ignore[reportMissingImports] + encode_items_to_latin1 as _cy_encode_items_to_latin1, + ) except ImportError: _cy_encode_items_to_latin1 = None From 1deda2ff6aa4ff143b17e1cb943c668b3d4f2f03 Mon Sep 17 00:00:00 2001 From: jkmnt Date: Wed, 16 Oct 2024 13:39:09 +0300 Subject: [PATCH 08/12] cosmetic --- falcon/response.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/falcon/response.py b/falcon/response.py index cbf92295a..70db069d1 100644 --- a/falcon/response.py +++ b/falcon/response.py @@ -26,7 +26,6 @@ Dict, Iterable, List, - Mapping, NoReturn, Optional, overload, @@ -37,6 +36,7 @@ ) from falcon._typing import _UNSET +from falcon._typing import HeaderArg from falcon._typing import RangeSetHeader from falcon._typing import UnsetOr from falcon.constants import _DEFAULT_STATIC_MEDIA_TYPES @@ -793,9 +793,7 @@ def append_header(self, name: str, value: str) -> None: self._headers[name] = value - def set_headers( - self, headers: Union[Mapping[str, str], Iterable[Tuple[str, str]]] - ) -> None: + def set_headers(self, headers: HeaderArg) -> None: """Set several headers at once. This method can be used to set a collection of raw header names and From b444a265ec2f1f90925ebd2a7428ad454f909bc7 Mon Sep 17 00:00:00 2001 From: Federico Caselli Date: Fri, 18 Oct 2024 15:33:58 +0200 Subject: [PATCH 09/12] types: fixed type errors reported by pyright --- falcon/__init__.py | 12 ++-- falcon/_typing.py | 8 +-- falcon/app.py | 18 +++--- falcon/app_helpers.py | 25 ++------ falcon/asgi/app.py | 45 ++++++-------- falcon/asgi/response.py | 4 +- falcon/asgi/ws.py | 2 +- falcon/errors.py | 88 +++++++++++++-------------- falcon/inspect.py | 2 +- falcon/media/base.py | 4 +- falcon/media/json.py | 6 +- falcon/media/msgpack.py | 2 +- falcon/media/multipart.py | 2 +- falcon/media/validators/jsonschema.py | 32 ++++++---- falcon/response_helpers.py | 3 +- falcon/testing/helpers.py | 4 +- falcon/testing/test_case.py | 4 +- pyproject.toml | 7 +++ 18 files changed, 131 insertions(+), 137 deletions(-) diff --git a/falcon/__init__.py b/falcon/__init__.py index cdc19f4dc..55ea9cde4 100644 --- a/falcon/__init__.py +++ b/falcon/__init__.py @@ -23,6 +23,8 @@ app = falcon.App() """ +import logging as _logging + __all__ = ( # API interface 'API', @@ -407,11 +409,6 @@ from falcon.hooks import before from falcon.http_error import HTTPError from falcon.http_status import HTTPStatus - -# NOTE(jkmnt): Moved logger to leaf module to avoid possible circular imports. -# the _logging symbol is reexported too - maybe it was used by test or smth. -from falcon.logger import _logger -from falcon.logger import logging as _logging from falcon.middleware import CORSMiddleware from falcon.redirects import HTTPFound from falcon.redirects import HTTPMovedPermanently @@ -644,3 +641,8 @@ # Package version from falcon.version import __version__ # NOQA: F401 + +# NOTE(kgriffs): Only to be used internally on the rare occasion that we +# need to log something that we can't communicate any other way. +_logger = _logging.getLogger('falcon') +_logger.addHandler(_logging.NullHandler()) diff --git a/falcon/_typing.py b/falcon/_typing.py index 946bf2c02..648bb8c8d 100644 --- a/falcon/_typing.py +++ b/falcon/_typing.py @@ -32,7 +32,6 @@ Optional, Pattern, Protocol, - Sequence, Tuple, TYPE_CHECKING, TypeVar, @@ -105,9 +104,6 @@ async def __call__( HeaderMapping = Mapping[str, str] HeaderIter = Iterable[Tuple[str, str]] HeaderArg = Union[HeaderMapping, HeaderIter] - -NarrowHeaderArg = Union[Mapping[str, str], Sequence[Tuple[str, str]]] - ResponseStatus = Union[http.HTTPStatus, str, int] StoreArg = Optional[Dict[str, Any]] Resource = object @@ -168,8 +164,8 @@ async def __call__( AsgiProcessResponseMethod = Callable[ ['AsgiRequest', 'AsgiResponse', Resource, bool], Awaitable[None] ] -AsgiProcessStartupMethod = Callable[[Dict[str, Any], 'AsgiEvent'], Awaitable[None]] -AsgiProcessShutdownMethod = Callable[[Dict[str, Any], 'AsgiEvent'], Awaitable[None]] +# AsgiProcessStartupMethod = Callable[[Dict[str, Any], 'AsgiEvent'], Awaitable[None]] +# AsgiProcessShutdownMethod = Callable[[Dict[str, Any], 'AsgiEvent'], Awaitable[None]] AsgiProcessRequestWsMethod = Callable[['AsgiRequest', 'WebSocket'], Awaitable[None]] AsgiProcessResourceWsMethod = Callable[ diff --git a/falcon/app.py b/falcon/app.py index d48243a11..42e1ef327 100644 --- a/falcon/app.py +++ b/falcon/app.py @@ -437,7 +437,7 @@ def __call__( # noqa: C901 break if not resp.complete: - responder(req, resp, **params) + responder(req, resp, **params) # pyright: ignore[reportPossiblyUnboundVariable] req_succeeded = True except Exception as ex: @@ -1071,7 +1071,7 @@ def _get_responder( if resource is not None: try: - responder = method_map[method] + responder = method_map[method] # pyright: ignore[reportPossiblyUnboundVariable] except KeyError: # NOTE(kgriffs): Dirty hack! We use __class__ here to avoid # binding self to the default responder method. We could @@ -1094,7 +1094,7 @@ def _get_responder( else: responder = self.__class__._default_responder_path_not_found - return (responder, params, resource, uri_template) + return (responder, params, resource, uri_template) # pyright: ignore[reportPossiblyUnboundVariable] def _compose_status_response( self, req: Request, resp: Response, http_status: HTTPStatus @@ -1258,17 +1258,21 @@ def _get_body( self._STREAM_BLOCK_SIZE, ) else: - iterable = cast(Iterable[bytes], stream) + iterable = stream - return iterable, None + return iterable, None # pyright: ignore[reportReturnType] return [], 0 def _update_sink_and_static_routes(self) -> None: if self._sink_before_static_route: - self._sink_and_static_routes = (*self._sinks, *self._static_routes) + self._sink_and_static_routes = tuple(self._sinks) + tuple( + self._static_routes + ) else: - self._sink_and_static_routes = (*self._static_routes, *self._sinks) + self._sink_and_static_routes = tuple(self._static_routes) + tuple( + self._sinks + ) # TODO(myusko): This class is a compatibility alias, and should be removed diff --git a/falcon/app_helpers.py b/falcon/app_helpers.py index 5972054ed..3b6af0cd9 100644 --- a/falcon/app_helpers.py +++ b/falcon/app_helpers.py @@ -18,9 +18,7 @@ from inspect import iscoroutinefunction from typing import ( - Any, - Awaitable, - Callable, + IO, Iterable, List, Literal, @@ -45,7 +43,6 @@ from falcon.errors import HTTPError from falcon.request import Request from falcon.response import Response -from falcon.typing import ReadableIO from falcon.util.sync import _wrap_non_coroutine_unsafe __all__ = ( @@ -379,7 +376,7 @@ class CloseableStreamIterator: block_size (int): Number of bytes to read per iteration. """ - def __init__(self, stream: ReadableIO, block_size: int) -> None: + def __init__(self, stream: IO[bytes], block_size: int) -> None: self._stream = stream self._block_size = block_size @@ -395,17 +392,7 @@ def __next__(self) -> bytes: return data def close(self) -> None: - close_maybe(self._stream) - - -# TODO(jkmnt): Move these to some other module, they don't belong here -def close_maybe(stream: Any) -> None: - close: Optional[Callable[[], None]] = getattr(stream, 'close', None) - if close: - close() - - -async def async_close_maybe(stream: Any) -> None: - close: Optional[Callable[[], Awaitable[None]]] = getattr(stream, 'close', None) - if close: - await close() + try: + self._stream.close() + except (AttributeError, TypeError): + pass diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py index 0613032de..a329d7f28 100644 --- a/falcon/asgi/app.py +++ b/falcon/asgi/app.py @@ -37,13 +37,12 @@ Union, ) +from falcon import _logger from falcon import constants from falcon import responders from falcon import routing from falcon._typing import _UNSET from falcon._typing import AsgiErrorHandler -from falcon._typing import AsgiProcessShutdownMethod -from falcon._typing import AsgiProcessStartupMethod from falcon._typing import AsgiReceive from falcon._typing import AsgiResponderCallable from falcon._typing import AsgiResponderWsCallable @@ -51,7 +50,6 @@ from falcon._typing import AsgiSinkCallable from falcon._typing import SinkPrefix import falcon.app -from falcon.app_helpers import async_close_maybe from falcon.app_helpers import AsyncPreparedMiddlewareResult from falcon.app_helpers import AsyncPreparedMiddlewareWsResult from falcon.app_helpers import prepare_middleware @@ -66,7 +64,6 @@ from falcon.errors import WebSocketDisconnected from falcon.http_error import HTTPError from falcon.http_status import HTTPStatus -from falcon.logger import _logger from falcon.media.multipart import MultipartFormHandler from falcon.util import get_argnames from falcon.util.misc import is_python_func @@ -343,10 +340,10 @@ async def process_resource_ws( # without having to import falcon.asgi. _ASGI: ClassVar[bool] = True - _default_responder_bad_request: ClassVar[AsgiResponderCallable] = ( + _default_responder_bad_request: ClassVar[AsgiResponderCallable] = ( # pyright: ignore[reportIncompatibleVariableOverride]# noqa: E501 responders.bad_request_async # type: ignore[assignment] ) - _default_responder_path_not_found: ClassVar[AsgiResponderCallable] = ( + _default_responder_path_not_found: ClassVar[AsgiResponderCallable] = ( # pyright: ignore[reportIncompatibleVariableOverride] # noqa: E501 responders.path_not_found_async # type: ignore[assignment] ) @@ -359,8 +356,8 @@ async def process_resource_ws( _error_handlers: Dict[Type[BaseException], AsgiErrorHandler] # type: ignore[assignment] _middleware: AsyncPreparedMiddlewareResult # type: ignore[assignment] _middleware_ws: AsyncPreparedMiddlewareWsResult - _request_type: Type[Request] - _response_type: Type[Response] + _request_type: Type[Request] # pyright: ignore[reportIncompatibleVariableOverride] + _response_type: Type[Response] # pyright: ignore[reportIncompatibleVariableOverride] ws_options: WebSocketOptions """A set of behavioral options related to WebSocket connections. @@ -526,7 +523,7 @@ async def __call__( # type: ignore[override] # noqa: C901 break if not resp.complete: - await responder(req, resp, **params) + await responder(req, resp, **params) # pyright: ignore[reportPossiblyUnboundVariable] req_succeeded = True @@ -773,13 +770,10 @@ async def watch_disconnect() -> None: # (c) async iterator # - read_meth: Optional[Callable[[int], Awaitable[bytes]]] = getattr( - stream, 'read', None - ) - if read_meth: + if hasattr(stream, 'read'): try: while True: - data = await read_meth(self._STREAM_BLOCK_SIZE) + data = await stream.read(self._STREAM_BLOCK_SIZE) # pyright: ignore[reportAttributeAccessIssue] if data == b'': break else: @@ -793,7 +787,8 @@ async def watch_disconnect() -> None: } ) finally: - await async_close_maybe(stream) + if hasattr(stream, 'close'): + await stream.close() # pyright: ignore[reportAttributeAccessIssue] else: # NOTE(kgriffs): Works for both async generators and iterators try: @@ -832,7 +827,8 @@ async def watch_disconnect() -> None: # NOTE(vytas): This could be DRYed with the above identical # twoliner in a one large block, but OTOH we would be # unable to reuse the current try.. except. - await async_close_maybe(stream) + if hasattr(stream, 'close'): + await stream.close() # pyright: ignore await send(_EVT_RESP_EOF) @@ -1009,8 +1005,7 @@ async def handle(req, resp, ex, params): 'The handler must be an awaitable coroutine function in order ' 'to be used safely with an ASGI app.' ) - assert handler - handler_callable: AsgiErrorHandler = handler + handler_callable: AsgiErrorHandler = handler # pyright: ignore[reportAssignmentType] exception_tuple: Tuple[type[BaseException], ...] try: @@ -1082,12 +1077,9 @@ async def _call_lifespan_handlers( return for handler in self._unprepared_middleware: - process_startup: Optional[AsgiProcessStartupMethod] = getattr( - handler, 'process_startup', None - ) - if process_startup: + if hasattr(handler, 'process_startup'): try: - await process_startup(scope, event) + await handler.process_startup(scope, event) # pyright: ignore[reportAttributeAccessIssue] except Exception: await send( { @@ -1101,12 +1093,9 @@ async def _call_lifespan_handlers( elif event['type'] == 'lifespan.shutdown': for handler in reversed(self._unprepared_middleware): - process_shutdown: Optional[AsgiProcessShutdownMethod] = getattr( - handler, 'process_shutdown', None - ) - if process_shutdown: + if hasattr(handler, 'process_shutdown'): try: - await process_shutdown(scope, event) + await handler.process_shutdown(scope, event) # pyright: ignore[reportAttributeAccessIssue] except Exception: await send( { diff --git a/falcon/asgi/response.py b/falcon/asgi/response.py index 1b5717444..0561c9b0c 100644 --- a/falcon/asgi/response.py +++ b/falcon/asgi/response.py @@ -146,7 +146,7 @@ async def emitter(): def sse(self, value: Optional[SSEEmitter]) -> None: self._sse = value - def set_stream( + def set_stream( # pyright: ignore[reportIncompatibleMethodOverride] self, stream: Union[AsyncReadableIO, AsyncIterator[bytes]], # type: ignore[override] content_length: int, @@ -175,7 +175,7 @@ def set_stream( Content-Length header in the response. """ - self.stream = stream + self.stream = stream # pyright: ignore[reportIncompatibleVariableOverride] # PERF(kgriffs): Set directly rather than incur the overhead of # the self.content_length property. diff --git a/falcon/asgi/ws.py b/falcon/asgi/ws.py index 6f8ba7191..709a13d2d 100644 --- a/falcon/asgi/ws.py +++ b/falcon/asgi/ws.py @@ -623,7 +623,7 @@ class WebSocketOptions: @classmethod def _init_default_close_reasons(cls) -> Dict[int, str]: - reasons: dict[int, str] = dict(cls._STANDARD_CLOSE_REASONS) + reasons = dict(cls._STANDARD_CLOSE_REASONS) for status_constant in dir(status_codes): if 'HTTP_100' <= status_constant < 'HTTP_599': status_line = getattr(status_codes, status_constant) diff --git a/falcon/errors.py b/falcon/errors.py index 6c647ba05..63f384a9a 100644 --- a/falcon/errors.py +++ b/falcon/errors.py @@ -45,7 +45,7 @@ def on_get(self, req, resp): from falcon.util.misc import dt_to_http if TYPE_CHECKING: - from falcon._typing import NarrowHeaderArg + from falcon._typing import HeaderArg from falcon.typing import Headers @@ -270,7 +270,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ) -> None: super().__init__( @@ -350,7 +350,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, challenges: Optional[Iterable[str]] = None, **kwargs: HTTPErrorKeywordArguments, ): @@ -425,7 +425,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -493,7 +493,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -614,7 +614,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): headers = _load_headers(headers) @@ -682,7 +682,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -752,7 +752,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -828,7 +828,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -889,7 +889,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -951,7 +951,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1026,7 +1026,7 @@ def __init__( title: Optional[str] = None, description: Optional[str] = None, retry_after: RetryAfter = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ) -> None: super().__init__( @@ -1104,7 +1104,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1166,7 +1166,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1242,7 +1242,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): headers = _load_headers(headers) @@ -1309,7 +1309,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1368,7 +1368,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1426,7 +1426,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1492,7 +1492,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1563,7 +1563,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, retry_after: RetryAfter = None, **kwargs: HTTPErrorKeywordArguments, ): @@ -1629,7 +1629,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1701,7 +1701,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1759,7 +1759,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1824,7 +1824,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1882,7 +1882,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -1956,7 +1956,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, retry_after: RetryAfter = None, **kwargs: HTTPErrorKeywordArguments, ): @@ -2016,7 +2016,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -2080,7 +2080,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -2142,7 +2142,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -2201,7 +2201,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -2272,7 +2272,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): super().__init__( @@ -2328,7 +2328,7 @@ def __init__( msg: str, header_name: str, *, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): description = 'The value provided for the "{0}" header is invalid. {1}' @@ -2384,7 +2384,7 @@ def __init__( self, header_name: str, *, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ): description = 'The "{0}" header is required.' @@ -2444,7 +2444,7 @@ def __init__( msg: str, param_name: str, *, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ) -> None: description = 'The "{0}" parameter is invalid. {1}' @@ -2502,7 +2502,7 @@ def __init__( self, param_name: str, *, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ) -> None: description = 'The "{0}" parameter is required.' @@ -2603,7 +2603,7 @@ class MediaMalformedError(HTTPBadRequest): def __init__( self, media_type: str, - **kwargs: Union[NarrowHeaderArg, HTTPErrorKeywordArguments], + **kwargs: Union[HeaderArg, HTTPErrorKeywordArguments], ): super().__init__( title='Invalid {0}'.format(media_type), @@ -2620,7 +2620,7 @@ def description(self) -> Optional[str]: return msg @description.setter - def description(self, value: str) -> None: + def description(self, value: str) -> None: # pyright: ignore[reportIncompatibleVariableOverride] pass @@ -2674,7 +2674,7 @@ def __init__( *, title: Optional[str] = None, description: Optional[str] = None, - headers: Optional[NarrowHeaderArg] = None, + headers: Optional[HeaderArg] = None, **kwargs: HTTPErrorKeywordArguments, ) -> None: super().__init__( @@ -2703,13 +2703,13 @@ class MultipartParseError(MediaMalformedError): """ # NOTE(caselit): remove the description @property in MediaMalformedError - description = None # pyright: ignore[reportAssignmentType, reportGeneralTypeIssues] + description = None # pyright: ignore def __init__( self, *, description: Optional[str] = None, - **kwargs: Union[NarrowHeaderArg, HTTPErrorKeywordArguments], + **kwargs: Union[HeaderArg, HTTPErrorKeywordArguments], ) -> None: HTTPBadRequest.__init__( self, @@ -2724,19 +2724,19 @@ def __init__( # ----------------------------------------------------------------------------- -def _load_headers(headers: Optional[NarrowHeaderArg]) -> Headers: +def _load_headers(headers: Optional[HeaderArg]) -> Headers: """Transform the headers to dict.""" if headers is None: return {} if isinstance(headers, dict): - return headers + return headers # pyright: ignore[reportReturnType] return dict(headers) def _parse_retry_after( - headers: Optional[NarrowHeaderArg], + headers: Optional[HeaderArg], retry_after: RetryAfter, -) -> Optional[NarrowHeaderArg]: +) -> Optional[HeaderArg]: """Set the Retry-After to the headers when required.""" if retry_after is None: return headers diff --git a/falcon/inspect.py b/falcon/inspect.py index 7cb749d0d..80b3003e0 100644 --- a/falcon/inspect.py +++ b/falcon/inspect.py @@ -250,7 +250,7 @@ def _traverse(roots: List[CompiledRouterNode], parent: str) -> None: method_info = RouteMethodInfo( method, source_info, - getattr(real_func, '__name__', '?'), + real_func.__name__, # pyright: ignore[reportAttributeAccessIssue] internal, ) methods.append(method_info) diff --git a/falcon/media/base.py b/falcon/media/base.py index daa8e1040..0d80611f3 100644 --- a/falcon/media/base.py +++ b/falcon/media/base.py @@ -28,7 +28,7 @@ class BaseHandler(metaclass=abc.ABCMeta): """Override to provide a synchronous deserialization method that takes a byte string.""" - def serialize(self, media: object, content_type: Optional[str] = None) -> bytes: + def serialize(self, media: object, content_type: str) -> bytes: """Serialize the media object on a :any:`falcon.Response`. By default, this method raises an instance of @@ -51,7 +51,7 @@ def serialize(self, media: object, content_type: Optional[str] = None) -> bytes: Returns: bytes: The resulting serialized bytes from the input object. """ - if content_type is not None and MEDIA_JSON in content_type: + if MEDIA_JSON in content_type: raise NotImplementedError( 'The JSON media handler requires the sync interface to be ' "implemented even in ASGI applications, because it's used " diff --git a/falcon/media/json.py b/falcon/media/json.py index 502be0126..e1df2726c 100644 --- a/falcon/media/json.py +++ b/falcon/media/json.py @@ -165,16 +165,16 @@ def __init__( # proper serialize implementation. result = self._dumps({'message': 'Hello World'}) if isinstance(result, str): - self.serialize = self._serialize_s # type: ignore[method-assign] + self.serialize = s_s = self._serialize_s # type: ignore[method-assign] self.serialize_async = self._serialize_async_s # type: ignore[method-assign] else: - self.serialize = self._serialize_b # type: ignore[method-assign] + self.serialize = s_s = self._serialize_b # type: ignore[method-assign] self.serialize_async = self._serialize_async_b # type: ignore[method-assign] # NOTE(kgriffs): To be safe, only enable the optimized protocol when # not subclassed. if type(self) is JSONHandler: - self._serialize_sync = self.serialize + self._serialize_sync = s_s self._deserialize_sync = self._deserialize def _deserialize(self, data: bytes) -> Any: diff --git a/falcon/media/msgpack.py b/falcon/media/msgpack.py index 95931811d..5b8c587c9 100644 --- a/falcon/media/msgpack.py +++ b/falcon/media/msgpack.py @@ -72,7 +72,7 @@ async def deserialize_async( ) -> Any: return self._deserialize(await stream.read()) - def serialize(self, media: Any, content_type: Optional[str] = None) -> bytes: + def serialize(self, media: Any, content_type: Optional[str]) -> bytes: return self._pack(media) async def serialize_async(self, media: Any, content_type: Optional[str]) -> bytes: diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py index cf1aba289..99cd8785e 100644 --- a/falcon/media/multipart.py +++ b/falcon/media/multipart.py @@ -549,7 +549,7 @@ async def deserialize_async( stream, content_type, content_length, form_cls=self._ASGI_MULTIPART_FORM ) - def serialize(self, media: object, content_type: Optional[str] = None) -> NoReturn: + def serialize(self, media: object, content_type: str) -> NoReturn: raise NotImplementedError('multipart form serialization unsupported') diff --git a/falcon/media/validators/jsonschema.py b/falcon/media/validators/jsonschema.py index 69be06345..295be7b3c 100644 --- a/falcon/media/validators/jsonschema.py +++ b/falcon/media/validators/jsonschema.py @@ -102,10 +102,12 @@ def wrapper( ) -> Any: if req_schema is not None: try: - jsonschema.validate( - req.media, req_schema, format_checker=jsonschema.FormatChecker() + jsonschema.validate( # pyright: ignore[reportPossiblyUnboundVariable] + req.media, + req_schema, + format_checker=jsonschema.FormatChecker(), # pyright: ignore[reportPossiblyUnboundVariable] ) - except jsonschema.ValidationError as ex: + except jsonschema.ValidationError as ex: # pyright: ignore[reportPossiblyUnboundVariable] raise falcon.MediaValidationError( title='Request data failed validation', description=ex.message ) from ex @@ -114,10 +116,12 @@ def wrapper( if resp_schema is not None: try: - jsonschema.validate( - resp.media, resp_schema, format_checker=jsonschema.FormatChecker() + jsonschema.validate( # pyright: ignore[reportPossiblyUnboundVariable] + resp.media, + resp_schema, + format_checker=jsonschema.FormatChecker(), # pyright: ignore[reportPossiblyUnboundVariable] ) - except jsonschema.ValidationError as ex: + except jsonschema.ValidationError as ex: # pyright: ignore[reportPossiblyUnboundVariable] raise falcon.HTTPInternalServerError( title='Response data failed validation' # Do not return 'e.message' in the response to @@ -141,10 +145,12 @@ async def wrapper( m = await req.get_media() try: - jsonschema.validate( - m, req_schema, format_checker=jsonschema.FormatChecker() + jsonschema.validate( # pyright: ignore[reportPossiblyUnboundVariable] + m, + req_schema, + format_checker=jsonschema.FormatChecker(), # pyright: ignore[reportPossiblyUnboundVariable] ) - except jsonschema.ValidationError as ex: + except jsonschema.ValidationError as ex: # pyright: ignore[reportPossiblyUnboundVariable] raise falcon.MediaValidationError( title='Request data failed validation', description=ex.message ) from ex @@ -153,10 +159,12 @@ async def wrapper( if resp_schema is not None: try: - jsonschema.validate( - resp.media, resp_schema, format_checker=jsonschema.FormatChecker() + jsonschema.validate( # pyright: ignore[reportPossiblyUnboundVariable] + resp.media, + resp_schema, + format_checker=jsonschema.FormatChecker(), # pyright: ignore[reportPossiblyUnboundVariable] ) - except jsonschema.ValidationError as ex: + except jsonschema.ValidationError as ex: # pyright: ignore[reportPossiblyUnboundVariable] raise falcon.HTTPInternalServerError( title='Response data failed validation' # Do not return 'e.message' in the response to diff --git a/falcon/response_helpers.py b/falcon/response_helpers.py index 1564fcc71..330764892 100644 --- a/falcon/response_helpers.py +++ b/falcon/response_helpers.py @@ -153,4 +153,5 @@ def _headers_to_items(headers: HeaderArg) -> HeaderIter: header_items: Optional[Callable[[], HeaderIter]] = getattr(headers, 'items', None) if callable(header_items): return header_items() - return headers # type: ignore[return-value] + else: + return headers # type: ignore[return-value] diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index 3b0ef50fe..dec8520c1 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -59,7 +59,6 @@ from falcon._typing import CookieArg from falcon._typing import HeaderArg from falcon._typing import ResponseStatus -from falcon.app_helpers import close_maybe import falcon.asgi from falcon.asgi_spec import AsgiEvent from falcon.asgi_spec import EventType @@ -1410,7 +1409,8 @@ def wrapper() -> Iterator[bytes]: for item in iterable: yield item finally: - close_maybe(iterable) + if hasattr(iterable, 'close'): + iterable.close() # pyright: ignore[reportAttributeAccessIssue] wrapped = wrapper() head: Tuple[bytes, ...] diff --git a/falcon/testing/test_case.py b/falcon/testing/test_case.py index 632b662b7..8b2dbe1db 100644 --- a/falcon/testing/test_case.py +++ b/falcon/testing/test_case.py @@ -19,7 +19,7 @@ """ try: - import testtools as unittest + import testtools as unittest # pyright: ignore[reportMissingImports] except ImportError: # pragma: nocover import unittest @@ -31,7 +31,7 @@ from falcon.testing.client import TestClient -class TestCase(unittest.TestCase, TestClient): +class TestCase(unittest.TestCase, TestClient): # pyright: ignore[reportGeneralTypeIssues] """Extends :mod:`unittest` to support WSGI/ASGI functional testing. Note: diff --git a/pyproject.toml b/pyproject.toml index 4fe30640a..3b37b0950 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,13 @@ exclude = ["examples", "tests"] ] ignore_missing_imports = true +[tool.pyright] + exclude = [ + "falcon/bench", + "falcon/cmd", + ] + reportUnnecessaryTypeComment = true # doesn't seem to work. https://github.com/microsoft/pyright/issues/2839#issuecomment-2421779145 + [tool.towncrier] package = "falcon" package_dir = "" From 03d9abd7f7ec75248e974b559f9504c767de3c72 Mon Sep 17 00:00:00 2001 From: Federico Caselli Date: Fri, 18 Oct 2024 15:36:18 +0200 Subject: [PATCH 10/12] chore: add pyright to tox/pipeline --- .github/workflows/tests.yaml | 1 + tox.ini | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 0a5106a6c..163b94d20 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -32,6 +32,7 @@ jobs: - env: towncrier # Typing - env: mypy + - env: pyright - env: mypy_tests # Python tests - env: mintest diff --git a/tox.ini b/tox.ini index 8cf52b9c1..f88bc8f0b 100644 --- a/tox.ini +++ b/tox.ini @@ -107,7 +107,7 @@ deps = {[with-debug-tools]deps} jsonschema # -------------------------------------------------------------------- -# Mypy +# Type checkers (Mypy, pyright) # -------------------------------------------------------------------- [testenv:mypy] @@ -118,6 +118,14 @@ deps = mypy commands = python {toxinidir}/tools/clean.py "{toxinidir}/falcon" mypy falcon +[testenv:pyright] +skipsdist = True +skip_install = True +deps = pyright + types-jsonschema +commands = python {toxinidir}/tools/clean.py "{toxinidir}/falcon" + pyright falcon + [testenv:mypy_tests] deps = {[testenv]deps} mypy From f6486fc325b1e2b18826c029a4501fdb0d45bf0f Mon Sep 17 00:00:00 2001 From: Federico Caselli Date: Fri, 18 Oct 2024 15:42:28 +0200 Subject: [PATCH 11/12] chore: remove commented code --- falcon/_typing.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/falcon/_typing.py b/falcon/_typing.py index 648bb8c8d..6756a4cfe 100644 --- a/falcon/_typing.py +++ b/falcon/_typing.py @@ -164,9 +164,6 @@ async def __call__( AsgiProcessResponseMethod = Callable[ ['AsgiRequest', 'AsgiResponse', Resource, bool], Awaitable[None] ] -# AsgiProcessStartupMethod = Callable[[Dict[str, Any], 'AsgiEvent'], Awaitable[None]] -# AsgiProcessShutdownMethod = Callable[[Dict[str, Any], 'AsgiEvent'], Awaitable[None]] - AsgiProcessRequestWsMethod = Callable[['AsgiRequest', 'WebSocket'], Awaitable[None]] AsgiProcessResourceWsMethod = Callable[ ['AsgiRequest', 'WebSocket', Resource, Dict[str, Any]], Awaitable[None] From 4cf08cc2ef760e76faf9c20094dba33261bc1a9f Mon Sep 17 00:00:00 2001 From: Federico Caselli Date: Fri, 18 Oct 2024 19:01:34 +0200 Subject: [PATCH 12/12] tox: fix errors in pyright env --- pyproject.toml | 2 +- tox.ini | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3b37b0950..442ea9979 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,7 +118,7 @@ exclude = ["examples", "tests"] "falcon/bench", "falcon/cmd", ] - reportUnnecessaryTypeComment = true # doesn't seem to work. https://github.com/microsoft/pyright/issues/2839#issuecomment-2421779145 + # reportUnnecessaryTypeIgnoreComment = true [tool.towncrier] package = "falcon" diff --git a/tox.ini b/tox.ini index f88bc8f0b..0b570ddb5 100644 --- a/tox.ini +++ b/tox.ini @@ -122,6 +122,8 @@ commands = python {toxinidir}/tools/clean.py "{toxinidir}/falcon" skipsdist = True skip_install = True deps = pyright + msgpack + jsonschema types-jsonschema commands = python {toxinidir}/tools/clean.py "{toxinidir}/falcon" pyright falcon