From 4567c972c19d9ab7b0881675534ce4e59f24b6ed Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sun, 15 Sep 2024 18:43:00 +0200 Subject: [PATCH 1/6] chore)tests): do not install `rapidjson` on PyPy (Because it does not seem to build on PyPy cleanly with setuptools>=58 any more.) --- requirements/tests | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements/tests b/requirements/tests index 5be00de33..ada7c3729 100644 --- a/requirements/tests +++ b/requirements/tests @@ -22,7 +22,8 @@ mujson ujson # it's slow to compile on emulated architectures; wheels missing for some EoL interpreters -python-rapidjson; platform_machine != 's390x' and platform_machine != 'aarch64' +# (and there is a new issue with building on PyPy in Actions, but we don't really need to test it with PyPy) +python-rapidjson; platform_python_implementation != 'PyPy' and platform_machine != 's390x' and platform_machine != 'aarch64' # wheels are missing some EoL interpreters and non-x86 platforms; build would fail unless rust is available orjson; platform_python_implementation != 'PyPy' and platform_machine != 's390x' and platform_machine != 'aarch64' From 87e04547cfb997598e64de12393ef9ffe2bbfe61 Mon Sep 17 00:00:00 2001 From: Agustin Arce <59893355+aarcex3@users.noreply.github.com> Date: Sun, 15 Sep 2024 12:58:59 -0400 Subject: [PATCH 2/6] feat (status_codes): add new HTTP status codes from RFC 9110 (#2328) * refactor (response): Remove deprecated attributes * refactor (response): Organize imports with ruff check --fix * test (response): Refactor test_response_set_stream test * test (response): Fix set stream test * test (response): Improve assertion * feat (status_codes): add new status codes * style: run ruff format * docs (status): update docs * style (status_codes): fix typo * docs (status): fix typo --- docs/api/status.rst | 8 ++++++++ falcon/__init__.py | 20 ++++++++++++++++++++ falcon/status_codes.py | 20 ++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/api/status.rst b/docs/api/status.rst index c48d7c918..449b2a071 100644 --- a/docs/api/status.rst +++ b/docs/api/status.rst @@ -66,10 +66,12 @@ HTTPStatus HTTP_CONTINUE = HTTP_100 HTTP_SWITCHING_PROTOCOLS = HTTP_101 HTTP_PROCESSING = HTTP_102 + HTTP_EARLY_HINTS = HTTP_103 HTTP_100 = '100 Continue' HTTP_101 = '101 Switching Protocols' HTTP_102 = '102 Processing' + HTTP_103 = '103 Early Hints' 2xx Success ----------- @@ -145,6 +147,7 @@ HTTPStatus HTTP_REQUESTED_RANGE_NOT_SATISFIABLE = HTTP_416 HTTP_EXPECTATION_FAILED = HTTP_417 HTTP_IM_A_TEAPOT = HTTP_418 + HTTP_MISDIRECTED_REQUEST = HTTP_421 HTTP_UNPROCESSABLE_ENTITY = HTTP_422 HTTP_LOCKED = HTTP_423 HTTP_FAILED_DEPENDENCY = HTTP_424 @@ -173,6 +176,7 @@ HTTPStatus HTTP_416 = '416 Range Not Satisfiable' HTTP_417 = '417 Expectation Failed' HTTP_418 = "418 I'm a teapot" + HTTP_421 = '421 Misdirected Request' HTTP_422 = "422 Unprocessable Entity" HTTP_423 = '423 Locked' HTTP_424 = '424 Failed Dependency' @@ -193,8 +197,10 @@ HTTPStatus HTTP_SERVICE_UNAVAILABLE = HTTP_503 HTTP_GATEWAY_TIMEOUT = HTTP_504 HTTP_HTTP_VERSION_NOT_SUPPORTED = HTTP_505 + HTTP_VARIANT_ALSO_NEGOTIATES = HTTP_506 HTTP_INSUFFICIENT_STORAGE = HTTP_507 HTTP_LOOP_DETECTED = HTTP_508 + HTTP_NOT_EXTENDED = HTTP_510 HTTP_NETWORK_AUTHENTICATION_REQUIRED = HTTP_511 HTTP_500 = '500 Internal Server Error' @@ -203,6 +209,8 @@ HTTPStatus HTTP_503 = '503 Service Unavailable' HTTP_504 = '504 Gateway Timeout' HTTP_505 = '505 HTTP Version Not Supported' + HTTP_506 = '506 Variant Also Negotiates' HTTP_507 = '507 Insufficient Storage' HTTP_508 = '508 Loop Detected' + HTTP_510 = '510 Not Extended' HTTP_511 = '511 Network Authentication Required' diff --git a/falcon/__init__.py b/falcon/__init__.py index b9976b643..d6bcc1a06 100644 --- a/falcon/__init__.py +++ b/falcon/__init__.py @@ -154,6 +154,7 @@ 'HTTP_100', 'HTTP_101', 'HTTP_102', + 'HTTP_103', 'HTTP_200', 'HTTP_201', 'HTTP_202', @@ -191,9 +192,11 @@ 'HTTP_416', 'HTTP_417', 'HTTP_418', + 'HTTP_421', 'HTTP_422', 'HTTP_423', 'HTTP_424', + 'HTTP_425', 'HTTP_426', 'HTTP_428', 'HTTP_429', @@ -205,8 +208,10 @@ 'HTTP_503', 'HTTP_504', 'HTTP_505', + 'HTTP_506', 'HTTP_507', 'HTTP_508', + 'HTTP_510', 'HTTP_511', 'HTTP_701', 'HTTP_702', @@ -262,6 +267,7 @@ 'HTTP_CONFLICT', 'HTTP_CONTINUE', 'HTTP_CREATED', + 'HTTP_EARLY_HINTS', 'HTTP_EXPECTATION_FAILED', 'HTTP_FAILED_DEPENDENCY', 'HTTP_FORBIDDEN', @@ -277,12 +283,14 @@ 'HTTP_LOCKED', 'HTTP_LOOP_DETECTED', 'HTTP_METHOD_NOT_ALLOWED', + 'HTTP_MISDIRECTED_REQUEST', 'HTTP_MOVED_PERMANENTLY', 'HTTP_MULTIPLE_CHOICES', 'HTTP_MULTI_STATUS', 'HTTP_NETWORK_AUTHENTICATION_REQUIRED', 'HTTP_NON_AUTHORITATIVE_INFORMATION', 'HTTP_NOT_ACCEPTABLE', + 'HTTP_NOT_EXTENDED', 'HTTP_NOT_FOUND', 'HTTP_NOT_IMPLEMENTED', 'HTTP_NOT_MODIFIED', @@ -305,6 +313,7 @@ 'HTTP_SERVICE_UNAVAILABLE', 'HTTP_SWITCHING_PROTOCOLS', 'HTTP_TEMPORARY_REDIRECT', + 'HTTP_TOO_EARLY', 'HTTP_TOO_MANY_REQUESTS', 'HTTP_UNAUTHORIZED', 'HTTP_UNAVAILABLE_FOR_LEGAL_REASONS', @@ -312,6 +321,7 @@ 'HTTP_UNSUPPORTED_MEDIA_TYPE', 'HTTP_UPGRADE_REQUIRED', 'HTTP_USE_PROXY', + 'HTTP_VARIANT_ALSO_NEGOTIATES', ) # NOTE(kgriffs,vytas): Hoist classes and functions into the falcon namespace. @@ -409,6 +419,7 @@ from falcon.status_codes import HTTP_100 from falcon.status_codes import HTTP_101 from falcon.status_codes import HTTP_102 +from falcon.status_codes import HTTP_103 from falcon.status_codes import HTTP_200 from falcon.status_codes import HTTP_201 from falcon.status_codes import HTTP_202 @@ -446,9 +457,11 @@ from falcon.status_codes import HTTP_416 from falcon.status_codes import HTTP_417 from falcon.status_codes import HTTP_418 +from falcon.status_codes import HTTP_421 from falcon.status_codes import HTTP_422 from falcon.status_codes import HTTP_423 from falcon.status_codes import HTTP_424 +from falcon.status_codes import HTTP_425 from falcon.status_codes import HTTP_426 from falcon.status_codes import HTTP_428 from falcon.status_codes import HTTP_429 @@ -460,8 +473,10 @@ from falcon.status_codes import HTTP_503 from falcon.status_codes import HTTP_504 from falcon.status_codes import HTTP_505 +from falcon.status_codes import HTTP_506 from falcon.status_codes import HTTP_507 from falcon.status_codes import HTTP_508 +from falcon.status_codes import HTTP_510 from falcon.status_codes import HTTP_511 from falcon.status_codes import HTTP_701 from falcon.status_codes import HTTP_702 @@ -517,6 +532,7 @@ from falcon.status_codes import HTTP_CONFLICT from falcon.status_codes import HTTP_CONTINUE from falcon.status_codes import HTTP_CREATED +from falcon.status_codes import HTTP_EARLY_HINTS from falcon.status_codes import HTTP_EXPECTATION_FAILED from falcon.status_codes import HTTP_FAILED_DEPENDENCY from falcon.status_codes import HTTP_FORBIDDEN @@ -532,6 +548,7 @@ from falcon.status_codes import HTTP_LOCKED from falcon.status_codes import HTTP_LOOP_DETECTED from falcon.status_codes import HTTP_METHOD_NOT_ALLOWED +from falcon.status_codes import HTTP_MISDIRECTED_REQUEST from falcon.status_codes import HTTP_MOVED_PERMANENTLY from falcon.status_codes import HTTP_MULTI_STATUS from falcon.status_codes import HTTP_MULTIPLE_CHOICES @@ -539,6 +556,7 @@ from falcon.status_codes import HTTP_NO_CONTENT from falcon.status_codes import HTTP_NON_AUTHORITATIVE_INFORMATION from falcon.status_codes import HTTP_NOT_ACCEPTABLE +from falcon.status_codes import HTTP_NOT_EXTENDED from falcon.status_codes import HTTP_NOT_FOUND from falcon.status_codes import HTTP_NOT_IMPLEMENTED from falcon.status_codes import HTTP_NOT_MODIFIED @@ -560,6 +578,7 @@ from falcon.status_codes import HTTP_SERVICE_UNAVAILABLE from falcon.status_codes import HTTP_SWITCHING_PROTOCOLS from falcon.status_codes import HTTP_TEMPORARY_REDIRECT +from falcon.status_codes import HTTP_TOO_EARLY from falcon.status_codes import HTTP_TOO_MANY_REQUESTS from falcon.status_codes import HTTP_UNAUTHORIZED from falcon.status_codes import HTTP_UNAVAILABLE_FOR_LEGAL_REASONS @@ -567,6 +586,7 @@ from falcon.status_codes import HTTP_UNSUPPORTED_MEDIA_TYPE from falcon.status_codes import HTTP_UPGRADE_REQUIRED from falcon.status_codes import HTTP_USE_PROXY +from falcon.status_codes import HTTP_VARIANT_ALSO_NEGOTIATES from falcon.stream import BoundedStream # NOTE(kgriffs): Ensure that "from falcon import uri" will import diff --git a/falcon/status_codes.py b/falcon/status_codes.py index 80c0b5f82..8c49f2655 100644 --- a/falcon/status_codes.py +++ b/falcon/status_codes.py @@ -23,6 +23,8 @@ HTTP_SWITCHING_PROTOCOLS: Final[str] = HTTP_101 HTTP_102: Final[str] = '102 Processing' HTTP_PROCESSING: Final[str] = HTTP_102 +HTTP_103: Final[str] = '103 Early Hints' +HTTP_EARLY_HINTS: Final[str] = HTTP_103 # 2xx - Success HTTP_200: Final[str] = '200 OK' @@ -103,12 +105,16 @@ HTTP_EXPECTATION_FAILED: Final[str] = HTTP_417 HTTP_418: Final[str] = "418 I'm a teapot" HTTP_IM_A_TEAPOT: Final[str] = HTTP_418 +HTTP_421: Final[str] = '421 Misdirected Request' +HTTP_MISDIRECTED_REQUEST: Final[str] = HTTP_421 HTTP_422: Final[str] = '422 Unprocessable Entity' HTTP_UNPROCESSABLE_ENTITY: Final[str] = HTTP_422 HTTP_423: Final[str] = '423 Locked' HTTP_LOCKED: Final[str] = HTTP_423 HTTP_424: Final[str] = '424 Failed Dependency' HTTP_FAILED_DEPENDENCY: Final[str] = HTTP_424 +HTTP_425: Final[str] = '425 Too Early' +HTTP_TOO_EARLY: Final[str] = HTTP_425 HTTP_426: Final[str] = '426 Upgrade Required' HTTP_UPGRADE_REQUIRED: Final[str] = HTTP_426 HTTP_428: Final[str] = '428 Precondition Required' @@ -133,10 +139,14 @@ HTTP_GATEWAY_TIMEOUT: Final[str] = HTTP_504 HTTP_505: Final[str] = '505 HTTP Version Not Supported' HTTP_HTTP_VERSION_NOT_SUPPORTED: Final[str] = HTTP_505 +HTTP_506: Final[str] = '506 Variant Also Negotiates' +HTTP_VARIANT_ALSO_NEGOTIATES: Final[str] = HTTP_506 HTTP_507: Final[str] = '507 Insufficient Storage' HTTP_INSUFFICIENT_STORAGE: Final[str] = HTTP_507 HTTP_508: Final[str] = '508 Loop Detected' HTTP_LOOP_DETECTED: Final[str] = HTTP_508 +HTTP_510: Final[str] = '510 Not Extended' +HTTP_NOT_EXTENDED: Final[str] = HTTP_510 HTTP_511: Final[str] = '511 Network Authentication Required' HTTP_NETWORK_AUTHENTICATION_REQUIRED: Final[str] = HTTP_511 @@ -209,6 +219,7 @@ 'HTTP_100', 'HTTP_101', 'HTTP_102', + 'HTTP_103', 'HTTP_200', 'HTTP_201', 'HTTP_202', @@ -246,9 +257,11 @@ 'HTTP_416', 'HTTP_417', 'HTTP_418', + 'HTTP_421', 'HTTP_422', 'HTTP_423', 'HTTP_424', + 'HTTP_425', 'HTTP_426', 'HTTP_428', 'HTTP_429', @@ -260,8 +273,10 @@ 'HTTP_503', 'HTTP_504', 'HTTP_505', + 'HTTP_506', 'HTTP_507', 'HTTP_508', + 'HTTP_510', 'HTTP_511', 'HTTP_701', 'HTTP_702', @@ -317,6 +332,7 @@ 'HTTP_CONFLICT', 'HTTP_CONTINUE', 'HTTP_CREATED', + 'HTTP_EARLY_HINTS', 'HTTP_EXPECTATION_FAILED', 'HTTP_FAILED_DEPENDENCY', 'HTTP_FORBIDDEN', @@ -332,12 +348,14 @@ 'HTTP_LOCKED', 'HTTP_LOOP_DETECTED', 'HTTP_METHOD_NOT_ALLOWED', + 'HTTP_MISDIRECTED_REQUEST', 'HTTP_MOVED_PERMANENTLY', 'HTTP_MULTIPLE_CHOICES', 'HTTP_MULTI_STATUS', 'HTTP_NETWORK_AUTHENTICATION_REQUIRED', 'HTTP_NON_AUTHORITATIVE_INFORMATION', 'HTTP_NOT_ACCEPTABLE', + 'HTTP_NOT_EXTENDED', 'HTTP_NOT_FOUND', 'HTTP_NOT_IMPLEMENTED', 'HTTP_NOT_MODIFIED', @@ -360,6 +378,7 @@ 'HTTP_SERVICE_UNAVAILABLE', 'HTTP_SWITCHING_PROTOCOLS', 'HTTP_TEMPORARY_REDIRECT', + 'HTTP_TOO_EARLY', 'HTTP_TOO_MANY_REQUESTS', 'HTTP_UNAUTHORIZED', 'HTTP_UNAVAILABLE_FOR_LEGAL_REASONS', @@ -367,4 +386,5 @@ 'HTTP_UNSUPPORTED_MEDIA_TYPE', 'HTTP_UPGRADE_REQUIRED', 'HTTP_USE_PROXY', + 'HTTP_VARIANT_ALSO_NEGOTIATES', ) From b29fd5540ae58bed47198ea447f1e9194c34155c Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Tue, 17 Sep 2024 23:15:57 +0200 Subject: [PATCH 3/6] fix(WebSocket): handle `OSError` upon `send()` + fix `max_receive_queue == 0` (#2324) * feat(WebSocket): handle `OSError` upon `send()` * fix(WebSocket): fix the `max_receive_queue == 0` case (WiP) * chore: do not build rapidjson on PyPy * test(WebSocket): add tests for the max_receive_queue==0 case * docs(ws): revise "Lost Connections" in the light of ASGI WS spec 2.4 * docs: market this as bugfix instead * test(WS): add a zero receive queue test with real servers * docs(WS): polish newsfragment * docs(WS): tone down inline comment --- .gitignore | 1 + README.rst | 2 +- docs/_newsfragments/2292.bugfix.rst | 16 +++++ docs/api/websocket.rst | 48 +++++++------- e2e-tests/server/app.py | 4 ++ falcon/asgi/ws.py | 48 +++++++++++++- falcon/testing/helpers.py | 8 +-- tests/asgi/_asgi_test_app.py | 24 +++++++ tests/asgi/test_asgi_servers.py | 15 ++++- tests/asgi/test_ws.py | 99 +++++++++++++++++++++++++++-- 10 files changed, 227 insertions(+), 38 deletions(-) create mode 100644 docs/_newsfragments/2292.bugfix.rst diff --git a/.gitignore b/.gitignore index e8b6d5f7d..a367aef02 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ pip-log.txt .ecosystem .tox .pytest_cache +downloaded_files/ geckodriver.log htmlcov nosetests.xml diff --git a/README.rst b/README.rst index 549e6cddb..bd83f9918 100644 --- a/README.rst +++ b/README.rst @@ -225,7 +225,7 @@ For your convenience, wheels containing pre-compiled binaries are available from PyPI for the majority of common platforms. Even if a binary build for your platform of choice is not available, ``pip`` will pick a pure-Python wheel. You can also cythonize Falcon for your environment; see our -`Installation docs `__. +`Installation docs `__ for more information on this and other advanced options. Dependencies diff --git a/docs/_newsfragments/2292.bugfix.rst b/docs/_newsfragments/2292.bugfix.rst new file mode 100644 index 000000000..9d7a0d4a2 --- /dev/null +++ b/docs/_newsfragments/2292.bugfix.rst @@ -0,0 +1,16 @@ +Falcon will now raise an instance of +:class:`~falcon.errors.WebSocketDisconnected` from the :class:`OSError` that +the ASGI server signals in the case of a disconnected client (as per +the `ASGI HTTP & WebSocket protocol +`__ version ``2.4``). +It is worth noting though that Falcon's +:ref:`built-in receive buffer ` normally detects the +``websocket.disconnect`` event itself prior the potentially failing attempt to +``send()``. + +Disabling this built-in receive buffer (by setting +:attr:`~falcon.asgi.WebSocketOptions.max_receive_queue` to ``0``) was also +found to interfere with receiving ASGI WebSocket messages in an unexpected +way. The issue has been fixed so that setting this option to ``0`` now properly +bypasses the buffer altogether, and extensive test coverage has been added for +validating this scenario. diff --git a/docs/api/websocket.rst b/docs/api/websocket.rst index 38878f964..b3bf42c05 100644 --- a/docs/api/websocket.rst +++ b/docs/api/websocket.rst @@ -112,32 +112,36 @@ Lost Connections ---------------- When the app attempts to receive a message from the client, the ASGI server -emits a `disconnect` event if the connection has been lost for any reason. Falcon -surfaces this event by raising an instance of :class:`~.WebSocketDisconnected` -to the caller. - -On the other hand, the ASGI spec requires the ASGI server to silently consume -messages sent by the app after the connection has been lost (i.e., it should -not be considered an error). Therefore, an endpoint that primarily streams -outbound events to the client might continue consuming resources unnecessarily -for some time after the connection is lost. +emits a ``disconnect`` event if the connection has been lost for any +reason. Falcon surfaces this event by raising an instance of +:class:`~.WebSocketDisconnected` to the caller. + +On the other hand, the ASGI spec previously required the ASGI server to +silently consume messages sent by the app after the connection has been lost +(i.e., it should not be considered an error). Therefore, an endpoint that +primarily streams outbound events to the client could continue consuming +resources unnecessarily for some time after the connection is lost. +This aspect has been rectified in the ASGI HTTP spec version ``2.4``, +and calling ``send()`` on a closed connection should now raise an +error. Unfortunately, not all ASGI servers have adopted this new behavior +uniformly yet. As a workaround, Falcon implements a small incoming message queue that is used to detect a lost connection and then raise an instance of -:class:`~.WebSocketDisconnected` to the caller the next time it attempts to send -a message. - -This workaround is only necessary when the app itself does not consume messages -from the client often enough to quickly detect when the connection is lost. -Otherwise, Falcon's receive queue can be disabled for a slight performance boost -by setting :attr:`~falcon.asgi.WebSocketOptions.max_receive_queue` to ``0`` via +:class:`~.WebSocketDisconnected` to the caller the next time it attempts to +send a message. +If your ASGI server of choice adheres to the spec version ``2.4``, this receive +queue can be safely disabled for a slight performance boost by setting +:attr:`~falcon.asgi.WebSocketOptions.max_receive_queue` to ``0`` via :attr:`~falcon.asgi.App.ws_options`. - -Note also that some ASGI server implementations do not strictly follow the ASGI -spec in this regard, and in fact will raise an error when the app attempts to -send a message after the client disconnects. If testing reveals this to be the -case for your ASGI server of choice, Falcon's own receive queue can be safely -disabled. +(We may revise this setting, and disable the queue by default in the future if +our testing indicates that all major ASGI servers have caught up with the +spec.) + +Furthermore, even on non-compliant or older ASGI servers, this workaround is +only necessary when the app itself does not consume messages from the client +often enough to quickly detect when the connection is lost. +Otherwise, Falcon's receive queue can also be disabled as described above. .. _ws_error_handling: diff --git a/e2e-tests/server/app.py b/e2e-tests/server/app.py index be9558985..61f6dc4a8 100644 --- a/e2e-tests/server/app.py +++ b/e2e-tests/server/app.py @@ -15,6 +15,10 @@ def create_app() -> falcon.asgi.App: app = falcon.asgi.App() + # NOTE(vytas): E2E tests run Uvicorn, and the latest versions support ASGI + # HTTP/WSspec ver 2.4, so buffering on our side should not be needed. + app.ws_options.max_receive_queue = 0 + hub = Hub() app.add_route('/ping', Pong()) app.add_route('/sse', Events(hub)) diff --git a/falcon/asgi/ws.py b/falcon/asgi/ws.py index 4cd9a6a76..c02031993 100644 --- a/falcon/asgi/ws.py +++ b/falcon/asgi/ws.py @@ -4,6 +4,7 @@ import collections from enum import auto from enum import Enum +import re from typing import Any, Deque, Dict, Iterable, Mapping, Optional, Tuple, Union from falcon import errors @@ -28,6 +29,9 @@ class _WebSocketState(Enum): CLOSED = auto() +_CLIENT_DISCONNECTED_CAUSE = re.compile(r'received (\d\d\d\d)') + + class WebSocket: """Represents a single WebSocket connection with a client.""" @@ -47,6 +51,8 @@ class WebSocket: 'subprotocols', ) + _asgi_receive: AsgiReceive + _asgi_send: AsgiSend _state: _WebSocketState _close_code: Optional[int] subprotocols: Tuple[str, ...] @@ -81,7 +87,12 @@ def __init__( # event via one of their receive() calls, and there is no # need for the added overhead. self._buffered_receiver = _BufferedReceiver(receive, max_receive_queue) - self._asgi_receive = self._buffered_receiver.receive + if max_receive_queue > 0: + self._asgi_receive = self._buffered_receiver.receive + else: + # NOTE(vytas): Pass through the receive callable bypassing the + # buffered receiver in the case max_receive_queue is set to 0. + self._asgi_receive = receive self._asgi_send = send mh_text = media_handlers[WebSocketPayloadType.TEXT] @@ -468,6 +479,8 @@ async def _send(self, msg: AsgiSendMsg) -> None: if self._buffered_receiver.client_disconnected: self._state = _WebSocketState.CLOSED self._close_code = self._buffered_receiver.client_disconnected_code + + if self._state == _WebSocketState.CLOSED: raise errors.WebSocketDisconnected(self._close_code) try: @@ -483,7 +496,16 @@ async def _send(self, msg: AsgiSendMsg) -> None: translated_ex = self._translate_webserver_error(ex) if translated_ex: - raise translated_ex + # NOTE(vytas): Mark WebSocket as closed if we catch an error + # upon sending. This is useful when not using the buffered + # receiver, and not receiving anything at the given moment. + self._state = _WebSocketState.CLOSED + if isinstance(translated_ex, errors.WebSocketDisconnected): + self._close_code = translated_ex.code + + # NOTE(vytas): Use the raise from form in order to preserve + # the traceback. + raise translated_ex from ex # NOTE(kgriffs): Re-raise other errors directly so that we don't # obscure the traceback. @@ -529,6 +551,25 @@ def _translate_webserver_error(self, ex: Exception) -> Optional[Exception]: 'WebSocket subprotocol must be from the list sent by the client' ) + # NOTE(vytas): Per ASGI HTTP & WebSocket spec v2.4: + # If send() is called on a closed connection the server should raise + # a server-specific subclass of IOError. + # NOTE(vytas): Uvicorn 0.30.6 seems to conform to the spec only when + # using the wsproto stack, it then raises an instance of + # uvicorn.protocols.utils.ClientDisconnected. + if isinstance(ex, OSError): + close_code = None + + # NOTE(vytas): If using the "websockets" backend, Uvicorn raises + # and instance of OSError from a websockets exception like this: + # "received 1001 (going away); then sent 1001 (going away)" + if ex.__cause__: + match = _CLIENT_DISCONNECTED_CAUSE.match(str(ex.__cause__)) + if match: + close_code = int(match.group(1)) + + return errors.WebSocketDisconnected(close_code) + return None @@ -679,7 +720,8 @@ def __init__(self, asgi_receive: AsgiReceive, max_queue: int) -> None: self.client_disconnected_code = None def start(self) -> None: - if self._pump_task is None: + # NOTE(vytas): Do not start anything if buffering is disabled. + if self._pump_task is None and self._max_queue > 0: self._pump_task = asyncio.create_task(self._pump()) async def stop(self) -> None: diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index e21961125..05513b885 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -785,10 +785,10 @@ async def _collect(self, event: Dict[str, Any]): else: assert self.closed - # NOTE(kgriffs): According to the ASGI spec, we are - # supposed to just silently eat events once the - # socket is disconnected. - pass + # NOTE(vytas): Tweaked in Falcon 4.0: we now simulate ASGI + # WebSocket protocol 2.4+, raising an instance of OSError upon + # send if the client has already disconnected. + raise falcon_errors.WebSocketDisconnected(self._close_code) # NOTE(kgriffs): Give whatever is waiting on the handshake or a # collected data/text event a chance to progress. diff --git a/tests/asgi/_asgi_test_app.py b/tests/asgi/_asgi_test_app.py index ac15a04e9..fcebd9bdf 100644 --- a/tests/asgi/_asgi_test_app.py +++ b/tests/asgi/_asgi_test_app.py @@ -264,6 +264,29 @@ async def on_post(self, req, resp): resp.status = falcon.HTTP_403 +class WSOptions: + _SUPPORTED_KEYS = frozenset( + {'default_close_reasons', 'error_close_code', 'max_receive_queue'} + ) + + def __init__(self, ws_options): + self._ws_options = ws_options + + async def on_get(self, req, resp): + resp.media = { + key: getattr(self._ws_options, key) for key in self._SUPPORTED_KEYS + } + + async def on_patch(self, req, resp): + update = await req.get_media() + for key, value in update.items(): + if key not in self._SUPPORTED_KEYS: + raise falcon.HTTPInvalidParam('unsupported option', key) + setattr(self._ws_options, key, value) + + resp.status = falcon.HTTP_NO_CONTENT + + def create_app(): app = falcon.asgi.App() bucket = Bucket() @@ -276,6 +299,7 @@ def create_app(): app.add_route('/forms', Multipart()) app.add_route('/jars', TestJar()) app.add_route('/feeds/{feed_id}', Feed()) + app.add_route('/wsoptions', WSOptions(app.ws_options)) app.add_middleware(lifespan_handler) diff --git a/tests/asgi/test_asgi_servers.py b/tests/asgi/test_asgi_servers.py index bda3e28a9..aef045d05 100644 --- a/tests/asgi/test_asgi_servers.py +++ b/tests/asgi/test_asgi_servers.py @@ -208,7 +208,20 @@ async def emitter(): class TestWebSocket: @pytest.mark.parametrize('explicit_close', [True, False]) @pytest.mark.parametrize('close_code', [None, 4321]) - async def test_hello(self, explicit_close, close_code, server_url_events_ws): + @pytest.mark.parametrize('max_receive_queue', [0, 4, 17]) + async def test_hello( + self, + explicit_close, + close_code, + max_receive_queue, + server_base_url, + server_url_events_ws, + ): + resp = requests.patch( + server_base_url + 'wsoptions', json={'max_receive_queue': max_receive_queue} + ) + resp.raise_for_status() + echo_expected = 'Check 1 - \U0001f600' extra_headers = {'X-Command': 'recv'} diff --git a/tests/asgi/test_ws.py b/tests/asgi/test_ws.py index c4d2a7d1e..dc068c7f9 100644 --- a/tests/asgi/test_ws.py +++ b/tests/asgi/test_ws.py @@ -1,5 +1,6 @@ import asyncio from collections import deque +import functools import os import pytest @@ -948,36 +949,77 @@ def __init__(self): async def on_websocket(self, req, ws): await ws.accept() - async def raise_disconnect(self): + async def raise_disconnect(event): raise Exception('Disconnected with code = 1000 (OK)') - async def raise_protocol_mismatch(self): + async def raise_io_error(event, cause=''): + class ConnectionClosed(RuntimeError): + pass + + class ClientDisconnected(OSError): + pass + + if cause: + raise ClientDisconnected() from ConnectionClosed(cause) + raise ClientDisconnected() + + async def raise_protocol_mismatch(event): raise Exception('protocol accepted must be from the list') - async def raise_other(self): + async def raise_other(event): raise RuntimeError() + # TODO(vytas): It would be nice to somehow refactor this test not + # to operate on the private members of WebSocket. + # But, OTOH, it is quite useful as it is. _asgi_send = ws._asgi_send + _original_state = ws._state ws._asgi_send = raise_other + ws._state = _original_state try: await ws.send_data(b'123') except Exception: self.error_count += 1 ws._asgi_send = raise_protocol_mismatch + ws._state = _original_state try: await ws.send_data(b'123') except ValueError: self.error_count += 1 ws._asgi_send = raise_disconnect + ws._state = _original_state + try: + await ws.send_data(b'123') + except falcon.WebSocketDisconnected: + self.error_count += 1 + + ws._asgi_send = raise_io_error + ws._state = _original_state try: await ws.send_data(b'123') except falcon.WebSocketDisconnected: self.error_count += 1 + ws._asgi_send = functools.partial(raise_io_error, cause='bork3d pipe') + ws._state = _original_state + try: + await ws.send_data(b'123') + except falcon.WebSocketDisconnected: + self.error_count += 1 + + ws._asgi_send = functools.partial(raise_io_error, cause='received 1001') + ws._state = _original_state + try: + await ws.send_data(b'123') + except falcon.WebSocketDisconnected: + self.error_count += 1 + assert ws._close_code == 1001 + ws._asgi_send = _asgi_send + ws._state = _original_state self.test_complete.set() @@ -988,6 +1030,8 @@ async def raise_other(self): async with c.simulate_ws(): await resource.test_complete.wait() + assert resource.error_count == 6 + def test_ws_base_not_implemented(): th = media.TextBaseHandlerWS() @@ -1062,10 +1106,11 @@ class Resource: await ws.close() - # NOTE(kgriffs): The collector should just eat all subsequent events - # returned from the ASGI app. - for __ in range(100): - await ws._collect({'type': EventType.WS_SEND}) + # NOTE(vytas): The collector should start raising an instance of + # OSError from now on per the ASGI WS spec ver 2.4+. + for __ in range(10): + with pytest.raises(OSError): + await ws._collect({'type': EventType.WS_SEND}) assert not ws._collected_server_events @@ -1372,3 +1417,43 @@ async def handle_foobar(req, resp, ex, param): # type: ignore[misc] async with c.simulate_ws(): pass assert err.value.code == exp_code + + +@pytest.mark.parametrize('max_receive_queue', [0, 1, 4, 7, 17]) +async def test_max_receive_queue_sizes(conductor, max_receive_queue): + class Chat: + async def on_websocket(self, req, ws, user): + await ws.accept() + + broadcast = [ + '[Guest123] ping', + '[John F. Barbaz] Hi everyone.', + f'Hello, {user}!', + ] + + while True: + await ws.send_text(broadcast.pop()) + + msg = await ws.receive_text() + if msg == '/quit': + await ws.send_text('Bye!') + await ws.close(reason='Received /quit') + break + else: + await ws.send_text(f'[{user}] {msg}') + + conductor.app.ws_options.max_receive_queue = max_receive_queue + conductor.app.add_route('/chat/{user}', Chat()) + + received = [] + messages = ['/quit', 'I have to leave this test soon.', 'Hello!'] + + async with conductor as c: + async with c.simulate_ws('/chat/foobarer') as ws: + while messages: + received.append(await ws.receive_text()) + + await ws.send_text(messages.pop()) + received.append(await ws.receive_text()) + + assert len(received) == 6 From 9f47efbadcc793efb719c945f3928eae22c7e455 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Fri, 20 Sep 2024 06:59:34 +0200 Subject: [PATCH 4/6] docs: refresh docs with the PyData Sphinx theme (MVP) (#2300) * docs: refresh docs with the PyData Sphinx theme * chore: reformat with `ruff`, specify a different PDF Latex engine * chore(docs): change Pygments theme for PDF; drop some deps * docs(theme): more tweaks, custom PyPI icon, testing Sphinx-design tabs * docs: add homepage icon * chore: replace sphinx-tabs with sphinx-design * docs: add external links * chore(docs): reenable the dark theme * chore(docs): remove local ToC that are no longer needed with the new theme * chore(docs): use a newer Falconry pygments theme version * docss: remove manually written tocs, add missing module description --------- Co-authored-by: Federico Caselli --- docs/Makefile | 184 +----------- docs/_static/custom-icons.js | 34 +++ docs/_static/custom.css | 394 ++----------------------- docs/api/app.rst | 4 - docs/api/cookies.rst | 8 +- docs/api/cors.rst | 6 +- docs/api/errors.rst | 8 +- docs/api/hooks.rst | 3 - docs/api/inspect.rst | 6 - docs/api/media.rst | 14 +- docs/api/middleware.rst | 8 +- docs/api/multipart.rst | 26 +- docs/api/request_and_response_asgi.rst | 3 - docs/api/request_and_response_wsgi.rst | 3 - docs/api/routing.rst | 20 +- docs/api/status.rst | 2 - docs/api/testing.rst | 2 - docs/api/util.rst | 2 +- docs/api/websocket.rst | 2 - docs/conf.py | 386 +++++++----------------- docs/index.rst | 3 +- docs/user/faq.rst | 10 +- docs/user/quickstart.rst | 16 +- docs/user/recipes/output-csv.rst | 16 +- falcon/media/validators/jsonschema.py | 8 +- requirements/docs | 13 +- 26 files changed, 263 insertions(+), 918 deletions(-) create mode 100644 docs/_static/custom-icons.js diff --git a/docs/Makefile b/docs/Makefile index e85440e47..278a71c7f 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,177 +1,19 @@ -# Makefile for Sphinx documentation -# +# Makefile for Sphinx documentation. -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . BUILDDIR = _build -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - +# Put it first so that "make" without argument is like "make help". help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Falcon.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Falcon.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/Falcon" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Falcon" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." +.PHONY: help Makefile -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/custom-icons.js b/docs/_static/custom-icons.js new file mode 100644 index 000000000..e1fe2f115 --- /dev/null +++ b/docs/_static/custom-icons.js @@ -0,0 +1,34 @@ +/******************************************************************************* + * Set a custom icon for pypi as it's not available in the fa built-in brands + */ +FontAwesome.library.add( + (faListOldStyle = { + prefix: "fa-custom", + iconName: "pypi", + icon: [ + 17.313, // viewBox width + 19.807, // viewBox height + [], // ligature + "e001", // unicode codepoint - private use area + "m10.383 0.2-3.239 1.1769 3.1883 1.1614 3.239-1.1798zm-3.4152 1.2411-3.2362 1.1769 3.1855 1.1614 3.2369-1.1769zm6.7177 0.00281-3.2947 1.2009v3.8254l3.2947-1.1988zm-3.4145 1.2439-3.2926 1.1981v3.8254l0.17548-0.064132 3.1171-1.1347zm-6.6564 0.018325v3.8247l3.244 1.1805v-3.8254zm10.191 0.20931v2.3137l3.1777-1.1558zm3.2947 1.2425-3.2947 1.1988v3.8254l3.2947-1.1988zm-8.7058 0.45739c0.00929-1.931e-4 0.018327-2.977e-4 0.027485 0 0.25633 0.00851 0.4263 0.20713 0.42638 0.49826 1.953e-4 0.38532-0.29327 0.80469-0.65542 0.93662-0.36226 0.13215-0.65608-0.073306-0.65613-0.4588-6.28e-5 -0.38556 0.2938-0.80504 0.65613-0.93662 0.068422-0.024919 0.13655-0.038114 0.20156-0.039466zm5.2913 0.78369-3.2947 1.1988v3.8247l3.2947-1.1981zm-10.132 1.239-3.2362 1.1769 3.1883 1.1614 3.2362-1.1769zm6.7177 0.00213-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4124 1.2439-3.2947 1.1988v3.8254l3.2947-1.1988zm-6.6585 0.016195v3.8275l3.244 1.1805v-3.8254zm16.9 0.21143-3.2947 1.1988v3.8247l3.2947-1.1981zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4124 1.2432-3.2947 1.1988v3.8254l3.2947-1.1988zm-6.6585 0.019027v3.8247l3.244 1.1805v-3.8254zm13.485 1.4497-3.2947 1.1988v3.8247l3.2947-1.1981zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm2.4018 0.38127c0.0093-1.83e-4 0.01833-3.16e-4 0.02749 0 0.25633 0.0085 0.4263 0.20713 0.42638 0.49826 1.97e-4 0.38532-0.29327 0.80469-0.65542 0.93662-0.36188 0.1316-0.65525-0.07375-0.65542-0.4588-1.95e-4 -0.38532 0.29328-0.80469 0.65542-0.93662 0.06842-0.02494 0.13655-0.03819 0.20156-0.03947zm-5.8142 0.86403-3.244 1.1805v1.4201l3.244 1.1805z", // svg path (https://simpleicons.org/icons/pypi.svg) + ], + }), +); + +/******************************************************************************* + * Set a custom icon for Falcon as it's not available in the fa built-in brands. + * NOTE(vytas): But one day we'll be there ("big as Django"). + */ +FontAwesome.library.add( + (faListOldStyle = { + prefix: "fa-custom", + iconName: "falcon", + icon: [ + 16.0, // viewBox width + 16.0, // viewBox height + [], // ligature + "e002", // unicode codepoint - private use area + "M 2.54188 0.40205 C 1.38028 1.60365 0.794478 2.81526 0.794478 4.02187 C 0.794478 4.29723 0.814578 4.53255 0.844578 4.54757 C 0.874678 4.56258 0.894678 4.62768 0.894678 4.68775 C 0.894678 4.82794 1.01478 5.1734 1.17508 5.49883 C 1.38528 5.93441 1.69068 6.23982 2.52188 6.84062 C 3.17268 7.31124 3.53318 7.84195 3.65338 8.51284 L 3.71838 8.87332 L 3.89868 8.79822 C 4.35428 8.60296 4.70468 8.53287 5.75108 8.42272 C 6.42198 8.35263 6.68238 8.2525 6.99278 7.94709 C 7.43336 7.5065 7.22308 7.22112 5.84628 6.40504 C 4.36428 5.52386 3.83358 5.11332 3.25778 4.39737 C 2.79718 3.8216 2.56188 3.34597 2.44668 2.75518 C 2.33658 2.20445 2.48678 1.33329 2.82218 0.55225 C 2.91738 0.321944 2.99748 0.121677 2.99748 0.101651 C 2.99748 -0.00348278 2.83228 0.106713 2.54188 0.40205 Z M 15.8896 0.80759 C 15.8646 0.827621 15.7995 0.927752 15.7544 1.02788 C 15.464 1.64871 14.5028 2.23449 12.9857 2.70511 C 12.8506 2.75018 12.6102 2.82026 12.46 2.87033 C 12.3098 2.9154 12.0144 3.01052 11.8092 3.0706 C 9.26079 3.84663 8.8152 4.24717 7.90398 6.55023 C 7.49344 7.5816 7.27815 7.92706 6.85258 8.20744 C 6.49708 8.44776 6.20668 8.52786 5.40068 8.628 C 4.11898 8.7832 3.62328 8.9334 3.22278 9.28887 C 2.97738 9.49915 2.89728 9.71945 2.89728 10.16 C 2.89728 10.3603 2.87728 10.5355 2.85728 10.5506 C 2.83218 10.5656 2.78718 10.7008 2.75208 10.856 C 2.70208 11.0662 2.70208 11.1714 2.74698 11.3066 C 2.81708 11.5118 3.01238 11.6821 3.18758 11.6821 C 3.31278 11.6821 3.31778 11.647 3.22268 11.4217 C 3.20258 11.3666 3.23268 11.2965 3.31778 11.2064 C 3.42798 11.0963 3.48298 11.0813 3.82348 11.0813 C 4.21398 11.0813 4.34418 11.0412 4.52938 10.871 C 4.63458 10.7759 4.63958 10.7759 4.77978 10.9311 C 5.07518 11.2565 5.84618 11.7171 6.80748 12.1477 C 7.26304 12.353 7.54842 12.5983 7.76371 12.9788 C 7.9089 13.2291 7.92893 13.2992 7.92893 13.6597 C 7.92893 14.2455 7.71865 14.646 7.3081 14.8363 C 7.23801 14.8713 7.13787 14.9164 7.09281 14.9414 C 7.04274 14.9664 6.85748 14.9864 6.67728 14.9864 L 6.35188 14.9864 L 6.24668 15.2218 C 6.18658 15.3569 6.11148 15.5222 6.07148 15.5923 C 6.01638 15.7024 6.01638 15.7374 6.07148 15.8176 C 6.13658 15.9027 6.15658 15.8927 6.39688 15.6473 C 6.64718 15.392 6.80238 15.3169 6.80238 15.4571 C 6.80238 15.4971 6.71228 15.5973 6.60718 15.6824 C 6.36188 15.8776 6.32678 16.0178 6.44198 16.3483 C 6.53708 16.6186 6.66228 16.6987 6.69228 16.5135 C 6.70228 16.4584 6.77738 16.3483 6.85748 16.2682 C 6.93758 16.193 7.00268 16.0979 7.00268 16.0629 C 7.00268 16.0278 7.03775 15.9728 7.0778 15.9377 C 7.18794 15.8476 7.233 16.0078 7.16292 16.2581 C 7.11786 16.4284 7.12286 16.4784 7.18794 16.5485 C 7.23801 16.6036 7.25303 16.6787 7.23301 16.7638 C 7.21303 16.8539 7.22304 16.889 7.26811 16.889 C 7.3382 16.889 7.50342 16.7338 7.50342 16.6687 C 7.50342 16.6487 7.54848 16.5636 7.60856 16.4834 C 7.7137 16.3332 7.7187 16.3032 7.62358 15.7725 C 7.60856 15.6724 7.61357 15.5873 7.63359 15.5873 C 7.73873 15.5873 8.09921 15.8176 8.11924 15.8977 C 8.15428 16.0278 8.30448 15.9527 8.30448 15.8025 C 8.30448 15.6123 8.1693 15.402 8.00408 15.3269 C 7.83886 15.2568 7.80882 15.1467 7.944 15.1066 C 8.25946 15.0065 8.55485 14.8262 8.8202 14.5609 C 9.14063 14.2505 9.1957 14.1854 9.40098 13.885 C 9.68636 13.4694 9.95171 13.2842 10.2571 13.2842 C 10.6777 13.2842 11.9994 13.875 13.0208 14.5208 C 13.4964 14.8212 14.4277 15.4721 14.5829 15.6123 C 14.6129 15.6423 14.7831 15.7675 14.9634 15.8977 C 15.1436 16.0228 15.454 16.2632 15.6543 16.4334 C 15.8546 16.5986 16.0348 16.7388 16.0548 16.7388 C 16.22 16.7388 16.2751 16.3082 16.1299 16.178 C 16.0799 16.138 15.9046 16.0278 15.7394 15.9377 C 15.3739 15.7324 14.668 15.2568 14.3626 15.0015 C 13.6717 14.4357 12.7905 13.3293 12.2047 12.2929 C 12.1496 12.2078 12.0645 12.0526 12.0094 11.9524 C 11.5889 11.2164 10.7878 10.2802 10.5725 10.2802 C 10.4874 10.2802 10.4874 10.2752 10.6226 9.62933 C 10.6626 9.42405 10.7227 9.07359 10.7528 8.8533 C 10.8429 8.24248 10.9731 7.54656 11.0682 7.2011 C 11.3385 6.18474 11.9143 5.47881 12.9607 4.88802 C 14.4277 4.05191 15.0134 3.53622 15.434 2.70011 C 15.6593 2.25451 15.7044 2.14437 15.8395 1.6437 C 15.9096 1.38836 15.9947 0.767537 15.9597 0.767537 C 15.9497 0.767537 15.9196 0.787568 15.8896 0.807587 Z M 7.22808 12.6483 C 7.21807 12.6834 7.20305 12.8086 7.18803 12.9387 C 7.158 13.2892 6.92268 13.7548 6.65228 14.0052 C 6.32688 14.3106 6.12658 14.4057 5.95638 14.3406 C 5.88628 14.3156 5.68608 14.2755 5.51578 14.2605 C 5.26548 14.2305 5.17538 14.2455 5.05518 14.3156 C 4.92498 14.3907 4.89998 14.4407 4.89998 14.5959 C 4.89998 14.8162 4.89488 14.8112 5.08018 14.7111 C 5.16038 14.671 5.25548 14.636 5.29048 14.636 C 5.39568 14.636 5.35558 14.7361 5.22538 14.7962 C 5.08028 14.8613 5.04018 14.9864 5.03018 15.377 C 5.02508 15.6724 5.11528 15.7875 5.19538 15.5772 C 5.22038 15.5122 5.31558 15.422 5.40568 15.372 C 5.50078 15.3219 5.63098 15.1917 5.70108 15.0866 C 5.83118 14.8663 5.90128 14.8312 5.90128 14.9814 C 5.90128 15.3019 6.06658 15.3169 6.20668 15.0065 C 6.29188 14.8363 6.33688 14.7912 6.39698 14.8162 C 6.56218 14.8863 6.75748 14.8963 6.82758 14.8363 C 6.89768 14.7762 6.89268 14.7562 6.79748 14.656 C 6.73748 14.5909 6.69238 14.5309 6.70738 14.5208 C 6.81758 14.4407 7.05787 14.3857 7.3032 14.3857 C 7.74378 14.3857 7.80386 14.3106 7.80386 13.7448 C 7.80386 13.169 7.65366 12.7585 7.40833 12.6534 C 7.28317 12.6033 7.24311 12.5983 7.22809 12.6483 Z M 11.2584 13.7248 C 11.2584 13.7498 11.4437 13.865 11.674 13.9801 C 12.7805 14.5659 13.4614 15.2017 13.7167 15.9077 C 13.8319 16.2131 13.8369 16.3232 13.7367 16.5535 C 13.6116 16.8589 13.5915 16.8539 14.4026 16.8289 C 15.1336 16.8039 15.5642 16.7238 15.5642 16.6086 C 15.5642 16.5535 14.9634 15.9878 14.7431 15.8426 C 14.683 15.8025 14.6279 15.7525 14.6129 15.7375 C 14.5979 15.7224 14.4978 15.6423 14.3876 15.5572 C 14.2775 15.4771 14.1573 15.387 14.1273 15.362 C 14.0922 15.3319 14.0121 15.2718 13.952 15.2268 C 13.8869 15.1767 13.7468 15.0666 13.6366 14.9815 C 13.5265 14.8963 13.3562 14.7812 13.2611 14.7311 C 13.0308 14.606 12.8906 14.5158 12.8606 14.4808 C 12.8255 14.4407 12.1346 14.0652 11.8092 13.91 C 11.659 13.8399 11.4938 13.7598 11.4487 13.7348 C 11.3385 13.6747 11.2584 13.6697 11.2584 13.7248 Z", + ], + }), +); diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 8948ecc90..781a7ff62 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -1,370 +1,26 @@ -body { - font-family: Oxygen, 'goudy old style', 'minion pro', 'bell mt', Georgia, 'Hiragino Mincho Pro', serif; - font-size: 17.5px; -} - - -.field-name { - /* Fix for https://github.com/bitprophet/alabaster/issues/95 */ - -moz-hyphens: manual; - -ms-hyphens: manual; - -webkit-hyphens: manual; - hyphens: manual; - - width: 110px; /* Prevent "Return type:" from wrapping. */ -} - -a { - text-decoration: none; -} - -h1 a:hover, h2 a:hover, h3 a:hover { - text-decoration: none; - border: none; -} - -div.document { - /* max body width + 60px padding + 220px sidebar */ - max-width: calc(760px + 60px + 220px); -} - -div.footer { - text-align: center; -} - -div.footer a:hover { - border-bottom: none; -} - -dd ul, dd table { - margin-bottom: 1.4em; -} - -table.field-list th, table.field-list td { - padding-top: 1em; -} - -table.field-list tbody tr:first-child th, table.field-list tbody tr:first-child td { - padding-top: 0; -} - -code.docutils.literal { - background-color: rgba(0, 0, 0, 0.06); - padding: 2px 5px 1px 5px; - font-size: 0.88em; -} - -code.xref.docutils.literal { - background-color: transparent; - padding: 0; - font-size: 0.9em; -} - -div.viewcode-block:target { - background: inherit; - background-color: #dadada; - border-radius: 5px; - padding: 5px; -} - -a:hover, div.sphinxsidebar a:hover, a.reference:hover, a.reference.internal:hover code { - color: #f0ad4e; - border-bottom: 1px solid #f0ad4e; -} - -a, div.sphinxsidebar a, a.reference, a code.literal { - color: #c77c11; - border: none; -} - -.highlight pre span { - line-height: 1.5em; -} - -.field-body cite { - font-style: normal; - font-weight: bold; -} - -/* Hide theme's default logo section */ -.logo a { - display: none; -} - -#logo { - position: relative; - left: -13px; -} - -#logo a, -#logo a:hover { - border-bottom: none; -} - -#logo img { - margin: 0; - padding: 0; -} - -#gh-buttons { - margin-top: 2em; -} - -#dev-warning { - background-color: #fdfbe8; - border: 1px solid #ccc; - padding: 10px; - margin-bottom: 1em; - } - -div.warning { - background-color: #fdfbe8; - border: 1px solid #ccc; -} - -div.body h1, -div.body h2, -div.body h3, -div.body h4, -div.body h5, -div.body h6, -div.sphinxsidebar h3 { - font-family: Oxygen, 'goudy old style', serif; - font-weight: bold; - color: #444; -} - -div.sphinxsidebar h3 { - margin: 1.5em 0 0 0; -} - -div.sphinxsidebar h4 { - font-family: Oxygen, Garamond, Georgia, serif; -} - -div.sphinxsidebar ul { - margin-top: 5px; -} - -div.sphinxsidebarwrapper { - padding-top: 0; -} - -div.admonition p.admonition-title { - display: block; - line-height: 1.4em; - font-family: Oxygen, Garamond, Georgia, serif; -} - -div.admonition .last.highlight-default { - display: inline-block; - font-size: smaller; -} - -pre { - /*background-color: #212529;*/ - padding: 1.25em 30px; -} - -.sphinx-tabs div.node, -.sphinx-tabs div.admonition, -.sphinx-tabs div.highlight -{ - margin-left: -8px; - margin-right: -8px; -} - -.highlight pre { - font-size: 14px; -} - - -/* NOTE(kgriffs): Make sure that characters in plain text blocks line up correctly. */ -.highlight-none .highlight pre { - line-height: 1em; - font-family: monospace; -} - -/* Fix drifting to the left in some parameters lists. */ -.field-body li .highlight pre { - float: right; -} - -div input[type="text"] { - padding: 5px; - margin: 0 0 0.5em 0; - font-family: Oxygen, Garamond, Georgia, serif; -} - -div input[type="submit"] { - font-size: 14px; - width: 72px; - height: 27px; - font-weight: 600; - font-family: Oxygen, Garamond, Georgia, serif; - - border: 1px solid #d5d5d5; - border-radius: 3px; - padding: 0 10px; - - color: #333; - background-color: #eee; - background-image: linear-gradient(to bottom,#fcfcfc,#eee); -} - -div input[type="submit"]:hover { - background-color: #ddd; - background-image: linear-gradient(to bottom,#eee,#ddd); - border-color: #ccc; -} - -div input[type="submit"]:active, #searchbox input[type="submit"]:active { - background-color: #dcdcdc; - background-image: none; - border-color: #b5b5b5; - box-shadow: inset 0 2px 4px rgba(0,0,0,.15); -} - -div input[type="submit"]:focus { - outline: none; -} - -input[type=text]:focus { - outline: 1px solid #999; -} - -#sidebarSupportFalcon a:hover, #sidebarSupportFalcon a:focus { - text-decoration: none; - border: none; - outline: none; -} - -dl.field-list > dt { - word-break: normal; - padding-left: 0; - hyphens: none; - margin-top: 0.2em; -} - -dl.field-list > dd { - margin-top: 0.2em; -} - -dl.field-list ul { - list-style: none; -} - -dl.field-list ul li { - list-style: none; - padding-bottom: 0.5em; -} - -dl.field-list.simple dd ul { - padding-left: 0; -} - -dl.field-list.simple dd ul li p:not(:first-child) { - margin-top: 0.4em; -} - -dl.attribute > dd > p { - margin: 0 0 1em 0; -} - -/* NOTE(kgriffs): Fix spacing issue with embedded Note blocks, and make - things generally more readable by spacing the paragraphs. */ -.field-list p { - margin-bottom: 1em; -} - -dl.function, dl.method, dl.attribute, dl.class { - padding-top: 1em; -} - -div.body div.section > ul.simple > li > dl.simple > dd > ul { - margin: 0; -} - -div.body div.toctree-wrapper.compound > ul > li.toctree-l1 > ul { - margin-top: 0; -} - -div.contents.local.topic { - background: none; - border: none; -} - -div.contents.local.topic > ul > li > p { - margin-bottom: 0; -} - -div.contents.local.topic > ul > li > ul { - margin-top: 0; -} - -[role="tablist"] { - border-color: #eee; -} - -.sphinx-tabs-panel { - border-color: #eee; - padding: 4px 1rem; -} - -.sphinx-tabs-tab { - top: 2px; - border-color: #eee; - color: #c77c11; -} - -.sphinx-tabs-tab[aria-selected="true"] { - border-color: #eee; -} - -.sphinx-tabs-tab[aria-selected="false"]:hover { - color: #f0ad4e; - text-decoration: underline; -} - -.sphinx-tabs-tab:focus { - z-index: 0; -} - -.sphinx-tabs-panel .highlight-python { - margin: 4px 0; -} - -div.note, pre { - background-color: rgb(245,245,245); -} - -div.note div.highlight > pre, div.admonition div.highlight > pre { - border: none; -} - -div.note, div.admonition { - /* Match tab radius */ - border-radius: .285714rem; - border-color: #eee; - padding-bottom: 1em; -} - -div.ui.bottom.attached.sphinx-tab.tab { - /* Match tab radius */ - border-bottom-left-radius: .285714rem; - border-bottom-right-radius: .285714rem; -} - -div.highlight > pre { - border: 1px solid #eee; - border-radius: .285714rem; -} - -.sphinx-tab pre { - border: none !important; -} - -/* TODO: remove once alabaster is updated */ -span.descname, span.descclassname { - font-size: 0.95em; +/* Customize the PyData theme's font colors. */ + +/* Some ideas partially inspired by Bokeh docs. */ +html[data-theme=light] { + --pst-color-borders: rgb(206, 212, 218); + --pst-color-primary: var(--pst-color-text-base); + /* Darken Falconry orange to meet WCAG 2.1 AA contrast */ + --pst-color-secondary: rgb(169, 103, 9); + --pst-color-link: var(--pst-color-secondary); + --pst-color-success: rgb(165, 205, 57); + --pst-color-admonition-tip: var(--pst-color-success); + --pst-color-inline-code: var(--pst-color-text-base); + --pst-color-inline-code-links: var(--pst-color-secondary); + --pst-color-surface: rgb(255, 250, 234); +} + +html[data-theme=dark] { + --pst-color-primary: var(--pst-color-text-base); + --pst-color-secondary: rgb(240, 173, 78); + --pst-color-link: var(--pst-color-secondary); + --pst-color-inline-code: var(--pst-color-text-base); + --pst-color-inline-code-links: var(--pst-color-secondary); + --pst-color-background: rgb(8, 6, 2); + --pst-color-on-background: rgb(39, 37, 35); + --pst-color-surface: rgb(31, 31, 31); } diff --git a/docs/api/app.rst b/docs/api/app.rst index b865a2d6f..9e56a4518 100644 --- a/docs/api/app.rst +++ b/docs/api/app.rst @@ -3,10 +3,6 @@ The App Class ============= -* `WSGI App`_ -* `ASGI App`_ -* `Options`_ - Falcon supports both the WSGI (:class:`falcon.App`) and ASGI (:class:`falcon.asgi.App`) protocols. This is done by instantiating the respective ``App`` class to create a diff --git a/docs/api/cookies.rst b/docs/api/cookies.rst index 719e025e4..5089957cc 100644 --- a/docs/api/cookies.rst +++ b/docs/api/cookies.rst @@ -3,7 +3,7 @@ Cookies ------- -.. contents:: :local: +This page describes the API provided by Falcon to manipulate cookies. .. _getting-cookies: @@ -24,9 +24,9 @@ need a collection of all the cookies in the request. Here's an example showing how to get cookies from a request: -.. tabs:: +.. tab-set:: - .. tab:: WSGI + .. tab-item:: WSGI .. code:: python @@ -43,7 +43,7 @@ Here's an example showing how to get cookies from a request: # will need to choose how to handle the additional values. v = my_cookie_values[0] - .. tab:: ASGI + .. tab-item:: ASGI .. code:: python diff --git a/docs/api/cors.rst b/docs/api/cors.rst index d19bf7a78..899278074 100644 --- a/docs/api/cors.rst +++ b/docs/api/cors.rst @@ -29,9 +29,9 @@ can be exposed. Usage ----- -.. tabs:: +.. tab-set:: - .. tab:: WSGI + .. tab-item:: WSGI .. code:: python @@ -45,7 +45,7 @@ Usage app = falcon.App(middleware=falcon.CORSMiddleware( allow_origins='example.com', allow_credentials='*')) - .. tab:: ASGI + .. tab-item:: ASGI .. code:: python diff --git a/docs/api/errors.rst b/docs/api/errors.rst index 462e188a6..528d435e2 100644 --- a/docs/api/errors.rst +++ b/docs/api/errors.rst @@ -3,8 +3,6 @@ Error Handling ============== -.. contents:: :local: - When it comes to error handling, you can always directly set the error status, appropriate response headers, and error body using the ``resp`` object. However, Falcon tries to make things a little easier by @@ -48,9 +46,9 @@ To customize what data is passed to the serializer, subclass All classes are available directly in the ``falcon`` package namespace: -.. tabs:: +.. tab-set:: - .. tab:: WSGI + .. tab-item:: WSGI .. code:: python @@ -68,7 +66,7 @@ All classes are available directly in the ``falcon`` package namespace: # -- snip -- - .. tab:: ASGI + .. tab-item:: ASGI .. code:: python diff --git a/docs/api/hooks.rst b/docs/api/hooks.rst index a3adf4333..91aacd3a5 100644 --- a/docs/api/hooks.rst +++ b/docs/api/hooks.rst @@ -3,9 +3,6 @@ Hooks ===== -* `Before Hooks`_ -* `After Hooks`_ - Falcon supports *before* and *after* hooks. You install a hook simply by applying one of the decorators below, either to an individual responder or to an entire resource. diff --git a/docs/api/inspect.rst b/docs/api/inspect.rst index 48cbfaec5..7e3d46283 100644 --- a/docs/api/inspect.rst +++ b/docs/api/inspect.rst @@ -3,12 +3,6 @@ Inspect Module ============== -* `Using Inspect Functions`_ -* `Inspect Functions Reference`_ -* `Router Inspection`_ -* `Information Classes`_ -* `Visitor Classes`_ - This module can be used to inspect a Falcon application to obtain information about its registered routes, middleware objects, static routes, sinks and error handlers. The entire application can be inspected at once using the diff --git a/docs/api/media.rst b/docs/api/media.rst index dbff05383..ac8105d9f 100644 --- a/docs/api/media.rst +++ b/docs/api/media.rst @@ -3,8 +3,6 @@ Media ===== -.. contents:: :local: - Falcon allows for easy and customizable internet media type handling. By default Falcon only enables handlers for JSON and HTML (URL-encoded and multipart) forms. However, additional handlers can be configured through the @@ -26,9 +24,9 @@ Zero configuration is needed if you're creating a JSON API. Simply use :attr:`~falcon.asgi.Response.media` (ASGI) to let Falcon do the heavy lifting for you. -.. tabs:: +.. tab-set:: - .. tab:: WSGI + .. tab-item:: WSGI .. code:: python @@ -52,7 +50,7 @@ do the heavy lifting for you. resp.media = {'message': message} resp.status = falcon.HTTP_200 - .. tab:: ASGI + .. tab-item:: ASGI .. code:: python @@ -109,9 +107,9 @@ response. If you do need full negotiation, it is very easy to bridge the gap using middleware. Here is an example of how this can be done: -.. tabs:: +.. tab-set:: - .. tab:: WSGI + .. tab-item:: WSGI .. code:: python @@ -121,7 +119,7 @@ middleware. Here is an example of how this can be done: def process_request(self, req: Request, resp: Response) -> None: resp.content_type = req.accept - .. tab:: ASGI + .. tab-item:: ASGI .. code:: python diff --git a/docs/api/middleware.rst b/docs/api/middleware.rst index 3cfbb18cd..251e8e8c0 100644 --- a/docs/api/middleware.rst +++ b/docs/api/middleware.rst @@ -3,8 +3,6 @@ Middleware ========== -.. contents:: :local: - Middleware components provide a way to execute logic before the framework routes each request, after each request is routed but before the target responder is called, or just before the response is returned @@ -18,9 +16,9 @@ when instantiating Falcon's :ref:`App class `. A middleware component is simply a class that implements one or more of the event handler methods defined below. -.. tabs:: +.. tab-set:: - .. tab:: WSGI + .. tab-item:: WSGI Falcon's middleware interface is defined as follows: @@ -96,7 +94,7 @@ defined below. app = App(middleware=[ExampleMiddleware()]) - .. tab:: ASGI + .. tab-item:: ASGI The ASGI middleware interface is similar to WSGI, but also supports the standard ASGI lifespan events. However, because lifespan events are an diff --git a/docs/api/multipart.rst b/docs/api/multipart.rst index cf2620414..a4bb664cf 100644 --- a/docs/api/multipart.rst +++ b/docs/api/multipart.rst @@ -3,17 +3,16 @@ Multipart Forms =============== -.. contents:: :local: - Falcon features easy and efficient access to submitted multipart forms by using :class:`~falcon.media.MultipartFormHandler` to handle the ``multipart/form-data`` :ref:`media ` type. This handler is enabled by default, allowing you to use ``req.get_media()`` to iterate over the :class:`body parts ` in a form: -.. tabs:: +.. tab-set:: - .. group-tab:: WSGI + .. tab-item:: WSGI + :sync: wsgi .. code:: python @@ -38,7 +37,8 @@ default, allowing you to use ``req.get_media()`` to iterate over the # Do something else form_data[part.name] = part.text - .. group-tab:: ASGI + .. tab-item:: ASGI + :sync: asgi .. code:: python @@ -72,9 +72,10 @@ default, allowing you to use ``req.get_media()`` to iterate over the Multipart Form and Body Part Types ---------------------------------- -.. tabs:: +.. tab-set:: - .. group-tab:: WSGI + .. tab-item:: WSGI + :sync: wsgi .. autoclass:: falcon.media.multipart.MultipartForm :members: @@ -82,7 +83,8 @@ Multipart Form and Body Part Types .. autoclass:: falcon.media.multipart.BodyPart :members: - .. group-tab:: ASGI + .. tab-item:: ASGI + :sync: asgi .. autoclass:: falcon.asgi.multipart.MultipartForm :members: @@ -126,9 +128,10 @@ way is to directly modify the properties of this attribute on the media handler In order to use your customized handler in an app, simply replace the default handler for ``multipart/form-data`` with the new one: -.. tabs:: +.. tab-set:: - .. group-tab:: WSGI + .. tab-item:: WSGI + :sync: wsgi .. code:: python @@ -137,7 +140,8 @@ handler for ``multipart/form-data`` with the new one: # handler is instantiated and configured as per the above snippet app.req_options.media_handlers[falcon.MEDIA_MULTIPART] = handler - .. group-tab:: ASGI + .. tab-item:: ASGI + :sync: asgi .. code:: python diff --git a/docs/api/request_and_response_asgi.rst b/docs/api/request_and_response_asgi.rst index 8d7d154cc..579d3d664 100644 --- a/docs/api/request_and_response_asgi.rst +++ b/docs/api/request_and_response_asgi.rst @@ -3,9 +3,6 @@ ASGI Request & Response ======================= -* `Request`_ -* `Response`_ - Instances of the :class:`falcon.asgi.Request` and :class:`falcon.asgi.Response` classes are passed into responders as the second and third arguments, respectively: diff --git a/docs/api/request_and_response_wsgi.rst b/docs/api/request_and_response_wsgi.rst index 540f26f42..3b715b643 100644 --- a/docs/api/request_and_response_wsgi.rst +++ b/docs/api/request_and_response_wsgi.rst @@ -3,9 +3,6 @@ WSGI Request & Response ======================= -* `Request`_ -* `Response`_ - Instances of the :class:`falcon.Request` and :class:`falcon.Response` classes are passed into WSGI app responders as the second and third arguments, respectively: diff --git a/docs/api/routing.rst b/docs/api/routing.rst index 347b3516d..4d339c9da 100644 --- a/docs/api/routing.rst +++ b/docs/api/routing.rst @@ -3,8 +3,6 @@ Routing ======= -.. contents:: :local: - Falcon uses resource-based routing to encourage a RESTful architectural style. Each resource is represented by a class that is responsible for handling all of the HTTP methods that the resource supports. @@ -29,9 +27,9 @@ associated resource for processing. Here's a quick example to show how all the pieces fit together: -.. tabs:: +.. tab-set:: - .. tab:: WSGI + .. tab-item:: WSGI .. code:: python @@ -66,7 +64,7 @@ Here's a quick example to show how all the pieces fit together: images = ImagesResource() app.add_route('/images', images) - .. tab:: ASGI + .. tab-item:: ASGI .. code:: python @@ -205,9 +203,9 @@ A PUT request to ``'/user/kgriffs'`` would cause the framework to invoke the ``on_put()`` responder method on the route's resource class, passing ``'kgriffs'`` via an additional `name` argument defined by the responder: -.. tabs:: +.. tab-set:: - .. tab:: WSGI + .. tab-item:: WSGI .. code:: python @@ -216,7 +214,7 @@ the ``on_put()`` responder method on the route's resource class, passing def on_put(self, req, resp, name): pass - .. tab:: ASGI + .. tab-item:: ASGI .. code:: python @@ -511,9 +509,9 @@ support custom HTTP methods, use one of the following methods: Once you have used the appropriate method, your custom methods should be active. You then can define request methods like any other HTTP method: -.. tabs:: +.. tab-set:: - .. tab:: WSGI + .. tab-item:: WSGI .. code:: python @@ -521,7 +519,7 @@ You then can define request methods like any other HTTP method: def on_foo(self, req, resp): pass - .. tab:: ASGI + .. tab-item:: ASGI .. code:: python diff --git a/docs/api/status.rst b/docs/api/status.rst index 449b2a071..13f6a1fd3 100644 --- a/docs/api/status.rst +++ b/docs/api/status.rst @@ -3,8 +3,6 @@ Status Codes ============ -.. contents:: :local: - Falcon provides a list of constants for common `HTTP response status codes `_. diff --git a/docs/api/testing.rst b/docs/api/testing.rst index c68267d35..67357cb52 100644 --- a/docs/api/testing.rst +++ b/docs/api/testing.rst @@ -3,8 +3,6 @@ Testing Helpers =============== -.. contents:: :local: - .. automodule:: falcon.testing :noindex: diff --git a/docs/api/util.rst b/docs/api/util.rst index d5dce81cb..810790448 100644 --- a/docs/api/util.rst +++ b/docs/api/util.rst @@ -3,7 +3,7 @@ Utilities ========= -.. contents:: :local: +This page describes miscellaneous utilities provided by Falcon. URI --- diff --git a/docs/api/websocket.rst b/docs/api/websocket.rst index b3bf42c05..6e632c521 100644 --- a/docs/api/websocket.rst +++ b/docs/api/websocket.rst @@ -3,8 +3,6 @@ WebSocket (ASGI Only) ===================== -.. contents:: :local: - Falcon builds upon the `ASGI WebSocket Specification `_ to provide a simple, no-nonsense WebSocket server implementation. diff --git a/docs/conf.py b/docs/conf.py index abd0a92c5..492c0028d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,17 +1,23 @@ -# Falcon documentation build configuration file, created by -# sphinx-quickstart on Wed Mar 12 14:14:02 2014. +# Copyright 2014-2024 by Falcon Contributors. # -# This file is execfile()d with the current directory set to its -# containing dir. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at # -# Note that not all possible configuration values are present in this -# autogenerated file. +# http://www.apache.org/licenses/LICENSE-2.0 # -# All configuration values have a default; values that are commented out -# serve to show the default. +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. -from collections import OrderedDict -from datetime import datetime +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +import datetime import multiprocessing import os import sys @@ -20,6 +26,8 @@ import falcon # noqa: E402 +# -- Build tweaks ------------------------------------------------------------- + # NOTE(kgriffs): Work around the change in Python 3.8 that breaks sphinx # on macOS. See also: # @@ -29,11 +37,11 @@ if not sys.platform.startswith('win'): multiprocessing.set_start_method('fork') -# on_rtd is whether we are on readthedocs.org -on_rtd = os.environ.get('READTHEDOCS', None) == 'True' +# _on_rtd is whether we are on readthedocs.org +# _on_rtd = os.environ.get('READTHEDOCS', None) == 'True' # Used to alter sphinx configuration for the Dash documentation build -dash_build = os.environ.get('DASHBUILD', False) == 'True' +_dash_build = os.environ.get('DASHBUILD', False) == 'True' # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -41,24 +49,32 @@ sys.path.insert(0, os.path.abspath('..')) sys.path.insert(0, os.path.abspath('.')) -# Path to custom themes -sys.path.append(os.path.abspath('_themes')) +# -- Project information ------------------------------------------------------ +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -# -- General configuration ------------------------------------------------ +_version_components = falcon.__version__.split('.') +_prerelease_version = any( + not component.isdigit() and not component.startswith('post') + for component in _version_components +) + + +project = 'Falcon' +copyright = '{year} Falcon Contributors'.format(year=datetime.datetime.now().year) +author = 'Kurt Griffiths et al.' +version = '.'.join(_version_components[0:2]) +release = falcon.__version__ -# If your documentation needs a minimal Sphinx version, state it here. -# needs_sphinx = '1.0' +# -- General configuration ---------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. extensions = [ - 'sphinx.ext.intersphinx', 'sphinx.ext.autodoc', - 'sphinx.ext.viewcode', + 'sphinx.ext.intersphinx', 'sphinx.ext.napoleon', - 'sphinx_tabs.tabs', - 'sphinx_tabs.tabs', + 'sphinx.ext.viewcode', + 'sphinx_copybutton', + 'sphinx_design', 'myst_parser', # Falcon-specific extensions 'ext.cibuildwheel', @@ -67,232 +83,99 @@ 'ext.rfc', ] -# Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] - -# The suffix of source filenames. -source_suffix = '.rst' - -# The encoding of source files. -# source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = 'Falcon' -copyright = '{year} Falcon Contributors'.format(year=datetime.now().year) - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. - -_version_components = falcon.__version__.split('.') -_prerelease = any( - not component.isdigit() and not component.startswith('post') - for component in _version_components -) - -html_context = {'prerelease': _prerelease} - -# The short X.Y version. -version = '.'.join(_version_components[0:2]) - -# The full version, including alpha/beta/rc tags. -release = falcon.__version__ - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# today = '' -# Else, today_fmt is used as the format for a strftime call. -# today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. exclude_patterns = ['_build', '_newsfragments'] -# The reST default role (used for this markup: `text`) to use for all -# documents. -# default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -# add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -# add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -# show_authors = False - # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'github' - -# A list of ignored prefixes for module index sorting. -# modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -# keep_warnings = False - - -# -- Options for HTML output ---------------------------------------------- +# NOTE(vytas): The PyData theme uses separate Pygments style settings for HTML, +# so we specify a print-friendly theme here for the likes of latexpdf. +pygments_style = 'bw' -# Add any paths that contain custom themes here, relative to this directory. -# html_theme_path = ['_themes'] -# html_theme = '' - -html_theme = 'alabaster' - -# if not on_rtd: -# # Use the RTD theme explicitly if it is available -# try: -# import sphinx_rtd_theme - -# html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] -# html_theme = "sphinx_rtd_theme" -# except ImportError: -# pass - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -html_theme_options = { - 'page_width': '80%', - 'body_max_width': '100%', - 'github_user': 'falconry', - 'github_repo': 'falcon', - 'github_button': False, - 'github_banner': False, - 'fixed_sidebar': False, - 'show_powered_by': False, - 'extra_nav_links': OrderedDict( - [ - ('Falcon Home', 'https://falconframework.org/'), - ('Falcon Wiki', 'https://github.com/falconry/falcon/wiki'), - ('GitHub Project', 'https://github.com/falconry/falcon'), - ('Get Help', '/community/help.html'), - ( - 'Support Falcon', - 'https://falconframework.org/#sectionSupportFalconDevelopment', - ), - ] - ), -} - -if dash_build: - html_theme_options.update( - { - 'font_size': 13, - } - ) - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -# html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -# html_short_title = None +# Intersphinx configuration +intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -# html_logo = '../falcon.png' +# -- Options for HTML output -------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. +html_css_files = ['custom.css'] +html_js_files = ['custom-icons.js'] html_favicon = '_static/img/favicon.ico' - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". +html_logo = '_static/img/logo.svg' html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -# html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -# html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -# html_sidebars = { -# 'index': ['side-primary.html', 'searchbox.html'], -# '**': ['side-secondary.html', 'localtoc.html', -# 'relations.html', 'searchbox.html'] -# } - -html_sidebars = { - '**': [ - 'sidebar-top.html', - 'sidebar-sponsors.html', - 'about.html', - 'navigation.html', - 'relations.html', - 'searchbox.html', - ] - if not dash_build - else [] +html_theme = 'pydata_sphinx_theme' +html_show_sourcelink = False + +html_context = { + # NOTE(vytas): We don't provide any default, the browser's preference + # should be used. + # 'default_mode': 'light', + 'prerelease': _prerelease_version, # True if tag is not the empty string } -# Additional templates that should be rendered to pages, maps page names to -# template names. -# html_additional_pages = {} - -# If false, no module index is generated. -# html_domain_indices = True - -# If false, no index is generated. -# html_use_index = True +# Theme options are theme-specific and customize the look and feel further. +# https://pydata-sphinx-theme.readthedocs.io/en/stable/user_guide/index.html -# If true, the index is split into individual pages for each letter. -# html_split_index = False - -# If true, links to the reST sources are added to the pages. -# html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -# html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# html_use_opensearch = '' +html_theme_options = { + 'pygments_light_style': 'falconry-light', + 'pygments_dark_style': 'falconry-dark', + 'header_links_before_dropdown': 4, + 'external_links': [ + { + 'name': 'Get Help', + 'url': 'https://falcon.readthedocs.io/community/help.html', + }, + {'name': 'Falcon Wiki', 'url': 'https://github.com/falconry/falcon/wiki'}, + { + 'name': 'Support Falcon', + 'url': 'https://falconframework.org/#sectionSupportFalconDevelopment', + }, + ], + 'icon_links': [ + { + 'name': 'GitHub', + 'url': 'https://github.com/falconry/falcon', + 'icon': 'fa-brands fa-github', + }, + { + 'name': 'PyPI', + 'url': 'https://pypi.org/project/falcon', + 'icon': 'fa-custom fa-pypi', + }, + { + 'name': 'Falcon Home', + 'url': 'https://falconframework.org', + 'icon': 'fa-custom fa-falcon', + }, + ], + # NOTE(vytas): Use only light theme for now. + # Add `theme-switcher` below to resurrect the dark option. + 'logo': { + 'text': 'Falcon', + # "image_dark": "_static/img/logo.svg", + }, + 'navbar_end': ['theme-switcher', 'navbar-icon-links'], + 'footer_start': ['copyright'], + 'footer_end': ['theme-version'], +} -# This is the file name suffix for HTML files (e.g. ".xhtml"). -# html_file_suffix = None +if _dash_build: + html_theme_options.update(font_size=13) -# Output file base name for HTML help builder. -htmlhelp_basename = 'Falcondoc' +# -- Options for LaTeX output ------------------------------------------------- -# -- Options for LaTeX output --------------------------------------------- +# NOTE(vytas): The default engine fails to build citing unsupported Unicode +# characters. +latex_engine = 'xelatex' latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # 'papersize': 'letterpaper', + 'papersize': 'a4paper', # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # 'preamble': '', } -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). latex_documents = [ ( 'index', @@ -303,42 +186,12 @@ ), ] -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# latex_use_parts = False - -# If true, show page references after internal links. -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# latex_appendices = [] - -# If false, no module index is generated. -# latex_domain_indices = True +# -- Options for manual page output ------------------------------------------- - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). man_pages = [('index', 'falcon', 'Falcon Documentation', ['Kurt Griffiths et al.'], 1)] -# If true, show URL addresses after external links. -# man_show_urls = False - +# -- Options for Texinfo output ----------------------------------------------- -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) texinfo_documents = [ ( 'index', @@ -350,18 +203,3 @@ 'Miscellaneous', ), ] - -# Documents to append as an appendix to all manuals. -# texinfo_appendices = [] - -# If false, no module index is generated. -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -# texinfo_no_detailmenu = False - -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} diff --git a/docs/index.rst b/docs/index.rst index 8e3ac1f0d..359062b5f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,7 +21,6 @@ while remaining highly effective. import falcon class QuoteResource: - def on_get(self, req, resp): """Handles GET requests""" quote = { @@ -128,7 +127,7 @@ Documentation :maxdepth: 3 user/index - deploy/index community/index api/index changes/index + deploy/index diff --git a/docs/user/faq.rst b/docs/user/faq.rst index 11a394b63..56826fbe0 100644 --- a/docs/user/faq.rst +++ b/docs/user/faq.rst @@ -3,8 +3,6 @@ FAQ === -.. contents:: :local: - Design Philosophy ~~~~~~~~~~~~~~~~~ @@ -689,9 +687,10 @@ The `stream` of a body part is a file-like object implementing the ``read()`` method, making it compatible with ``boto3``\'s `upload_fileobj `_: -.. tabs:: +.. tab-set:: - .. group-tab:: WSGI + .. tab-item:: WSGI + :sync: wsgi .. code:: python @@ -705,7 +704,8 @@ method, making it compatible with ``boto3``\'s if part.name == 'myfile': s3.upload_fileobj(part.stream, 'mybucket', 'mykey') - .. group-tab:: ASGI + .. tab-item:: ASGI + :sync: asgi .. code:: python diff --git a/docs/user/quickstart.rst b/docs/user/quickstart.rst index 43157ac9f..38f0f88c7 100644 --- a/docs/user/quickstart.rst +++ b/docs/user/quickstart.rst @@ -13,9 +13,10 @@ Learning by Example Here is a simple example from Falcon's README, showing how to get started writing an app. -.. tabs:: +.. tab-set:: - .. group-tab:: WSGI + .. tab-item:: WSGI + :sync: wsgi .. literalinclude:: ../../examples/things.py :language: python @@ -41,7 +42,8 @@ started writing an app. $ pip install --upgrade httpie $ http localhost:8000/things - .. group-tab:: ASGI + .. tab-item:: ASGI + :sync: asgi .. literalinclude:: ../../examples/things_asgi.py :language: python @@ -75,9 +77,10 @@ A More Complex Example Here is a more involved example that demonstrates reading headers and query parameters, handling errors, and working with request and response bodies. -.. tabs:: +.. tab-set:: - .. group-tab:: WSGI + .. tab-item:: WSGI + :sync: wsgi Note that this example assumes that the `requests `_ package has been installed. @@ -135,7 +138,8 @@ parameters, handling errors, and working with request and response bodies. • Error handlers: ⇜ StorageError handle - .. group-tab:: ASGI + .. tab-item:: ASGI + :sync: asgi Note that this example requires the `httpx `_ package in lieu of diff --git a/docs/user/recipes/output-csv.rst b/docs/user/recipes/output-csv.rst index 42556253b..8f3f8b948 100644 --- a/docs/user/recipes/output-csv.rst +++ b/docs/user/recipes/output-csv.rst @@ -9,14 +9,16 @@ file is a fairly common back-end service task. The easiest approach is to simply write CSV rows to an ``io.StringIO`` stream, and then assign its value to :attr:`resp.text `: -.. tabs:: +.. tab-set:: - .. group-tab:: WSGI + .. tab-item:: WSGI + :sync: wsgi .. literalinclude:: ../../../examples/recipes/output_csv_text_wsgi.py :language: python - .. group-tab:: ASGI + .. tab-item:: ASGI + :sync: asgi .. literalinclude:: ../../../examples/recipes/output_csv_text_asgi.py :language: python @@ -38,14 +40,16 @@ our own pseudo stream object. Our stream's ``write()`` method will simply accumulate the CSV data in a list. We will then set :attr:`resp.stream ` to a generator yielding data chunks from this list: -.. tabs:: +.. tab-set:: - .. group-tab:: WSGI + .. tab-item:: WSGI + :sync: wsgi .. literalinclude:: ../../../examples/recipes/output_csv_stream_wsgi.py :language: python - .. group-tab:: ASGI + .. tab-item:: ASGI + :sync: asgi .. literalinclude:: ../../../examples/recipes/output_csv_stream_wsgi.py :language: python diff --git a/falcon/media/validators/jsonschema.py b/falcon/media/validators/jsonschema.py index 8fc53ade9..0e6f14b67 100644 --- a/falcon/media/validators/jsonschema.py +++ b/falcon/media/validators/jsonschema.py @@ -56,9 +56,9 @@ def validate(req_schema=None, resp_schema=None, is_async=False): Example: - .. tabs:: + .. tab-set:: - .. tab:: WSGI + .. tab-item:: WSGI .. code:: python @@ -71,7 +71,7 @@ def on_post(self, req, resp): # -- snip -- - .. tab:: ASGI + .. tab-item:: ASGI .. code:: python @@ -84,7 +84,7 @@ async def on_post(self, req, resp): # -- snip -- - .. tab:: ASGI (Cythonized App) + .. tab-item:: ASGI (Cythonized App) .. code:: python diff --git a/requirements/docs b/requirements/docs index 4e4fece59..1eb338fb8 100644 --- a/requirements/docs +++ b/requirements/docs @@ -1,11 +1,8 @@ -docutils doc2dash -jinja2 -markupsafe -pygments +falconry-pygments-theme >= 0.2.0 myst-parser -pygments-style-github +pydata-sphinx-theme PyYAML -sphinx -sphinx_rtd_theme -sphinx-tabs +Sphinx +sphinx-copybutton +sphinx_design From ddff2ce8d4a5efa142c12ba023936082f44e8c6f Mon Sep 17 00:00:00 2001 From: Federico Caselli Date: Fri, 20 Sep 2024 16:41:22 +0200 Subject: [PATCH 5/6] feat(typing): type response (#2304) * typing: type app * typing: type websocket module * typing: type asgi.reader, asgi.structures, asgi.stream * typing: type most of media * typing: type multipart * typing: type response * style: fix spelling in multipart.py * style(tests): explain referencing the same property multiple times * style: fix linter errors * chore: revert behavioral change to cors middleware. * chore: do not build rapidjson on PyPy --------- Co-authored-by: Vytautas Liuolia --- falcon/app.py | 14 +- falcon/asgi/app.py | 12 +- falcon/asgi/response.py | 253 ++++++---------- falcon/constants.py | 4 - falcon/media/urlencoded.py | 4 +- falcon/middleware.py | 2 +- falcon/request.py | 6 +- falcon/responders.py | 40 ++- falcon/response.py | 535 ++++++++++++++++++++++------------ falcon/response_helpers.py | 38 ++- falcon/routing/static.py | 6 +- falcon/typing.py | 7 + pyproject.toml | 4 - tests/test_cors_middleware.py | 23 ++ tests/test_headers.py | 3 +- 15 files changed, 558 insertions(+), 393 deletions(-) diff --git a/falcon/app.py b/falcon/app.py index 88ab0e554..b66247051 100644 --- a/falcon/app.py +++ b/falcon/app.py @@ -26,7 +26,6 @@ ClassVar, Dict, FrozenSet, - IO, Iterable, List, Literal, @@ -62,6 +61,7 @@ from falcon.typing import ErrorSerializer from falcon.typing import FindMethod from falcon.typing import ProcessResponseMethod +from falcon.typing import ReadableIO from falcon.typing import ResponderCallable from falcon.typing import SinkCallable from falcon.typing import SinkPrefix @@ -1191,7 +1191,9 @@ def _handle_exception( def _get_body( self, resp: Response, - wsgi_file_wrapper: Optional[Callable[[IO[bytes], int], Iterable[bytes]]] = None, + wsgi_file_wrapper: Optional[ + Callable[[ReadableIO, int], Iterable[bytes]] + ] = None, ) -> Tuple[Iterable[bytes], Optional[int]]: """Convert resp content into an iterable as required by PEP 333. @@ -1229,11 +1231,13 @@ def _get_body( # TODO(kgriffs): Make block size configurable at the # global level, pending experimentation to see how # useful that would be. See also the discussion on - # this GitHub PR: http://goo.gl/XGrtDz - iterable = wsgi_file_wrapper(stream, self._STREAM_BLOCK_SIZE) + # this GitHub PR: + # https://github.com/falconry/falcon/pull/249#discussion_r11269730 + iterable = wsgi_file_wrapper(stream, self._STREAM_BLOCK_SIZE) # type: ignore[arg-type] else: iterable = helpers.CloseableStreamIterator( - stream, self._STREAM_BLOCK_SIZE + stream, # type: ignore[arg-type] + self._STREAM_BLOCK_SIZE, ) else: iterable = stream diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py index 4fe7b7db3..4478728c2 100644 --- a/falcon/asgi/app.py +++ b/falcon/asgi/app.py @@ -46,7 +46,6 @@ from falcon.asgi_spec import AsgiSendMsg from falcon.asgi_spec import EventType from falcon.asgi_spec import WSCloseCode -from falcon.constants import _UNSET from falcon.constants import MEDIA_JSON from falcon.errors import CompatibilityError from falcon.errors import HTTPBadRequest @@ -60,6 +59,7 @@ from falcon.typing import AsgiResponderWsCallable from falcon.typing import AsgiSend from falcon.typing import AsgiSinkCallable +from falcon.typing import MISSING from falcon.typing import SinkPrefix from falcon.util import get_argnames from falcon.util.misc import is_python_func @@ -552,9 +552,9 @@ async def __call__( # type: ignore[override] # noqa: C901 data = resp._data if data is None and resp._media is not None: - # NOTE(kgriffs): We use a special _UNSET singleton since + # NOTE(kgriffs): We use a special MISSING singleton since # None is ambiguous (the media handler might return None). - if resp._media_rendered is _UNSET: + if resp._media_rendered is MISSING: opt = resp.options if not resp.content_type: resp.content_type = opt.default_media_type @@ -577,7 +577,7 @@ async def __call__( # type: ignore[override] # noqa: C901 data = text.encode() except AttributeError: # NOTE(kgriffs): Assume it was a bytes object already - data = text + data = text # type: ignore[assignment] else: # NOTE(vytas): Custom response type. @@ -1028,9 +1028,9 @@ def _schedule_callbacks(self, resp: Response) -> None: loop = asyncio.get_running_loop() - for cb, is_async in callbacks: # type: ignore[attr-defined] + for cb, is_async in callbacks or (): if is_async: - loop.create_task(cb()) + loop.create_task(cb()) # type: ignore[arg-type] else: loop.run_in_executor(None, cb) diff --git a/falcon/asgi/response.py b/falcon/asgi/response.py index b545b5201..73fcfbfbf 100644 --- a/falcon/asgi/response.py +++ b/falcon/asgi/response.py @@ -14,11 +14,18 @@ """ASGI Response class.""" +from __future__ import annotations + from inspect import iscoroutine from inspect import iscoroutinefunction +from typing import Awaitable, Callable, List, Literal, Optional, Tuple, Union from falcon import response -from falcon.constants import _UNSET +from falcon.typing import AsyncIterator +from falcon.typing import AsyncReadableIO +from falcon.typing import MISSING +from falcon.typing import ResponseCallbacks +from falcon.typing import SseEmitter from falcon.util.misc import _encode_items_to_latin1 from falcon.util.misc import is_python_func @@ -33,184 +40,109 @@ class Response(response.Response): Keyword Arguments: options (dict): Set of global options passed from the App handler. + """ - Attributes: - status (Union[str,int]): HTTP status code or line (e.g., ``'200 OK'``). - This may be set to a member of :class:`http.HTTPStatus`, an HTTP - status line string or byte string (e.g., ``'200 OK'``), or an - ``int``. - - Note: - The Falcon framework itself provides a number of constants for - common status codes. They all start with the ``HTTP_`` prefix, - as in: ``falcon.HTTP_204``. (See also: :ref:`status`.) - - status_code (int): HTTP status code normalized from :attr:`status`. - When a code is assigned to this property, :attr:`status` is updated, - and vice-versa. The status code can be useful when needing to check - in middleware for codes that fall into a certain class, e.g.:: - - if resp.status_code >= 400: - log.warning(f'returning error response: {resp.status_code}') - - media (object): A serializable object supported by the media handlers - configured via :class:`falcon.RequestOptions`. - - Note: - See also :ref:`media` for more information regarding media - handling. - - text (str): String representing response content. - - Note: - Falcon will encode the given text as UTF-8 - in the response. If the content is already a byte string, - use the :attr:`data` attribute instead (it's faster). - - data (bytes): Byte string representing response content. - - Use this attribute in lieu of `text` when your content is - already a byte string (of type ``bytes``). - - Warning: - Always use the `text` attribute for text, or encode it - first to ``bytes`` when using the `data` attribute, to - ensure Unicode characters are properly encoded in the - HTTP response. - - stream: An async iterator or generator that yields a series of - byte strings that will be streamed to the ASGI server as a - series of "http.response.body" events. Falcon will assume the - body is complete when the iterable is exhausted or as soon as it - yields ``None`` rather than an instance of ``bytes``:: - - async def producer(): - while True: - data_chunk = await read_data() - if not data_chunk: - break - - yield data_chunk - - resp.stream = producer - - Alternatively, a file-like object may be used as long as it - implements an awaitable ``read()`` method:: - - resp.stream = await aiofiles.open('resp_data.bin', 'rb') - - If the object assigned to :attr:`~.stream` holds any resources - (such as a file handle) that must be explicitly released, the - object must implement a ``close()`` method. The ``close()`` method - will be called after exhausting the iterable or file-like object. - - Note: - In order to be compatible with Python 3.7+ and PEP 479, - async iterators must return ``None`` instead of raising - :class:`StopIteration`. This requirement does not - apply to async generators (PEP 525). - - Note: - If the stream length is known in advance, you may wish to - also set the Content-Length header on the response. - - sse (coroutine): A Server-Sent Event (SSE) emitter, implemented as - an async iterator or generator that yields a series of - of :class:`falcon.asgi.SSEvent` instances. Each event will be - serialized and sent to the client as HTML5 Server-Sent Events:: + # PERF(kgriffs): These will be shadowed when set on an instance; let's + # us avoid having to implement __init__ and incur the overhead of + # an additional function call. + _sse: Optional[SseEmitter] = None + _registered_callbacks: Optional[List[ResponseCallbacks]] = None - async def emitter(): - while True: - some_event = await get_next_event() + stream: Union[AsyncReadableIO, AsyncIterator[bytes], None] # type: ignore[assignment] + """An async iterator or generator that yields a series of + byte strings that will be streamed to the ASGI server as a + series of "http.response.body" events. Falcon will assume the + body is complete when the iterable is exhausted or as soon as it + yields ``None`` rather than an instance of ``bytes``:: - if not some_event: - # Send an event consisting of a single "ping" - # comment to keep the connection alive. - yield SSEvent() + async def producer(): + while True: + data_chunk = await read_data() + if not data_chunk: + break - # Alternatively, one can simply yield None and - # a "ping" will also be sent as above. + yield data_chunk - # yield + resp.stream = producer - continue + Alternatively, a file-like object may be used as long as it + implements an awaitable ``read()`` method:: - yield SSEvent(json=some_event, retry=5000) + resp.stream = await aiofiles.open('resp_data.bin', 'rb') - # ...or + If the object assigned to :attr:`~.stream` holds any resources + (such as a file handle) that must be explicitly released, the + object must implement a ``close()`` method. The ``close()`` method + will be called after exhausting the iterable or file-like object. - yield SSEvent(data=b'something', event_id=some_id) + Note: + In order to be compatible with Python 3.7+ and PEP 479, + async iterators must return ``None`` instead of raising + :class:`StopIteration`. This requirement does not + apply to async generators (PEP 525). - # Alternatively, you may yield anything that implements - # a serialize() method that returns a byte string - # conforming to the SSE event stream format. + Note: + If the stream length is known in advance, you may wish to + also set the Content-Length header on the response. + """ - # yield some_event + @property + def sse(self) -> Optional[SseEmitter]: + """A Server-Sent Event (SSE) emitter, implemented as + an async iterator or generator that yields a series of + of :class:`falcon.asgi.SSEvent` instances. Each event will be + serialized and sent to the client as HTML5 Server-Sent Events:: - resp.sse = emitter() + async def emitter(): + while True: + some_event = await get_next_event() - Note: - When the `sse` property is set, it supersedes both the - `text` and `data` properties. + if not some_event: + # Send an event consisting of a single "ping" + # comment to keep the connection alive. + yield SSEvent() - Note: - When hosting an app that emits Server-Sent Events, the web - server should be set with a relatively long keep-alive TTL to - minimize the overhead of connection renegotiations. + # Alternatively, one can simply yield None and + # a "ping" will also be sent as above. - context (object): Empty object to hold any data (in its attributes) - about the response which is specific to your app (e.g. session - object). Falcon itself will not interact with this attribute after - it has been initialized. + # yield - Note: - The preferred way to pass response-specific data, when using the - default context type, is to set attributes directly on the - `context` object. For example:: + continue - resp.context.cache_strategy = 'lru' + yield SSEvent(json=some_event, retry=5000) - context_type (class): Class variable that determines the factory or - type to use for initializing the `context` attribute. By default, - the framework will instantiate bare objects (instances of the bare - :class:`falcon.Context` class). However, you may override this - behavior by creating a custom child class of - :class:`falcon.asgi.Response`, and then passing that new class - to ``falcon.App()`` by way of the latter's `response_type` - parameter. + # ...or - Note: - When overriding `context_type` with a factory function (as - opposed to a class), the function is called like a method of - the current Response instance. Therefore the first argument is - the Response instance itself (self). + yield SSEvent(data=b'something', event_id=some_id) - options (dict): Set of global options passed in from the App handler. + # Alternatively, you may yield anything that implements + # a serialize() method that returns a byte string + # conforming to the SSE event stream format. - headers (dict): Copy of all headers set for the response, - sans cookies. Note that a new copy is created and returned each - time this property is referenced. + # yield some_event - complete (bool): Set to ``True`` from within a middleware method to - signal to the framework that request processing should be - short-circuited (see also :ref:`Middleware `). - """ + resp.sse = emitter() - # PERF(kgriffs): These will be shadowed when set on an instance; let's - # us avoid having to implement __init__ and incur the overhead of - # an additional function call. - _sse = None - _registered_callbacks = None + Note: + When the `sse` property is set, it supersedes both the + `text` and `data` properties. - @property - def sse(self): + Note: + When hosting an app that emits Server-Sent Events, the web + server should be set with a relatively long keep-alive TTL to + minimize the overhead of connection renegotiations. + """ # noqa: D400 D205 return self._sse @sse.setter - def sse(self, value): + def sse(self, value: Optional[SseEmitter]) -> None: self._sse = value - def set_stream(self, stream, content_length): + def set_stream( + self, + stream: Union[AsyncReadableIO, AsyncIterator[bytes]], # type: ignore[override] + content_length: int, + ) -> None: """Set both `stream` and `content_length`. Although the :attr:`~falcon.asgi.Response.stream` and @@ -241,7 +173,7 @@ def set_stream(self, stream, content_length): # the self.content_length property. self._headers['content-length'] = str(content_length) - async def render_body(self): + async def render_body(self) -> Optional[bytes]: # type: ignore[override] """Get the raw bytestring content for the response body. This coroutine can be awaited to get the raw data for the @@ -261,14 +193,15 @@ async def render_body(self): # NOTE(vytas): The code below is also inlined in asgi.App.__call__. + data: Optional[bytes] text = self.text if text is None: data = self._data if data is None and self._media is not None: - # NOTE(kgriffs): We use a special _UNSET singleton since + # NOTE(kgriffs): We use a special MISSING singleton since # None is ambiguous (the media handler might return None). - if self._media_rendered is _UNSET: + if self._media_rendered is MISSING: if not self.content_type: self.content_type = self.options.default_media_type @@ -290,11 +223,11 @@ async def render_body(self): data = text.encode() except AttributeError: # NOTE(kgriffs): Assume it was a bytes object already - data = text + data = text # type: ignore[assignment] return data - def schedule(self, callback): + def schedule(self, callback: Callable[[], Awaitable[None]]) -> None: """Schedule an async callback to run soon after sending the HTTP response. This method can be used to execute a background job after the response @@ -341,14 +274,14 @@ def schedule(self, callback): # by tests running in a Cython environment, but we can't # detect it with the coverage tool. - rc = (callback, True) + rc: Tuple[Callable[[], Awaitable[None]], Literal[True]] = (callback, True) if not self._registered_callbacks: self._registered_callbacks = [rc] else: self._registered_callbacks.append(rc) - def schedule_sync(self, callback): + def schedule_sync(self, callback: Callable[[], None]) -> None: """Schedule a synchronous callback to run soon after sending the HTTP response. This method can be used to execute a background job after the @@ -387,7 +320,7 @@ def schedule_sync(self, callback): callable. The callback will be called without arguments. """ - rc = (callback, False) + rc: Tuple[Callable[[], None], Literal[False]] = (callback, False) if not self._registered_callbacks: self._registered_callbacks = [rc] @@ -398,7 +331,9 @@ def schedule_sync(self, callback): # Helper methods # ------------------------------------------------------------------------ - def _asgi_headers(self, media_type=None): + def _asgi_headers( + self, media_type: Optional[str] = None + ) -> List[Tuple[bytes, bytes]]: """Convert headers into the format expected by ASGI servers. Header names must be lowercased and both name and value must be diff --git a/falcon/constants.py b/falcon/constants.py index dbbb94934..b1df391d8 100644 --- a/falcon/constants.py +++ b/falcon/constants.py @@ -183,10 +183,6 @@ ) ) -# NOTE(kgriffs): Special singleton to be used internally whenever using -# None would be ambiguous. -_UNSET = object() # TODO: remove once replaced with missing - class WebSocketPayloadType(Enum): """Enum representing the two possible WebSocket payload types.""" diff --git a/falcon/media/urlencoded.py b/falcon/media/urlencoded.py index 1d7f6cb04..ee38391db 100644 --- a/falcon/media/urlencoded.py +++ b/falcon/media/urlencoded.py @@ -52,7 +52,9 @@ def serialize(self, media: Any, content_type: Optional[str] = None) -> bytes: def _deserialize(self, body: bytes) -> Any: try: - # NOTE(kgriffs): According to http://goo.gl/6rlcux the + # NOTE(kgriffs): According to + # https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#application%2Fx-www-form-urlencoded-encoding-algorithm + # the # body should be US-ASCII. Enforcing this also helps # catch malicious input. body_str = body.decode('ascii') diff --git a/falcon/middleware.py b/falcon/middleware.py index 5772e16c7..0e87275ed 100644 --- a/falcon/middleware.py +++ b/falcon/middleware.py @@ -120,7 +120,7 @@ def process_response( 'Access-Control-Request-Headers', default='*' ) - resp.set_header('Access-Control-Allow-Methods', allow) + resp.set_header('Access-Control-Allow-Methods', str(allow)) resp.set_header('Access-Control-Allow-Headers', allow_headers) resp.set_header('Access-Control-Max-Age', '86400') # 24 hours diff --git a/falcon/request.py b/falcon/request.py index db89c9470..db7ad6ea4 100644 --- a/falcon/request.py +++ b/falcon/request.py @@ -81,7 +81,7 @@ class Request: also PEP-3333. Keyword Arguments: - options (dict): Set of global options passed from the App handler. + options (RequestOptions): Set of global options passed from the App handler. """ __slots__ = ( @@ -2368,7 +2368,9 @@ def _parse_form_urlencoded(self) -> None: body_bytes = self.stream.read(content_length) - # NOTE(kgriffs): According to http://goo.gl/6rlcux the + # NOTE(kgriffs): According to + # https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#application%2Fx-www-form-urlencoded-encoding-algorithm + # the # body should be US-ASCII. Enforcing this also helps # catch malicious input. try: diff --git a/falcon/responders.py b/falcon/responders.py index b3ee73763..b28277b47 100644 --- a/falcon/responders.py +++ b/falcon/responders.py @@ -14,33 +14,47 @@ """Default responder implementations.""" +from __future__ import annotations + +from typing import Any, Iterable, NoReturn, TYPE_CHECKING, Union + from falcon.errors import HTTPBadRequest from falcon.errors import HTTPMethodNotAllowed from falcon.errors import HTTPRouteNotFound from falcon.status_codes import HTTP_200 +from falcon.typing import AsgiResponderCallable +from falcon.typing import ResponderCallable + +if TYPE_CHECKING: + from falcon import Request + from falcon import Response + from falcon.asgi import Request as AsgiRequest + from falcon.asgi import Response as AsgiResponse -def path_not_found(req, resp, **kwargs): +def path_not_found(req: Request, resp: Response, **kwargs: Any) -> NoReturn: """Raise 404 HTTPRouteNotFound error.""" raise HTTPRouteNotFound() -async def path_not_found_async(req, resp, **kwargs): +async def path_not_found_async(req: Request, resp: Response, **kwargs: Any) -> NoReturn: """Raise 404 HTTPRouteNotFound error.""" raise HTTPRouteNotFound() -def bad_request(req, resp, **kwargs): +def bad_request(req: Request, resp: Response, **kwargs: Any) -> NoReturn: """Raise 400 HTTPBadRequest error.""" raise HTTPBadRequest(title='Bad request', description='Invalid HTTP method') -async def bad_request_async(req, resp, **kwargs): +async def bad_request_async(req: Request, resp: Response, **kwargs: Any) -> NoReturn: """Raise 400 HTTPBadRequest error.""" raise HTTPBadRequest(title='Bad request', description='Invalid HTTP method') -def create_method_not_allowed(allowed_methods, asgi=False): +def create_method_not_allowed( + allowed_methods: Iterable[str], asgi: bool = False +) -> Union[ResponderCallable, AsgiResponderCallable]: """Create a responder for "405 Method Not Allowed". Args: @@ -52,18 +66,22 @@ def create_method_not_allowed(allowed_methods, asgi=False): if asgi: - async def method_not_allowed_responder_async(req, resp, **kwargs): + async def method_not_allowed_responder_async( + req: AsgiRequest, resp: AsgiResponse, **kwargs: Any + ) -> NoReturn: raise HTTPMethodNotAllowed(allowed_methods) return method_not_allowed_responder_async - def method_not_allowed(req, resp, **kwargs): + def method_not_allowed(req: Request, resp: Response, **kwargs: Any) -> NoReturn: raise HTTPMethodNotAllowed(allowed_methods) return method_not_allowed -def create_default_options(allowed_methods, asgi=False): +def create_default_options( + allowed_methods: Iterable[str], asgi: bool = False +) -> Union[ResponderCallable, AsgiResponderCallable]: """Create a default responder for the OPTIONS method. Args: @@ -76,14 +94,16 @@ def create_default_options(allowed_methods, asgi=False): if asgi: - async def options_responder_async(req, resp, **kwargs): + async def options_responder_async( + req: AsgiRequest, resp: AsgiResponse, **kwargs: Any + ) -> None: resp.status = HTTP_200 resp.set_header('Allow', allowed) resp.set_header('Content-Length', '0') return options_responder_async - def options_responder(req, resp, **kwargs): + def options_responder(req: Request, resp: Response, **kwargs: Any) -> None: resp.status = HTTP_200 resp.set_header('Allow', allowed) resp.set_header('Content-Length', '0') diff --git a/falcon/response.py b/falcon/response.py index 9446e662d..5c2a5c2f7 100644 --- a/falcon/response.py +++ b/falcon/response.py @@ -16,13 +16,27 @@ from __future__ import annotations +from datetime import datetime from datetime import timezone import functools import mimetypes -from typing import Dict +from typing import ( + Any, + ClassVar, + Dict, + Iterable, + List, + Mapping, + NoReturn, + Optional, + overload, + Tuple, + Type, + TYPE_CHECKING, + Union, +) from falcon.constants import _DEFAULT_STATIC_MEDIA_TYPES -from falcon.constants import _UNSET from falcon.constants import DEFAULT_MEDIA_TYPE from falcon.errors import HeaderNotSupported from falcon.media import Handlers @@ -32,6 +46,11 @@ from falcon.response_helpers import format_range from falcon.response_helpers import header_property from falcon.response_helpers import is_ascii_encodable +from falcon.typing import Headers +from falcon.typing import MISSING +from falcon.typing import MissingOr +from falcon.typing import RangeSetHeader +from falcon.typing import ReadableIO from falcon.util import dt_to_http from falcon.util import http_cookies from falcon.util import http_status_to_code @@ -41,8 +60,11 @@ from falcon.util.uri import encode_check_escaped as uri_encode from falcon.util.uri import encode_value_check_escaped as uri_encode_value -_RESERVED_CROSSORIGIN_VALUES = frozenset({'anonymous', 'use-credentials'}) +if TYPE_CHECKING: + import http + +_RESERVED_CROSSORIGIN_VALUES = frozenset({'anonymous', 'use-credentials'}) _RESERVED_SAMESITE_VALUES = frozenset({'lax', 'strict', 'none'}) @@ -53,100 +75,7 @@ class Response: ``Response`` is not meant to be instantiated directly by responders. Keyword Arguments: - options (dict): Set of global options passed from the App handler. - - Attributes: - status (Union[str,int]): HTTP status code or line (e.g., ``'200 OK'``). - This may be set to a member of :class:`http.HTTPStatus`, an HTTP - status line string or byte string (e.g., ``'200 OK'``), or an - ``int``. - - Note: - The Falcon framework itself provides a number of constants for - common status codes. They all start with the ``HTTP_`` prefix, - as in: ``falcon.HTTP_204``. (See also: :ref:`status`.) - - status_code (int): HTTP status code normalized from :attr:`status`. - When a code is assigned to this property, :attr:`status` is updated, - and vice-versa. The status code can be useful when needing to check - in middleware for codes that fall into a certain class, e.g.:: - - if resp.status_code >= 400: - log.warning(f'returning error response: {resp.status_code}') - - media (object): A serializable object supported by the media handlers - configured via :class:`falcon.RequestOptions`. - - Note: - See also :ref:`media` for more information regarding media - handling. - - text (str): String representing response content. - - Note: - Falcon will encode the given text as UTF-8 - in the response. If the content is already a byte string, - use the :attr:`data` attribute instead (it's faster). - - data (bytes): Byte string representing response content. - - Use this attribute in lieu of `text` when your content is - already a byte string (of type ``bytes``). See also the note below. - - Warning: - Always use the `text` attribute for text, or encode it - first to ``bytes`` when using the `data` attribute, to - ensure Unicode characters are properly encoded in the - HTTP response. - - stream: Either a file-like object with a `read()` method that takes - an optional size argument and returns a block of bytes, or an - iterable object, representing response content, and yielding - blocks as byte strings. Falcon will use *wsgi.file_wrapper*, if - provided by the WSGI server, in order to efficiently serve - file-like objects. - - Note: - If the stream is set to an iterable object that requires - resource cleanup, it can implement a close() method to do so. - The close() method will be called upon completion of the request. - - context (object): Empty object to hold any data (in its attributes) - about the response which is specific to your app (e.g. session - object). Falcon itself will not interact with this attribute after - it has been initialized. - - Note: - **New in 2.0:** The default `context_type` (see below) was - changed from :class:`dict` to a bare class; the preferred way to - pass response-specific data is now to set attributes directly - on the `context` object. For example:: - - resp.context.cache_strategy = 'lru' - - context_type (class): Class variable that determines the factory or - type to use for initializing the `context` attribute. By default, - the framework will instantiate bare objects (instances of the bare - :class:`falcon.Context` class). However, you may override this - behavior by creating a custom child class of - :class:`falcon.Response`, and then passing that new class to - ``falcon.App()`` by way of the latter's `response_type` parameter. - - Note: - When overriding `context_type` with a factory function (as - opposed to a class), the function is called like a method of - the current Response instance. Therefore the first argument is - the Response instance itself (self). - - options (dict): Set of global options passed from the App handler. - - headers (dict): Copy of all headers set for the response, - sans cookies. Note that a new copy is created and returned each - time this property is referenced. - - complete (bool): Set to ``True`` from within a middleware method to - signal to the framework that request processing should be - short-circuited (see also :ref:`Middleware `). + options (ResponseOptions): Set of global options passed from the App handler. """ __slots__ = ( @@ -164,12 +93,81 @@ class Response: '__dict__', ) - complete = False + _cookies: Optional[http_cookies.SimpleCookie] + _data: Optional[bytes] + _extra_headers: Optional[List[Tuple[str, str]]] + _headers: Headers + _media: Optional[Any] + _media_rendered: MissingOr[bytes] # Child classes may override this - context_type = structures.Context + context_type: ClassVar[Type[structures.Context]] = structures.Context + """Class variable that determines the factory or + type to use for initializing the `context` attribute. By default, + the framework will instantiate bare objects (instances of the bare + :class:`falcon.Context` class). However, you may override this + behavior by creating a custom child class of + :class:`falcon.Response`, and then passing that new class to + ``falcon.App()`` by way of the latter's `response_type` parameter. + + Note: + When overriding `context_type` with a factory function (as + opposed to a class), the function is called like a method of + the current Response instance. Therefore the first argument is + the Response instance itself (self). + """ - def __init__(self, options=None): + # Attribute declaration + complete: bool = False + """Set to ``True`` from within a middleware method to signal to the framework that + request processing should be short-circuited (see also + :ref:`Middleware `). + """ + status: Union[str, int, http.HTTPStatus] + """HTTP status code or line (e.g., ``'200 OK'``). + + This may be set to a member of :class:`http.HTTPStatus`, an HTTP status line + string (e.g., ``'200 OK'``), or an ``int``. + + Note: + The Falcon framework itself provides a number of constants for + common status codes. They all start with the ``HTTP_`` prefix, + as in: ``falcon.HTTP_204``. (See also: :ref:`status`.) + """ + text: Optional[str] + """String representing response content. + + Note: + Falcon will encode the given text as UTF-8 in the response. If the content + is already a byte string, use the :attr:`data` attribute instead (it's faster). + """ + stream: Union[ReadableIO, Iterable[bytes], None] + """Either a file-like object with a `read()` method that takes an optional size + argument and returns a block of bytes, or an iterable object, representing response + content, and yielding blocks as byte strings. Falcon will use *wsgi.file_wrapper*, + if provided by the WSGI server, in order to efficiently serve file-like objects. + + Note: + If the stream is set to an iterable object that requires + resource cleanup, it can implement a close() method to do so. + The close() method will be called upon completion of the request. + """ + context: structures.Context + """Empty object to hold any data (in its attributes) about the response which is + specific to your app (e.g. session object). + Falcon itself will not interact with this attribute after it has been initialized. + + Note: + The preferred way to pass response-specific data, when using the + default context type, is to set attributes directly on the + `context` object. For example:: + + resp.context.cache_strategy = 'lru' + """ + options: ResponseOptions + """Set of global options passed in from the App handler.""" + + def __init__(self, options: Optional[ResponseOptions] = None) -> None: self.status = '200 OK' self._headers = {} @@ -191,54 +189,86 @@ def __init__(self, options=None): self.stream = None self._data = None self._media = None - self._media_rendered = _UNSET + self._media_rendered = MISSING self.context = self.context_type() @property def status_code(self) -> int: + """HTTP status code normalized from :attr:`status`. + + When a code is assigned to this property, :attr:`status` is updated, + and vice-versa. The status code can be useful when needing to check + in middleware for codes that fall into a certain class, e.g.:: + + if resp.status_code >= 400: + log.warning(f'returning error response: {resp.status_code}') + """ return http_status_to_code(self.status) @status_code.setter - def status_code(self, value): + def status_code(self, value: int) -> None: self.status = value @property - def body(self): + def body(self) -> NoReturn: raise AttributeRemovedError( 'The body attribute is no longer supported. ' 'Please use the text attribute instead.' ) @body.setter - def body(self, value): + def body(self, value: Any) -> NoReturn: raise AttributeRemovedError( 'The body attribute is no longer supported. ' 'Please use the text attribute instead.' ) @property - def data(self): + def data(self) -> Optional[bytes]: + """Byte string representing response content. + + Use this attribute in lieu of `text` when your content is + already a byte string (of type ``bytes``). See also the note below. + + Warning: + Always use the `text` attribute for text, or encode it + first to ``bytes`` when using the `data` attribute, to + ensure Unicode characters are properly encoded in the + HTTP response. + """ return self._data @data.setter - def data(self, value): + def data(self, value: Optional[bytes]) -> None: self._data = value @property - def headers(self): + def headers(self) -> Headers: + """Copy of all headers set for the response, without cookies. + + Note that a new copy is created and returned each time this property is + referenced. + """ return self._headers.copy() @property - def media(self): + def media(self) -> Any: + """A serializable object supported by the media handlers configured via + :class:`falcon.RequestOptions`. + + Note: + See also :ref:`media` for more information regarding media + handling. + """ # noqa D205 return self._media @media.setter - def media(self, value): + def media(self, value: Any) -> None: self._media = value - self._media_rendered = _UNSET + self._media_rendered = MISSING - def render_body(self): + def render_body(self) -> Optional[bytes]: """Get the raw bytestring content for the response body. This method returns the raw data for the HTTP response body, taking @@ -255,15 +285,15 @@ def render_body(self): finally the serialized value of the `media` attribute. If none of these attributes are set, ``None`` is returned. """ - + data: Optional[bytes] text = self.text if text is None: data = self._data if data is None and self._media is not None: - # NOTE(kgriffs): We use a special _UNSET singleton since + # NOTE(kgriffs): We use a special MISSING singleton since # None is ambiguous (the media handler might return None). - if self._media_rendered is _UNSET: + if self._media_rendered is MISSING: if not self.content_type: self.content_type = self.options.default_media_type @@ -282,14 +312,16 @@ def render_body(self): data = text.encode() except AttributeError: # NOTE(kgriffs): Assume it was a bytes object already - data = text + data = text # type: ignore[assignment] return data - def __repr__(self): - return '<%s: %s>' % (self.__class__.__name__, self.status) + def __repr__(self) -> str: + return f'<{self.__class__.__name__}: {self.status}>' - def set_stream(self, stream, content_length): + def set_stream( + self, stream: Union[ReadableIO, Iterable[bytes]], content_length: int + ) -> None: """Set both `stream` and `content_length`. Although the :attr:`~falcon.Response.stream` and @@ -321,17 +353,17 @@ def set_stream(self, stream, content_length): def set_cookie( # noqa: C901 self, - name, - value, - expires=None, - max_age=None, - domain=None, - path=None, - secure=None, - http_only=True, - same_site=None, - partitioned=False, - ): + name: str, + value: str, + expires: Optional[datetime] = None, + max_age: Optional[int] = None, + domain: Optional[str] = None, + path: Optional[str] = None, + secure: Optional[bool] = None, + http_only: bool = True, + same_site: Optional[str] = None, + partitioned: bool = False, + ) -> None: """Set a response cookie. Note: @@ -526,7 +558,13 @@ def set_cookie( # noqa: C901 if partitioned: self._cookies[name]['partitioned'] = True - def unset_cookie(self, name, samesite='Lax', domain=None, path=None): + def unset_cookie( + self, + name: str, + samesite: str = 'Lax', + domain: Optional[str] = None, + path: Optional[str] = None, + ) -> None: """Unset a cookie in the response. Clears the contents of the cookie, and instructs the user @@ -602,7 +640,13 @@ def unset_cookie(self, name, samesite='Lax', domain=None, path=None): if path: self._cookies[name]['path'] = path - def get_header(self, name, default=None): + @overload + def get_header(self, name: str, default: str) -> str: ... + + @overload + def get_header(self, name: str, default: Optional[str] = ...) -> Optional[str]: ... + + def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]: """Retrieve the raw string value for the given header. Normally, when a header has multiple values, they will be @@ -634,7 +678,7 @@ def get_header(self, name, default=None): return self._headers.get(name, default) - def set_header(self, name, value): + def set_header(self, name: str, value: str) -> None: """Set a header for this response to a given value. Warning: @@ -670,7 +714,7 @@ def set_header(self, name, value): self._headers[name] = value - def delete_header(self, name): + def delete_header(self, name: str) -> None: """Delete a header that was previously set for this response. If the header was not previously set, nothing is done (no error is @@ -704,7 +748,7 @@ def delete_header(self, name): self._headers.pop(name, None) - def append_header(self, name, value): + def append_header(self, name: str, value: str) -> None: """Set or append a header for this response. If the header already exists, the new value will normally be appended @@ -744,7 +788,9 @@ def append_header(self, name, value): self._headers[name] = value - def set_headers(self, headers): + def set_headers( + self, headers: Union[Mapping[str, str], Iterable[Tuple[str, str]]] + ) -> None: """Set several headers at once. This method can be used to set a collection of raw header names and @@ -785,7 +831,7 @@ def set_headers(self, headers): # normalize the header names. _headers = self._headers - for name, value in headers: + for name, value in headers: # type: ignore[misc] # 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 @@ -800,16 +846,16 @@ def set_headers(self, headers): def append_link( self, - target, - rel, - title=None, - title_star=None, - anchor=None, - hreflang=None, - type_hint=None, - crossorigin=None, - link_extension=None, - ): + target: str, + rel: str, + title: Optional[str] = None, + title_star: Optional[Tuple[str, str]] = None, + anchor: Optional[str] = None, + hreflang: Optional[Union[str, Iterable[str]]] = None, + type_hint: Optional[str] = None, + crossorigin: Optional[str] = None, + link_extension: Optional[Iterable[Tuple[str, str]]] = None, + ) -> None: """Append a link header to the response. (See also: RFC 5988, Section 1) @@ -834,7 +880,7 @@ def append_link( characters, you will need to use `title_star` instead, or provide both a US-ASCII version using `title` and a Unicode version using `title_star`. - title_star (tuple of str): Localized title describing the + title_star (tuple[str, str]): Localized title describing the destination of the link (default ``None``). The value must be a two-member tuple in the form of (*language-tag*, *text*), where *language-tag* is a standard language identifier as @@ -891,40 +937,34 @@ def append_link( if ' ' in rel: rel = '"' + ' '.join([uri_encode(r) for r in rel.split()]) + '"' else: - rel = '"' + uri_encode(rel) + '"' + rel = f'"{uri_encode(rel)}"' value = '<' + uri_encode(target) + '>; rel=' + rel if title is not None: - value += '; title="' + title + '"' + value += f'; title="{title}"' if title_star is not None: - value += ( - "; title*=UTF-8'" - + title_star[0] - + "'" - + uri_encode_value(title_star[1]) - ) + value += f"; title*=UTF-8'{title_star[0]}'{uri_encode_value(title_star[1])}" if type_hint is not None: - value += '; type="' + type_hint + '"' + value += f'; type="{type_hint}"' if hreflang is not None: if isinstance(hreflang, str): - value += '; hreflang=' + hreflang + value += f'; hreflang={hreflang}' else: value += '; ' value += '; '.join(['hreflang=' + lang for lang in hreflang]) if anchor is not None: - value += '; anchor="' + uri_encode(anchor) + '"' + value += f'; anchor="{uri_encode(anchor)}"' if crossorigin is not None: crossorigin = crossorigin.lower() if crossorigin not in _RESERVED_CROSSORIGIN_VALUES: raise ValueError( - 'crossorigin must be set to either ' - "'anonymous' or 'use-credentials'" + "crossorigin must be set to either 'anonymous' or 'use-credentials'" ) if crossorigin == 'anonymous': value += '; crossorigin' @@ -935,11 +975,11 @@ def append_link( if link_extension is not None: value += '; ' - value += '; '.join([p + '=' + v for p, v in link_extension]) + value += '; '.join([f'{p}={v}' for p, v in link_extension]) _headers = self._headers if 'link' in _headers: - _headers['link'] += ', ' + value + _headers['link'] += f', {value}' else: _headers['link'] = value @@ -948,19 +988,24 @@ def append_link( append_link ) - cache_control = header_property( + cache_control: Union[str, Iterable[str], None] = header_property( 'Cache-Control', """Set the Cache-Control header. Used to set a list of cache directives to use as the value of the Cache-Control header. The list will be joined with ", " to produce the value for the header. - """, format_header_value_list, ) + """Set the Cache-Control header. - content_location = header_property( + Used to set a list of cache directives to use as the value of the + Cache-Control header. The list will be joined with ", " to produce + the value for the header. + """ + + content_location: Optional[str] = header_property( 'Content-Location', """Set the Content-Location header. @@ -970,8 +1015,14 @@ def append_link( """, uri_encode, ) + """Set the Content-Location header. + + This value will be URI encoded per RFC 3986. If the value that is + being set is already URI encoded it should be decoded first or the + header should be set manually using the set_header method. + """ - content_length = header_property( + content_length: Union[str, int, None] = header_property( 'Content-Length', """Set the Content-Length header. @@ -991,8 +1042,25 @@ def append_link( """, ) + """Set the Content-Length header. + + This property can be used for responding to HEAD requests when you + aren't actually providing the response body, or when streaming the + response. If either the `text` property or the `data` property is set + on the response, the framework will force Content-Length to be the + length of the given text bytes. Therefore, it is only necessary to + manually set the content length when those properties are not used. + + Note: + In cases where the response content is a stream (readable + file-like object), Falcon will not supply a Content-Length header + to the server unless `content_length` is explicitly set. + Consequently, the server may choose to use chunked encoding in this + case. + + """ - content_range = header_property( + content_range: Union[str, RangeSetHeader, None] = header_property( 'Content-Range', """A tuple to use in constructing a value for the Content-Range header. @@ -1012,8 +1080,24 @@ def append_link( """, format_range, ) + """A tuple to use in constructing a value for the Content-Range header. + + The tuple has the form (*start*, *end*, *length*, [*unit*]), where *start* and + *end* designate the range (inclusive), and *length* is the + total length, or '\\*' if unknown. You may pass ``int``'s for + these numbers (no need to convert to ``str`` beforehand). The optional value + *unit* describes the range unit and defaults to 'bytes' + + Note: + You only need to use the alternate form, 'bytes \\*/1234', for + responses that use the status '416 Range Not Satisfiable'. In this + case, raising ``falcon.HTTPRangeNotSatisfiable`` will do the right + thing. + + (See also: RFC 7233, Section 4.2) + """ - content_type = header_property( + content_type: Optional[str] = header_property( 'Content-Type', """Sets the Content-Type header. @@ -1026,8 +1110,18 @@ def append_link( and ``falcon.MEDIA_GIF``. """, ) + """Sets the Content-Type header. + + The ``falcon`` module provides a number of constants for + common media types, including ``falcon.MEDIA_JSON``, + ``falcon.MEDIA_MSGPACK``, ``falcon.MEDIA_YAML``, + ``falcon.MEDIA_XML``, ``falcon.MEDIA_HTML``, + ``falcon.MEDIA_JS``, ``falcon.MEDIA_TEXT``, + ``falcon.MEDIA_JPEG``, ``falcon.MEDIA_PNG``, + and ``falcon.MEDIA_GIF``. + """ - downloadable_as = header_property( + downloadable_as: Optional[str] = header_property( 'Content-Disposition', """Set the Content-Disposition header using the given filename. @@ -1042,8 +1136,19 @@ def append_link( """, functools.partial(format_content_disposition, disposition_type='attachment'), ) + """Set the Content-Disposition header using the given filename. + + The value will be used for the ``filename`` directive. For example, + given ``'report.pdf'``, the Content-Disposition header would be set + to: ``'attachment; filename="report.pdf"'``. - viewable_as = header_property( + As per `RFC 6266 `_ + recommendations, non-ASCII filenames will be encoded using the + ``filename*`` directive, whereas ``filename`` will contain the US + ASCII fallback. + """ + + viewable_as: Optional[str] = header_property( 'Content-Disposition', """Set an inline Content-Disposition header using the given filename. @@ -1060,8 +1165,21 @@ def append_link( """, functools.partial(format_content_disposition, disposition_type='inline'), ) + """Set an inline Content-Disposition header using the given filename. + + The value will be used for the ``filename`` directive. For example, + given ``'report.pdf'``, the Content-Disposition header would be set + to: ``'inline; filename="report.pdf"'``. - etag = header_property( + As per `RFC 6266 `_ + recommendations, non-ASCII filenames will be encoded using the + ``filename*`` directive, whereas ``filename`` will contain the US + ASCII fallback. + + .. versionadded:: 3.1 + """ + + etag: Optional[str] = header_property( 'ETag', """Set the ETag header. @@ -1070,8 +1188,13 @@ def append_link( """, format_etag_header, ) + """Set the ETag header. + + The ETag header will be wrapped with double quotes ``"value"`` in case + the user didn't pass it. + """ - expires = header_property( + expires: Union[str, datetime, None] = header_property( 'Expires', """Set the Expires header. Set to a ``datetime`` (UTC) instance. @@ -1080,8 +1203,13 @@ def append_link( """, dt_to_http, ) + """Set the Expires header. Set to a ``datetime`` (UTC) instance. - last_modified = header_property( + Note: + Falcon will format the ``datetime`` as an HTTP date string. + """ + + last_modified: Union[str, datetime, None] = header_property( 'Last-Modified', """Set the Last-Modified header. Set to a ``datetime`` (UTC) instance. @@ -1090,8 +1218,13 @@ def append_link( """, dt_to_http, ) + """Set the Last-Modified header. Set to a ``datetime`` (UTC) instance. + + Note: + Falcon will format the ``datetime`` as an HTTP date string. + """ - location = header_property( + location: Optional[str] = header_property( 'Location', """Set the Location header. @@ -1101,18 +1234,28 @@ def append_link( """, uri_encode, ) + """Set the Location header. + + This value will be URI encoded per RFC 3986. If the value that is + being set is already URI encoded it should be decoded first or the + header should be set manually using the set_header method. + """ - retry_after = header_property( + retry_after: Union[int, str, None] = header_property( 'Retry-After', """Set the Retry-After header. The expected value is an integral number of seconds to use as the value for the header. The HTTP-date syntax is not supported. """, - str, ) + """Set the Retry-After header. + + The expected value is an integral number of seconds to use as the + value for the header. The HTTP-date syntax is not supported. + """ - vary = header_property( + vary: Union[str, Iterable[str], None] = header_property( 'Vary', """Value to use for the Vary header. @@ -1131,8 +1274,23 @@ def append_link( """, format_header_value_list, ) + """Value to use for the Vary header. - accept_ranges = header_property( + Set this property to an iterable of header names. For a single + asterisk or field value, simply pass a single-element ``list`` + or ``tuple``. + + The "Vary" header field in a response describes what parts of + a request message, aside from the method, Host header field, + and request target, might influence the origin server's + process for selecting and representing this response. The + value consists of either a single asterisk ("*") or a list of + header field names (case-insensitive). + + (See also: RFC 7231, Section 7.1.4) + """ + + accept_ranges: Optional[str] = header_property( 'Accept-Ranges', """Set the Accept-Ranges header. @@ -1150,8 +1308,23 @@ def append_link( """, ) + """Set the Accept-Ranges header. + + The Accept-Ranges header field indicates to the client which + range units are supported (e.g. "bytes") for the target + resource. + + If range requests are not supported for the target resource, + the header may be set to "none" to advise the client not to + attempt any such requests. + + Note: + "none" is the literal string, not Python's built-in ``None`` + type. + + """ - def _set_media_type(self, media_type=None): + def _set_media_type(self, media_type: Optional[str] = None) -> None: """Set a content-type; wrapper around set_header. Args: @@ -1166,7 +1339,7 @@ def _set_media_type(self, media_type=None): if media_type is not None and 'content-type' not in self._headers: self._headers['content-type'] = media_type - def _wsgi_headers(self, media_type=None): + def _wsgi_headers(self, media_type: Optional[str] = None) -> list[tuple[str, str]]: """Convert headers into the format expected by WSGI servers. Args: @@ -1243,7 +1416,7 @@ class ResponseOptions: 'static_media_types', ) - def __init__(self): + def __init__(self) -> None: self.secure_cookies_by_default = True self.default_media_type = DEFAULT_MEDIA_TYPE self.media_handlers = Handlers() diff --git a/falcon/response_helpers.py b/falcon/response_helpers.py index 2e59ba78f..8e1d90207 100644 --- a/falcon/response_helpers.py +++ b/falcon/response_helpers.py @@ -14,11 +14,21 @@ """Utilities for the Response class.""" +from __future__ import annotations + +from typing import Any, Callable, Iterable, Optional, TYPE_CHECKING + +from falcon.typing import RangeSetHeader from falcon.util import uri from falcon.util.misc import secure_filename +if TYPE_CHECKING: + from falcon import Response + -def header_property(name, doc, transform=None): +def header_property( + name: str, doc: str, transform: Optional[Callable[[Any], str]] = None +) -> Any: """Create a header getter/setter. Args: @@ -32,7 +42,7 @@ def header_property(name, doc, transform=None): """ normalized_name = name.lower() - def fget(self): + def fget(self: Response) -> Optional[str]: try: return self._headers[normalized_name] except KeyError: @@ -40,7 +50,7 @@ def fget(self): if transform is None: - def fset(self, value): + def fset(self: Response, value: Optional[Any]) -> None: if value is None: try: del self._headers[normalized_name] @@ -51,7 +61,7 @@ def fset(self, value): else: - def fset(self, value): + def fset(self: Response, value: Optional[Any]) -> None: if value is None: try: del self._headers[normalized_name] @@ -60,31 +70,27 @@ def fset(self, value): else: self._headers[normalized_name] = transform(value) - def fdel(self): + def fdel(self: Response) -> None: del self._headers[normalized_name] return property(fget, fset, fdel, doc) -def format_range(value): +def format_range(value: RangeSetHeader) -> str: """Format a range header tuple per the HTTP spec. Args: value: ``tuple`` passed to `req.range` """ - - # PERF(kgriffs): % was found to be faster than str.format(), - # string concatenation, and str.join() in this case. - if len(value) == 4: - result = '%s %s-%s/%s' % (value[3], value[0], value[1], value[2]) + result = f'{value[3]} {value[0]}-{value[1]}/{value[2]}' else: - result = 'bytes %s-%s/%s' % (value[0], value[1], value[2]) + result = f'bytes {value[0]}-{value[1]}/{value[2]}' return result -def format_content_disposition(value, disposition_type='attachment'): +def format_content_disposition(value: str, disposition_type: str = 'attachment') -> str: """Format a Content-Disposition header given a filename.""" # NOTE(vytas): RFC 6266, Appendix D. @@ -111,7 +117,7 @@ def format_content_disposition(value, disposition_type='attachment'): ) -def format_etag_header(value): +def format_etag_header(value: str) -> str: """Format an ETag header, wrap it with " " in case of need.""" if value[-1] != '"': @@ -120,12 +126,12 @@ def format_etag_header(value): return value -def format_header_value_list(iterable): +def format_header_value_list(iterable: Iterable[str]) -> str: """Join an iterable of strings with commas.""" return ', '.join(iterable) -def is_ascii_encodable(s): +def is_ascii_encodable(s: str) -> bool: """Check if argument encodes to ascii without error.""" try: s.encode('ascii') diff --git a/falcon/routing/static.py b/falcon/routing/static.py index d07af4211..76ef4ed14 100644 --- a/falcon/routing/static.py +++ b/falcon/routing/static.py @@ -249,7 +249,7 @@ async def __call__(self, req: asgi.Request, resp: asgi.Response, **kw: Any) -> N super().__call__(req, resp, **kw) # NOTE(kgriffs): Fixup resp.stream so that it is non-blocking - resp.stream = _AsyncFileReader(resp.stream) + resp.stream = _AsyncFileReader(resp.stream) # type: ignore[assignment,arg-type] class _AsyncFileReader: @@ -259,8 +259,8 @@ def __init__(self, file: IO[bytes]) -> None: self._file = file self._loop = asyncio.get_running_loop() - async def read(self, size=-1): + async def read(self, size: int = -1) -> bytes: return await self._loop.run_in_executor(None, partial(self._file.read, size)) - async def close(self): + async def close(self) -> None: await self._loop.run_in_executor(None, self._file.close) diff --git a/falcon/typing.py b/falcon/typing.py index c7e417667..a548b32ac 100644 --- a/falcon/typing.py +++ b/falcon/typing.py @@ -48,6 +48,7 @@ if TYPE_CHECKING: from falcon.asgi import Request as AsgiRequest from falcon.asgi import Response as AsgiResponse + from falcon.asgi import SSEvent from falcon.asgi import WebSocket from falcon.asgi_spec import AsgiEvent from falcon.asgi_spec import AsgiSendMsg @@ -120,6 +121,7 @@ async def __call__( ResponseStatus = Union[http.HTTPStatus, str, int] StoreArgument = Optional[Dict[str, Any]] Resource = object +RangeSetHeader = Union[Tuple[int, int, int], Tuple[int, int, int, str]] class ResponderMethod(Protocol): @@ -177,6 +179,11 @@ async def __call__( AsgiProcessResourceWsMethod = Callable[ ['AsgiRequest', 'WebSocket', Resource, Dict[str, Any]], Awaitable[None] ] +SseEmitter = AsyncIterator[Optional['SSEvent']] +ResponseCallbacks = Union[ + Tuple[Callable[[], None], Literal[False]], + Tuple[Callable[[], Awaitable[None]], Literal[True]], +] class AsgiResponderCallable(Protocol): diff --git a/pyproject.toml b/pyproject.toml index 5a9040bac..679607566 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,11 +116,7 @@ exclude = ["examples", "tests"] [[tool.mypy.overrides]] module = [ - "falcon.asgi.response", "falcon.media.validators.*", - "falcon.responders", - "falcon.response_helpers", - "falcon.response", "falcon.routing.*", "falcon.routing.converters", "falcon.testing.*", diff --git a/tests/test_cors_middleware.py b/tests/test_cors_middleware.py index 244d22398..4594242be 100644 --- a/tests/test_cors_middleware.py +++ b/tests/test_cors_middleware.py @@ -28,6 +28,12 @@ def on_delete(self, req, resp): resp.text = "I'm a CORS test response" +class CORSOptionsResource: + def on_options(self, req, resp): + # No allow header set + resp.set_header('Content-Length', '0') + + class TestCorsMiddleware: def test_disabled_cors_should_not_add_any_extra_headers(self, client): client.app.add_route('/', CORSHeaderResource()) @@ -80,6 +86,23 @@ def test_enabled_cors_handles_preflighting(self, cors_client): result.headers['Access-Control-Max-Age'] == '86400' ) # 24 hours in seconds + @pytest.mark.xfail(reason='will be fixed in 2325') + def test_enabled_cors_handles_preflighting_custom_option(self, cors_client): + cors_client.app.add_route('/', CORSOptionsResource()) + result = cors_client.simulate_options( + headers=( + ('Origin', 'localhost'), + ('Access-Control-Request-Method', 'GET'), + ('Access-Control-Request-Headers', 'X-PINGOTHER, Content-Type'), + ) + ) + assert 'Access-Control-Allow-Methods' not in result.headers + assert ( + result.headers['Access-Control-Allow-Headers'] + == 'X-PINGOTHER, Content-Type' + ) + assert result.headers['Access-Control-Max-Age'] == '86400' + def test_enabled_cors_handles_preflighting_no_headers_in_req(self, cors_client): cors_client.app.add_route('/', CORSHeaderResource()) result = cors_client.simulate_options( diff --git a/tests/test_headers.py b/tests/test_headers.py index 67a80e147..f7ba41d72 100644 --- a/tests/test_headers.py +++ b/tests/test_headers.py @@ -56,7 +56,8 @@ def on_get(self, req, resp): resp.last_modified = self.last_modified resp.retry_after = 3601 - # Relative URI's are OK per http://goo.gl/DbVqR + # Relative URI's are OK per + # https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.2 resp.location = '/things/87' resp.content_location = '/things/78' From e470e62395762e52f5bf72347f40657711de04ec Mon Sep 17 00:00:00 2001 From: Federico Caselli Date: Fri, 20 Sep 2024 17:14:04 +0200 Subject: [PATCH 6/6] feat(typing): annotate routing package (#2327) * typing: type app * typing: type websocket module * typing: type asgi.reader, asgi.structures, asgi.stream * typing: type most of media * typing: type multipart * typing: type response * style: fix spelling in multipart.py * style(tests): explain referencing the same property multiple times * style: fix linter errors * chore: revert behavioral change to cors middleware. * typing: type falcon.routing package * chore: do not build rapidjson on PyPy --------- Co-authored-by: Vytautas Liuolia --- falcon/routing/compiled.py | 90 +++++++++++++++++------------------- falcon/routing/converters.py | 65 +++++++++++++++++--------- falcon/routing/util.py | 19 ++++---- pyproject.toml | 2 - 4 files changed, 95 insertions(+), 81 deletions(-) diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py index 443d0d4f3..6407484c6 100644 --- a/falcon/routing/compiled.py +++ b/falcon/routing/compiled.py @@ -240,7 +240,7 @@ def find_cmp_converter(node: CompiledRouterNode) -> Optional[Tuple[str, str]]: else: return None - def insert(nodes: List[CompiledRouterNode], path_index: int = 0): + def insert(nodes: List[CompiledRouterNode], path_index: int = 0) -> None: for node in nodes: segment = path[path_index] if node.matches(segment): @@ -351,12 +351,7 @@ def _require_coroutine_responders(self, method_map: MethodDict) -> None: # issue. if not iscoroutinefunction(responder) and is_python_func(responder): if _should_wrap_non_coroutines(): - - def let(responder=responder): - method_map[method] = wrap_sync_to_async(responder) - - let() - + method_map[method] = wrap_sync_to_async(responder) else: msg = ( 'The {} responder must be a non-blocking ' @@ -515,12 +510,13 @@ def _generate_ast( # noqa: C901 else: # NOTE(kgriffs): Simple nodes just capture the entire path - # segment as the value for the param. + # segment as the value for the param. They have a var_name defined + field_name = node.var_name + assert field_name is not None if node.var_converter_map: assert len(node.var_converter_map) == 1 - field_name = node.var_name __, converter_name, converter_argstr = node.var_converter_map[0] converter_class = self._converter_map[converter_name] @@ -547,7 +543,7 @@ def _generate_ast( # noqa: C901 parent.append_child(cx_converter) parent = cx_converter else: - params_stack.append(_CxSetParamFromPath(node.var_name, level)) + params_stack.append(_CxSetParamFromPath(field_name, level)) # NOTE(kgriffs): We don't allow multiple simple var nodes # to exist at the same level, e.g.: @@ -745,7 +741,7 @@ def __init__( method_map: Optional[MethodDict] = None, resource: Optional[object] = None, uri_template: Optional[str] = None, - ): + ) -> None: self.children: List[CompiledRouterNode] = [] self.raw_segment = raw_segment @@ -833,12 +829,12 @@ def __init__( if self.is_complex: assert self.is_var - def matches(self, segment: str): + def matches(self, segment: str) -> bool: """Return True if this node matches the supplied template segment.""" return segment == self.raw_segment - def conflicts_with(self, segment: str): + def conflicts_with(self, segment: str) -> bool: """Return True if this node conflicts with a given template segment.""" # NOTE(kgriffs): This method assumes that the caller has already @@ -900,11 +896,11 @@ class ConverterDict(UserDict): data: Dict[str, Type[converters.BaseConverter]] - def __setitem__(self, name, converter): + def __setitem__(self, name: str, converter: Type[converters.BaseConverter]) -> None: self._validate(name) UserDict.__setitem__(self, name, converter) - def _validate(self, name): + def _validate(self, name: str) -> None: if not _IDENTIFIER_PATTERN.match(name): raise ValueError( 'Invalid converter name. Names may not be blank, and may ' @@ -948,14 +944,14 @@ class CompiledRouterOptions: __slots__ = ('converters',) - def __init__(self): + def __init__(self) -> None: object.__setattr__( self, 'converters', ConverterDict((name, converter) for name, converter in converters.BUILTIN), ) - def __setattr__(self, name, value) -> None: + def __setattr__(self, name: str, value: Any) -> None: if name == 'converters': raise AttributeError('Cannot set "converters", please update it in place.') super().__setattr__(name, value) @@ -978,13 +974,13 @@ class _CxParent: def __init__(self) -> None: self._children: List[_CxElement] = [] - def append_child(self, construct: _CxElement): + def append_child(self, construct: _CxElement) -> None: self._children.append(construct) def src(self, indentation: int) -> str: return self._children_src(indentation + 1) - def _children_src(self, indentation): + def _children_src(self, indentation: int) -> str: src_lines = [child.src(indentation) for child in self._children] return '\n'.join(src_lines) @@ -997,12 +993,12 @@ def src(self, indentation: int) -> str: class _CxIfPathLength(_CxParent): - def __init__(self, comparison, length): + def __init__(self, comparison: str, length: int) -> None: super().__init__() self._comparison = comparison self._length = length - def src(self, indentation): + def src(self, indentation: int) -> str: template = '{0}if path_len {1} {2}:\n{3}' return template.format( _TAB_STR * indentation, @@ -1013,12 +1009,12 @@ def src(self, indentation): class _CxIfPathSegmentLiteral(_CxParent): - def __init__(self, segment_idx, literal): + def __init__(self, segment_idx: int, literal: str) -> None: super().__init__() self._segment_idx = segment_idx self._literal = literal - def src(self, indentation): + def src(self, indentation: int) -> str: template = "{0}if path[{1}] == '{2}':\n{3}" return template.format( _TAB_STR * indentation, @@ -1029,13 +1025,13 @@ def src(self, indentation): class _CxIfPathSegmentPattern(_CxParent): - def __init__(self, segment_idx, pattern_idx, pattern_text): + def __init__(self, segment_idx: int, pattern_idx: int, pattern_text: str) -> None: super().__init__() self._segment_idx = segment_idx self._pattern_idx = pattern_idx self._pattern_text = pattern_text - def src(self, indentation): + def src(self, indentation: int) -> str: lines = [ '{0}match = patterns[{1}].match(path[{2}]) # {3}'.format( _TAB_STR * indentation, @@ -1051,13 +1047,13 @@ def src(self, indentation): class _CxIfConverterField(_CxParent): - def __init__(self, unique_idx, converter_idx): + def __init__(self, unique_idx: int, converter_idx: int) -> None: super().__init__() self._converter_idx = converter_idx self._unique_idx = unique_idx self.field_variable_name = 'field_value_{0}'.format(unique_idx) - def src(self, indentation): + def src(self, indentation: int) -> str: lines = [ '{0}{1} = converters[{2}].convert(fragment)'.format( _TAB_STR * indentation, @@ -1074,10 +1070,10 @@ def src(self, indentation): class _CxSetFragmentFromField(_CxChild): - def __init__(self, field_name): + def __init__(self, field_name: str) -> None: self._field_name = field_name - def src(self, indentation): + def src(self, indentation: int) -> str: return "{0}fragment = groups.pop('{1}')".format( _TAB_STR * indentation, self._field_name, @@ -1085,10 +1081,10 @@ def src(self, indentation): class _CxSetFragmentFromPath(_CxChild): - def __init__(self, segment_idx): + def __init__(self, segment_idx: int) -> None: self._segment_idx = segment_idx - def src(self, indentation): + def src(self, indentation: int) -> str: return '{0}fragment = path[{1}]'.format( _TAB_STR * indentation, self._segment_idx, @@ -1096,10 +1092,10 @@ def src(self, indentation): class _CxSetFragmentFromRemainingPaths(_CxChild): - def __init__(self, segment_idx): + def __init__(self, segment_idx: int) -> None: self._segment_idx = segment_idx - def src(self, indentation): + def src(self, indentation: int) -> str: return '{0}fragment = path[{1}:]'.format( _TAB_STR * indentation, self._segment_idx, @@ -1107,51 +1103,51 @@ def src(self, indentation): class _CxVariableFromPatternMatch(_CxChild): - def __init__(self, unique_idx): + def __init__(self, unique_idx: int) -> None: self._unique_idx = unique_idx self.dict_variable_name = 'dict_match_{0}'.format(unique_idx) - def src(self, indentation): + def src(self, indentation: int) -> str: return '{0}{1} = match.groupdict()'.format( _TAB_STR * indentation, self.dict_variable_name ) class _CxVariableFromPatternMatchPrefetched(_CxChild): - def __init__(self, unique_idx): + def __init__(self, unique_idx: int) -> None: self._unique_idx = unique_idx self.dict_variable_name = 'dict_groups_{0}'.format(unique_idx) - def src(self, indentation): + def src(self, indentation: int) -> str: return '{0}{1} = groups'.format(_TAB_STR * indentation, self.dict_variable_name) class _CxPrefetchGroupsFromPatternMatch(_CxChild): - def src(self, indentation): + def src(self, indentation: int) -> str: return '{0}groups = match.groupdict()'.format(_TAB_STR * indentation) class _CxReturnNone(_CxChild): - def src(self, indentation): + def src(self, indentation: int) -> str: return '{0}return None'.format(_TAB_STR * indentation) class _CxReturnValue(_CxChild): - def __init__(self, value_idx): + def __init__(self, value_idx: int) -> None: self._value_idx = value_idx - def src(self, indentation): + def src(self, indentation: int) -> str: return '{0}return return_values[{1}]'.format( _TAB_STR * indentation, self._value_idx ) class _CxSetParamFromPath(_CxChild): - def __init__(self, param_name, segment_idx): + def __init__(self, param_name: str, segment_idx: int) -> None: self._param_name = param_name self._segment_idx = segment_idx - def src(self, indentation): + def src(self, indentation: int) -> str: return "{0}params['{1}'] = path[{2}]".format( _TAB_STR * indentation, self._param_name, @@ -1160,11 +1156,11 @@ def src(self, indentation): class _CxSetParamFromValue(_CxChild): - def __init__(self, param_name, field_value_name): + def __init__(self, param_name: str, field_value_name: str) -> None: self._param_name = param_name self._field_value_name = field_value_name - def src(self, indentation): + def src(self, indentation: int) -> str: return "{0}params['{1}'] = {2}".format( _TAB_STR * indentation, self._param_name, @@ -1173,10 +1169,10 @@ def src(self, indentation): class _CxSetParamsFromDict(_CxChild): - def __init__(self, dict_value_name): + def __init__(self, dict_value_name: str) -> None: self._dict_value_name = dict_value_name - def src(self, indentation): + def src(self, indentation: int) -> str: return '{0}params.update({1})'.format( _TAB_STR * indentation, self._dict_value_name, diff --git a/falcon/routing/converters.py b/falcon/routing/converters.py index 2d2bc7fa1..d50d6b85e 100644 --- a/falcon/routing/converters.py +++ b/falcon/routing/converters.py @@ -11,11 +11,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations import abc from datetime import datetime from math import isfinite -from typing import Optional +from typing import Any, ClassVar, Iterable, Optional, overload, Union import uuid __all__ = ( @@ -34,7 +35,7 @@ class BaseConverter(metaclass=abc.ABCMeta): """Abstract base class for URI template field converters.""" - CONSUME_MULTIPLE_SEGMENTS = False + CONSUME_MULTIPLE_SEGMENTS: ClassVar[bool] = False """When set to ``True`` it indicates that this converter will consume multiple URL path segments. Currently a converter with ``CONSUME_MULTIPLE_SEGMENTS=True`` must be at the end of the URL template @@ -42,8 +43,8 @@ class BaseConverter(metaclass=abc.ABCMeta): segments. """ - @abc.abstractmethod # pragma: no cover - def convert(self, value): + @abc.abstractmethod + def convert(self, value: str) -> Any: """Convert a URI template field value to another format or type. Args: @@ -76,14 +77,19 @@ class IntConverter(BaseConverter): __slots__ = ('_num_digits', '_min', '_max') - def __init__(self, num_digits=None, min=None, max=None): + def __init__( + self, + num_digits: Optional[int] = None, + min: Optional[int] = None, + max: Optional[int] = None, + ) -> None: if num_digits is not None and num_digits < 1: raise ValueError('num_digits must be at least 1') self._num_digits = num_digits self._min = min self._max = max - def convert(self, value): + def convert(self, value: str) -> Optional[int]: if self._num_digits is not None and len(value) != self._num_digits: return None @@ -96,22 +102,35 @@ def convert(self, value): return None try: - value = int(value) + converted = int(value) except ValueError: return None - return self._validate_min_max_value(value) + return _validate_min_max_value(self, converted) - def _validate_min_max_value(self, value): - if self._min is not None and value < self._min: - return None - if self._max is not None and value > self._max: - return None - return value +@overload +def _validate_min_max_value(converter: IntConverter, value: int) -> Optional[int]: ... + + +@overload +def _validate_min_max_value( + converter: FloatConverter, value: float +) -> Optional[float]: ... + + +def _validate_min_max_value( + converter: Union[IntConverter, FloatConverter], value: Union[int, float] +) -> Optional[Union[int, float]]: + if converter._min is not None and value < converter._min: + return None + if converter._max is not None and value > converter._max: + return None + + return value -class FloatConverter(IntConverter): +class FloatConverter(BaseConverter): """Converts a field value to an float. Identifier: `float` @@ -124,19 +143,19 @@ class FloatConverter(IntConverter): nan, inf, and -inf in addition to finite numbers. """ - __slots__ = '_finite' + __slots__ = '_finite', '_min', '_max' def __init__( self, min: Optional[float] = None, max: Optional[float] = None, finite: bool = True, - ): + ) -> None: self._min = min self._max = max self._finite = finite if finite is not None else True - def convert(self, value: str): + def convert(self, value: str) -> Optional[float]: if value.strip() != value: return None @@ -149,7 +168,7 @@ def convert(self, value: str): except ValueError: return None - return self._validate_min_max_value(converted) + return _validate_min_max_value(self, converted) class DateTimeConverter(BaseConverter): @@ -165,10 +184,10 @@ class DateTimeConverter(BaseConverter): __slots__ = ('_format_string',) - def __init__(self, format_string='%Y-%m-%dT%H:%M:%SZ'): + def __init__(self, format_string: str = '%Y-%m-%dT%H:%M:%SZ') -> None: self._format_string = format_string - def convert(self, value): + def convert(self, value: str) -> Optional[datetime]: try: return strptime(value, self._format_string) except ValueError: @@ -185,7 +204,7 @@ class UUIDConverter(BaseConverter): Note, however, that hyphens and the URN prefix are optional. """ - def convert(self, value): + def convert(self, value: str) -> Optional[uuid.UUID]: try: return uuid.UUID(value) except ValueError: @@ -213,7 +232,7 @@ class PathConverter(BaseConverter): CONSUME_MULTIPLE_SEGMENTS = True - def convert(self, value): + def convert(self, value: Iterable[str]) -> str: return '/'.join(value) diff --git a/falcon/routing/util.py b/falcon/routing/util.py index 3d254acc1..b789b0829 100644 --- a/falcon/routing/util.py +++ b/falcon/routing/util.py @@ -17,22 +17,25 @@ from __future__ import annotations import re -from typing import Callable, Dict, Optional +from typing import Optional, Set, Tuple, TYPE_CHECKING from falcon import constants from falcon import responders from falcon.util.deprecation import deprecated +if TYPE_CHECKING: + from falcon.typing import MethodDict + class SuffixedMethodNotFoundError(Exception): - def __init__(self, message): + def __init__(self, message: str) -> None: super(SuffixedMethodNotFoundError, self).__init__(message) self.message = message # NOTE(kgriffs): Published method; take care to avoid breaking changes. @deprecated('This method will be removed in Falcon 4.0.') -def compile_uri_template(template): +def compile_uri_template(template: str) -> Tuple[Set[str], re.Pattern[str]]: """Compile the given URI template string into a pattern matcher. This function can be used to construct custom routing engines that @@ -102,9 +105,7 @@ def compile_uri_template(template): return fields, re.compile(pattern, re.IGNORECASE) -def map_http_methods( - resource: object, suffix: Optional[str] = None -) -> Dict[str, Callable]: +def map_http_methods(resource: object, suffix: Optional[str] = None) -> MethodDict: """Map HTTP methods (e.g., GET, POST) to methods of a resource object. Args: @@ -151,7 +152,7 @@ def map_http_methods( return method_map -def set_default_responders(method_map, asgi=False): +def set_default_responders(method_map: MethodDict, asgi: bool = False) -> None: """Map HTTP methods not explicitly defined on a resource to default responders. Args: @@ -169,11 +170,11 @@ def set_default_responders(method_map, asgi=False): if 'OPTIONS' not in method_map: # OPTIONS itself is intentionally excluded from the Allow header opt_responder = responders.create_default_options(allowed_methods, asgi=asgi) - method_map['OPTIONS'] = opt_responder + method_map['OPTIONS'] = opt_responder # type: ignore[assignment] allowed_methods.append('OPTIONS') na_responder = responders.create_method_not_allowed(allowed_methods, asgi=asgi) for method in constants.COMBINED_METHODS: if method not in method_map: - method_map[method] = na_responder + method_map[method] = na_responder # type: ignore[assignment] diff --git a/pyproject.toml b/pyproject.toml index 679607566..ee74ee31d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,8 +117,6 @@ exclude = ["examples", "tests"] [[tool.mypy.overrides]] module = [ "falcon.media.validators.*", - "falcon.routing.*", - "falcon.routing.converters", "falcon.testing.*", "falcon.vendor.*", ]