Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/python_versions.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: 🐍 python versions
on:
workflow_dispatch:
schedule:
- cron: "0 8 * * *"
permissions: {}
jobs:
bump:
name: ⬆️ bump
runs-on: ubuntu-24.04
environment: update-deps
permissions:
contents: write
pull-requests: write
steps:
- name: 📥 Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: 🐍 Setup uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: ⬆️ Set the releases the defaults name
run: uv run tasks/python_versions.py
- name: 🎨 Run prek
run: uvx prek run --all-files || true
- name: 🔀 Create Pull Request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
with:
token: ${{ secrets.RELEASE_TOKEN }}
commit-message: "Set the Python releases the defaults name"
title: "Set the Python releases the defaults name"
body: |
The newest release the defaults name has to satisfy two things at once: PyPI publishes
a classifier for it, and Python has published a release candidate. A classifier appears
while a release is still changing, and a candidate PyPI has no classifier for is one no
upload may carry, so neither alone is enough.

The oldest comes from the release cycles still carrying fixes.

Each run reads what is true that day rather than any date published ahead of it, so a
release that slips arrives when it arrives. Anyone naming `max_supported_python`
themselves keeps what they set.
branch: update-python-versions
delete-branch: true
7 changes: 4 additions & 3 deletions pyproject-fmt/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Put per-file settings in ``[tool.pyproject-fmt]``:
indent = 2
keep_full_version = false
generate_python_version_classifiers = true
max_supported_python = "3.14"
max_supported_python = "3.15"
table_format = "short"
sub_table_spacing = ""
separate_root_table = "\n"
Expand All @@ -84,7 +84,7 @@ A standalone ``pyproject-fmt.toml`` can hold settings for several projects. The
column_width = 120
indent = 2
table_format = "short"
max_supported_python = "3.14"
max_supported_python = "3.15"

For each input, the formatter searches from the input's directory toward the filesystem root and uses the nearest
``pyproject-fmt.toml``. ``--config`` selects a file directly:
Expand Down Expand Up @@ -622,7 +622,7 @@ at whitespace; without it, installers read the ``;`` and the marker as part of t
[project]
optional-dependencies.dev-tools = [ "pytest" ]

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

.. code-block:: toml
Expand All @@ -641,6 +641,7 @@ Disable generation with ``generate_python_version_classifiers = false``:
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
]

**Entry points:** inline tables within ``entry-points`` expand to dotted keys:
Expand Down
4 changes: 2 additions & 2 deletions pyproject-fmt/docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Put per-file settings in ``[tool.pyproject-fmt]``:
indent = 2
keep_full_version = false
generate_python_version_classifiers = true
max_supported_python = "3.14"
max_supported_python = "3.15"
table_format = "short"
sub_table_spacing = ""
separate_root_table = "\n"
Expand All @@ -39,7 +39,7 @@ A standalone ``pyproject-fmt.toml`` can hold settings for several projects. The
column_width = 120
indent = 2
table_format = "short"
max_supported_python = "3.14"
max_supported_python = "3.15"

For each input, the formatter searches from the input's directory toward the filesystem root and uses the nearest
``pyproject-fmt.toml``. ``--config`` selects a file directly:
Expand Down
2 changes: 1 addition & 1 deletion pyproject-fmt/docs/formatting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ keywords, and validates the version.
[project.optional-dependencies]
Dev_Tools = ["pytest"]

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

.. fmt-example::
Expand Down
1 change: 1 addition & 0 deletions pyproject-fmt/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ test = [
"pytest-cov>=7.1",
"pytest-mock>=3.15.1",
"tomli>=2.4.1; python_version<'3.11'",
"trove-classifiers>=2026.6.1.19",
]
type = [
"mypy==2.3.1",
Expand Down
2 changes: 1 addition & 1 deletion pyproject-fmt/src/pyproject_fmt/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def add_format_flags(parser: ArgumentGroup) -> None:
"--max-supported-python",
metavar="major.minor",
type=_version_argument,
default=(3, 14),
default=(3, 15),
help="latest Python version the project supports (e.g. 3.14)",
)

Expand Down
27 changes: 27 additions & 0 deletions pyproject-fmt/tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import tomli as tomllib

import pytest
from trove_classifiers import classifiers

from pyproject_fmt import build_parser, run

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


def test_the_default_max_supported_python_names_a_published_classifier() -> None:
"""PyPI turns away an upload naming a classifier it does not publish, so the default writes none."""
major, minor = build_parser().get_default("max_supported_python")

assert f"Programming Language :: Python :: {major}.{minor}" in classifiers


def test_the_default_max_supported_python_trails_the_newest_classifier_by_one() -> None:
"""
A classifier is published while its release is still in beta, so the newest one runs a release
ahead of the newest that ships. Two ahead says a release has shipped that the default leaves out,
which is what asks for the bump.
"""
major, minor = build_parser().get_default("max_supported_python")
published = max(
int(held)
for name in classifiers
if (held := name.removeprefix(f"Programming Language :: Python :: {major}.")).isdigit()
and name.startswith(f"Programming Language :: Python :: {major}.")
)

assert published - minor <= 1, (
f"Python {major}.{published} has a classifier, so the default may name {major}.{published - 1}"
)


@pytest.mark.parametrize(
"in_place",
[
Expand Down
2 changes: 1 addition & 1 deletion tasks/fmt_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"column_width": 120,
"indent": 2,
"keep_full_version": False,
"max_supported_python": (3, 14),
"max_supported_python": (3, 15),
"min_supported_python": (3, 10),
"generate_python_version_classifiers": True,
"table_format": "short",
Expand Down
155 changes: 155 additions & 0 deletions tasks/python_versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# /// script
# requires-python = ">=3.12"
# dependencies = ["trove-classifiers>=2026.6.1.19"]
# ///
"""
The Python releases the formatter's defaults stand for.

The two ends answer different questions and are read from different places. The newest release a
project may be given a classifier for has to satisfy two things at once: PyPI publishes a classifier
naming it, and it has reached its release candidate. Either alone says too little, since a
classifier appears while a release is still changing and a candidate PyPI has no classifier for
cannot be written down. The oldest is the one still carrying fixes, which a classifier outlives by
years and so cannot say.

Nothing here is predicted. Each run reads what is true that day and writes down the numbers, so a
release that slips arrives when it arrives. Someone naming `max_supported_python` themselves is
saying what their own project supports, which no rule here overrides.
"""

from __future__ import annotations

import json
import re
import sys
import urllib.request
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Final

from trove_classifiers import classifiers

_ROOT: Final[Path] = Path(__file__).resolve().parents[1]
_MAJOR: Final[int] = 3


def main(*, check: bool) -> None:
places = written_places(newest_candidate(), oldest_supported())
drifted = [(place, said) for place in places if (said := place.said()) != place.spelling]
if not drifted:
print(f"the defaults name {_MAJOR}.{places[-1].minor} through {_MAJOR}.{places[0].minor}")
return
for place, said in drifted:
print(f"{place.path.relative_to(_ROOT)}: {said} -> {place.spelling}")
if check:
sys.exit(1)
for place, said in drifted:
place.write(said)


def newest_candidate() -> int:
"""
The newest release both PyPI and Python itself have got to: a classifier naming it, and a
release candidate published.

A release still in alpha or beta is one whose own behavior is still moving, so a project saying
it runs there says more than it knows. A candidate PyPI publishes no classifier for is one no
project can name, since an upload carrying an unknown classifier is turned away.
"""
return max(with_a_candidate() & with_a_classifier())


def with_a_candidate() -> set[int]:
"""The releases Python has published a candidate for."""
released = fetch("https://www.python.org/api/v2/downloads/release/?is_published=true")
named = re.compile(rf"^Python {_MAJOR}\.(\d+)\.\d+rc\d+$")
return {int(found[1]) for release in released if (found := named.match(release["name"]))}


def with_a_classifier() -> set[int]:
"""The releases PyPI publishes a classifier for."""
prefix = f"Programming Language :: Python :: {_MAJOR}."
return {
int(minor) for name in classifiers if name.startswith(prefix) and (minor := name.removeprefix(prefix)).isdigit()
}


def oldest_supported() -> int:
"""The oldest release still carrying fixes, which is the oldest the formatter writes for."""
today = datetime.now(tz=UTC).date().isoformat()
cycles = fetch("https://endoflife.date/api/python.json")
live = (row for row in cycles if isinstance(row["eol"], str) and row["eol"] > today)
return min(int(row["cycle"].split(".")[1]) for row in live)


def fetch(url: str) -> list[dict[str, str]]:
with urllib.request.urlopen(url, timeout=30) as response: # ruff: ignore[suspicious-url-open-usage] # a named host
return json.load(response)


def written_places(newest: int, oldest: int) -> list[Place]:
"""Every file that spells either release, the newest ones first."""
spelled = [
("pyproject-fmt/src/pyproject_fmt/__main__.py", r"default=\(\d+, \d+\)", f"default=({_MAJOR}, {newest})"),
(
"tasks/fmt_examples.py",
r'"max_supported_python": \(\d+, \d+\)',
f'"max_supported_python": ({_MAJOR}, {newest})',
),
(
"pyproject-fmt/pyproject.toml",
r'max_supported_python = "\d+\.\d+"',
f'max_supported_python = "{_MAJOR}.{newest}"',
),
(
"tox-toml-fmt/pyproject.toml",
r'max_supported_python = "\d+\.\d+"',
f'max_supported_python = "{_MAJOR}.{newest}"',
),
(
"pyproject-fmt/docs/configuration.rst",
r'max_supported_python = "\d+\.\d+"',
f'max_supported_python = "{_MAJOR}.{newest}"',
),
(
"pyproject-fmt/docs/formatting.rst",
r"``max_supported_python`` \(here ``\d+\.\d+``\)",
f"``max_supported_python`` (here ``{_MAJOR}.{newest}``)",
),
]
places = [Place(_ROOT / name, pattern, newest, spelling) for name, pattern, spelling in spelled]
places.append(
Place(
_ROOT / "pyproject-fmt/src/pyproject_fmt/__main__.py",
r"_MIN_SUPPORTED_PYTHON: Final\[tuple\[int, int\]\] = \(_PYTHON_MAJOR, \d+\)",
oldest,
f"_MIN_SUPPORTED_PYTHON: Final[tuple[int, int]] = (_PYTHON_MAJOR, {oldest})",
)
)
return places


@dataclass(frozen=True)
class Place:
"""One spelling of a release, in the file that writes it."""

path: Path
pattern: str
minor: int
spelling: str

def said(self) -> str:
"""What the file writes there, which a run holds against the spelling it should write."""
found = re.search(self.pattern, self.path.read_text(encoding="utf-8"))
if found is None:
print(f"{self.path.relative_to(_ROOT)}: nothing matches {self.pattern}")
sys.exit(1)
return found.group(0)

def write(self, said: str) -> None:
self.path.write_text(self.path.read_text(encoding="utf-8").replace(said, self.spelling), encoding="utf-8")


if __name__ == "__main__":
main(check="--check" in sys.argv[1:])
15 changes: 15 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.