Skip to content

Commit

Permalink
chore: support CPython 3.12, update CI jobs (#2177)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
vytas7 committed Nov 16, 2023
1 parent f9bbcad commit 5a5e5d2
Show file tree
Hide file tree
Showing 19 changed files with 110 additions and 45 deletions.
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion .github/workflows/create-wheels.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ jobs:
- "3.8"
- "3.9"
- "3.10"
- "3.11.0-rc - 3.11"
- "3.11"
- "3.12"
architecture:
- x64

Expand Down Expand Up @@ -99,6 +100,7 @@ jobs:
- cp39-cp39
- cp310-cp310
- cp311-cp311
- cp312-cp312
architecture:
- x64

Expand Down Expand Up @@ -230,6 +232,7 @@ jobs:
- cp39-cp39
- cp310-cp310
- cp311-cp311
- cp312-cp312
architecture:
- aarch64
- s390x
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/tests-mailman.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions falcon/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions falcon/routing/compiled.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
23 changes: 18 additions & 5 deletions falcon/util/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import http
import inspect
import re
from typing import Callable
import unicodedata

from falcon import status_codes
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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',
Expand All @@ -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

Expand Down
17 changes: 17 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
12 changes: 1 addition & 11 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions tests/asgi/test_ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 8 additions & 4 deletions tests/test_cookies.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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='/')
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
4 changes: 3 additions & 1 deletion tests/test_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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'
Expand Down
8 changes: 4 additions & 4 deletions tests/test_middleware.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from datetime import datetime
import json

try:
Expand All @@ -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

Expand All @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion tests/test_request_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/test_request_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand All @@ -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)


Expand Down
9 changes: 4 additions & 5 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-

from datetime import datetime
from datetime import datetime, timezone
import functools
import http
import itertools
Expand Down Expand Up @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion tests/test_validators.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import typing
import typing # NOQA: F401

try:
import jsonschema as _jsonschema # NOQA
Expand Down
Loading

0 comments on commit 5a5e5d2

Please sign in to comment.