From 1bc0b1f175c7c4dfd465bf2137e6e29520b60d6d Mon Sep 17 00:00:00 2001 From: Ben Darnell Date: Fri, 7 Aug 2026 10:33:43 -0400 Subject: [PATCH 1/3] Drop support for python 3.10, add 3.15. 3.10 will be nearly EOL by the time Tornado 6.6 is released. 3.15 support was previously added partially, without using the cog script. --- .github/workflows/test.yml | 23 ++++++++++------------- maint/scripts/runcog.sh | 8 ++++---- pyproject.toml | 4 ++-- setup.cfg | 2 +- setup.py | 8 ++++---- tox.ini | 4 ++-- 6 files changed, 23 insertions(+), 26 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0a0bba874..c6caa1ad4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,25 +57,22 @@ jobs: # if minor == int(dev_python_minor): # # The range trick above doesn't work for threaded builds, so we'll # # just be stuck with last prerelease until we update dev_python_minor. - # configs.append((f"3.{minor}t-dev", f"py3{minor}")) + # configs.append((f"3.{minor}t-dev", f"py3{minor}t")) # else: - # configs.append((f"3.{minor}t", f"py3{minor}")) - # # Early versions of 3.10 and 3.11 had different deprecation + # configs.append((f"3.{minor}t", f"py3{minor}t")) + # # Early versions of 3.11 had different deprecation # # warnings in asyncio. Test with them too to make sure everything # # works the same way. - # configs.append(("3.10.8", "py310-full")) # configs.append(("3.11.0", "py311-full")) # # Pypy is a lot slower due to jit warmup costs, so don't run the # # "full" test config there. - # configs.append(("pypy-3.10", "pypy3")) + # configs.append(("pypy-3.11", "pypy3")) # # Docs python version must be synced with tox.ini # configs.append((f"3.{default_python_minor}", "docs")) # for version, tox_env in configs: # cog.outl(f" - python: '{version}'") # cog.outl(f" tox_env: {tox_env}") # ]]] - - python: '3.10' - tox_env: py310-full - python: '3.11' tox_env: py311-full - python: '3.12' @@ -83,16 +80,16 @@ jobs: - python: '3.13' tox_env: py313-full - python: '3.14' - tox_env: py314 - - python: '3.14t-dev' + tox_env: py314-full + - python: '3.14t' tox_env: py314t - - python: '3.15-dev' + - python: '3.15.0-dev - 3.15' tox_env: py315 - - python: '3.10.8' - tox_env: py310-full + - python: '3.15t-dev' + tox_env: py315t - python: '3.11.0' tox_env: py311-full - - python: 'pypy-3.10' + - python: 'pypy-3.11' tox_env: pypy3 - python: '3.13' tox_env: docs diff --git a/maint/scripts/runcog.sh b/maint/scripts/runcog.sh index 0314bfb44..9ebee92c5 100755 --- a/maint/scripts/runcog.sh +++ b/maint/scripts/runcog.sh @@ -10,10 +10,10 @@ # dev_python_minor is the version of Python that is currently under development # and is used to install pre-release versions of Python in CI. uvx --from cogapp cog \ - -D min_python_minor=10 \ - -D max_python_minor=14 \ + -D min_python_minor=11 \ + -D max_python_minor=15 \ -D min_python_threaded_minor=14 \ - -D max_python_threaded_minor=14 \ + -D max_python_threaded_minor=15 \ -D default_python_minor=13 \ - -D dev_python_minor=14 \ + -D dev_python_minor=15 \ -r $(git grep -l '\[\[\[cog') diff --git a/pyproject.toml b/pyproject.toml index d4cfb5b67..8a3566f5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" # [[[cog # cog.outl(f"target-version = ['py3{min_python_minor}']") # ]]] -target-version = ['py310'] +target-version = ['py311'] #[[[end]]] [tool.cibuildwheel] @@ -15,7 +15,7 @@ target-version = ['py310'] # versions += ["cp314t*"] # cog.outl(f"build = \"{" ".join(versions)}\"") # ]]] -build = "cp310* cp311* cp312* cp313* cp314* cp314t*" +build = "cp311* cp312* cp313* cp314* cp315* cp314t*" #[[[end]]] test-command = "python -m tornado.test" diff --git a/setup.cfg b/setup.cfg index 957b526bb..d5dda829e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,7 @@ license_file = LICENSE [mypy] # [[[cog cog.outl(f"python_version = 3.{min_python_minor}")]]] -python_version = 3.10 +python_version = 3.11 # [[[end]]] no_implicit_optional = True diff --git a/setup.py b/setup.py index a4dbb9657..e30c579d3 100644 --- a/setup.py +++ b/setup.py @@ -46,8 +46,8 @@ # cog.outl(")]") # cog.outl(f"bdist_wheel_options = {{\"py_limited_api\": \"cp3{min_python_minor}\"}}") # ]]] - define_macros = [("Py_LIMITED_API", "0x030a0000")] - bdist_wheel_options = {"py_limited_api": "cp310"} + define_macros = [("Py_LIMITED_API", "0x030b0000")] + bdist_wheel_options = {"py_limited_api": "cp311"} # [[[end]]] else: define_macros = [] @@ -77,7 +77,7 @@ name="tornado", version=version, # [[[cog cog.outl(f"python_requires=\">= 3.{min_python_minor}\",")]]] - python_requires=">= 3.10", + python_requires=">= 3.11", # [[[end]]] packages=["tornado", "tornado.test", "tornado.platform"], package_data={ @@ -122,11 +122,11 @@ # for minor in range(int(min_python_minor), int(max_python_minor) + 1): # cog.outl(f"\"Programming Language :: Python :: 3.{minor}\",") # ]]] - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", # [[[end]]] "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", diff --git a/tox.ini b/tox.ini index db0a4b604..b4c2884e1 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,7 @@ envlist = # versions += ["pypy3"] # cog.outl(','.join(versions)) # ]]] - py310,py311,py312,py313,py314,pypy3 + py311,py312,py313,py314,py315,pypy3 # [[[end]]] # Build and test the docs with sphinx. @@ -60,7 +60,7 @@ setenv = # versions.insert(0, "py3") # cog.outl(f"{{{",".join(versions)}}}: TORNADO_EXTENSION=1") # ]]] - {py3,py310,py311,py312,py313,py314}: TORNADO_EXTENSION=1 + {py3,py311,py312,py313,py314,py315}: TORNADO_EXTENSION=1 # [[[end]]] # CI workers are often overloaded and can cause our tests to exceed # the default timeout of 5s. From 0f298efccf4e37ca0aad5a736a8646c6349f77da Mon Sep 17 00:00:00 2001 From: Ben Darnell Date: Fri, 7 Aug 2026 11:14:00 -0400 Subject: [PATCH 2/3] ci: Add linter to detect cog divergence --- .github/workflows/test.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c6caa1ad4..9a4a10b22 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -172,3 +172,20 @@ jobs: # [[[cog cog.outl(f"CIBW_BUILD: cp3{default_python_minor}-manylinux*")]]] CIBW_BUILD: cp313-manylinux* # [[[end]]] + + test_cog: + # Run cog and ensure that nothing changes, to make sure that cog-generated files are not + # edited manually. + name: Test cog generation + runs-on: ubuntu-24.04 + needs: test_quick + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@v7 + name: Install uv + - name: Run cog + run: maint/scripts/runcog.sh + - name: Check for changes + run: git diff --exit-code From 47e09ae9ae036d1c64394086fb55cbf6ce02a6a4 Mon Sep 17 00:00:00 2001 From: Ben Darnell Date: Fri, 7 Aug 2026 11:26:32 -0400 Subject: [PATCH 3/3] pyupgrade: Automated use of new py311 features. Only one seems to be the new datetime.UTC alias (and a change to subprocess.run in a test, but this is because the test had changed since the last time we ran pyupgrade) --- tornado/locale.py | 6 +++--- tornado/test/httpclient_test.py | 2 +- tornado/test/httputil_test.py | 10 ++++------ tornado/test/locale_test.py | 2 +- tornado/test/process_test.py | 3 +-- tornado/test/web_test.py | 12 ++++-------- tornado/test/websocket_test.py | 6 ++---- tornado/web.py | 12 +++++------- 8 files changed, 21 insertions(+), 32 deletions(-) diff --git a/tornado/locale.py b/tornado/locale.py index c09eebc15..8d4613f78 100644 --- a/tornado/locale.py +++ b/tornado/locale.py @@ -350,10 +350,10 @@ def format_date( datetimes are still assumed to be UTC). """ if isinstance(date, (int, float)): - date = datetime.datetime.fromtimestamp(date, datetime.timezone.utc) + date = datetime.datetime.fromtimestamp(date, datetime.UTC) if date.tzinfo is None: - date = date.replace(tzinfo=datetime.timezone.utc) - now = datetime.datetime.now(datetime.timezone.utc) + date = date.replace(tzinfo=datetime.UTC) + now = datetime.datetime.now(datetime.UTC) if date > now: if relative and (date - now).seconds < 60: # Due to click skew, things are some things slightly diff --git a/tornado/test/httpclient_test.py b/tornado/test/httpclient_test.py index 1540ac3c2..7eaf66eb9 100644 --- a/tornado/test/httpclient_test.py +++ b/tornado/test/httpclient_test.py @@ -988,7 +988,7 @@ def test_body_setter(self): self.assertEqual(request.body, utf8("foo")) def test_if_modified_since(self): - http_date = datetime.datetime.now(datetime.timezone.utc) + http_date = datetime.datetime.now(datetime.UTC) request = HTTPRequest("http://example.com", if_modified_since=http_date) self.assertEqual( request.headers, {"If-Modified-Since": format_timestamp(http_date)} diff --git a/tornado/test/httputil_test.py b/tornado/test/httputil_test.py index 619527d72..896db88f2 100644 --- a/tornado/test/httputil_test.py +++ b/tornado/test/httputil_test.py @@ -581,9 +581,9 @@ def test_time_tuple(self): def test_utc_naive_datetime(self): self.check( - datetime.datetime.fromtimestamp( - self.TIMESTAMP, datetime.timezone.utc - ).replace(tzinfo=None) + datetime.datetime.fromtimestamp(self.TIMESTAMP, datetime.UTC).replace( + tzinfo=None + ) ) def test_utc_naive_datetime_deprecated(self): @@ -591,9 +591,7 @@ def test_utc_naive_datetime_deprecated(self): self.check(datetime.datetime.utcfromtimestamp(self.TIMESTAMP)) def test_utc_aware_datetime(self): - self.check( - datetime.datetime.fromtimestamp(self.TIMESTAMP, datetime.timezone.utc) - ) + self.check(datetime.datetime.fromtimestamp(self.TIMESTAMP, datetime.UTC)) def test_other_aware_datetime(self): # Other timezones are ignored; the timezone is always printed as GMT diff --git a/tornado/test/locale_test.py b/tornado/test/locale_test.py index b9a4e1e79..3ca5ed4a6 100644 --- a/tornado/test/locale_test.py +++ b/tornado/test/locale_test.py @@ -91,7 +91,7 @@ def test_format_date(self): locale.format_date(date, full_format=True), "April 28, 2013 at 6:35 pm" ) - aware_dt = datetime.datetime.now(datetime.timezone.utc) + aware_dt = datetime.datetime.now(datetime.UTC) naive_dt = aware_dt.replace(tzinfo=None) for name, now in {"aware": aware_dt, "naive": naive_dt}.items(): with self.subTest(dt=name): diff --git a/tornado/test/process_test.py b/tornado/test/process_test.py index 775f99a3f..0d638bdac 100644 --- a/tornado/test/process_test.py +++ b/tornado/test/process_test.py @@ -147,8 +147,7 @@ def test_multi_process(self): result = subprocess.run( [sys.executable, "-c", _MULTI_PROCESS_TEST_SCRIPT], env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + capture_output=True, timeout=30, ) if result.returncode != 0: diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py index c00761c02..e004cbb68 100644 --- a/tornado/test/web_test.py +++ b/tornado/test/web_test.py @@ -486,9 +486,7 @@ def test_set_cookie_expires_days(self): self.assertIsNotNone(match) assert match is not None # for mypy - expires = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( - days=10 - ) + expires = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=10) header_expires = email.utils.parsedate_to_datetime(match.groupdict()["expires"]) self.assertLess(abs((expires - header_expires).total_seconds()), 10) @@ -1892,7 +1890,7 @@ def test_date_header(self): response = self.fetch("/") header_date = email.utils.parsedate_to_datetime(response.headers["Date"]) self.assertLess( - header_date - datetime.datetime.now(datetime.timezone.utc), + header_date - datetime.datetime.now(datetime.UTC), datetime.timedelta(seconds=2), ) @@ -3198,12 +3196,10 @@ def test_xsrf_httponly(self): match = re.match(".*; expires=(?P.+);.*", header) assert match is not None - expires = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( - days=2 - ) + expires = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=2) header_expires = email.utils.parsedate_to_datetime(match.groupdict()["expires"]) if header_expires.tzinfo is None: - header_expires = header_expires.replace(tzinfo=datetime.timezone.utc) + header_expires = header_expires.replace(tzinfo=datetime.UTC) self.assertTrue(abs((expires - header_expires).total_seconds()) < 10) diff --git a/tornado/test/websocket_test.py b/tornado/test/websocket_test.py index fc45688c9..7adbdd3fe 100644 --- a/tornado/test/websocket_test.py +++ b/tornado/test/websocket_test.py @@ -934,11 +934,9 @@ class PingCalculationTest(unittest.TestCase): def test_ping_sleep_time(self): from tornado.websocket import WebSocketProtocol13 - now = datetime.datetime(2025, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) + now = datetime.datetime(2025, 1, 1, 12, 0, 0, tzinfo=datetime.UTC) interval = 10 # seconds - last_ping_time = datetime.datetime( - 2025, 1, 1, 11, 59, 54, tzinfo=datetime.timezone.utc - ) + last_ping_time = datetime.datetime(2025, 1, 1, 11, 59, 54, tzinfo=datetime.UTC) sleep_time = WebSocketProtocol13.ping_sleep_time( last_ping_time=last_ping_time.timestamp(), interval=interval, diff --git a/tornado/web.py b/tornado/web.py index 9656148ab..ad32165b1 100644 --- a/tornado/web.py +++ b/tornado/web.py @@ -715,7 +715,7 @@ def set_cookie( if domain: morsel["domain"] = domain if expires_days is not None and not expires: - expires = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( + expires = datetime.datetime.now(datetime.UTC) + datetime.timedelta( days=expires_days ) if expires: @@ -768,9 +768,7 @@ def clear_cookie(self, name: str, **kwargs: Any) -> None: raise TypeError( f"clear_cookie() got an unexpected keyword argument '{excluded_arg}'" ) - expires = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( - days=365 - ) + expires = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=365) self.set_cookie(name, value="", expires=expires, **kwargs) def clear_all_cookies(self, **kwargs: Any) -> None: @@ -2889,7 +2887,7 @@ def set_headers(self) -> None: if cache_time > 0: self.set_header( "Expires", - datetime.datetime.now(datetime.timezone.utc) + datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=cache_time), ) self.set_header("Cache-Control", "max-age=" + str(cache_time)) @@ -2914,7 +2912,7 @@ def should_return_304(self) -> bool: except Exception: return False if if_since.tzinfo is None: - if_since = if_since.replace(tzinfo=datetime.timezone.utc) + if_since = if_since.replace(tzinfo=datetime.UTC) assert self.modified is not None if if_since >= self.modified: return True @@ -3096,7 +3094,7 @@ def get_modified_time(self) -> datetime.datetime | None: # that relies on this), we truncate the float here, although # I'm not sure that's the right thing to do. modified = datetime.datetime.fromtimestamp( - int(stat_result.st_mtime), datetime.timezone.utc + int(stat_result.st_mtime), datetime.UTC ) return modified