Skip to content

Commit 9fb8b7b

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 9fb8b7b

2 files changed

Lines changed: 324 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)