Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: add pyright typecheck #2379

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ jobs:
- env: towncrier
# Typing
- env: mypy
- env: pyright
- env: mypy_tests
# Python tests
- env: mintest
Expand Down
3 changes: 1 addition & 2 deletions falcon/_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,6 @@ async def __call__(
Tuple[Callable[[], Awaitable[None]], Literal[True]],
]


# Routing

MethodDict = Union[
Expand All @@ -190,7 +189,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]
Expand Down
23 changes: 17 additions & 6 deletions falcon/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -1253,15 +1260,19 @@ def _get_body(
else:
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 = tuple(self._sinks + self._static_routes) # type: ignore[operator]
self._sink_and_static_routes = tuple(self._sinks) + tuple(
self._static_routes
)
else:
self._sink_and_static_routes = tuple(self._static_routes + self._sinks) # type: ignore[operator]
self._sink_and_static_routes = tuple(self._static_routes) + tuple(
self._sinks
)


# TODO(myusko): This class is a compatibility alias, and should be removed
Expand Down
11 changes: 10 additions & 1 deletion falcon/app_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,16 @@
from __future__ import annotations

from inspect import iscoroutinefunction
from typing import IO, Iterable, List, Literal, Optional, overload, Tuple, Union
from typing import (
IO,
Iterable,
List,
Literal,
Optional,
overload,
Tuple,
Union,
)

from falcon import util
from falcon._typing import AsgiProcessRequestMethod as APRequest
Expand Down
38 changes: 19 additions & 19 deletions falcon/asgi/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
Union,
)

from falcon import _logger
from falcon import constants
from falcon import responders
from falcon import routing
Expand All @@ -59,6 +60,7 @@
from falcon.constants import MEDIA_JSON
from falcon.errors import CompatibilityError
from falcon.errors import HTTPBadRequest
from falcon.errors import HTTPInternalServerError
from falcon.errors import WebSocketDisconnected
from falcon.http_error import HTTPError
from falcon.http_status import HTTPStatus
Expand Down Expand Up @@ -338,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]
)

Expand All @@ -354,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.
Expand Down Expand Up @@ -521,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

Expand Down Expand Up @@ -771,7 +773,7 @@ async def watch_disconnect() -> None:
if hasattr(stream, 'read'):
try:
while True:
data = await stream.read(self._STREAM_BLOCK_SIZE)
data = await stream.read(self._STREAM_BLOCK_SIZE) # pyright: ignore[reportAttributeAccessIssue]
if data == b'':
break
else:
Expand All @@ -786,7 +788,7 @@ async def watch_disconnect() -> None:
)
finally:
if hasattr(stream, 'close'):
await stream.close()
await stream.close() # pyright: ignore[reportAttributeAccessIssue]
else:
# NOTE(kgriffs): Works for both async generators and iterators
try:
Expand Down Expand Up @@ -826,7 +828,7 @@ async def watch_disconnect() -> None:
# 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 stream.close() # pyright: ignore

await send(_EVT_RESP_EOF)

Expand Down Expand Up @@ -1003,7 +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.'
)
handler_callable: AsgiErrorHandler = handler
handler_callable: AsgiErrorHandler = handler # pyright: ignore[reportAssignmentType]

exception_tuple: Tuple[type[BaseException], ...]
try:
Expand Down Expand Up @@ -1077,7 +1079,7 @@ async def _call_lifespan_handlers(
for handler in self._unprepared_middleware:
if hasattr(handler, 'process_startup'):
try:
await handler.process_startup(scope, event)
await handler.process_startup(scope, event) # pyright: ignore[reportAttributeAccessIssue]
except Exception:
await send(
{
Expand All @@ -1093,7 +1095,7 @@ async def _call_lifespan_handlers(
for handler in reversed(self._unprepared_middleware):
if hasattr(handler, 'process_shutdown'):
try:
await handler.process_shutdown(scope, event)
await handler.process_shutdown(scope, event) # pyright: ignore[reportAttributeAccessIssue]
except Exception:
await send(
{
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -1244,9 +1246,7 @@ async def _ws_disconnected_error_handler(
) -> None:
assert resp is None
assert ws is not None
falcon._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:
Expand Down Expand Up @@ -1323,7 +1323,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.'
Expand Down
4 changes: 2 additions & 2 deletions falcon/asgi/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 3 additions & 8 deletions falcon/asgi/ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +18,7 @@
from falcon.asgi_spec import EventType
from falcon.asgi_spec import WSCloseCode
from falcon.constants import WebSocketPayloadType
from falcon.response_helpers import _headers_to_items
from falcon.util import misc

__all__ = ('WebSocket',)
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 6 additions & 4 deletions falcon/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2601,7 +2601,9 @@ class MediaMalformedError(HTTPBadRequest):
"""

def __init__(
self, media_type: str, **kwargs: Union[HeaderArg, HTTPErrorKeywordArguments]
self,
media_type: str,
**kwargs: Union[HeaderArg, HTTPErrorKeywordArguments],
):
super().__init__(
title='Invalid {0}'.format(media_type),
Expand All @@ -2618,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


Expand Down Expand Up @@ -2701,7 +2703,7 @@ class MultipartParseError(MediaMalformedError):
"""

# NOTE(caselit): remove the description @property in MediaMalformedError
description = None
description = None # pyright: ignore

def __init__(
self,
Expand All @@ -2727,7 +2729,7 @@ def _load_headers(headers: Optional[HeaderArg]) -> Headers:
if headers is None:
return {}
if isinstance(headers, dict):
return headers
return headers # pyright: ignore[reportReturnType]
return dict(headers)


Expand Down
8 changes: 4 additions & 4 deletions falcon/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion falcon/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,10 @@ 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,
real_func.__name__, # pyright: ignore[reportAttributeAccessIssue]
internal,
)
methods.append(method_info)
source_info, class_name = _get_source_info_and_name(root.resource)
Expand Down
Loading