From a78cfb38a0c0f6031cc3ff39ff8bf4afd03ef008 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Thu, 21 Mar 2024 20:59:26 +0100 Subject: [PATCH 1/9] chore(sync): use `asyncio.Runner` for `async_to_sync()` on py311+ (#2216) * chore(asyncio): replace `get_event_loop()` -> `get_running_loop()` where applicable * chore(sync): use `asyncio.Runner` for `async_to_sync()` on py311+ * chore(sync): exempt a line from coverage as it can only be hit on 3.11+ * chore(tests/asgi): adapt to Uvicorn now propagating signals to retcode * chore(tests/asgi): do not check ASGI server retcode on Windows * chore(tests/asgi): check for a M$ Windows specific exit code constant * chore(sync): use a nicer pattern to get the active runner --- falcon/util/sync.py | 60 ++++++++++++++++++++++++--------- pyproject.toml | 1 - tests/asgi/test_asgi_servers.py | 10 ++++-- tests/asgi/test_scope.py | 6 ++-- tests/dump_asgi.py | 2 +- 5 files changed, 56 insertions(+), 23 deletions(-) diff --git a/falcon/util/sync.py b/falcon/util/sync.py index db9e21e37..96d05c058 100644 --- a/falcon/util/sync.py +++ b/falcon/util/sync.py @@ -22,6 +22,42 @@ 'wrap_sync_to_async_unsafe', ] +Result = TypeVar('Result') + + +class _DummyRunner: + def run(self, coro: Awaitable[Result]) -> Result: # pragma: nocover + # NOTE(vytas): Work around get_event_loop deprecation in 3.10 by going + # via get_event_loop_policy(). This should be equivalent for + # async_to_sync's use case as it is currently impossible to invoke + # run_until_complete() from a running loop anyway. + return self.get_loop().run_until_complete(coro) + + def get_loop(self) -> asyncio.AbstractEventLoop: # pragma: nocover + return asyncio.get_event_loop_policy().get_event_loop() + + def close(self) -> None: # pragma: nocover + pass + + +class _ActiveRunner: + def __init__(self, runner_cls: type): + self._runner_cls = runner_cls + self._runner = runner_cls() + + # TODO(vytas): This typing is wrong on py311+, but mypy accepts it. + # It doesn't, OTOH, accept any of my ostensibly valid attempts to + # describe it. + def __call__(self) -> _DummyRunner: + # NOTE(vytas): Sometimes our runner's loop can get picked and consumed + # by other utilities and test methods. If that happens, recreate the runner. + if self._runner.get_loop().is_closed(): + # NOTE(vytas): This condition is never hit on _DummyRunner. + self._runner = self._runner_cls() # pragma: nocover + return self._runner + + +_active_runner = _ActiveRunner(getattr(asyncio, 'Runner', _DummyRunner)) _one_thread_to_rule_them_all = ThreadPoolExecutor(max_workers=1) create_task = asyncio.create_task @@ -190,9 +226,6 @@ def _wrap_non_coroutine_unsafe( return wrap_sync_to_async_unsafe(func) -Result = TypeVar('Result') - - def async_to_sync( coroutine: Callable[..., Awaitable[Result]], *args: Any, **kwargs: Any ) -> Result: @@ -204,8 +237,13 @@ def async_to_sync( one will be created. Warning: - This method is very inefficient and is intended primarily for testing - and prototyping. + Executing async code in this manner is inefficient since it involves + synchronization via threading primitives, and is intended primarily for + testing, prototyping or compatibility purposes. + + Note: + On Python 3.11+, this function leverages a module-wide + ``asyncio.Runner``. Args: coroutine: A coroutine function to invoke. @@ -214,17 +252,7 @@ def async_to_sync( Keyword Args: **kwargs: Additional args are passed through to the coroutine function. """ - - # TODO(vytas): The canonical way of doing this for simple use cases is - # asyncio.run(), but that would be a breaking change wrt the above - # documented behaviour; breaking enough to break some of our own tests. - - # NOTE(vytas): Work around get_event_loop deprecation in 3.10 by going via - # get_event_loop_policy(). This should be equivalent for async_to_sync's - # use case as it is currently impossible to invoke run_until_complete() - # from a running loop anyway. - loop = asyncio.get_event_loop_policy().get_event_loop() - return loop.run_until_complete(coroutine(*args, **kwargs)) + return _active_runner().run(coroutine(*args, **kwargs)) def runs_sync(coroutine: Callable[..., Awaitable[Result]]) -> Callable[..., Result]: diff --git a/pyproject.toml b/pyproject.toml index ad445ce55..5ed0c5fab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,7 +97,6 @@ filterwarnings = [ "ignore:.cgi. is deprecated and slated for removal:DeprecationWarning", "ignore:path is deprecated\\. Use files\\(\\) instead:DeprecationWarning", "ignore:This process \\(.+\\) is multi-threaded", - "ignore:There is no current event loop", ] testpaths = [ "tests" diff --git a/tests/asgi/test_asgi_servers.py b/tests/asgi/test_asgi_servers.py index 26f51ad0c..321e41f96 100644 --- a/tests/asgi/test_asgi_servers.py +++ b/tests/asgi/test_asgi_servers.py @@ -4,6 +4,7 @@ import os import platform import random +import signal import subprocess import sys import time @@ -27,7 +28,9 @@ _SERVER_HOST = '127.0.0.1' _SIZE_1_KB = 1024 _SIZE_1_MB = _SIZE_1_KB**2 - +# NOTE(vytas): Windows specific: {Application Exit by CTRL+C}. +# The application terminated as a result of a CTRL+C. +_STATUS_CONTROL_C_EXIT = 0xC000013A _REQUEST_TIMEOUT = 10 @@ -620,7 +623,10 @@ def server_base_url(request): yield base_url - assert server.returncode == 0 + # NOTE(vytas): Starting with 0.29.0, Uvicorn will propagate signal + # values into the return code (which is a good practice in Unix); + # see also https://github.com/encode/uvicorn/pull/1600 + assert server.returncode in (0, -signal.SIGTERM, _STATUS_CONTROL_C_EXIT) break diff --git a/tests/asgi/test_scope.py b/tests/asgi/test_scope.py index bb60ed0e7..e368f6576 100644 --- a/tests/asgi/test_scope.py +++ b/tests/asgi/test_scope.py @@ -70,7 +70,7 @@ def test_supported_asgi_version(version, supported): resp_event_collector = testing.ASGIResponseEventCollector() async def task(): - coro = asyncio.get_event_loop().create_task( + coro = asyncio.get_running_loop().create_task( app(scope, req_event_emitter, resp_event_collector) ) @@ -142,7 +142,7 @@ def test_lifespan_scope_default_version(): scope = {'type': 'lifespan'} async def t(): - t = asyncio.get_event_loop().create_task( + t = asyncio.get_running_loop().create_task( app(scope, req_event_emitter, resp_event_collector) ) @@ -196,7 +196,7 @@ def test_lifespan_scope_version(spec_version, supported): return async def t(): - t = asyncio.get_event_loop().create_task( + t = asyncio.get_running_loop().create_task( app(scope, req_event_emitter, resp_event_collector) ) diff --git a/tests/dump_asgi.py b/tests/dump_asgi.py index 0742a3ca0..0dfdb4b0a 100644 --- a/tests/dump_asgi.py +++ b/tests/dump_asgi.py @@ -23,5 +23,5 @@ async def app(scope, receive, send): } ) - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() loop.create_task(_say_hi()) From 7ec1d31a93461924636c003e61fed34d13071608 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 3 Apr 2024 22:27:30 +0200 Subject: [PATCH 2/9] feat(parse_header): provide our own implementation of `parse_header()` (#2217) * feat(parse_header): provide our own implementation of `parse_header()` * docs(newsfragments): add a newsfragment + address 1 review comment * test(test_mediatypes.py): add tests for multiple parameters --- README.rst | 2 +- docs/_newsfragments/2066.newandimproved.rst | 4 + docs/api/util.rst | 5 ++ docs/user/recipes/pretty-json.rst | 3 +- falcon/__init__.py | 1 + falcon/asgi/multipart.py | 5 +- falcon/media/multipart.py | 10 +-- falcon/testing/helpers.py | 4 +- falcon/util/__init__.py | 1 + falcon/util/mediatypes.py | 89 +++++++++++++++++++++ falcon/vendor/mimeparse/mimeparse.py | 4 +- pyproject.toml | 1 - tests/test_mediatypes.py | 41 ++++++++++ 13 files changed, 154 insertions(+), 16 deletions(-) create mode 100644 docs/_newsfragments/2066.newandimproved.rst create mode 100644 falcon/util/mediatypes.py create mode 100644 tests/test_mediatypes.py diff --git a/README.rst b/README.rst index 26105b0dd..738b2f2b8 100644 --- a/README.rst +++ b/README.rst @@ -1027,7 +1027,7 @@ See also: `CONTRIBUTING.md `: .. code:: python - import cgi import json import falcon @@ -66,7 +65,7 @@ implemented with a :ref:`custom media handler `: return json.loads(data.decode()) def serialize(self, media, content_type): - _, params = cgi.parse_header(content_type) + _, params = falcon.parse_header(content_type) indent = params.get('indent') if indent is not None: try: diff --git a/falcon/__init__.py b/falcon/__init__.py index 1d33539b3..745856058 100644 --- a/falcon/__init__.py +++ b/falcon/__init__.py @@ -77,6 +77,7 @@ from falcon.util import IS_64_BITS from falcon.util import is_python_func from falcon.util import misc +from falcon.util import parse_header from falcon.util import reader from falcon.util import runs_sync from falcon.util import secure_filename diff --git a/falcon/asgi/multipart.py b/falcon/asgi/multipart.py index b268c2b5a..52cb0505e 100644 --- a/falcon/asgi/multipart.py +++ b/falcon/asgi/multipart.py @@ -14,11 +14,10 @@ """ASGI multipart form media handler components.""" -import cgi - from falcon.asgi.reader import BufferedReader from falcon.errors import DelimiterError from falcon.media import multipart +from falcon.util.mediatypes import parse_header _ALLOWED_CONTENT_HEADERS = multipart._ALLOWED_CONTENT_HEADERS _CRLF = multipart._CRLF @@ -54,7 +53,7 @@ async def get_media(self): return self._media async def get_text(self): - content_type, options = cgi.parse_header(self.content_type) + content_type, options = parse_header(self.content_type) if content_type != 'text/plain': return None diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py index c3fc37d56..5b55d4b4f 100644 --- a/falcon/media/multipart.py +++ b/falcon/media/multipart.py @@ -14,7 +14,6 @@ """Multipart form media handler.""" -import cgi import re from urllib.parse import unquote_to_bytes @@ -24,6 +23,7 @@ from falcon.stream import BoundedStream from falcon.util import BufferedReader from falcon.util import misc +from falcon.util.mediatypes import parse_header # TODO(vytas): @@ -249,7 +249,7 @@ def get_text(self): str: The part decoded as a text string provided the part is encoded as ``text/plain``, ``None`` otherwise. """ - content_type, options = cgi.parse_header(self.content_type) + content_type, options = parse_header(self.content_type) if content_type != 'text/plain': return None @@ -275,7 +275,7 @@ def filename(self): if self._content_disposition is None: value = self._headers.get(b'content-disposition', b'') - self._content_disposition = cgi.parse_header(value.decode()) + self._content_disposition = parse_header(value.decode()) _, params = self._content_disposition @@ -311,7 +311,7 @@ def name(self): if self._content_disposition is None: value = self._headers.get(b'content-disposition', b'') - self._content_disposition = cgi.parse_header(value.decode()) + self._content_disposition = parse_header(value.decode()) _, params = self._content_disposition self._name = params.get('name') @@ -493,7 +493,7 @@ def __init__(self, parse_options=None): def _deserialize_form( self, stream, content_type, content_length, form_cls=MultipartForm ): - _, options = cgi.parse_header(content_type) + _, options = parse_header(content_type) try: boundary = options['boundary'] except KeyError: diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index 00959495a..39e8c12f8 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -23,7 +23,6 @@ """ import asyncio -import cgi from collections import defaultdict from collections import deque import contextlib @@ -51,6 +50,7 @@ from falcon.constants import SINGLETON_HEADERS import falcon.request from falcon.util import uri +from falcon.util.mediatypes import parse_header # NOTE(kgriffs): Changed in 3.0 from 'curl/7.24.0 (x86_64-apple-darwin12.0)' DEFAULT_UA = 'falcon-client/' + falcon.__version__ @@ -802,7 +802,7 @@ def get_encoding_from_headers(headers): if not content_type: return None - content_type, params = cgi.parse_header(content_type) + content_type, params = parse_header(content_type) if 'charset' in params: return params['charset'].strip('\'"') diff --git a/falcon/util/__init__.py b/falcon/util/__init__.py index 3fec8b06e..1cead07e8 100644 --- a/falcon/util/__init__.py +++ b/falcon/util/__init__.py @@ -29,6 +29,7 @@ from falcon.util.deprecation import deprecated from falcon.util.deprecation import deprecated_args from falcon.util.deprecation import DeprecatedWarning +from falcon.util.mediatypes import parse_header from falcon.util.misc import code_to_http_status from falcon.util.misc import dt_to_http from falcon.util.misc import get_argnames diff --git a/falcon/util/mediatypes.py b/falcon/util/mediatypes.py new file mode 100644 index 000000000..c0dca5121 --- /dev/null +++ b/falcon/util/mediatypes.py @@ -0,0 +1,89 @@ +# Copyright 2023-2024 by Vytautas Liuolia. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Media (aka MIME) type parsing and matching utilities.""" + +import typing + + +def _parse_param_old_stdlib(s): # type: ignore + while s[:1] == ';': + s = s[1:] + end = s.find(';') + while end > 0 and (s.count('"', 0, end) - s.count('\\"', 0, end)) % 2: + end = s.find(';', end + 1) + if end < 0: + end = len(s) + f = s[:end] + yield f.strip() + s = s[end:] + + +def _parse_header_old_stdlib(line): # type: ignore + """Parse a Content-type like header. + + Return the main content-type and a dictionary of options. + + Note: + This method has been copied (almost) verbatim from CPython 3.8 stdlib. + It is slated for removal from the stdlib in 3.13. + """ + parts = _parse_param_old_stdlib(';' + line) + key = parts.__next__() + pdict = {} + for p in parts: + i = p.find('=') + if i >= 0: + name = p[:i].strip().lower() + value = p[i + 1 :].strip() + if len(value) >= 2 and value[0] == value[-1] == '"': + value = value[1:-1] + value = value.replace('\\\\', '\\').replace('\\"', '"') + pdict[name] = value + return key, pdict + + +def parse_header(line: str) -> typing.Tuple[str, dict]: + """Parse a Content-type like header. + + Return the main content-type and a dictionary of options. + + Args: + line: A header value to parse. + + Returns: + tuple: (the main content-type, dictionary of options). + + Note: + This function replaces an equivalent method previously available in the + stdlib as ``cgi.parse_header()``. + It was removed from the stdlib in Python 3.13. + """ + if '"' not in line and '\\' not in line: + key, semicolon, parts = line.partition(';') + if not semicolon: + return (key.strip(), {}) + + pdict = {} + for part in parts.split(';'): + name, equals, value = part.partition('=') + if equals: + pdict[name.strip().lower()] = value.strip() + + return (key.strip(), pdict) + + return _parse_header_old_stdlib(line) + + +__all__ = ['parse_header'] diff --git a/falcon/vendor/mimeparse/mimeparse.py b/falcon/vendor/mimeparse/mimeparse.py index 0218553cf..f96e63384 100755 --- a/falcon/vendor/mimeparse/mimeparse.py +++ b/falcon/vendor/mimeparse/mimeparse.py @@ -1,4 +1,4 @@ -import cgi +from falcon.util.mediatypes import parse_header __version__ = '1.6.0' __author__ = 'Joe Gregorio' @@ -23,7 +23,7 @@ def parse_mime_type(mime_type): :rtype: (str,str,dict) """ - full_type, params = cgi.parse_header(mime_type) + full_type, params = parse_header(mime_type) # Java URLConnection class sends an Accept header that includes a # single '*'. Turn it into a legal wildcard. if full_type == '*': diff --git a/pyproject.toml b/pyproject.toml index 5ed0c5fab..44a829feb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,7 +94,6 @@ filterwarnings = [ "ignore:Using or importing the ABCs:DeprecationWarning", "ignore:cannot collect test class 'TestClient':pytest.PytestCollectionWarning", "ignore:inspect.getargspec\\(\\) is deprecated:DeprecationWarning", - "ignore:.cgi. is deprecated and slated for removal:DeprecationWarning", "ignore:path is deprecated\\. Use files\\(\\) instead:DeprecationWarning", "ignore:This process \\(.+\\) is multi-threaded", ] diff --git a/tests/test_mediatypes.py b/tests/test_mediatypes.py new file mode 100644 index 000000000..0fae79b43 --- /dev/null +++ b/tests/test_mediatypes.py @@ -0,0 +1,41 @@ +import pytest + +from falcon.util import mediatypes + + +@pytest.mark.parametrize( + 'value,expected', + [ + ('', ('', {})), + ('strange', ('strange', {})), + ('text/plain', ('text/plain', {})), + ('text/plain ', ('text/plain', {})), + (' text/plain', ('text/plain', {})), + (' text/plain ', ('text/plain', {})), + (' text/plain ', ('text/plain', {})), + ( + 'falcon/peregrine; key1; key2=value; key3', + ('falcon/peregrine', {'key2': 'value'}), + ), + ( + 'audio/pcm;rate=48000;encoding=float;bits=32', + ('audio/pcm', {'bits': '32', 'encoding': 'float', 'rate': '48000'}), + ), + ( + 'falcon/*; genus=falco; family=falconidae; class=aves; ', + ('falcon/*', {'class': 'aves', 'family': 'falconidae', 'genus': 'falco'}), + ), + ('"falcon/peregrine" ; key="value"', ('"falcon/peregrine"', {'key': 'value'})), + ('falcon/peregrine; empty=""', ('falcon/peregrine', {'empty': ''})), + ('falcon/peregrine; quote="', ('falcon/peregrine', {'quote': '"'})), + ('text/plain; charset=utf-8', ('text/plain', {'charset': 'utf-8'})), + ('stuff/strange; missing-value; missing-another', ('stuff/strange', {})), + ('stuff/strange; missing-value\\missing-another', ('stuff/strange', {})), + ( + 'application/falcon; P1 = "key; value"; P2="\\""', + ('application/falcon', {'p1': 'key; value', 'p2': '"'}), + ), + ], +) +def test_parse_header(value, expected): + assert mediatypes.parse_header(value) == expected From 64af3cfd0aa9c74c160560e8089a5a03699dfd58 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Fri, 5 Apr 2024 22:33:42 +0200 Subject: [PATCH 3/9] chore(typing): add more descriptive typing to `Context` (#2226) * chore(typing): add more descriptive typing to `Context` * chore(typing): define only stubs when under `TYPE_CHECKING` --- falcon/util/structures.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/falcon/util/structures.py b/falcon/util/structures.py index fc7ba2a88..5a9e51176 100644 --- a/falcon/util/structures.py +++ b/falcon/util/structures.py @@ -37,6 +37,7 @@ from typing import KeysView from typing import Optional from typing import Tuple +from typing import TYPE_CHECKING from typing import ValuesView @@ -141,6 +142,19 @@ class Context: True """ + # NOTE(vytas): Define synthetic attr access methods (under TYPE_CHECKING) + # merely to let mypy know this is a namespace object. + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: + ... + + def __setattr__(self, name: str, value: Any) -> None: + ... + + def __delattr__(self, name: str) -> None: + ... + def __contains__(self, key: str) -> bool: return self.__dict__.__contains__(key) From 52422170cb72b3776f80bdc18ee04488178fb854 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 10 Apr 2024 13:22:38 +0200 Subject: [PATCH 4/9] chore: make CPython 3.12 default CI version (#2227) * chore: make CPython 3.12 default CI version * chore(CI): update forgotten Windows, macOS & cov gates * chore: try codecov v4 --- .github/workflows/tests.yaml | 22 +++++++++++----------- CONTRIBUTING.md | 2 +- docs/changes/4.0.0.rst | 2 +- tox.ini | 12 ++++++------ 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 6a0ddbe40..cdb9d9cfd 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: python-version: - - "3.10" + - "3.12" os: - "ubuntu-latest" toxenv: @@ -28,9 +28,9 @@ jobs: - "pep8-docstrings" - "mypy" - "mypy_tests" - - "py310" - - "py310_sans_msgpack" - - "py310_cython" + - "py312" + - "py312_sans_msgpack" + - "py312_cython" - "docs" - "towncrier" - "look" @@ -82,12 +82,12 @@ jobs: - python-version: "3.12" os: ubuntu-latest toxenv: py312_cython - - python-version: "3.10" + - python-version: "3.12" os: macos-latest - toxenv: py310_nocover - - python-version: "3.10" + toxenv: py312_nocover + - python-version: "3.12" os: windows-latest - toxenv: py310_nocover + toxenv: py312_nocover # These env require 3.8 and 20.04, see tox.ini - python-version: "3.8" os: ubuntu-20.04 @@ -133,14 +133,14 @@ jobs: run: tox -e ${{ matrix.toxenv }} - name: Combine coverage - if: ${{ matrix.toxenv == 'py310' || matrix.toxenv == 'py310_sans_msgpack' }} + if: ${{ matrix.toxenv == 'py312' || matrix.toxenv == 'py312_sans_msgpack' }} run: | coverage --version coverage combine - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 - if: ${{ matrix.toxenv == 'py310' || matrix.toxenv == 'py310_sans_msgpack' }} + uses: codecov/codecov-action@v4 + if: ${{ matrix.toxenv == 'py312' || matrix.toxenv == 'py312_sans_msgpack' }} with: env_vars: PYTHON fail_ci_if_error: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e3d8082f..b28054167 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ $ pip install -U blue $ blue . ``` -You can check all this by running ``tox`` from within the Falcon project directory. Your environment must be based on CPython 3.8, 3.10 or 3.11: +You can check all this by running ``tox`` from within the Falcon project directory. Your environment must be based on CPython 3.8, 3.10, 3.11 or 3.12: ```bash $ pip install -U tox diff --git a/docs/changes/4.0.0.rst b/docs/changes/4.0.0.rst index 93359c678..361c17619 100644 --- a/docs/changes/4.0.0.rst +++ b/docs/changes/4.0.0.rst @@ -13,7 +13,7 @@ Changes to Supported Platforms ------------------------------ - CPython 3.11 is now fully supported. (`#2072 `__) -- CPython 3.12 will be fully supported. (`#2196 `__) +- CPython 3.12 is now fully supported. (`#2196 `__) - End-of-life Python 3.5 & 3.6 are no longer supported. (`#2074 `__) - Python 3.7 is no longer actively supported, but the framework should still continue to install from source. We may remove the support for 3.7 altogether diff --git a/tox.ini b/tox.ini index 1715258c5..67d9d7c7d 100644 --- a/tox.ini +++ b/tox.ini @@ -99,21 +99,21 @@ deps = {[testenv]deps} commands = pip uninstall --yes msgpack coverage run -m pytest tests -k "test_ws and test_msgpack_missing" -[testenv:py310] -basepython = python3.10 +[testenv:py312] +basepython = python3.12 deps = {[testenv]deps} pytest-randomly jsonschema commands = {[with-coverage]commands} -[testenv:py310_sans_msgpack] -basepython = python3.10 +[testenv:py312_sans_msgpack] +basepython = python3.12 deps = {[testenv]deps} commands = pip uninstall --yes msgpack coverage run -m pytest tests -k "test_ws and test_msgpack_missing" -[testenv:py310_nocover] -basepython = python3.10 +[testenv:py312_nocover] +basepython = python3.12 deps = {[testenv]deps} pytest-randomly jsonschema From 89bfb57c7ee9ea7b10d6a1f267556e20734a6771 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 10 Apr 2024 14:44:28 +0200 Subject: [PATCH 5/9] chore(codecov): add `CODECOV_TOKEN` as required in v4+ (#2228) --- .github/workflows/tests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index cdb9d9cfd..6098b43d7 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -144,3 +144,4 @@ jobs: with: env_vars: PYTHON fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} From 33e0bb3ac5ec584d19186d7fa0b32441575ca058 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 17 Apr 2024 16:22:55 +0200 Subject: [PATCH 6/9] chore(tox): drop `--no-build-isolation` from Cython tests (#2229) * chore(tox): drop `--no-build-isolation` from Cython tests * chore(tox): fix a couple of PEBCAKs --- tox.ini | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/tox.ini b/tox.ini index 67d9d7c7d..8ebde179e 100644 --- a/tox.ini +++ b/tox.ini @@ -170,61 +170,53 @@ commands = python "{toxinidir}/tools/clean.py" "{toxinidir}/falcon" # -------------------------------------------------------------------- [with-cython] +# NOTE(vytas): Specify Cython dep for tests/test_cython.py as PEP 517 build +# does not require it (although Tox seems to inject it in the current impl). deps = -r{toxinidir}/requirements/tests Cython - # NOTE(vytas): By using --no-build-isolation, we need to manage build - # deps ourselves, and on CPython 3.12, it seems even setuptools - # (our PEP 517 backend of choice) is not guaranteed to be there. - setuptools - wheel setenv = PIP_CONFIG_FILE={toxinidir}/pip.conf FALCON_DISABLE_CYTHON= FALCON_ASGI_WRAP_NON_COROUTINES=Y FALCON_TESTING_SESSION=Y PYTHONASYNCIODEBUG=1 -install_command = python -m pip install --no-build-isolation {opts} {packages} commands = pytest tests [] [testenv:py37_cython] basepython = python3.7 -install_command = {[with-cython]install_command} deps = {[with-cython]deps} setenv = {[with-cython]setenv} commands = {[with-cython]commands} [testenv:py38_cython] basepython = python3.8 -install_command = {[with-cython]install_command} deps = {[with-cython]deps} setenv = {[with-cython]setenv} commands = {[with-cython]commands} [testenv:py39_cython] basepython = python3.9 -install_command = {[with-cython]install_command} deps = {[with-cython]deps} setenv = {[with-cython]setenv} commands = {[with-cython]commands} [testenv:py310_cython] basepython = python3.10 -install_command = {[with-cython]install_command} deps = {[with-cython]deps} setenv = {[with-cython]setenv} commands = {[with-cython]commands} [testenv:py311_cython] basepython = python3.11 -install_command = {[with-cython]install_command} deps = {[with-cython]deps} setenv = {[with-cython]setenv} commands = {[with-cython]commands} [testenv:py312_cython] basepython = python3.12 -install_command = {[with-cython]install_command} +# NOTE(vytas): pyximport relies on distutils.extension deps = {[with-cython]deps} + setuptools setenv = {[with-cython]setenv} commands = {[with-cython]commands} @@ -233,7 +225,6 @@ commands = {[with-cython]commands} # -------------------------------------------------------------------- [testenv:wsgi_servers] -install_command = {[with-cython]install_command} setenv = {[with-cython]setenv} deps = {[with-cython]deps} gunicorn From e619c39ab8a9ac6186eee866ad964f01c43bf6bf Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 17 Apr 2024 19:19:18 +0200 Subject: [PATCH 7/9] chore: migrate to `ruff` code formatter (#2230) * chore: migrate to `ruff` code formatter * chore: update falcon/inspect.py per @CaselIT's suggestion Co-authored-by: Federico Caselli * chore: fix spelling * chore(ruff): use parentheses to prevent too sparse layout --------- Co-authored-by: Federico Caselli --- .github/workflows/tests.yaml | 4 ++-- CONTRIBUTING.md | 8 ++++---- README.rst | 5 +---- examples/look/look/images.py | 1 - examples/things_advanced.py | 1 - examples/things_advanced_asgi.py | 1 - falcon/asgi/request.py | 1 - falcon/bench/nuts/nuts/app.py | 1 - falcon/cmd/inspect_app.py | 2 +- falcon/inspect.py | 6 ++++-- falcon/media/multipart.py | 2 -- falcon/middleware.py | 1 - falcon/request_helpers.py | 2 +- falcon/routing/compiled.py | 3 +-- falcon/routing/static.py | 1 - falcon/testing/client.py | 2 -- falcon/testing/helpers.py | 3 --- falcon/util/deprecation.py | 1 - falcon/util/misc.py | 1 + falcon/util/reader.py | 2 +- falcon/util/structures.py | 11 ++++------- falcon/util/sync.py | 2 +- falcon/util/uri.py | 3 +-- pyproject.toml | 9 +++++++++ setup.py | 1 - tests/asgi/test_boundedstream_asgi.py | 8 ++++---- tests/asgi/test_example_asgi.py | 1 - tests/asgi/test_request_context_asgi.py | 2 -- tests/asgi/test_response_media_asgi.py | 5 ++--- tests/asgi/test_sse.py | 5 ++--- tests/asgi/test_ws.py | 19 +++++++++---------- tests/test_after_hooks.py | 3 --- tests/test_before_hooks.py | 1 - tests/test_custom_router.py | 1 - tests/test_example.py | 1 - tests/test_headers.py | 11 +++++------ tests/test_hello.py | 2 -- tests/test_inspect.py | 2 +- tests/test_media_multipart.py | 8 ++++---- tests/test_middleware.py | 1 - tests/test_request_access_route.py | 1 - tests/test_request_attrs.py | 1 - tests/test_request_context.py | 2 -- tests/test_request_forwarded.py | 1 - tests/test_response_media.py | 4 ++-- tests/test_utils.py | 6 +++--- tox.ini | 14 ++++++++------ 47 files changed, 71 insertions(+), 102 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 6098b43d7..30556b9d2 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -23,9 +23,9 @@ jobs: - "ubuntu-latest" toxenv: - "pep8" - - "blue" - - "pep8-examples" - "pep8-docstrings" + - "pep8-examples" + - "ruff" - "mypy" - "mypy_tests" - "py312" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b28054167..f5dbd5cbc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,13 +28,13 @@ Please note that all contributors and maintainers of this project are subject to Before submitting a pull request, please ensure you have added or updated tests as appropriate, and that all existing tests still pass with your changes. -Please also ensure that your coding style follows PEP 8 and the ``blue`` formatting style. +Please also ensure that your coding style follows PEP 8 and the ``ruff`` formatting style. -In order to reformat your code with ``blue``, simply issue: +In order to reformat your code with ``ruff``, simply issue: ```bash -$ pip install -U blue -$ blue . +$ pip install -U ruff +$ ruff format ``` You can check all this by running ``tox`` from within the Falcon project directory. Your environment must be based on CPython 3.8, 3.10, 3.11 or 3.12: diff --git a/README.rst b/README.rst index 738b2f2b8..82c93d832 100644 --- a/README.rst +++ b/README.rst @@ -8,7 +8,7 @@ > -|Build Status| |Docs| |codecov.io| |Blue| +|Build Status| |Docs| |codecov.io| The Falcon Web Framework ======================== @@ -1049,6 +1049,3 @@ limitations under the License. :target: https://github.com/falconry/falcon/actions?query=workflow%3A%22Run+tests%22 .. |codecov.io| image:: https://codecov.io/gh/falconry/falcon/branch/master/graphs/badge.svg :target: http://codecov.io/gh/falconry/falcon -.. |Blue| image:: https://img.shields.io/badge/code%20style-blue-blue.svg - :target: https://blue.readthedocs.io/ - :alt: code style: blue diff --git a/examples/look/look/images.py b/examples/look/look/images.py index e355a68a1..3b4e84a0c 100644 --- a/examples/look/look/images.py +++ b/examples/look/look/images.py @@ -31,7 +31,6 @@ def on_post(self, req, resp): class ImageStore: - _CHUNK_SIZE_BYTES = 4096 # Note the use of dependency injection for standard library diff --git a/examples/things_advanced.py b/examples/things_advanced.py index 7e68173f8..5c28280a7 100644 --- a/examples/things_advanced.py +++ b/examples/things_advanced.py @@ -26,7 +26,6 @@ def handle(ex, req, resp, params): class SinkAdapter: - engines = { 'ddg': 'https://duckduckgo.com', 'y': 'https://search.yahoo.com/search', diff --git a/examples/things_advanced_asgi.py b/examples/things_advanced_asgi.py index 3f958983b..dcd816ef5 100644 --- a/examples/things_advanced_asgi.py +++ b/examples/things_advanced_asgi.py @@ -26,7 +26,6 @@ async def handle(ex, req, resp, params): class SinkAdapter: - engines = { 'ddg': 'https://duckduckgo.com', 'y': 'https://search.yahoo.com/search', diff --git a/falcon/asgi/request.py b/falcon/asgi/request.py index 3bb5f5c86..9650bfb63 100644 --- a/falcon/asgi/request.py +++ b/falcon/asgi/request.py @@ -384,7 +384,6 @@ class Request(request.Request): _wsgi_errors = None def __init__(self, scope, receive, first_event=None, options=None): - # ===================================================================== # Prepare headers # ===================================================================== diff --git a/falcon/bench/nuts/nuts/app.py b/falcon/bench/nuts/nuts/app.py index 37ca87029..d7c9cb1e7 100644 --- a/falcon/bench/nuts/nuts/app.py +++ b/falcon/bench/nuts/nuts/app.py @@ -8,7 +8,6 @@ def create(): def setup_app(config): - return make_app( config.app.root, static_root=config.app.static_root, diff --git a/falcon/cmd/inspect_app.py b/falcon/cmd/inspect_app.py index 260ef8dc4..bd3844f88 100644 --- a/falcon/cmd/inspect_app.py +++ b/falcon/cmd/inspect_app.py @@ -15,6 +15,7 @@ """ Script that prints out the routes of an App instance. """ + import argparse import importlib import os @@ -59,7 +60,6 @@ def make_parser(): def load_app(parser, args): - try: module, instance = args.app_module.split(':', 1) except ValueError: diff --git a/falcon/inspect.py b/falcon/inspect.py index 62fc74c28..e5bc4c690 100644 --- a/falcon/inspect.py +++ b/falcon/inspect.py @@ -13,6 +13,7 @@ # limitations under the License. """Inspect utilities for falcon applications.""" + from functools import partial import inspect from typing import Callable # NOQA: F401 @@ -89,8 +90,9 @@ def inspect_my_router(router): def wraps(fn): if router_class in _supported_routers: raise ValueError( - 'Another function is already registered' - ' for the router {}'.format(router_class) + 'Another function is already registered for the router {}'.format( + router_class + ) ) _supported_routers[router_class] = fn return fn diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py index 5b55d4b4f..240a4781c 100644 --- a/falcon/media/multipart.py +++ b/falcon/media/multipart.py @@ -272,7 +272,6 @@ def content_type(self): @property def filename(self): if self._filename is None: - if self._content_disposition is None: value = self._headers.get(b'content-disposition', b'') self._content_disposition = parse_header(value.decode()) @@ -308,7 +307,6 @@ def secure_filename(self): @property def name(self): if self._name is None: - if self._content_disposition is None: value = self._headers.get(b'content-disposition', b'') self._content_disposition = parse_header(value.decode()) diff --git a/falcon/middleware.py b/falcon/middleware.py index e6e8c00be..195892a29 100644 --- a/falcon/middleware.py +++ b/falcon/middleware.py @@ -108,7 +108,6 @@ def process_response(self, req: Request, resp: Response, resource, req_succeeded and req.method == 'OPTIONS' and req.get_header('Access-Control-Request-Method') ): - # NOTE(kgriffs): This is a CORS preflight request. Patch the # response accordingly. diff --git a/falcon/request_helpers.py b/falcon/request_helpers.py index 7534db108..f3cbf51cb 100644 --- a/falcon/request_helpers.py +++ b/falcon/request_helpers.py @@ -30,7 +30,7 @@ # (see also: https://www.python.org/dev/peps/pep-3333/#unicode-issues) # _COOKIE_NAME_RESERVED_CHARS = re.compile( - '[\x00-\x1F\x7F-\xFF()<>@,;:\\\\"/[\\]?={} \x09]' + '[\x00-\x1f\x7f-\xff()<>@,;:\\\\"/[\\]?={} \x09]' ) # NOTE(kgriffs): strictly speaking, the weakness indicator is diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py index 0b45edbc1..b7d6c3244 100644 --- a/falcon/routing/compiled.py +++ b/falcon/routing/compiled.py @@ -651,8 +651,7 @@ def _compile(self): src_lines.append( # PERF(kgriffs): Explicit return of None is faster than implicit - _TAB_STR - + 'return None' + _TAB_STR + 'return None' ) self._finder_src = '\n'.join(src_lines) diff --git a/falcon/routing/static.py b/falcon/routing/static.py index ff9bbef2a..4e7c27706 100644 --- a/falcon/routing/static.py +++ b/falcon/routing/static.py @@ -173,7 +173,6 @@ def __call__(self, req, resp): or '//' in without_prefix or len(without_prefix) > self._MAX_NON_PREFIXED_LEN ): - raise falcon.HTTPNotFound() normalized = os.path.normpath(without_prefix) diff --git a/falcon/testing/client.py b/falcon/testing/client.py index fce0f4d0a..05a59f599 100644 --- a/falcon/testing/client.py +++ b/falcon/testing/client.py @@ -452,7 +452,6 @@ def simulate_request( asgi_chunk_size=4096, asgi_disconnect_ttl=300, ) -> _ResultBase: - """Simulate a request to a WSGI or ASGI application. Performs a request against a WSGI or ASGI application. In the case of @@ -671,7 +670,6 @@ async def _simulate_request_asgi( _one_shot=True, _stream_result=False, ) -> _ResultBase: - """Simulate a request to an ASGI application. Keyword Args: diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index 39e8c12f8..d0d92dbff 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -864,7 +864,6 @@ def create_scope( include_server=True, cookies=None, ) -> Dict[str, Any]: - """Create a mock ASGI scope ``dict`` for simulating HTTP requests. Keyword Args: @@ -1002,7 +1001,6 @@ def create_scope_ws( subprotocols=None, spec_version='2.1', ) -> Dict[str, Any]: - """Create a mock ASGI scope ``dict`` for simulating WebSocket requests. Keyword Args: @@ -1089,7 +1087,6 @@ def create_environ( root_path=None, cookies=None, ) -> Dict[str, Any]: - """Create a mock PEP-3333 environ ``dict`` for simulating WSGI requests. Keyword Args: diff --git a/falcon/util/deprecation.py b/falcon/util/deprecation.py index 5e2607ad7..ed1229916 100644 --- a/falcon/util/deprecation.py +++ b/falcon/util/deprecation.py @@ -66,7 +66,6 @@ def deprecated( """ def decorator(func: Callable[..., Any]) -> Callable[[Callable[..., Any]], Any]: - object_name = 'property' if is_property else 'function' post_name = '' if is_property else '(...)' message = 'Call to deprecated {} {}{}. {}'.format( diff --git a/falcon/util/misc.py b/falcon/util/misc.py index 3690aeca4..bbd3080ed 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -22,6 +22,7 @@ now = falcon.http_now() """ + import datetime import functools import http diff --git a/falcon/util/reader.py b/falcon/util/reader.py index 263d66716..b97484f7f 100644 --- a/falcon/util/reader.py +++ b/falcon/util/reader.py @@ -13,6 +13,7 @@ # limitations under the License. """Buffered stream reader.""" + from __future__ import annotations import functools @@ -189,7 +190,6 @@ def _finalize_read_until( next_chunk: Optional[bytes] = None, next_chunk_len: int = 0, ) -> bytes: - if delimiter_pos < 0 and delimiter is not None: delimiter_pos = self._buffer.find(delimiter, self._buffer_pos) diff --git a/falcon/util/structures.py b/falcon/util/structures.py index 5a9e51176..f5b4e97c5 100644 --- a/falcon/util/structures.py +++ b/falcon/util/structures.py @@ -25,6 +25,7 @@ things = falcon.CaseInsensitiveDict() """ + from __future__ import annotations from collections.abc import Mapping @@ -146,14 +147,11 @@ class Context: # merely to let mypy know this is a namespace object. if TYPE_CHECKING: - def __getattr__(self, name: str) -> Any: - ... + def __getattr__(self, name: str) -> Any: ... - def __setattr__(self, name: str, value: Any) -> None: - ... + def __setattr__(self, name: str, value: Any) -> None: ... - def __delattr__(self, name: str) -> None: - ... + def __delattr__(self, name: str) -> None: ... def __contains__(self, key: str) -> bool: return self.__dict__.__contains__(key) @@ -217,7 +215,6 @@ def pop(self, key: str, default: Optional[Any] = None) -> Optional[Any]: return self.__dict__.pop(key, default) def popitem(self) -> Tuple[str, Any]: - return self.__dict__.popitem() def setdefault( diff --git a/falcon/util/sync.py b/falcon/util/sync.py index 96d05c058..f19d9f1ae 100644 --- a/falcon/util/sync.py +++ b/falcon/util/sync.py @@ -198,7 +198,7 @@ def _should_wrap_non_coroutines() -> bool: def _wrap_non_coroutine_unsafe( - func: Optional[Callable[..., Any]] + func: Optional[Callable[..., Any]], ) -> Union[Callable[..., Awaitable[Any]], Callable[..., Any], None]: """Wrap a coroutine using ``wrap_sync_to_async_unsafe()`` for internal test cases. diff --git a/falcon/util/uri.py b/falcon/util/uri.py index 5daa7c68a..15bef3c97 100644 --- a/falcon/util/uri.py +++ b/falcon/util/uri.py @@ -22,6 +22,7 @@ name, port = uri.parse_host('example.org:8080') """ + from typing import Callable from typing import Dict from typing import List @@ -57,7 +58,6 @@ def _create_char_encoder(allowed_chars: str) -> Callable[[int], str]: - lookup = {} for code_point in range(256): @@ -74,7 +74,6 @@ def _create_char_encoder(allowed_chars: str) -> Callable[[int], str]: def _create_str_encoder( is_value: bool, check_is_escaped: bool = False ) -> Callable[[str], str]: - allowed_chars = _UNRESERVED if is_value else _ALL_ALLOWED allowed_chars_plus_percent = allowed_chars + '%' encode_char = _create_char_encoder(allowed_chars) diff --git a/pyproject.toml b/pyproject.toml index 44a829feb..73fbcea1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,10 +82,19 @@ extend-exclude = "falcon/vendor" [tool.blue] + # NOTE(vytas): Before switching to Ruff, Falcon used the Blue formatter. + # With the below settings, accidentally running blue should yield + # only minor cosmetic changes in a handful of files. target-version = ["py37"] line-length = 88 extend-exclude = "falcon/vendor" +[tool.ruff] + target-version = "py37" + format.quote-style = "single" + line-length = 88 + extend-exclude = ["falcon/vendor"] + [tool.pytest.ini_options] filterwarnings = [ "ignore:Unknown REQUEST_METHOD. '(CONNECT|DELETE|GET|HEAD|OPTIONS|PATCH|POST|PUT|TRACE|CHECKIN|CHECKOUT|COPY|LOCK|MKCOL|MOVE|PROPFIND|PROPPATCH|REPORT|UNCHECKIN|UNLOCK|UPDATE|VERSION-CONTROL)':wsgiref.validate.WSGIWarning", diff --git a/setup.py b/setup.py index c3db954af..edae6c702 100644 --- a/setup.py +++ b/setup.py @@ -139,7 +139,6 @@ def load_description(): # NOTE(kgriffs): PyPI does not support the raw directive for readme_line in io.open('README.rst', 'r', encoding='utf-8'): - # NOTE(vytas): The patron list largely builds upon raw sections if readme_line.startswith('.. Patron list starts'): in_patron_list = True diff --git a/tests/asgi/test_boundedstream_asgi.py b/tests/asgi/test_boundedstream_asgi.py index 3d80c7f86..135d7441d 100644 --- a/tests/asgi/test_boundedstream_asgi.py +++ b/tests/asgi/test_boundedstream_asgi.py @@ -11,9 +11,9 @@ [ b'', b'\x00', - b'\x00\xFF', + b'\x00\xff', b'catsup', - b'\xDE\xAD\xBE\xEF' * 512, + b'\xde\xad\xbe\xef' * 512, testing.rand_string(1, 2048), os.urandom(100 * 2**20), ], @@ -193,9 +193,9 @@ async def receive(): [ b'', b'\x00', - b'\x00\xFF', + b'\x00\xff', b'catsup', - b'\xDE\xAD\xBE\xEF' * 512, + b'\xde\xad\xbe\xef' * 512, testing.rand_string(1, 2048).encode(), ], ids=['empty', 'null', 'null-ff', 'normal', 'long', 'random'], diff --git a/tests/asgi/test_example_asgi.py b/tests/asgi/test_example_asgi.py index cee1138a1..f67ee3af6 100644 --- a/tests/asgi/test_example_asgi.py +++ b/tests/asgi/test_example_asgi.py @@ -27,7 +27,6 @@ async def handle(ex, req, resp, params): class SinkAdapter: - engines = { 'ddg': 'https://duckduckgo.com', 'y': 'https://search.yahoo.com/search', diff --git a/tests/asgi/test_request_context_asgi.py b/tests/asgi/test_request_context_asgi.py index e06b965d5..31d1f965b 100644 --- a/tests/asgi/test_request_context_asgi.py +++ b/tests/asgi/test_request_context_asgi.py @@ -20,7 +20,6 @@ def test_default_request_context( assert req.context.get('note') == req.context['note'] def test_custom_request_context(self): - # Define a Request-alike with a custom context type class MyCustomContextType: pass @@ -32,7 +31,6 @@ class MyCustomRequest(Request): assert isinstance(req.context, MyCustomContextType) def test_custom_request_context_failure(self): - # Define a Request-alike with a non-callable custom context type class MyCustomRequest(Request): context_type = False diff --git a/tests/asgi/test_response_media_asgi.py b/tests/asgi/test_response_media_asgi.py index 3d5856282..a55c6e606 100644 --- a/tests/asgi/test_response_media_asgi.py +++ b/tests/asgi/test_response_media_asgi.py @@ -59,7 +59,7 @@ async def on_get(self, req, resp): '', 'I am a \u1d0a\ua731\u1d0f\u0274 string.', ['\u2665', '\u2660', '\u2666', '\u2663'], - {'message': '\xa1Hello Unicode! \U0001F638'}, + {'message': '\xa1Hello Unicode! \U0001f638'}, { 'description': 'A collection of primitive Python type examples.', 'bool': False is not True and True is not False, @@ -69,7 +69,7 @@ async def on_get(self, req, resp): 'list': ['a', 'sequence', 'of', 'items'], 'none': None, 'str': 'ASCII string', - 'unicode': 'Hello Unicode! \U0001F638', + 'unicode': 'Hello Unicode! \U0001f638', }, ], ) @@ -221,7 +221,6 @@ def run_test(test_fn): class TestResource: async def on_get(self, req, resp): - await test_fn(resp) resp.text = None diff --git a/tests/asgi/test_sse.py b/tests/asgi/test_sse.py index eac00b4b9..df04688c7 100644 --- a/tests/asgi/test_sse.py +++ b/tests/asgi/test_sse.py @@ -83,7 +83,7 @@ def test_multiple_events(): '\n' ': Serve with chips.\n' 'retry: 100\n' - 'data: guacamole \u1F951\n' + 'data: guacamole \u1f951\n' '\n' 'retry: 100\n' 'data: {"condiment": "salsa"}\n' @@ -101,7 +101,7 @@ async def emitter(): data=b'onions', event='topping', event_id='5678', retry=100 ), SSEvent( - text='guacamole \u1F951', retry=100, comment='Serve with chips.' + text='guacamole \u1f951', retry=100, comment='Serve with chips.' ), SSEvent(json={'condiment': 'salsa'}, retry=100), ]: @@ -173,7 +173,6 @@ async def _test(): async with testing.ASGIConductor(app) as conductor: # NOTE(vytas): Using the get_stream() alias. async with conductor.get_stream() as sr: - event_count = 0 result_text = '' diff --git a/tests/asgi/test_ws.py b/tests/asgi/test_ws.py index f45ea9758..d00f71dc6 100644 --- a/tests/asgi/test_ws.py +++ b/tests/asgi/test_ws.py @@ -442,7 +442,7 @@ async def on_websocket(self, req, ws): for __ in range(3): try: await ws.receive_media() - except (ValueError): + except ValueError: self.deserialize_error_count += 1 finally: self.finished.set() @@ -467,9 +467,9 @@ def serialize(self, media: object) -> str: def deserialize(self, payload: str) -> object: return rapidjson.loads(payload) - app.ws_options.media_handlers[ - falcon.WebSocketPayloadType.TEXT - ] = RapidJSONHandler() + app.ws_options.media_handlers[falcon.WebSocketPayloadType.TEXT] = ( + RapidJSONHandler() + ) if custom_data: @@ -481,9 +481,9 @@ def serialize(self, media: object) -> bytes: def deserialize(self, payload: bytes) -> object: return cbor2.loads(payload) - app.ws_options.media_handlers[ - falcon.WebSocketPayloadType.BINARY - ] = CBORHandler() + app.ws_options.media_handlers[falcon.WebSocketPayloadType.BINARY] = ( + CBORHandler() + ) async with conductor as c: async with c.simulate_ws() as ws: @@ -503,7 +503,7 @@ def deserialize(self, payload: bytes) -> object: # ensure we aren't getting any false-positives. await ws.send_text('"DEADBEEF"') await ws.send_text('DEADBEEF') - await ws.send_data(b'\xDE\xAD\xBE\xEF') + await ws.send_data(b'\xde\xad\xbe\xef') await resource.finished.wait() @@ -1119,7 +1119,6 @@ async def on_websocket(self, req, ws): @pytest.mark.skipif(msgpack, reason='test requires msgpack lib to be missing') def test_msgpack_missing(): - options = WebSocketOptions() handler = options.media_handlers[falcon.WebSocketPayloadType.BINARY] @@ -1231,7 +1230,7 @@ async def process_resource_ws(self, req, ws, res, params): if handler_has_ws: - async def handle_foobar(req, resp, ex, param, ws=None): # type: ignore + async def handle_foobar(req, resp, ex, param, ws=None): # type: ignore raise thing(status) else: diff --git a/tests/test_after_hooks.py b/tests/test_after_hooks.py index 4f95914b7..4f5cc1f00 100644 --- a/tests/test_after_hooks.py +++ b/tests/test_after_hooks.py @@ -147,7 +147,6 @@ async def on_post(self, req, resp): @falcon.after(cuteness, 'fluffy', postfix=' and innocent') @falcon.after(fluffiness, 'kitten') class WrappedClassResource: - # Test that the decorator skips non-callables on_post = False @@ -196,7 +195,6 @@ def on_get(self, req, resp, field1, field2): # at once for the sake of simplicity @falcon.after(resource_aware_cuteness) class ClassResourceWithAwareHooks: - # Test that the decorator skips non-callables on_delete = False @@ -342,7 +340,6 @@ def test_wrapped_resource_with_hooks_aware_of_resource(client, wrapped_resource_ class ResourceAwareGameHook: - VALUES = ('rock', 'scissors', 'paper') @classmethod diff --git a/tests/test_before_hooks.py b/tests/test_before_hooks.py index 882a5964c..ebc936030 100644 --- a/tests/test_before_hooks.py +++ b/tests/test_before_hooks.py @@ -130,7 +130,6 @@ def on_get(self, req, resp, doc=None): @falcon.before(bunnies) class WrappedClassResource: - _some_fish = Fish() # Test non-callable should be skipped by decorator diff --git a/tests/test_custom_router.py b/tests/test_custom_router.py index 3640c8404..b21b0c447 100644 --- a/tests/test_custom_router.py +++ b/tests/test_custom_router.py @@ -79,7 +79,6 @@ def find(self, uri, req=None): @pytest.mark.parametrize('asgi', [True, False]) def test_can_pass_additional_params_to_add_route(asgi): - check = [] class CustomRouter: diff --git a/tests/test_example.py b/tests/test_example.py index bb430a94c..400458451 100644 --- a/tests/test_example.py +++ b/tests/test_example.py @@ -25,7 +25,6 @@ def handle(req, resp, ex, params): class SinkAdapter: - engines = { 'ddg': 'https://duckduckgo.com', 'y': 'https://search.yahoo.com/search', diff --git a/tests/test_headers.py b/tests/test_headers.py index bf9d47536..afdf18e0c 100644 --- a/tests/test_headers.py +++ b/tests/test_headers.py @@ -119,7 +119,6 @@ def on_put(self, req, resp): class LocationHeaderUnicodeResource: - URL1 = '/\u00e7runchy/bacon' URL2 = 'ab\u00e7' @@ -156,14 +155,14 @@ def on_patch(self, req, resp): def on_post(self, req, resp): resp.set_headers( [ - ('X-symb\u00F6l', 'thing'), + ('X-symb\u00f6l', 'thing'), ] ) def on_put(self, req, resp): resp.set_headers( [ - ('X-Thing', '\u00FF'), + ('X-Thing', '\u00ff'), ] ) @@ -498,7 +497,7 @@ def test_default_media_type(self, client): @pytest.mark.parametrize( 'content_type,body', [ - ('text/plain; charset=UTF-8', 'Hello Unicode! \U0001F638'), + ('text/plain; charset=UTF-8', 'Hello Unicode! \U0001f638'), # NOTE(kgriffs): This only works because the client defaults to # ISO-8859-1 IFF the media type is 'text'. ('text/plain', 'Hello ISO-8859-1!'), @@ -514,7 +513,7 @@ def test_override_default_media_type(self, asgi, client, content_type, body): @pytest.mark.parametrize('asgi', [True, False]) def test_override_default_media_type_missing_encoding(self, asgi, client): - body = '{"msg": "Hello Unicode! \U0001F638"}' + body = '{"msg": "Hello Unicode! \U0001f638"}' client.app = create_app(asgi=asgi, media_type='application/json') client.app.add_route('/', testing.SimpleTestResource(body=body)) @@ -523,7 +522,7 @@ def test_override_default_media_type_missing_encoding(self, asgi, client): assert result.content == body.encode('utf-8') assert isinstance(result.text, str) assert result.text == body - assert result.json == {'msg': 'Hello Unicode! \U0001F638'} + assert result.json == {'msg': 'Hello Unicode! \U0001f638'} def test_response_header_helpers_on_get(self, client): last_modified = datetime(2013, 1, 1, 10, 30, 30) diff --git a/tests/test_hello.py b/tests/test_hello.py index e444e529e..1bef8d773 100644 --- a/tests/test_hello.py +++ b/tests/test_hello.py @@ -71,7 +71,6 @@ def on_head(self, req, resp): class ClosingBytesIO(io.BytesIO): - close_called = False def close(self): @@ -80,7 +79,6 @@ def close(self): class NonClosingBytesIO(io.BytesIO): - # Not callable; test that CloseableStreamIterator ignores it close = False # type: ignore diff --git a/tests/test_inspect.py b/tests/test_inspect.py index 4d11dbb82..84b9c218b 100644 --- a/tests/test_inspect.py +++ b/tests/test_inspect.py @@ -233,7 +233,7 @@ def test_middleware_tree(self, asgi): mi = inspect.inspect_middleware(make_app_async() if asgi else make_app()) def test(tl, names, cls): - for (t, n, c) in zip(tl, names, cls): + for t, n, c in zip(tl, names, cls): assert isinstance(t, inspect.MiddlewareTreeItemInfo) assert t.name == n assert t.class_name == c diff --git a/tests/test_media_multipart.py b/tests/test_media_multipart.py index 7edf51f4c..6643a3146 100644 --- a/tests/test_media_multipart.py +++ b/tests/test_media_multipart.py @@ -60,7 +60,7 @@ b'--BOUNDARY\r\n' b'Content-Disposition: form-data; name="file"; filename="bytes"\r\n' b'Content-Type: application/x-falcon\r\n\r\n' - + b'123456789abcdef\n' * 64 * 1024 * 2 + + (b'123456789abcdef\n' * 64 * 1024 * 2) + b'\r\n' b'--BOUNDARY\r\n' b'Content-Disposition: form-data; name="empty"\r\n' @@ -622,9 +622,9 @@ def on_post(self, req, resp): resp.media = example parser = media.MultipartFormHandler() - parser.parse_options.media_handlers[ - 'multipart/mixed' - ] = media.MultipartFormHandler() + parser.parse_options.media_handlers['multipart/mixed'] = ( + media.MultipartFormHandler() + ) app = falcon.App() app.req_options.media_handlers[falcon.MEDIA_MULTIPART] = parser diff --git a/tests/test_middleware.py b/tests/test_middleware.py index b6b1b9d30..04de946e8 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -122,7 +122,6 @@ def process_request(self, req, resp): class ResponseCacheMiddlware: - PROCESS_REQUEST_CACHED_BODY = {'cached': True} PROCESS_RESOURCE_CACHED_BODY = {'cached': True, 'resource': True} diff --git a/tests/test_request_access_route.py b/tests/test_request_access_route.py index 202dac223..7571efae7 100644 --- a/tests/test_request_access_route.py +++ b/tests/test_request_access_route.py @@ -83,7 +83,6 @@ def test_malformed_rfc_forwarded(asgi): @pytest.mark.parametrize('include_localhost', [True, False]) def test_x_forwarded_for(asgi, include_localhost): - forwarded_for = '192.0.2.43, 2001:db8:cafe::17,unknown, _hidden, 203.0.113.60' if include_localhost: diff --git a/tests/test_request_attrs.py b/tests/test_request_attrs.py index 466a0c875..8ae5b72dc 100644 --- a/tests/test_request_attrs.py +++ b/tests/test_request_attrs.py @@ -709,7 +709,6 @@ def test_date(self, asgi, header, attr): ], ) def test_date_invalid(self, asgi, header, attr): - # Date formats don't conform to RFC 1123 headers = {header: 'Thu, 04 Apr 2013'} expected_desc = ( diff --git a/tests/test_request_context.py b/tests/test_request_context.py index 90bf9b90b..6d26a27a2 100644 --- a/tests/test_request_context.py +++ b/tests/test_request_context.py @@ -20,7 +20,6 @@ def test_default_request_context( assert req.context.get('note') == req.context['note'] def test_custom_request_context(self): - # Define a Request-alike with a custom context type class MyCustomContextType: pass @@ -33,7 +32,6 @@ class MyCustomRequest(Request): assert isinstance(req.context, MyCustomContextType) def test_custom_request_context_failure(self): - # Define a Request-alike with a non-callable custom context type class MyCustomRequest(Request): context_type = False diff --git a/tests/test_request_forwarded.py b/tests/test_request_forwarded.py index 66e4e406b..c4d36c39f 100644 --- a/tests/test_request_forwarded.py +++ b/tests/test_request_forwarded.py @@ -190,7 +190,6 @@ def test_forwarded_quote_escaping(asgi): ], ) def test_escape_malformed_requests(forwarded, expected_dest, asgi): - req = create_req( asgi, host='suchproxy02.suchtesting.com', diff --git a/tests/test_response_media.py b/tests/test_response_media.py index 2b4445ae6..6c19a59ea 100644 --- a/tests/test_response_media.py +++ b/tests/test_response_media.py @@ -61,7 +61,7 @@ def test_json(client, media_type): '', 'I am a \u1d0a\ua731\u1d0f\u0274 string.', ['\u2665', '\u2660', '\u2666', '\u2663'], - {'message': '\xa1Hello Unicode! \U0001F638'}, + {'message': '\xa1Hello Unicode! \U0001f638'}, { 'description': 'A collection of primitive Python type examples.', 'bool': False is not True and True is not False, @@ -71,7 +71,7 @@ def test_json(client, media_type): 'list': ['a', 'sequence', 'of', 'items'], 'none': None, 'str': 'ASCII string', - 'unicode': 'Hello Unicode! \U0001F638', + 'unicode': 'Hello Unicode! \U0001f638', }, ], ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 349032856..df5ff2fc4 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -645,8 +645,8 @@ def test_secure_filename_empty_value(self): ('/api', True), ('/data/items/something?query=apples%20and%20oranges', True), ('/food?item=ð\x9f\x8d\x94', False), - ('\x00\x00\x7F\x00\x00\x7F\x00', True), - ('\x00\x00\x7F\x00\x00\x80\x00', False), + ('\x00\x00\x7f\x00\x00\x7f\x00', True), + ('\x00\x00\x7f\x00\x00\x80\x00', False), ], ) @pytest.mark.parametrize('method', ['isascii', '_isascii']) @@ -952,7 +952,7 @@ def test_query_string_in_path(self, app): '', 'I am a \u1d0a\ua731\u1d0f\u0274 string.', [1, 3, 3, 7], - {'message': '\xa1Hello Unicode! \U0001F638'}, + {'message': '\xa1Hello Unicode! \U0001f638'}, { 'count': 4, 'items': [ diff --git a/tox.ini b/tox.ini index 8ebde179e..0e6dcd477 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,7 @@ # -------------------------------------------------------------------- envlist = cleanup, - blue, + ruff, pep8, mypy, mypy_tests, @@ -269,13 +269,15 @@ deps = flake8 flake8-import-order commands = flake8 [] -[testenv:blue] -deps = blue>=0.9.0 -commands = blue --check . [] +[testenv:ruff] +deps = ruff>=0.3.7 +skip_install = True +commands = ruff format --check . [] [testenv:reformat] -deps = blue>=0.9.0 -commands = blue . [] +deps = ruff>=0.3.7 +skip_install = True +commands = ruff format . [] [testenv:pep8-docstrings] deps = flake8 From c4a5f32e19297c498b09b35923a5339dedecb530 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Tue, 7 May 2024 20:26:49 +0200 Subject: [PATCH 8/9] chore(CI): pin `pytest` + ASGI tutorial fixes (#2233) --- docs/user/tutorial-asgi.rst | 5 +---- examples/asgilook/asgilook/store.py | 2 +- requirements/tests | 5 ++++- tox.ini | 3 +-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/user/tutorial-asgi.rst b/docs/user/tutorial-asgi.rst index 65ffa2fa9..dff2206b2 100644 --- a/docs/user/tutorial-asgi.rst +++ b/docs/user/tutorial-asgi.rst @@ -180,13 +180,12 @@ We can now implement a basic async image store. Save the following code as class Image: - def __init__(self, config, image_id, size): self._config = config self.image_id = image_id self.size = size - self.modified = datetime.datetime.utcnow() + self.modified = datetime.datetime.now(datetime.timezone.utc) @property def path(self): @@ -206,7 +205,6 @@ We can now implement a basic async image store. Save the following code as class Store: - def __init__(self, config): self._config = config self._images = {} @@ -272,7 +270,6 @@ of images. Place the code below in a file named ``images.py``: class Images: - def __init__(self, config, store): self._config = config self._store = store diff --git a/examples/asgilook/asgilook/store.py b/examples/asgilook/asgilook/store.py index 2633f0ac0..faf873c91 100644 --- a/examples/asgilook/asgilook/store.py +++ b/examples/asgilook/asgilook/store.py @@ -13,7 +13,7 @@ def __init__(self, config, image_id, size): self.image_id = image_id self.size = size - self.modified = datetime.datetime.utcnow() + self.modified = datetime.datetime.now(datetime.timezone.utc) @property def path(self): diff --git a/requirements/tests b/requirements/tests index 19b34bcd3..e3623da8d 100644 --- a/requirements/tests +++ b/requirements/tests @@ -1,5 +1,8 @@ coverage >= 4.1 -pytest +# TODO(vytas): Our use of testtools breaks under pytest 8.2 along the lines of +# https://github.com/pytest-dev/pytest/issues/12263, unpin when fixed +# (or drop support for testtools altogether?) +pytest >= 7.0, < 8.2 pyyaml requests # TODO(vytas): Check if testtools still brings anything to the table, and diff --git a/tox.ini b/tox.ini index 0e6dcd477..e6ca31342 100644 --- a/tox.ini +++ b/tox.ini @@ -435,7 +435,6 @@ commands = # -------------------------------------------------------------------- [testenv:look] -basepython = python3.10 deps = -r{toxinidir}/examples/look/requirements/test commands = @@ -446,7 +445,7 @@ commands = # -------------------------------------------------------------------- [testenv:asgilook] -basepython = python3.10 +basepython = python3.12 deps = -r{toxinidir}/examples/asgilook/requirements/asgilook -r{toxinidir}/examples/asgilook/requirements/test From 9b27c71e40ffafa76a160b33905281084430ef98 Mon Sep 17 00:00:00 2001 From: Mario Rivera <150161290+MRLab12@users.noreply.github.com> Date: Tue, 7 May 2024 15:30:52 -0400 Subject: [PATCH 9/9] docs(asgi-tutorial): include info on setting up logging for debugging (#2223) * Update tutorial-asgi to include info on setting up logging for debugging an application * Add Python logging docs link/add note that debugging asgi also applies to wsgi * 80 char limit / changed logging example to show use with falcon / added intersphinx * docs: update tutorial-asgi.rst --------- Co-authored-by: Vytautas Liuolia --- docs/user/tutorial-asgi.rst | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/user/tutorial-asgi.rst b/docs/user/tutorial-asgi.rst index dff2206b2..dd7c63b20 100644 --- a/docs/user/tutorial-asgi.rst +++ b/docs/user/tutorial-asgi.rst @@ -964,6 +964,54 @@ 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 +--------------------------- +(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 falcon + import logging + + 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? ---------