Skip to content

Commit

Permalink
Merge branch 'master' into 1026-add-messsagepack-support
Browse files Browse the repository at this point in the history
  • Loading branch information
vytas7 authored Dec 30, 2024
2 parents f226b5b + 7621942 commit f562383
Show file tree
Hide file tree
Showing 15 changed files with 199 additions and 46 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)'
Expand Down
5 changes: 5 additions & 0 deletions docs/_newsfragments/2262.newandimproved.rst
Original file line number Diff line number Diff line change
@@ -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
<https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope>`__).
17 changes: 5 additions & 12 deletions docs/api/cookies.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
<https://developers.google.com/privacy-sandbox/3pcd/prepare/prepare-for-phaseout>`__.
If your site is relying on cross-site cookies, it might be necessary to set the
Expand All @@ -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.
1 change: 1 addition & 0 deletions docs/user/recipes/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Recipes
header-name-case
multipart-mixed
output-csv
plain-text-handler
pretty-json
raw-url-path
request-id
31 changes: 31 additions & 0 deletions docs/user/recipes/plain-text-handler.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.. _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.
6 changes: 3 additions & 3 deletions docs/user/recipes/request-id.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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
Expand Down Expand Up @@ -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
20 changes: 20 additions & 0 deletions examples/recipes/plain_text_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import functools

import falcon


class TextHandler(falcon.media.BaseHandler):
DEFAULT_CHARSET = 'utf-8'

@classmethod
@functools.lru_cache
def _get_charset(cls, content_type):
_, params = falcon.parse_header(content_type)
return params.get('charset') or cls.DEFAULT_CHARSET

def deserialize(self, stream, content_type, content_length):
data = stream.read()
return data.decode(self._get_charset(content_type))

def serialize(self, media, content_type):
return media.encode(self._get_charset(content_type))
8 changes: 4 additions & 4 deletions examples/recipes/request_id_context.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 3 additions & 1 deletion examples/recipes/request_id_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
9 changes: 8 additions & 1 deletion falcon/testing/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ''
Expand All @@ -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,
}

Expand Down
22 changes: 14 additions & 8 deletions falcon/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion requirements/tests
Original file line number Diff line number Diff line change
Expand Up @@ -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'
18 changes: 18 additions & 0 deletions tests/asgi/test_testing_asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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&param2=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()
8 changes: 6 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import json
import os
import pathlib
import sys
import urllib.parse

import pytest
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down
91 changes: 77 additions & 14 deletions tests/test_recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,29 +115,92 @@ 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()


class TestTextPlainHandler:
class MediaEcho:
def on_post(self, req, resp):
resp.content_type = req.content_type
resp.media = req.get_media()

def test_text_plain_basic(self, util):
recipe = util.load_module('examples/recipes/plain_text_main.py')

app = falcon.App()
app.req_options.media_handlers['text/plain'] = recipe.TextHandler()
app.resp_options.media_handlers['text/plain'] = recipe.TextHandler()

app.add_route('/media', self.MediaEcho())

client = falcon.testing.TestClient(app)
payload = 'Hello, Falcon!'
headers = {'Content-Type': 'text/plain'}
response = client.simulate_post('/media', body=payload, headers=headers)

assert response.status_code == 200
assert response.content_type == 'text/plain'
assert response.text == payload


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']

0 comments on commit f562383

Please sign in to comment.