From 5a5e5d2de2297a4c95b61ec219cb7b5f62e802b0 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Sun, 5 Nov 2023 19:42:40 +0100 Subject: [PATCH] chore: support CPython 3.12, update CI jobs (#2177) * chore: add basic CPython 3.12 definitions and CI jobs * chore(mailman): temporarily disabling failing GNU Mailman integr tests * chore(tox): add `setuptools` & `wheel` manually when using `--no-build-isolation` * test(test_ws.py): fix one async race potentially yielding unexpected result * test(test_ws.py): give some tests enough coroutine cycles under CPython 3.12 optimizations * chore(py312): address or ignore new deprecations * style: remove an unused import * style: update `flake8` version * refactor: privatize local datetime aliases in falcon.util.misc --- .coveragerc | 2 ++ .github/workflows/create-wheels.yaml | 5 ++++- .github/workflows/tests-mailman.yaml | 9 ++++++--- .github/workflows/tests.yaml | 6 ++++++ docs/conf.py | 2 +- falcon/inspect.py | 6 +++--- falcon/routing/compiled.py | 5 +++-- falcon/util/misc.py | 23 ++++++++++++++++++----- pyproject.toml | 17 +++++++++++++++++ setup.cfg | 12 +----------- tests/asgi/test_ws.py | 13 +++++++++++++ tests/test_cookies.py | 12 ++++++++---- tests/test_headers.py | 4 +++- tests/test_middleware.py | 8 ++++---- tests/test_request_attrs.py | 2 +- tests/test_request_media.py | 4 ++-- tests/test_utils.py | 9 ++++----- tests/test_validators.py | 2 +- tox.ini | 14 +++++++++++++- 19 files changed, 110 insertions(+), 45 deletions(-) diff --git a/.coveragerc b/.coveragerc index 32235c156..c6b05ca9d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -8,6 +8,8 @@ parallel = True [report] show_missing = True exclude_lines = + if TYPE_CHECKING: + if not TYPE_CHECKING: pragma: nocover pragma: no cover pragma: no py39,py310 cover 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-mailman.yaml b/.github/workflows/tests-mailman.yaml index 415fafcf8..61f0f85a2 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: diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b415c0e7f..52ca2414c 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -75,6 +75,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/docs/conf.py b/docs/conf.py index d0f1afeb2..6c051c805 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -84,7 +84,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/inspect.py b/falcon/inspect.py index 46bd9ed88..c83eddf7e 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 ceb97e3e7..69290bda5 100644 --- a/falcon/routing/compiled.py +++ b/falcon/routing/compiled.py @@ -19,6 +19,7 @@ import keyword import re from threading import Lock +from typing import TYPE_CHECKING from falcon.routing import converters from falcon.routing.util import map_http_methods @@ -27,8 +28,8 @@ from falcon.util.sync import _should_wrap_non_coroutines from falcon.util.sync import wrap_sync_to_async -if False: # TODO: switch to TYPE_CHECKING once support for py3.5 is dropped - from typing import Any +if TYPE_CHECKING: + from typing import Any # NOQA: F401 _TAB_STR = ' ' * 4 _FIELD_PATTERN = re.compile( diff --git a/falcon/util/misc.py b/falcon/util/misc.py index b7c7746b1..666019a41 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -28,6 +28,7 @@ import http import inspect import re +from typing import Callable import unicodedata from falcon import status_codes @@ -70,8 +71,20 @@ _UNSAFE_CHARS = re.compile(r'[^a-zA-Z0-9.-]') # PERF(kgriffs): Avoid superfluous namespace lookups -strptime = datetime.datetime.strptime -utcnow = datetime.datetime.utcnow +_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): This is tested in the gate but we do not want devs to @@ -135,7 +148,7 @@ def http_now(): 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): @@ -179,7 +192,7 @@ def http_date_to_dt(http_date, obs_date=False): # 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', @@ -191,7 +204,7 @@ def http_date_to_dt(http_date, obs_date=False): # 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/pyproject.toml b/pyproject.toml index eac1f45d1..5b3a42446 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,3 +51,20 @@ target-version = ["py35"] 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", + "ignore:Unknown REQUEST_METHOD. '(FOO|BAR|BREW|SETECASTRONOMY)':wsgiref.validate.WSGIWarning", + "ignore:\"@coroutine\" decorator is deprecated:DeprecationWarning", + "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", + "ignore:There is no current event loop", +] +testpaths = [ + "tests" +] diff --git a/setup.cfg b/setup.cfg index 70edb7f99..d98a8ec39 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,6 +31,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 @@ -80,17 +81,6 @@ tag_build = b1 [aliases] test=pytest -[tool:pytest] -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 - ignore:Unknown REQUEST_METHOD. '(FOO|BAR|BREW|SETECASTRONOMY)':wsgiref.validate.WSGIWarning - ignore:"@coroutine" decorator is deprecated:DeprecationWarning - 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 - [flake8] max-complexity = 15 exclude = .ecosystem,.eggs,.git,.tox,.venv,build,dist,docs,examples,falcon/bench/nuts diff --git a/tests/asgi/test_ws.py b/tests/asgi/test_ws.py index 7ada6cb24..20bc707d6 100644 --- a/tests/asgi/test_ws.py +++ b/tests/asgi/test_ws.py @@ -371,6 +371,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) @@ -1112,6 +1119,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: @@ -1209,6 +1219,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: diff --git a/tests/test_cookies.py b/tests/test_cookies.py index 0312d5aca..d88ae2269 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='/') @@ -154,7 +158,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 @@ -177,7 +181,7 @@ def test(cookie, path, domain): assert cookie.domain == domain assert cookie.path == path assert cookie.same_site == 'Lax' - 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) @@ -262,7 +266,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 014c231c1..a52bda3cb 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..c4aecd3e6 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,4 +1,3 @@ -from datetime import datetime import json try: @@ -10,6 +9,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 +36,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_request_attrs.py b/tests/test_request_attrs.py index 42123fbaf..a7f23e276 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_utils.py b/tests/test_utils.py index 13d0a6cb1..cd95e5e97 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 ( 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 286c89fd5..df064befd 100644 --- a/tox.ini +++ b/tox.ini @@ -161,6 +161,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= @@ -219,6 +224,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) # -------------------------------------------------------------------- @@ -264,7 +276,7 @@ commands = {[smoke-test]commands} # -------------------------------------------------------------------- [testenv:pep8] -deps = flake8>=3.7.0 +deps = flake8 flake8-quotes flake8-import-order commands = flake8 []