From cf51816a2f7cd7a23d3e1129fe9418a5fc85d8be Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Mon, 11 Nov 2024 08:43:02 +0100 Subject: [PATCH 1/6] chore(tests/asgi): migrate to the new `websockets` async client (#2406) * chore(tests/asgi): migrate to the new `websockets` async client * chore: update the unsupported WS protocol exception for Daphne/Hypercorn --- requirements/tests | 2 +- tests/asgi/test_asgi_servers.py | 67 +++++++++++++++++---------------- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/requirements/tests b/requirements/tests index ada7c3729..36825fd23 100644 --- a/requirements/tests +++ b/requirements/tests @@ -13,7 +13,7 @@ testtools; python_version < '3.10' aiofiles httpx uvicorn >= 0.17.0 -websockets +websockets >= 13.1 # Handler Specific cbor2 diff --git a/tests/asgi/test_asgi_servers.py b/tests/asgi/test_asgi_servers.py index eb35ac62d..044d46a38 100644 --- a/tests/asgi/test_asgi_servers.py +++ b/tests/asgi/test_asgi_servers.py @@ -24,6 +24,7 @@ try: import websockets + import websockets.asyncio.client import websockets.exceptions except ImportError: websockets = None # type: ignore @@ -232,9 +233,9 @@ async def test_hello( if close_code: extra_headers['X-Close-Code'] = str(close_code) - async with websockets.connect( + async with websockets.asyncio.client.connect( server_url_events_ws, - extra_headers=extra_headers, + additional_headers=extra_headers, ) as ws: got_message = False @@ -273,22 +274,22 @@ async def test_rejected(self, explicit_close, close_code, server_url_events_ws): if close_code: extra_headers['X-Close-Code'] = str(close_code) - with pytest.raises(websockets.exceptions.InvalidStatusCode) as exc_info: - async with websockets.connect( - server_url_events_ws, extra_headers=extra_headers + with pytest.raises(websockets.exceptions.InvalidStatus) as exc_info: + async with websockets.asyncio.client.connect( + server_url_events_ws, additional_headers=extra_headers ): pass - assert exc_info.value.status_code == 403 + assert exc_info.value.response.status_code == 403 async def test_missing_responder(self, server_url_events_ws): server_url_events_ws += '/404' - with pytest.raises(websockets.exceptions.InvalidStatusCode) as exc_info: - async with websockets.connect(server_url_events_ws): + with pytest.raises(websockets.exceptions.InvalidStatus) as exc_info: + async with websockets.asyncio.client.connect(server_url_events_ws): pass - assert exc_info.value.status_code == 403 + assert exc_info.value.response.status_code == 403 @pytest.mark.parametrize( 'subprotocol, expected', @@ -301,9 +302,9 @@ async def test_select_subprotocol_known( self, subprotocol, expected, server_url_events_ws ): extra_headers = {'X-Subprotocol': subprotocol} - async with websockets.connect( + async with websockets.asyncio.client.connect( server_url_events_ws, - extra_headers=extra_headers, + additional_headers=extra_headers, subprotocols=['amqp', 'wamp'], ) as ws: assert ws.subprotocol == expected @@ -312,9 +313,9 @@ async def test_select_subprotocol_unknown(self, server_url_events_ws): extra_headers = {'X-Subprotocol': 'xmpp'} try: - async with websockets.connect( + async with websockets.asyncio.client.connect( server_url_events_ws, - extra_headers=extra_headers, + additional_headers=extra_headers, subprotocols=['amqp', 'wamp'], ): pass @@ -329,8 +330,8 @@ async def test_select_subprotocol_unknown(self, server_url_events_ws): except websockets.exceptions.NegotiationError as ex: assert 'unsupported subprotocol: xmpp' in str(ex) - # Daphne - except websockets.exceptions.InvalidMessage: + # Daphne, Hypercorn + except EOFError: pass # NOTE(kgriffs): When executing this test under pytest with the -s @@ -340,8 +341,8 @@ async def test_select_subprotocol_unknown(self, server_url_events_ws): # but the usual ways of capturing stdout/stderr with pytest do # not work. async def test_disconnecting_client_early(self, server_url_events_ws): - ws = await websockets.connect( - server_url_events_ws, extra_headers={'X-Close': 'True'} + ws = await websockets.asyncio.client.connect( + server_url_events_ws, additional_headers={'X-Close': 'True'} ) await asyncio.sleep(0.2) @@ -361,8 +362,8 @@ async def test_disconnecting_client_early(self, server_url_events_ws): async def test_send_before_accept(self, server_url_events_ws): extra_headers = {'x-accept': 'skip'} - async with websockets.connect( - server_url_events_ws, extra_headers=extra_headers + async with websockets.asyncio.client.connect( + server_url_events_ws, additional_headers=extra_headers ) as ws: message = await ws.recv() assert message == 'OperationNotAllowed' @@ -370,8 +371,8 @@ async def test_send_before_accept(self, server_url_events_ws): async def test_recv_before_accept(self, server_url_events_ws): extra_headers = {'x-accept': 'skip', 'x-command': 'recv'} - async with websockets.connect( - server_url_events_ws, extra_headers=extra_headers + async with websockets.asyncio.client.connect( + server_url_events_ws, additional_headers=extra_headers ) as ws: message = await ws.recv() assert message == 'OperationNotAllowed' @@ -379,8 +380,8 @@ async def test_recv_before_accept(self, server_url_events_ws): async def test_invalid_close_code(self, server_url_events_ws): extra_headers = {'x-close': 'True', 'x-close-code': 42} - async with websockets.connect( - server_url_events_ws, extra_headers=extra_headers + async with websockets.asyncio.client.connect( + server_url_events_ws, additional_headers=extra_headers ) as ws: start = time.time() @@ -395,22 +396,22 @@ async def test_invalid_close_code(self, server_url_events_ws): async def test_close_code_on_unhandled_error(self, server_url_events_ws): extra_headers = {'x-raise-error': 'generic'} - async with websockets.connect( - server_url_events_ws, extra_headers=extra_headers + async with websockets.asyncio.client.connect( + server_url_events_ws, additional_headers=extra_headers ) as ws: await ws.wait_closed() - assert ws.close_code in {3011, 1011} + assert ws.protocol.close_code in {3011, 1011} async def test_close_code_on_unhandled_http_error(self, server_url_events_ws): extra_headers = {'x-raise-error': 'http'} - async with websockets.connect( - server_url_events_ws, extra_headers=extra_headers + async with websockets.asyncio.client.connect( + server_url_events_ws, additional_headers=extra_headers ) as ws: await ws.wait_closed() - assert ws.close_code == 3400 + assert ws.protocol.close_code == 3400 @pytest.mark.parametrize('mismatch', ['send', 'recv']) @pytest.mark.parametrize('mismatch_type', ['text', 'data']) @@ -420,8 +421,8 @@ async def test_type_mismatch(self, mismatch, mismatch_type, server_url_events_ws 'X-Mismatch-Type': mismatch_type, } - async with websockets.connect( - server_url_events_ws, extra_headers=extra_headers + async with websockets.asyncio.client.connect( + server_url_events_ws, additional_headers=extra_headers ) as ws: if mismatch == 'recv': if mismatch_type == 'text': @@ -431,13 +432,13 @@ async def test_type_mismatch(self, mismatch, mismatch_type, server_url_events_ws await ws.wait_closed() - assert ws.close_code in {3011, 1011} + assert ws.protocol.close_code in {3011, 1011} async def test_passing_path_params(self, server_base_url_ws): expected_feed_id = '1ee7' url = f'{server_base_url_ws}feeds/{expected_feed_id}' - async with websockets.connect(url) as ws: + async with websockets.asyncio.client.connect(url) as ws: feed_id = await ws.recv() assert feed_id == expected_feed_id From b541976b7ad5cc8e53bbf454fbc42ba2f71bdff4 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sun, 17 Nov 2024 08:26:56 +0100 Subject: [PATCH 2/6] docs(community): write a guide for packaging Falcon (#2409) * docs(community): create a skeleton for packaging guide * docs(community): further improve pkg guide * docs(community): flesh out pkg guide * docs(community): finalize pkg docs * docs(community): address a couple of review comments * docs(community/packaging): add a note on SemVer + other improvements --- docs/community/index.rst | 1 + docs/community/packaging.rst | 300 +++++++++++++++++++++++++++++++++++ docs/user/intro.rst | 2 +- 3 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 docs/community/packaging.rst diff --git a/docs/community/index.rst b/docs/community/index.rst index 7e800a910..93e82a5b9 100644 --- a/docs/community/index.rst +++ b/docs/community/index.rst @@ -6,4 +6,5 @@ Community Guide help contributing + packaging ../user/faq diff --git a/docs/community/packaging.rst b/docs/community/packaging.rst new file mode 100644 index 000000000..90b47a224 --- /dev/null +++ b/docs/community/packaging.rst @@ -0,0 +1,300 @@ +Packaging Guide +=============== + +Normally, the recommended way to :ref:`install Falcon ` into your +project is using ``pip`` (or a compatible package/project manager, such as +``poetry``, ``uv``, and many others) that fetches package archives from +`PyPI`_. + +However, the `PyPI`_-based way of installation is not always applicable or +optimal. For instance, when the system package manager of a Linux distribution +is used to install Python-based software, it normally gets the dependencies +from the same distribution channel, as the specific versions of the packages +were carefully tested to work well together on the operating system in +question. + +This guide is primarily aimed at engineers who create and maintain Falcon +packages for operating systems such as Linux and BSD distributions, as well as +alternative Python distribution channels such as +`conda-forge `__. + +.. note:: + Unless noted otherwise in specific sections, this document is only + applicable to Falcon 4.0.1 or newer. + +If you run into any packaging issues, questions that this guide does not cover, +or just find a bug in Falcon, please :ref:`let us know `! + + +Obtaining Release +----------------- + +In order to package a specific Falcon release, you first need to obtain its +source archive. + +It is up to you which authoritative source to use. +The most common alternatives are: + +* **Source distribution** (aka "*sdist*") on `PyPI`_. + + If you are unsure, the recommended way is to use our source distribution from + `PyPI`_ (also available on GitHub releases, see below). + + You can query PyPA Warehouse's + `JSON API `__ in order to + obtain the latest stable version of Falcon, fetch the *sdist* URL, and more. + + The API URL specifically for Falcon is https://pypi.org/pypi/falcon/json. + Here is how you can query it using the popular ``requests``: + + >>> import requests + >>> resp = requests.get('https://pypi.org/pypi/falcon/json') + >>> for url in resp.json()['urls']: + ... if url['packagetype'] == 'sdist': + ... print(f'Latest Falcon sdist: {url["url"]}') + ... + Latest Falcon sdist: https://files.pythonhosted.org/<...>/falcon-4.0.2.tar.gz + + (``4.0.2`` was the latest version at the time of this writing.) + +* **GitHub release archive**. + + Alternatively, you can download the archive from our + `Releases on GitHub `__. + GitHub automatically archives the whole repository for every release, and + attaches the tarball to the release page. In addition, our release automation + also uploads the *sdist* (see above) to the release as well. + +* **Clone GitHub repository**. + + If your packaging workflow is based on a Git repository that tracks both the + framework's source code, and your patches or tooling scripts, you will + probably want to clone our + `GitHGub repository `__ instead. + + Every release has a corresponding annotated Git tag that shares the name + with the package version on PyPI, e.g., ``4.0.2``. + + +Semantic Versioning +------------------- + +Falcon strictly adheres to `SemVer `__ -- incompatible API +changes are only introduced in conjunction with a major version increment. + +When updating your Falcon package, you should always carefully review +:doc:`the changelog ` for the new release that you targeting, +especially if you are moving up to a new SemVer major version. +(In that case, the release notes will include a "Breaking Changes" section.) + +For a packager, another section worth checking is called +"Changes to Supported Platforms", where we announce support for new Python +interpreter versions (or even new implementations), as well as deprecate or +remove the old ones. +While there seems to be +`no clear consensus `__ on whether +removing platform support constitutes a SemVer breaking change, Falcon assumes +that it does (unless we have communicated otherwise in advance, e.g., the +:doc:`Falcon 4.x series ` only guarantees Python 3.10+ support). + +.. note:: + The SemVer guarantees primarily cover the publicly documented API from the + framework user's perspective, so even a minor release may contain important + changes to the build process, tests, and project tooling. + + +Metadata and Dependencies +------------------------- + +It is recommend to synchronize the metadata such as the project's description +with recent releases on `PyPI`_. + +Falcon has **no hard runtime dependencies** except the standard Python +library. So depending on how Python is packaged in your distribution +(i.e., whether parts of the stdlib are potentially broken out to separate +packages), Falcon should only depend on the basic installation of the targeted +Python interpreter. + +.. note:: + Falcon has no third-party dependencies since 2.0, however, we were + vendoring the ``python-mimeparse`` library (which also had a different + licence, MIT versus Falcon's Apache 2.0). + + This is no longer a concern as the relevant functionality has been + reimplemented from scratch in Falcon 4.0.0, also fixing some long standing + behavioral quirks and bugs on the way. + As a result, the Falcon 4.x series currently has no vendored dependencies. + +Optional dependencies +^^^^^^^^^^^^^^^^^^^^^ +Falcon has no official list of optional dependencies, but if you want to +provide "suggested packages" or similar, various media (de-) serialization +libraries can make good candidates, especially those that have official media +handlers such as ``msgpack`` (:class:`~falcon.media.MessagePackHandler`). +:class:`~falcon.media.JSONHandler` can be easily customized using faster JSON +implementations such as ``orjson``, ``rapidjson``, etc, so you can suggest +those that are already packaged for your distribution. + +Otherwise, various ASGI and WSGI application servers could also fit the bill. + +See also :ref:`packaging_test_deps` for the list of third party libraries that +we test against in our Continuous Integration (CI) tests. + + +Building Binaries +----------------- + +The absolute minimum in terms of packaging is not building any binaries, but +just distributing the Python modules found under ``falcon/``. This is roughly +equivalent to our pure-Python wheel on `PyPI`_. + +.. tip:: + The easiest way to skip the binaries is to set the + ``FALCON_DISABLE_CYTHON`` environment variable to a non-empty value in the + build environment. + +The framework would still function just fine, however, the overall performance +would be somewhat (~30-40%) lower, and potentially much lower (an order of +magnitude) for certain "hot" code paths that feature a dedicated implementation +in Cython. + +.. note:: + The above notes on performance only apply to CPython. + + In the unlikely case you are packaging Falcon for PyPy, we recommend simply + sticking to pure-Python code. + +In order to build a binary package, you will obviously need a compiler +toolchain, and the CPython library headers. +Hopefully your distribution already has Python tooling that speaks +`PEP 517 `__ -- this is how the framework's +build process is implemented +(using the popular `setuptools `__). + +We also use `cibuildwheel`_ to build our packages that are later uploaded to +`PyPI`_, but we realize that its isolated, Docker-centric approach probably +does not lend itself very well to packaging for a distribution of an operating +system. + +If your build process does not support installation of build dependencies in +a PEP 517 compatible way, you will also have to install Cython yourself +(version 3.0.8 or newer is recommended to build Falcon). + +Big-endian support +^^^^^^^^^^^^^^^^^^ +We regularly build and test :ref:`binary wheels ` on the +IBM Z platform (aka ``s390x``) which is big-endian. +We are not aware of any endianness-related issues. + +32-bit support +^^^^^^^^^^^^^^ +Falcon is not very well tested on 32-bit systems, and we do not provide any +32-bit binary wheels either. We even explicitly fall back to pure-Python code +in some cases such as the multipart form parser (as the smaller ``Py_ssize_t`` +would interfere with uploading of files larger than 2 GiB) if we detect a +32-bit flavor of CPython. + +If you do opt to provide 32-bit Falcon binaries, make sure that you run +:ref:`extensive tests ` against the built package. + + +Building Documentation +---------------------- + +It is quite uncommon to also include offline documentation (or to provide a +separate documentation package) as the user can simply browse our documentation +at `Read the Docs `__. Even if the package does +not contain the latest version of Falcon, it is possible to switch to an +older one using Read the Docs version picker. + +If you do decide to ship the offline docs too, you can build it using +``docs/Makefile`` (you can also invoke ``sphinx-build`` directly). + +.. note:: + Building the HTML documentation requires the packages listed in + ``requirements/docs``. + + Building ``man`` pages requires only Sphinx itself and the plugins + referenced directly in ``docs/conf.py`` + (currently ``myst-parser``, ``sphinx-copybutton``, and ``sphinx-design``). + +* To build HTML docs, use ``make html``. + + The resulting files will be built in ``docs/_build/html/``. + +* To build man pages, use ``make man``. + + The resulting man page file will be called ``docs/_build/man/falcon.1``. + + You will need to rename this file to match your package naming standards, and + copy it an appropriate man page directory + (typically under ``/usr/share/man/`` or similar). + + +.. _packaging_testing: + +Testing Package +--------------- + +When your Falcon package is ready, it is a common (highly recommended!) +practice to install it into your distribution, and run tests verifying that the +package functions as intended. + +As of Falcon 4.0+, the only hard test dependency is ``pytest``. + +You can simply run it against Falcon's test suite found in the ``tests/`` +subdirectory:: + + pytest tests/ + +These tests will provide decent (98-99%), although not complete, code coverage, +and should ensure that the basic wiring of your package is correct +(however, see also the next chapter: :ref:`packaging_test_deps`). + +.. tip:: + You can run ``pytest`` from any directory, i.e., the below should work just + fine:: + + /usr/local/foo-bin/pytest /bar/baz/falcon-release-dir/tests/ + + This pattern is regularly exercised in our CI gates, as `cibuildwheel`_ + (see above) does not run tests from the project's directory either. + +.. _packaging_test_deps: + +Optional test dependencies +^^^^^^^^^^^^^^^^^^^^^^^^^^ +As mentioned above, Falcon has no hard test dependencies except ``pytest``, +however, our test suite includes optional integration tests against a selection +of third-party libraries. + +When building :ref:`wheels ` with `cibuildwheel`_, we install a +small subset of the basic optional test dependencies, see the +``requirements/cibwtest`` file in the repository. +Furthermore, when running our full test suite in the CI, we exercise +integration with a larger number of optional libraries and applications servers +(see the ``requirements/tests`` file, as well as various ASGI/WSGI server +integration test definitions in ``tox.ini``). + +Ideally, if your distribution also provides packages for any of the above +optional test dependencies, it may be a good idea to install them into your +test environment as well. This will help verifying that your Falcon package is +compatible with the specific versions of these packages in your distribution. + + +.. _packaging_thank_you: + +Thank You +--------- + +If you are already maintaining Falcon packages, thank you! + +Although we do not have the bandwidth to maintain Falcon packages for any +distribution channel beyond `PyPI`_ ourselves, we are happy to help if you run +into any problems. File an +`issue on GitHub `__, +or just :ref:`send us a message `! + + +.. _PyPI: https://pypi.org/project/falcon/ +.. _cibuildwheel: https://cibuildwheel.pypa.io/ diff --git a/docs/user/intro.rst b/docs/user/intro.rst index ba35f6199..d7fdb2cd2 100644 --- a/docs/user/intro.rst +++ b/docs/user/intro.rst @@ -55,7 +55,7 @@ Falcon + PyPy first. **Reliable.** We go to great lengths to avoid introducing breaking changes, and when we do they are fully documented and only introduced (in the spirit of -`SemVer `__) with a major version +`SemVer `__) with a major version increment. The code is rigorously tested with numerous inputs and we require 100% coverage at all times. Falcon does not depend on any external Python packages. From 66eb88650cea8a9eaa5771c47b45223d6b064303 Mon Sep 17 00:00:00 2001 From: Jasper Spaans Date: Wed, 20 Nov 2024 12:35:04 +0100 Subject: [PATCH 3/6] fix(typing): use proper type for SinkCallable **kwargs (#2411) --- falcon/_typing.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/falcon/_typing.py b/falcon/_typing.py index d82a5bac5..c8f27c877 100644 --- a/falcon/_typing.py +++ b/falcon/_typing.py @@ -92,12 +92,14 @@ async def __call__( class SinkCallable(Protocol): - def __call__(self, req: Request, resp: Response, **kwargs: str) -> None: ... + def __call__( + self, req: Request, resp: Response, **kwargs: Optional[str] + ) -> None: ... class AsgiSinkCallable(Protocol): async def __call__( - self, req: AsgiRequest, resp: AsgiResponse, **kwargs: str + self, req: AsgiRequest, resp: AsgiResponse, **kwargs: Optional[str] ) -> None: ... From 5f295ab55572f601ae3c73769caca9cb6f9999b8 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 27 Nov 2024 21:49:38 +0100 Subject: [PATCH 4/6] chore: implement initial support for CPython 3.14 (#2412) This patch implements rudimentary support for CPython 3.14, adapting our monkey-patching of Morsel attributes to a new optimization in 3.14 stdlib. In addition, the monkey patching of the SameSite attribute was removed, since it is no longer needed on 3.8+. --- .github/workflows/tests.yaml | 2 ++ docs/api/cookies.rst | 17 +++++------------ falcon/util/__init__.py | 22 ++++++++++++++-------- requirements/tests | 3 ++- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 0a5106a6c..bfa969e8e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -59,6 +59,8 @@ jobs: python-version: "3.13" - env: py313_cython python-version: "3.13" + - env: py314 + python-version: "3.14.0-alpha.2 - 3.14.0" - env: py312_nocover os: macos-latest platform-label: ' (macos)' diff --git a/docs/api/cookies.rst b/docs/api/cookies.rst index 5089957cc..1b0a3e50e 100644 --- a/docs/api/cookies.rst +++ b/docs/api/cookies.rst @@ -155,13 +155,6 @@ at least set this attribute to ``'Lax'`` in order to mitigate Currently, :meth:`~falcon.Response.set_cookie` does not set `SameSite` by default, although this may change in a future release. -.. note:: - - The standard ``http.cookies`` module does not support the `SameSite` - attribute in versions prior to Python 3.8. Therefore, Falcon performs a - simple monkey-patch on the standard library module to backport this - feature for apps running on older Python versions. - .. _RFC 6265, Section 4.1.2.5: https://tools.ietf.org/html/rfc6265#section-4.1.2.5 @@ -172,7 +165,7 @@ by setting the 'samesite' kwarg. The Partitioned Attribute ~~~~~~~~~~~~~~~~~~~~~~~~~ -Starting from Q1 2024, Google Chrome will start to +Starting from Q1 2024, Google Chrome started to `phase out support for third-party cookies `__. If your site is relying on cross-site cookies, it might be necessary to set the @@ -187,7 +180,7 @@ automatically depending on other attributes (like ``SameSite``), although this may change in a future release. .. note:: - Similar to ``SameSite`` on older Python versions, the standard - :mod:`http.cookies` module does not support the ``Partitioned`` attribute - yet, and Falcon performs the same monkey-patching as it did for - ``SameSite``. + The standard :mod:`http.cookies` module does not support the `Partitioned` + attribute in versions prior to Python 3.14. Therefore, Falcon performs a + simple monkey-patch on the standard library module to backport this + feature for apps running on older Python versions. diff --git a/falcon/util/__init__.py b/falcon/util/__init__.py index a52a197cd..d2af7c58e 100644 --- a/falcon/util/__init__.py +++ b/falcon/util/__init__.py @@ -52,17 +52,23 @@ from falcon.util.sync import wrap_sync_to_async_unsafe from falcon.util.time import TimezoneGMT -# NOTE(kgriffs): Backport support for the new 'SameSite' attribute -# for Python versions prior to 3.8. We do it this way because -# SimpleCookie does not give us a simple way to specify our own -# subclass of Morsel. +# NOTE(kgriffs, m-mueller): Monkey-patch support for the new 'Partitioned' +# attribute that will probably be added in Python 3.14. +# We do it this way because SimpleCookie does not give us a simple way to +# specify our own subclass of Morsel. _reserved_cookie_attrs = http_cookies.Morsel._reserved # type: ignore -if 'samesite' not in _reserved_cookie_attrs: # pragma: no cover - _reserved_cookie_attrs['samesite'] = 'SameSite' -# NOTE(m-mueller): Same for the 'partitioned' attribute that will -# probably be added in Python 3.13 or 3.14. if 'partitioned' not in _reserved_cookie_attrs: # pragma: no cover _reserved_cookie_attrs['partitioned'] = 'Partitioned' +# NOTE(vytas): Morsel._reserved_defaults is a new optimization in Python 3.14+ +# for faster initialization of Morsel instances. +# TODO(vytas): Remove this part of monkey-patching in the case CPython 3.14 +# adds the Partitioned attribute to Morsel. +_reserved_cookie_defaults = getattr(http_cookies.Morsel, '_reserved_defaults', None) +if ( + _reserved_cookie_defaults is not None + and 'partitioned' not in _reserved_cookie_defaults +): # pragma: no cover + _reserved_cookie_defaults['partitioned'] = '' IS_64_BITS = sys.maxsize > 2**32 diff --git a/requirements/tests b/requirements/tests index 36825fd23..6f6788592 100644 --- a/requirements/tests +++ b/requirements/tests @@ -26,4 +26,5 @@ ujson python-rapidjson; platform_python_implementation != 'PyPy' and platform_machine != 's390x' and platform_machine != 'aarch64' # wheels are missing some EoL interpreters and non-x86 platforms; build would fail unless rust is available -orjson; platform_python_implementation != 'PyPy' and platform_machine != 's390x' and platform_machine != 'aarch64' +# (not available for CPython 3.14 either yet) +orjson; python_version < '3.14' and platform_python_implementation != 'PyPy' and platform_machine != 's390x' and platform_machine != 'aarch64' From 11076b8fc78bb61707c271953783198f4b6a04f1 Mon Sep 17 00:00:00 2001 From: Agustin Arce <59893355+aarcex3@users.noreply.github.com> Date: Sun, 8 Dec 2024 18:12:29 -0300 Subject: [PATCH 5/6] feat(testing): add `raw_path` to ASGI scope (#2331) * style (testing): fix errors in gates * docs (newsfragment): add newsfragment * refactor (testing): refactor asgi test * test (test_recipes): update test for asgi scope * docs(testing): clean up the docs, tests and notes before merging * docs(testing): add a versionadded note, improve impl --------- Co-authored-by: Vytautas Liuolia --- docs/_newsfragments/2262.newandimproved.rst | 5 +++++ falcon/testing/helpers.py | 9 ++++++++- tests/asgi/test_testing_asgi.py | 18 +++++++++++++++++ tests/test_recipes.py | 22 ++++++++------------- 4 files changed, 39 insertions(+), 15 deletions(-) create mode 100644 docs/_newsfragments/2262.newandimproved.rst diff --git a/docs/_newsfragments/2262.newandimproved.rst b/docs/_newsfragments/2262.newandimproved.rst new file mode 100644 index 000000000..374c5b642 --- /dev/null +++ b/docs/_newsfragments/2262.newandimproved.rst @@ -0,0 +1,5 @@ +Similar to :func:`~falcon.testing.create_environ`, +the :func:`~falcon.testing.create_scope` testing helper now preserves the raw URI path, +and propagates it to the created ASGI connection scope as the ``raw_path`` byte string +(according to the `ASGI specification +`__). diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index 97392d57a..0fc95bc8f 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -974,10 +974,16 @@ def create_scope( iterable yielding a series of two-member (*name*, *value*) iterables. Each pair of items provides the name and value for the 'Set-Cookie' header. + + .. versionadded:: 4.1 + The raw (i.e., not URL-decoded) version of the provided `path` is now + preserved in the returned scope as the ``raw_path`` byte string. + According to the ASGI specification, ``raw_path`` **does not include** + any query string. """ http_version = _fixup_http_version(http_version) - + raw_path, _, _ = path.partition('?') path = uri.decode(path, unquote_plus=False) # NOTE(kgriffs): Handles both None and '' @@ -995,6 +1001,7 @@ def create_scope( 'http_version': http_version, 'method': method.upper(), 'path': path, + 'raw_path': raw_path.encode(), 'query_string': query_string_bytes, } diff --git a/tests/asgi/test_testing_asgi.py b/tests/asgi/test_testing_asgi.py index 8ec041361..67db94cc2 100644 --- a/tests/asgi/test_testing_asgi.py +++ b/tests/asgi/test_testing_asgi.py @@ -153,3 +153,21 @@ def test_immediate_disconnect(): with pytest.raises(ConnectionError): client.simulate_get('/', asgi_disconnect_ttl=0) + + +@pytest.mark.parametrize( + 'path, expected', + [ + ('/cache/http%3A%2F%2Ffalconframework.org/status', True), + ( + '/cache/http%3A%2F%2Ffalconframework.org/status?param1=value1¶m2=value2', + False, + ), + ], +) +def test_create_scope_preserve_raw_path(path, expected): + scope = testing.create_scope(path=path) + if expected: + assert scope['raw_path'] == path.encode() + else: + assert scope['raw_path'] != path.encode() diff --git a/tests/test_recipes.py b/tests/test_recipes.py index 9b2160087..be2e9af9e 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -115,29 +115,23 @@ def test_optional_indent(self, util): class TestRawURLPath: - def path_extras(self, asgi, url): - if asgi: - return {'raw_path': url.encode()} - return None - def test_raw_path(self, asgi, app_kind, util): recipe = util.load_module( 'raw_url_path', parent_dir='examples/recipes', suffix=app_kind ) - # TODO(vytas): Improve TestClient to automatically add ASGI raw_path - # (as it does for WSGI): GH #2262. - url1 = '/cache/http%3A%2F%2Ffalconframework.org' - result1 = falcon.testing.simulate_get( - recipe.app, url1, extras=self.path_extras(asgi, url1) - ) + result1 = falcon.testing.simulate_get(recipe.app, url1) assert result1.status_code == 200 assert result1.json == {'url': 'http://falconframework.org'} + scope1 = falcon.testing.create_scope(url1) + assert scope1['raw_path'] == url1.encode() + url2 = '/cache/http%3A%2F%2Ffalconframework.org/status' - result2 = falcon.testing.simulate_get( - recipe.app, url2, extras=self.path_extras(asgi, url2) - ) + result2 = falcon.testing.simulate_get(recipe.app, url2) assert result2.status_code == 200 assert result2.json == {'cached': True} + + scope2 = falcon.testing.create_scope(url2) + assert scope2['raw_path'] == url2.encode() From 77d5e6394a88ead151c9469494749f95f06b24bf Mon Sep 17 00:00:00 2001 From: EricGoulart <106963488+EricGoulart@users.noreply.github.com> Date: Fri, 27 Dec 2024 07:33:34 -0300 Subject: [PATCH 6/6] refactor: update request-id recipe to use contextvars (#2404) * refactor: request-id to use contextvars Migrate the request-id handling from threading.local() to contextvars to improve compatibility with async coroutines and avoid issues with threading. This change ensures that request-id is properly tracked in asynchronous environments, providing more robust handling in both sync and async contexts. Previously, threading.local() was used, which does not handle coroutines effectively. By using contextvars, we ensure that the request-id remains consistent across async calls. Closes #2260 * adding test to test_recipes * test(request_id): add tests for request id middleware context handling Add tests to verify that request_id is unique per request and correctly set in response headers. Tests include checks for isolation in async calls and persistence in synchronous requests. Related to issue #2260 * test(request_id): add tests for request id middleware context handling Add tests to verify that request_id is unique per request and correctly set in response headers. Tests include checks for isolation in async calls and persistence in synchronous requests. Related to issue falconry#2260 * minor changes in tests * fix(recipes): fix request-id recipe tests --------- Co-authored-by: Vytautas Liuolia --- docs/user/recipes/request-id.rst | 6 ++-- examples/recipes/request_id_context.py | 8 ++--- examples/recipes/request_id_middleware.py | 4 ++- tests/conftest.py | 8 +++-- tests/test_recipes.py | 44 +++++++++++++++++++++++ 5 files changed, 60 insertions(+), 10 deletions(-) diff --git a/docs/user/recipes/request-id.rst b/docs/user/recipes/request-id.rst index 9274a5ad8..7a69b8336 100644 --- a/docs/user/recipes/request-id.rst +++ b/docs/user/recipes/request-id.rst @@ -10,14 +10,14 @@ to every log entry. If you wish to trace each request throughout your application, including from within components that are deeply nested or otherwise live outside of the -normal request context, you can use a `thread-local`_ context object to store +normal request context, you can use a `contextvars`_ object to store the request ID: .. literalinclude:: ../../../examples/recipes/request_id_context.py :language: python Then, you can create a :ref:`middleware ` class to generate a -unique ID for each request, persisting it in the thread local: +unique ID for each request, persisting it in the `contextvars` object: .. literalinclude:: ../../../examples/recipes/request_id_middleware.py :language: python @@ -48,4 +48,4 @@ In a pinch, you can also output the request ID directly: .. literalinclude:: ../../../examples/recipes/request_id_log.py :language: python -.. _thread-local: https://docs.python.org/3/library/threading.html#thread-local-data +.. _contextvars: https://docs.python.org/3/library/contextvars.html diff --git a/examples/recipes/request_id_context.py b/examples/recipes/request_id_context.py index d071c904d..a7e36ddc3 100644 --- a/examples/recipes/request_id_context.py +++ b/examples/recipes/request_id_context.py @@ -1,19 +1,19 @@ # context.py -import threading +import contextvars class _Context: def __init__(self): - self._thread_local = threading.local() + self._request_id_var = contextvars.ContextVar('request_id', default=None) @property def request_id(self): - return getattr(self._thread_local, 'request_id', None) + return self._request_id_var.get() @request_id.setter def request_id(self, value): - self._thread_local.request_id = value + self._request_id_var.set(value) ctx = _Context() diff --git a/examples/recipes/request_id_middleware.py b/examples/recipes/request_id_middleware.py index e99109b8d..4996b57fe 100644 --- a/examples/recipes/request_id_middleware.py +++ b/examples/recipes/request_id_middleware.py @@ -7,7 +7,9 @@ class RequestIDMiddleware: def process_request(self, req, resp): - ctx.request_id = str(uuid4()) + request_id = str(uuid4()) + ctx.request_id = request_id + req.context.request_id = request_id # It may also be helpful to include the ID in the response def process_response(self, req, resp, resource, req_succeeded): diff --git a/tests/conftest.py b/tests/conftest.py index defdaddb8..b7e70a8ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ import json import os import pathlib +import sys import urllib.parse import pytest @@ -96,7 +97,7 @@ def disable_asgi_non_coroutine_wrapping(): os.environ['FALCON_ASGI_WRAP_NON_COROUTINES'] = 'Y' @staticmethod - def load_module(filename, parent_dir=None, suffix=None): + def load_module(filename, parent_dir=None, suffix=None, module_name=None): if parent_dir: filename = pathlib.Path(parent_dir) / filename else: @@ -105,11 +106,14 @@ def load_module(filename, parent_dir=None, suffix=None): if suffix is not None: path = path.with_name(f'{path.stem}_{suffix}.py') prefix = '.'.join(filename.parent.parts) - module_name = f'{prefix}.{path.stem}' + sys_module_name = module_name + module_name = module_name or f'{prefix}.{path.stem}' spec = importlib.util.spec_from_file_location(module_name, path) assert spec is not None, f'could not load module from {path}' module = importlib.util.module_from_spec(spec) + if sys_module_name: + sys.modules[sys_module_name] = module spec.loader.exec_module(module) return module diff --git a/tests/test_recipes.py b/tests/test_recipes.py index be2e9af9e..a60e26838 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -135,3 +135,47 @@ def test_raw_path(self, asgi, app_kind, util): scope2 = falcon.testing.create_scope(url2) assert scope2['raw_path'] == url2.encode() + + +class TestRequestIDContext: + @pytest.fixture + def app(self, util): + # NOTE(vytas): Inject `context` into the importable system modules + # as it is referenced from other recipes. + util.load_module( + 'examples/recipes/request_id_context.py', module_name='context' + ) + recipe = util.load_module('examples/recipes/request_id_middleware.py') + + app = falcon.App(middleware=[recipe.RequestIDMiddleware()]) + app.add_route('/test', self.RequestIDResource()) + return app + + class RequestIDResource: + def on_get(self, req, resp): + resp.media = {'request_id': req.context.request_id} + + def test_request_id_isolated(self, app): + client = falcon.testing.TestClient(app) + request_id1 = client.simulate_get('/test').json['request_id'] + request_id2 = client.simulate_get('/test').json['request_id'] + + assert request_id1 != request_id2 + + def test_request_id_persistence(self, app): + client = falcon.testing.TestClient(app) + + response = client.simulate_get('/test') + request_id1 = response.json['request_id'] + + response = client.simulate_get('/test') + request_id2 = response.json['request_id'] + + assert request_id1 != request_id2 + + def test_request_id_in_response_header(self, app): + client = falcon.testing.TestClient(app) + + response = client.simulate_get('/test') + assert 'X-Request-ID' in response.headers + assert response.headers['X-Request-ID'] == response.json['request_id']