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 authored Nov 5, 2023
1 parent 18934a1 commit 79fbbb5
Show file tree
Hide file tree
Showing 18 changed files with 92 additions and 37 deletions.
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 @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
2 changes: 1 addition & 1 deletion falcon/routing/compiled.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
22 changes: 17 additions & 5 deletions falcon/util/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,20 @@
_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] = 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,vytas): This is tested in the PyPy gate but we do not want devs
Expand Down Expand Up @@ -132,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:
Expand Down Expand Up @@ -176,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',
Expand All @@ -188,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

Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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)

Expand Down Expand Up @@ -1111,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:
Expand Down Expand Up @@ -1208,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:
Expand Down
14 changes: 9 additions & 5 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 @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
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
18 changes: 14 additions & 4 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -216,6 +221,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)
# --------------------------------------------------------------------
Expand Down Expand Up @@ -261,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 []
Expand All @@ -286,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

Expand Down

0 comments on commit 79fbbb5

Please sign in to comment.