From 8d03fb57ef32e33cffe4cc38a87cc11515318e2a Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Mon, 4 Nov 2024 13:12:13 +0100 Subject: [PATCH 1/9] chore: publish sdist & pure-Python wheel without waiting for binaries (#2391) --- .github/workflows/cibuildwheel.yaml | 98 ++++++++++++++--------------- 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/.github/workflows/cibuildwheel.yaml b/.github/workflows/cibuildwheel.yaml index fc64534b6..5e0ba8554 100644 --- a/.github/workflows/cibuildwheel.yaml +++ b/.github/workflows/cibuildwheel.yaml @@ -52,6 +52,54 @@ jobs: name: cibw-sdist path: dist/falcon-* + publish-sdist: + name: publish-sdist + needs: + - build-sdist + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + pattern: cibw-sdist + path: dist + merge-multiple: true + + - name: Check collected artifacts + run: | + tools/check_dist.py ${{ github.event_name == 'release' && format('-r {0}', github.ref) || '' }} + + - name: Upload sdist to release + uses: AButler/upload-release-assets@v3.0 + if: github.event_name == 'release' + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + files: 'dist/*.tar.gz' + + - name: Publish sdist and pure-Python wheel to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + if: github.event_name == 'workflow_dispatch' + with: + password: ${{ secrets.TEST_PYPI_TOKEN }} + repository-url: https://test.pypi.org/legacy/ + + - name: Publish sdist and pure-Python wheel to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + if: github.event_name == 'release' + with: + password: ${{ secrets.PYPI_TOKEN }} + build-wheels: name: ${{ matrix.python }}-${{ matrix.platform.name }} needs: build-sdist @@ -123,59 +171,9 @@ jobs: name: cibw-wheel-${{ matrix.python }}-${{ matrix.platform.name }} path: wheelhouse/falcon-*.whl - publish-sdist: - name: publish-sdist - needs: - - build-sdist - - build-wheels - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - pattern: cibw-sdist - path: dist - merge-multiple: true - - - name: Check collected artifacts - run: | - tools/check_dist.py ${{ github.event_name == 'release' && format('-r {0}', github.ref) || '' }} - - - name: Upload sdist to release - uses: AButler/upload-release-assets@v3.0 - if: github.event_name == 'release' - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - files: 'dist/*.tar.gz' - - - name: Publish sdist and pure-Python wheel to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 - if: github.event_name == 'workflow_dispatch' - with: - password: ${{ secrets.TEST_PYPI_TOKEN }} - repository-url: https://test.pypi.org/legacy/ - - - name: Publish sdist and pure-Python wheel to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - if: github.event_name == 'release' - with: - password: ${{ secrets.PYPI_TOKEN }} - publish-wheels: name: publish-wheels needs: - - build-sdist - build-wheels - publish-sdist runs-on: ubuntu-latest From 5c4406cc7e7c04542f28cd837ed4aac3d3c31a23 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Mon, 4 Nov 2024 19:46:45 +0100 Subject: [PATCH 2/9] docs(changes): add a newsfragment for #2365 (#2400) --- .readthedocs.yaml | 5 ++++- docs/_newsfragments/2365.misc.rst | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 docs/_newsfragments/2365.misc.rst diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 20221e8d0..837ab8dcb 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -13,8 +13,11 @@ build: # Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/conf.py + +# Optionally build your docs in additional formats such as PDF and ePub formats: - - pdf + - pdf + # We recommend specifying your dependencies to enable reproducible builds: # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html python: diff --git a/docs/_newsfragments/2365.misc.rst b/docs/_newsfragments/2365.misc.rst new file mode 100644 index 000000000..e379cb63a --- /dev/null +++ b/docs/_newsfragments/2365.misc.rst @@ -0,0 +1 @@ +The printable PDF version of our documentation was enabled on Read the Docs. From ca2e6d9f493936e7f58c578b64d11a0a4dd2f9d2 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Mon, 4 Nov 2024 21:23:48 +0100 Subject: [PATCH 3/9] docs(typing): restructure typing docs into a separate chapter (#2401) * docs(typing): restructure typing docs into a separate chapter * docs(typing): tweak misc stuff for consistency --------- Co-authored-by: Federico Caselli --- docs/api/index.rst | 1 + docs/api/typing.rst | 75 +++++++++++++++++++++++++++++++++++++++++++++ docs/api/util.rst | 6 ---- falcon/typing.py | 2 +- 4 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 docs/api/typing.rst diff --git a/docs/api/index.rst b/docs/api/index.rst index fdeaadfe3..80ca97517 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -20,3 +20,4 @@ Framework Reference inspect util testing + typing diff --git a/docs/api/typing.rst b/docs/api/typing.rst new file mode 100644 index 000000000..5bca16c44 --- /dev/null +++ b/docs/api/typing.rst @@ -0,0 +1,75 @@ +Typing +====== + +Type checking support was introduced in version 4.0. While most of the library is +now typed, further type annotations may be added throughout the 4.x release cycle. +To improve them, we may introduce changes to the typing that do not affect +runtime behavior, but may surface new or different errors with type checkers. + +.. role:: python(code) + :language: python + +.. note:: + All undocumented type aliases coming from ``falcon._typing`` are considered + private to the framework itself, and not meant for annotating applications + using Falcon. To that end, it is advisable to only use classes from the + public interface, and public aliases from :mod:`falcon.typing`, e.g.: + + .. code-block:: python + + class MyResource: + def on_get(self, req: falcon.Request, resp: falcon.Response) -> None: + resp.media = {'message': 'Hello, World!'} + + If you still decide to reuse the private aliases anyway, they should + preferably be imported inside :python:`if TYPE_CHECKING:` blocks in order + to avoid possible runtime errors after an update. + Also, make sure to :ref:`let us know ` which essential aliases are + missing from the public interface! + + +Known Limitations +----------------- + +Falcon's emphasis on flexibility and performance presents certain +challenges when it comes to adding type annotations to the existing code base. +One notable limitation involves using custom :class:`~falcon.Request` and/or +:class:`~falcon.Response` types in callbacks that are passed back +to the framework, such as when adding an +:meth:`error handler `. + +For instance, the following application might unexpectedly not pass type +checking: + +.. code-block:: python + + from typing import Any + + from falcon import App, HTTPInternalServerError, Request, Response + + + class MyRequest(Request): + ... + + + def handle_os_error(req: MyRequest, resp: Response, ex: Exception, + params: dict[str, Any]) -> None: + raise HTTPInternalServerError(title='OS error!') from ex + + + app = App(request_type=MyRequest) + app.add_error_handler(OSError, handle_os_error) + +(Please also see the following GitHub issue: +`#2372 `__.) + +.. important:: + This is only a typing limitation that has no effect outside of type + checking -- the above ``app`` will run just fine! + + +Public Type Aliases +------------------- + +.. automodule:: falcon.typing + :members: diff --git a/docs/api/util.rst b/docs/api/util.rst index 254b4cb60..93fa163a4 100644 --- a/docs/api/util.rst +++ b/docs/api/util.rst @@ -89,9 +89,3 @@ Other .. autoclass:: falcon.ETag :members: - -Type Aliases ------------- - -.. automodule:: falcon.typing - :members: diff --git a/falcon/typing.py b/falcon/typing.py index 1eadeee9f..dd4aa8212 100644 --- a/falcon/typing.py +++ b/falcon/typing.py @@ -26,7 +26,7 @@ # WSGI class ReadableIO(Protocol): - """File like protocol that defines only a read method.""" + """File-like protocol that defines only a read method.""" def read(self, n: Optional[int] = ..., /) -> bytes: ... From ab2ce4c5b41b77baee4d99f9edb01eeefe993b1b Mon Sep 17 00:00:00 2001 From: Alessandro Chitarrini <140112899+chitvs@users.noreply.github.com> Date: Tue, 5 Nov 2024 16:43:58 +0100 Subject: [PATCH 4/9] docs(FAQ): add entry for error tracebacks in ASGI apps (#2395) * Add FAQ entry for error tracebacks in ASGI applications and update tutorial for logging * Remove unnecessary news fragments and update changelog for ASGI error logging * Revert "Remove unnecessary news fragments and update changelog for ASGI error logging" This reverts commit 32255624187c09a9194ff2c4bdd96047e0a4f6cc. * moved link to related topic from faq to tutorial-asgi --------- Co-authored-by: Vytautas Liuolia --- docs/user/faq.rst | 45 +++++++++++++++++++++++++++++++++++++ docs/user/tutorial-asgi.rst | 6 ++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/docs/user/faq.rst b/docs/user/faq.rst index 789379600..6790c8773 100644 --- a/docs/user/faq.rst +++ b/docs/user/faq.rst @@ -1347,3 +1347,48 @@ Alternatively, you can set the Cookie header directly as demonstrated in this ve To include multiple values, simply use ``"; "`` to separate each name-value pair. For example, if you were to pass ``{'Cookie': 'xxx=yyy; hello=world'}``, you would get ``{'cookies': {'xxx': 'yyy', 'hello': 'world'}}``. + +Why do I not see error tracebacks in ASGI applications? +------------------------------------------------------- + +When using Falcon with ASGI servers like Uvicorn, +you might notice that server errors do not display a traceback by default. +This behavior differs from WSGI applications, where errors are logged to `stderr`, +providing detailed tracebacks. + +The reason for this is that ASGI does not define a standardized way to log errors back to the application server, +unlike WSGI. Therefore, you need to configure logging manually to see these tracebacks. + +Here’s how to set up logging in your ASGI Falcon application to capture error tracebacks: + +.. code:: python + + import logging + import falcon + import falcon.asgi + + logging.basicConfig( + format="%(asctime)s [%(levelname)s] %(message)s", + level=logging.INFO + ) + + class ThingsResource: + async def on_get(self, req, resp): + raise ValueError('foo') + + app = falcon.asgi.App() + things = ThingsResource() + app.add_route('/things', things) + +By adding the above logging configuration, you will see tracebacks like this in your console: + +.. code-block:: none + + [ERROR] [FALCON] Unhandled exception in ASGI app + Traceback (most recent call last): + File "<...>", line 12, in on_get + raise ValueError('foo') + ValueError: foo + +For additional details on this topic, +please refer to :ref:`debugging-asgi-applications`. \ No newline at end of file diff --git a/docs/user/tutorial-asgi.rst b/docs/user/tutorial-asgi.rst index 643d87f27..8450a9efd 100644 --- a/docs/user/tutorial-asgi.rst +++ b/docs/user/tutorial-asgi.rst @@ -963,6 +963,8 @@ adding ``--cov-fail-under=100`` (or any other percent threshold) to our tests in multiple environments would most probably involve running ``coverage`` directly, and combining results. +.. _debugging-asgi-applications: + Debugging ASGI Applications --------------------------- (This section also applies to WSGI applications) @@ -979,10 +981,8 @@ your ASGI Falcon application: .. code:: python import logging - import falcon - logging.basicConfig(level=logging.INFO) class ErrorResource: @@ -992,7 +992,6 @@ your ASGI Falcon application: app = falcon.App() app.add_route('/error', ErrorResource()) - When the above route is accessed, Falcon will catch the unhandled exception and automatically log an error message. Below is an example of what the log output might look like: @@ -1007,7 +1006,6 @@ might look like: raise Exception("Something went wrong!") Exception: Something went wrong! - .. note:: While logging is helpful for development and debugging, be mindful of logging sensitive information. Ensure that log files are stored securely and are not From 4ac5ec6ff6c7502873bdb3b4d79af5c960126028 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 6 Nov 2024 19:29:37 +0100 Subject: [PATCH 5/9] chore: prepare Falcon 4.0.2 (in-tree) (#2392) * chore: prepare 4.0.2 in-tree (WiP) * chore: relabel #2387 as bugfix * chore: aggregate 4.0.2 contributors * chore: bump up version to `4.0.2` * docs: slightly tweak newsfragment for #2387 * chore: aggregate contributors, +docs tweaks * docs(ASGI): clean up docs on logging setup * chore: render out newsfragments * docs(typing); describe another limitation/inconsistency --- AUTHORS | 3 + docs/_newsfragments/2365.misc.rst | 1 - docs/_newsfragments/2387.misc.rst | 7 -- docs/api/typing.rst | 16 +++-- docs/changes/4.0.2.rst | 38 +++++++++++ docs/changes/4.1.0.rst | 25 ------- docs/changes/index.rst | 2 +- docs/user/faq.rst | 49 +++++++------ docs/user/tutorial-asgi.rst | 110 +++++++++++++++++------------- falcon/typing.py | 17 ++++- falcon/version.py | 2 +- pyproject.toml | 2 +- 12 files changed, 155 insertions(+), 117 deletions(-) delete mode 100644 docs/_newsfragments/2365.misc.rst delete mode 100644 docs/_newsfragments/2387.misc.rst create mode 100644 docs/changes/4.0.2.rst delete mode 100644 docs/changes/4.1.0.rst diff --git a/AUTHORS b/AUTHORS index 95b44b1fd..9c1976cb3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -199,6 +199,9 @@ listed below by date of first contribution: * Agustin Arce (aarcex3) * Christian Grossmüller (chgad) * Sai Prathik R (prathik2401) +* Akshay Awate (AkshayAwate) +* Jasper Spaans (jap) +* Alessandro Chitarrini (chitvs) (et al.) diff --git a/docs/_newsfragments/2365.misc.rst b/docs/_newsfragments/2365.misc.rst deleted file mode 100644 index e379cb63a..000000000 --- a/docs/_newsfragments/2365.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The printable PDF version of our documentation was enabled on Read the Docs. diff --git a/docs/_newsfragments/2387.misc.rst b/docs/_newsfragments/2387.misc.rst deleted file mode 100644 index 0aa219996..000000000 --- a/docs/_newsfragments/2387.misc.rst +++ /dev/null @@ -1,7 +0,0 @@ -Running mypy on code that uses parts of ``falcon.testing`` naively -would lead to errors like:: - - Name "falcon.testing.TestClient" is not defined - -This has been fixed by explicitly exporting the names that are -imported in the ``falcon.testing`` namespace. diff --git a/docs/api/typing.rst b/docs/api/typing.rst index 5bca16c44..3dab9abfe 100644 --- a/docs/api/typing.rst +++ b/docs/api/typing.rst @@ -33,11 +33,11 @@ Known Limitations Falcon's emphasis on flexibility and performance presents certain challenges when it comes to adding type annotations to the existing code base. + One notable limitation involves using custom :class:`~falcon.Request` and/or :class:`~falcon.Response` types in callbacks that are passed back to the framework, such as when adding an :meth:`error handler `. - For instance, the following application might unexpectedly not pass type checking: @@ -60,12 +60,20 @@ checking: app = App(request_type=MyRequest) app.add_error_handler(OSError, handle_os_error) -(Please also see the following GitHub issue: +(We are working on addressing this limitation at the time of writing -- +please see the following GitHub issue for the progress, and possible solutions: `#2372 `__.) +Another known inconsistency is the typing of the +:class:`converter interface `, where certain +subclasses (such as :class:`~falcon.routing.PathConverter`) declare a different +input type than the base ``convert()`` method. +(See also the discussions and possible solutions on the GitHub issue +`#2396 `__.) + .. important:: - This is only a typing limitation that has no effect outside of type - checking -- the above ``app`` will run just fine! + The above issues are only typing limitations that have no effect outside of + type checking -- applications will work just fine at runtime! Public Type Aliases diff --git a/docs/changes/4.0.2.rst b/docs/changes/4.0.2.rst new file mode 100644 index 000000000..521c3d591 --- /dev/null +++ b/docs/changes/4.0.2.rst @@ -0,0 +1,38 @@ +Changelog for Falcon 4.0.2 +========================== + +Summary +------- + +This is a minor point release to fix some missed re-exports for type checkers. +In addition, we have also included a couple of documentation improvements. + + +Fixed +----- + +- Running Mypy on code that uses parts of ``falcon.testing`` + would previously lead to errors like:: + + Name "falcon.testing.TestClient" is not defined + + This has been fixed by explicitly exporting the names that are + imported into the ``falcon.testing`` namespace. (`#2387 `__) + + +Misc +---- + +- The printable PDF version of our documentation was enabled on Read the Docs. (`#2365 `__) + + +Contributors to this Release +---------------------------- + +Many thanks to those who contributed to this bugfix release: + +- `AkshayAwate `__ +- `CaselIT `__ +- `chitvs `__ +- `jap `__ +- `vytas7 `__ diff --git a/docs/changes/4.1.0.rst b/docs/changes/4.1.0.rst deleted file mode 100644 index c9a6f005a..000000000 --- a/docs/changes/4.1.0.rst +++ /dev/null @@ -1,25 +0,0 @@ -Changelog for Falcon 4.1.0 -========================== - -Summary -------- - -Falcon 4.1 is in development. The progress is tracked via the -`Version 4.1 milestone `__ -on GitHub. - - -Changes to Supported Platforms ------------------------------- - -.. NOTE(vytas): No changes to the supported platforms (yet). - - -.. towncrier release notes start - -Contributors to this Release ----------------------------- - -Many thanks to all of our talented and stylish contributors for this release! - -- `vytas7 `__ diff --git a/docs/changes/index.rst b/docs/changes/index.rst index cec3621d2..24deb80b7 100644 --- a/docs/changes/index.rst +++ b/docs/changes/index.rst @@ -3,7 +3,7 @@ Changelogs .. toctree:: - 4.1.0 <4.1.0> + 4.0.2 <4.0.2> 4.0.1 <4.0.1> 4.0.0 <4.0.0> 3.1.3 <3.1.3> diff --git a/docs/user/faq.rst b/docs/user/faq.rst index 6790c8773..414cf08d3 100644 --- a/docs/user/faq.rst +++ b/docs/user/faq.rst @@ -1348,47 +1348,44 @@ To include multiple values, simply use ``"; "`` to separate each name-value pair. For example, if you were to pass ``{'Cookie': 'xxx=yyy; hello=world'}``, you would get ``{'cookies': {'xxx': 'yyy', 'hello': 'world'}}``. -Why do I not see error tracebacks in ASGI applications? -------------------------------------------------------- +Why do I see no error tracebacks in my ASGI application? +-------------------------------------------------------- -When using Falcon with ASGI servers like Uvicorn, -you might notice that server errors do not display a traceback by default. -This behavior differs from WSGI applications, where errors are logged to `stderr`, -providing detailed tracebacks. +When using Falcon with an ASGI server like Uvicorn, +you might notice that server errors do not include any traceback by default. +This behavior differs from WSGI, where the PEP-3333 specification defines the +`wsgi.errors `__ stream +(which Falcon utilizes to log unhandled +:class:`internal server errors `). -The reason for this is that ASGI does not define a standardized way to log errors back to the application server, -unlike WSGI. Therefore, you need to configure logging manually to see these tracebacks. +Since there is no standardized way to log errors back to the ASGI server, +the framework simply opts to log them using the ``falcon`` +:class:`logger `. -Here’s how to set up logging in your ASGI Falcon application to capture error tracebacks: +The easiest way to get started is configuring the root logger via +:func:`logging.basicConfig`: .. code:: python import logging + import falcon import falcon.asgi logging.basicConfig( - format="%(asctime)s [%(levelname)s] %(message)s", - level=logging.INFO - ) + format="%(asctime)s [%(levelname)s] %(message)s", level=logging.INFO) + - class ThingsResource: + class FaultyResource: async def on_get(self, req, resp): raise ValueError('foo') - app = falcon.asgi.App() - things = ThingsResource() - app.add_route('/things', things) - -By adding the above logging configuration, you will see tracebacks like this in your console: -.. code-block:: none + app = falcon.asgi.App() + app.add_route('/things', FaultyResource()) - [ERROR] [FALCON] Unhandled exception in ASGI app - Traceback (most recent call last): - File "<...>", line 12, in on_get - raise ValueError('foo') - ValueError: foo +By adding the above logging configuration, you should now see tracebacks logged +to :any:`stderr ` when accessing ``/things``. -For additional details on this topic, -please refer to :ref:`debugging-asgi-applications`. \ No newline at end of file +For additional details on this topic, +please refer to :ref:`debugging_asgi_applications`. diff --git a/docs/user/tutorial-asgi.rst b/docs/user/tutorial-asgi.rst index 8450a9efd..2d10c9378 100644 --- a/docs/user/tutorial-asgi.rst +++ b/docs/user/tutorial-asgi.rst @@ -115,6 +115,68 @@ Woohoo, it works!!! Well, sort of. Onwards to adding some real functionality! +.. _debugging_asgi_applications: + +Debugging ASGI Applications +--------------------------- + +While developing and testing a Falcon ASGI application along the lines of this +tutorial, you may encounter unexpected issues or behaviors, be it a copy-paste +mistake, an idea that didn't work out, or unusual input where validation falls +outside of the scope of this tutorial. + +Unlike WSGI, the ASGI specification has no standard mechanism for logging +errors back to the application server, so Falcon falls back to the stdlib's +:mod:`logging` (using the ``falcon`` :class:`logger `). + +As a well-behaved library, Falcon does not configure any loggers since that +might interfere with the user's logging setup. +Here's how you can set up basic logging in your ASGI Falcon application via +:func:`logging.basicConfig`: + +.. code:: python + + import logging + + import falcon + + logging.basicConfig(level=logging.INFO) + + + class ErrorResource: + def on_get(self, req, resp): + raise Exception('Something went wrong!') + + + app = falcon.App() + app.add_route('/error', ErrorResource()) + +When the above route is accessed, Falcon will catch the unhandled exception and +automatically log an error message. Below is an example of what the log output +might look like: + +.. code-block:: none + + ERROR:falcon.asgi.app:Unhandled exception in ASGI application + Traceback (most recent call last): + File "/path/to/your/app.py", line 123, in __call__ + resp = resource.on_get(req, resp) + File "/path/to/your/app.py", line 7, in on_get + raise Exception("Something went wrong!") + Exception: Something went wrong! + +.. note:: + While logging is helpful for development and debugging, be mindful of + logging sensitive information. Ensure that log files are stored securely + and are not accessible to unauthorized users. + +.. note:: + Unhandled errors are only logged automatically by Falcon's default error + handler for :class:`Exception`. If you + :meth:`replace this handler ` with your + own generic :class:`Exception` handler, you are responsible for logging or + reporting these errors yourself. + .. _asgi_tutorial_config: Configuration @@ -963,54 +1025,6 @@ adding ``--cov-fail-under=100`` (or any other percent threshold) to our tests in multiple environments would most probably involve running ``coverage`` directly, and combining results. -.. _debugging-asgi-applications: - -Debugging ASGI Applications ---------------------------- -(This section also applies to WSGI applications) - -While developing and testing ASGI applications, understanding how to configure -and utilize logging can be helpful, especially when you encounter unexpected -issues or behaviors. - -By default, Falcon does not set up logging for you, -but Python's built-in :mod:`logging` module provides a flexible framework for -emitting and capturing log messages. Here's how you can set up basic logging in -your ASGI Falcon application: - -.. code:: python - - import logging - import falcon - - logging.basicConfig(level=logging.INFO) - - class ErrorResource: - def on_get(self, req, resp): - raise Exception('Something went wrong!') - - app = falcon.App() - app.add_route('/error', ErrorResource()) - -When the above route is accessed, Falcon will catch the unhandled exception and -automatically log an error message. Below is an example of what the log output -might look like: - -.. code-block:: none - - ERROR:falcon.asgi.app:Unhandled exception in ASGI application - Traceback (most recent call last): - File "path/to/falcon/app.py", line 123, in __call__ - resp = resource.on_get(req, resp) - File "/path/to/your/app.py", line 7, in on_get - raise Exception("Something went wrong!") - Exception: Something went wrong! - -.. note:: - While logging is helpful for development and debugging, be mindful of logging - sensitive information. Ensure that log files are stored securely and are not - accessible to unauthorized users. - What Now? --------- diff --git a/falcon/typing.py b/falcon/typing.py index dd4aa8212..b1532442a 100644 --- a/falcon/typing.py +++ b/falcon/typing.py @@ -21,19 +21,28 @@ from falcon.asgi import SSEvent Headers = Dict[str, str] -"""Headers dictionary returned by the framework.""" +"""Headers dictionary returned by the framework. + +.. versionadded:: 4.0 +""" # WSGI class ReadableIO(Protocol): - """File-like protocol that defines only a read method.""" + """File-like protocol that defines only a read method. + + .. versionadded:: 4.0 + """ def read(self, n: Optional[int] = ..., /) -> bytes: ... # ASGI class AsyncReadableIO(Protocol): - """Async file-like protocol that defines only a read method, and is iterable.""" + """Async file-like protocol that defines only a read method, and is iterable. + + .. versionadded:: 4.0 + """ async def read(self, n: Optional[int] = ..., /) -> bytes: ... def __aiter__(self) -> AsyncIterator[bytes]: ... @@ -42,4 +51,6 @@ def __aiter__(self) -> AsyncIterator[bytes]: ... SSEEmitter = AsyncIterator[Optional['SSEvent']] """Async generator or iterator over Server-Sent Events (instances of :class:`falcon.asgi.SSEvent`). + +.. versionadded:: 4.0 """ diff --git a/falcon/version.py b/falcon/version.py index 21a355c2c..8698e5afa 100644 --- a/falcon/version.py +++ b/falcon/version.py @@ -14,5 +14,5 @@ """Falcon version.""" -__version__ = '4.1.0.dev1' +__version__ = '4.0.2' """Current version of Falcon.""" diff --git a/pyproject.toml b/pyproject.toml index ac71c10c4..21001d2f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,7 +116,7 @@ include = ["falcon*"] [tool.towncrier] package = "falcon" package_dir = "" - filename = "docs/changes/4.1.0.rst" + filename = "docs/changes/4.0.2.rst" directory = "docs/_newsfragments" issue_format = "`#{issue} `__" From f1a7f2df7e4bdb4dce047c17b74ad72cffc2a55d Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Thu, 7 Nov 2024 07:43:38 +0100 Subject: [PATCH 6/9] chore: reopen 4.1.0 development --- docs/changes/4.1.0.rst | 25 +++++++++++++++++++++++++ docs/changes/index.rst | 1 + falcon/version.py | 2 +- pyproject.toml | 2 +- 4 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 docs/changes/4.1.0.rst diff --git a/docs/changes/4.1.0.rst b/docs/changes/4.1.0.rst new file mode 100644 index 000000000..5a9d8dc43 --- /dev/null +++ b/docs/changes/4.1.0.rst @@ -0,0 +1,25 @@ +Changelog for Falcon 4.1.0 +========================== + +Summary +------- + +Falcon 4.1 is in development. The progress is tracked via the +`Version 4.1 milestone `__ +on GitHub. + + +.. Changes to Supported Platforms +.. ------------------------------ + +.. NOTE(vytas): No changes to the supported platforms (yet). + + +.. towncrier release notes start + +Contributors to this Release +---------------------------- + +Many thanks to all of our talented and stylish contributors for this release! + +- `vytas7 `__ diff --git a/docs/changes/index.rst b/docs/changes/index.rst index 24deb80b7..dca255c42 100644 --- a/docs/changes/index.rst +++ b/docs/changes/index.rst @@ -3,6 +3,7 @@ Changelogs .. toctree:: + 4.1.0 <4.1.0> 4.0.2 <4.0.2> 4.0.1 <4.0.1> 4.0.0 <4.0.0> diff --git a/falcon/version.py b/falcon/version.py index 8698e5afa..21a355c2c 100644 --- a/falcon/version.py +++ b/falcon/version.py @@ -14,5 +14,5 @@ """Falcon version.""" -__version__ = '4.0.2' +__version__ = '4.1.0.dev1' """Current version of Falcon.""" diff --git a/pyproject.toml b/pyproject.toml index 21001d2f8..ac71c10c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,7 +116,7 @@ include = ["falcon*"] [tool.towncrier] package = "falcon" package_dir = "" - filename = "docs/changes/4.0.2.rst" + filename = "docs/changes/4.1.0.rst" directory = "docs/_newsfragments" issue_format = "`#{issue} `__" From b765b256bbb4959548554a6ca707d99e487454fe Mon Sep 17 00:00:00 2001 From: bssyousefi <44493177+bssyousefi@users.noreply.github.com> Date: Thu, 7 Nov 2024 02:10:07 -0500 Subject: [PATCH 7/9] docs(user): add query strings tutorial (#2239) * add query strings to docs/user/tutorial * fix some parts in the docs * shorten rst lines to around 80 * update examples/look and tests * fix linter error; remove unnecessary variable * fix other linter errors * refactor: use dedicated attribute --- docs/user/tutorial.rst | 214 +++++++++++++++++++++++++++++++- examples/look/look/app.py | 7 +- examples/look/look/images.py | 56 +++++++-- examples/look/tests/test_app.py | 76 ++++++++++-- 4 files changed, 321 insertions(+), 32 deletions(-) diff --git a/docs/user/tutorial.rst b/docs/user/tutorial.rst index b4589af9f..9e483d3ed 100644 --- a/docs/user/tutorial.rst +++ b/docs/user/tutorial.rst @@ -1174,7 +1174,7 @@ Go ahead and edit your ``images.py`` file to look something like this: _CHUNK_SIZE_BYTES = 4096 _IMAGE_NAME_PATTERN = re.compile( - '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z]{2,4}$' + r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z]{2,4}$' ) def __init__(self, storage_path, uuidgen=uuid.uuid4, fopen=io.open): @@ -1304,10 +1304,216 @@ Inspecting the application now returns: ⇒ /images/{name} - Item: └── GET - on_get -.. Query Strings -.. ------------- +Query Strings +------------- +Now that we are able to get the images from the service, we need a way to get +a list of available images. We have already set up this route. Before testing this +route let's change its output format back to JSON to have a more +terminal-friendly output. The top of file ``images.py`` should look like this: + +.. code:: python + + import io + import os + import re + import uuid + import mimetypes + + import falcon + import json + + + class Collection: + + def __init__(self, image_store): + self._image_store = image_store + + def on_get(self, req, resp): + # TODO: Modify this to return a list of href's based on + # what images are actually available. + doc = { + 'images': [ + { + 'href': '/images/1eaf6ef1-7f2d-4ecc-a8d5-6e8adba7cc0e.png' + } + ] + } + + resp.text = json.dumps(doc, ensure_ascii=False) + resp.status = falcon.HTTP_200 + + def on_post(self, req, resp): + name = self._image_store.save(req.stream, req.content_type) + resp.status = falcon.HTTP_201 + resp.location = '/images/' + name + + +Now try the following: + +.. code:: bash + + http localhost:8000/images + +In response you should get the following data that we statically have put in the code. + +.. code:: + + { + "images": [ + { + "href": "/images/1eaf6ef1-7f2d-4ecc-a8d5-6e8adba7cc0e.png" + } + ] + } + +Let's go back to the ``on_get`` method and create a dynamic response. We can +use query strings to set maximum image size and get the list of all images +smaller than the specified value. We will use method ``get_param_as_int`` to +set a default value of ``-1`` in case no ``maxsize`` query string was provided +and also to enable a minimum value validation. + +.. code:: python + + import io + import os + import re + import uuid + import mimetypes + + import falcon + import json + + + class Collection: + + def __init__(self, image_store): + self._image_store = image_store + + def on_get(self, req, resp): + max_size = req.get_param_as_int("maxsize", min_value=1, default=-1) + images = self._image_store.list(max_size) + doc = { + 'images': [ + {'href': '/images/' + image} for image in images + ] + } + + resp.text = json.dumps(doc, ensure_ascii=False) + resp.status = falcon.HTTP_200 + + def on_post(self, req, resp): + name = self._image_store.save(req.stream, req.content_type) + resp.status = falcon.HTTP_201 + resp.location = '/images/' + name + + + class Item: + + def __init__(self, image_store): + self._image_store = image_store + + def on_get(self, req, resp, name): + resp.content_type = mimetypes.guess_type(name)[0] + resp.stream, resp.content_length = self._image_store.open(name) + + + class ImageStore: + + _CHUNK_SIZE_BYTES = 4096 + _IMAGE_NAME_PATTERN = re.compile( + r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z]{2,4}$' + ) + + def __init__(self, storage_path, uuidgen=uuid.uuid4, fopen=io.open): + self._storage_path = storage_path + self._uuidgen = uuidgen + self._fopen = fopen + + def save(self, image_stream, image_content_type): + ext = mimetypes.guess_extension(image_content_type) + name = '{uuid}{ext}'.format(uuid=self._uuidgen(), ext=ext) + image_path = os.path.join(self._storage_path, name) + + with self._fopen(image_path, 'wb') as image_file: + while True: + chunk = image_stream.read(self._CHUNK_SIZE_BYTES) + if not chunk: + break + + image_file.write(chunk) + + return name + + def open(self, name): + # Always validate untrusted input! + if not self._IMAGE_NAME_PATTERN.match(name): + raise IOError('File not found') + + image_path = os.path.join(self._storage_path, name) + stream = self._fopen(image_path, 'rb') + content_length = os.path.getsize(image_path) + + return stream, content_length + + def list(self, max_size): + images = [ + image for image in os.listdir(self._storage_path) + if self._IMAGE_NAME_PATTERN.match(image) + and ( + max_size == -1 + or os.path.getsize(os.path.join(self._storage_path, image)) <= max_size + ) + ] + return images + +As you can see the method ``list`` has been added to ``ImageStore`` in order +to return list of available images smaller than ``max_size`` unless it is not +``-1``, in which case it will behave like there was no predicament of image size. +Let's try to save some binary data as images in the service and then try to +retrieve their list. Execute the following commands in order to simulate the +creation of 3 files as images with different sizes. While these are not valid +PNG files, they will work for this tutorial. + +.. code:: bash + + echo "First Case" > pseudo-image-1.png + echo "Second Case" > pseudo-image-2.png + echo "3rd Case" > pseudo-image-3.png + +Now we need to store these files using ``POST`` request: + +.. code:: bash + + http POST localhost:8000/images Content-Type:image/png < pseudo-image-1.png + http POST localhost:8000/images Content-Type:image/png < pseudo-image-2.png + http POST localhost:8000/images Content-Type:image/png < pseudo-image-3.png + +If we check the size of these files, we will see that they are 11, 12, 9 bytes +respectively. Let's try to get the list of the images which are smaller or +equal to 11 bytes. + +.. code:: bash + + http localhost:8000/images?maxsize=11 + +We expect to get a list of 2 files, which will be similar to the following: + +.. code:: + + { + "images": [ + { + "href": "/images/7ba2ebc9-726f-46b0-9615-a69824f5089b.png" + }, + { + "href": "/images/e4354a31-2161-4064-805c-3bc7c332e7e6.png" + } + ] + } + +You could also now validate the response with getting the image files using +the ``href`` value in the response and compare them with the original files. -.. *Coming soon...* Introducing Hooks ----------------- diff --git a/examples/look/look/app.py b/examples/look/look/app.py index abce808bd..7b591ce40 100644 --- a/examples/look/look/app.py +++ b/examples/look/look/app.py @@ -2,14 +2,15 @@ import falcon +from .images import Collection from .images import ImageStore -from .images import Resource +from .images import Item def create_app(image_store): - image_resource = Resource(image_store) app = falcon.App() - app.add_route('/images', image_resource) + app.add_route('/images', Collection(image_store)) + app.add_route('/images/{name}', Item(image_store)) return app diff --git a/examples/look/look/images.py b/examples/look/look/images.py index 31466d93c..8f2332fc2 100644 --- a/examples/look/look/images.py +++ b/examples/look/look/images.py @@ -1,28 +1,23 @@ import io +import json import mimetypes import os +import re import uuid -import msgpack - import falcon -class Resource: +class Collection: def __init__(self, image_store): self._image_store = image_store def on_get(self, req, resp): - doc = { - 'images': [ - { - 'href': '/images/1eaf6ef1-7f2d-4ecc-a8d5-6e8adba7cc0e.png', - }, - ], - } - - resp.data = msgpack.packb(doc, use_bin_type=True) - resp.content_type = 'application/msgpack' + max_size = req.get_param_as_int('maxsize', min_value=1, default=-1) + images = self._image_store.list(max_size) + doc = {'images': [{'href': '/images/' + image} for image in images]} + + resp.text = json.dumps(doc, ensure_ascii=False) resp.status = falcon.HTTP_200 def on_post(self, req, resp): @@ -31,8 +26,20 @@ def on_post(self, req, resp): resp.location = '/images/' + name +class Item: + def __init__(self, image_store): + self._image_store = image_store + + def on_get(self, req, resp, name): + resp.content_type = mimetypes.guess_type(name)[0] + resp.stream, resp.content_length = self._image_store.open(name) + + class ImageStore: _CHUNK_SIZE_BYTES = 4096 + _IMAGE_NAME_PATTERN = re.compile( + r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z]{2,4}$' + ) # Note the use of dependency injection for standard library # methods. We'll use these later to avoid monkey-patching. @@ -55,3 +62,26 @@ def save(self, image_stream, image_content_type): image_file.write(chunk) return name + + def open(self, name): + # Always validate untrusted input! + if not self._IMAGE_NAME_PATTERN.match(name): + raise IOError('File not found') + + image_path = os.path.join(self._storage_path, name) + stream = self._fopen(image_path, 'rb') + content_length = os.path.getsize(image_path) + + return stream, content_length + + def list(self, max_size): + images = [ + image + for image in os.listdir(self._storage_path) + if self._IMAGE_NAME_PATTERN.match(image) + and ( + max_size == -1 + or os.path.getsize(os.path.join(self._storage_path, image)) <= max_size + ) + ] + return images diff --git a/examples/look/tests/test_app.py b/examples/look/tests/test_app.py index c6db6451c..419fe8371 100644 --- a/examples/look/tests/test_app.py +++ b/examples/look/tests/test_app.py @@ -1,10 +1,12 @@ import io +import os +from unittest import TestCase from unittest.mock import call from unittest.mock import MagicMock from unittest.mock import mock_open +import uuid from wsgiref.validate import InputWrapper -import msgpack import pytest import falcon @@ -25,19 +27,17 @@ def client(mock_store): return testing.TestClient(api) -def test_list_images(client): - doc = { - 'images': [ - { - 'href': '/images/1eaf6ef1-7f2d-4ecc-a8d5-6e8adba7cc0e.png', - }, - ], - } +def test_list_images(client, mock_store): + images = ['first-file', 'second-file', 'third-file'] + image_docs = [{'href': '/images/' + image} for image in images] + + mock_store.list.return_value = images response = client.simulate_get('/images') - result_doc = msgpack.unpackb(response.content, raw=False) - assert result_doc == doc + result = response.json + + assert result['images'] == image_docs assert response.status == falcon.HTTP_OK @@ -64,7 +64,7 @@ def test_post_image(client, mock_store): assert saver_call[0][1] == image_content_type -def test_saving_image(monkeypatch): +def test_saving_image(): # This still has some mocks, but they are more localized and do not # have to be monkey-patched into standard library modules (always a # risky business). @@ -84,3 +84,55 @@ def mock_uuidgen(): assert store.save(fake_request_stream, 'image/png') == fake_uuid + '.png' assert call().write(fake_image_bytes) in mock_file_open.mock_calls + + +def test_get_image(client, mock_store): + file_bytes = b'fake-image-bytes' + + mock_store.open.return_value = ((file_bytes,), 17) + + response = client.simulate_get('/images/filename.png') + + assert response.status == falcon.HTTP_OK + assert response.content == file_bytes + + +def test_opening_image(): + file_name = f'{uuid.uuid4()}.png' + storage_path = '.' + file_path = f'{storage_path}/{file_name}' + fake_image_bytes = b'fake-image-bytes' + with open(file_path, 'wb') as image_file: + file_length = image_file.write(fake_image_bytes) + + store = look.images.ImageStore(storage_path) + + file_reader, content_length = store.open(file_name) + assert content_length == file_length + assert file_reader.read() == fake_image_bytes + os.remove(file_path) + + with TestCase().assertRaises(IOError): + store.open('wrong_file_name_format') + + +def test_listing_images(): + file_names = [f'{uuid.uuid4()}.png' for _ in range(2)] + storage_path = '.' + file_paths = [f'{storage_path}/{name}' for name in file_names] + fake_images_bytes = [ + b'fake-image-bytes', # 17 + b'fake-image-bytes-with-more-length', # 34 + ] + for i in range(2): + with open(file_paths[i], 'wb') as image_file: + image_file.write(fake_images_bytes[i]) + + store = look.images.ImageStore(storage_path) + assert store.list(10) == [] + assert store.list(20) == [file_names[0]] + assert len(store.list(40)) == 2 + assert sorted(store.list(40)) == sorted(file_names) + + for file_path in file_paths: + os.remove(file_path) From cf51816a2f7cd7a23d3e1129fe9418a5fc85d8be Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Mon, 11 Nov 2024 08:43:02 +0100 Subject: [PATCH 8/9] 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 9/9] 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.