Skip to content

Commit 12e1842

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 12e1842

2 files changed

Lines changed: 326 additions & 0 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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(
35+
pattern: str, repl: str | Callable[[re.Match[str]], str], string: str, label: str, **kwargs: Any
36+
) -> str:
37+
"""Like re.sub(), but warns on stderr when the pattern does not match."""
38+
result, count = re.subn(pattern, repl, string, **kwargs)
39+
if count == 0:
40+
print(f"WARNING: no match for {label} pattern: {pattern}", file=sys.stderr)
41+
return result
42+
43+
44+
def fetch_release_cycle() -> dict[str, Any]:
45+
with urllib.request.urlopen(RELEASE_CYCLE_URL, timeout=30) as response:
46+
return json.loads(response.read())
47+
48+
49+
def version_key(version: str) -> tuple[int, ...]:
50+
return tuple(int(x) for x in version.split("."))
51+
52+
53+
def compute_version_sets(cycle: dict[str, Any]) -> tuple[set[str], set[str]]:
54+
"""Return (eol, active) sets of '3.X' version strings."""
55+
eol = set()
56+
active = set()
57+
for version, info in cycle.items():
58+
if not re.match(r"^3\.\d+$", version):
59+
continue
60+
status = info.get("status", "")
61+
if status == "end-of-life":
62+
eol.add(version)
63+
elif status in ("bugfix", "security"):
64+
active.add(version)
65+
return eol, active
66+
67+
68+
def get_current_versions(setup_content: str) -> set[str]:
69+
"""Extract '3.X' versions from setup.py Programming Language classifiers."""
70+
return set(re.findall(r'"Programming Language :: Python :: (3\.\d+)"', setup_content))
71+
72+
73+
def update_setup_py(content: str, to_add: set[str], to_remove: set[str], min_active: str) -> str:
74+
# Remove EOL classifiers (exact line match: 8 spaces + string + comma + newline)
75+
for version in sorted(to_remove):
76+
content = re.sub(
77+
r' "Programming Language :: Python :: ' + re.escape(version) + r'",\n',
78+
"",
79+
content,
80+
)
81+
82+
# Insert new classifiers before the Implementation classifiers
83+
if to_add:
84+
new_lines = "\n".join(
85+
' "Programming Language :: Python :: ' + version + '",'
86+
for version in sorted(to_add, key=version_key)
87+
) + "\n"
88+
content = _require_sub(
89+
r'( "Programming Language :: Python :: Implementation :: CPython")',
90+
new_lines + r"\1",
91+
content,
92+
"setup.py CPython classifier anchor",
93+
count=1,
94+
)
95+
96+
# Update python_requires minimum
97+
content = _require_sub(
98+
r'python_requires=">=3\.\d+"',
99+
f'python_requires=">={min_active}"',
100+
content,
101+
"setup.py python_requires",
102+
)
103+
return content
104+
105+
106+
def update_tox_ini(content: str, active_versions: set[str]) -> str:
107+
envlist = ",".join(
108+
"py" + v.replace(".", "")
109+
for v in sorted(active_versions, key=version_key)
110+
)
111+
return _require_sub(r"^envlist = .*$", f"envlist = {envlist}", content, "tox.ini envlist", flags=re.MULTILINE)
112+
113+
114+
def update_ci_yml(content: str, active_versions: set[str], eol_versions: set[str], latest_stable: str) -> str:
115+
sorted_versions = sorted(active_versions, key=version_key)
116+
117+
# Replace the entire `python: [...]` matrix block, preserving non-EOL PyPy entries
118+
def rebuild_python_list(m: re.Match[str]) -> str:
119+
existing_pypy = re.findall(r'"pypy-(3\.\d+)"', m.group(2))
120+
# Keep order, deduplicate, drop EOL
121+
seen: set[str] = set()
122+
valid_pypy: list[str] = []
123+
for v in existing_pypy:
124+
if v not in eol_versions and v not in seen:
125+
seen.add(v)
126+
valid_pypy.append(v)
127+
lines = [f' "{v}",' for v in sorted_versions]
128+
lines += [f' "pypy-{v}",' for v in valid_pypy]
129+
return m.group(1) + "\n" + "\n".join(lines) + m.group(3)
130+
131+
content = _require_sub(
132+
r"( python: \[)(.*?)(\n \])",
133+
rebuild_python_list,
134+
content,
135+
"ci.yml python matrix",
136+
flags=re.DOTALL,
137+
)
138+
139+
# Update the checks job python-version pin
140+
content = _require_sub(
141+
r'(python-version: )"3\.\d+"',
142+
f'\\1"{latest_stable}"',
143+
content,
144+
"ci.yml python-version pin",
145+
)
146+
147+
# Update include entries to use latest_stable.
148+
# Include entries use `python: "X.Y"` format (distinct from the matrix list).
149+
# Find whatever version they currently pin (the highest one = last "latest") and bump it.
150+
current_include_version = re.findall(r'python: "(\d+\.\d+)"', content)
151+
if current_include_version:
152+
old_include_version = max(current_include_version, key=version_key)
153+
if old_include_version != latest_stable:
154+
content = content.replace(
155+
f'python: "{old_include_version}"',
156+
f'python: "{latest_stable}"',
157+
)
158+
159+
return content
160+
161+
162+
def update_release_yml(content: str, latest_stable: str) -> str:
163+
return _require_sub(
164+
r'(PYTHON_VERSION: )"3\.\d+"',
165+
f'\\1"{latest_stable}"',
166+
content,
167+
"release.yml PYTHON_VERSION",
168+
)
169+
170+
171+
def update_readme(content: str, min_active: str) -> str:
172+
return _require_sub(r"Python>=3\.\d+", f"Python>={min_active}", content, "README.md Python>= mention")
173+
174+
175+
def update_pre_commit(content: str, min_active: str) -> str:
176+
compact = min_active.replace(".", "")
177+
return _require_sub(r"--py\d+-plus", f"--py{compact}-plus", content, ".pre-commit-config.yaml --pyXX-plus")
178+
179+
180+
# ---------------------------------------------------------------------------
181+
# Main
182+
# ---------------------------------------------------------------------------
183+
184+
def main() -> None:
185+
print("Fetching Python release cycle data...")
186+
try:
187+
cycle = fetch_release_cycle()
188+
except Exception as exc:
189+
print(f"ERROR fetching release cycle: {exc}", file=sys.stderr)
190+
sys.exit(1)
191+
192+
eol, active = compute_version_sets(cycle)
193+
if not active:
194+
print("ERROR: No active Python versions found in release cycle data.", file=sys.stderr)
195+
sys.exit(1)
196+
197+
latest_stable = max(active, key=version_key)
198+
min_active = min(active, key=version_key)
199+
200+
print(f"Active versions: {sorted(active, key=version_key)}")
201+
print(f"EOL versions: {sorted(eol, key=version_key)}")
202+
print(f"Latest stable: {latest_stable}")
203+
print(f"Minimum active: {min_active}")
204+
205+
with open("setup.py", encoding="utf-8") as f:
206+
setup_content = f.read()
207+
current = get_current_versions(setup_content)
208+
to_add = active - current
209+
to_remove = current & eol
210+
211+
print(f"Currently in setup.py: {sorted(current, key=version_key)}")
212+
print(f"To add: {sorted(to_add, key=version_key)}")
213+
print(f"To remove: {sorted(to_remove, key=version_key)}")
214+
215+
files: dict[str, Callable[[str], str]] = {
216+
"setup.py": lambda c: update_setup_py(c, to_add, to_remove, min_active),
217+
"tox.ini": lambda c: update_tox_ini(c, active),
218+
".github/workflows/ci.yml": lambda c: update_ci_yml(c, active, eol, latest_stable),
219+
".github/workflows/release.yml": lambda c: update_release_yml(c, latest_stable),
220+
"README.md": lambda c: update_readme(c, min_active),
221+
".pre-commit-config.yaml": lambda c: update_pre_commit(c, min_active),
222+
}
223+
224+
changed = False
225+
for path, updater in files.items():
226+
with open(path, encoding="utf-8") as f:
227+
original = f.read()
228+
updated = updater(original)
229+
if updated != original:
230+
with open(path, "w", encoding="utf-8") as f:
231+
f.write(updated)
232+
print(f"Updated: {path}")
233+
changed = True
234+
235+
if not changed:
236+
print("No changes needed.")
237+
else:
238+
print("All files updated successfully.")
239+
240+
241+
if __name__ == "__main__":
242+
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)