From 0c24a182c4d4f8cf67a564f3dc0a7f828a1bd727 Mon Sep 17 00:00:00 2001 From: Federico Caselli Date: Fri, 27 Sep 2024 21:39:11 +0200 Subject: [PATCH] chore: typing cleanup (#2339) * refactor: privatize typing module * refactor: rename missing to unset * typing: define public typing module Other minor changes in the header types * docs: improved attributes docs by inlining them * typing: improve secure_filename typing * docs: fix linter issues * docs: make sphinx happy * docs: improve attribute docs * typing: finalize typing module * docs: workaround sphinx autoclass typing rendering bug * docs: add typing aliases to the docs also customize how generic aliases docs are rendered to avoid adding "alias of " * chore: update .coveragerc typing => _typing * chore: update after review * fix: fix typo --------- Co-authored-by: Vytautas Liuolia --- .coveragerc | 2 +- docs/api/util.rst | 6 + docs/conf.py | 1 + docs/ext/autodoc_customizations.py | 10 ++ falcon/_typing.py | 198 ++++++++++++++++++++++++ falcon/app.py | 32 ++-- falcon/app_helpers.py | 16 +- falcon/asgi/app.py | 28 ++-- falcon/asgi/multipart.py | 4 +- falcon/asgi/request.py | 32 ++-- falcon/asgi/response.py | 18 ++- falcon/asgi/stream.py | 2 +- falcon/asgi/structures.py | 5 +- falcon/asgi/ws.py | 6 +- falcon/errors.py | 8 +- falcon/forwarded.py | 44 +++--- falcon/hooks.py | 8 +- falcon/http_error.py | 47 +++--- falcon/http_status.py | 4 +- falcon/inspect.py | 14 +- falcon/media/base.py | 4 +- falcon/media/handlers.py | 4 +- falcon/media/multipart.py | 18 +-- falcon/request.py | 100 ++++++------ falcon/responders.py | 4 +- falcon/response.py | 16 +- falcon/response_helpers.py | 2 +- falcon/routing/compiled.py | 2 +- falcon/routing/static.py | 2 +- falcon/routing/util.py | 2 +- falcon/stream.py | 22 ++- falcon/testing/client.py | 238 ++++++++++++----------------- falcon/testing/helpers.py | 100 ++++++------ falcon/testing/resource.py | 59 ++++--- falcon/testing/srmock.py | 30 ++-- falcon/testing/test_case.py | 47 +++--- falcon/typing.py | 202 ++---------------------- falcon/util/misc.py | 2 +- falcon/util/structures.py | 7 +- tests/test_after_hooks.py | 2 +- tests/test_boundedstream.py | 12 ++ tests/test_media_multipart.py | 13 +- 42 files changed, 714 insertions(+), 659 deletions(-) create mode 100644 docs/ext/autodoc_customizations.py create mode 100644 falcon/_typing.py diff --git a/.coveragerc b/.coveragerc index 0b87d961d..64399d912 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,7 +1,7 @@ [run] branch = True source = falcon -omit = falcon/tests*,falcon/typing.py,falcon/cmd/bench.py,falcon/bench/*,falcon/vendor/* +omit = falcon/tests*,falcon/_typing.py,falcon/cmd/bench.py,falcon/bench/*,falcon/vendor/* parallel = True diff --git a/docs/api/util.rst b/docs/api/util.rst index 094bf4434..f15a3b444 100644 --- a/docs/api/util.rst +++ b/docs/api/util.rst @@ -85,3 +85,9 @@ Other .. autoclass:: falcon.ETag :members: + +Type Aliases +------------ + +.. automodule:: falcon.typing + :members: diff --git a/docs/conf.py b/docs/conf.py index 492c0028d..6edb23d08 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -77,6 +77,7 @@ 'sphinx_design', 'myst_parser', # Falcon-specific extensions + 'ext.autodoc_customizations', 'ext.cibuildwheel', 'ext.doorway', 'ext.private_args', diff --git a/docs/ext/autodoc_customizations.py b/docs/ext/autodoc_customizations.py new file mode 100644 index 000000000..2f4d07950 --- /dev/null +++ b/docs/ext/autodoc_customizations.py @@ -0,0 +1,10 @@ +"""Customizations to the autodoc functionalities""" + +import sphinx.ext.autodoc as ad + + +def setup(app): + # avoid adding "alias of xyz" + ad.GenericAliasMixin.update_content = ad.DataDocumenterMixinBase.update_content + + return {'parallel_read_safe': True} diff --git a/falcon/_typing.py b/falcon/_typing.py new file mode 100644 index 000000000..223533021 --- /dev/null +++ b/falcon/_typing.py @@ -0,0 +1,198 @@ +# Copyright 2021-2023 by Vytautas Liuolia. +# +# 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 +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +"""Private type aliases used internally by Falcon..""" + +from __future__ import annotations + +from enum import auto +from enum import Enum +import http +from http.cookiejar import Cookie +import sys +from typing import ( + Any, + Awaitable, + Callable, + Dict, + Iterable, + List, + Literal, + Mapping, + Optional, + Pattern, + Protocol, + Tuple, + TYPE_CHECKING, + TypeVar, + Union, +) + +# NOTE(vytas): Mypy still struggles to handle a conditional import in the EAFP +# fashion, so we branch on Py version instead (which it does understand). +if sys.version_info >= (3, 11): + from wsgiref.types import StartResponse as StartResponse + from wsgiref.types import WSGIEnvironment as WSGIEnvironment +else: + WSGIEnvironment = Dict[str, Any] + StartResponse = Callable[[str, List[Tuple[str, str]]], Callable[[bytes], None]] + +if TYPE_CHECKING: + from falcon.asgi import Request as AsgiRequest + from falcon.asgi import Response as AsgiResponse + from falcon.asgi import WebSocket + from falcon.asgi_spec import AsgiEvent + from falcon.asgi_spec import AsgiSendMsg + from falcon.http_error import HTTPError + from falcon.request import Request + from falcon.response import Response + + +class _Unset(Enum): + UNSET = auto() + + +_T = TypeVar('_T') +_UNSET = _Unset.UNSET +UnsetOr = Union[Literal[_Unset.UNSET], _T] + +Link = Dict[str, str] +CookieArg = Mapping[str, Union[str, Cookie]] +# Error handlers +ErrorHandler = Callable[['Request', 'Response', BaseException, Dict[str, Any]], None] + + +class AsgiErrorHandler(Protocol): + async def __call__( + self, + req: AsgiRequest, + resp: Optional[AsgiResponse], + error: BaseException, + params: Dict[str, Any], + *, + ws: Optional[WebSocket] = ..., + ) -> None: ... + + +# Error serializers +ErrorSerializer = Callable[['Request', 'Response', 'HTTPError'], None] + +# Sinks +SinkPrefix = Union[str, Pattern[str]] + + +class SinkCallable(Protocol): + def __call__(self, req: Request, resp: Response, **kwargs: str) -> None: ... + + +class AsgiSinkCallable(Protocol): + async def __call__( + self, req: AsgiRequest, resp: AsgiResponse, **kwargs: str + ) -> None: ... + + +HeaderMapping = Mapping[str, str] +HeaderIter = Iterable[Tuple[str, str]] +HeaderArg = Union[HeaderMapping, HeaderIter] +ResponseStatus = Union[http.HTTPStatus, str, int] +StoreArg = Optional[Dict[str, Any]] +Resource = object +RangeSetHeader = Union[Tuple[int, int, int], Tuple[int, int, int, str]] + + +# WSGI +class ResponderMethod(Protocol): + def __call__( + self, + resource: Resource, + req: Request, + resp: Response, + **kwargs: Any, + ) -> None: ... + + +class ResponderCallable(Protocol): + def __call__(self, req: Request, resp: Response, **kwargs: Any) -> None: ... + + +ProcessRequestMethod = Callable[['Request', 'Response'], None] +ProcessResourceMethod = Callable[ + ['Request', 'Response', Resource, Dict[str, Any]], None +] +ProcessResponseMethod = Callable[['Request', 'Response', Resource, bool], None] + + +# ASGI +class AsgiResponderMethod(Protocol): + async def __call__( + self, + resource: Resource, + req: AsgiRequest, + resp: AsgiResponse, + **kwargs: Any, + ) -> None: ... + + +class AsgiResponderCallable(Protocol): + async def __call__( + self, req: AsgiRequest, resp: AsgiResponse, **kwargs: Any + ) -> None: ... + + +class AsgiResponderWsCallable(Protocol): + async def __call__( + self, req: AsgiRequest, ws: WebSocket, **kwargs: Any + ) -> None: ... + + +AsgiReceive = Callable[[], Awaitable['AsgiEvent']] +AsgiSend = Callable[['AsgiSendMsg'], Awaitable[None]] +AsgiProcessRequestMethod = Callable[['AsgiRequest', 'AsgiResponse'], Awaitable[None]] +AsgiProcessResourceMethod = Callable[ + ['AsgiRequest', 'AsgiResponse', Resource, Dict[str, Any]], Awaitable[None] +] +AsgiProcessResponseMethod = Callable[ + ['AsgiRequest', 'AsgiResponse', Resource, bool], Awaitable[None] +] +AsgiProcessRequestWsMethod = Callable[['AsgiRequest', 'WebSocket'], Awaitable[None]] +AsgiProcessResourceWsMethod = Callable[ + ['AsgiRequest', 'WebSocket', Resource, Dict[str, Any]], Awaitable[None] +] +ResponseCallbacks = Union[ + Tuple[Callable[[], None], Literal[False]], + Tuple[Callable[[], Awaitable[None]], Literal[True]], +] + + +# Routing + +MethodDict = Union[ + Dict[str, ResponderCallable], + Dict[str, Union[AsgiResponderCallable, AsgiResponderWsCallable]], +] + + +class FindMethod(Protocol): + def __call__( + self, uri: str, req: Optional[Request] + ) -> Optional[Tuple[object, MethodDict, Dict[str, Any], Optional[str]]]: ... + + +# Media +class SerializeSync(Protocol): + def __call__(self, media: Any, content_type: Optional[str] = ...) -> bytes: ... + + +DeserializeSync = Callable[[bytes], Any] + +Responder = Union[ResponderMethod, AsgiResponderMethod] diff --git a/falcon/app.py b/falcon/app.py index 4eacee04b..9c764f557 100644 --- a/falcon/app.py +++ b/falcon/app.py @@ -45,6 +45,18 @@ from falcon import constants from falcon import responders from falcon import routing +from falcon._typing import AsgiResponderCallable +from falcon._typing import AsgiResponderWsCallable +from falcon._typing import AsgiSinkCallable +from falcon._typing import ErrorHandler +from falcon._typing import ErrorSerializer +from falcon._typing import FindMethod +from falcon._typing import ProcessResponseMethod +from falcon._typing import ResponderCallable +from falcon._typing import SinkCallable +from falcon._typing import SinkPrefix +from falcon._typing import StartResponse +from falcon._typing import WSGIEnvironment from falcon.errors import CompatibilityError from falcon.errors import HTTPBadRequest from falcon.errors import HTTPInternalServerError @@ -56,19 +68,7 @@ from falcon.response import Response from falcon.response import ResponseOptions import falcon.status_codes as status -from falcon.typing import AsgiResponderCallable -from falcon.typing import AsgiResponderWsCallable -from falcon.typing import AsgiSinkCallable -from falcon.typing import ErrorHandler -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 -from falcon.typing import StartResponse -from falcon.typing import WSGIEnvironment from falcon.util import deprecation from falcon.util import misc from falcon.util.misc import code_to_http_status @@ -303,8 +303,8 @@ def process_response( def __init__( self, media_type: str = constants.DEFAULT_MEDIA_TYPE, - request_type: Type[Request] = Request, - response_type: Type[Response] = Response, + request_type: Optional[Type[Request]] = None, + response_type: Optional[Type[Response]] = None, middleware: Union[object, Iterable[object]] = None, router: Optional[routing.CompiledRouter] = None, independent_middleware: bool = True, @@ -342,8 +342,8 @@ def __init__( self._router = router or routing.DefaultRouter() self._router_search = self._router.find - self._request_type = request_type - self._response_type = response_type + self._request_type = request_type or Request + self._response_type = response_type or Response self._error_handlers = {} self._serialize_error = helpers.default_serialize_error diff --git a/falcon/app_helpers.py b/falcon/app_helpers.py index bca38a3bc..e9ccba253 100644 --- a/falcon/app_helpers.py +++ b/falcon/app_helpers.py @@ -20,20 +20,20 @@ from typing import IO, Iterable, List, Literal, Optional, overload, Tuple, Union from falcon import util +from falcon._typing import AsgiProcessRequestMethod as APRequest +from falcon._typing import AsgiProcessRequestWsMethod +from falcon._typing import AsgiProcessResourceMethod as APResource +from falcon._typing import AsgiProcessResourceWsMethod +from falcon._typing import AsgiProcessResponseMethod as APResponse +from falcon._typing import ProcessRequestMethod as PRequest +from falcon._typing import ProcessResourceMethod as PResource +from falcon._typing import ProcessResponseMethod as PResponse from falcon.constants import MEDIA_JSON from falcon.constants import MEDIA_XML from falcon.errors import CompatibilityError from falcon.errors import HTTPError from falcon.request import Request from falcon.response import Response -from falcon.typing import AsgiProcessRequestMethod as APRequest -from falcon.typing import AsgiProcessRequestWsMethod -from falcon.typing import AsgiProcessResourceMethod as APResource -from falcon.typing import AsgiProcessResourceWsMethod -from falcon.typing import AsgiProcessResponseMethod as APResponse -from falcon.typing import ProcessRequestMethod as PRequest -from falcon.typing import ProcessResourceMethod as PResource -from falcon.typing import ProcessResponseMethod as PResponse from falcon.util.sync import _wrap_non_coroutine_unsafe __all__ = ( diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py index fd8e75eee..d28c8c49a 100644 --- a/falcon/asgi/app.py +++ b/falcon/asgi/app.py @@ -40,6 +40,14 @@ from falcon import constants from falcon import responders from falcon import routing +from falcon._typing import _UNSET +from falcon._typing import AsgiErrorHandler +from falcon._typing import AsgiReceive +from falcon._typing import AsgiResponderCallable +from falcon._typing import AsgiResponderWsCallable +from falcon._typing import AsgiSend +from falcon._typing import AsgiSinkCallable +from falcon._typing import SinkPrefix import falcon.app from falcon.app_helpers import AsyncPreparedMiddlewareResult from falcon.app_helpers import AsyncPreparedMiddlewareWsResult @@ -55,14 +63,6 @@ from falcon.http_error import HTTPError from falcon.http_status import HTTPStatus from falcon.media.multipart import MultipartFormHandler -from falcon.typing import AsgiErrorHandler -from falcon.typing import AsgiReceive -from falcon.typing import AsgiResponderCallable -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 from falcon.util.sync import _should_wrap_non_coroutines @@ -366,8 +366,8 @@ async def process_resource_ws( def __init__( self, media_type: str = constants.DEFAULT_MEDIA_TYPE, - request_type: Type[Request] = Request, - response_type: Type[Response] = Response, + request_type: Optional[Type[Request]] = None, + response_type: Optional[Type[Response]] = None, middleware: Union[object, Iterable[object]] = None, router: Optional[routing.CompiledRouter] = None, independent_middleware: bool = True, @@ -376,8 +376,8 @@ def __init__( ) -> None: super().__init__( media_type, - request_type, - response_type, + request_type or Request, + response_type or Response, middleware, router, independent_middleware, @@ -386,7 +386,7 @@ def __init__( ) self.ws_options = WebSocketOptions() - self._standard_response_type = response_type is Response + self._standard_response_type = response_type in (None, Response) self.add_error_handler( WebSocketDisconnected, self._ws_disconnected_error_handler @@ -556,7 +556,7 @@ async def __call__( # type: ignore[override] # noqa: C901 if data is None and resp._media is not None: # NOTE(kgriffs): We use a special MISSING singleton since # None is ambiguous (the media handler might return None). - if resp._media_rendered is MISSING: + if resp._media_rendered is _UNSET: opt = resp.options if not resp.content_type: resp.content_type = opt.default_media_type diff --git a/falcon/asgi/multipart.py b/falcon/asgi/multipart.py index 403028b26..eee479f23 100644 --- a/falcon/asgi/multipart.py +++ b/falcon/asgi/multipart.py @@ -25,11 +25,11 @@ TYPE_CHECKING, ) +from falcon._typing import _UNSET 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: @@ -102,7 +102,7 @@ async def get_media(self) -> Any: Returns: object: The deserialized media representation. """ - if self._media is MISSING: + if self._media is _UNSET: handler, _, _ = self._parse_options.media_handlers._resolve( self.content_type, 'text/plain' ) diff --git a/falcon/asgi/request.py b/falcon/asgi/request.py index d2eb15a19..8442ea8bc 100644 --- a/falcon/asgi/request.py +++ b/falcon/asgi/request.py @@ -34,13 +34,13 @@ from falcon import errors from falcon import request from falcon import request_helpers as helpers +from falcon._typing import _UNSET +from falcon._typing import AsgiReceive +from falcon._typing import StoreArg +from falcon._typing import UnsetOr from falcon.asgi_spec import AsgiEvent from falcon.constants import SINGLETON_HEADERS from falcon.forwarded import Forwarded -from falcon.typing import AsgiReceive -from falcon.typing import MISSING -from falcon.typing import MissingOr -from falcon.typing import StoreArgument from falcon.util import deprecation from falcon.util import ETag from falcon.util.uri import parse_host @@ -100,7 +100,7 @@ class Request(request.Request): _cached_prefix: Optional[str] = None _cached_relative_uri: Optional[str] = None _cached_uri: Optional[str] = None - _media: MissingOr[Any] = MISSING + _media: UnsetOr[Any] = _UNSET _media_error: Optional[Exception] = None _stream: Optional[BoundedStream] = None @@ -163,7 +163,7 @@ def __init__( self.scope = scope self.is_websocket = scope['type'] == 'websocket' - self.options = options if options else request.RequestOptions() + self.options = options if options is not None else request.RequestOptions() self.method = 'GET' if self.is_websocket else scope['method'] @@ -564,7 +564,7 @@ def netloc(self) -> str: return netloc_value - async def get_media(self, default_when_empty: MissingOr[Any] = MISSING) -> Any: + async def get_media(self, default_when_empty: UnsetOr[Any] = _UNSET) -> Any: """Return a deserialized form of the request stream. The first time this method is called, the request stream will be @@ -606,10 +606,10 @@ async def get_media(self, default_when_empty: MissingOr[Any] = MISSING) -> Any: media (object): The deserialized media representation. """ - if self._media is not MISSING: + if self._media is not _UNSET: return self._media if self._media_error is not None: - if default_when_empty is not MISSING and isinstance( + if default_when_empty is not _UNSET and isinstance( self._media_error, errors.MediaNotFoundError ): return default_when_empty @@ -629,7 +629,7 @@ async def get_media(self, default_when_empty: MissingOr[Any] = MISSING) -> Any: except errors.MediaNotFoundError as err: self._media_error = err - if default_when_empty is not MISSING: + if default_when_empty is not _UNSET: return default_when_empty raise except Exception as err: @@ -655,7 +655,7 @@ def if_match(self) -> Optional[List[Union[ETag, Literal['*']]]]: # TODO(kgriffs): It may make sense at some point to create a # header property generator that DRY's up the memoization # pattern for us. - if self._cached_if_match is MISSING: + if self._cached_if_match is _UNSET: header_value = self._asgi_headers.get(b'if-match') if header_value: self._cached_if_match = helpers._parse_etags( @@ -668,7 +668,7 @@ def if_match(self) -> Optional[List[Union[ETag, Literal['*']]]]: @property def if_none_match(self) -> Optional[List[Union[ETag, Literal['*']]]]: - if self._cached_if_none_match is MISSING: + if self._cached_if_none_match is _UNSET: header_value = self._asgi_headers.get(b'if-none-match') if header_value: self._cached_if_none_match = helpers._parse_etags( @@ -792,7 +792,7 @@ def get_param( self, name: str, required: Literal[True], - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[str] = ..., ) -> str: ... @@ -801,7 +801,7 @@ def get_param( self, name: str, required: bool = ..., - store: StoreArgument = ..., + store: StoreArg = ..., *, default: str, ) -> str: ... @@ -811,7 +811,7 @@ def get_param( self, name: str, required: bool = False, - store: StoreArgument = None, + store: StoreArg = None, default: Optional[str] = None, ) -> Optional[str]: ... @@ -819,7 +819,7 @@ def get_param( self, name: str, required: bool = False, - store: StoreArgument = None, + store: StoreArg = None, default: Optional[str] = None, ) -> Optional[str]: """Return the raw value of a query string parameter as a string. diff --git a/falcon/asgi/response.py b/falcon/asgi/response.py index 73fcfbfbf..a2f5911e3 100644 --- a/falcon/asgi/response.py +++ b/falcon/asgi/response.py @@ -18,13 +18,21 @@ from inspect import iscoroutine from inspect import iscoroutinefunction -from typing import Awaitable, Callable, List, Literal, Optional, Tuple, Union +from typing import ( + AsyncIterator, + Awaitable, + Callable, + List, + Literal, + Optional, + Tuple, + Union, +) from falcon import response -from falcon.typing import AsyncIterator +from falcon._typing import _UNSET +from falcon._typing import ResponseCallbacks 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 @@ -201,7 +209,7 @@ async def render_body(self) -> Optional[bytes]: # type: ignore[override] if data is None and self._media is not None: # NOTE(kgriffs): We use a special MISSING singleton since # None is ambiguous (the media handler might return None). - if self._media_rendered is MISSING: + if self._media_rendered is _UNSET: if not self.content_type: self.content_type = self.options.default_media_type diff --git a/falcon/asgi/stream.py b/falcon/asgi/stream.py index 6213b1da1..43bcc6e13 100644 --- a/falcon/asgi/stream.py +++ b/falcon/asgi/stream.py @@ -18,9 +18,9 @@ from typing import AsyncIterator, NoReturn, Optional +from falcon._typing import AsgiReceive from falcon.asgi_spec import AsgiEvent from falcon.errors import OperationNotAllowed -from falcon.typing import AsgiReceive __all__ = ('BoundedStream',) diff --git a/falcon/asgi/structures.py b/falcon/asgi/structures.py index 7e66c310b..91533a05f 100644 --- a/falcon/asgi/structures.py +++ b/falcon/asgi/structures.py @@ -5,7 +5,6 @@ from falcon.constants import MEDIA_JSON from falcon.media import BaseHandler from falcon.media.json import _DEFAULT_JSON_HANDLER -from falcon.typing import JSONSerializable __all__ = ('SSEvent',) @@ -60,7 +59,7 @@ class SSEvent: """String to use for the ``data`` field in the message. Will be encoded as UTF-8 in the event. Takes precedence over `json`. """ - json: JSONSerializable + json: object """JSON-serializable object to be converted to JSON and used as the ``data`` field in the event message. """ @@ -89,7 +88,7 @@ def __init__( self, data: Optional[bytes] = None, text: Optional[str] = None, - json: JSONSerializable = None, + json: Optional[object] = None, event: Optional[str] = None, event_id: Optional[str] = None, retry: Optional[int] = None, diff --git a/falcon/asgi/ws.py b/falcon/asgi/ws.py index f2c693d5d..03800682f 100644 --- a/falcon/asgi/ws.py +++ b/falcon/asgi/ws.py @@ -10,14 +10,14 @@ from falcon import errors from falcon import media from falcon import status_codes +from falcon._typing import AsgiReceive +from falcon._typing import AsgiSend +from falcon._typing import HeaderArg from falcon.asgi_spec import AsgiEvent from falcon.asgi_spec import AsgiSendMsg from falcon.asgi_spec import EventType from falcon.asgi_spec import WSCloseCode from falcon.constants import WebSocketPayloadType -from falcon.typing import AsgiReceive -from falcon.typing import AsgiSend -from falcon.typing import HeaderArg from falcon.util import misc __all__ = ('WebSocket',) diff --git a/falcon/errors.py b/falcon/errors.py index 4222b885a..4acc7247d 100644 --- a/falcon/errors.py +++ b/falcon/errors.py @@ -45,7 +45,7 @@ def on_get(self, req, resp): from falcon.util.misc import dt_to_http if TYPE_CHECKING: - from falcon.typing import HeaderArg + from falcon._typing import HeaderArg from falcon.typing import Headers @@ -144,11 +144,11 @@ class WebSocketDisconnected(ConnectionError): Keyword Args: code (int): The WebSocket close code, as per the WebSocket spec (default ``1000``). - - Attributes: - code (int): The WebSocket close code, as per the WebSocket spec. """ + code: int + """The WebSocket close code, as per the WebSocket spec.""" + def __init__(self, code: Optional[int] = None) -> None: self.code = code or 1000 # Default to "Normal Closure" diff --git a/falcon/forwarded.py b/falcon/forwarded.py index f855b3661..e2a4838d2 100644 --- a/falcon/forwarded.py +++ b/falcon/forwarded.py @@ -55,21 +55,6 @@ class Forwarded: """Represents a parsed Forwarded header. (See also: RFC 7239, Section 4) - - Attributes: - src (str): The value of the "for" parameter, or - ``None`` if the parameter is absent. Identifies the - node making the request to the proxy. - dest (str): The value of the "by" parameter, or - ``None`` if the parameter is absent. Identifies the - client-facing interface of the proxy. - host (str): The value of the "host" parameter, or - ``None`` if the parameter is absent. Provides the host - request header field as received by the proxy. - scheme (str): The value of the "proto" parameter, or - ``None`` if the parameter is absent. Indicates the - protocol that was used to make the request to - the proxy. """ # NOTE(kgriffs): Use "src" since "for" is a keyword, and @@ -77,11 +62,32 @@ class Forwarded: # falcon.Request interface. __slots__ = ('src', 'dest', 'host', 'scheme') + src: Optional[str] + """The value of the "for" parameter, or ``None`` if the parameter is absent. + + Identifies the node making the request to the proxy. + """ + dest: Optional[str] + """The value of the "by" parameter, or ``None`` if the parameter is absent. + + Identifies the client-facing interface of the proxy. + """ + host: Optional[str] + """The value of the "host" parameter, or ``None`` if the parameter is absent. + + Provides the host request header field as received by the proxy. + """ + scheme: Optional[str] + """The value of the "proto" parameter, or ``None`` if the parameter is absent. + + Indicates the protocol that was used to make the request to the proxy. + """ + def __init__(self) -> None: - self.src: Optional[str] = None - self.dest: Optional[str] = None - self.host: Optional[str] = None - self.scheme: Optional[str] = None + self.src = None + self.dest = None + self.host = None + self.scheme = None def _parse_forwarded_header(forwarded: str) -> List[Forwarded]: diff --git a/falcon/hooks.py b/falcon/hooks.py index 024766751..c3e695b4f 100644 --- a/falcon/hooks.py +++ b/falcon/hooks.py @@ -41,10 +41,10 @@ if TYPE_CHECKING: import falcon as wsgi from falcon import asgi - from falcon.typing import AsgiResponderMethod - from falcon.typing import Resource - from falcon.typing import Responder - from falcon.typing import ResponderMethod + from falcon._typing import AsgiResponderMethod + from falcon._typing import Resource + from falcon._typing import Responder + from falcon._typing import ResponderMethod # TODO: if is_async is removed these protocol would no longer be needed, since diff --git a/falcon/http_error.py b/falcon/http_error.py index e1da5b352..b0aef8cc1 100644 --- a/falcon/http_error.py +++ b/falcon/http_error.py @@ -25,10 +25,10 @@ from falcon.util import uri if TYPE_CHECKING: + from falcon._typing import HeaderArg + from falcon._typing import Link + from falcon._typing import ResponseStatus from falcon.media import BaseHandler - from falcon.typing import HeaderArg - from falcon.typing import Link - from falcon.typing import ResponseStatus class HTTPError(Exception): @@ -87,21 +87,6 @@ class HTTPError(Exception): code (int): An internal code that customers can reference in their support request or to help them when searching for knowledge base articles related to this error (default ``None``). - - 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``. - status_code (int): HTTP status code normalized from the ``status`` - argument passed to the initializer. - title (str): Error title to send to the client. - description (str): Description of the error to send to the client. - headers (dict): Extra headers to add to the response. - link (str): An href that the client can provide to the user for - getting help. - code (int): An internal application code that a user can reference when - requesting support for the error. """ __slots__ = ( @@ -113,6 +98,28 @@ class HTTPError(Exception): 'code', ) + status: ResponseStatus + """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``. + """ + title: str + """Error title to send to the client. + + Derived from the ``status`` if not provided. + """ + description: Optional[str] + """Description of the error to send to the client.""" + headers: Optional[HeaderArg] + """Extra headers to add to the response.""" + link: Optional[Link] + """An href that the client can provide to the user for getting help.""" + code: Optional[int] + """An internal application code that a user can reference when requesting + support for the error. + """ + def __init__( self, status: ResponseStatus, @@ -135,7 +142,6 @@ def __init__( self.description = description self.headers = headers self.code = code - self.link: Optional[Link] if href: link = self.link = OrderedDict() @@ -152,6 +158,9 @@ def __repr__(self) -> str: @property def status_code(self) -> int: + """HTTP status code normalized from the ``status`` argument passed + to the initializer. + """ # noqa: D205 return http_status_to_code(self.status) def to_dict( diff --git a/falcon/http_status.py b/falcon/http_status.py index 688bac367..306519c9e 100644 --- a/falcon/http_status.py +++ b/falcon/http_status.py @@ -20,8 +20,8 @@ from falcon.util import http_status_to_code if TYPE_CHECKING: - from falcon.typing import HeaderArg - from falcon.typing import ResponseStatus + from falcon._typing import HeaderArg + from falcon._typing import ResponseStatus class HTTPStatus(Exception): diff --git a/falcon/inspect.py b/falcon/inspect.py index 6d221f713..00b840d68 100644 --- a/falcon/inspect.py +++ b/falcon/inspect.py @@ -300,13 +300,16 @@ class RouteMethodInfo(_Traversable): internal (bool): Whether or not this was a default responder added by the framework. - Attributes: - suffix (str): The suffix of this route function. This is set to an empty - string when the function has no suffix. """ __visit_name__ = 'route_method' + suffix: str + """The suffix of this route function. + + This is set to an empty string when the function has no suffix. + """ + def __init__( self, method: str, source_info: str, function_name: str, internal: bool ): @@ -490,12 +493,13 @@ class MiddlewareInfo(_Traversable): independent (bool): Whether or not the middleware components are executed independently. - Attributes: - independent_text (str): Text created from the `independent` arg. """ __visit_name__ = 'middleware' + independent_text: str + """Text created from the `independent` arg.""" + def __init__( self, middleware_tree: MiddlewareTreeInfo, diff --git a/falcon/media/base.py b/falcon/media/base.py index 70ceea776..0d80611f3 100644 --- a/falcon/media/base.py +++ b/falcon/media/base.py @@ -4,11 +4,11 @@ import io from typing import Optional, Union +from falcon._typing import DeserializeSync +from falcon._typing import SerializeSync from falcon.constants import MEDIA_JSON from falcon.typing import AsyncReadableIO -from falcon.typing import DeserializeSync from falcon.typing import ReadableIO -from falcon.typing import SerializeSync class BaseHandler(metaclass=abc.ABCMeta): diff --git a/falcon/media/handlers.py b/falcon/media/handlers.py index 8c1965f06..6b096ec91 100644 --- a/falcon/media/handlers.py +++ b/falcon/media/handlers.py @@ -18,6 +18,8 @@ ) from falcon import errors +from falcon._typing import DeserializeSync +from falcon._typing import SerializeSync from falcon.constants import MEDIA_JSON from falcon.constants import MEDIA_MULTIPART from falcon.constants import MEDIA_URLENCODED @@ -28,8 +30,6 @@ from falcon.media.multipart import MultipartFormHandler from falcon.media.multipart import MultipartParseOptions from falcon.media.urlencoded import URLEncodedFormHandler -from falcon.typing import DeserializeSync -from falcon.typing import SerializeSync from falcon.util import misc from falcon.vendor import mimeparse diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py index 340a5aea6..3f4eca88f 100644 --- a/falcon/media/multipart.py +++ b/falcon/media/multipart.py @@ -33,12 +33,12 @@ from urllib.parse import unquote_to_bytes from falcon import errors +from falcon._typing import _UNSET +from falcon._typing import UnsetOr 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 @@ -80,9 +80,9 @@ class BodyPart: _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 + _filename: UnsetOr[Optional[str]] = _UNSET + _media: UnsetOr[Any] = _UNSET + _name: UnsetOr[Optional[str]] = _UNSET stream: PyBufferedReader """File-like input object for reading the body part of the @@ -198,7 +198,7 @@ def content_type(self) -> str: @property 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._filename is _UNSET: if self._content_disposition is None: value = self._headers.get(b'content-disposition', b'') self._content_disposition = parse_header(value.decode()) @@ -233,7 +233,7 @@ def secure_filename(self) -> str: See also: :func:`~.secure_filename` """ # noqa: D205 try: - return misc.secure_filename(self.filename) + return misc.secure_filename(self.filename or '') except ValueError as ex: raise MultipartParseError(description=str(ex)) from ex @@ -253,7 +253,7 @@ def name(self) -> Optional[str]: 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._name is _UNSET: if self._content_disposition is None: value = self._headers.get(b'content-disposition', b'') self._content_disposition = parse_header(value.decode()) @@ -277,7 +277,7 @@ def get_media(self) -> Any: Returns: object: The deserialized media representation. """ - if self._media is MISSING: + if self._media is _UNSET: handler, _, _ = self._parse_options.media_handlers._resolve( self.content_type, 'text/plain' ) diff --git a/falcon/request.py b/falcon/request.py index ea121beca..59ddddab3 100644 --- a/falcon/request.py +++ b/falcon/request.py @@ -40,6 +40,9 @@ from falcon import errors from falcon import request_helpers as helpers from falcon import util +from falcon._typing import _UNSET +from falcon._typing import StoreArg +from falcon._typing import UnsetOr from falcon.constants import DEFAULT_MEDIA_TYPE from falcon.constants import MEDIA_JSON from falcon.forwarded import _parse_forwarded_header @@ -47,10 +50,7 @@ from falcon.media import Handlers from falcon.media.json import _DEFAULT_JSON_HANDLER from falcon.stream import BoundedStream -from falcon.typing import MISSING -from falcon.typing import MissingOr from falcon.typing import ReadableIO -from falcon.typing import StoreArgument from falcon.util import deprecation from falcon.util import ETag from falcon.util import structures @@ -114,10 +114,8 @@ class Request: ) _cookies: Optional[Dict[str, List[str]]] = None _cookies_collapsed: Optional[Dict[str, str]] = None - _cached_if_match: MissingOr[Optional[List[Union[ETag, Literal['*']]]]] = MISSING - _cached_if_none_match: MissingOr[Optional[List[Union[ETag, Literal['*']]]]] = ( - MISSING - ) + _cached_if_match: UnsetOr[Optional[List[Union[ETag, Literal['*']]]]] = _UNSET + _cached_if_none_match: UnsetOr[Optional[List[Union[ETag, Literal['*']]]]] = _UNSET # Child classes may override this context_type: ClassVar[Type[structures.Context]] = structures.Context @@ -246,13 +244,13 @@ def __init__( self.is_websocket: bool = False self.env = env - self.options = options if options else RequestOptions() + self.options = options if options is not None else RequestOptions() self._wsgierrors: TextIO = env['wsgi.errors'] self.method = env['REQUEST_METHOD'] self.uri_template = None - self._media: MissingOr[Any] = MISSING + self._media: UnsetOr[Any] = _UNSET self._media_error: Optional[Exception] = None # NOTE(kgriffs): PEP 3333 specifies that PATH_INFO may be the @@ -492,7 +490,7 @@ def if_match(self) -> Optional[List[Union[ETag, Literal['*']]]]: # TODO(kgriffs): It may make sense at some point to create a # header property generator that DRY's up the memoization # pattern for us. - if self._cached_if_match is MISSING: + if self._cached_if_match is _UNSET: header_value = self.env.get('HTTP_IF_MATCH') if header_value: self._cached_if_match = helpers._parse_etags(header_value) @@ -513,7 +511,7 @@ def if_none_match(self) -> Optional[List[Union[ETag, Literal['*']]]]: (See also: RFC 7232, Section 3.2) """ # noqa: D205 - if self._cached_if_none_match is MISSING: + if self._cached_if_none_match is _UNSET: header_value = self.env.get('HTTP_IF_NONE_MATCH') if header_value: self._cached_if_none_match = helpers._parse_etags(header_value) @@ -1062,7 +1060,7 @@ def netloc(self) -> str: return netloc_value - def get_media(self, default_when_empty: MissingOr[Any] = MISSING) -> Any: + def get_media(self, default_when_empty: UnsetOr[Any] = _UNSET) -> Any: """Return a deserialized form of the request stream. The first time this method is called, the request stream will be @@ -1103,10 +1101,10 @@ def get_media(self, default_when_empty: MissingOr[Any] = MISSING) -> Any: Returns: media (object): The deserialized media representation. """ - if self._media is not MISSING: + if self._media is not _UNSET: return self._media if self._media_error is not None: - if default_when_empty is not MISSING and isinstance( + if default_when_empty is not _UNSET and isinstance( self._media_error, errors.MediaNotFoundError ): return default_when_empty @@ -1122,7 +1120,7 @@ def get_media(self, default_when_empty: MissingOr[Any] = MISSING) -> Any: ) except errors.MediaNotFoundError as err: self._media_error = err - if default_when_empty is not MISSING: + if default_when_empty is not _UNSET: return default_when_empty raise except Exception as err: @@ -1376,7 +1374,7 @@ def get_param( self, name: str, required: Literal[True], - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[str] = ..., ) -> str: ... @@ -1385,7 +1383,7 @@ def get_param( self, name: str, required: bool = ..., - store: StoreArgument = ..., + store: StoreArg = ..., *, default: str, ) -> str: ... @@ -1395,7 +1393,7 @@ def get_param( self, name: str, required: bool = False, - store: StoreArgument = None, + store: StoreArg = None, default: Optional[str] = None, ) -> Optional[str]: ... @@ -1403,7 +1401,7 @@ def get_param( self, name: str, required: bool = False, - store: StoreArgument = None, + store: StoreArg = None, default: Optional[str] = None, ) -> Optional[str]: """Return the raw value of a query string parameter as a string. @@ -1488,7 +1486,7 @@ def get_param_as_int( required: Literal[True], min_value: Optional[int] = ..., max_value: Optional[int] = ..., - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[int] = ..., ) -> int: ... @@ -1499,7 +1497,7 @@ def get_param_as_int( required: bool = ..., min_value: Optional[int] = ..., max_value: Optional[int] = ..., - store: StoreArgument = ..., + store: StoreArg = ..., *, default: int, ) -> int: ... @@ -1511,7 +1509,7 @@ def get_param_as_int( required: bool = ..., min_value: Optional[int] = ..., max_value: Optional[int] = ..., - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[int] = ..., ) -> Optional[int]: ... @@ -1521,7 +1519,7 @@ def get_param_as_int( required: bool = False, min_value: Optional[int] = None, max_value: Optional[int] = None, - store: StoreArgument = None, + store: StoreArg = None, default: Optional[int] = None, ) -> Optional[int]: """Return the value of a query string parameter as an int. @@ -1601,7 +1599,7 @@ def get_param_as_float( required: Literal[True], min_value: Optional[float] = ..., max_value: Optional[float] = ..., - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[float] = ..., ) -> float: ... @@ -1612,7 +1610,7 @@ def get_param_as_float( required: bool = ..., min_value: Optional[float] = ..., max_value: Optional[float] = ..., - store: StoreArgument = ..., + store: StoreArg = ..., *, default: float, ) -> float: ... @@ -1624,7 +1622,7 @@ def get_param_as_float( required: bool = ..., min_value: Optional[float] = ..., max_value: Optional[float] = ..., - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[float] = ..., ) -> Optional[float]: ... @@ -1634,7 +1632,7 @@ def get_param_as_float( required: bool = False, min_value: Optional[float] = None, max_value: Optional[float] = None, - store: StoreArgument = None, + store: StoreArg = None, default: Optional[float] = None, ) -> Optional[float]: """Return the value of a query string parameter as an float. @@ -1712,7 +1710,7 @@ def get_param_as_uuid( self, name: str, required: Literal[True], - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[UUID] = ..., ) -> UUID: ... @@ -1721,7 +1719,7 @@ def get_param_as_uuid( self, name: str, required: bool = ..., - store: StoreArgument = ..., + store: StoreArg = ..., *, default: UUID, ) -> UUID: ... @@ -1731,7 +1729,7 @@ def get_param_as_uuid( self, name: str, required: bool = ..., - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[UUID] = ..., ) -> Optional[UUID]: ... @@ -1739,7 +1737,7 @@ def get_param_as_uuid( self, name: str, required: bool = False, - store: StoreArgument = None, + store: StoreArg = None, default: Optional[UUID] = None, ) -> Optional[UUID]: """Return the value of a query string parameter as an UUID. @@ -1812,7 +1810,7 @@ def get_param_as_bool( self, name: str, required: Literal[True], - store: StoreArgument = ..., + store: StoreArg = ..., blank_as_true: bool = ..., default: Optional[bool] = ..., ) -> bool: ... @@ -1822,7 +1820,7 @@ def get_param_as_bool( self, name: str, required: bool = ..., - store: StoreArgument = ..., + store: StoreArg = ..., blank_as_true: bool = ..., *, default: bool, @@ -1833,7 +1831,7 @@ def get_param_as_bool( self, name: str, required: bool = ..., - store: StoreArgument = ..., + store: StoreArg = ..., blank_as_true: bool = ..., default: Optional[bool] = ..., ) -> Optional[bool]: ... @@ -1842,7 +1840,7 @@ def get_param_as_bool( self, name: str, required: bool = False, - store: StoreArgument = None, + store: StoreArg = None, blank_as_true: bool = True, default: Optional[bool] = None, ) -> Optional[bool]: @@ -1926,7 +1924,7 @@ def get_param_as_list( transform: None = ..., *, required: Literal[True], - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[List[str]] = ..., ) -> List[str]: ... @@ -1936,7 +1934,7 @@ def get_param_as_list( name: str, transform: Callable[[str], _T], required: Literal[True], - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[List[_T]] = ..., ) -> List[_T]: ... @@ -1946,7 +1944,7 @@ def get_param_as_list( name: str, transform: None = ..., required: bool = ..., - store: StoreArgument = ..., + store: StoreArg = ..., *, default: List[str], ) -> List[str]: ... @@ -1957,7 +1955,7 @@ def get_param_as_list( name: str, transform: Callable[[str], _T], required: bool = ..., - store: StoreArgument = ..., + store: StoreArg = ..., *, default: List[_T], ) -> List[_T]: ... @@ -1968,7 +1966,7 @@ def get_param_as_list( name: str, transform: None = ..., required: bool = ..., - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[List[str]] = ..., ) -> Optional[List[str]]: ... @@ -1978,7 +1976,7 @@ def get_param_as_list( name: str, transform: Callable[[str], _T], required: bool = ..., - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[List[_T]] = ..., ) -> Optional[List[_T]]: ... @@ -1987,7 +1985,7 @@ def get_param_as_list( name: str, transform: Optional[Callable[[str], _T]] = None, required: bool = False, - store: StoreArgument = None, + store: StoreArg = None, default: Optional[List[_T]] = None, ) -> Optional[List[_T] | List[str]]: """Return the value of a query string parameter as a list. @@ -2087,7 +2085,7 @@ def get_param_as_datetime( format_string: str = ..., *, required: Literal[True], - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[datetime] = ..., ) -> datetime: ... @@ -2097,7 +2095,7 @@ def get_param_as_datetime( name: str, format_string: str = ..., required: bool = ..., - store: StoreArgument = ..., + store: StoreArg = ..., *, default: datetime, ) -> datetime: ... @@ -2108,7 +2106,7 @@ def get_param_as_datetime( name: str, format_string: str = ..., required: bool = ..., - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[datetime] = ..., ) -> Optional[datetime]: ... @@ -2117,7 +2115,7 @@ def get_param_as_datetime( name: str, format_string: str = '%Y-%m-%dT%H:%M:%SZ', required: bool = False, - store: StoreArgument = None, + store: StoreArg = None, default: Optional[datetime] = None, ) -> Optional[datetime]: """Return the value of a query string parameter as a datetime. @@ -2171,7 +2169,7 @@ def get_param_as_date( format_string: str = ..., *, required: Literal[True], - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[py_date] = ..., ) -> py_date: ... @@ -2181,7 +2179,7 @@ def get_param_as_date( name: str, format_string: str = ..., required: bool = ..., - store: StoreArgument = ..., + store: StoreArg = ..., *, default: py_date, ) -> py_date: ... @@ -2192,7 +2190,7 @@ def get_param_as_date( name: str, format_string: str = ..., required: bool = ..., - store: StoreArgument = ..., + store: StoreArg = ..., default: Optional[py_date] = ..., ) -> Optional[py_date]: ... @@ -2201,7 +2199,7 @@ def get_param_as_date( name: str, format_string: str = '%Y-%m-%d', required: bool = False, - store: StoreArgument = None, + store: StoreArg = None, default: Optional[py_date] = None, ) -> Optional[py_date]: """Return the value of a query string parameter as a date. @@ -2247,7 +2245,7 @@ def get_param_as_json( self, name: str, required: bool = False, - store: StoreArgument = None, + store: StoreArg = None, default: Optional[Any] = None, ) -> Any: """Return the decoded JSON value of a query string parameter. diff --git a/falcon/responders.py b/falcon/responders.py index b28277b47..a1efa722c 100644 --- a/falcon/responders.py +++ b/falcon/responders.py @@ -18,12 +18,12 @@ from typing import Any, Iterable, NoReturn, TYPE_CHECKING, Union +from falcon._typing import AsgiResponderCallable +from falcon._typing import ResponderCallable 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 diff --git a/falcon/response.py b/falcon/response.py index 8617d76d5..d468b0db3 100644 --- a/falcon/response.py +++ b/falcon/response.py @@ -36,6 +36,9 @@ Union, ) +from falcon._typing import _UNSET +from falcon._typing import RangeSetHeader +from falcon._typing import UnsetOr from falcon.constants import _DEFAULT_STATIC_MEDIA_TYPES from falcon.constants import DEFAULT_MEDIA_TYPE from falcon.errors import HeaderNotSupported @@ -47,9 +50,6 @@ 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 @@ -97,7 +97,7 @@ class Response: _extra_headers: Optional[List[Tuple[str, str]]] _headers: Headers _media: Optional[Any] - _media_rendered: MissingOr[bytes] + _media_rendered: UnsetOr[bytes] # Child classes may override this context_type: ClassVar[Type[structures.Context]] = structures.Context @@ -178,7 +178,7 @@ def __init__(self, options: Optional[ResponseOptions] = None) -> None: # only instantiating the list object later on IFF it is needed. self._extra_headers = None - self.options = options if options else ResponseOptions() + self.options = options if options is not None else ResponseOptions() # NOTE(tbug): will be set to a SimpleCookie object # when cookie is set via set_cookie @@ -188,7 +188,7 @@ def __init__(self, options: Optional[ResponseOptions] = None) -> None: self.stream = None self._data = None self._media = None - self._media_rendered = MISSING + self._media_rendered = _UNSET self.context = self.context_type() @@ -251,7 +251,7 @@ def media(self) -> Any: @media.setter def media(self, value: Any) -> None: self._media = value - self._media_rendered = MISSING + self._media_rendered = _UNSET def render_body(self) -> Optional[bytes]: """Get the raw bytestring content for the response body. @@ -278,7 +278,7 @@ def render_body(self) -> Optional[bytes]: if data is None and self._media is not None: # NOTE(kgriffs): We use a special MISSING singleton since # None is ambiguous (the media handler might return None). - if self._media_rendered is MISSING: + if self._media_rendered is _UNSET: if not self.content_type: self.content_type = self.options.default_media_type diff --git a/falcon/response_helpers.py b/falcon/response_helpers.py index 4e2b459eb..71d397ec5 100644 --- a/falcon/response_helpers.py +++ b/falcon/response_helpers.py @@ -18,7 +18,7 @@ from typing import Any, Callable, Iterable, Optional, TYPE_CHECKING -from falcon.typing import RangeSetHeader +from falcon._typing import RangeSetHeader from falcon.util import uri from falcon.util.misc import secure_filename diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py index 6407484c6..836288780 100644 --- a/falcon/routing/compiled.py +++ b/falcon/routing/compiled.py @@ -35,10 +35,10 @@ Union, ) +from falcon._typing import MethodDict from falcon.routing import converters from falcon.routing.util import map_http_methods from falcon.routing.util import set_default_responders -from falcon.typing import MethodDict from falcon.util.misc import is_python_func from falcon.util.sync import _should_wrap_non_coroutines from falcon.util.sync import wrap_sync_to_async diff --git a/falcon/routing/static.py b/falcon/routing/static.py index cc2a5ab92..57e327ab6 100644 --- a/falcon/routing/static.py +++ b/falcon/routing/static.py @@ -9,12 +9,12 @@ from typing import Any, ClassVar, IO, Optional, Pattern, Tuple, TYPE_CHECKING, Union import falcon +from falcon.typing import ReadableIO if TYPE_CHECKING: from falcon import asgi from falcon import Request from falcon import Response -from falcon.typing import ReadableIO def _open_range( diff --git a/falcon/routing/util.py b/falcon/routing/util.py index eb709a442..18259edde 100644 --- a/falcon/routing/util.py +++ b/falcon/routing/util.py @@ -22,7 +22,7 @@ from falcon import responders if TYPE_CHECKING: - from falcon.typing import MethodDict + from falcon._typing import MethodDict class SuffixedMethodNotFoundError(Exception): diff --git a/falcon/stream.py b/falcon/stream.py index f5bbe463d..127c44ffc 100644 --- a/falcon/stream.py +++ b/falcon/stream.py @@ -19,6 +19,8 @@ import io from typing import BinaryIO, Callable, List, Optional, TypeVar, Union +from falcon.util import deprecated + __all__ = ('BoundedStream',) @@ -43,12 +45,6 @@ class BoundedStream(io.IOBase): stream: Instance of ``socket._fileobject`` from ``environ['wsgi.input']`` stream_len: Expected content length of the stream. - - Attributes: - eof (bool): ``True`` if there is no more data to read from - the stream, otherwise ``False``. - is_exhausted (bool): Deprecated alias for `eof`. - """ def __init__(self, stream: BinaryIO, stream_len: int) -> None: @@ -166,9 +162,21 @@ def exhaust(self, chunk_size: int = 64 * 1024) -> None: @property def eof(self) -> bool: + """``True`` if there is no more data to read from the stream, + otherwise ``False``. + """ # noqa: D205 return self._bytes_remaining <= 0 - is_exhausted = eof + @property + # NOTE(caselit): Deprecated long ago. Warns since 4.0. + @deprecated( + 'Use `eof` instead. ' + '(This compatibility alias will be removed in Falcon 5.0.)', + is_property=True, + ) + def is_exhausted(self) -> bool: + """Deprecated alias for `eof`.""" + return self.eof # NOTE(kgriffs): Alias for backwards-compat diff --git a/falcon/testing/client.py b/falcon/testing/client.py index 5a29b9573..47a55fef6 100644 --- a/falcon/testing/client.py +++ b/falcon/testing/client.py @@ -41,12 +41,17 @@ Sequence, TextIO, Tuple, + TYPE_CHECKING, TypeVar, Union, ) import warnings import wsgiref.validate +from falcon._typing import CookieArg +from falcon._typing import HeaderArg +from falcon._typing import HeaderIter +from falcon._typing import HeaderMapping from falcon.asgi_spec import AsgiEvent from falcon.asgi_spec import ScopeType from falcon.constants import COMBINED_METHODS @@ -54,8 +59,6 @@ from falcon.errors import CompatibilityError from falcon.testing import helpers from falcon.testing.srmock import StartResponseMock -from falcon.typing import CookieArg -from falcon.typing import HeaderList from falcon.typing import Headers from falcon.util import async_to_sync from falcon.util import CaseInsensitiveDict @@ -64,6 +67,10 @@ from falcon.util import http_date_to_dt from falcon.util import to_query_str +if TYPE_CHECKING: + import falcon + from falcon import asgi + warnings.filterwarnings( 'error', ('Unknown REQUEST_METHOD: ' + "'({})'".format('|'.join(COMBINED_METHODS))), @@ -103,29 +110,7 @@ class Cookie: """Represents a cookie returned by a simulated request. Args: - morsel: A ``Morsel`` object from which to derive the cookie - data. - - Attributes: - name (str): The cookie's name. - value (str): The value of the cookie. - expires(datetime.datetime): Expiration timestamp for the cookie, - or ``None`` if not specified. - path (str): The path prefix to which this cookie is restricted, - or ``None`` if not specified. - domain (str): The domain to which this cookie is restricted, - or ``None`` if not specified. - max_age (int): The lifetime of the cookie in seconds, or - ``None`` if not specified. - secure (bool): Whether or not the cookie may only only be - transmitted from the client via HTTPS. - http_only (bool): Whether or not the cookie may only be - included in unscripted requests from the client. - same_site (str): Specifies whether cookies are send in - cross-site requests. Possible values are 'Lax', 'Strict' - and 'None'. ``None`` if not specified. - partitioned (bool): Indicates if the cookie has the - ``Partitioned`` flag set. + morsel: A ``Morsel`` object from which to derive the cookie data. """ _expires: Optional[str] @@ -156,14 +141,17 @@ def __init__(self, morsel: Morsel) -> None: @property def name(self) -> str: + """The cookie's name.""" return self._name @property def value(self) -> str: + """The value of the cookie.""" return self._value @property def expires(self) -> Optional[dt.datetime]: + """Expiration timestamp for the cookie, or ``None`` if not specified.""" if self._expires: return http_date_to_dt(self._expires, obs_date=True) @@ -171,30 +159,48 @@ def expires(self) -> Optional[dt.datetime]: @property def path(self) -> str: + """The path prefix to which this cookie is restricted. + + An empty string if not specified. + """ return self._path @property def domain(self) -> str: + """The domain to which this cookie is restricted. + + An empty string if not specified. + """ return self._domain @property def max_age(self) -> Optional[int]: + """The lifetime of the cookie in seconds, or ``None`` if not specified.""" return int(self._max_age) if self._max_age else None @property def secure(self) -> bool: + """Whether or not the cookie may only only be transmitted + from the client via HTTPS. + """ # noqa: D205 return bool(self._secure) @property def http_only(self) -> bool: + """Whether or not the cookie will be visible from JavaScript in the client.""" return bool(self._httponly) @property def same_site(self) -> Optional[str]: + """Specifies whether cookies are send in cross-site requests. + + Possible values are 'Lax', 'Strict' and 'None'. ``None`` if not specified. + """ return self._samesite if self._samesite else None @property def partitioned(self) -> bool: + """Indicates if the cookie has the ``Partitioned`` flag set.""" return bool(self._partitioned) @@ -206,36 +212,9 @@ class _ResultBase: reason string headers (list): A list of (header_name, header_value) tuples, per PEP-3333 - - Attributes: - status (str): HTTP status string given in the response - status_code (int): The code portion of the HTTP status string - headers (CaseInsensitiveDict): A case-insensitive dictionary - containing all the headers in the response, except for - cookies, which may be accessed via the `cookies` - attribute. - - Note: - - Multiple instances of a header in the response are - currently not supported; it is unspecified which value - will "win" and be represented in `headers`. - - cookies (dict): A dictionary of - :class:`falcon.testing.Cookie` values parsed from the - response, by name. - - The cookies dictionary can be used directly in subsequent requests:: - - client = testing.TestClient(app) - response_one = client.simulate_get('/') - response_two = client.simulate_post('/', cookies=response_one.cookies) - - encoding (str): Text encoding of the response body, or ``None`` - if the encoding can not be determined. """ - def __init__(self, status: str, headers: HeaderList) -> None: + def __init__(self, status: str, headers: HeaderIter) -> None: self._status = status self._status_code = int(status[:3]) self._headers = CaseInsensitiveDict(headers) @@ -253,29 +232,46 @@ def __init__(self, status: str, headers: HeaderList) -> None: @property def status(self) -> str: + """HTTP status string given in the response.""" return self._status @property def status_code(self) -> int: + """The code portion of the HTTP status string.""" return self._status_code @property - def headers(self) -> CaseInsensitiveDict: - # NOTE(kgriffs): It would probably be better to annotate this with - # a generic Mapping[str, str] type, but currently there is an - # incompatibility with Cython that prevents us from modifying - # CaseInsensitiveDict to inherit from a generic MutableMapping - # type. This might be resolved in the future by moving - # the CaseInsensitiveDict implementation to the falcon.testing - # module so that it is no longer cythonized. - return self._headers + def headers(self) -> Headers: + """A case-insensitive dictionary containing all the headers in the response, + except for cookies, which may be accessed via the `cookies` attribute. + + Note: + + Multiple instances of a header in the response are + currently not supported; it is unspecified which value + will "win" and be represented in `headers`. + """ # noqa: D205 + return self._headers # type: ignore[return-value] @property def cookies(self) -> Dict[str, Cookie]: + """A dictionary of :class:`falcon.testing.Cookie` values parsed from + the response, by name. + + The cookies dictionary can be used directly in subsequent requests:: + + client = testing.TestClient(app) + response_one = client.simulate_get('/') + response_two = client.simulate_post('/', cookies=response_one.cookies) + """ # noqa: D205 return self._cookies @property def encoding(self) -> Optional[str]: + """Text encoding of the response body. + + Returns ``None`` if the encoding can not be determined. + """ return self._encoding @@ -326,38 +322,10 @@ class Result(_ResultBase): reason string headers (list): A list of (header_name, header_value) tuples, per PEP-3333 - - Attributes: - status (str): HTTP status string given in the response - status_code (int): The code portion of the HTTP status string - headers (CaseInsensitiveDict): A case-insensitive dictionary - containing all the headers in the response, except for - cookies, which may be accessed via the `cookies` - attribute. - - Note: - - Multiple instances of a header in the response are - currently not supported; it is unspecified which value - will "win" and be represented in `headers`. - - cookies (dict): A dictionary of - :class:`falcon.testing.Cookie` values parsed from the - response, by name. - encoding (str): Text encoding of the response body, or ``None`` - if the encoding can not be determined. - content (bytes): Raw response body, or ``bytes`` if the - response body was empty. - text (str): Decoded response body of type ``str``. - If the content type does not specify an encoding, UTF-8 is - assumed. - json (JSON serializable): Deserialized JSON body. Will be ``None`` if - the body has no content to deserialize. Otherwise, raises an error - if the response is not valid JSON. """ def __init__( - self, iterable: Iterable[bytes], status: str, headers: HeaderList + self, iterable: Iterable[bytes], status: str, headers: HeaderIter ) -> None: super().__init__(status, headers) @@ -366,10 +334,15 @@ def __init__( @property def content(self) -> bytes: + """Raw response body, or an ``b''`` if the response body was empty.""" return self._content @property def text(self) -> str: + """Decoded response body of type ``str``. + + If the content type does not specify an encoding, UTF-8 is assumed. + """ if self._text is None: if not self.content: self._text = '' @@ -385,6 +358,11 @@ def text(self) -> str: @property def json(self) -> Any: + """Deserialized JSON body. + + Will be ``None`` if the body has no content to deserialize. + Otherwise, raises an error if the response is not valid JSON. + """ if not self.text: return None @@ -422,34 +400,13 @@ class StreamedResult(_ResultBase): application via its receive() method. :meth:`~.finalize` will cause the event emitter to simulate an ``'http.disconnect'`` event before returning. - - Attributes: - status (str): HTTP status string given in the response - status_code (int): The code portion of the HTTP status string - headers (CaseInsensitiveDict): A case-insensitive dictionary - containing all the headers in the response, except for - cookies, which may be accessed via the `cookies` - attribute. - - Note: - - Multiple instances of a header in the response are - currently not supported; it is unspecified which value - will "win" and be represented in `headers`. - - cookies (dict): A dictionary of - :class:`falcon.testing.Cookie` values parsed from the - response, by name. - encoding (str): Text encoding of the response body, or ``None`` - if the encoding can not be determined. - stream (ResultStream): Raw response body, as a byte stream. """ def __init__( self, body_chunks: Sequence[bytes], status: str, - headers: HeaderList, + headers: HeaderIter, task: asyncio.Task, req_event_emitter: helpers.ASGIRequestEventEmitter, ): @@ -461,6 +418,7 @@ def __init__( @property def stream(self) -> ResultBodyStream: + """Raw response body, as a byte stream.""" return self._stream async def finalize(self) -> None: @@ -483,7 +441,7 @@ def simulate_request( method: str = 'GET', path: str = '/', query_string: Optional[str] = None, - headers: Optional[Headers] = None, + headers: Optional[HeaderArg] = None, content_type: Optional[str] = None, body: Optional[Union[str, bytes]] = None, json: Optional[Any] = None, @@ -695,7 +653,7 @@ async def _simulate_request_asgi( method: str = ..., path: str = ..., query_string: Optional[str] = ..., - headers: Optional[Headers] = ..., + headers: Optional[HeaderArg] = ..., content_type: Optional[str] = ..., body: Optional[Union[str, bytes]] = ..., json: Optional[Any] = ..., @@ -722,7 +680,7 @@ async def _simulate_request_asgi( method: str = ..., path: str = ..., query_string: Optional[str] = ..., - headers: Optional[Headers] = ..., + headers: Optional[HeaderArg] = ..., content_type: Optional[str] = ..., body: Optional[Union[str, bytes]] = ..., json: Optional[Any] = ..., @@ -752,7 +710,7 @@ async def _simulate_request_asgi( method: str = 'GET', path: str = '/', query_string: Optional[str] = None, - headers: Optional[Headers] = None, + headers: Optional[HeaderArg] = None, content_type: Optional[str] = None, body: Optional[Union[str, bytes]] = None, json: Optional[Any] = None, @@ -1091,21 +1049,22 @@ async def get_events_sse(): headers (dict): Default headers to set on every request (default ``None``). These defaults may be overridden by passing values for the same headers to one of the ``simulate_*()`` methods. - - Attributes: - app: The app that this client instance was configured to use. - """ + # NOTE(caseit): while any asgi app is accept, type this as a falcon + # asgi app for user convenience + app: asgi.App + """The app that this client instance was configured to use.""" + def __init__( self, - app: Callable[..., Coroutine[Any, Any, Any]], - headers: Optional[Headers] = None, + app: Callable[..., Any], # accept any asgi app + headers: Optional[HeaderMapping] = None, ): if not _is_asgi_app(app): raise CompatibilityError('ASGIConductor may only be used with an ASGI app') - self.app = app + self.app = app # type: ignore[assignment] self._default_headers = headers self._shutting_down = asyncio.Condition() @@ -1286,9 +1245,9 @@ async def simulate_request( if self._default_headers: # NOTE(kgriffs): Handle the case in which headers is explicitly # set to None. - additional_headers = kwargs.get('headers', {}) or {} + additional_headers = kwargs.get('headers') or {} - merged_headers = self._default_headers.copy() + merged_headers = dict(self._default_headers) merged_headers.update(additional_headers) kwargs['headers'] = merged_headers @@ -2089,21 +2048,22 @@ class TestClient: headers (dict): Default headers to set on every request (default ``None``). These defaults may be overridden by passing values for the same headers to one of the ``simulate_*()`` methods. - - Attributes: - app: The app that this client instance was configured to use. - """ # NOTE(aryaniyaps): Prevent pytest from collecting tests on the class. __test__ = False + # NOTE(caseit): while any asgi/wsgi app is accept, type this as a falcon + # app for user convenience + app: falcon.App + """The app that this client instance was configured to use.""" + def __init__( self, app: Callable[..., Any], # accept any asgi/wsgi app - headers: Optional[Headers] = None, + headers: Optional[HeaderMapping] = None, ) -> None: - self.app = app + self.app = app # type: ignore[assignment] self._default_headers = headers self._conductor: Optional[ASGIConductor] = None @@ -2193,9 +2153,9 @@ def simulate_request(self, *args: Any, **kwargs: Any) -> Result: if self._default_headers: # NOTE(kgriffs): Handle the case in which headers is explicitly # set to None. - additional_headers = kwargs.get('headers', {}) or {} + additional_headers = kwargs.get('headers') or {} - merged_headers = self._default_headers.copy() + merged_headers = dict(self._default_headers) merged_headers.update(additional_headers) kwargs['headers'] = merged_headers @@ -2275,11 +2235,13 @@ def _prepare_sim_args( params: Optional[Mapping[str, Any]], params_csv: bool, content_type: Optional[str], - headers: Optional[Headers], + headers: Optional[HeaderArg], body: Optional[Union[str, bytes]], json: Optional[Any], extras: Optional[Mapping[str, Any]], -) -> Tuple[str, str, Optional[Headers], Optional[Union[str, bytes]], Mapping[str, Any]]: +) -> Tuple[ + str, str, Optional[HeaderArg], Optional[Union[str, bytes]], Mapping[str, Any] +]: if not path.startswith('/'): raise ValueError("path must start with '/'") @@ -2304,12 +2266,12 @@ def _prepare_sim_args( ) if content_type is not None: - headers = headers or {} + headers = dict(headers or {}) headers['Content-Type'] = content_type if json is not None: body = json_module.dumps(json, ensure_ascii=False) - headers = headers or {} + headers = dict(headers or {}) headers['Content-Type'] = MEDIA_JSON return path, query_string, headers, body, extras diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index fa00920b2..97392d57a 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -56,6 +56,9 @@ import falcon from falcon import errors as falcon_errors +from falcon._typing import CookieArg +from falcon._typing import HeaderArg +from falcon._typing import ResponseStatus import falcon.asgi from falcon.asgi_spec import AsgiEvent from falcon.asgi_spec import EventType @@ -63,10 +66,6 @@ from falcon.asgi_spec import WSCloseCode from falcon.constants import SINGLETON_HEADERS import falcon.request -from falcon.typing import CookieArg -from falcon.typing import HeaderArg -from falcon.typing import HeaderList -from falcon.typing import ResponseStatus from falcon.util import code_to_http_status from falcon.util import uri from falcon.util.mediatypes import parse_header @@ -146,10 +145,6 @@ class ASGIRequestEventEmitter: ``0`` is treated as a special case, and will result in an ``'http.disconnect'`` event being immediately emitted (rather than first emitting an ``'http.request'`` event). - - Attributes: - disconnected (bool): Returns ``True`` if the simulated client - connection is in a "disconnected" state. """ # TODO(kgriffs): If this pattern later becomes useful elsewhere, @@ -187,6 +182,9 @@ def __init__( @property def disconnected(self) -> bool: + """Returns ``True`` if the simulated client connection is in a + "disconnected" state. + """ # noqa: D205 return self._disconnected or (self._disconnect_at <= time.time()) def disconnect(self, exhaust_body: Optional[bool] = None) -> None: @@ -294,20 +292,6 @@ def _toggle_branch(self, name: str) -> bool: class ASGIResponseEventCollector: """Collects and validates ASGI events returned by an app. - Attributes: - events (iterable): An iterable of events that were emitted by - the app, collected as-is from the app. - headers (iterable): An iterable of (str, str) tuples representing - the ISO-8859-1 decoded headers emitted by the app in the body of - the ``'http.response.start'`` event. - status (int): HTTP status code emitted by the app in the body of - the ``'http.response.start'`` event. - body_chunks (iterable): An iterable of ``bytes`` objects emitted - by the app via ``'http.response.body'`` events. - more_body (bool): Whether or not the app expects to emit more - body chunks. Will be ``None`` if unknown (i.e., the app has - not yet emitted any ``'http.response.body'`` events.) - Raises: TypeError: An event field emitted by the app was of an unexpected type. ValueError: Invalid event name or field value. @@ -325,12 +309,35 @@ class ASGIResponseEventCollector: _HEADER_NAME_RE = re.compile(rb'^[a-zA-Z][a-zA-Z0-9\-_]*$') _BAD_HEADER_VALUE_RE = re.compile(rb'[\000-\037]') + events: List[AsgiEvent] + """An iterable of events that were emitted by the app, + collected as-is from the app. + """ + headers: List[Tuple[str, str]] + """An iterable of (str, str) tuples representing the ISO-8859-1 decoded + headers emitted by the app in the body of the ``'http.response.start'`` event. + """ + status: Optional[ResponseStatus] + """HTTP status code emitted by the app in the body of the + ``'http.response.start'`` event. + """ + body_chunks: List[bytes] + """An iterable of ``bytes`` objects emitted by the app via + ``'http.response.body'`` events. + """ + more_body: Optional[bool] + """Whether or not the app expects to emit more body chunks. + + Will be ``None`` if unknown (i.e., the app has not yet emitted + any ``'http.response.body'`` events.) + """ + def __init__(self) -> None: - self.events: List[AsgiEvent] = [] - self.headers: HeaderList = [] - self.status: Optional[ResponseStatus] = None - self.body_chunks: list[bytes] = [] - self.more_body: Optional[bool] = None + self.events = [] + self.headers = [] + self.status = None + self.body_chunks = [] + self.more_body = None async def collect(self, event: AsgiEvent) -> None: if self.more_body is False: @@ -409,23 +416,6 @@ class ASGIWebSocketSimulator: The ASGIWebSocketSimulator class is not designed to be instantiated directly; rather it should be obtained via :meth:`~falcon.testing.ASGIConductor.simulate_ws`. - - Attributes: - ready (bool): ``True`` if the WebSocket connection has been - accepted and the client is still connected, ``False`` otherwise. - closed (bool): ``True`` if the WebSocket connection has been - denied or closed by the app, or the client has disconnected. - close_code (int): The WebSocket close code provided by the app if - the connection is closed, or ``None`` if the connection is open. - close_reason (str): The WebSocket close reason provided by the app if - the connection is closed, or ``None`` if the connection is open. - subprotocol (str): The subprotocol the app wishes to accept, or - ``None`` if not specified. - headers (Iterable[Iterable[bytes]]): An iterable of ``[name, value]`` - two-item iterables, where *name* is the header name, and *value* is - the header value for each header returned by the app when - it accepted the WebSocket connection. This property resolves to - ``None`` if the connection has not been accepted. """ _DEFAULT_WAIT_READY_TIMEOUT = 5 @@ -446,26 +436,46 @@ def __init__(self) -> None: @property def ready(self) -> bool: + """``True`` if the WebSocket connection has been accepted and the client is + still connected, ``False`` otherwise. + """ # noqa: D205 return self._state == _WebSocketState.ACCEPTED @property def closed(self) -> bool: + """``True`` if the WebSocket connection has been denied or closed by the app, + or the client has disconnected. + """ # noqa: D205 return self._state in {_WebSocketState.DENIED, _WebSocketState.CLOSED} @property def close_code(self) -> Optional[int]: + """The WebSocket close code provided by the app if the connection is closed. + + Returns ``None`` if the connection is still open. + """ return self._close_code @property def close_reason(self) -> Optional[str]: + """The WebSocket close reason provided by the app if the connection is closed. + + Returns ``None`` if the connection is still open. + """ return self._close_reason @property def subprotocol(self) -> Optional[str]: + """The subprotocol the app wishes to accept, or ``None`` if not specified.""" return self._accepted_subprotocol @property def headers(self) -> Optional[List[Tuple[bytes, bytes]]]: + """An iterable of ``[name, value]`` two-item tuples, where *name* is the + header name, and *value* is the header value for each header returned by + the app when it accepted the WebSocket connection. + This property resolves to ``None`` if the connection has not been accepted. + """ # noqa: D205 return self._accepted_headers async def wait_ready(self, timeout: Optional[int] = None) -> None: @@ -1171,7 +1181,7 @@ def create_environ( f'falcon-client/{falcon.__version__}' root_path (str): Value for the ``SCRIPT_NAME`` environ variable, described in - PEP-333: 'The initial portion of the request URL's "path" that + PEP-3333: 'The initial portion of the request URL's "path" that corresponds to the application object, so that the application knows its virtual "location". This may be an empty string, if the application corresponds to the "root" of the server.' (default ``''``) diff --git a/falcon/testing/resource.py b/falcon/testing/resource.py index 9896a9da6..2728dc2eb 100644 --- a/falcon/testing/resource.py +++ b/falcon/testing/resource.py @@ -32,9 +32,9 @@ if typing.TYPE_CHECKING: # pragma: no cover from falcon import app as wsgi + from falcon._typing import HeaderArg + from falcon._typing import Resource from falcon.asgi import app as asgi - from falcon.typing import HeaderArg - from falcon.typing import Resource def capture_responder_args( @@ -164,16 +164,28 @@ class SimpleTestResource: *json* or *body* may be specified, but not both. headers (dict): Default set of additional headers to include in responses + """ + + captured_req: typing.Optional[typing.Union[wsgi.Request, asgi.Request]] + """The last Request object passed into any one of the responder methods.""" + captured_resp: typing.Optional[typing.Union[wsgi.Response, asgi.Response]] + """The last Response object passed into any one of the responder methods.""" + + captured_kwargs: typing.Optional[typing.Any] + """The last dictionary of kwargs, beyond ``req`` and ``resp``, that were + passed into any one of the responder methods.""" + + captured_req_media: typing.Optional[typing.Any] + """The last Request media provided to any one of the responder methods. - Attributes: - called (bool): Whether or not a req/resp was captured. - captured_req (falcon.Request): The last Request object passed - into any one of the responder methods. - captured_resp (falcon.Response): The last Response object passed - into any one of the responder methods. - captured_kwargs (dict): The last dictionary of kwargs, beyond - ``req`` and ``resp``, that were passed into any one of the - responder methods. + This value is only captured when the ``'capture-req-media'`` header is + set on the request. + """ + captured_req_body: typing.Optional[bytes] + """The last Request body provided to any one of the responder methods. + + This value is only captured when the ``'capture-req-body-bytes'`` header is + set on the request. The value of the header is the number of bytes to read. """ def __init__( @@ -198,18 +210,15 @@ def __init__( else: self._default_body = body - self.captured_req: typing.Optional[typing.Union[wsgi.Request, asgi.Request]] = ( - None - ) - self.captured_resp: typing.Optional[ - typing.Union[wsgi.Response, asgi.Response] - ] = None - self.captured_kwargs: typing.Optional[typing.Any] = None - self.captured_req_media: typing.Optional[typing.Any] = None - self.captured_req_body: typing.Optional[bytes] = None + self.captured_req = None + self.captured_resp = None + self.captured_kwargs = None + self.captured_req_media = None + self.captured_req_body = None @property def called(self) -> bool: + """Whether or not a req/resp was captured.""" return self.captured_req is not None @falcon.before(capture_responder_args) @@ -253,16 +262,6 @@ class SimpleTestResourceAsync(SimpleTestResource): *json* or *body* may be specified, but not both. headers (dict): Default set of additional headers to include in responses - - Attributes: - called (bool): Whether or not a req/resp was captured. - captured_req (falcon.Request): The last Request object passed - into any one of the responder methods. - captured_resp (falcon.Response): The last Response object passed - into any one of the responder methods. - captured_kwargs (dict): The last dictionary of kwargs, beyond - ``req`` and ``resp``, that were passed into any one of the - responder methods. """ @falcon.before(capture_responder_args_async) diff --git a/falcon/testing/srmock.py b/falcon/testing/srmock.py index 97decb90d..6e38236fc 100644 --- a/falcon/testing/srmock.py +++ b/falcon/testing/srmock.py @@ -23,33 +23,30 @@ from typing import Any, Optional from falcon import util -from falcon.typing import HeaderList +from falcon._typing import HeaderIter +from falcon.typing import Headers class StartResponseMock: - """Mock object representing a WSGI `start_response` callable. + """Mock object representing a WSGI `start_response` callable.""" - Attributes: - call_count (int): Number of times `start_response` was called. - status (str): HTTP status line, e.g. '785 TPS Cover Sheet - not attached'. - headers (list): Raw headers list passed to `start_response`, - per PEP-333. - headers_dict (dict): Headers as a case-insensitive - ``dict``-like object, instead of a ``list``. - - """ + status: Optional[str] + """HTTP status line, e.g. '785 TPS Cover Sheet not attached'.""" + headers: Optional[HeaderIter] + """Raw headers list passed to `start_response`, per PEP-3333.""" + headers_dict: Headers + """Headers as a case-insensitive ``dict``-like object, instead of a ``list``.""" def __init__(self) -> None: self._called = 0 - self.status: Optional[str] = None - self.headers: Optional[HeaderList] = None + self.status = None + self.headers = None self.exc_info: Optional[Any] = None def __call__( self, status: str, - headers: HeaderList, + headers: HeaderIter, exc_info: Optional[Any] = None, ) -> Any: """Implement the PEP-3333 `start_response` protocol.""" @@ -62,9 +59,10 @@ def __call__( # worry about the case-insensitive nature of header names. self.headers = [(name.lower(), value) for name, value in headers] - self.headers_dict = util.CaseInsensitiveDict(headers) + self.headers_dict = util.CaseInsensitiveDict(headers) # type: ignore[assignment] self.exc_info = exc_info @property def call_count(self) -> int: + """Number of times `start_response` was called.""" return self._called diff --git a/falcon/testing/test_case.py b/falcon/testing/test_case.py index 368ce0978..632b662b7 100644 --- a/falcon/testing/test_case.py +++ b/falcon/testing/test_case.py @@ -45,38 +45,39 @@ class TestCase(unittest.TestCase, TestClient): Simply inherit from this class in your test case classes instead of :class:`unittest.TestCase` or :class:`testtools.TestCase`. + """ - Attributes: - app (object): A WSGI or ASGI application to target when simulating - requests (defaults to ``falcon.App()``). When testing your - application, you will need to set this to your own instance - of :class:`falcon.App` or :class:`falcon.asgi.App`. For - example:: + # NOTE(vytas): Here we have to restore __test__ to allow collecting tests! + __test__ = True - from falcon import testing - import myapp + app: falcon.App + """A WSGI or ASGI application to target when simulating + requests (defaults to ``falcon.App()``). When testing your + application, you will need to set this to your own instance + of :class:`falcon.App` or :class:`falcon.asgi.App`. For + example:: + from falcon import testing + import myapp - class MyTestCase(testing.TestCase): - def setUp(self): - super(MyTestCase, self).setUp() - # Assume the hypothetical `myapp` package has a - # function called `create()` to initialize and - # return a `falcon.App` instance. - self.app = myapp.create() + class MyTestCase(testing.TestCase): + def setUp(self): + super(MyTestCase, self).setUp() + # Assume the hypothetical `myapp` package has a + # function called `create()` to initialize and + # return a `falcon.App` instance. + self.app = myapp.create() - class TestMyApp(MyTestCase): - def test_get_message(self): - doc = {'message': 'Hello world!'} - result = self.simulate_get('/messages/42') - self.assertEqual(result.json, doc) - """ + class TestMyApp(MyTestCase): + def test_get_message(self): + doc = {'message': 'Hello world!'} - # NOTE(vytas): Here we have to restore __test__ to allow collecting tests! - __test__ = True + result = self.simulate_get('/messages/42') + self.assertEqual(result.json, doc) + """ def setUp(self) -> None: super(TestCase, self).setUp() diff --git a/falcon/typing.py b/falcon/typing.py index 881e92766..5ff4f47f1 100644 --- a/falcon/typing.py +++ b/falcon/typing.py @@ -1,4 +1,4 @@ -# Copyright 2021-2023 by Vytautas Liuolia. +# Copyright 2024 by Federico Caselli # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -11,215 +11,35 @@ # 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. -"""Shorthand definitions for more complex types.""" +"""Module that defines public Falcon type definitions.""" from __future__ import annotations -from enum import auto -from enum import Enum -import http -from http.cookiejar import Cookie -import sys -from typing import ( - Any, - AsyncIterator, - Awaitable, - Callable, - Dict, - List, - Literal, - Mapping, - Optional, - Pattern, - Protocol, - Tuple, - TYPE_CHECKING, - TypeVar, - Union, -) - -# NOTE(vytas): Mypy still struggles to handle a conditional import in the EAFP -# fashion, so we branch on Py version instead (which it does understand). -if sys.version_info >= (3, 11): - from wsgiref.types import StartResponse as StartResponse - from wsgiref.types import WSGIEnvironment as WSGIEnvironment -else: - WSGIEnvironment = Dict[str, Any] - StartResponse = Callable[[str, List[Tuple[str, str]]], Callable[[bytes], None]] +from typing import AsyncIterator, Dict, Optional, Protocol, TYPE_CHECKING 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 - from falcon.http_error import HTTPError - from falcon.request import Request - from falcon.response import Response - - -class _Missing(Enum): - MISSING = auto() - - -_T = TypeVar('_T') -MISSING = _Missing.MISSING -MissingOr = Union[Literal[_Missing.MISSING], _T] - -Link = Dict[str, str] -CookieArg = Mapping[str, Union[str, Cookie]] -# Error handlers -ErrorHandler = Callable[['Request', 'Response', BaseException, Dict[str, Any]], None] - - -class AsgiErrorHandler(Protocol): - async def __call__( - self, - req: AsgiRequest, - resp: Optional[AsgiResponse], - error: BaseException, - params: Dict[str, Any], - *, - ws: Optional[WebSocket] = ..., - ) -> None: ... - - -# Error serializers -ErrorSerializer = Callable[['Request', 'Response', 'HTTPError'], None] - -JSONSerializable = Union[ - Dict[str, 'JSONSerializable'], - List['JSONSerializable'], - Tuple['JSONSerializable', ...], - bool, - float, - int, - str, - None, -] -# Sinks -SinkPrefix = Union[str, Pattern[str]] - - -class SinkCallable(Protocol): - def __call__(self, req: Request, resp: Response, **kwargs: str) -> None: ... - - -class AsgiSinkCallable(Protocol): - async def __call__( - self, req: AsgiRequest, resp: AsgiResponse, **kwargs: str - ) -> None: ... - - -# TODO(vytas): Is it possible to specify a Callable or a Protocol that defines -# type hints for the two first parameters, but accepts any number of keyword -# arguments afterwords? -# class SinkCallable(Protocol): -# def __call__(sef, req: Request, resp: Response, ): ... Headers = Dict[str, str] -HeaderList = List[Tuple[str, str]] -HeaderArg = Union[Headers, HeaderList] -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): - def __call__( - self, - resource: Resource, - req: Request, - resp: Response, - **kwargs: Any, - ) -> None: ... +"""Headers dictionary returned by the framework.""" # WSGI class ReadableIO(Protocol): - def read(self, n: Optional[int] = ..., /) -> bytes: ... - + """File like protocol that defines only a read method.""" -ProcessRequestMethod = Callable[['Request', 'Response'], None] -ProcessResourceMethod = Callable[ - ['Request', 'Response', Resource, Dict[str, Any]], None -] -ProcessResponseMethod = Callable[['Request', 'Response', Resource, bool], None] - - -class ResponderCallable(Protocol): - def __call__(self, req: Request, resp: Response, **kwargs: Any) -> None: ... + def read(self, n: Optional[int] = ..., /) -> bytes: ... # ASGI class AsyncReadableIO(Protocol): + """Async file like protocol that defines only a read method and is iterable.""" + async def read(self, n: Optional[int] = ..., /) -> bytes: ... def __aiter__(self) -> AsyncIterator[bytes]: ... -class AsgiResponderMethod(Protocol): - async def __call__( - self, - resource: Resource, - req: AsgiRequest, - resp: AsgiResponse, - **kwargs: Any, - ) -> None: ... - - -AsgiReceive = Callable[[], Awaitable['AsgiEvent']] -AsgiSend = Callable[['AsgiSendMsg'], Awaitable[None]] -AsgiProcessRequestMethod = Callable[['AsgiRequest', 'AsgiResponse'], Awaitable[None]] -AsgiProcessResourceMethod = Callable[ - ['AsgiRequest', 'AsgiResponse', Resource, Dict[str, Any]], Awaitable[None] -] -AsgiProcessResponseMethod = Callable[ - ['AsgiRequest', 'AsgiResponse', Resource, bool], Awaitable[None] -] -AsgiProcessRequestWsMethod = Callable[['AsgiRequest', 'WebSocket'], Awaitable[None]] -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): - async def __call__( - self, req: AsgiRequest, resp: AsgiResponse, **kwargs: Any - ) -> None: ... - - -class AsgiResponderWsCallable(Protocol): - async def __call__( - self, req: AsgiRequest, ws: WebSocket, **kwargs: Any - ) -> None: ... - - -# Routing - -MethodDict = Union[ - Dict[str, ResponderCallable], - Dict[str, Union[AsgiResponderCallable, AsgiResponderWsCallable]], -] - - -class FindMethod(Protocol): - def __call__( - self, uri: str, req: Optional[Request] - ) -> Optional[Tuple[object, MethodDict, Dict[str, Any], Optional[str]]]: ... - - -# Media -class SerializeSync(Protocol): - def __call__(self, media: Any, content_type: Optional[str] = ...) -> bytes: ... - - -DeserializeSync = Callable[[bytes], Any] - -Responder = Union[ResponderMethod, AsgiResponderMethod] +"""Async iterator or generator that generates Server-Sent Events +returning :class:`falcon.asgi.SSEvent` insatnces. +""" diff --git a/falcon/util/misc.py b/falcon/util/misc.py index b862a6704..1f68626ca 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -332,7 +332,7 @@ def get_argnames(func: Callable[..., Any]) -> List[str]: return args -def secure_filename(filename: Optional[str]) -> str: +def secure_filename(filename: str) -> str: """Sanitize the provided `filename` to contain only ASCII characters. Only ASCII alphanumerals, ``'.'``, ``'-'`` and ``'_'`` are allowed for diff --git a/falcon/util/structures.py b/falcon/util/structures.py index 020d43471..3a9716f53 100644 --- a/falcon/util/structures.py +++ b/falcon/util/structures.py @@ -259,13 +259,10 @@ def on_get(self, req, resp): resp.status = falcon.HTTP_200 (See also: RFC 7232) - - Attributes: - is_weak (bool): ``True`` if the entity-tag is weak, otherwise ``False``. - """ - is_weak = False + is_weak: bool = False + """``True`` if the entity-tag is weak, otherwise ``False``.""" def strong_compare(self, other: ETag) -> bool: """Perform a strong entity-tag comparison. diff --git a/tests/test_after_hooks.py b/tests/test_after_hooks.py index f6e53769f..25ad975dd 100644 --- a/tests/test_after_hooks.py +++ b/tests/test_after_hooks.py @@ -7,7 +7,7 @@ import falcon from falcon import app as wsgi from falcon import testing -from falcon.typing import Resource +from falcon._typing import Resource # -------------------------------------------------------------------- # Fixtures diff --git a/tests/test_boundedstream.py b/tests/test_boundedstream.py index 3c9ec2091..4845fcc53 100644 --- a/tests/test_boundedstream.py +++ b/tests/test_boundedstream.py @@ -3,6 +3,7 @@ import pytest from falcon.stream import BoundedStream +from falcon.util.deprecation import DeprecatedWarning @pytest.fixture @@ -15,3 +16,14 @@ def test_not_writable(bounded_stream): with pytest.raises(IOError): bounded_stream.write(b'something something') + + +def test_exhausted(): + bs = BoundedStream(io.BytesIO(b'foobar'), 6) + assert not bs.eof + with pytest.warns(DeprecatedWarning, match='Use `eof` instead'): + assert not bs.is_exhausted + assert bs.read() == b'foobar' + assert bs.eof + with pytest.warns(DeprecatedWarning, match='Use `eof` instead'): + assert bs.is_exhausted diff --git a/tests/test_media_multipart.py b/tests/test_media_multipart.py index 7c408e32c..cd269b119 100644 --- a/tests/test_media_multipart.py +++ b/tests/test_media_multipart.py @@ -300,11 +300,14 @@ def test_body_part_properties(): assert part.secure_filename == part.filename -def test_empty_filename(): +def test_empty_or_missing_filename(): data = ( b'--a0d738bcdb30449eb0d13f4b72c2897e\r\n' b'Content-Disposition: form-data; name="file"; filename=\r\n\r\n' b'An empty filename.\r\n' + b'--a0d738bcdb30449eb0d13f4b72c2897e\r\n' + b'Content-Disposition: form-data; name="no file";\r\n\r\n' + b'No filename.\r\n' b'--a0d738bcdb30449eb0d13f4b72c2897e--\r\n' ) @@ -313,10 +316,16 @@ def test_empty_filename(): stream = BufferedReader(io.BytesIO(data).read, len(data)) form = handler.deserialize(stream, content_type, len(data)) + parts = 0 for part in form: - assert part.filename == '' + parts += 1 + if part.name == 'file': + assert part.filename == '' + else: + assert part.filename is None with pytest.raises(falcon.MediaMalformedError): part.secure_filename + assert parts == 2 class MultipartAnalyzer: