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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/_newsfragments/2628.newandimproved.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improve ASGI typing by introducing TypedDict definitions for HTTP event messages.
11 changes: 6 additions & 5 deletions falcon/asgi/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
ClassVar,
overload,
TYPE_CHECKING,
cast
)
import warnings

Expand Down Expand Up @@ -55,6 +56,7 @@
from falcon.asgi_spec import AsgiSendMsg
from falcon.asgi_spec import EventType
from falcon.asgi_spec import WSCloseCode
from falcon.asgi_spec import ASGIScope
from falcon.constants import MEDIA_JSON
from falcon.errors import CompatibilityError
from falcon.errors import HTTPBadRequest
Expand All @@ -68,7 +70,6 @@
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 ._asgi_helpers import _validate_asgi_scope
from ._asgi_helpers import _wrap_asgi_coroutine_func
from .multipart import MultipartForm
Expand Down Expand Up @@ -456,7 +457,7 @@ def __init__(
@_wrap_asgi_coroutine_func
async def __call__( # type: ignore[override] # noqa: C901
self,
scope: dict[str, Any],
scope: ASGIScope | dict[str, Any],
receive: AsgiReceive,
send: AsgiSend,
) -> None:
Expand Down Expand Up @@ -488,12 +489,12 @@ async def __call__( # type: ignore[override] # noqa: C901
# PERF(vytas): Evaluate the potentially recurring WebSocket path
# first (in contrast to one-shot lifespan events).
if scope_type == 'websocket':
await self._handle_websocket(spec_version, scope, receive, send)
await self._handle_websocket(spec_version, cast(dict[str, Any], scope), receive, send)
return

# NOTE(vytas): Else 'lifespan' -- other scope_type values have been
# eliminated by _validate_asgi_scope at this point.
await self._call_lifespan_handlers(spec_version, scope, receive, send)
await self._call_lifespan_handlers(spec_version, cast(dict[str, Any], scope), receive, send)
return

# NOTE(kgriffs): Per the ASGI spec, we should not proceed with request
Expand All @@ -514,7 +515,7 @@ async def __call__( # type: ignore[override] # noqa: C901
assert first_event_type == 'http.request'

req = self._request_type(
scope, receive, first_event=first_event, options=self.req_options
cast(dict[str, Any], scope), receive, first_event=first_event, options=self.req_options
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the hottest code path in Falcon, we simply cannot afford cast(...) here.

)
resp = self._response_type(options=self.resp_options)

Expand Down
47 changes: 43 additions & 4 deletions falcon/asgi_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
"""Constants, etc. defined by the ASGI specification."""

from __future__ import annotations

from collections.abc import Mapping
from typing import Any

from typing import TypedDict, Literal, Any, Required



class EventType:
Expand All @@ -42,6 +43,45 @@ class EventType:
WS_DISCONNECT = 'websocket.disconnect'
WS_CLOSE = 'websocket.close'

class HTTPRequestEvent(TypedDict, total=False):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see how these could belong to asgi_spec.py too, but let's colocate these aliases in falcon/_typing.py for now.

type: Literal["http.request"]
body: bytes
more_body: bool

class HTTPDisconnectEvent(TypedDict):
type: Literal["http.disconnect"]

class HTTPResponseStartEvent(TypedDict):
type: Literal["http.response.start"]
status: int
headers: list[tuple[bytes, bytes]]


class HTTPResponseBodyEvent(TypedDict, total=False):
type: Literal["http.response.body"]
body: bytes
more_body: bool

class HTTPScope(TypedDict, total=False):
type: Required[Literal["http"]]
http_version: str
method: str
path: str
headers: list[tuple[bytes, bytes]]
asgi: dict[str, Any]

class WebSocketScope(TypedDict, total=False):
type: Required[Literal["websocket"]]
path: str
headers: list[tuple[bytes, bytes]]
http_version: str
asgi: dict[str, Any]

class LifespanScope(TypedDict, total=False):
type: Required[Literal["lifespan"]]
asgi: dict[str, Any]
http_version: str


class ScopeType:
"""Standard ASGI event type strings."""
Expand All @@ -64,7 +104,6 @@ class WSCloseCode:
HANDLER_NOT_FOUND = 3405


# TODO: use a typed dict for event dicts
AsgiEvent = Mapping[str, Any]
# TODO: use a typed dict for send msg dicts
AsgiSendMsg = dict[str, Any]
ASGIScope = HTTPScope | WebSocketScope | LifespanScope
Loading