From a4e8ad4e937808f79a5b7474c21266bfc9eee7e9 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sun, 12 Nov 2023 20:21:29 +0100 Subject: [PATCH 01/14] chore(readthedocs): add `.readthedocs.yaml` as required (#2185) --- .readthedocs.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..5d3177879 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,21 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.10" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# We recommend specifying your dependencies to enable reproducible builds: +# https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - requirements: requirements/docs From 52db96d2921a60bdeac8e56c2d1db14a49b737fb Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Fri, 1 Dec 2023 22:33:16 +0100 Subject: [PATCH 02/14] chore(alabaster): disable GitHub banner (#2191) (As it links to a removed S3 object.) See also: https://github.com/sphinx-doc/alabaster/issues/166. --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 772521b69..4778378ee 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -168,7 +168,7 @@ 'github_user': 'falconry', 'github_repo': 'falcon', 'github_button': False, - 'github_banner': not dash_build, + 'github_banner': False, 'fixed_sidebar': False, 'show_powered_by': False, 'extra_nav_links': OrderedDict( From 241205e2574c48388118f0edd12baee94c390ab1 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Tue, 5 Dec 2023 22:29:41 +0100 Subject: [PATCH 03/14] chore: incorporate 3.1.2 & 3.1.3 releases (#2195) * chore: incorporate 3.1.2 & 3.1.3 releases * chore(s390x): use older urllib3 on 3.7 * docs(changes): update notes on platform support --- AUTHORS | 1 + docs/_newsfragments/2051.bugfix.rst | 4 --- docs/_newsfragments/2146.bugfix.rst | 6 ---- docs/_newsfragments/2157.bugfix.rst | 1 - docs/changes/3.1.2.rst | 54 +++++++++++++++++++++++++++++ docs/changes/3.1.3.rst | 11 ++++++ docs/changes/4.0.0.rst | 5 +++ docs/changes/index.rst | 2 ++ requirements/tests | 6 +++- 9 files changed, 78 insertions(+), 12 deletions(-) delete mode 100644 docs/_newsfragments/2051.bugfix.rst delete mode 100644 docs/_newsfragments/2146.bugfix.rst delete mode 100644 docs/_newsfragments/2157.bugfix.rst create mode 100644 docs/changes/3.1.2.rst create mode 100644 docs/changes/3.1.3.rst diff --git a/AUTHORS b/AUTHORS index 96702c6a0..fece25507 100644 --- a/AUTHORS +++ b/AUTHORS @@ -131,6 +131,7 @@ listed below by date of first contribution: * John G G (john-g-g) * Aryan Iyappan (aryaniyaps) * Eujin Ong (euj1n0ng) +* Libor Jelínek (liborjelinek) (et al.) diff --git a/docs/_newsfragments/2051.bugfix.rst b/docs/_newsfragments/2051.bugfix.rst deleted file mode 100644 index 7f51c1097..000000000 --- a/docs/_newsfragments/2051.bugfix.rst +++ /dev/null @@ -1,4 +0,0 @@ -Some essential files were unintentionally omitted from the source distribution -archive, rendering it unsuitable to run the test suite off. -This has been fixed, and the ``sdist`` tarball should now be usable as a base -for packaging Falcon in OS distributions. diff --git a/docs/_newsfragments/2146.bugfix.rst b/docs/_newsfragments/2146.bugfix.rst deleted file mode 100644 index ab27e52ba..000000000 --- a/docs/_newsfragments/2146.bugfix.rst +++ /dev/null @@ -1,6 +0,0 @@ - -:ref:`WebSocket ` implementation has been fixed to properly handle -:class:`~falcon.HTTPError` and :class:`~falcon.HTTPStatus` exceptions raised by -custom :func:`error handlers `. -The WebSocket connection is now correctly closed with an appropriate code -instead of bubbling up an unhandled error to the application server. diff --git a/docs/_newsfragments/2157.bugfix.rst b/docs/_newsfragments/2157.bugfix.rst deleted file mode 100644 index f2a33e7f1..000000000 --- a/docs/_newsfragments/2157.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -:class:`~falcon.testing.TestCase` mimics behavior of real WSGI servers following WSGI spec where is said that ``PATH_INFO`` CGI variable for WSGI app is already percent-decoded. However, this breaks routing if slash (encoded as ``%2F``) is part of path element, not a path separator, as explained in the FAQ :ref:`routing_encoded_slashes`. The workaround based on some WSGI servers' non-standard CGI variables described in :ref:`raw_url_path_recipe` recipe breaks tests because :py:func:`falcon.testing.helpers.create_environ` hard-code CGI variable ``RAW_URI`` to ``/`` instead to real path *before* percent-decoding. \ No newline at end of file diff --git a/docs/changes/3.1.2.rst b/docs/changes/3.1.2.rst new file mode 100644 index 000000000..9720b9161 --- /dev/null +++ b/docs/changes/3.1.2.rst @@ -0,0 +1,54 @@ +Changelog for Falcon 3.1.2 +========================== + +Summary +------- + +This is a minor point release fixing a couple of high impact bugs, +as well as publishing binary wheels for the recently released CPython 3.12. + + +Changes to Supported Platforms +------------------------------ + +- Falcon is now supported (including binary wheels) on CPython 3.12. + A couple of remaining stdlib deprecations from 3.11 and 3.12 will be + addressed in Falcon 4.0. +- As with the previous release, Python 3.5 & 3.6 remain deprecated and + will no longer be supported in Falcon 4.0. +- EOL Python 3.7 will no longer be actively supported in 4.0, but the framework + should still continue to install from source. We may remove the support for + 3.7 altogether later in the 4.x series if we are faced with incompatible + ecosystem changes in typing, Cython, etc. + + +Fixed +----- + +- Some essential files were unintentionally omitted from the source distribution + archive, rendering it unsuitable to run the test suite off. + This has been fixed, and the ``sdist`` tarball should now be usable as a base + for packaging Falcon in OS distributions. (`#2051 `__) +- :ref:`WebSocket ` implementation has been fixed to properly handle + :class:`~falcon.HTTPError` and :class:`~falcon.HTTPStatus` exceptions raised by + custom :func:`error handlers `. + The WebSocket connection is now correctly closed with an appropriate code + instead of bubbling up an unhandled error to the application server. (`#2146 `__) +- Falcon's :class:`~falcon.testing.TestClient` mimics the behavior of real WSGI + servers (and the WSGI spec) by presenting the ``PATH_INFO`` CGI variable + already in the percent-decoded form. However, the client also used to + indiscriminately set the non-standard ``RAW_URI`` CGI variable to ``/``, which + made writing tests for apps :ref:`decoding raw URL path ` + cumbersome. This has been fixed, and the raw path of a simulated request is now + preserved in ``RAW_URI``. (`#2157 `__) + + +Contributors to this Release +---------------------------- + +Many thanks to those who contributed to this bugfix release: + +- `CaselIT `__ +- `kgriffs `__ +- `liborjelinek `__ +- `vytas7 `__ diff --git a/docs/changes/3.1.3.rst b/docs/changes/3.1.3.rst new file mode 100644 index 000000000..416e77133 --- /dev/null +++ b/docs/changes/3.1.3.rst @@ -0,0 +1,11 @@ +Changelog for Falcon 3.1.3 +========================== + +Summary +------- + +This is a minor bugfix release that only pins the ``pytest-asyncio`` test +dependency in order to prevent an incompatible version from interfering with +the build workflow. + +This release is otherwise identical to :doc:`Falcon 3.1.2 <3.1.2>`. diff --git a/docs/changes/4.0.0.rst b/docs/changes/4.0.0.rst index 457343885..93359c678 100644 --- a/docs/changes/4.0.0.rst +++ b/docs/changes/4.0.0.rst @@ -13,7 +13,12 @@ Changes to Supported Platforms ------------------------------ - CPython 3.11 is now fully supported. (`#2072 `__) +- CPython 3.12 will be fully supported. (`#2196 `__) - End-of-life Python 3.5 & 3.6 are no longer supported. (`#2074 `__) +- Python 3.7 is no longer actively supported, but the framework should still + continue to install from source. We may remove the support for 3.7 altogether + later in the 4.x series if we are faced with incompatible ecosystem changes + in typing, Cython, etc. .. towncrier release notes start diff --git a/docs/changes/index.rst b/docs/changes/index.rst index f122227ae..06371e311 100644 --- a/docs/changes/index.rst +++ b/docs/changes/index.rst @@ -4,6 +4,8 @@ Changelogs .. toctree:: 4.0.0 <4.0.0> + 3.1.3 <3.1.3> + 3.1.2 <3.1.2> 3.1.1 <3.1.1> 3.1.0 <3.1.0> 3.0.1 <3.0.1> diff --git a/requirements/tests b/requirements/tests index 187bb054e..19b34bcd3 100644 --- a/requirements/tests +++ b/requirements/tests @@ -7,7 +7,8 @@ requests testtools; python_version < '3.10' # ASGI Specific (daphne is installed on a its own tox env) -pytest-asyncio +# TODO(vytas): Some ASGI tests hang with pytest-asyncio-0.23 on 3.8 & 3.9. +pytest-asyncio < 0.22.0 aiofiles httpx uvicorn >= 0.17.0 @@ -24,3 +25,6 @@ python-rapidjson; platform_machine != 's390x' and platform_machine != 'aarch64' # wheels are missing some EoL interpreters and non-x86 platforms; build would fail unless rust is available orjson; platform_python_implementation != 'PyPy' and platform_machine != 's390x' and platform_machine != 'aarch64' + +# Images for 3.7 on emulated architectures seem to only have OpenSSL 1.0.2 +urllib3 < 2.0; python_version <= '3.7' From 2382d44c99a5b7f80b2af51a833c5ad679377f48 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Thu, 14 Dec 2023 20:55:30 +0100 Subject: [PATCH 04/14] feat(add_error_handler): deprecate the Falcon 1.x signature shim (#2197) --- falcon/app.py | 7 +++++++ tests/test_error_handlers.py | 10 +++++++--- tests/test_middleware.py | 10 ++++++++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/falcon/app.py b/falcon/app.py index 442d41676..34186b418 100644 --- a/falcon/app.py +++ b/falcon/app.py @@ -20,6 +20,7 @@ import re import traceback from typing import Callable, Iterable, Optional, Tuple, Type, Union +import warnings from falcon import app_helpers as helpers from falcon import constants @@ -828,6 +829,12 @@ def handler(req, resp, ex, params): ('ex',), ('exception',), ) or arg_names[1:3] in (('req', 'resp'), ('request', 'response')): + warnings.warn( + f'handler is using a deprecated signature; please order its ' + f'arguments as {handler.__qualname__}(req, resp, ex, params). ' + f'This compatibility shim will be removed in Falcon 5.0.', + deprecation.DeprecatedWarning, + ) handler = wrap_old_handler(handler) exception_tuple: tuple diff --git a/tests/test_error_handlers.py b/tests/test_error_handlers.py index c43589dbb..06282bdc7 100644 --- a/tests/test_error_handlers.py +++ b/tests/test_error_handlers.py @@ -3,6 +3,7 @@ import falcon from falcon import constants, testing import falcon.asgi +from falcon.util.deprecation import DeprecatedWarning from _util import create_app, disable_asgi_non_coroutine_wrapping # NOQA @@ -212,9 +213,12 @@ def legacy_handler3(err, rq, rs, prms): app.add_route('/', ErroredClassResource()) client = testing.TestClient(app) - client.app.add_error_handler(Exception, legacy_handler1) - client.app.add_error_handler(CustomBaseException, legacy_handler2) - client.app.add_error_handler(CustomException, legacy_handler3) + with pytest.warns(DeprecatedWarning, match='deprecated signature'): + client.app.add_error_handler(Exception, legacy_handler1) + with pytest.warns(DeprecatedWarning, match='deprecated signature'): + client.app.add_error_handler(CustomBaseException, legacy_handler2) + with pytest.warns(DeprecatedWarning, match='deprecated signature'): + client.app.add_error_handler(CustomException, legacy_handler3) client.simulate_delete() client.simulate_get() diff --git a/tests/test_middleware.py b/tests/test_middleware.py index c4aecd3e6..52516622e 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -9,6 +9,7 @@ import falcon import falcon.errors import falcon.testing as testing +from falcon.util.deprecation import DeprecatedWarning from falcon.util.misc import _utcnow from _util import create_app # NOQA @@ -900,14 +901,19 @@ def test_http_status_raised_from_error_handler(self, asgi): def _http_error_handler(error, req, resp, params): raise falcon.HTTPStatus(falcon.HTTP_201) - async def _http_error_handler_async(error, req, resp, params): + async def _http_error_handler_async(req, resp, error, params): raise falcon.HTTPStatus(falcon.HTTP_201) h = _http_error_handler_async if asgi else _http_error_handler # NOTE(kgriffs): This will take precedence over the default # handler for facon.HTTPError. - app.add_error_handler(falcon.HTTPError, h) + if asgi: + # NOTE(vytas): The ASGI flavour supports no reordering shim. + app.add_error_handler(falcon.HTTPError, h) + else: + with pytest.warns(DeprecatedWarning, match='deprecated signature'): + app.add_error_handler(falcon.HTTPError, h) response = client.simulate_request(path='/', method='POST') assert response.status == falcon.HTTP_201 From 0aac9508a68bf7530d849a1c887678475cbc7cf3 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sun, 17 Dec 2023 21:12:45 +0100 Subject: [PATCH 05/14] chore: make contributor's checklist pass on CPython 3.12 (#2199) * chore(py312): fix docs build errors/warnings on CPython 3.12 * test(ws): add missing coverage on py312 * chore(mintest): add py312 to CI * chore: add missing setuptools dep * chore: leave mintest.yaml only for master merge CI after demonstration --- .github/workflows/mintest.yaml | 3 ++- docs/conf.py | 2 +- docs/ext/rfc.py | 2 +- falcon/asgi/ws.py | 4 +++- falcon/media/handlers.py | 3 ++- falcon/testing/helpers.py | 17 ++++++++++------- tests/asgi/test_ws.py | 29 +++++++++++++++++++++++++++++ 7 files changed, 48 insertions(+), 12 deletions(-) diff --git a/.github/workflows/mintest.yaml b/.github/workflows/mintest.yaml index 6fbe2c640..cf2ff0d78 100644 --- a/.github/workflows/mintest.yaml +++ b/.github/workflows/mintest.yaml @@ -18,6 +18,7 @@ jobs: - "3.8" - "3.10" - "3.11" + - "3.12" steps: - name: Checkout repo @@ -31,7 +32,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -U tox + pip install -U setuptools tox wheel python --version pip --version tox --version diff --git a/docs/conf.py b/docs/conf.py index 4778378ee..1b4b4ecb9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -88,7 +88,7 @@ # |version| and |release|, also used in various other places throughout the # built documents. -cfg = configparser.SafeConfigParser() +cfg = configparser.ConfigParser() cfg.read('../setup.cfg') tag = cfg.get('egg_info', 'tag_build') diff --git a/docs/ext/rfc.py b/docs/ext/rfc.py index 5288c43a3..608f22d96 100644 --- a/docs/ext/rfc.py +++ b/docs/ext/rfc.py @@ -23,7 +23,7 @@ import re -RFC_PATTERN = re.compile('RFC (\d{4}), Section ([\d\.]+)') +RFC_PATTERN = re.compile(r'RFC (\d{4}), Section ([\d\.]+)') def _render_section(section_number, rfc_number): diff --git a/falcon/asgi/ws.py b/falcon/asgi/ws.py index e7db3d5fa..db3700b2d 100644 --- a/falcon/asgi/ws.py +++ b/falcon/asgi/ws.py @@ -530,12 +530,14 @@ class WebSocketOptions: __slots__ = ['error_close_code', 'max_receive_queue', 'media_handlers'] - def __init__(self): + def __init__(self) -> None: try: import msgpack except ImportError: msgpack = None + bin_handler: media.BinaryBaseHandlerWS + if msgpack: bin_handler = media.MessagePackHandlerWS() else: diff --git a/falcon/media/handlers.py b/falcon/media/handlers.py index bd3898d3c..0186e0aee 100644 --- a/falcon/media/handlers.py +++ b/falcon/media/handlers.py @@ -6,6 +6,7 @@ from falcon.constants import MEDIA_MULTIPART from falcon.constants import MEDIA_URLENCODED from falcon.constants import PYPY +from falcon.media.base import BinaryBaseHandlerWS from falcon.media.json import JSONHandler from falcon.media.multipart import MultipartFormHandler from falcon.media.multipart import MultipartParseOptions @@ -15,7 +16,7 @@ from falcon.vendor import mimeparse -class MissingDependencyHandler: +class MissingDependencyHandler(BinaryBaseHandlerWS): """Placeholder handler that always raises an error. This handler is used by the framework for media types that require an diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index 3388ec3f0..00959495a 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -402,6 +402,8 @@ class ASGIWebSocketSimulator: ``None`` if the connection has not been accepted. """ + _DEFAULT_WAIT_READY_TIMEOUT = 5 + def __init__(self): self.__msgpack = None @@ -435,7 +437,7 @@ def subprotocol(self) -> str: def headers(self) -> Iterable[Iterable[bytes]]: return self._accepted_headers - async def wait_ready(self, timeout: Optional[int] = 5): + async def wait_ready(self, timeout: Optional[int] = None): """Wait until the connection has been accepted or denied. This coroutine can be awaited in order to pause execution until the @@ -447,16 +449,17 @@ async def wait_ready(self, timeout: Optional[int] = 5): raising an error (default: ``5``). """ + timeout = timeout or self._DEFAULT_WAIT_READY_TIMEOUT + try: await asyncio.wait_for(self._event_handshake_complete.wait(), timeout) except asyncio.TimeoutError: msg = ( - 'Timed out after waiting {} seconds for ' - 'the WebSocket handshake to complete. Check the ' - 'on_websocket responder and ' - 'any middleware for any conditions that may be stalling the ' - 'request flow.' - ).format(timeout) + f'Timed out after waiting {timeout} seconds for the WebSocket ' + f'handshake to complete. Check the on_websocket responder and ' + f'any middleware for any conditions that may be stalling the ' + f'request flow.' + ) raise asyncio.TimeoutError(msg) self._require_accepted() diff --git a/tests/asgi/test_ws.py b/tests/asgi/test_ws.py index 75b976211..f45ea9758 100644 --- a/tests/asgi/test_ws.py +++ b/tests/asgi/test_ws.py @@ -10,6 +10,7 @@ from falcon import media, testing from falcon.asgi import App from falcon.asgi.ws import _WebSocketState as ServerWebSocketState +from falcon.asgi.ws import WebSocket from falcon.asgi.ws import WebSocketOptions from falcon.testing.helpers import _WebSocketState as ClientWebSocketState @@ -1088,6 +1089,34 @@ class Resource: event = await ws._emit() +@pytest.mark.asyncio +async def test_ws_responder_never_ready(conductor, monkeypatch): + async def noop_close(obj, code=None): + pass + + class SleepyResource: + async def on_websocket(self, req, ws): + for i in range(10): + await asyncio.sleep(0.001) + + conductor.app.add_route('/', SleepyResource()) + + # NOTE(vytas): It seems that it is hard to impossible to hit the second + # `await ready_waiter` of the _WSContextManager on CPython 3.12 due to + # different async code optimizations, so we mock away WebSocket.close. + monkeypatch.setattr(WebSocket, 'close', noop_close) + + # NOTE(vytas): Shorten the timeout so that we do not wait for 5 seconds. + monkeypatch.setattr( + testing.ASGIWebSocketSimulator, '_DEFAULT_WAIT_READY_TIMEOUT', 0.5 + ) + + async with conductor as c: + with pytest.raises(asyncio.TimeoutError): + async with c.simulate_ws(): + pass + + @pytest.mark.skipif(msgpack, reason='test requires msgpack lib to be missing') def test_msgpack_missing(): From 5b6e4c4bfb8706b323bce61d05378cc0d92205f7 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 20 Dec 2023 00:14:36 +0100 Subject: [PATCH 06/14] feat(cors): disallow `cors_enable` & additional `CORSMiddleware` combo (#2201) * feat(cors): disallow `cors_enable` & additional CORSMiddleware combo * chore: ignore typing for 1 line because it is unclear how to fix it --- docs/_newsfragments/1977.breakingchange.rst | 4 +++ docs/api/cors.rst | 12 ++++++--- falcon/app.py | 25 ++++++++++++++--- tests/test_middleware.py | 30 +++++++++++++-------- 4 files changed, 54 insertions(+), 17 deletions(-) create mode 100644 docs/_newsfragments/1977.breakingchange.rst diff --git a/docs/_newsfragments/1977.breakingchange.rst b/docs/_newsfragments/1977.breakingchange.rst new file mode 100644 index 000000000..ff6b9c1c3 --- /dev/null +++ b/docs/_newsfragments/1977.breakingchange.rst @@ -0,0 +1,4 @@ +Previously, it was possible to create an :class:`~falcon.App` with the +``cors_enable`` option, and add additional :class:`~falcon.CORSMiddleware`, +leading to unexpected behavior and dysfunctional CORS. This combination now +explicitly results in a :class:`ValueError`. diff --git a/docs/api/cors.rst b/docs/api/cors.rst index 40bec453a..d19bf7a78 100644 --- a/docs/api/cors.rst +++ b/docs/api/cors.rst @@ -60,9 +60,15 @@ Usage allow_origins='example.com', allow_credentials='*')) .. note:: - Passing the ``cors_enable`` parameter set to ``True`` should be seen as - mutually exclusive with directly passing an instance of - :class:`~falcon.CORSMiddleware` to the application's initializer. + Passing the ``cors_enable`` parameter set to ``True`` is mutually exclusive + with directly passing an instance of :class:`~falcon.CORSMiddleware` to the + application's initializer. + + .. versionchanged:: 4.0 + + Attempt to use the combination of ``cors_enable=True`` and an additional + instance of :class:`~falcon.CORSMiddleware` now results in a + :class:`ValueError`. CORSMiddleware -------------- diff --git a/falcon/app.py b/falcon/app.py index 34186b418..434dd6b39 100644 --- a/falcon/app.py +++ b/falcon/app.py @@ -244,6 +244,7 @@ def __init__( cors_enable=False, sink_before_static_route=True, ): + self._cors_enable = cors_enable self._sink_before_static_route = sink_before_static_route self._sinks = [] self._static_routes = [] @@ -447,7 +448,7 @@ def __call__( # noqa: C901 def router_options(self): return self._router.options - def add_middleware(self, middleware: object) -> None: + def add_middleware(self, middleware: Union[object, Iterable]) -> None: """Add one or more additional middleware components. Arguments: @@ -461,10 +462,28 @@ def add_middleware(self, middleware: object) -> None: # the chance that middleware may be None. if middleware: try: - self._unprepared_middleware += middleware + middleware = list(middleware) # type: ignore except TypeError: # middleware is not iterable; assume it is just one bare component - self._unprepared_middleware.append(middleware) + middleware = [middleware] + + if ( + self._cors_enable + and len( + [ + mc + for mc in self._unprepared_middleware + middleware + if isinstance(mc, CORSMiddleware) + ] + ) + > 1 + ): + raise ValueError( + 'CORSMiddleware is not allowed in conjunction with ' + 'cors_enable (which already constructs one instance)' + ) + + self._unprepared_middleware += middleware # NOTE(kgriffs): Even if middleware is None or an empty list, we still # need to make sure self._middleware is initialized if this is the diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 52516622e..b6b1b9d30 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1000,20 +1000,28 @@ def test_process_resource_cached(self, asgi, independent_middleware): class TestCORSMiddlewareWithAnotherMiddleware(TestMiddleware): @pytest.mark.parametrize( - 'mw', + 'mw,allowed', [ - CaptureResponseMiddleware(), - [CaptureResponseMiddleware()], - (CaptureResponseMiddleware(),), - iter([CaptureResponseMiddleware()]), + (CaptureResponseMiddleware(), True), + ([CaptureResponseMiddleware()], True), + ((CaptureResponseMiddleware(),), True), + (iter([CaptureResponseMiddleware()]), True), + (falcon.CORSMiddleware(), False), + ([falcon.CORSMiddleware()], False), ], ) - def test_api_initialization_with_cors_enabled_and_middleware_param(self, mw, asgi): - app = create_app(asgi, middleware=mw, cors_enable=True) - app.add_route('/', TestCorsResource()) - client = testing.TestClient(app) - result = client.simulate_get(headers={'Origin': 'localhost'}) - assert result.headers['Access-Control-Allow-Origin'] == '*' + def test_api_initialization_with_cors_enabled_and_middleware_param( + self, mw, asgi, allowed + ): + if allowed: + app = create_app(asgi, middleware=mw, cors_enable=True) + app.add_route('/', TestCorsResource()) + client = testing.TestClient(app) + result = client.simulate_get(headers={'Origin': 'localhost'}) + assert result.headers['Access-Control-Allow-Origin'] == '*' + else: + with pytest.raises(ValueError, match='CORSMiddleware'): + app = create_app(asgi, middleware=mw, cors_enable=True) @pytest.mark.skipif(cython, reason='Cythonized coroutine functions cannot be detected') From 064872649ed054b6cf6968e4865c62f3af265138 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Tue, 26 Dec 2023 17:51:00 +0100 Subject: [PATCH 07/14] feat(parse_query_string): change the default value of `csv` to `False` (#2200) * feat(parse_query_string): change the default value of `csv` to `False` * docs(parse_query_string): improve docstring in the light of the new default * test(parse_query_string): resurrect some older tests for `csv=True` --- docs/_newsfragments/1999.breakingchange.rst | 6 ++++++ falcon/cyutil/uri.pyx | 2 +- falcon/util/uri.py | 19 +++++++++++-------- tests/test_utils.py | 15 ++++++++++++--- 4 files changed, 30 insertions(+), 12 deletions(-) create mode 100644 docs/_newsfragments/1999.breakingchange.rst diff --git a/docs/_newsfragments/1999.breakingchange.rst b/docs/_newsfragments/1999.breakingchange.rst new file mode 100644 index 000000000..6cac71257 --- /dev/null +++ b/docs/_newsfragments/1999.breakingchange.rst @@ -0,0 +1,6 @@ +The default value of the ``csv`` parameter in +:func:`~falcon.uri.parse_query_string` was changed to ``False``, matching the +default behavior of other parts of the framework (such as +:attr:`req.params `, the test client, etc). +If the old behavior fits your use case better, pass the ``csv=True`` keyword +argument explicitly. diff --git a/falcon/cyutil/uri.pyx b/falcon/cyutil/uri.pyx index da3b2b296..477e7cc7e 100644 --- a/falcon/cyutil/uri.pyx +++ b/falcon/cyutil/uri.pyx @@ -251,7 +251,7 @@ cdef cy_parse_query_string(unsigned char* data, Py_ssize_t length, def parse_query_string(unicode query_string not None, bint keep_blank=False, - bint csv=True): + bint csv=False): cdef bytes byte_string = query_string.encode('utf-8') cdef unsigned char* data = byte_string return cy_parse_query_string(data, len(byte_string), keep_blank, csv) diff --git a/falcon/util/uri.py b/falcon/util/uri.py index f4092634c..5daa7c68a 100644 --- a/falcon/util/uri.py +++ b/falcon/util/uri.py @@ -337,13 +337,13 @@ def decode(encoded_uri: str, unquote_plus: bool = True) -> str: def parse_query_string( - query_string: str, keep_blank: bool = False, csv: bool = True + query_string: str, keep_blank: bool = False, csv: bool = False ) -> Dict[str, Union[str, List[str]]]: """Parse a query string into a dict. Query string parameters are assumed to use standard form-encoding. Only parameters with values are returned. For example, given 'foo=bar&flag', - this function would ignore 'flag' unless the `keep_blank_qs_values` option + this function would ignore 'flag' unless the `keep_blank` option is set. Note: @@ -351,6 +351,8 @@ def parse_query_string( lists by repeating a given param multiple times, Falcon supports a more compact form in which the param may be given a single time but set to a ``list`` of comma-separated elements (e.g., 'foo=a,b,c'). + This comma-separated format can be enabled by setting the `csv` + option (see below) to ``True``. When using this format, all commas uri-encoded will not be treated by Falcon as a delimiter. If the client wants to send a value as a list, @@ -365,12 +367,13 @@ def parse_query_string( they do not have a value (default ``False``). For comma-separated values, this option also determines whether or not empty elements in the parsed list are retained. - csv: Set to ``False`` in order to disable splitting query - parameters on ``,`` (default ``True``). Depending on the user agent, - encoding lists as multiple occurrences of the same parameter might - be preferable. In this case, setting `parse_qs_csv` to ``False`` - will cause the framework to treat commas as literal characters in - each occurring parameter value. + csv: Set to ``True`` in order to enable splitting query + parameters on ``,`` (default ``False``). + Depending on the user agent, encoding lists as multiple occurrences + of the same parameter might be preferable. In this case, keeping + `parse_qs_csv` at its default value (``False``) will cause the + framework to treat commas as literal characters in each occurring + parameter value. Returns: dict: A dictionary of (*name*, *value*) pairs, one per query diff --git a/tests/test_utils.py b/tests/test_utils.py index 83a8dacce..349032856 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -380,13 +380,13 @@ def test_parse_query_string(self): result = uri.parse_query_string(query_string) assert result['a'] == decoded_url assert result['b'] == decoded_json - assert result['c'] == ['1', '2', '3'] + assert result['c'] == '1,2,3' assert result['d'] == 'test' - assert result['e'] == ['a', '&=,'] + assert result['e'] == 'a,,&=,' assert result['f'] == ['a', 'a=b'] assert result['é'] == 'a=b' - result = uri.parse_query_string(query_string, True) + result = uri.parse_query_string(query_string, True, True) assert result['a'] == decoded_url assert result['b'] == decoded_json assert result['c'] == ['1', '2', '3'] @@ -395,6 +395,15 @@ def test_parse_query_string(self): assert result['f'] == ['a', 'a=b'] assert result['é'] == 'a=b' + result = uri.parse_query_string(query_string, csv=True) + assert result['a'] == decoded_url + assert result['b'] == decoded_json + assert result['c'] == ['1', '2', '3'] + assert result['d'] == 'test' + assert result['e'] == ['a', '&=,'] + assert result['f'] == ['a', 'a=b'] + assert result['é'] == 'a=b' + @pytest.mark.parametrize( 'query_string,keep_blank,expected', [ From 4910dd73ecd1b9c8cf6cae045b26ad432fa56128 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Tue, 16 Jan 2024 09:13:02 +0100 Subject: [PATCH 08/14] docs(FAQ): add a FAQ item on shutting down `wsgiref.simple_server` (#2203) * docs(FAQ): add a FAQ item on shutting down `wsgiref.simple_server` * docs(FAQ): add a note that wsgiref.simple_server is not recommended for prod --- docs/user/faq.rst | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/user/faq.rst b/docs/user/faq.rst index 21c6a114c..d442c9140 100644 --- a/docs/user/faq.rst +++ b/docs/user/faq.rst @@ -1233,6 +1233,55 @@ the tutorial in the docs provides an excellent introduction to (See also: `Testing `_) +Can I shut my server down cleanly from the app? +----------------------------------------------- + +Normally, the lifetime of an app server is controlled by other means than from +inside the running app, and there is no standardized way for a WSGI or ASGI +framework to shut down the server programmatically. + +However, if you need to spin up a real server for testing purposes (such as for +collecting coverage while interacting with other services over the network), +your app server of choice may offer a Python API or hooks that you can +integrate into your app. + +For instance, the stdlib's :mod:`wsgiref` server inherits from +:class:`~socketserver.TCPServer`, which can be stopped by calling its +``shutdown()`` method. Just make sure to perform the call from a different +thread (otherwise it may deadlock): + +.. code:: python + + import http + import threading + import wsgiref.simple_server + + import falcon + + + class Shutdown: + def __init__(self, httpd): + self._httpd = httpd + + def on_post(self, req, resp): + thread = threading.Thread(target=self._httpd.shutdown, daemon=True) + thread.start() + + resp.content_type = falcon.MEDIA_TEXT + resp.text = 'Shutting down...\n' + resp.status = http.HTTPStatus.ACCEPTED + + + with wsgiref.simple_server.make_server('', 8000, app := falcon.App()) as httpd: + app.add_route('/shutdown', Shutdown(httpd)) + print('Serving on port 8000, POST to /shutdown to stop...') + httpd.serve_forever() + +.. warning:: + While ``wsgiref.simple_server`` is handy for integration testing, it builds + upon :mod:`http.server`, which is not recommended for production. (See + :ref:`install` on how to install a production-ready WSGI or ASGI server.) + How can I set cookies when simulating requests? ----------------------------------------------- From dc8d2d430381a82ef52eb4229b023c1cd1dd809e Mon Sep 17 00:00:00 2001 From: Kent Bull <65027257+kentbull@users.noreply.github.com> Date: Sat, 2 Mar 2024 06:22:28 -0700 Subject: [PATCH 09/14] chore: update to latest Redis (aioredis is deprecated) (#2204) * Update to latest Redis - aioredis deprecated As of Feb 21, 2023 aioredis-py was archived. See the package repo here: https://github.com/aio-libs-abandoned/aioredis-py * style: update config.py * fix(asgilook): directly use `redis.asyncio.from_url()` --------- Co-authored-by: Vytautas Liuolia --- docs/user/tutorial-asgi.rst | 13 ++++++------- examples/asgilook/asgilook/cache.py | 3 ++- examples/asgilook/asgilook/config.py | 7 +++---- examples/asgilook/requirements/asgilook | 2 +- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/user/tutorial-asgi.rst b/docs/user/tutorial-asgi.rst index 7d4909a87..65ffa2fa9 100644 --- a/docs/user/tutorial-asgi.rst +++ b/docs/user/tutorial-asgi.rst @@ -685,10 +685,10 @@ small files littering our storage, it consumes CPU resources, and we would soon find our application crumbling under load. Let's mitigate this problem with response caching. We'll use Redis, taking -advantage of `aioredis `_ for async +advantage of `redis `_ for async support:: - pip install aioredis + pip install redis We will also need to serialize response data (the ``Content-Type`` header and the body in the first version); ``msgpack`` should do:: @@ -700,7 +700,7 @@ installing Redis server on your machine, one could also: * Spin up Redis in Docker, eg:: - docker run -p 6379:6379 redis + docker run -p 6379:6379 redis/redis-stack:latest * Assuming Redis is installed on the machine, one could also try `pifpaf `_ for spinning up Redis just @@ -747,9 +747,8 @@ implementations for production and testing. ``self.redis_host``. Such a design might prove helpful for apps that need to create client connections in more than one place. -Assuming we call our new :ref:`configuration ` items -``redis_host`` and ``redis_from_url()``, respectively, the final version of -``config.py`` now reads: +Assuming we call our new :ref:`configuration ` item +``redis_host`` the final version of ``config.py`` now reads: .. literalinclude:: ../../examples/asgilook/asgilook/config.py :language: python @@ -860,7 +859,7 @@ any problems with importing local utility modules or checking code coverage:: $ mkdir -p tests $ touch tests/__init__.py -Next, let's implement fixtures to replace ``uuid`` and ``aioredis``, and inject them +Next, let's implement fixtures to replace ``uuid`` and ``redis``, and inject them into our tests via ``conftest.py`` (place your code in the newly created ``tests`` directory): diff --git a/examples/asgilook/asgilook/cache.py b/examples/asgilook/asgilook/cache.py index 02af68778..e4819d0d0 100644 --- a/examples/asgilook/asgilook/cache.py +++ b/examples/asgilook/asgilook/cache.py @@ -1,4 +1,5 @@ import msgpack +import redis.asyncio as redis class RedisCache: @@ -24,7 +25,7 @@ async def process_startup(self, scope, event): await self._redis.ping() async def process_shutdown(self, scope, event): - await self._redis.close() + await self._redis.aclose() async def process_request(self, req, resp): resp.context.cached = False diff --git a/examples/asgilook/asgilook/config.py b/examples/asgilook/asgilook/config.py index 3e0870fba..e5ac12c5b 100644 --- a/examples/asgilook/asgilook/config.py +++ b/examples/asgilook/asgilook/config.py @@ -1,15 +1,14 @@ import os import pathlib +import redis.asyncio import uuid -import aioredis - class Config: DEFAULT_CONFIG_PATH = '/tmp/asgilook' DEFAULT_MIN_THUMB_SIZE = 64 + DEFAULT_REDIS_FROM_URL = redis.asyncio.from_url DEFAULT_REDIS_HOST = 'redis://localhost' - DEFAULT_REDIS_FROM_URL = aioredis.from_url DEFAULT_UUID_GENERATOR = uuid.uuid4 def __init__(self): @@ -18,7 +17,7 @@ def __init__(self): ) self.storage_path.mkdir(parents=True, exist_ok=True) - self.redis_from_url = Config.DEFAULT_REDIS_FROM_URL self.min_thumb_size = self.DEFAULT_MIN_THUMB_SIZE + self.redis_from_url = Config.DEFAULT_REDIS_FROM_URL self.redis_host = self.DEFAULT_REDIS_HOST self.uuid_generator = Config.DEFAULT_UUID_GENERATOR diff --git a/examples/asgilook/requirements/asgilook b/examples/asgilook/requirements/asgilook index c0081cbf5..7f716db72 100644 --- a/examples/asgilook/requirements/asgilook +++ b/examples/asgilook/requirements/asgilook @@ -1,4 +1,4 @@ aiofiles>=0.4.0 -aioredis>=2.0 +redis>=5.0 msgpack Pillow>=6.0.0 From a78cfb38a0c0f6031cc3ff39ff8bf4afd03ef008 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Thu, 21 Mar 2024 20:59:26 +0100 Subject: [PATCH 10/14] chore(sync): use `asyncio.Runner` for `async_to_sync()` on py311+ (#2216) * chore(asyncio): replace `get_event_loop()` -> `get_running_loop()` where applicable * chore(sync): use `asyncio.Runner` for `async_to_sync()` on py311+ * chore(sync): exempt a line from coverage as it can only be hit on 3.11+ * chore(tests/asgi): adapt to Uvicorn now propagating signals to retcode * chore(tests/asgi): do not check ASGI server retcode on Windows * chore(tests/asgi): check for a M$ Windows specific exit code constant * chore(sync): use a nicer pattern to get the active runner --- falcon/util/sync.py | 60 ++++++++++++++++++++++++--------- pyproject.toml | 1 - tests/asgi/test_asgi_servers.py | 10 ++++-- tests/asgi/test_scope.py | 6 ++-- tests/dump_asgi.py | 2 +- 5 files changed, 56 insertions(+), 23 deletions(-) diff --git a/falcon/util/sync.py b/falcon/util/sync.py index db9e21e37..96d05c058 100644 --- a/falcon/util/sync.py +++ b/falcon/util/sync.py @@ -22,6 +22,42 @@ 'wrap_sync_to_async_unsafe', ] +Result = TypeVar('Result') + + +class _DummyRunner: + def run(self, coro: Awaitable[Result]) -> Result: # pragma: nocover + # NOTE(vytas): Work around get_event_loop deprecation in 3.10 by going + # via get_event_loop_policy(). This should be equivalent for + # async_to_sync's use case as it is currently impossible to invoke + # run_until_complete() from a running loop anyway. + return self.get_loop().run_until_complete(coro) + + def get_loop(self) -> asyncio.AbstractEventLoop: # pragma: nocover + return asyncio.get_event_loop_policy().get_event_loop() + + def close(self) -> None: # pragma: nocover + pass + + +class _ActiveRunner: + def __init__(self, runner_cls: type): + self._runner_cls = runner_cls + self._runner = runner_cls() + + # TODO(vytas): This typing is wrong on py311+, but mypy accepts it. + # It doesn't, OTOH, accept any of my ostensibly valid attempts to + # describe it. + def __call__(self) -> _DummyRunner: + # NOTE(vytas): Sometimes our runner's loop can get picked and consumed + # by other utilities and test methods. If that happens, recreate the runner. + if self._runner.get_loop().is_closed(): + # NOTE(vytas): This condition is never hit on _DummyRunner. + self._runner = self._runner_cls() # pragma: nocover + return self._runner + + +_active_runner = _ActiveRunner(getattr(asyncio, 'Runner', _DummyRunner)) _one_thread_to_rule_them_all = ThreadPoolExecutor(max_workers=1) create_task = asyncio.create_task @@ -190,9 +226,6 @@ def _wrap_non_coroutine_unsafe( return wrap_sync_to_async_unsafe(func) -Result = TypeVar('Result') - - def async_to_sync( coroutine: Callable[..., Awaitable[Result]], *args: Any, **kwargs: Any ) -> Result: @@ -204,8 +237,13 @@ def async_to_sync( one will be created. Warning: - This method is very inefficient and is intended primarily for testing - and prototyping. + Executing async code in this manner is inefficient since it involves + synchronization via threading primitives, and is intended primarily for + testing, prototyping or compatibility purposes. + + Note: + On Python 3.11+, this function leverages a module-wide + ``asyncio.Runner``. Args: coroutine: A coroutine function to invoke. @@ -214,17 +252,7 @@ def async_to_sync( Keyword Args: **kwargs: Additional args are passed through to the coroutine function. """ - - # TODO(vytas): The canonical way of doing this for simple use cases is - # asyncio.run(), but that would be a breaking change wrt the above - # documented behaviour; breaking enough to break some of our own tests. - - # NOTE(vytas): Work around get_event_loop deprecation in 3.10 by going via - # get_event_loop_policy(). This should be equivalent for async_to_sync's - # use case as it is currently impossible to invoke run_until_complete() - # from a running loop anyway. - loop = asyncio.get_event_loop_policy().get_event_loop() - return loop.run_until_complete(coroutine(*args, **kwargs)) + return _active_runner().run(coroutine(*args, **kwargs)) def runs_sync(coroutine: Callable[..., Awaitable[Result]]) -> Callable[..., Result]: diff --git a/pyproject.toml b/pyproject.toml index ad445ce55..5ed0c5fab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,7 +97,6 @@ filterwarnings = [ "ignore:.cgi. is deprecated and slated for removal:DeprecationWarning", "ignore:path is deprecated\\. Use files\\(\\) instead:DeprecationWarning", "ignore:This process \\(.+\\) is multi-threaded", - "ignore:There is no current event loop", ] testpaths = [ "tests" diff --git a/tests/asgi/test_asgi_servers.py b/tests/asgi/test_asgi_servers.py index 26f51ad0c..321e41f96 100644 --- a/tests/asgi/test_asgi_servers.py +++ b/tests/asgi/test_asgi_servers.py @@ -4,6 +4,7 @@ import os import platform import random +import signal import subprocess import sys import time @@ -27,7 +28,9 @@ _SERVER_HOST = '127.0.0.1' _SIZE_1_KB = 1024 _SIZE_1_MB = _SIZE_1_KB**2 - +# NOTE(vytas): Windows specific: {Application Exit by CTRL+C}. +# The application terminated as a result of a CTRL+C. +_STATUS_CONTROL_C_EXIT = 0xC000013A _REQUEST_TIMEOUT = 10 @@ -620,7 +623,10 @@ def server_base_url(request): yield base_url - assert server.returncode == 0 + # NOTE(vytas): Starting with 0.29.0, Uvicorn will propagate signal + # values into the return code (which is a good practice in Unix); + # see also https://github.com/encode/uvicorn/pull/1600 + assert server.returncode in (0, -signal.SIGTERM, _STATUS_CONTROL_C_EXIT) break diff --git a/tests/asgi/test_scope.py b/tests/asgi/test_scope.py index bb60ed0e7..e368f6576 100644 --- a/tests/asgi/test_scope.py +++ b/tests/asgi/test_scope.py @@ -70,7 +70,7 @@ def test_supported_asgi_version(version, supported): resp_event_collector = testing.ASGIResponseEventCollector() async def task(): - coro = asyncio.get_event_loop().create_task( + coro = asyncio.get_running_loop().create_task( app(scope, req_event_emitter, resp_event_collector) ) @@ -142,7 +142,7 @@ def test_lifespan_scope_default_version(): scope = {'type': 'lifespan'} async def t(): - t = asyncio.get_event_loop().create_task( + t = asyncio.get_running_loop().create_task( app(scope, req_event_emitter, resp_event_collector) ) @@ -196,7 +196,7 @@ def test_lifespan_scope_version(spec_version, supported): return async def t(): - t = asyncio.get_event_loop().create_task( + t = asyncio.get_running_loop().create_task( app(scope, req_event_emitter, resp_event_collector) ) diff --git a/tests/dump_asgi.py b/tests/dump_asgi.py index 0742a3ca0..0dfdb4b0a 100644 --- a/tests/dump_asgi.py +++ b/tests/dump_asgi.py @@ -23,5 +23,5 @@ async def app(scope, receive, send): } ) - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() loop.create_task(_say_hi()) From 7ec1d31a93461924636c003e61fed34d13071608 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 3 Apr 2024 22:27:30 +0200 Subject: [PATCH 11/14] feat(parse_header): provide our own implementation of `parse_header()` (#2217) * feat(parse_header): provide our own implementation of `parse_header()` * docs(newsfragments): add a newsfragment + address 1 review comment * test(test_mediatypes.py): add tests for multiple parameters --- README.rst | 2 +- docs/_newsfragments/2066.newandimproved.rst | 4 + docs/api/util.rst | 5 ++ docs/user/recipes/pretty-json.rst | 3 +- falcon/__init__.py | 1 + falcon/asgi/multipart.py | 5 +- falcon/media/multipart.py | 10 +-- falcon/testing/helpers.py | 4 +- falcon/util/__init__.py | 1 + falcon/util/mediatypes.py | 89 +++++++++++++++++++++ falcon/vendor/mimeparse/mimeparse.py | 4 +- pyproject.toml | 1 - tests/test_mediatypes.py | 41 ++++++++++ 13 files changed, 154 insertions(+), 16 deletions(-) create mode 100644 docs/_newsfragments/2066.newandimproved.rst create mode 100644 falcon/util/mediatypes.py create mode 100644 tests/test_mediatypes.py diff --git a/README.rst b/README.rst index 26105b0dd..738b2f2b8 100644 --- a/README.rst +++ b/README.rst @@ -1027,7 +1027,7 @@ See also: `CONTRIBUTING.md `: .. code:: python - import cgi import json import falcon @@ -66,7 +65,7 @@ implemented with a :ref:`custom media handler `: return json.loads(data.decode()) def serialize(self, media, content_type): - _, params = cgi.parse_header(content_type) + _, params = falcon.parse_header(content_type) indent = params.get('indent') if indent is not None: try: diff --git a/falcon/__init__.py b/falcon/__init__.py index 1d33539b3..745856058 100644 --- a/falcon/__init__.py +++ b/falcon/__init__.py @@ -77,6 +77,7 @@ from falcon.util import IS_64_BITS from falcon.util import is_python_func from falcon.util import misc +from falcon.util import parse_header from falcon.util import reader from falcon.util import runs_sync from falcon.util import secure_filename diff --git a/falcon/asgi/multipart.py b/falcon/asgi/multipart.py index b268c2b5a..52cb0505e 100644 --- a/falcon/asgi/multipart.py +++ b/falcon/asgi/multipart.py @@ -14,11 +14,10 @@ """ASGI multipart form media handler components.""" -import cgi - from falcon.asgi.reader import BufferedReader from falcon.errors import DelimiterError from falcon.media import multipart +from falcon.util.mediatypes import parse_header _ALLOWED_CONTENT_HEADERS = multipart._ALLOWED_CONTENT_HEADERS _CRLF = multipart._CRLF @@ -54,7 +53,7 @@ async def get_media(self): return self._media async def get_text(self): - content_type, options = cgi.parse_header(self.content_type) + content_type, options = parse_header(self.content_type) if content_type != 'text/plain': return None diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py index c3fc37d56..5b55d4b4f 100644 --- a/falcon/media/multipart.py +++ b/falcon/media/multipart.py @@ -14,7 +14,6 @@ """Multipart form media handler.""" -import cgi import re from urllib.parse import unquote_to_bytes @@ -24,6 +23,7 @@ from falcon.stream import BoundedStream from falcon.util import BufferedReader from falcon.util import misc +from falcon.util.mediatypes import parse_header # TODO(vytas): @@ -249,7 +249,7 @@ def get_text(self): str: The part decoded as a text string provided the part is encoded as ``text/plain``, ``None`` otherwise. """ - content_type, options = cgi.parse_header(self.content_type) + content_type, options = parse_header(self.content_type) if content_type != 'text/plain': return None @@ -275,7 +275,7 @@ def filename(self): if self._content_disposition is None: value = self._headers.get(b'content-disposition', b'') - self._content_disposition = cgi.parse_header(value.decode()) + self._content_disposition = parse_header(value.decode()) _, params = self._content_disposition @@ -311,7 +311,7 @@ def name(self): if self._content_disposition is None: value = self._headers.get(b'content-disposition', b'') - self._content_disposition = cgi.parse_header(value.decode()) + self._content_disposition = parse_header(value.decode()) _, params = self._content_disposition self._name = params.get('name') @@ -493,7 +493,7 @@ def __init__(self, parse_options=None): def _deserialize_form( self, stream, content_type, content_length, form_cls=MultipartForm ): - _, options = cgi.parse_header(content_type) + _, options = parse_header(content_type) try: boundary = options['boundary'] except KeyError: diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index 00959495a..39e8c12f8 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -23,7 +23,6 @@ """ import asyncio -import cgi from collections import defaultdict from collections import deque import contextlib @@ -51,6 +50,7 @@ from falcon.constants import SINGLETON_HEADERS import falcon.request from falcon.util import uri +from falcon.util.mediatypes import parse_header # NOTE(kgriffs): Changed in 3.0 from 'curl/7.24.0 (x86_64-apple-darwin12.0)' DEFAULT_UA = 'falcon-client/' + falcon.__version__ @@ -802,7 +802,7 @@ def get_encoding_from_headers(headers): if not content_type: return None - content_type, params = cgi.parse_header(content_type) + content_type, params = parse_header(content_type) if 'charset' in params: return params['charset'].strip('\'"') diff --git a/falcon/util/__init__.py b/falcon/util/__init__.py index 3fec8b06e..1cead07e8 100644 --- a/falcon/util/__init__.py +++ b/falcon/util/__init__.py @@ -29,6 +29,7 @@ from falcon.util.deprecation import deprecated from falcon.util.deprecation import deprecated_args from falcon.util.deprecation import DeprecatedWarning +from falcon.util.mediatypes import parse_header from falcon.util.misc import code_to_http_status from falcon.util.misc import dt_to_http from falcon.util.misc import get_argnames diff --git a/falcon/util/mediatypes.py b/falcon/util/mediatypes.py new file mode 100644 index 000000000..c0dca5121 --- /dev/null +++ b/falcon/util/mediatypes.py @@ -0,0 +1,89 @@ +# Copyright 2023-2024 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. + +"""Media (aka MIME) type parsing and matching utilities.""" + +import typing + + +def _parse_param_old_stdlib(s): # type: ignore + while s[:1] == ';': + s = s[1:] + end = s.find(';') + while end > 0 and (s.count('"', 0, end) - s.count('\\"', 0, end)) % 2: + end = s.find(';', end + 1) + if end < 0: + end = len(s) + f = s[:end] + yield f.strip() + s = s[end:] + + +def _parse_header_old_stdlib(line): # type: ignore + """Parse a Content-type like header. + + Return the main content-type and a dictionary of options. + + Note: + This method has been copied (almost) verbatim from CPython 3.8 stdlib. + It is slated for removal from the stdlib in 3.13. + """ + parts = _parse_param_old_stdlib(';' + line) + key = parts.__next__() + pdict = {} + for p in parts: + i = p.find('=') + if i >= 0: + name = p[:i].strip().lower() + value = p[i + 1 :].strip() + if len(value) >= 2 and value[0] == value[-1] == '"': + value = value[1:-1] + value = value.replace('\\\\', '\\').replace('\\"', '"') + pdict[name] = value + return key, pdict + + +def parse_header(line: str) -> typing.Tuple[str, dict]: + """Parse a Content-type like header. + + Return the main content-type and a dictionary of options. + + Args: + line: A header value to parse. + + Returns: + tuple: (the main content-type, dictionary of options). + + Note: + This function replaces an equivalent method previously available in the + stdlib as ``cgi.parse_header()``. + It was removed from the stdlib in Python 3.13. + """ + if '"' not in line and '\\' not in line: + key, semicolon, parts = line.partition(';') + if not semicolon: + return (key.strip(), {}) + + pdict = {} + for part in parts.split(';'): + name, equals, value = part.partition('=') + if equals: + pdict[name.strip().lower()] = value.strip() + + return (key.strip(), pdict) + + return _parse_header_old_stdlib(line) + + +__all__ = ['parse_header'] diff --git a/falcon/vendor/mimeparse/mimeparse.py b/falcon/vendor/mimeparse/mimeparse.py index 0218553cf..f96e63384 100755 --- a/falcon/vendor/mimeparse/mimeparse.py +++ b/falcon/vendor/mimeparse/mimeparse.py @@ -1,4 +1,4 @@ -import cgi +from falcon.util.mediatypes import parse_header __version__ = '1.6.0' __author__ = 'Joe Gregorio' @@ -23,7 +23,7 @@ def parse_mime_type(mime_type): :rtype: (str,str,dict) """ - full_type, params = cgi.parse_header(mime_type) + full_type, params = parse_header(mime_type) # Java URLConnection class sends an Accept header that includes a # single '*'. Turn it into a legal wildcard. if full_type == '*': diff --git a/pyproject.toml b/pyproject.toml index 5ed0c5fab..44a829feb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,7 +94,6 @@ filterwarnings = [ "ignore:Using or importing the ABCs:DeprecationWarning", "ignore:cannot collect test class 'TestClient':pytest.PytestCollectionWarning", "ignore:inspect.getargspec\\(\\) is deprecated:DeprecationWarning", - "ignore:.cgi. is deprecated and slated for removal:DeprecationWarning", "ignore:path is deprecated\\. Use files\\(\\) instead:DeprecationWarning", "ignore:This process \\(.+\\) is multi-threaded", ] diff --git a/tests/test_mediatypes.py b/tests/test_mediatypes.py new file mode 100644 index 000000000..0fae79b43 --- /dev/null +++ b/tests/test_mediatypes.py @@ -0,0 +1,41 @@ +import pytest + +from falcon.util import mediatypes + + +@pytest.mark.parametrize( + 'value,expected', + [ + ('', ('', {})), + ('strange', ('strange', {})), + ('text/plain', ('text/plain', {})), + ('text/plain ', ('text/plain', {})), + (' text/plain', ('text/plain', {})), + (' text/plain ', ('text/plain', {})), + (' text/plain ', ('text/plain', {})), + ( + 'falcon/peregrine; key1; key2=value; key3', + ('falcon/peregrine', {'key2': 'value'}), + ), + ( + 'audio/pcm;rate=48000;encoding=float;bits=32', + ('audio/pcm', {'bits': '32', 'encoding': 'float', 'rate': '48000'}), + ), + ( + 'falcon/*; genus=falco; family=falconidae; class=aves; ', + ('falcon/*', {'class': 'aves', 'family': 'falconidae', 'genus': 'falco'}), + ), + ('"falcon/peregrine" ; key="value"', ('"falcon/peregrine"', {'key': 'value'})), + ('falcon/peregrine; empty=""', ('falcon/peregrine', {'empty': ''})), + ('falcon/peregrine; quote="', ('falcon/peregrine', {'quote': '"'})), + ('text/plain; charset=utf-8', ('text/plain', {'charset': 'utf-8'})), + ('stuff/strange; missing-value; missing-another', ('stuff/strange', {})), + ('stuff/strange; missing-value\\missing-another', ('stuff/strange', {})), + ( + 'application/falcon; P1 = "key; value"; P2="\\""', + ('application/falcon', {'p1': 'key; value', 'p2': '"'}), + ), + ], +) +def test_parse_header(value, expected): + assert mediatypes.parse_header(value) == expected From 64af3cfd0aa9c74c160560e8089a5a03699dfd58 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Fri, 5 Apr 2024 22:33:42 +0200 Subject: [PATCH 12/14] chore(typing): add more descriptive typing to `Context` (#2226) * chore(typing): add more descriptive typing to `Context` * chore(typing): define only stubs when under `TYPE_CHECKING` --- falcon/util/structures.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/falcon/util/structures.py b/falcon/util/structures.py index fc7ba2a88..5a9e51176 100644 --- a/falcon/util/structures.py +++ b/falcon/util/structures.py @@ -37,6 +37,7 @@ from typing import KeysView from typing import Optional from typing import Tuple +from typing import TYPE_CHECKING from typing import ValuesView @@ -141,6 +142,19 @@ class Context: True """ + # NOTE(vytas): Define synthetic attr access methods (under TYPE_CHECKING) + # merely to let mypy know this is a namespace object. + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: + ... + + def __setattr__(self, name: str, value: Any) -> None: + ... + + def __delattr__(self, name: str) -> None: + ... + def __contains__(self, key: str) -> bool: return self.__dict__.__contains__(key) From 52422170cb72b3776f80bdc18ee04488178fb854 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 10 Apr 2024 13:22:38 +0200 Subject: [PATCH 13/14] chore: make CPython 3.12 default CI version (#2227) * chore: make CPython 3.12 default CI version * chore(CI): update forgotten Windows, macOS & cov gates * chore: try codecov v4 --- .github/workflows/tests.yaml | 22 +++++++++++----------- CONTRIBUTING.md | 2 +- docs/changes/4.0.0.rst | 2 +- tox.ini | 12 ++++++------ 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 6a0ddbe40..cdb9d9cfd 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: python-version: - - "3.10" + - "3.12" os: - "ubuntu-latest" toxenv: @@ -28,9 +28,9 @@ jobs: - "pep8-docstrings" - "mypy" - "mypy_tests" - - "py310" - - "py310_sans_msgpack" - - "py310_cython" + - "py312" + - "py312_sans_msgpack" + - "py312_cython" - "docs" - "towncrier" - "look" @@ -82,12 +82,12 @@ jobs: - python-version: "3.12" os: ubuntu-latest toxenv: py312_cython - - python-version: "3.10" + - python-version: "3.12" os: macos-latest - toxenv: py310_nocover - - python-version: "3.10" + toxenv: py312_nocover + - python-version: "3.12" os: windows-latest - toxenv: py310_nocover + toxenv: py312_nocover # These env require 3.8 and 20.04, see tox.ini - python-version: "3.8" os: ubuntu-20.04 @@ -133,14 +133,14 @@ jobs: run: tox -e ${{ matrix.toxenv }} - name: Combine coverage - if: ${{ matrix.toxenv == 'py310' || matrix.toxenv == 'py310_sans_msgpack' }} + if: ${{ matrix.toxenv == 'py312' || matrix.toxenv == 'py312_sans_msgpack' }} run: | coverage --version coverage combine - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 - if: ${{ matrix.toxenv == 'py310' || matrix.toxenv == 'py310_sans_msgpack' }} + uses: codecov/codecov-action@v4 + if: ${{ matrix.toxenv == 'py312' || matrix.toxenv == 'py312_sans_msgpack' }} with: env_vars: PYTHON fail_ci_if_error: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e3d8082f..b28054167 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ $ pip install -U blue $ blue . ``` -You can check all this by running ``tox`` from within the Falcon project directory. Your environment must be based on CPython 3.8, 3.10 or 3.11: +You can check all this by running ``tox`` from within the Falcon project directory. Your environment must be based on CPython 3.8, 3.10, 3.11 or 3.12: ```bash $ pip install -U tox diff --git a/docs/changes/4.0.0.rst b/docs/changes/4.0.0.rst index 93359c678..361c17619 100644 --- a/docs/changes/4.0.0.rst +++ b/docs/changes/4.0.0.rst @@ -13,7 +13,7 @@ Changes to Supported Platforms ------------------------------ - CPython 3.11 is now fully supported. (`#2072 `__) -- CPython 3.12 will be fully supported. (`#2196 `__) +- CPython 3.12 is now fully supported. (`#2196 `__) - End-of-life Python 3.5 & 3.6 are no longer supported. (`#2074 `__) - Python 3.7 is no longer actively supported, but the framework should still continue to install from source. We may remove the support for 3.7 altogether diff --git a/tox.ini b/tox.ini index 1715258c5..67d9d7c7d 100644 --- a/tox.ini +++ b/tox.ini @@ -99,21 +99,21 @@ deps = {[testenv]deps} commands = pip uninstall --yes msgpack coverage run -m pytest tests -k "test_ws and test_msgpack_missing" -[testenv:py310] -basepython = python3.10 +[testenv:py312] +basepython = python3.12 deps = {[testenv]deps} pytest-randomly jsonschema commands = {[with-coverage]commands} -[testenv:py310_sans_msgpack] -basepython = python3.10 +[testenv:py312_sans_msgpack] +basepython = python3.12 deps = {[testenv]deps} commands = pip uninstall --yes msgpack coverage run -m pytest tests -k "test_ws and test_msgpack_missing" -[testenv:py310_nocover] -basepython = python3.10 +[testenv:py312_nocover] +basepython = python3.12 deps = {[testenv]deps} pytest-randomly jsonschema From 89bfb57c7ee9ea7b10d6a1f267556e20734a6771 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 10 Apr 2024 14:44:28 +0200 Subject: [PATCH 14/14] chore(codecov): add `CODECOV_TOKEN` as required in v4+ (#2228) --- .github/workflows/tests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index cdb9d9cfd..6098b43d7 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -144,3 +144,4 @@ jobs: with: env_vars: PYTHON fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }}