Skip to content

Commit ed4af96

Browse files
authored
🐛 fix(pyproject-fmt): keep the classifiers a bound admits (#463)
1 parent 2946fc3 commit ed4af96

10 files changed

Lines changed: 251 additions & 8 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: 🐍 python versions
2+
on:
3+
workflow_dispatch:
4+
schedule:
5+
- cron: "0 8 * * *"
6+
permissions: {}
7+
jobs:
8+
bump:
9+
name: ⬆️ bump
10+
runs-on: ubuntu-24.04
11+
environment: update-deps
12+
permissions:
13+
contents: write
14+
pull-requests: write
15+
steps:
16+
- name: 📥 Checkout
17+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
18+
with:
19+
persist-credentials: false
20+
- name: 🐍 Setup uv
21+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
22+
- name: ⬆️ Set the releases the defaults name
23+
run: uv run tasks/python_versions.py
24+
- name: 🎨 Run prek
25+
run: uvx prek run --all-files || true
26+
- name: 🔀 Create Pull Request
27+
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
28+
with:
29+
token: ${{ secrets.RELEASE_TOKEN }}
30+
commit-message: "Set the Python releases the defaults name"
31+
title: "Set the Python releases the defaults name"
32+
body: |
33+
The newest release the defaults name has to satisfy two things at once: PyPI publishes
34+
a classifier for it, and Python has published a release candidate. A classifier appears
35+
while a release is still changing, and a candidate PyPI has no classifier for is one no
36+
upload may carry, so neither alone is enough.
37+
38+
The oldest comes from the release cycles still carrying fixes.
39+
40+
Each run reads what is true that day rather than any date published ahead of it, so a
41+
release that slips arrives when it arrives. Anyone naming `max_supported_python`
42+
themselves keeps what they set.
43+
branch: update-python-versions
44+
delete-branch: true

pyproject-fmt/README.rst

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Put per-file settings in ``[tool.pyproject-fmt]``:
5858
indent = 2
5959
keep_full_version = false
6060
generate_python_version_classifiers = true
61-
max_supported_python = "3.14"
61+
max_supported_python = "3.15"
6262
table_format = "short"
6363
sub_table_spacing = ""
6464
separate_root_table = "\n"
@@ -84,7 +84,7 @@ A standalone ``pyproject-fmt.toml`` can hold settings for several projects. The
8484
column_width = 120
8585
indent = 2
8686
table_format = "short"
87-
max_supported_python = "3.14"
87+
max_supported_python = "3.15"
8888
8989
For each input, the formatter searches from the input's directory toward the filesystem root and uses the nearest
9090
``pyproject-fmt.toml``. ``--config`` selects a file directly:
@@ -622,7 +622,7 @@ at whitespace; without it, installers read the ``;`` and the marker as part of t
622622
[project]
623623
optional-dependencies.dev-tools = [ "pytest" ]
624624
625-
**Python version classifiers** derive from ``requires-python`` and ``max_supported_python`` (here ``3.14``).
625+
**Python version classifiers** derive from ``requires-python`` and ``max_supported_python`` (here ``3.15``).
626626
Disable generation with ``generate_python_version_classifiers = false``:
627627

628628
.. code-block:: toml
@@ -641,6 +641,7 @@ Disable generation with ``generate_python_version_classifiers = false``:
641641
"Programming Language :: Python :: 3.12",
642642
"Programming Language :: Python :: 3.13",
643643
"Programming Language :: Python :: 3.14",
644+
"Programming Language :: Python :: 3.15",
644645
]
645646
646647
**Entry points:** inline tables within ``entry-points`` expand to dotted keys:

pyproject-fmt/docs/configuration.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Put per-file settings in ``[tool.pyproject-fmt]``:
1313
indent = 2
1414
keep_full_version = false
1515
generate_python_version_classifiers = true
16-
max_supported_python = "3.14"
16+
max_supported_python = "3.15"
1717
table_format = "short"
1818
sub_table_spacing = ""
1919
separate_root_table = "\n"
@@ -39,7 +39,7 @@ A standalone ``pyproject-fmt.toml`` can hold settings for several projects. The
3939
column_width = 120
4040
indent = 2
4141
table_format = "short"
42-
max_supported_python = "3.14"
42+
max_supported_python = "3.15"
4343
4444
For each input, the formatter searches from the input's directory toward the filesystem root and uses the nearest
4545
``pyproject-fmt.toml``. ``--config`` selects a file directly:

pyproject-fmt/docs/formatting.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ keywords, and validates the version.
362362
[project.optional-dependencies]
363363
Dev_Tools = ["pytest"]
364364

365-
**Python version classifiers** derive from ``requires-python`` and ``max_supported_python`` (here ``3.14``).
365+
**Python version classifiers** derive from ``requires-python`` and ``max_supported_python`` (here ``3.15``).
366366
Disable generation with ``generate_python_version_classifiers = false``:
367367

368368
.. fmt-example::

pyproject-fmt/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ test = [
5252
"pytest-cov>=7.1",
5353
"pytest-mock>=3.15.1",
5454
"tomli>=2.4.1; python_version<'3.11'",
55+
"trove-classifiers>=2026.6.1.19",
5556
]
5657
type = [
5758
"mypy==2.3.1",

pyproject-fmt/src/pyproject_fmt/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def add_format_flags(parser: ArgumentGroup) -> None:
5959
"--max-supported-python",
6060
metavar="major.minor",
6161
type=_version_argument,
62-
default=(3, 14),
62+
default=(3, 15),
6363
help="latest Python version the project supports (e.g. 3.14)",
6464
)
6565

pyproject-fmt/tests/test_main.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import tomli as tomllib
1111

1212
import pytest
13+
from trove_classifiers import classifiers
1314

1415
from pyproject_fmt import build_parser, run
1516

@@ -18,6 +19,32 @@ def test_build_parser_uses_program_name() -> None:
1819
assert build_parser().prog == "pyproject-fmt"
1920

2021

22+
def test_the_default_max_supported_python_names_a_published_classifier() -> None:
23+
"""PyPI turns away an upload naming a classifier it does not publish, so the default writes none."""
24+
major, minor = build_parser().get_default("max_supported_python")
25+
26+
assert f"Programming Language :: Python :: {major}.{minor}" in classifiers
27+
28+
29+
def test_the_default_max_supported_python_trails_the_newest_classifier_by_one() -> None:
30+
"""
31+
A classifier is published while its release is still in beta, so the newest one runs a release
32+
ahead of the newest that ships. Two ahead says a release has shipped that the default leaves out,
33+
which is what asks for the bump.
34+
"""
35+
major, minor = build_parser().get_default("max_supported_python")
36+
published = max(
37+
int(held)
38+
for name in classifiers
39+
if (held := name.removeprefix(f"Programming Language :: Python :: {major}.")).isdigit()
40+
and name.startswith(f"Programming Language :: Python :: {major}.")
41+
)
42+
43+
assert published - minor <= 1, (
44+
f"Python {major}.{published} has a classifier, so the default may name {major}.{published - 1}"
45+
)
46+
47+
2148
@pytest.mark.parametrize(
2249
"in_place",
2350
[

tasks/fmt_examples.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"column_width": 120,
1717
"indent": 2,
1818
"keep_full_version": False,
19-
"max_supported_python": (3, 14),
19+
"max_supported_python": (3, 15),
2020
"min_supported_python": (3, 10),
2121
"generate_python_version_classifiers": True,
2222
"table_format": "short",

tasks/python_versions.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# /// script
2+
# requires-python = ">=3.12"
3+
# dependencies = ["trove-classifiers>=2026.6.1.19"]
4+
# ///
5+
"""
6+
The Python releases the formatter's defaults stand for.
7+
8+
The two ends answer different questions and are read from different places. The newest release a
9+
project may be given a classifier for has to satisfy two things at once: PyPI publishes a classifier
10+
naming it, and it has reached its release candidate. Either alone says too little, since a
11+
classifier appears while a release is still changing and a candidate PyPI has no classifier for
12+
cannot be written down. The oldest is the one still carrying fixes, which a classifier outlives by
13+
years and so cannot say.
14+
15+
Nothing here is predicted. Each run reads what is true that day and writes down the numbers, so a
16+
release that slips arrives when it arrives. Someone naming `max_supported_python` themselves is
17+
saying what their own project supports, which no rule here overrides.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import json
23+
import re
24+
import sys
25+
import urllib.request
26+
from dataclasses import dataclass
27+
from datetime import UTC, datetime
28+
from pathlib import Path
29+
from typing import Final
30+
31+
from trove_classifiers import classifiers
32+
33+
_ROOT: Final[Path] = Path(__file__).resolve().parents[1]
34+
_MAJOR: Final[int] = 3
35+
36+
37+
def main(*, check: bool) -> None:
38+
places = written_places(newest_candidate(), oldest_supported())
39+
drifted = [(place, said) for place in places if (said := place.said()) != place.spelling]
40+
if not drifted:
41+
print(f"the defaults name {_MAJOR}.{places[-1].minor} through {_MAJOR}.{places[0].minor}")
42+
return
43+
for place, said in drifted:
44+
print(f"{place.path.relative_to(_ROOT)}: {said} -> {place.spelling}")
45+
if check:
46+
sys.exit(1)
47+
for place, said in drifted:
48+
place.write(said)
49+
50+
51+
def newest_candidate() -> int:
52+
"""
53+
The newest release both PyPI and Python itself have got to: a classifier naming it, and a
54+
release candidate published.
55+
56+
A release still in alpha or beta is one whose own behavior is still moving, so a project saying
57+
it runs there says more than it knows. A candidate PyPI publishes no classifier for is one no
58+
project can name, since an upload carrying an unknown classifier is turned away.
59+
"""
60+
return max(with_a_candidate() & with_a_classifier())
61+
62+
63+
def with_a_candidate() -> set[int]:
64+
"""The releases Python has published a candidate for."""
65+
released = fetch("https://www.python.org/api/v2/downloads/release/?is_published=true")
66+
named = re.compile(rf"^Python {_MAJOR}\.(\d+)\.\d+rc\d+$")
67+
return {int(found[1]) for release in released if (found := named.match(release["name"]))}
68+
69+
70+
def with_a_classifier() -> set[int]:
71+
"""The releases PyPI publishes a classifier for."""
72+
prefix = f"Programming Language :: Python :: {_MAJOR}."
73+
return {
74+
int(minor) for name in classifiers if name.startswith(prefix) and (minor := name.removeprefix(prefix)).isdigit()
75+
}
76+
77+
78+
def oldest_supported() -> int:
79+
"""The oldest release still carrying fixes, which is the oldest the formatter writes for."""
80+
today = datetime.now(tz=UTC).date().isoformat()
81+
cycles = fetch("https://endoflife.date/api/python.json")
82+
live = (row for row in cycles if isinstance(row["eol"], str) and row["eol"] > today)
83+
return min(int(row["cycle"].split(".")[1]) for row in live)
84+
85+
86+
def fetch(url: str) -> list[dict[str, str]]:
87+
with urllib.request.urlopen(url, timeout=30) as response: # ruff: ignore[suspicious-url-open-usage] # a named host
88+
return json.load(response)
89+
90+
91+
def written_places(newest: int, oldest: int) -> list[Place]:
92+
"""Every file that spells either release, the newest ones first."""
93+
spelled = [
94+
("pyproject-fmt/src/pyproject_fmt/__main__.py", r"default=\(\d+, \d+\)", f"default=({_MAJOR}, {newest})"),
95+
(
96+
"tasks/fmt_examples.py",
97+
r'"max_supported_python": \(\d+, \d+\)',
98+
f'"max_supported_python": ({_MAJOR}, {newest})',
99+
),
100+
(
101+
"pyproject-fmt/pyproject.toml",
102+
r'max_supported_python = "\d+\.\d+"',
103+
f'max_supported_python = "{_MAJOR}.{newest}"',
104+
),
105+
(
106+
"tox-toml-fmt/pyproject.toml",
107+
r'max_supported_python = "\d+\.\d+"',
108+
f'max_supported_python = "{_MAJOR}.{newest}"',
109+
),
110+
(
111+
"pyproject-fmt/docs/configuration.rst",
112+
r'max_supported_python = "\d+\.\d+"',
113+
f'max_supported_python = "{_MAJOR}.{newest}"',
114+
),
115+
(
116+
"pyproject-fmt/docs/formatting.rst",
117+
r"``max_supported_python`` \(here ``\d+\.\d+``\)",
118+
f"``max_supported_python`` (here ``{_MAJOR}.{newest}``)",
119+
),
120+
]
121+
places = [Place(_ROOT / name, pattern, newest, spelling) for name, pattern, spelling in spelled]
122+
places.append(
123+
Place(
124+
_ROOT / "pyproject-fmt/src/pyproject_fmt/__main__.py",
125+
r"_MIN_SUPPORTED_PYTHON: Final\[tuple\[int, int\]\] = \(_PYTHON_MAJOR, \d+\)",
126+
oldest,
127+
f"_MIN_SUPPORTED_PYTHON: Final[tuple[int, int]] = (_PYTHON_MAJOR, {oldest})",
128+
)
129+
)
130+
return places
131+
132+
133+
@dataclass(frozen=True)
134+
class Place:
135+
"""One spelling of a release, in the file that writes it."""
136+
137+
path: Path
138+
pattern: str
139+
minor: int
140+
spelling: str
141+
142+
def said(self) -> str:
143+
"""What the file writes there, which a run holds against the spelling it should write."""
144+
found = re.search(self.pattern, self.path.read_text(encoding="utf-8"))
145+
if found is None:
146+
print(f"{self.path.relative_to(_ROOT)}: nothing matches {self.pattern}")
147+
sys.exit(1)
148+
return found.group(0)
149+
150+
def write(self, said: str) -> None:
151+
self.path.write_text(self.path.read_text(encoding="utf-8").replace(said, self.spelling), encoding="utf-8")
152+
153+
154+
if __name__ == "__main__":
155+
main(check="--check" in sys.argv[1:])

uv.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)