From ead6535f1cc160e4b97ba9f21bd855e983c7f0bf Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Thu, 2 Jan 2025 18:06:23 +0100 Subject: [PATCH] docs(recipes): clean up recent recipe additions (+misc touchups) (#2425) * chore(AUTHORS): aggregate 4.1.0 contributors so far * docs: happy new year * chore: remove carriage-returns (we only use LF for indentation) * docs(recipes): improve the plain text media handler recipe * docs(recipes): clean up request-id log recipes --- AUTHORS | 2 + README.rst | 2 +- docs/changes/4.1.0.rst | 5 ++ docs/conf.py | 2 +- docs/ext/cibuildwheel.py | 2 +- docs/user/faq.rst | 2 + docs/user/intro.rst | 2 +- docs/user/recipes/plain-text-handler.rst | 71 +++++++++++++---------- docs/user/recipes/request-id.rst | 14 +++-- e2e-tests/conftest.py | 2 +- e2e-tests/test_e2e.py | 2 +- examples/recipes/request_id_context.py | 2 - examples/recipes/request_id_log.py | 5 +- examples/recipes/request_id_middleware.py | 6 +- examples/recipes/request_id_structlog.py | 4 +- falcon/_typing.py | 2 +- falcon/asgi/_asgi_helpers.py | 2 +- falcon/asgi/multipart.py | 2 +- falcon/asgi/reader.py | 2 +- falcon/cyutil/misc.pyx | 2 +- falcon/cyutil/reader.pyx | 2 +- falcon/cyutil/uri.pyx | 2 +- falcon/media/multipart.py | 2 +- falcon/util/mediatypes.py | 2 +- falcon/util/reader.py | 2 +- tests/conftest.py | 19 +++++- tests/test_recipes.py | 53 +++++++++-------- 27 files changed, 125 insertions(+), 90 deletions(-) diff --git a/AUTHORS b/AUTHORS index 9c1976cb3..19afd260e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -202,6 +202,8 @@ listed below by date of first contribution: * Akshay Awate (AkshayAwate) * Jasper Spaans (jap) * Alessandro Chitarrini (chitvs) +* Eric Goulart (EricGoulart) +* Diego (diegomirandap) (et al.) diff --git a/README.rst b/README.rst index 2e5d1b3fc..78f353e0b 100644 --- a/README.rst +++ b/README.rst @@ -963,7 +963,7 @@ See also: `CONTRIBUTING.md `__ +- `bssyousefi `__ +- `diegomirandap `__ +- `EricGoulart `__ +- `jap `__ - `vytas7 `__ diff --git a/docs/conf.py b/docs/conf.py index 6edb23d08..d8746e47c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,4 @@ -# Copyright 2014-2024 by Falcon Contributors. +# Copyright 2014-2025 by Falcon Contributors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/docs/ext/cibuildwheel.py b/docs/ext/cibuildwheel.py index 3afea5d11..5cb7fe7dd 100644 --- a/docs/ext/cibuildwheel.py +++ b/docs/ext/cibuildwheel.py @@ -1,4 +1,4 @@ -# Copyright 2024 by Vytautas Liuolia. +# Copyright 2024-2025 by Vytautas Liuolia. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/docs/user/faq.rst b/docs/user/faq.rst index 414cf08d3..19e194c9e 100644 --- a/docs/user/faq.rst +++ b/docs/user/faq.rst @@ -892,6 +892,8 @@ types: Response Handling ~~~~~~~~~~~~~~~~~ +.. _resp_media_data_text: + When would I use media, data, text, and stream? ----------------------------------------------- diff --git a/docs/user/intro.rst b/docs/user/intro.rst index d7fdb2cd2..0d8bd866a 100644 --- a/docs/user/intro.rst +++ b/docs/user/intro.rst @@ -87,7 +87,7 @@ Now, if you do make changes to Falcon itself, please consider contributing your Falcon License -------------- -Copyright 2012-2024 by Rackspace Hosting, Inc. and other contributors, +Copyright 2012-2025 by Rackspace Hosting, Inc. and other contributors, as noted in the individual source code files. Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/docs/user/recipes/plain-text-handler.rst b/docs/user/recipes/plain-text-handler.rst index 278be573f..99b1ab144 100644 --- a/docs/user/recipes/plain-text-handler.rst +++ b/docs/user/recipes/plain-text-handler.rst @@ -1,31 +1,40 @@ -.. _plain_text_handler_recipe: - -Handling Plain Text as Media -============================ - -This example demonstrates how to create a custom handler in Falcon to -process the ``text/plain`` media type. The handler implements serialization -and deserialization of textual content, respecting the charset specified -in the ``Content-Type`` header (or defaulting to ``utf-8`` when no charset is provided). - -.. literalinclude:: ../../../examples/recipes/plain_text_main.py - :language: python - -To use this handler, register it in the Falcon application's media -options for both request and response: - -.. code:: python - - import falcon - from your_module import TextHandler - - app = falcon.App() - app.req_options.media_handlers['text/plain'] = TextHandler() - app.resp_options.media_handlers['text/plain'] = TextHandler() - -With this setup, the application can handle textual data directly -as ``text/plain``, ensuring support for various character encodings as needed. - -.. warning:: - Be sure to validate and limit the size of incoming data when - working with textual content to prevent server overload or denial-of-service attacks. +.. _plain_text_handler_recipe: + +Handling Plain Text as Media +============================ + +Normally, it is easiest to render a plain text response by setting +:attr:`resp.text ` +(see also: :ref:`resp_media_data_text`). +However, if plain text is just one of +the :ref:`Internet media types ` that your application speaks, it may be +useful to generalize handling plain text as :ref:`media ` too. + +This recipe demonstrates how to create a +:ref:`custom media handler ` to process the +``text/plain`` media type. The handler implements serialization and +deserialization of textual content, respecting the charset specified in the +``Content-Type`` header +(or defaulting to ``utf-8`` when no charset is provided). + +.. literalinclude:: ../../../examples/recipes/plain_text_main.py + :language: python + +To use this handler, :ref:`register ` it in the Falcon +application's media options for both request and response: + +.. code:: python + + import falcon + from your_module import TextHandler + + app = falcon.App() + app.req_options.media_handlers['text/plain'] = TextHandler() + app.resp_options.media_handlers['text/plain'] = TextHandler() + +With this setup, the application can handle textual data directly +as ``text/plain``, ensuring support for various character encodings as needed. + +.. warning:: + Be sure to validate and limit the size of incoming data when working with + textual content to prevent server overload or denial-of-service attacks. diff --git a/docs/user/recipes/request-id.rst b/docs/user/recipes/request-id.rst index 7a69b8336..62164fb47 100644 --- a/docs/user/recipes/request-id.rst +++ b/docs/user/recipes/request-id.rst @@ -10,26 +10,29 @@ 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 `contextvars`_ object to store -the request ID: +normal request context, you can use a :class:`~contextvars.ContextVar` object +to store the request ID: .. literalinclude:: ../../../examples/recipes/request_id_context.py + :caption: *context.py* :language: python Then, you can create a :ref:`middleware ` class to generate a unique ID for each request, persisting it in the `contextvars` object: .. literalinclude:: ../../../examples/recipes/request_id_middleware.py + :caption: *middleware.py* :language: python -Alternatively, if all of your application logic has access to the :ref:`request -`, you can simply use the `context` object to store the ID: +Alternatively, if all of your application logic has access to the +:ref:`request `, you can simply use the +:attr:`req.context ` object to store the ID: .. literalinclude:: ../../../examples/recipes/request_id_structlog.py + :caption: *middleware.py* :language: python .. note:: - If your app is deployed behind a reverse proxy that injects a request ID header, you can easily adapt this recipe to use the upstream ID rather than generating a new one. By doing so, you can provide traceability across the @@ -46,6 +49,7 @@ or by using a third-party logging library such as In a pinch, you can also output the request ID directly: .. literalinclude:: ../../../examples/recipes/request_id_log.py + :caption: *some_other_module.py* :language: python .. _contextvars: https://docs.python.org/3/library/contextvars.html diff --git a/e2e-tests/conftest.py b/e2e-tests/conftest.py index ecc842602..cf537a6c0 100644 --- a/e2e-tests/conftest.py +++ b/e2e-tests/conftest.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 by Vytautas Liuolia. +# Copyright 2020-2025 by Vytautas Liuolia. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/e2e-tests/test_e2e.py b/e2e-tests/test_e2e.py index 8d33f888f..8f4b8e5b5 100644 --- a/e2e-tests/test_e2e.py +++ b/e2e-tests/test_e2e.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 by Vytautas Liuolia. +# Copyright 2020-2025 by Vytautas Liuolia. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/examples/recipes/request_id_context.py b/examples/recipes/request_id_context.py index a7e36ddc3..f4b514d20 100644 --- a/examples/recipes/request_id_context.py +++ b/examples/recipes/request_id_context.py @@ -1,5 +1,3 @@ -# context.py - import contextvars diff --git a/examples/recipes/request_id_log.py b/examples/recipes/request_id_log.py index ec71a2560..e44a011f9 100644 --- a/examples/recipes/request_id_log.py +++ b/examples/recipes/request_id_log.py @@ -1,8 +1,7 @@ -# some_other_module.py - import logging -from context import ctx +# Import the above context.py +from my_app.context import ctx def create_widget_object(name: str): diff --git a/examples/recipes/request_id_middleware.py b/examples/recipes/request_id_middleware.py index 4996b57fe..e3c0fa928 100644 --- a/examples/recipes/request_id_middleware.py +++ b/examples/recipes/request_id_middleware.py @@ -1,15 +1,13 @@ -# middleware.py - from uuid import uuid4 -from context import ctx +# Import the above context.py +from my_app.context import ctx class RequestIDMiddleware: def process_request(self, req, resp): 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/examples/recipes/request_id_structlog.py b/examples/recipes/request_id_structlog.py index 60666704b..61b36fa83 100644 --- a/examples/recipes/request_id_structlog.py +++ b/examples/recipes/request_id_structlog.py @@ -1,5 +1,3 @@ -# middleware.py - from uuid import uuid4 # Optional logging package (pip install structlog) @@ -10,7 +8,7 @@ class RequestIDMiddleware: def process_request(self, req, resp): request_id = str(uuid4()) - # Using Falcon 2.0 syntax + # Using Falcon 2.0+ context style req.context.request_id = request_id # Or if your logger has built-in support for contexts diff --git a/falcon/_typing.py b/falcon/_typing.py index c8f27c877..9ffbecbfb 100644 --- a/falcon/_typing.py +++ b/falcon/_typing.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 by Vytautas Liuolia. +# Copyright 2021-2025 by Vytautas Liuolia. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/falcon/asgi/_asgi_helpers.py b/falcon/asgi/_asgi_helpers.py index cf9e18bbc..3bf4dd5ef 100644 --- a/falcon/asgi/_asgi_helpers.py +++ b/falcon/asgi/_asgi_helpers.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 by Vytautas Liuolia. +# Copyright 2020-2025 by Vytautas Liuolia. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/falcon/asgi/multipart.py b/falcon/asgi/multipart.py index e4b3fdce4..b1b7e9e68 100644 --- a/falcon/asgi/multipart.py +++ b/falcon/asgi/multipart.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 by Vytautas Liuolia. +# Copyright 2019-2025 by Vytautas Liuolia. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/falcon/asgi/reader.py b/falcon/asgi/reader.py index 1d748dd89..766552b97 100644 --- a/falcon/asgi/reader.py +++ b/falcon/asgi/reader.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 by Vytautas Liuolia. +# Copyright 2019-2025 by Vytautas Liuolia. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/falcon/cyutil/misc.pyx b/falcon/cyutil/misc.pyx index 61b688cda..3b8fb0412 100644 --- a/falcon/cyutil/misc.pyx +++ b/falcon/cyutil/misc.pyx @@ -1,4 +1,4 @@ -# Copyright 2020-2024 by Vytautas Liuolia. +# Copyright 2020-2025 by Vytautas Liuolia. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/falcon/cyutil/reader.pyx b/falcon/cyutil/reader.pyx index 449983a97..f511dd0dd 100644 --- a/falcon/cyutil/reader.pyx +++ b/falcon/cyutil/reader.pyx @@ -1,4 +1,4 @@ -# Copyright 2019-2024 by Vytautas Liuolia. +# Copyright 2019-2025 by Vytautas Liuolia. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/falcon/cyutil/uri.pyx b/falcon/cyutil/uri.pyx index bb42cfdff..417edd0c1 100644 --- a/falcon/cyutil/uri.pyx +++ b/falcon/cyutil/uri.pyx @@ -1,4 +1,4 @@ -# Copyright 2019-2024 by Vytautas Liuolia. +# Copyright 2019-2025 by Vytautas Liuolia. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py index 99cd8785e..cb2ed1559 100644 --- a/falcon/media/multipart.py +++ b/falcon/media/multipart.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 by Vytautas Liuolia. +# Copyright 2019-2025 by Vytautas Liuolia. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/falcon/util/mediatypes.py b/falcon/util/mediatypes.py index 1fd4983a6..8453bfb5b 100644 --- a/falcon/util/mediatypes.py +++ b/falcon/util/mediatypes.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 by Vytautas Liuolia. +# Copyright 2023-2025 by Vytautas Liuolia. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/falcon/util/reader.py b/falcon/util/reader.py index 8f5431d5c..a02a2deba 100644 --- a/falcon/util/reader.py +++ b/falcon/util/reader.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 by Vytautas Liuolia. +# Copyright 2019-2025 by Vytautas Liuolia. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/tests/conftest.py b/tests/conftest.py index b7e70a8ed..0a21d4e57 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -106,18 +106,31 @@ def load_module(filename, parent_dir=None, suffix=None, module_name=None): if suffix is not None: path = path.with_name(f'{path.stem}_{suffix}.py') prefix = '.'.join(filename.parent.parts) - 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 +@pytest.fixture() +def register_module(): + """Temporarily monkey-patch a module into sys.modules.""" + + def _register(name, module): + sys.modules[name] = module + patched_modules.add(name) + + patched_modules = set() + + yield _register + + for name in patched_modules: + sys.modules.pop(name, None) + + @pytest.fixture(scope='session') def util(): return _SuiteUtils() diff --git a/tests/test_recipes.py b/tests/test_recipes.py index cca799d1f..d4c4120ec 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -1,3 +1,6 @@ +import types +import unittest.mock + import pytest import falcon @@ -163,42 +166,46 @@ def test_text_plain_basic(self, util): class TestRequestIDContext: - @pytest.fixture - def app(self, util): + @pytest.fixture(params=['middleware', 'structlog']) + def app(self, request, util, register_module): + class RequestIDResource: + def on_get(self, req, resp): + # NOTE(vytas): Reference either ContextVar or req.context + # depending on the recipe being tested. + context = getattr(recipe, 'ctx', req.context) + resp.media = {'request_id': context.request_id} + + context = util.load_module( + 'examples/recipes/request_id_context.py', module_name='my_app.context' + ) # 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') + register_module('my_app.context', context) - app = falcon.App(middleware=[recipe.RequestIDMiddleware()]) - app.add_route('/test', self.RequestIDResource()) - return app + # NOTE(vytas): Inject a fake structlog module because we do not want to + # introduce a new test dependency for a single recipe. + fake_structlog = types.ModuleType('structlog') + fake_structlog.get_logger = unittest.mock.MagicMock() + register_module('structlog', fake_structlog) - class RequestIDResource: - def on_get(self, req, resp): - resp.media = {'request_id': req.context.request_id} + recipe = util.load_module(f'examples/recipes/request_id_{request.param}.py') - 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 + app = falcon.App(middleware=[recipe.RequestIDMiddleware()]) + app.add_route('/test', RequestIDResource()) + return app def test_request_id_persistence(self, app): client = falcon.testing.TestClient(app) - response = client.simulate_get('/test') - request_id1 = response.json['request_id'] + resp1 = client.simulate_get('/test') + request_id1 = resp1.json['request_id'] - response = client.simulate_get('/test') - request_id2 = response.json['request_id'] + resp2 = client.simulate_get('/test') + request_id2 = resp2.json['request_id'] assert request_id1 != request_id2 - def test_request_id_in_response_header(self, app): + def test_request_id_header(self, app): client = falcon.testing.TestClient(app) response = client.simulate_get('/test')