From 5367bbdc431d701e33d0fc0ebb6afe0490f25a59 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sat, 14 Oct 2023 12:37:05 +0200 Subject: [PATCH 1/9] chore: add basic CPython 3.12 definitions and CI jobs --- .github/workflows/create-wheels.yaml | 5 ++++- .github/workflows/tests.yaml | 6 ++++++ setup.cfg | 1 + tox.ini | 7 +++++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/create-wheels.yaml b/.github/workflows/create-wheels.yaml index a82642075..2a469dee4 100644 --- a/.github/workflows/create-wheels.yaml +++ b/.github/workflows/create-wheels.yaml @@ -29,7 +29,8 @@ jobs: - "3.8" - "3.9" - "3.10" - - "3.11.0-rc - 3.11" + - "3.11" + - "3.12" architecture: - x64 @@ -99,6 +100,7 @@ jobs: - cp39-cp39 - cp310-cp310 - cp311-cp311 + - cp312-cp312 architecture: - x64 @@ -230,6 +232,7 @@ jobs: - cp39-cp39 - cp310-cp310 - cp311-cp311 + - cp312-cp312 architecture: - aarch64 - s390x diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 398fd0046..6a0ddbe40 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -76,6 +76,12 @@ jobs: - python-version: "3.11" os: ubuntu-latest toxenv: py311_cython + - python-version: "3.12" + os: ubuntu-latest + toxenv: py312 + - python-version: "3.12" + os: ubuntu-latest + toxenv: py312_cython - python-version: "3.10" os: macos-latest toxenv: py310_nocover diff --git a/setup.cfg b/setup.cfg index 15de77818..e7c308429 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,6 +29,7 @@ classifiers = Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 Programming Language :: Cython keywords = asgi diff --git a/tox.ini b/tox.ini index 98ba87223..3040215a3 100644 --- a/tox.ini +++ b/tox.ini @@ -216,6 +216,13 @@ deps = {[with-cython]deps} setenv = {[with-cython]setenv} commands = {[with-cython]commands} +[testenv:py312_cython] +basepython = python3.12 +install_command = {[with-cython]install_command} +deps = {[with-cython]deps} +setenv = {[with-cython]setenv} +commands = {[with-cython]commands} + # -------------------------------------------------------------------- # WSGI servers (Cythonized Falcon) # -------------------------------------------------------------------- From 7065fd8a210b146d6c7c5d195f58f47c9373b597 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sat, 14 Oct 2023 12:40:18 +0200 Subject: [PATCH 2/9] chore(mailman): temporarily disabling failing GNU Mailman integr tests --- .github/workflows/tests-mailman.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests-mailman.yaml b/.github/workflows/tests-mailman.yaml index 2243bcce4..60fd7691a 100644 --- a/.github/workflows/tests-mailman.yaml +++ b/.github/workflows/tests-mailman.yaml @@ -3,9 +3,12 @@ name: Run tests (GNU Mailman 3) on: # Trigger the workflow on master but also allow it to run manually. workflow_dispatch: - push: - branches: - - master + + # NOTE(vytas): Disabled as it is failing as of 2023-09. + # Maybe @maxking just needs to update the Docker image (?) + # push: + # branches: + # - master jobs: run_tox: From 937ca9f9b37bada60c4c6a1b74aab310b94c5fb8 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sat, 14 Oct 2023 17:17:55 +0200 Subject: [PATCH 3/9] chore(tox): add `setuptools` & `wheel` manually when using `--no-build-isolation` --- tox.ini | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tox.ini b/tox.ini index 3040215a3..327298fe5 100644 --- a/tox.ini +++ b/tox.ini @@ -172,6 +172,11 @@ commands = python "{toxinidir}/tools/clean.py" "{toxinidir}/falcon" [with-cython] 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= From fbac12ab49a2a60bc8c2ae56b976cb8495d4e69d Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sat, 14 Oct 2023 19:49:58 +0200 Subject: [PATCH 4/9] test(test_ws.py): fix one async race potentially yielding unexpected result --- tests/asgi/test_ws.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/asgi/test_ws.py b/tests/asgi/test_ws.py index c96ed3e0f..265312f16 100644 --- a/tests/asgi/test_ws.py +++ b/tests/asgi/test_ws.py @@ -370,6 +370,13 @@ async def on_websocket(self, req, ws): await resource.data_received.wait() assert resource.data == sample_data + # NOTE(vytas): When testing the case where the server + # explicitly closes the connection, try to receive some data + # before closing from the client side (and potentially + # winning the async race of which side closes first). + if explicit_close_server: + await ws.receive_data() + if explicit_close_client: await ws.close(4042) From 115c1104a090f462495497d1a0de34d2ac27c42c Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sat, 14 Oct 2023 19:59:39 +0200 Subject: [PATCH 5/9] test(test_ws.py): give some tests enough coroutine cycles under CPython 3.12 optimizations --- tests/asgi/test_ws.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/asgi/test_ws.py b/tests/asgi/test_ws.py index 265312f16..75b976211 100644 --- a/tests/asgi/test_ws.py +++ b/tests/asgi/test_ws.py @@ -1118,6 +1118,9 @@ async def on_websocket(self, req, ws): async with conductor as c: if accept: async with c.simulate_ws() as ws: + # Make sure the responder has a chance to reach the raise point + for _ in range(3): + await asyncio.sleep(0) assert ws.closed assert ws.close_code == exp_code else: @@ -1215,6 +1218,9 @@ async def handle_foobar(req, resp, ex, param): # type: ignore async with conductor as c: if place == 'ws_after_accept': async with c.simulate_ws() as ws: + # Make sure the responder has a chance to reach the raise point + for _ in range(3): + await asyncio.sleep(0) assert ws.closed assert ws.close_code == exp_code else: From 89c1d5c0629c8dad48cfc76d967356cc7e9497c0 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sun, 15 Oct 2023 19:27:17 +0200 Subject: [PATCH 6/9] chore(py312): address or ignore new deprecations --- falcon/util/misc.py | 4 +++- pyproject.toml | 2 ++ tests/test_cookies.py | 14 +++++++++----- tests/test_headers.py | 4 +++- tests/test_middleware.py | 7 ++++--- tests/test_utils.py | 9 ++++----- 6 files changed, 25 insertions(+), 15 deletions(-) diff --git a/falcon/util/misc.py b/falcon/util/misc.py index 2b7fb7b25..fa6f74faf 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -75,7 +75,9 @@ # PERF(kgriffs): Avoid superfluous namespace lookups strptime: Callable[[str, str], datetime.datetime] = datetime.datetime.strptime -utcnow: Callable[[], datetime.datetime] = datetime.datetime.utcnow +utcnow: Callable[[], datetime.datetime] = functools.partial( + datetime.datetime.now, datetime.timezone.utc +) # NOTE(kgriffs,vytas): This is tested in the PyPy gate but we do not want devs diff --git a/pyproject.toml b/pyproject.toml index ee04f5013..ad445ce55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,8 @@ filterwarnings = [ "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", + "ignore:There is no current event loop", ] testpaths = [ "tests" diff --git a/tests/test_cookies.py b/tests/test_cookies.py index 72759f0e5..1d2e0c847 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, tzinfo +from datetime import datetime, timedelta, timezone, tzinfo from http import cookies as http_cookies import re @@ -28,6 +28,10 @@ def dst(self, dt): GMT_PLUS_ONE = TimezoneGMTPlus1() +def utcnow_naive(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + class CookieResource: def on_get(self, req, resp): resp.set_cookie('foo', 'bar', domain='example.com', path='/') @@ -171,7 +175,7 @@ def test_response_complex_case(client): assert cookie.domain is None assert cookie.same_site == 'Lax' - assert cookie.expires < datetime.utcnow() + assert cookie.expires < utcnow_naive() # NOTE(kgriffs): I know accessing a private attr like this is # naughty of me, but we just need to sanity-check that the @@ -193,7 +197,7 @@ def test(cookie, path, domain, samesite='Lax'): assert cookie.domain == domain assert cookie.path == path assert cookie.same_site == samesite - assert cookie.expires < datetime.utcnow() + assert cookie.expires < utcnow_naive() test(result.cookies['foo'], path=None, domain=None) test(result.cookies['bar'], path='/bar', domain=None) @@ -231,7 +235,7 @@ def test_set(cookie, value, samesite=None): def test_unset(cookie, samesite='Lax'): assert cookie.value == '' # An unset cookie has an empty value assert cookie.same_site == samesite - assert cookie.expires < datetime.utcnow() + assert cookie.expires < utcnow_naive() test_unset(result_unset.cookies['foo'], samesite='Strict') # default: bar is unset with no samesite param, so should go to Lax @@ -325,7 +329,7 @@ def test_response_unset_cookie(client): assert match expiration = http_date_to_dt(match.group(1), obs_date=True) - assert expiration < datetime.utcnow() + assert expiration < utcnow_naive() def test_cookie_timezone(client): diff --git a/tests/test_headers.py b/tests/test_headers.py index 63c5170fb..5a2997b43 100644 --- a/tests/test_headers.py +++ b/tests/test_headers.py @@ -6,6 +6,8 @@ import falcon from falcon import testing from falcon.util.deprecation import DeprecatedWarning +from falcon.util.misc import utcnow + from _util import create_app # NOQA @@ -31,7 +33,7 @@ def __init__(self, last_modified=None): if last_modified is not None: self.last_modified = last_modified else: - self.last_modified = datetime.utcnow() + self.last_modified = utcnow() def _overwrite_headers(self, req, resp): resp.content_type = 'x-falcon/peregrine' diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 5b794c7aa..42e3dfe54 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -10,6 +10,7 @@ import falcon import falcon.errors import falcon.testing as testing +from falcon.util.misc import utcnow from _util import create_app # NOQA @@ -36,15 +37,15 @@ def process_request(self, req, resp): class RequestTimeMiddleware: def process_request(self, req, resp): global context - context['start_time'] = datetime.utcnow() + context['start_time'] = utcnow() def process_resource(self, req, resp, resource, params): global context - context['mid_time'] = datetime.utcnow() + context['mid_time'] = utcnow() def process_response(self, req, resp, resource, req_succeeded): global context - context['end_time'] = datetime.utcnow() + context['end_time'] = utcnow() context['req_succeeded'] = req_succeeded async def process_request_async(self, req, resp): diff --git a/tests/test_utils.py b/tests/test_utils.py index 83a0e1f22..83a8dacce 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -from datetime import datetime +from datetime import datetime, timezone import functools import http import itertools @@ -109,13 +109,12 @@ def old_thing(): assert msg in str(warn.message) def test_http_now(self): - expected = datetime.utcnow() + expected = datetime.now(timezone.utc) actual = falcon.http_date_to_dt(falcon.http_now()) - delta = actual - expected - delta_sec = abs(delta.days * 86400 + delta.seconds) + delta = actual.replace(tzinfo=timezone.utc) - expected - assert delta_sec <= 1 + assert delta.total_seconds() <= 1 def test_dt_to_http(self): assert ( From 25910550634f98dc5eb33ab6d75eb989601951aa Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sun, 15 Oct 2023 19:33:27 +0200 Subject: [PATCH 7/9] style: remove an unused import --- tests/test_middleware.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 42e3dfe54..bfafc6637 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,4 +1,3 @@ -from datetime import datetime import json try: From f4ebc1c0bca57b67ce6713995a7277a4fba17b5a Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sun, 15 Oct 2023 19:50:44 +0200 Subject: [PATCH 8/9] style: update `flake8` version --- falcon/inspect.py | 6 +++--- falcon/routing/compiled.py | 2 +- tests/test_request_attrs.py | 2 +- tests/test_request_media.py | 4 ++-- tests/test_validators.py | 2 +- tox.ini | 6 ++---- 6 files changed, 10 insertions(+), 12 deletions(-) diff --git a/falcon/inspect.py b/falcon/inspect.py index d57d451af..62fc74c28 100644 --- a/falcon/inspect.py +++ b/falcon/inspect.py @@ -15,11 +15,11 @@ """Inspect utilities for falcon applications.""" from functools import partial import inspect -from typing import Callable -from typing import Dict +from typing import Callable # NOQA: F401 +from typing import Dict # NOQA: F401 from typing import List from typing import Optional -from typing import Type +from typing import Type # NOQA: F401 from falcon import app_helpers from falcon.app import App diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py index 239467823..0b45edbc1 100644 --- a/falcon/routing/compiled.py +++ b/falcon/routing/compiled.py @@ -29,7 +29,7 @@ from falcon.util.sync import wrap_sync_to_async if TYPE_CHECKING: - from typing import Any + from typing import Any # NOQA: F401 _TAB_STR = ' ' * 4 _FIELD_PATTERN = re.compile( diff --git a/tests/test_request_attrs.py b/tests/test_request_attrs.py index ba0f7e293..466a0c875 100644 --- a/tests/test_request_attrs.py +++ b/tests/test_request_attrs.py @@ -110,7 +110,7 @@ def test_subdomain(self, asgi): # NOTE(kgriffs): Behavior for IP addresses is undefined, # so just make sure it doesn't blow up. req = create_req(asgi, host='127.0.0.1', path='/hello', headers=self.headers) - assert type(req.subdomain) == str + assert type(req.subdomain) is str # NOTE(kgriffs): Test fallback to SERVER_NAME by using # HTTP 1.0, which will cause .create_environ to not set diff --git a/tests/test_request_media.py b/tests/test_request_media.py index 31305b5c4..23654cc27 100644 --- a/tests/test_request_media.py +++ b/tests/test_request_media.py @@ -184,7 +184,7 @@ def test_invalid_json(asgi): try: json.loads(expected_body) except Exception as e: - assert type(client.resource.captured_error.value.__cause__) == type(e) + assert type(client.resource.captured_error.value.__cause__) is type(e) assert str(client.resource.captured_error.value.__cause__) == str(e) @@ -210,7 +210,7 @@ def test_invalid_msgpack(asgi): try: msgpack.unpackb(expected_body.encode('utf-8')) except Exception as e: - assert type(client.resource.captured_error.value.__cause__) == type(e) + assert type(client.resource.captured_error.value.__cause__) is type(e) assert str(client.resource.captured_error.value.__cause__) == str(e) diff --git a/tests/test_validators.py b/tests/test_validators.py index 51450c7e5..a7f5ed273 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,4 +1,4 @@ -import typing +import typing # NOQA: F401 try: import jsonschema as _jsonschema # NOQA diff --git a/tox.ini b/tox.ini index 327298fe5..1715258c5 100644 --- a/tox.ini +++ b/tox.ini @@ -273,8 +273,7 @@ commands = {[smoke-test]commands} # -------------------------------------------------------------------- [testenv:pep8] -# TODO(vytas): Unpin flake8 when the below plugins have caught up. -deps = flake8<6.0 +deps = flake8 flake8-quotes flake8-import-order commands = flake8 [] @@ -298,8 +297,7 @@ commands = flake8 \ [] [testenv:pep8-examples] -# TODO(vytas): Unpin flake8 when the below plugins have caught up. -deps = flake8<6.0 +deps = flake8 flake8-quotes flake8-import-order From 601b4a5b8e85507cf87a482a8bd60cf61f1a1599 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sun, 5 Nov 2023 11:05:09 +0100 Subject: [PATCH 9/9] refactor: privatize local datetime aliases in falcon.util.misc --- docs/conf.py | 2 +- falcon/util/misc.py | 20 +++++++++++++++----- tests/test_headers.py | 4 ++-- tests/test_middleware.py | 8 ++++---- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 3e2a2c05a..772521b69 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -82,7 +82,7 @@ # General information about the project. project = 'Falcon' -copyright = '{year} Falcon Contributors'.format(year=datetime.utcnow().year) +copyright = '{year} Falcon Contributors'.format(year=datetime.now().year) # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/falcon/util/misc.py b/falcon/util/misc.py index fa6f74faf..3690aeca4 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -74,11 +74,21 @@ _UNSAFE_CHARS = re.compile(r'[^a-zA-Z0-9.-]') # PERF(kgriffs): Avoid superfluous namespace lookups -strptime: Callable[[str, str], datetime.datetime] = datetime.datetime.strptime -utcnow: Callable[[], datetime.datetime] = functools.partial( +_strptime: Callable[[str, str], datetime.datetime] = datetime.datetime.strptime +_utcnow: Callable[[], datetime.datetime] = functools.partial( datetime.datetime.now, datetime.timezone.utc ) +# The above aliases were not underscored prior to Falcon 3.1.2. +strptime: Callable[[str, str], datetime.datetime] = deprecated( + 'This was a private alias local to this module; ' + 'please reference datetime.strptime() directly.' +)(datetime.datetime.strptime) +utcnow: Callable[[], datetime.datetime] = deprecated( + 'This was a private alias local to this module; ' + 'please reference datetime.utcnow() directly.' +)(datetime.datetime.utcnow) + # NOTE(kgriffs,vytas): This is tested in the PyPy gate but we do not want devs # to have to install PyPy to check coverage on their workstations, so we use @@ -134,7 +144,7 @@ def http_now() -> str: e.g., 'Tue, 15 Nov 1994 12:45:26 GMT'. """ - return dt_to_http(utcnow()) + return dt_to_http(_utcnow()) def dt_to_http(dt: datetime.datetime) -> str: @@ -178,7 +188,7 @@ def http_date_to_dt(http_date: str, obs_date: bool = False) -> datetime.datetime # over it, and setting up exception handling blocks each # time around the loop, in the case that we don't actually # need to check for multiple formats. - return strptime(http_date, '%a, %d %b %Y %H:%M:%S %Z') + return _strptime(http_date, '%a, %d %b %Y %H:%M:%S %Z') time_formats = ( '%a, %d %b %Y %H:%M:%S %Z', @@ -190,7 +200,7 @@ def http_date_to_dt(http_date: str, obs_date: bool = False) -> datetime.datetime # Loop through the formats and return the first that matches for time_format in time_formats: try: - return strptime(http_date, time_format) + return _strptime(http_date, time_format) except ValueError: continue diff --git a/tests/test_headers.py b/tests/test_headers.py index 5a2997b43..bf9d47536 100644 --- a/tests/test_headers.py +++ b/tests/test_headers.py @@ -6,7 +6,7 @@ import falcon from falcon import testing from falcon.util.deprecation import DeprecatedWarning -from falcon.util.misc import utcnow +from falcon.util.misc import _utcnow from _util import create_app # NOQA @@ -33,7 +33,7 @@ def __init__(self, last_modified=None): if last_modified is not None: self.last_modified = last_modified else: - self.last_modified = utcnow() + self.last_modified = _utcnow() def _overwrite_headers(self, req, resp): resp.content_type = 'x-falcon/peregrine' diff --git a/tests/test_middleware.py b/tests/test_middleware.py index bfafc6637..c4aecd3e6 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -9,7 +9,7 @@ import falcon import falcon.errors import falcon.testing as testing -from falcon.util.misc import utcnow +from falcon.util.misc import _utcnow from _util import create_app # NOQA @@ -36,15 +36,15 @@ def process_request(self, req, resp): class RequestTimeMiddleware: def process_request(self, req, resp): global context - context['start_time'] = utcnow() + context['start_time'] = _utcnow() def process_resource(self, req, resp, resource, params): global context - context['mid_time'] = utcnow() + context['mid_time'] = _utcnow() def process_response(self, req, resp, resource, req_succeeded): global context - context['end_time'] = utcnow() + context['end_time'] = _utcnow() context['req_succeeded'] = req_succeeded async def process_request_async(self, req, resp):