Skip to content

Commit f308a38

Browse files
committed
Add automated Python version update workflow
Add a scheduled GitHub Action that keeps the project's Python version support matrix and metadata up to date without manual PRs.
1 parent 25693ec commit f308a38

2 files changed

Lines changed: 306 additions & 0 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
#!/usr/bin/env python3
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
"""Fetches the official Python release cycle and updates project files to reflect
14+
the current set of supported Python versions.
15+
16+
Run from the project root:
17+
python .github/scripts/update_python_versions.py
18+
19+
Exit code 0 on success (whether or not files were changed).
20+
The caller can inspect `git diff --quiet` to decide whether to open a PR.
21+
"""
22+
import json
23+
import re
24+
import sys
25+
import urllib.request
26+
from typing import Any
27+
from typing import Callable
28+
29+
RELEASE_CYCLE_URL = (
30+
"https://raw.githubusercontent.com/python/devguide/main/include/release-cycle.json"
31+
)
32+
33+
34+
def fetch_release_cycle() -> dict[str, Any]:
35+
with urllib.request.urlopen(RELEASE_CYCLE_URL) as response:
36+
return json.loads(response.read())
37+
38+
39+
def version_key(version: str) -> tuple[int, ...]:
40+
return tuple(int(x) for x in version.split("."))
41+
42+
43+
def compute_version_sets(cycle: dict[str, Any]) -> tuple[set[str], set[str]]:
44+
"""Return (eol, active) sets of '3.X' version strings."""
45+
eol = set()
46+
active = set()
47+
for version, info in cycle.items():
48+
if not re.match(r"^3\.\d+$", version):
49+
continue
50+
status = info.get("status", "")
51+
if status == "end-of-life":
52+
eol.add(version)
53+
elif status in ("bugfix", "security"):
54+
active.add(version)
55+
return eol, active
56+
57+
58+
def get_current_versions(setup_content: str) -> set[str]:
59+
"""Extract '3.X' versions from setup.py Programming Language classifiers."""
60+
return set(re.findall(r'"Programming Language :: Python :: (3\.\d+)"', setup_content))
61+
62+
63+
def update_setup_py(content: str, to_add: set[str], to_remove: set[str], min_active: str) -> str:
64+
# Remove EOL classifiers (exact line match: 8 spaces + string + comma + newline)
65+
for version in sorted(to_remove):
66+
content = re.sub(
67+
r' "Programming Language :: Python :: ' + re.escape(version) + r'",\n',
68+
"",
69+
content,
70+
)
71+
72+
# Insert new classifiers before the Implementation classifiers
73+
if to_add:
74+
new_lines = "\n".join(
75+
' "Programming Language :: Python :: ' + version + '",'
76+
for version in sorted(to_add, key=version_key)
77+
) + "\n"
78+
content = re.sub(
79+
r'( "Programming Language :: Python :: Implementation :: CPython")',
80+
new_lines + r"\1",
81+
content,
82+
count=1,
83+
)
84+
85+
# Update python_requires minimum
86+
content = re.sub(
87+
r'python_requires=">=3\.\d+"',
88+
f'python_requires=">={min_active}"',
89+
content,
90+
)
91+
return content
92+
93+
94+
def update_tox_ini(content: str, active_versions: set[str]) -> str:
95+
envlist = ",".join(
96+
"py" + v.replace(".", "")
97+
for v in sorted(active_versions, key=version_key)
98+
)
99+
return re.sub(r"^envlist = .*$", f"envlist = {envlist}", content, flags=re.MULTILINE)
100+
101+
102+
def update_ci_yml(content: str, active_versions: set[str], eol_versions: set[str], latest_stable: str) -> str:
103+
sorted_versions = sorted(active_versions, key=version_key)
104+
105+
# Replace the entire `python: [...]` matrix block, preserving non-EOL PyPy entries
106+
def rebuild_python_list(m: re.Match[str]) -> str:
107+
existing_pypy = re.findall(r'"pypy-(3\.\d+)"', m.group(2))
108+
# Keep order, deduplicate, drop EOL
109+
seen: set[str] = set()
110+
valid_pypy: list[str] = []
111+
for v in existing_pypy:
112+
if v not in eol_versions and v not in seen:
113+
seen.add(v)
114+
valid_pypy.append(v)
115+
lines = [f' "{v}",' for v in sorted_versions]
116+
lines += [f' "pypy-{v}",' for v in valid_pypy]
117+
return m.group(1) + "\n" + "\n".join(lines) + m.group(3)
118+
119+
content = re.sub(
120+
r"( python: \[)(.*?)(\n \])",
121+
rebuild_python_list,
122+
content,
123+
flags=re.DOTALL,
124+
)
125+
126+
# Update the checks job python-version pin
127+
content = re.sub(
128+
r'(python-version: )"3\.\d+"',
129+
f'\\1"{latest_stable}"',
130+
content,
131+
)
132+
133+
# Update include entries to use latest_stable.
134+
# Include entries use `python: "X.Y"` format (distinct from the matrix list).
135+
# Find whatever version they currently pin (the highest one = last "latest") and bump it.
136+
current_include_version = re.findall(r'python: "(\d+\.\d+)"', content)
137+
if current_include_version:
138+
old_include_version = max(current_include_version, key=version_key)
139+
if old_include_version != latest_stable:
140+
content = content.replace(
141+
f'python: "{old_include_version}"',
142+
f'python: "{latest_stable}"',
143+
)
144+
145+
return content
146+
147+
148+
def update_release_yml(content: str, latest_stable: str) -> str:
149+
return re.sub(
150+
r'(PYTHON_VERSION: )"3\.\d+"',
151+
f'\\1"{latest_stable}"',
152+
content,
153+
)
154+
155+
156+
def update_readme(content: str, min_active: str) -> str:
157+
return re.sub(r"Python>=3\.\d+", f"Python>={min_active}", content)
158+
159+
160+
def update_pre_commit(content: str, min_active: str) -> str:
161+
compact = min_active.replace(".", "")
162+
return re.sub(r"--py\d+-plus", f"--py{compact}-plus", content)
163+
164+
165+
# ---------------------------------------------------------------------------
166+
# Main
167+
# ---------------------------------------------------------------------------
168+
169+
def main() -> None:
170+
print("Fetching Python release cycle data...")
171+
try:
172+
cycle = fetch_release_cycle()
173+
except Exception as exc:
174+
print(f"ERROR fetching release cycle: {exc}", file=sys.stderr)
175+
sys.exit(1)
176+
177+
eol, active = compute_version_sets(cycle)
178+
if not active:
179+
print("ERROR: No active Python versions found in release cycle data.", file=sys.stderr)
180+
sys.exit(1)
181+
182+
latest_stable = max(active, key=version_key)
183+
min_active = min(active, key=version_key)
184+
185+
print(f"Active versions: {sorted(active, key=version_key)}")
186+
print(f"EOL versions: {sorted(eol, key=version_key)}")
187+
print(f"Latest stable: {latest_stable}")
188+
print(f"Minimum active: {min_active}")
189+
190+
with open("setup.py") as f:
191+
setup_content = f.read()
192+
current = get_current_versions(setup_content)
193+
to_add = active - current
194+
to_remove = current & eol
195+
196+
print(f"Currently in setup.py: {sorted(current, key=version_key)}")
197+
print(f"To add: {sorted(to_add, key=version_key)}")
198+
print(f"To remove: {sorted(to_remove, key=version_key)}")
199+
200+
files: dict[str, Callable[[str], str]] = {
201+
"setup.py": lambda c: update_setup_py(c, to_add, to_remove, min_active),
202+
"tox.ini": lambda c: update_tox_ini(c, active),
203+
".github/workflows/ci.yml": lambda c: update_ci_yml(c, active, eol, latest_stable),
204+
".github/workflows/release.yml": lambda c: update_release_yml(c, latest_stable),
205+
"README.md": lambda c: update_readme(c, min_active),
206+
".pre-commit-config.yaml": lambda c: update_pre_commit(c, min_active),
207+
}
208+
209+
changed = False
210+
for path, updater in files.items():
211+
with open(path) as f:
212+
original = f.read()
213+
updated = updater(original)
214+
if updated != original:
215+
with open(path, "w") as f:
216+
f.write(updated)
217+
print(f"Updated: {path}")
218+
changed = True
219+
220+
if not changed:
221+
print("No changes needed.")
222+
else:
223+
print("All files updated successfully.")
224+
225+
226+
if __name__ == "__main__":
227+
main()
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: Update Python versions
2+
3+
on:
4+
schedule:
5+
- cron: '0 0 1 * *' # first of each month
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
pull-requests: write
11+
12+
jobs:
13+
update-python-versions:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
with:
18+
token: ${{ secrets.GITHUB_TOKEN }}
19+
20+
- uses: actions/setup-python@v5
21+
with:
22+
python-version: "3.x"
23+
24+
- name: Run version update script
25+
run: python .github/scripts/update_python_versions.py
26+
27+
- name: Check for changes
28+
id: check_changes
29+
run: |
30+
if git diff --quiet; then
31+
echo "changed=false" >> $GITHUB_OUTPUT
32+
else
33+
echo "changed=true" >> $GITHUB_OUTPUT
34+
fi
35+
36+
- name: Commit and push to bot branch
37+
if: steps.check_changes.outputs.changed == 'true'
38+
run: |
39+
git config user.email "github-actions[bot]@users.noreply.github.com"
40+
git config user.name "github-actions[bot]"
41+
git checkout -B bot/update-python-versions
42+
git add \
43+
setup.py \
44+
tox.ini \
45+
.github/workflows/ci.yml \
46+
.github/workflows/release.yml \
47+
README.md \
48+
.pre-commit-config.yaml
49+
git commit -m "chore: update Python version support"
50+
git push --force origin bot/update-python-versions
51+
52+
- name: Create pull request
53+
if: steps.check_changes.outputs.changed == 'true'
54+
env:
55+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
56+
run: |
57+
EXISTING_PR=$(gh pr list --head bot/update-python-versions --json number --jq '.[0].number' 2>/dev/null || echo "")
58+
if [ -z "$EXISTING_PR" ]; then
59+
gh pr create \
60+
--title "chore: update Python version support" \
61+
--body "$(cat <<'EOF'
62+
Automated update of supported Python versions based on the [Python release cycle](https://devguide.python.org/versions/).
63+
64+
Files updated:
65+
- `setup.py`: classifiers and `python_requires`
66+
- `tox.ini`: `envlist`
67+
- `.github/workflows/ci.yml`: build matrix and Python pin
68+
- `.github/workflows/release.yml`: Python version pin
69+
- `README.md`: Python version mention
70+
- `.pre-commit-config.yaml`: `--pyXX-plus` argument
71+
72+
> This PR was automatically created by the [Update Python versions](${{ github.server_url }}/${{ github.repository }}/actions/workflows/update-python-versions.yml) workflow.
73+
EOF
74+
)" \
75+
--base master \
76+
--head bot/update-python-versions
77+
else
78+
echo "PR #$EXISTING_PR already exists for bot/update-python-versions — branch updated in place."
79+
fi

0 commit comments

Comments
 (0)