From 00f2212a1940e62e4fcb1a4d724a529e73999ec0 Mon Sep 17 00:00:00 2001 From: Federico Caselli Date: Fri, 30 Aug 2024 21:11:59 +0200 Subject: [PATCH 01/15] feat(typing): type multipart forms (#2299) * typing: type app * typing: type websocket module * typing: type asgi.reader, asgi.structures, asgi.stream * typing: type most of media * typing: type multipart * style: fix spelling in multipart.py * style(tests): explain referencing the same property multiple times --------- Co-authored-by: Vytautas Liuolia --- docs/api/multipart.rst | 24 ++- falcon/asgi/multipart.py | 133 +++++++++++- falcon/asgi/reader.py | 17 +- falcon/media/multipart.py | 370 +++++++++++++++++----------------- falcon/request.py | 2 +- falcon/typing.py | 2 + falcon/util/misc.py | 8 +- falcon/util/reader.py | 2 +- pyproject.toml | 3 - tests/test_media_multipart.py | 4 + 10 files changed, 357 insertions(+), 208 deletions(-) diff --git a/docs/api/multipart.rst b/docs/api/multipart.rst index 8eed74f61..cf2620414 100644 --- a/docs/api/multipart.rst +++ b/docs/api/multipart.rst @@ -69,12 +69,26 @@ default, allowing you to use ``req.get_media()`` to iterate over the `. Falcon offers straightforward support for all of these scenarios. -Body Part Type --------------- +Multipart Form and Body Part Types +---------------------------------- -.. autoclass:: falcon.media.multipart.BodyPart - :members: - :exclude-members: data, media, text +.. tabs:: + + .. group-tab:: WSGI + + .. autoclass:: falcon.media.multipart.MultipartForm + :members: + + .. autoclass:: falcon.media.multipart.BodyPart + :members: + + .. group-tab:: ASGI + + .. autoclass:: falcon.asgi.multipart.MultipartForm + :members: + + .. autoclass:: falcon.asgi.multipart.BodyPart + :members: .. _multipart_parser_conf: diff --git a/falcon/asgi/multipart.py b/falcon/asgi/multipart.py index 52cb0505e..403028b26 100644 --- a/falcon/asgi/multipart.py +++ b/falcon/asgi/multipart.py @@ -14,11 +14,27 @@ """ASGI multipart form media handler components.""" +from __future__ import annotations + +from typing import ( + Any, + AsyncIterator, + Awaitable, + Dict, + Optional, + TYPE_CHECKING, +) + from falcon.asgi.reader import BufferedReader from falcon.errors import DelimiterError from falcon.media import multipart +from falcon.typing import AsyncReadableIO +from falcon.typing import MISSING from falcon.util.mediatypes import parse_header +if TYPE_CHECKING: + from falcon.media.multipart import MultipartParseOptions + _ALLOWED_CONTENT_HEADERS = multipart._ALLOWED_CONTENT_HEADERS _CRLF = multipart._CRLF _CRLF_CRLF = multipart._CRLF_CRLF @@ -27,7 +43,43 @@ class BodyPart(multipart.BodyPart): - async def get_data(self): + """Represents a body part in a multipart form in a ASGI application. + + Note: + :class:`BodyPart` is meant to be instantiated directly only by the + :class:`MultipartFormHandler` parser. + """ + + if TYPE_CHECKING: + + def __init__( + self, + stream: BufferedReader, + headers: Dict[bytes, bytes], + parse_options: MultipartParseOptions, + ): ... + + stream: BufferedReader # type: ignore[assignment] + """File-like input object for reading the body part of the + multipart form request, if any. This object provides direct access + to the server's data stream and is non-seekable. The stream is + automatically delimited according to the multipart stream boundary. + + With the exception of being buffered to keep track of the boundary, + the wrapped body part stream interface and behavior mimic + :attr:`Request.stream `. + + Similarly to :attr:`BoundedStream `, + the most efficient way to read the body part content is asynchronous + iteration over part data chunks: + + .. code:: python + + async for data_chunk in part.stream: + pass + """ + + async def get_data(self) -> bytes: # type: ignore[override] if self._data is None: max_size = self._parse_options.max_body_part_buffer_size + 1 self._data = await self.stream.read(max_size) @@ -36,8 +88,21 @@ async def get_data(self): return self._data - async def get_media(self): - if self._media is None: + async def get_media(self) -> Any: + """Return a deserialized form of the multipart body part. + + When called, this method will attempt to deserialize the body part + stream using the Content-Type header as well as the media-type handlers + configured via :class:`~falcon.media.multipart.MultipartParseOptions`. + + The result will be cached and returned in subsequent calls:: + + deserialized_media = await part.get_media() + + Returns: + object: The deserialized media representation. + """ + if self._media is MISSING: handler, _, _ = self._parse_options.media_handlers._resolve( self.content_type, 'text/plain' ) @@ -52,7 +117,7 @@ async def get_media(self): return self._media - async def get_text(self): + async def get_text(self) -> Optional[str]: # type: ignore[override] content_type, options = parse_header(self.content_type) if content_type != 'text/plain': return None @@ -65,13 +130,61 @@ async def get_text(self): description='invalid text or charset: {}'.format(charset) ) from err - data = property(get_data) - media = property(get_media) - text = property(get_text) + data: Awaitable[bytes] = property(get_data) # type: ignore[assignment] + """Property that acts as a convenience alias for :meth:`~.get_data`. + + The ``await`` keyword must still be added when referencing + the property:: + + # Equivalent to: content = await part.get_data() + content = await part.data + """ + media: Awaitable[Any] = property(get_media) # type: ignore[assignment] + """Property that acts as a convenience alias for :meth:`~.get_media`. + + The ``await`` keyword must still be added when referencing + the property:: + + # Equivalent to: deserialized_media = await part.get_media() + deserialized_media = await part.media + """ + text: Awaitable[bytes] = property(get_text) # type: ignore[assignment] + """Property that acts as a convenience alias for :meth:`~.get_text`. + + The ``await`` keyword must still be added when referencing + the property:: + + # Equivalent to: decoded_text = await part.get_text() + decoded_text = await part.text + """ class MultipartForm: - def __init__(self, stream, boundary, content_length, parse_options): + """Iterable object that returns each form part as :class:`BodyPart` instances. + + Typical usage illustrated below:: + + async def on_post(self, req: Request, resp: Response) -> None: + form: MultipartForm = await req.get_media() + + async for part in form: + if part.name == 'foo': + ... + else: + ... + + Note: + :class:`MultipartForm` is meant to be instantiated directly only by the + :class:`MultipartFormHandler` parser. + """ + + def __init__( + self, + stream: AsyncReadableIO, + boundary: bytes, + content_length: Optional[int], + parse_options: MultipartParseOptions, + ) -> None: self._stream = ( stream if isinstance(stream, BufferedReader) else BufferedReader(stream) ) @@ -83,10 +196,10 @@ def __init__(self, stream, boundary, content_length, parse_options): self._dash_boundary = b'--' + boundary self._parse_options = parse_options - def __aiter__(self): + def __aiter__(self) -> AsyncIterator[BodyPart]: return self._iterate_parts() - async def _iterate_parts(self): + async def _iterate_parts(self) -> AsyncIterator[BodyPart]: prologue = True delimiter = self._dash_boundary stream = self._stream diff --git a/falcon/asgi/reader.py b/falcon/asgi/reader.py index 281e607c4..c2adda791 100644 --- a/falcon/asgi/reader.py +++ b/falcon/asgi/reader.py @@ -17,10 +17,11 @@ from __future__ import annotations import io -from typing import AsyncIterator, List, NoReturn, Optional, Protocol +from typing import AsyncIterator, List, NoReturn, Optional, Protocol, Union from falcon.errors import DelimiterError from falcon.errors import OperationNotAllowed +from falcon.typing import AsyncReadableIO DEFAULT_CHUNK_SIZE = 8192 """Default minimum chunk size for :class:`BufferedReader` (8 KiB).""" @@ -58,7 +59,11 @@ class BufferedReader: _max_join_size: int _source: AsyncIterator[bytes] - def __init__(self, source: AsyncIterator[bytes], chunk_size: Optional[int] = None): + def __init__( + self, + source: Union[AsyncReadableIO, AsyncIterator[bytes]], + chunk_size: Optional[int] = None, + ): self._source = self._iter_normalized(source) self._chunk_size = chunk_size or DEFAULT_CHUNK_SIZE self._max_join_size = self._chunk_size * _MAX_JOIN_CHUNKS @@ -71,7 +76,7 @@ def __init__(self, source: AsyncIterator[bytes], chunk_size: Optional[int] = Non self._iteration_started = False async def _iter_normalized( - self, source: AsyncIterator[bytes] + self, source: Union[AsyncReadableIO, AsyncIterator[bytes]] ) -> AsyncIterator[bytes]: chunk = b'' chunk_size = self._chunk_size @@ -190,7 +195,9 @@ def _trim_buffer(self) -> None: self._buffer_len -= self._buffer_pos self._buffer_pos = 0 - async def _read_from(self, source: AsyncIterator[bytes], size: int = -1) -> bytes: + async def _read_from( + self, source: AsyncIterator[bytes], size: Optional[int] = -1 + ) -> bytes: if size == -1 or size is None: result_bytes = io.BytesIO() async for chunk in source: @@ -290,7 +297,7 @@ async def pipe_until( if consume_delimiter: await self._consume_delimiter(delimiter) - async def read(self, size: int = -1) -> bytes: + async def read(self, size: Optional[int] = -1) -> bytes: return await self._read_from(self._iter_with_buffer(size_hint=size or 0), size) async def readall(self) -> bytes: diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py index 4e08b5306..17a990265 100644 --- a/falcon/media/multipart.py +++ b/falcon/media/multipart.py @@ -17,13 +17,29 @@ from __future__ import annotations import re -from typing import Any, ClassVar, Dict, Optional, Tuple, Type, TYPE_CHECKING +from typing import ( + Any, + ClassVar, + Dict, + Iterator, + NoReturn, + Optional, + overload, + Tuple, + Type, + TYPE_CHECKING, + Union, +) from urllib.parse import unquote_to_bytes from falcon import errors from falcon.errors import MultipartParseError from falcon.media.base import BaseHandler from falcon.stream import BoundedStream +from falcon.typing import AsyncReadableIO +from falcon.typing import MISSING +from falcon.typing import MissingOr +from falcon.typing import ReadableIO from falcon.util import BufferedReader from falcon.util import misc from falcon.util.mediatypes import parse_header @@ -31,6 +47,7 @@ if TYPE_CHECKING: from falcon.asgi.multipart import MultipartForm as AsgiMultipartForm from falcon.media import Handlers + from falcon.util.reader import BufferedReader as PyBufferedReader # TODO(vytas): # * Better support for form-wide charset setting @@ -54,158 +71,57 @@ # TODO(vytas): Consider supporting -charset- stuff. # Does anyone use that (?) class BodyPart: - """Represents a body part in a multipart form. + """Represents a body part in a multipart form in an ASGI application. Note: :class:`BodyPart` is meant to be instantiated directly only by the :class:`MultipartFormHandler` parser. + """ - Attributes: - content_type (str): Value of the Content-Type header, or the multipart - form default ``text/plain`` if the header is missing. - - data (bytes): Property that acts as a convenience alias for - :meth:`~.get_data`. - - - .. tabs:: - - .. tab:: WSGI - - .. code:: python - - # Equivalent to: content = part.get_data() - content = part.data - - .. tab:: ASGI - - The ``await`` keyword must still be added when referencing - the property:: - - # Equivalent to: content = await part.get_data() - content = await part.data - - name(str): The name parameter of the Content-Disposition header. - The value of the "name" parameter is the original field name from - the submitted HTML form. - - .. note:: - According to `RFC 7578, section 4.2 - `__, each part - MUST include a Content-Disposition header field of type - "form-data", where the name parameter is mandatory. - - However, Falcon will not raise any error if this parameter is - missing; the property value will be ``None`` in that case. - - filename (str): File name if the body part is an attached file, and - ``None`` otherwise. - - secure_filename (str): The sanitized version of `filename` using only - the most common ASCII characters for maximum portability and safety - wrt using this name as a filename on a regular file system. - - If `filename` is empty or unset when referencing this property, an - instance of :class:`.MultipartParseError` will be raised. - - See also: :func:`~.secure_filename` - - stream: File-like input object for reading the body part of the - multipart form request, if any. This object provides direct access - to the server's data stream and is non-seekable. The stream is - automatically delimited according to the multipart stream boundary. - - With the exception of being buffered to keep track of the boundary, - the wrapped body part stream interface and behavior mimic - :attr:`Request.bounded_stream ` - (WSGI) and :attr:`Request.stream ` - (ASGI), respectively: - - .. tabs:: - - .. tab:: WSGI - - Reading the whole part content: - - .. code:: python - - data = part.stream.read() - - This is also safe: - - .. code:: python - - doc = yaml.safe_load(part.stream) - - .. tab:: ASGI - - Similarly to - :attr:`BoundedStream `, the most - efficient way to read the body part content is asynchronous - iteration over part data chunks: - - .. code:: python - - async for data_chunk in part.stream: - pass - - media (object): Property that acts as a convenience alias for - :meth:`~.get_media`. - - .. tabs:: - - .. tab:: WSGI - - .. code:: python - - # Equivalent to: deserialized_media = part.get_media() - deserialized_media = req.media - - .. tab:: ASGI - - The ``await`` keyword must still be added when referencing - the property:: - - # Equivalent to: deserialized_media = await part.get_media() - deserialized_media = await part.media + _content_disposition: Optional[Tuple[str, Dict[str, str]]] = None + _data: Optional[bytes] = None + _filename: MissingOr[Optional[str]] = MISSING + _media: MissingOr[Any] = MISSING + _name: MissingOr[Optional[str]] = MISSING - text (str): Property that acts as a convenience alias for - :meth:`~.get_text`. + stream: PyBufferedReader + """File-like input object for reading the body part of the + multipart form request, if any. This object provides direct access + to the server's data stream and is non-seekable. The stream is + automatically delimited according to the multipart stream boundary. - .. tabs:: + With the exception of being buffered to keep track of the boundary, + the wrapped body part stream interface and behavior mimic + :attr:`Request.bounded_stream `. - .. tab:: WSGI + Reading the whole part content: - .. code:: python + .. code:: python - # Equivalent to: decoded_text = part.get_text() - decoded_text = part.text + data = part.stream.read() - .. tab:: ASGI + This is also safe: - The ``await`` keyword must still be added when referencing - the property:: + .. code:: python - # Equivalent to: decoded_text = await part.get_text() - decoded_text = await part.text + doc = yaml.safe_load(part.stream) """ - _content_disposition: Optional[Tuple[str, Dict[str, str]]] = None - _data: Optional[bytes] = None - _filename: Optional[str] = None - _media: Optional[Any] = None - _name: Optional[str] = None - - def __init__(self, stream, headers, parse_options): + def __init__( + self, + stream: PyBufferedReader, + headers: Dict[bytes, bytes], + parse_options: MultipartParseOptions, + ): self.stream = stream self._headers = headers self._parse_options = parse_options - def get_data(self): + def get_data(self) -> bytes: """Return the body part content bytes. The maximum number of bytes that may be read is configurable via - :class:`MultipartParseOptions`, and a :class:`.MultipartParseError` is + :class:`.MultipartParseOptions`, and a :class:`.MultipartParseError` is raised if the body part is larger that this size. The size limit guards against reading unexpectedly large amount of data @@ -230,7 +146,7 @@ def get_data(self): return self._data - def get_text(self): + def get_text(self) -> Optional[str]: """Return the body part content decoded as a text string. Text is decoded from the part content (as returned by @@ -268,7 +184,11 @@ def get_text(self): ) from err @property - def content_type(self): + def content_type(self) -> str: + """Value of the Content-Type header. + + When the header is missing returns the multipart form default ``text/plain``. + """ # NOTE(vytas): RFC 7578, section 4.4. # Each part MAY have an (optional) "Content-Type" header field, which # defaults to "text/plain". @@ -276,8 +196,9 @@ def content_type(self): return value.decode('ascii') @property - def filename(self): - if self._filename is None: + def filename(self) -> Optional[str]: + """File name if the body part is an attached file, and ``None`` otherwise.""" + if self._filename is MISSING: if self._content_disposition is None: value = self._headers.get(b'content-disposition', b'') self._content_disposition = parse_header(value.decode()) @@ -288,31 +209,51 @@ def filename(self): # been spotted in the wild, even though RFC 7578 forbids it. match = _FILENAME_STAR_RFC5987.match(params.get('filename*', '')) if match: - charset, value = match.groups() + charset, filename_raw = match.groups() try: - self._filename = unquote_to_bytes(value).decode(charset) + self._filename = unquote_to_bytes(filename_raw).decode(charset) except (ValueError, LookupError) as err: raise MultipartParseError( description='invalid text or charset: {}'.format(charset) ) from err else: - value = params.get('filename') - if value is None: - return None - self._filename = value + self._filename = params.get('filename') return self._filename @property - def secure_filename(self): + def secure_filename(self) -> str: + """The sanitized version of `filename` using only the most common ASCII + characters for maximum portability and safety wrt using this name as a + filename on a regular file system. + + If `filename` is empty or unset when referencing this property, an + instance of :class:`.MultipartParseError` will be raised. + + See also: :func:`~.secure_filename` + """ # noqa: D205 try: return misc.secure_filename(self.filename) except ValueError as ex: raise MultipartParseError(description=str(ex)) from ex @property - def name(self): - if self._name is None: + def name(self) -> Optional[str]: + """The name parameter of the Content-Disposition header. + + The value of the "name" parameter is the original field name from + the submitted HTML form. + + .. note:: + According to `RFC 7578, section 4.2 + `__, each part + MUST include a Content-Disposition header field of type + "form-data", where the name parameter is mandatory. + + However, Falcon will not raise any error if this parameter is + missing; the property value will be ``None`` in that case. + """ + if self._name is MISSING: if self._content_disposition is None: value = self._headers.get(b'content-disposition', b'') self._content_disposition = parse_header(value.decode()) @@ -322,31 +263,21 @@ def name(self): return self._name - def get_media(self): + def get_media(self) -> Any: """Return a deserialized form of the multipart body part. When called, this method will attempt to deserialize the body part stream using the Content-Type header as well as the media-type handlers configured via :class:`MultipartParseOptions`. - .. tabs:: + The result will be cached and returned in subsequent calls:: - .. tab:: WSGI - - The result will be cached and returned in subsequent calls:: - - deserialized_media = part.get_media() - - .. tab:: ASGI - - The result will be cached and returned in subsequent calls:: - - deserialized_media = await part.get_media() + deserialized_media = part.get_media() Returns: object: The deserialized media representation. """ - if self._media is None: + if self._media is MISSING: handler, _, _ = self._parse_options.media_handlers._resolve( self.content_type, 'text/plain' ) @@ -359,24 +290,70 @@ def get_media(self): return self._media - data = property(get_data) - media = property(get_media) - text = property(get_text) + data: bytes = property(get_data) # type: ignore[assignment] + """Property that acts as a convenience alias for :meth:`~.get_data`. + + .. code:: python + + # Equivalent to: content = part.get_data() + content = part.data + """ + media: Any = property(get_media) + """Property that acts as a convenience alias for :meth:`~.get_media`. + + .. code:: python + + # Equivalent to: deserialized_media = part.get_media() + deserialized_media = req.media + """ + text: str = property(get_text) # type: ignore[assignment] + """Property that acts as a convenience alias for :meth:`~.get_text`. + + .. code:: python + + # Equivalent to: decoded_text = part.get_text() + decoded_text = part.text + """ class MultipartForm: - def __init__(self, stream, boundary, content_length, parse_options): + """Iterable object that returns each form part as :class:`BodyPart` instances. + + Typical usage illustrated below:: + + def on_post(self, req: Request, resp: Response) -> None: + form: MultipartForm = req.get_media() + + for part in form: + if part.name == 'foo': + ... + else: + ... + + Note: + :class:`MultipartForm` is meant to be instantiated directly only by the + :class:`MultipartFormHandler` parser. + """ + + def __init__( + self, + stream: ReadableIO, + boundary: bytes, + content_length: Optional[int], + parse_options: MultipartParseOptions, + ) -> None: # NOTE(vytas): More lenient check whether the provided stream is not # already an instance of BufferedReader. # This approach makes testing both the Cythonized and pure-Python # streams easier within the same test/benchmark suite. if not hasattr(stream, 'read_until'): + assert content_length is not None if isinstance(stream, BoundedStream): stream = BufferedReader(stream.stream.read, content_length) else: stream = BufferedReader(stream.read, content_length) - self._stream = stream + self._stream: PyBufferedReader = stream # type: ignore[assignment] self._boundary = boundary # NOTE(vytas): Here self._dash_boundary is not prepended with CRLF # (yet) for parsing the prologue. The CRLF will be prepended later to @@ -385,7 +362,7 @@ def __init__(self, stream, boundary, content_length, parse_options): self._dash_boundary = b'--' + boundary self._parse_options = parse_options - def __iter__(self): + def __iter__(self) -> Iterator[BodyPart]: prologue = True delimiter = self._dash_boundary stream = self._stream @@ -419,7 +396,7 @@ def __iter__(self): description='unexpected form structure' ) from err - headers = {} + headers: Dict[bytes, bytes] = {} try: headers_block = stream.read_until( _CRLF_CRLF, max_headers_size, consume_delimiter=True @@ -480,23 +457,46 @@ class MultipartFormHandler(BaseHandler): over the media object. For examples on parsing the request form, see also: :ref:`multipart`. - - Attributes: - parse_options (MultipartParseOptions): - Configuration options for the multipart form parser and instances - of :class:`~falcon.media.multipart.BodyPart` it yields. - - See also: :ref:`multipart_parser_conf`. """ _ASGI_MULTIPART_FORM: ClassVar[Type[AsgiMultipartForm]] - def __init__(self, parse_options=None): + parse_options: MultipartParseOptions + """Configuration options for the multipart form parser and instances of + :class:`~falcon.media.multipart.BodyPart` it yields. + + See also: :ref:`multipart_parser_conf`. + """ + + def __init__(self, parse_options: Optional[MultipartParseOptions] = None) -> None: self.parse_options = parse_options or MultipartParseOptions() + @overload def _deserialize_form( - self, stream, content_type, content_length, form_cls=MultipartForm - ): + self, + stream: ReadableIO, + content_type: Optional[str], + content_length: Optional[int], + form_cls: Type[MultipartForm] = ..., + ) -> MultipartForm: ... + + @overload + def _deserialize_form( + self, + stream: AsyncReadableIO, + content_type: Optional[str], + content_length: Optional[int], + form_cls: Type[AsgiMultipartForm] = ..., + ) -> AsgiMultipartForm: ... + + def _deserialize_form( + self, + stream: Union[ReadableIO, AsyncReadableIO], + content_type: Optional[str], + content_length: Optional[int], + form_cls: Type[Union[MultipartForm, AsgiMultipartForm]] = MultipartForm, + ) -> Union[MultipartForm, AsgiMultipartForm]: + assert content_type is not None _, options = parse_header(content_type) try: boundary = options['boundary'] @@ -522,17 +522,27 @@ def _deserialize_form( 'Content-Type', ) - return form_cls(stream, boundary.encode(), content_length, self.parse_options) + return form_cls(stream, boundary.encode(), content_length, self.parse_options) # type: ignore[arg-type] - def deserialize(self, stream, content_type, content_length): + def deserialize( + self, + stream: ReadableIO, + content_type: Optional[str], + content_length: Optional[int], + ) -> MultipartForm: return self._deserialize_form(stream, content_type, content_length) - async def deserialize_async(self, stream, content_type, content_length): + async def deserialize_async( + self, + stream: AsyncReadableIO, + content_type: Optional[str], + content_length: Optional[int], + ) -> AsgiMultipartForm: return self._deserialize_form( stream, content_type, content_length, form_cls=self._ASGI_MULTIPART_FORM ) - def serialize(self, media, content_type): + def serialize(self, media: object, content_type: str) -> NoReturn: raise NotImplementedError('multipart form serialization unsupported') @@ -595,7 +605,7 @@ class MultipartParseOptions: 'media_handlers', ) - def __init__(self): + def __init__(self) -> None: self.default_charset = 'utf-8' self.max_body_part_buffer_size = 1024 * 1024 self.max_body_part_count = 64 diff --git a/falcon/request.py b/falcon/request.py index 1641a295f..db89c9470 100644 --- a/falcon/request.py +++ b/falcon/request.py @@ -312,7 +312,7 @@ def __init__( self._cached_uri: Optional[str] = None try: - self.content_type: Union[str, None] = self.env['CONTENT_TYPE'] + self.content_type = self.env['CONTENT_TYPE'] except KeyError: self.content_type = None diff --git a/falcon/typing.py b/falcon/typing.py index 6a7fe3813..c7e417667 100644 --- a/falcon/typing.py +++ b/falcon/typing.py @@ -21,6 +21,7 @@ import sys from typing import ( Any, + AsyncIterator, Awaitable, Callable, Dict, @@ -150,6 +151,7 @@ def __call__(self, req: Request, resp: Response, **kwargs: Any) -> None: ... # ASGI class AsyncReadableIO(Protocol): async def read(self, n: Optional[int] = ..., /) -> bytes: ... + def __aiter__(self) -> AsyncIterator[bytes]: ... class AsgiResponderMethod(Protocol): diff --git a/falcon/util/misc.py b/falcon/util/misc.py index 18a27b95e..1fbe09ef3 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -23,12 +23,14 @@ now = falcon.http_now() """ +from __future__ import annotations + import datetime import functools import http import inspect import re -from typing import Any, Callable, Dict, List, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Tuple, Union import unicodedata from falcon import status_codes @@ -214,7 +216,7 @@ def http_date_to_dt(http_date: str, obs_date: bool = False) -> datetime.datetime def to_query_str( - params: dict, comma_delimited_lists: bool = True, prefix: bool = True + params: Dict[str, Any], comma_delimited_lists: bool = True, prefix: bool = True ) -> str: """Convert a dictionary of parameters to a query string. @@ -370,7 +372,7 @@ def get_http_status( return str(code) + ' ' + default_reason -def secure_filename(filename: str) -> str: +def secure_filename(filename: Optional[str]) -> str: """Sanitize the provided `filename` to contain only ASCII characters. Only ASCII alphanumerals, ``'.'``, ``'-'`` and ``'_'`` are allowed for diff --git a/falcon/util/reader.py b/falcon/util/reader.py index 96adc4b5f..645b35520 100644 --- a/falcon/util/reader.py +++ b/falcon/util/reader.py @@ -120,7 +120,7 @@ def _normalize_size(self, size: Optional[int]) -> int: return max_size return size - def read(self, size: int = -1) -> bytes: + def read(self, size: Optional[int] = -1) -> bytes: return self._read(self._normalize_size(size)) def _read(self, size: int) -> bytes: diff --git a/pyproject.toml b/pyproject.toml index 214ba7bde..ef17db4f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,10 +42,7 @@ [[tool.mypy.overrides]] module = [ - "falcon.asgi.multipart", "falcon.asgi.response", - "falcon.asgi.stream", - "falcon.media.multipart", "falcon.media.validators.*", "falcon.responders", "falcon.response_helpers", diff --git a/tests/test_media_multipart.py b/tests/test_media_multipart.py index 277c0a567..31c7bbff8 100644 --- a/tests/test_media_multipart.py +++ b/tests/test_media_multipart.py @@ -289,8 +289,12 @@ def test_body_part_properties(): for part in form: if part.content_type == 'application/json': + # NOTE(vytas): This is not a typo, but a test that the name + # property can be safely referenced multiple times. assert part.name == part.name == 'document' elif part.name == 'file1': + # NOTE(vytas): This is not a typo, but a test that the filename + # property can be safely referenced multiple times. assert part.filename == part.filename == 'test.txt' assert part.secure_filename == part.filename From 7bb24dc70293d8c0f8b9b17079fe88cfb527d1fa Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sat, 31 Aug 2024 21:44:15 +0200 Subject: [PATCH 02/15] chore(dist): remove `setup.cfg` (#2277) * chore: prototype removal of `setup.cfg` (WiP) * chore(pyproject.toml): patch up license-files * docs(README): use PyPI compatible embedding of README * docs(README): make readme static * chore(docs/conf.py): fix docs not to depend on `setup.cfg` * chore(pyproject.toml):explicitly specify `text/x-rst` media type * docs(README): add Sentry * docs: polish newsfragments * docs: fix one newsfragment, update `RELEASE.md` * chore: specify test dependencies * chore: remove extraneous wheel build requirement --- RELEASE.md | 36 +++++---- docs/_newsfragments/2182.breakingchange.rst | 5 +- docs/_newsfragments/2253.misc.rst | 5 +- docs/_newsfragments/2301.misc.rst | 3 +- docs/_newsfragments/2314.breakingchange.rst | 5 ++ docs/conf.py | 17 ++-- falcon/version.py | 2 +- pyproject.toml | 86 +++++++++++++++++++-- setup.cfg | 83 -------------------- 9 files changed, 123 insertions(+), 119 deletions(-) create mode 100644 docs/_newsfragments/2314.breakingchange.rst delete mode 100644 setup.cfg diff --git a/RELEASE.md b/RELEASE.md index 81ac71cef..7ebfd7d79 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,7 +2,7 @@ Release Process: -1. Bump version and update tag. +1. Bump version (including the suffix for pre-release, if applicable). 2. Update changelog and render towncrier fragments. 3. Release beta or rc. 4. Run benchmark and check for regressions. @@ -12,16 +12,30 @@ Release Process: 8. Announce the new version in Gitter channels and on the socials. 9. Improve this document. -### Bump version and update tag +### Bump version Modify `falcon/version.py` if it has not been updated since the previous release. Examine the rendered changelog to determine the appropriate SEMVER field to modify. -Update changelog filename in `pyproject.toml` to suit. +Note that we have dropped `setup.cfg` altogether, so `__version__` in +`falcon/version.py` must contain the **whole version** +(including the previously separately managed `tag_build`), for instance: +```python +# Development version +__version__ = '4.0.0.dev1' + +# First alpha +__version__ = '4.0.0a1' + +# Release candidate +__version__ = '4.0.0rc1' + +# Stable release +__version__ = '4.0.0' +``` -Update the build tag in `setup.cfg` under `[egg_info]` for pre-release builds, -or remove it (leaving it blank as in `tag_build =`) for a final release. +Update changelog filename in `pyproject.toml` to suit. ### Update changelog and render towncrier fragments @@ -30,22 +44,14 @@ the following template, and update the summary and changes to supported platforms to suit: ```rst -Changelog for Falcon 3.0.1 +Changelog for Falcon 4.0.1 ========================== Summary ------- This is a minor point release to take care of a couple of bugs that we did -not catch for 3.0.0. - -Changes to Supported Platforms ------------------------------- - -- CPython 3.10 is now fully supported. (`#1966 `__) -- Support for Python 3.6 is now deprecated and will be removed in Falcon 4.0. -- As with the previous release, Python 3.5 support remains deprecated and will - no longer be supported in Falcon 4.0. +not catch for 4.0.0. .. towncrier release notes start diff --git a/docs/_newsfragments/2182.breakingchange.rst b/docs/_newsfragments/2182.breakingchange.rst index aeeb2e308..14853175b 100644 --- a/docs/_newsfragments/2182.breakingchange.rst +++ b/docs/_newsfragments/2182.breakingchange.rst @@ -1,2 +1,3 @@ -The function :func:`falcon.http_date_to_dt` now validates http-dates to have the correct -timezone set. It now also returns timezone aware datetime objects. +The function :func:`falcon.http_date_to_dt` now validates HTTP dates to have +the correct timezone set. It now also returns timezone-aware +:class:`~datetime.datetime` objects. diff --git a/docs/_newsfragments/2253.misc.rst b/docs/_newsfragments/2253.misc.rst index ea7118af4..256f4a736 100644 --- a/docs/_newsfragments/2253.misc.rst +++ b/docs/_newsfragments/2253.misc.rst @@ -1,2 +1,3 @@ -The functions ``create_task()`` and ``get_running_loop()`` of `falcon.util.sync` are deprecated. -The counterpart functions in the builtin package `asyncio` are encouraged to be used. +The :ref:`utility functions ` ``create_task()`` and +``get_running_loop()`` are now deprecated in favor of their standard library +counterparts, :func:`asyncio.create_task` and `:func:`asyncio.get_running_loop`. diff --git a/docs/_newsfragments/2301.misc.rst b/docs/_newsfragments/2301.misc.rst index fe8c43ab4..e6d2c8f89 100644 --- a/docs/_newsfragments/2301.misc.rst +++ b/docs/_newsfragments/2301.misc.rst @@ -1 +1,2 @@ -Deprecated the ``TimezoneGMT`` class. Use :attr:`datetime.timezone.utc` instead. +The ``falcon.TimezoneGMT`` class was deprecated. Use the UTC timezone +(:attr:`datetime.timezone.utc`) from the standard library instead. diff --git a/docs/_newsfragments/2314.breakingchange.rst b/docs/_newsfragments/2314.breakingchange.rst new file mode 100644 index 000000000..a1783ef02 --- /dev/null +++ b/docs/_newsfragments/2314.breakingchange.rst @@ -0,0 +1,5 @@ +``setup.cfg`` was dropped in favor of consolidating all static project +configuration in ``pyproject.toml`` (``setup.py`` is still needed for +programmatic control of the build process). While this change should not impact +the framework's end-users directly, some ``setuptools``\-based legacy workflows +(such as the obsolete ``setup.py test``) will no longer work. diff --git a/docs/conf.py b/docs/conf.py index e2390c14a..97a493231 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,7 +11,6 @@ # serve to show the default. from collections import OrderedDict -import configparser from datetime import datetime import multiprocessing import os @@ -87,19 +86,19 @@ # |version| and |release|, also used in various other places throughout the # built documents. -cfg = configparser.ConfigParser() -cfg.read('../setup.cfg') -tag = cfg.get('egg_info', 'tag_build') +_version_components = falcon.__version__.split('.') +_prerelease = any( + not component.isdigit() and not component.startswith('post') + for component in _version_components +) -html_context = { - 'prerelease': bool(tag), # True if tag is not the empty string -} +html_context = {'prerelease': _prerelease} # The short X.Y version. -version = '.'.join(falcon.__version__.split('.')[0:2]) + tag +version = '.'.join(_version_components[0:2]) # The full version, including alpha/beta/rc tags. -release = falcon.__version__ + tag +release = falcon.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/falcon/version.py b/falcon/version.py index c3971dba6..35f56858a 100644 --- a/falcon/version.py +++ b/falcon/version.py @@ -14,5 +14,5 @@ """Falcon version.""" -__version__ = '4.0.0' +__version__ = '4.0.0.dev2' """Current version of Falcon.""" diff --git a/pyproject.toml b/pyproject.toml index ef17db4f0..5a9040bac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,84 @@ [build-system] - build-backend = "setuptools.build_meta" - requires = [ - "setuptools>=47", - "wheel>=0.34", - "cython>=3.0.8; python_implementation == 'CPython'", # Skip cython when using pypy - ] +build-backend = "setuptools.build_meta" +requires = [ + "setuptools>=61", + "cython>=3.0.8; python_implementation == 'CPython'", # Skip cython when using pypy +] + +[project] +name = "falcon" +readme = {file = "README.rst", content-type = "text/x-rst"} +dynamic = ["version"] +dependencies = [] +requires-python = ">=3.8" +description = "The ultra-reliable, fast ASGI+WSGI framework for building data plane APIs at scale." +authors = [ + {name = "Kurt Griffiths", email = "mail@kgriffs.com"}, +] +license = {text = "Apache 2.0"} +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Natural Language :: English", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: Apache Software License", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Topic :: Internet :: WWW/HTTP :: WSGI", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Programming Language :: Python", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Cython", + "Typing :: Typed", +] +keywords = [ + "asgi", + "wsgi", + "web", + "api", + "framework", + "rest", + "http", + "cloud", +] + +[project.optional-dependencies] +test = ["pytest"] + +[project.scripts] +falcon-bench = "falcon.cmd.bench:main" +falcon-inspect-app = "falcon.cmd.inspect_app:main" +falcon-print-routes = "falcon.cmd.inspect_app:route_main" + +[project.urls] +Homepage = "https://falconframework.org" +Documentation = "https://falcon.readthedocs.io/en/stable/" +"Release Notes" = "https://falcon.readthedocs.io/en/stable/changes/" +"Source" = "https://github.com/falconry/falcon" +"Issue Tracker" = "https://github.com/falconry/falcon/issues" +Funding = "https://opencollective.com/falcon" +Chat = "https://gitter.im/falconry/user" + +[tool.setuptools] +include-package-data = true +license-files = ["LICENSE"] +zip-safe = false + +[tool.setuptools.dynamic] +version = {attr = "falcon.version.__version__"} + +[tool.setuptools.packages.find] +exclude = ["examples", "tests"] [tool.mypy] exclude = [ diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 830760163..000000000 --- a/setup.cfg +++ /dev/null @@ -1,83 +0,0 @@ -[metadata] -name = falcon -version = attr: falcon.__version__ -description = The ultra-reliable, fast ASGI+WSGI framework for building data plane APIs at scale. -long_description = file: README.rst -long_description_content_type = text/x-rst -url = https://falconframework.org -author = Kurt Griffiths -author_email = mail@kgriffs.com -license = Apache 2.0 -license_files = LICENSE -classifiers = - Development Status :: 5 - Production/Stable - Environment :: Web Environment - Natural Language :: English - Intended Audience :: Developers - Intended Audience :: System Administrators - License :: OSI Approved :: Apache Software License - Operating System :: MacOS :: MacOS X - Operating System :: Microsoft :: Windows - Operating System :: POSIX - Topic :: Internet :: WWW/HTTP :: WSGI - Topic :: Software Development :: Libraries :: Application Frameworks - Programming Language :: Python - Programming Language :: Python :: Implementation :: CPython - Programming Language :: Python :: Implementation :: PyPy - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 - Programming Language :: Python :: 3.13 - Programming Language :: Cython - Typing :: Typed -keywords = - asgi - wsgi - web - api - framework - rest - http - cloud -project_urls = - Homepage=https://falconframework.org - Documentation=https://falcon.readthedocs.io/en/stable/ - Release Notes=https://falcon.readthedocs.io/en/stable/changes/ - Source=https://github.com/falconry/falcon - Issue Tracker=https://github.com/falconry/falcon/issues - Funding=https://opencollective.com/falcon - Chat=https://gitter.im/falconry/user - -[options] -zip_safe = False -include_package_data = True -packages = find: -python_requires = >=3.8 -install_requires = -tests_require = - testtools - requests - pyyaml - pytest - pytest-runner - -[options.packages.find] -exclude = - examples - tests - -[options.entry_points] -console_scripts = - falcon-bench = falcon.cmd.bench:main - falcon-inspect-app = falcon.cmd.inspect_app:main - falcon-print-routes = falcon.cmd.inspect_app:route_main - -[egg_info] -# TODO replace -tag_build = dev2 - -[aliases] -test=pytest From 7b77c56154671c6b13e32ffef6d1b85a1cc36e3a Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sun, 1 Sep 2024 13:37:46 +0200 Subject: [PATCH 03/15] chore: clean up tests even further (#2315) * chore: remove all mandatory deps from tests except `pytest` * refactor: run at least smth over the network if requests isn't installed --- requirements/mintest | 1 - tests/asgi/test_asgi_servers.py | 14 +++++++-- tests/conftest.py | 53 ++++++++++++++++++++++++++++++++- tests/test_examples.py | 8 +++++ tests/test_wsgi.py | 43 +++++++++++++------------- tests/test_wsgi_servers.py | 9 +++++- tox.ini | 31 ++----------------- 7 files changed, 103 insertions(+), 56 deletions(-) diff --git a/requirements/mintest b/requirements/mintest index 158929194..a245ecde6 100644 --- a/requirements/mintest +++ b/requirements/mintest @@ -1,3 +1,2 @@ coverage>=4.1 pytest -requests diff --git a/tests/asgi/test_asgi_servers.py b/tests/asgi/test_asgi_servers.py index c8772db40..bda3e28a9 100644 --- a/tests/asgi/test_asgi_servers.py +++ b/tests/asgi/test_asgi_servers.py @@ -10,14 +10,18 @@ import time import pytest -import requests -import requests.exceptions try: import httpx except ImportError: httpx = None # type: ignore +try: + import requests + import requests.exceptions +except ImportError: + requests = None # type: ignore + try: import websockets import websockets.exceptions @@ -44,6 +48,9 @@ _REQUEST_TIMEOUT = 10 +@pytest.mark.skipif( + requests is None, reason='requests module is required for this test' +) class TestASGIServer: def test_get(self, server_base_url): resp = requests.get(server_base_url, timeout=_REQUEST_TIMEOUT) @@ -192,6 +199,9 @@ async def emitter(): assert resp.json().get('drops') >= 1 +@pytest.mark.skipif( + requests is None, reason='requests module is required for this test' +) @pytest.mark.skipif( websockets is None, reason='websockets is required for this test class' ) diff --git a/tests/conftest.py b/tests/conftest.py index d65e242fa..defdaddb8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,19 @@ import contextlib +import functools +import http.client import importlib.util import inspect +import json import os import pathlib +import urllib.parse import pytest import falcon import falcon.asgi import falcon.testing +import falcon.util try: import cython # noqa @@ -109,12 +114,58 @@ def load_module(filename, parent_dir=None, suffix=None): return module -# TODO(vytas): Migrate all cases to use this fixture instead of _util. @pytest.fixture(scope='session') def util(): return _SuiteUtils() +class _RequestsLite: + """Poor man's requests using nothing but the stdlib+Falcon.""" + + DEFAULT_TIMEOUT = 15.0 + + class Response: + def __init__(self, resp): + self.content = resp.read() + self.headers = falcon.util.CaseInsensitiveDict(resp.getheaders()) + self.status_code = resp.status + + @property + def text(self): + return self.content.decode() + + def json(self): + content_type, _ = falcon.parse_header( + self.headers.get('Content-Type') or '' + ) + if content_type != falcon.MEDIA_JSON: + raise ValueError(f'Content-Type is not {falcon.MEDIA_JSON}') + return json.loads(self.content) + + def __init__(self): + self.delete = functools.partial(self.request, 'DELETE') + self.get = functools.partial(self.request, 'GET') + self.head = functools.partial(self.request, 'HEAD') + self.patch = functools.partial(self.request, 'PATCH') + self.post = functools.partial(self.request, 'POST') + self.put = functools.partial(self.request, 'PUT') + + def request(self, method, url, data=None, headers=None, timeout=None): + parsed = urllib.parse.urlparse(url) + uri = urllib.parse.urlunparse(('', '', parsed.path, '', parsed.query, '')) + headers = headers or {} + timeout = timeout or self.DEFAULT_TIMEOUT + + conn = http.client.HTTPConnection(parsed.netloc) + conn.request(method, uri, body=data, headers=headers) + return self.Response(conn.getresponse()) + + +@pytest.fixture(scope='session') +def requests_lite(): + return _RequestsLite() + + def pytest_configure(config): if config.pluginmanager.getplugin('asyncio'): config.option.asyncio_mode = 'strict' diff --git a/tests/test_examples.py b/tests/test_examples.py index d6d37fff7..5fd5b1db7 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -5,6 +5,11 @@ except ImportError: httpx = None # type: ignore +try: + import requests +except ImportError: + requests = None # type: ignore + import falcon.testing as testing @@ -24,6 +29,9 @@ def test_things(asgi, util): @pytest.mark.skipif( httpx is None, reason='things_advanced_asgi.py requires httpx [not found]' ) +@pytest.mark.skipif( + requests is None, reason='things_advanced.py requires requests [not found]' +) def test_things_advanced(asgi, util): suffix = '_asgi' if asgi else '' advanced = util.load_module(f'examples/things_advanced{suffix}.py') diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py index b8f029df5..c511d899f 100644 --- a/tests/test_wsgi.py +++ b/tests/test_wsgi.py @@ -5,7 +5,6 @@ from wsgiref.simple_server import make_server import pytest -import requests import falcon import falcon.testing as testing @@ -19,48 +18,48 @@ @pytest.mark.usefixtures('_setup_wsgi_server') class TestWSGIServer: - def test_get(self): - resp = requests.get(_SERVER_BASE_URL) + def test_get(self, requests_lite): + resp = requests_lite.get(_SERVER_BASE_URL) assert resp.status_code == 200 assert resp.text == '127.0.0.1' - def test_get_file(self): + def test_get_file(self, requests_lite): # NOTE(vytas): There was a breaking change in the behaviour of # ntpath.isabs() in CPython 3.13, let us verify basic file serving. - resp = requests.get(_SERVER_BASE_URL + 'tests/test_wsgi.py') + resp = requests_lite.get(_SERVER_BASE_URL + 'tests/test_wsgi.py') assert resp.status_code == 200 assert 'class TestWSGIServer:' in resp.text - def test_put(self): + def test_put(self, requests_lite): body = '{}' - resp = requests.put(_SERVER_BASE_URL, data=body) + resp = requests_lite.put(_SERVER_BASE_URL, data=body) assert resp.status_code == 200 assert resp.text == '{}' - def test_head_405(self): + def test_head_405(self, requests_lite): body = '{}' - resp = requests.head(_SERVER_BASE_URL, data=body) + resp = requests_lite.head(_SERVER_BASE_URL, data=body) assert resp.status_code == 405 - def test_post(self): + def test_post(self, requests_lite): body = testing.rand_string(_SIZE_1_KB // 2, _SIZE_1_KB) - resp = requests.post(_SERVER_BASE_URL, data=body) + resp = requests_lite.post(_SERVER_BASE_URL, data=body) assert resp.status_code == 200 assert resp.text == body - def test_post_invalid_content_length(self): + def test_post_invalid_content_length(self, requests_lite): headers = {'Content-Length': 'invalid'} - resp = requests.post(_SERVER_BASE_URL, headers=headers) + resp = requests_lite.post(_SERVER_BASE_URL, headers=headers) assert resp.status_code == 400 - def test_post_read_bounded_stream(self): + def test_post_read_bounded_stream(self, requests_lite): body = testing.rand_string(_SIZE_1_KB // 2, _SIZE_1_KB) - resp = requests.post(_SERVER_BASE_URL + 'bucket', data=body) + resp = requests_lite.post(_SERVER_BASE_URL + 'bucket', data=body) assert resp.status_code == 200 assert resp.text == body - def test_post_read_bounded_stream_no_body(self): - resp = requests.post(_SERVER_BASE_URL + 'bucket') + def test_post_read_bounded_stream_no_body(self, requests_lite): + resp = requests_lite.post(_SERVER_BASE_URL + 'bucket') assert not resp.text @@ -109,7 +108,7 @@ def on_post(self, req, resp): @pytest.fixture(scope='module') -def _setup_wsgi_server(): +def _setup_wsgi_server(requests_lite): stop_event = multiprocessing.Event() process = multiprocessing.Process( target=_run_server, @@ -124,9 +123,9 @@ def _setup_wsgi_server(): # NOTE(vytas): Give the server some time to start. for attempt in range(3): try: - requests.get(_SERVER_BASE_URL, timeout=1) + requests_lite.get(_SERVER_BASE_URL, timeout=1) break - except requests.exceptions.RequestException: + except OSError: pass time.sleep(attempt + 0.2) @@ -139,8 +138,8 @@ def _setup_wsgi_server(): # made it to the next server.handle_request() before we sent the # event. try: - requests.get(_SERVER_BASE_URL) - except Exception: + requests_lite.get(_SERVER_BASE_URL) + except OSError: pass # Process already exited process.join() diff --git a/tests/test_wsgi_servers.py b/tests/test_wsgi_servers.py index f1f776a34..e8f00892c 100644 --- a/tests/test_wsgi_servers.py +++ b/tests/test_wsgi_servers.py @@ -6,7 +6,11 @@ import time import pytest -import requests + +try: + import requests +except ImportError: + requests = None # type: ignore from falcon import testing @@ -190,6 +194,9 @@ def server_url(server_args): ) +@pytest.mark.skipif( + requests is None, reason='requests module is required for this test' +) class TestWSGIServer: def test_get(self, server_url): resp = requests.get(server_url + '/hello', timeout=_REQUEST_TIMEOUT) diff --git a/tox.ini b/tox.ini index 4558d1a24..0b96f9d77 100644 --- a/tox.ini +++ b/tox.ini @@ -2,8 +2,8 @@ # -------------------------------------------------------------------- # -# NOTE(kgriffs,vytas): Python 3.8 or 3.10 are required when checking -# combined coverage. To check coverage: +# NOTE(kgriffs,vytas): Python 3.10, 3.11, 3.12, or 3.13 are required +# when checking combined coverage. To check coverage: # # $ tox # @@ -372,7 +372,6 @@ commands = # -------------------------------------------------------------------- [testenv:docs] -basepython = python3.10 deps = -r{toxinidir}/requirements/docs commands = sphinx-build -j auto -W -E -b html docs docs/_build/html [] @@ -382,7 +381,6 @@ commands = # hence we pin it manually here. [testenv:towncrier] deps = -r{toxinidir}/requirements/docs - importlib-resources < 6.0 toml towncrier commands = @@ -488,28 +486,3 @@ deps = commands = pytest {toxinidir}/e2e-tests/ --browser=firefox -# -------------------------------------------------------------------- -# Wheels stuff -# -------------------------------------------------------------------- - -[testenv:wheel_check] -basepython = python -skipsdist = True -skip_install = True -deps = setuptools>=44 - pytest -passenv = GITHUB_* -setenv = - PYTHONASYNCIODEBUG=0 - PYTHONNOUSERSITE=1 - FALCON_ASGI_WRAP_NON_COROUTINES=Y - FALCON_TESTING_SESSION=Y - FALCON_DISABLE_CYTHON=Y -commands = - pip uninstall --yes falcon - pip install --find-links {toxinidir}/dist --no-index --ignore-installed falcon - python --version - python -c "import sys; sys.path.pop(0); from falcon.cyutil import misc, reader, uri" - python .github/workflows/scripts/verify_tag.py {toxinidir}/dist - pip install -r{toxinidir}/requirements/tests - pytest -q tests [] From fa5c72c78a657faa420121d944b1a5df1c698b6d Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sun, 1 Sep 2024 22:03:04 +0200 Subject: [PATCH 04/15] docs: deduplicate contributor's guide (#2316) --- CONTRIBUTING.md | 37 +++++++++++++++++----------------- docs/community/contribute.rst | 30 --------------------------- docs/community/contributing.md | 6 ++++++ docs/community/index.rst | 2 +- docs/conf.py | 1 + requirements/docs | 1 + 6 files changed, 28 insertions(+), 49 deletions(-) delete mode 100644 docs/community/contribute.rst create mode 100644 docs/community/contributing.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 935c8aacb..5422355cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -## Contributor's Guide +# Contribute to Falcon Thanks for your interest in the project! We welcome pull requests from developers of all skill levels. To get started, simply fork the master branch @@ -24,7 +24,7 @@ little help getting started. You can find us in Please note that all contributors and maintainers of this project are subject to our [Code of Conduct][coc]. -### Pull Requests +## Pull Requests Before submitting a pull request, please ensure you have added or updated tests as appropriate, and that all existing tests still pass with your changes. @@ -47,7 +47,7 @@ $ tox --recreate You may also use a CPython 3.9 environment, although in that case ``coverage`` will likely report a false positive on missing branches, and the total coverage might fall short of 100%. These issues are caused by bugs in the interpreter itself, and are unlikely to ever get fixed. -#### Reviews +### Reviews Falcon is used in a number of mission-critical applications and is known for its stability and reliability. Therefore, we invest a lot of time in carefully reviewing PRs and working with contributors to ensure that every patch merged into the master branch is correct, complete, performant, well-documented, and appropriate. @@ -65,7 +65,7 @@ Project maintainers review each PR for the following: - [ ] **Changelog.** Does the PR have a changelog entry in newsfragments? Is the type correct? Try running `towncrier --draft` to ensure it renders correctly. -### Test coverage +## Test coverage Pull requests must maintain 100% test coverage of all code branches. This helps ensure the quality of the Falcon framework. To check coverage before submitting a pull request: @@ -77,7 +77,7 @@ It is necessary to combine test coverage from multiple environments in order to Running the default sequence of ``tox`` environments generates an HTML coverage report that can be viewed by simply opening `.coverage_html/index.html` in a browser. This can be helpful in tracking down specific lines or branches that are missing coverage. -### Debugging +## Debugging We use pytest to run all of our tests. Pytest supports pdb and will break as expected on any `pdb.set_trace()` calls. If you would like to use pdb++ instead of the standard Python @@ -90,7 +90,7 @@ $ tox -e py3_debug If you wish, you can customize Falcon's `tox.ini` to install alternative debuggers, such as ipdb or pudb. -### Benchmarking +## Benchmarking A few simple benchmarks are included with the source under ``falcon/bench``. These can be taken as a rough measure of the performance impact (if any) that your changes have on the framework. You can run these tests by invoking one of the tox environments included for this purpose (see also the ``tox.ini`` file). For example: @@ -117,7 +117,7 @@ $ falcon-bench Note that benchmark results for the same code will vary between runs based on a number of factors, including overall system load and CPU scheduling. These factors may be somewhat mitigated by running the benchmarks on a Linux server dedicated to this purpose, and pinning the benchmark process to a specific CPU core. -### Documentation +## Documentation To check documentation changes (including docstrings), before submitting a PR, ensure the tox job builds the documentation correctly: @@ -134,7 +134,7 @@ $ gnome-open docs/_build/html/index.html $ xdg-open docs/_build/html/index.html ``` -### Recipes and code snippets +## Recipes and code snippets If you are adding new recipes (in `docs/user/recipes`), try to break out code snippets into separate files inside `examples/recipes`. @@ -145,11 +145,11 @@ Then simply use `literalinclude` to embed these snippets into your `.rst` recipe If possible, try to implement tests for your recipe in `tests/test_recipes.py`. This helps to ensure that our recipes stay up-to-date as the framework's development progresses! -### VS Code Dev Container development environment +## VS Code Dev Container development environment -When opening the project using the [VS Code](https://code.visualstudio.com/) IDE, if you have [Docker](https://www.docker.com/) (or some drop-in replacement such as [Podman](https://podman.io/) or [Colima](https://github.com/abiosoft/colima) or [Rancher Desktop](https://rancherdesktop.io/)) installed, you can leverage the [Dev Containers](https://code.visualstudio.com/docs/devcontainers/containers) feature to start a container in the background with all the dependencies required to test and debug the Falcon code. VS Code integrates with the Dev Container seamlessly, which can be configured via [devcontainer.json](.devcontainer/devcontainer.json). Once you open the project in VS Code, you can execute the "Reopen in Container" command to start the Dev Container which will run the headless VS Code Server process that the local VS Code app will connect to via a [published port](https://docs.docker.com/config/containers/container-networking/#published-ports). +When opening the project using the [VS Code](https://code.visualstudio.com/) IDE, if you have [Docker](https://www.docker.com/) (or some drop-in replacement such as [Podman](https://podman.io/) or [Colima](https://github.com/abiosoft/colima) or [Rancher Desktop](https://rancherdesktop.io/)) installed, you can leverage the [Dev Containers](https://code.visualstudio.com/docs/devcontainers/containers) feature to start a container in the background with all the dependencies required to test and debug the Falcon code. VS Code integrates with the Dev Container seamlessly, which can be configured via [devcontainer.json][devcontainer]. Once you open the project in VS Code, you can execute the "Reopen in Container" command to start the Dev Container which will run the headless VS Code Server process that the local VS Code app will connect to via a [published port](https://docs.docker.com/config/containers/container-networking/#published-ports). -### Code style rules +## Code style rules * Docstrings are required for classes, attributes, methods, and functions. Follow the following guidelines for docstrings: @@ -183,13 +183,13 @@ or in mathematical expressions implementing well-known formulas. * Heavily document code that is especially complex and/or clever. * When in doubt, optimize for readability. -### Changelog +## Changelog We use [towncrier](https://towncrier.readthedocs.io/) to manage the changelog. Each PR that modifies the functionality of Falcon should include a short description in a news fragment file in the `docs/_newsfragments` directory. The newsfragment file name should have the format `{issue_number}.{fragment_type}.rst`, where the fragment type is one of `breakingchange`, `newandimproved`, `bugfix`, or `misc`. If your PR closes another issue, then the original issue number should be used for the newsfragment; otherwise, use the PR number itself. -### Commit Message Format +## Commit Message Format Falcon's commit message format uses [AngularJS's style guide][ajs], reproduced here for convenience, with some minor edits for clarity. @@ -205,7 +205,7 @@ Each commit message consists of a **header**, a **body** and a **footer**. The h No line may exceed 100 characters. This makes it easier to read the message on GitHub as well as in various git tools. -##### Type +### Type Must be one of the following: * **feat**: A new feature @@ -217,23 +217,24 @@ Must be one of the following: * **test**: Adding missing tests * **chore**: Changes to the build process or auxiliary tools and libraries such as documentation generation -##### Scope +### Scope The scope could be anything specifying place of the commit change. For example: `$location`, `$browser`, `$compile`, `$rootScope`, `ngHref`, `ngClick`, `ngView`, etc... -##### Subject +### Subject The subject contains succinct description of the change: * use the imperative, present tense: "change" not "changed" nor "changes" * don't capitalize first letter * no dot (.) at the end -##### Body +### Body Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". The body should include the motivation for the change and contrast this with previous behavior. -##### Footer +### Footer The footer should contain any information about **Breaking Changes** and is also the place to reference GitHub issues that this commit **Closes**. [ajs]: https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines +[devcontainer]: https://github.com/falconry/falcon/blob/master/.devcontainer/devcontainer.json [docstrings]: https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html#example-google-style-python-docstrings [goog-style]: http://google-styleguide.googlecode.com/svn/trunk/pyguide.html#Comments [rtd]: https://falcon.readthedocs.io diff --git a/docs/community/contribute.rst b/docs/community/contribute.rst deleted file mode 100644 index a7fc43894..000000000 --- a/docs/community/contribute.rst +++ /dev/null @@ -1,30 +0,0 @@ -.. _contribute: - -Contribute to Falcon -==================== - -Thanks for your interest in the project! We welcome pull requests from -developers of all skill levels. To get started, simply fork the master branch -on GitHub to your personal account and then clone the fork into your -development environment. - -The core Falcon project maintainers are: - -* Kurt Griffiths, Project Lead (**kgriffs** on GH, Gitter, and Twitter) -* John Vrbanac (**jmvrbanac** on GH, Gitter, and Twitter) -* Vytautas Liuolia (**vytas7** on GH and Gitter, and **vliuolia** on Twitter) -* Nick Zaccardi (**nZac** on GH and Gitter) -* Federico Caselli (**CaselIT** on GH and Gitter) - -Falcon is developed by a growing community of users and contributors just like -you! - -Please don't hesitate to reach out if you have any questions, or just need a -little help getting started. You can find us in -`falconry/dev `_ on Gitter. - -Please check out our -`Contributor's Guide `_ -for more information. - -Thanks! diff --git a/docs/community/contributing.md b/docs/community/contributing.md new file mode 100644 index 000000000..352931dff --- /dev/null +++ b/docs/community/contributing.md @@ -0,0 +1,6 @@ +% NOTE(vytas): This document is in MyST markdown (unlike others) in order to be +% able to share CONTRIBUTING.md verbatim. + +```{include} ../../CONTRIBUTING.md + +``` diff --git a/docs/community/index.rst b/docs/community/index.rst index 2f0a0f4aa..7e800a910 100644 --- a/docs/community/index.rst +++ b/docs/community/index.rst @@ -5,5 +5,5 @@ Community Guide :maxdepth: 1 help - contribute + contributing ../user/faq diff --git a/docs/conf.py b/docs/conf.py index 97a493231..abd0a92c5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -59,6 +59,7 @@ 'sphinx.ext.napoleon', 'sphinx_tabs.tabs', 'sphinx_tabs.tabs', + 'myst_parser', # Falcon-specific extensions 'ext.cibuildwheel', 'ext.doorway', diff --git a/requirements/docs b/requirements/docs index 888447d50..4e4fece59 100644 --- a/requirements/docs +++ b/requirements/docs @@ -3,6 +3,7 @@ doc2dash jinja2 markupsafe pygments +myst-parser pygments-style-github PyYAML sphinx From 4f3135661970797b0228f63abf516f57307f19bf Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sun, 1 Sep 2024 22:24:05 +0200 Subject: [PATCH 05/15] fix(sdist): include Markdown documents in sdist --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index fa3728c1a..983c489ac 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -recursive-include docs *.rst *.html *.ico *.png *.py *.svg +recursive-include docs *.rst *.md *.html *.ico *.png *.py *.svg recursive-include e2e-tests *.py *.css *.html *.js recursive-include examples *.py recursive-include falcon *.pyx From f20c3cc8ed59da587c759db6bc54a69d6bc4b319 Mon Sep 17 00:00:00 2001 From: Agustin Arce <59893355+aarcex3@users.noreply.github.com> Date: Tue, 3 Sep 2024 01:40:13 -0400 Subject: [PATCH 06/15] refactor (response): remove deprecated attributes (#2317) * 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 --- falcon/response.py | 17 ----------------- tests/test_response.py | 20 ++++++++++++-------- 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/falcon/response.py b/falcon/response.py index bc676c31a..9446e662d 100644 --- a/falcon/response.py +++ b/falcon/response.py @@ -41,11 +41,6 @@ from falcon.util.uri import encode_check_escaped as uri_encode from falcon.util.uri import encode_value_check_escaped as uri_encode_value -_STREAM_LEN_REMOVED_MSG = ( - 'The deprecated stream_len property was removed in Falcon 3.0. ' - 'Please use Response.set_stream() or Response.content_length instead.' -) - _RESERVED_CROSSORIGIN_VALUES = frozenset({'anonymous', 'use-credentials'}) _RESERVED_SAMESITE_VALUES = frozenset({'lax', 'strict', 'none'}) @@ -243,18 +238,6 @@ def media(self, value): self._media = value self._media_rendered = _UNSET - @property - def stream_len(self): - # NOTE(kgriffs): Provide some additional information by raising the - # error explicitly. - raise AttributeError(_STREAM_LEN_REMOVED_MSG) - - @stream_len.setter - def stream_len(self, value): - # NOTE(kgriffs): We explicitly disallow setting the deprecated attribute - # so that apps relying on it do not fail silently. - raise AttributeError(_STREAM_LEN_REMOVED_MSG) - def render_body(self): """Get the raw bytestring content for the response body. diff --git a/tests/test_response.py b/tests/test_response.py index aa349ee2c..7fac6b006 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -1,3 +1,4 @@ +from io import BytesIO from unittest.mock import MagicMock import pytest @@ -53,14 +54,6 @@ def test_response_attempt_to_set_read_only_headers(resp): assert headers['x-things3'] == 'thing-3a, thing-3b' -def test_response_removed_stream_len(resp): - with pytest.raises(AttributeError): - resp.stream_len = 128 - - with pytest.raises(AttributeError): - resp.stream_len - - def test_response_option_mimetype_init(monkeypatch): mock = MagicMock() mock.inited = False @@ -81,3 +74,14 @@ def test_response_option_mimetype_init(monkeypatch): assert ro.static_media_types['.js'] == 'text/javascript' assert ro.static_media_types['.json'] == 'application/json' assert ro.static_media_types['.mjs'] == 'text/javascript' + + +@pytest.mark.parametrize('content', [b'', b'dummy content']) +def test_response_set_stream(resp, content): + stream = BytesIO(content) + content_length = len(content) + + resp.set_stream(stream, content_length) + + assert resp.stream is stream + assert resp.headers['content-length'] == str(content_length) From 1f914c5143250c1113089868f30eed67697dc40d Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Thu, 5 Sep 2024 06:13:05 +0200 Subject: [PATCH 07/15] docs(install): update the installation docs wrt PEP 517 (#2319) * docs(install): update the installation docs wrt PEP 517 * docs(install): shorted README, tweak and link other docs * docs(install): further readme tweakz * docs(install): add more notes on installation from src * docs(install): commit review suggestion Co-authored-by: Federico Caselli --------- Co-authored-by: Federico Caselli --- README.rst | 62 +++------------ docs/community/contributing.md | 2 + docs/user/faq.rst | 2 + docs/user/install.rst | 121 ++++++++++++++++++++++-------- docs/user/tutorial-asgi.rst | 5 +- docs/user/tutorial-websockets.rst | 4 +- 6 files changed, 106 insertions(+), 90 deletions(-) diff --git a/README.rst b/README.rst index ec6bd3eeb..549e6cddb 100644 --- a/README.rst +++ b/README.rst @@ -217,63 +217,21 @@ Or, to install the latest beta or release candidate, if any: $ pip install --pre falcon -In order to provide an extra speed boost, Falcon can compile itself with -Cython. Wheels containing pre-compiled binaries are available from PyPI for -several common platforms. However, if a wheel for your platform of choice is not -available, you can install the source distribution. The installation process -will automatically try to cythonize Falcon for your environment, falling back to -a normal pure-Python install if any issues are encountered during the -cythonization step: +In order to provide an extra speed boost, Falcon automatically compiles itself +with `Cython `__ under any +`PEP 517 `__\-compliant installer. -.. code:: bash - - $ pip install --no-binary :all: falcon - -If you want to verify that Cython is being invoked, simply -pass the verbose flag `-v` to pip in order to echo the compilation commands. - -The cythonization step is only active when using the ``CPython`` Python -implementation, so installing using ``PyPy`` will skip it. -If you want to skip Cython compilation step and install -the pure-Python version directly you can set the environment variable -``FALCON_DISABLE_CYTHON`` to a non empty value before install: - -.. code:: bash - - $ FALCON_DISABLE_CYTHON=Y pip install -v --no-binary :all: falcon - -Please note that ``pip>=10`` is required to be able to install Falcon from -source. - -**Installing on OS X** - -Xcode Command Line Tools are required to compile Cython. Install them -with this command: - -.. code:: bash - - $ xcode-select --install - -The Clang compiler treats unrecognized command-line options as -errors, for example: - -.. code:: bash - - clang: error: unknown argument: '-mno-fused-madd' [-Wunused-command-line-argument-hard-error-in-future] - -You might also see warnings about unused functions. You can work around -these issues by setting additional Clang C compiler flags as follows: - -.. code:: bash - - $ export CFLAGS="-Qunused-arguments -Wno-unused-function" +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 `__. +for more information on this and other advanced options. Dependencies ^^^^^^^^^^^^ -Falcon does not require the installation of any other packages, although if -Cython has been installed into the environment, it will be used to optimize -the framework as explained above. +Falcon does not require the installation of any other packages. WSGI Server ----------- diff --git a/docs/community/contributing.md b/docs/community/contributing.md index 352931dff..8ba104369 100644 --- a/docs/community/contributing.md +++ b/docs/community/contributing.md @@ -1,6 +1,8 @@ % NOTE(vytas): This document is in MyST markdown (unlike others) in order to be % able to share CONTRIBUTING.md verbatim. +(contribute)= + ```{include} ../../CONTRIBUTING.md ``` diff --git a/docs/user/faq.rst b/docs/user/faq.rst index 571f187b6..11a394b63 100644 --- a/docs/user/faq.rst +++ b/docs/user/faq.rst @@ -98,6 +98,8 @@ Therefore, as long as you implement these classes and callables in a thread-safe manner, and ensure that any third-party libraries used by your app are also thread-safe, your WSGI app as a whole will be thread-safe. +.. _faq_free_threading: + Can I run Falcon on free-threaded CPython? ------------------------------------------ diff --git a/docs/user/install.rst b/docs/user/install.rst index bec085e61..9c5ae3e20 100644 --- a/docs/user/install.rst +++ b/docs/user/install.rst @@ -37,39 +37,68 @@ Or, to install the latest beta or release candidate, if any: $ pip install --pre falcon -In order to provide an extra speed boost, Falcon can compile itself with -Cython. Wheels containing pre-compiled binaries are available from PyPI for -several common platforms (see :ref:`binary_wheels` below for the complete list -of the platforms that we target, or simply check +In order to provide an extra speed boost, Falcon automatically compiles itself +with `Cython `__. Wheels containing pre-compiled binaries +are available from PyPI for the majority of common platforms (see +:ref:`binary_wheels` below for the complete list of the platforms that we +target, or simply check `Falcon files on PyPI `__). -However, even if a wheel for your platform of choice is not available, you can -choose to stick with the source distribution, or use the instructions below to -cythonize Falcon for your environment. +However, even if a binary build for your platform of choice is not available, +you can choose to stick with the generic pure-Python wheel (that ``pip`` should +pick automatically), or cythonize Falcon for your environment (see +:ref:`instructions below `). +The pure-Python version is functionally identical to binary wheels; +it is just slower on CPython. -The following commands tell pip to install Cython, and then to invoke -Falcon's ``setup.py``, which will in turn detect the presence of Cython -and then compile (AKA cythonize) the Falcon framework with the system's -default C compiler. +.. _cythonize: + +Cythonizing Falcon +^^^^^^^^^^^^^^^^^^ + +Falcon leverages `PEP 517 `__ to +automatically compile (AKA *cythonize*) itself with Cython whenever it is +installed from the source distribution. So if a suitable +:ref:`binary wheel ` is unavailable for your platform, or if you +want to recompile locally, you simply need to instruct ``pip`` not to use +prebuilt wheels: .. code:: bash - $ pip install cython - $ pip install --no-build-isolation --no-binary :all: falcon + $ pip install --no-binary :all: falcon -Note that ``--no-build-isolation`` is necessary to override pip's default -PEP 517 behavior that can cause Cython not to be found in the build -environment. +If you want to verify that Cython is being invoked, +pass ``-v`` to ``pip`` in order to echo the compilation commands: + +.. code:: bash -If you want to verify that Cython is being invoked, simply -pass `-v` to pip in order to echo the compilation commands: + $ pip install -v --no-binary :all: falcon + +Apart from the obvious requirement to have a functional compiler toolchain set +up with CPython development headers, the only inconvenience of running +cythonization on your side is the extra couple of minutes it takes (depending +on your hardware; it can take much more on an underpowered CI runner, or if you +are using emulation to prepare your software for another architecture). + +Furthermore, you can also cythonize the latest developmental snapshot Falcon +directly from the :ref:`source code ` on GitHub: .. code:: bash - $ pip install -v --no-build-isolation --no-binary :all: falcon + $ pip install git+https://github.com/falconry/falcon/ -Installing on OS X -^^^^^^^^^^^^^^^^^^ +.. danger:: + Although we try to keep the main development branch in a good shape at all + times, we strongly recommend to use only stable versions of Falcon in + production. + +Compiling on Mac OS +^^^^^^^^^^^^^^^^^^^ + +.. tip:: + Pre-compiled Falcon wheels are available for macOS on both Intel and Apple + Silicon chips, so normally you should be fine with just + ``pip install falcon``. Xcode Command Line Tools are required to compile Cython. Install them with this command: @@ -97,7 +126,7 @@ these issues by setting additional Clang C compiler flags as follows: Binary Wheels ^^^^^^^^^^^^^ -Binary Falcon wheels for are automatically built for many CPython platforms, +Binary Falcon wheels are automatically built for many CPython platforms, courtesy of `cibuildwheel `__. .. wheels:: .github/workflows/cibuildwheel.yaml @@ -105,10 +134,18 @@ courtesy of `cibuildwheel `__. The following table summarizes the wheel availability on different combinations of CPython versions vs CPython platforms: -.. note:: +.. warning:: The `free-threaded build `__ - mode is not enabled for our wheels at this time. + flag is not yet enabled for our wheels at this time. + + If you wish to experiment with + :ref:`running Falcon in the free-threaded mode `, you + will need to explicitly tell the interpreter to disable GIL (via the + ``PYTHON_GIL`` environment variable, or the ``-X gil=0`` option). + It is also advisable to :ref:`recompile Falcon from source ` on + a free-threaded CPython 3.13+ build before proceeding. + :ref:`Let us know how it went `! While we believe that our build configuration covers the most common development and deployment scenarios, :ref:`let us known ` if you are @@ -117,9 +154,7 @@ interested in any builds that are currently missing from our selection! Dependencies ------------ -Falcon does not require the installation of any other packages, although if -Cython has been installed into the environment, it will be used to optimize -the framework as explained above. +Falcon does not require the installation of any other packages. WSGI Server ----------- @@ -173,30 +208,49 @@ For a more in-depth overview of available servers, see also: See also a longer explanation on Uvicorn's website: `Quickstart `_. +.. _source_code: + Source Code ----------- Falcon `lives on GitHub `_, making the -code easy to browse, download, fork, etc. Pull requests are always welcome! Also, -please remember to star the project if it makes you happy. :) +code easy to browse, download, fork, etc. :ref:`Pull requests ` +are always welcome! +Also, please remember to star the project if it makes you happy. :) Once you have cloned the repo or downloaded a tarball from GitHub, you can install Falcon like this: .. code:: bash + $ # Clone over SSH: + $ # git clone git@github.com:falconry/falcon.git + $ # Or, if you prefer, over HTTPS: + $ # git clone https://github.com/falconry/falcon $ cd falcon $ pip install . +.. tip:: + The above command will automatically install the + :ref:`cythonized ` version of Falcon. If you just want to + experiment with the latest snapshot, you can skip the cythonization step by + setting the ``FALCON_DISABLE_CYTHON`` environment variable to a non-empty + value: + + .. code:: bash + + $ cd falcon + $ FALCON_DISABLE_CYTHON=Y pip install . + Or, if you want to edit the code, first fork the main repo, clone the fork -to your desktop, and then run the following to install it using symbolic -linking, so that when you change your code, the changes will be automagically -available to your app without having to reinstall the package: +to your desktop, and then run the following command to install it using +symbolic linking, so that when you change your code, the changes will be +automagically available to your app without having to reinstall the package: .. code:: bash $ cd falcon - $ pip install -e . + $ FALCON_DISABLE_CYTHON=Y pip install -e . You can manually test changes to the Falcon framework by switching to the directory of the cloned repo and then running pytest: @@ -204,6 +258,7 @@ directory of the cloned repo and then running pytest: .. code:: bash $ cd falcon + $ FALCON_DISABLE_CYTHON=Y pip install -e . $ pip install -r requirements/tests $ pytest tests diff --git a/docs/user/tutorial-asgi.rst b/docs/user/tutorial-asgi.rst index a58c96c23..643d87f27 100644 --- a/docs/user/tutorial-asgi.rst +++ b/docs/user/tutorial-asgi.rst @@ -49,10 +49,9 @@ We'll create a *virtualenv* using the ``venv`` module from the standard library `pipenv `_, particularly when it comes to hopping between several environments. -Next, install Falcon into your *virtualenv*. ASGI support requires version -3.0 or higher:: +Next, :ref:`install Falcon ` into your *virtualenv*:: - $ pip install "falcon>=3.*" + $ pip install falcon You can then create a basic :class:`Falcon ASGI application ` by adding an ``asgilook/app.py`` module with the following contents: diff --git a/docs/user/tutorial-websockets.rst b/docs/user/tutorial-websockets.rst index 46826fc00..70327faac 100644 --- a/docs/user/tutorial-websockets.rst +++ b/docs/user/tutorial-websockets.rst @@ -39,8 +39,8 @@ Create the following directory structure:: └── app.py -And next we'll install Falcon and Uvicorn in our freshly created virtual -environment: +And next we'll :ref:`install Falcon ` and Uvicorn in our freshly +created virtual environment: .. code-block:: bash From cae50da40454a361fec84e58d4b44bedc5b6c0cd Mon Sep 17 00:00:00 2001 From: Mykhailo Yusko Date: Fri, 6 Sep 2024 12:15:09 +0300 Subject: [PATCH 08/15] fix(multipart): don't share MultipartParseOptions._DEFAULT_HANDLERS (#2322) * fix(multipart): don't share MultipartParseOptions._DEFAULT_HANDLERS between instances * fix(media): implement a proper `Handlers.copy()` method * chore: add a versionadded docs directive * docs(newsfragments): add a newsfragment for #2293 --------- Co-authored-by: Vytautas Liuolia --- docs/_newsfragments/2293.bugfix.rst | 10 ++++++++++ falcon/media/handlers.py | 16 ++++++++++++++++ falcon/media/multipart.py | 5 ++++- tests/test_media_multipart.py | 12 ++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 docs/_newsfragments/2293.bugfix.rst diff --git a/docs/_newsfragments/2293.bugfix.rst b/docs/_newsfragments/2293.bugfix.rst new file mode 100644 index 000000000..de4c6ba44 --- /dev/null +++ b/docs/_newsfragments/2293.bugfix.rst @@ -0,0 +1,10 @@ +Customizing +:attr:`MultipartParseOptions.media_handlers +` could previously +lead to unintentionally modifying a shared class variable. +This has been fixed, and the +:attr:`~falcon.media.multipart.MultipartParseOptions.media_handlers` attribute +is now initialized to a fresh copy of handlers for every instance of +:class:`~falcon.media.multipart.MultipartParseOptions`. To that end, a proper +:meth:`~falcon.media.Handlers.copy` method has been implemented for the media +:class:`~falcon.media.Handlers` class. diff --git a/falcon/media/handlers.py b/falcon/media/handlers.py index e37d5e3b8..4eeaf4f7f 100644 --- a/falcon/media/handlers.py +++ b/falcon/media/handlers.py @@ -164,6 +164,22 @@ def resolve( return cast(ResolverMethod, resolve) + def copy(self) -> Handlers: + """Create a shallow copy of this instance of handlers. + + The resulting copy contains the same keys and values, but it can be + customized separately without affecting the original object. + + Returns: + A shallow copy of handlers. + + .. versionadded:: 4.0 + """ + # NOTE(vytas): In the unlikely case we are dealing with a subclass, + # return the matching type. + handlers_cls = type(self) + return handlers_cls(self.data) + @deprecation.deprecated( 'This undocumented method is no longer supported as part of the public ' 'interface and will be removed in a future release.' diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py index 17a990265..340a5aea6 100644 --- a/falcon/media/multipart.py +++ b/falcon/media/multipart.py @@ -610,4 +610,7 @@ def __init__(self) -> None: self.max_body_part_buffer_size = 1024 * 1024 self.max_body_part_count = 64 self.max_body_part_headers_size = 8192 - self.media_handlers = self._DEFAULT_HANDLERS + # NOTE(myusko,vytas): Here we create a copy of _DEFAULT_HANDLERS in + # order to prevent the modification of the class variable whenever + # parse_options.media_handlers are customized. + self.media_handlers = self._DEFAULT_HANDLERS.copy() diff --git a/tests/test_media_multipart.py b/tests/test_media_multipart.py index 31c7bbff8..7c408e32c 100644 --- a/tests/test_media_multipart.py +++ b/tests/test_media_multipart.py @@ -8,6 +8,7 @@ import falcon from falcon import media from falcon import testing +from falcon.media.multipart import MultipartParseOptions from falcon.util import BufferedReader try: @@ -849,3 +850,14 @@ async def deserialize_async(self, stream, content_type, content_length): assert resp.status_code == 200 assert resp.json == ['', '0x48'] + + +def test_multipart_parse_options_default_handlers_unique(): + parse_options_one = MultipartParseOptions() + parse_options_two = MultipartParseOptions() + + parse_options_one.media_handlers.pop(falcon.MEDIA_JSON) + + assert parse_options_one.media_handlers is not parse_options_two.media_handlers + assert len(parse_options_one.media_handlers) == 1 + assert len(parse_options_two.media_handlers) >= 2 From 4567c972c19d9ab7b0881675534ce4e59f24b6ed Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sun, 15 Sep 2024 18:43:00 +0200 Subject: [PATCH 09/15] 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 10/15] 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 11/15] 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 12/15] 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 13/15] 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 14/15] 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.*", ] From afe6c9f5abad14f1889b3ba791c3f808435844c1 Mon Sep 17 00:00:00 2001 From: Federico Caselli Date: Sun, 22 Sep 2024 15:00:28 +0200 Subject: [PATCH 15/15] feat(CORS): improve cors middleware (#2326) * 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. * feat: improve cors header to properly handle missing allow headers The static resource will now properly supports CORS requests. Fixes: #2325 * chore: do not build rapidjson on PyPy --- docs/_newsfragments/2325.newandimproved.rst | 4 ++ falcon/app.py | 9 ++++ falcon/middleware.py | 23 ++++++-- falcon/routing/static.py | 12 +++-- tests/test_cors_middleware.py | 58 ++++++++++++++++++--- tests/test_static.py | 16 ++++++ 6 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 docs/_newsfragments/2325.newandimproved.rst diff --git a/docs/_newsfragments/2325.newandimproved.rst b/docs/_newsfragments/2325.newandimproved.rst new file mode 100644 index 000000000..62a61b81e --- /dev/null +++ b/docs/_newsfragments/2325.newandimproved.rst @@ -0,0 +1,4 @@ +The :class:`~CORSMiddleware` now properly handles the missing ``Allow`` +header case, by denying the preflight CORS request. +The static resource has been updated to properly support CORS request, +by allowing GET requests. diff --git a/falcon/app.py b/falcon/app.py index b66247051..6ef6cda25 100644 --- a/falcon/app.py +++ b/falcon/app.py @@ -759,6 +759,15 @@ def add_sink(self, sink: SinkCallable, prefix: SinkPrefix = r'/') -> None: impractical. For example, you might use a sink to create a smart proxy that forwards requests to one or more backend services. + Note: + To support CORS preflight requests when using the default CORS middleware, + either by setting ``App.cors_enable=True`` or by adding the + :class:`~.CORSMiddleware` to the ``App.middleware``, the sink should + set the ``Allow`` header in the request to the allowed + method values when serving an ``OPTIONS`` request. If the ``Allow`` header + is missing from the response, the default CORS middleware will deny the + preflight request. + Args: sink (callable): A callable taking the form ``func(req, resp, **kwargs)``. diff --git a/falcon/middleware.py b/falcon/middleware.py index 0e87275ed..d457a44b8 100644 --- a/falcon/middleware.py +++ b/falcon/middleware.py @@ -17,6 +17,15 @@ class CORSMiddleware(object): * https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS * https://www.w3.org/TR/cors/#resource-processing-model + Note: + Falcon will automatically add OPTIONS responders if they are missing from the + responder instances added to the routes. When providing a custom ``on_options`` + method, the ``Allow`` headers in the response should be set to the allowed + method values. If the ``Allow`` header is missing from the response, + this middleware will deny the preflight request. + + This is also valid when using a sink function. + Keyword Arguments: allow_origins (Union[str, Iterable[str]]): List of origins to allow (case sensitive). The string ``'*'`` acts as a wildcard, matching every origin. @@ -120,9 +129,17 @@ def process_response( 'Access-Control-Request-Headers', default='*' ) - 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 + if allow is None: + # there is no allow set, remove all access control headers + resp.delete_header('Access-Control-Allow-Methods') + resp.delete_header('Access-Control-Allow-Headers') + resp.delete_header('Access-Control-Max-Age') + resp.delete_header('Access-Control-Expose-Headers') + resp.delete_header('Access-Control-Allow-Origin') + else: + resp.set_header('Access-Control-Allow-Methods', allow) + resp.set_header('Access-Control-Allow-Headers', allow_headers) + resp.set_header('Access-Control-Max-Age', '86400') # 24 hours async def process_response_async(self, *args: Any) -> None: self.process_response(*args) diff --git a/falcon/routing/static.py b/falcon/routing/static.py index 76ef4ed14..cc2a5ab92 100644 --- a/falcon/routing/static.py +++ b/falcon/routing/static.py @@ -183,6 +183,12 @@ def match(self, path: str) -> bool: def __call__(self, req: Request, resp: Response, **kw: Any) -> None: """Resource responder for this route.""" assert not kw + if req.method == 'OPTIONS': + # it's likely a CORS request. Set the allow header to the appropriate value. + resp.set_header('Allow', 'GET') + resp.set_header('Content-Length', '0') + return + without_prefix = req.path[len(self._prefix) :] # NOTE(kgriffs): Check surrounding whitespace and strip trailing @@ -247,9 +253,9 @@ class StaticRouteAsync(StaticRoute): async def __call__(self, req: asgi.Request, resp: asgi.Response, **kw: Any) -> None: # type: ignore[override] super().__call__(req, resp, **kw) - - # NOTE(kgriffs): Fixup resp.stream so that it is non-blocking - resp.stream = _AsyncFileReader(resp.stream) # type: ignore[assignment,arg-type] + if resp.stream is not None: # None when in an option request + # NOTE(kgriffs): Fixup resp.stream so that it is non-blocking + resp.stream = _AsyncFileReader(resp.stream) # type: ignore[assignment,arg-type] class _AsyncFileReader: diff --git a/tests/test_cors_middleware.py b/tests/test_cors_middleware.py index 4594242be..9aff6abf6 100644 --- a/tests/test_cors_middleware.py +++ b/tests/test_cors_middleware.py @@ -1,3 +1,5 @@ +from pathlib import Path + import pytest import falcon @@ -86,8 +88,7 @@ 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): + def test_enabled_cors_handles_preflight_custom_option(self, cors_client): cors_client.app.add_route('/', CORSOptionsResource()) result = cors_client.simulate_options( headers=( @@ -97,11 +98,10 @@ def test_enabled_cors_handles_preflighting_custom_option(self, cors_client): ) ) 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' + assert 'Access-Control-Allow-Headers' not in result.headers + assert 'Access-Control-Max-Age' not in result.headers + assert 'Access-Control-Expose-Headers' not in result.headers + assert 'Access-Control-Allow-Origin' not in result.headers def test_enabled_cors_handles_preflighting_no_headers_in_req(self, cors_client): cors_client.app.add_route('/', CORSHeaderResource()) @@ -117,6 +117,50 @@ def test_enabled_cors_handles_preflighting_no_headers_in_req(self, cors_client): result.headers['Access-Control-Max-Age'] == '86400' ) # 24 hours in seconds + def test_enabled_cors_static_route(self, cors_client): + cors_client.app.add_static_route('/static', Path(__file__).parent) + result = cors_client.simulate_options( + f'/static/{Path(__file__).name}', + headers=( + ('Origin', 'localhost'), + ('Access-Control-Request-Method', 'GET'), + ), + ) + + assert result.headers['Access-Control-Allow-Methods'] == 'GET' + assert result.headers['Access-Control-Allow-Headers'] == '*' + assert result.headers['Access-Control-Max-Age'] == '86400' + assert result.headers['Access-Control-Allow-Origin'] == '*' + + @pytest.mark.parametrize('support_options', [True, False]) + def test_enabled_cors_sink_route(self, cors_client, support_options): + def my_sink(req, resp): + if req.method == 'OPTIONS' and support_options: + resp.set_header('ALLOW', 'GET') + else: + resp.text = 'my sink' + + cors_client.app.add_sink(my_sink, '/sink') + result = cors_client.simulate_options( + '/sink/123', + headers=( + ('Origin', 'localhost'), + ('Access-Control-Request-Method', 'GET'), + ), + ) + + if support_options: + assert result.headers['Access-Control-Allow-Methods'] == 'GET' + assert result.headers['Access-Control-Allow-Headers'] == '*' + assert result.headers['Access-Control-Max-Age'] == '86400' + assert result.headers['Access-Control-Allow-Origin'] == '*' + else: + assert 'Access-Control-Allow-Methods' not in result.headers + assert 'Access-Control-Allow-Headers' not in result.headers + assert 'Access-Control-Max-Age' not in result.headers + assert 'Access-Control-Expose-Headers' not in result.headers + assert 'Access-Control-Allow-Origin' not in result.headers + @pytest.fixture(scope='function') def make_cors_client(asgi, util): diff --git a/tests/test_static.py b/tests/test_static.py index cfeffc3b3..1b38d2e64 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -617,3 +617,19 @@ def test_file_closed(client, patch_open): assert patch_open.current_file is not None assert patch_open.current_file.closed + + +def test_options_request(util, asgi, patch_open): + patch_open() + app = util.create_app(asgi, cors_enable=True) + app.add_static_route('/static', '/var/www/statics') + client = testing.TestClient(app) + + resp = client.simulate_options( + path='/static/foo/bar.txt', + headers={'Origin': 'localhost', 'Access-Control-Request-Method': 'GET'}, + ) + assert resp.status_code == 200 + assert resp.text == '' + assert int(resp.headers['Content-Length']) == 0 + assert resp.headers['Access-Control-Allow-Methods'] == 'GET'