From 714d11038284049d1fc9e3ca4b37739aecc3d9a0 Mon Sep 17 00:00:00 2001 From: Ashhar Hasan Date: Fri, 6 Mar 2026 20:46:10 +0530 Subject: [PATCH] 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. --- .github/scripts/update_python_versions.py | 251 +++++++++++++++++++ .github/workflows/update-python-versions.yml | 74 ++++++ 2 files changed, 325 insertions(+) create mode 100644 .github/scripts/update_python_versions.py create mode 100644 .github/workflows/update-python-versions.yml diff --git a/.github/scripts/update_python_versions.py b/.github/scripts/update_python_versions.py new file mode 100644 index 00000000..df2ec31c --- /dev/null +++ b/.github/scripts/update_python_versions.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Fetches the official Python release cycle and updates project files to reflect +the current set of supported Python versions. + +Run from the project root: + python .github/scripts/update_python_versions.py + +Exit code 0 on success (whether or not files were changed). +The caller can inspect `git diff --quiet` to decide whether to open a PR. +""" +import json +import re +import sys +import urllib.request +from typing import Any +from typing import Callable + +RELEASE_CYCLE_URL = ( + "https://raw.githubusercontent.com/python/devguide/main/include/release-cycle.json" +) + + +def _require_sub( + pattern: str, replacement: str | Callable[[re.Match[str]], str], string: str, label: str, **kwargs: Any +) -> str: + """Like re.sub(), but exits with an error when the pattern does not match.""" + result, count = re.subn(pattern, replacement, string, **kwargs) + if count == 0: + print(f"ERROR: no match for {label} pattern: {pattern}", file=sys.stderr) + sys.exit(1) + return result + + +def fetch_release_cycle() -> dict[str, Any]: + with urllib.request.urlopen(RELEASE_CYCLE_URL, timeout=30) as response: + return json.loads(response.read()) + + +def version_key(version: str) -> tuple[int, ...]: + return tuple(int(part) for part in version.split(".")) + + +def partition_versions(cycle: dict[str, Any]) -> tuple[set[str], set[str]]: + """Return (eol, active) sets of '3.X' version strings.""" + eol = set() + active = set() + for version, info in cycle.items(): + if not re.match(r"^3\.\d+$", version): + continue + status = info.get("status", "") + if status == "end-of-life": + eol.add(version) + elif status in ("bugfix", "security"): + active.add(version) + return eol, active + + +def get_current_versions(setup_content: str) -> set[str]: + """Extract '3.X' versions from setup.py Programming Language classifiers.""" + return set(re.findall(r'"Programming Language :: Python :: (3\.\d+)"', setup_content)) + + +def update_setup_py(content: str, to_add: set[str], to_remove: set[str], min_active: str) -> str: + prefix = ' "Programming Language :: Python :: ' + + # Remove EOL classifiers (exact line match: 8 spaces + string + comma + newline) + for version in sorted(to_remove, key=version_key): + content = re.sub(prefix + re.escape(version) + r'",\n', "", content) + + # Insert new classifiers before the Implementation classifiers + if to_add: + new_lines = "\n".join( + f'{prefix}{version}",' + for version in sorted(to_add, key=version_key) + ) + "\n" + content = _require_sub( + rf'({prefix}Implementation :: CPython")', + new_lines + r"\1", + content, + "setup.py CPython classifier anchor", + count=1, + ) + + # Update python_requires minimum + content = _require_sub( + r'python_requires=">=3\.\d+"', + f'python_requires=">={min_active}"', + content, + "setup.py python_requires", + ) + return content + + +def update_tox_ini(content: str, active_versions: set[str]) -> str: + envlist = ",".join( + "py" + version.replace(".", "") + for version in sorted(active_versions, key=version_key) + ) + return _require_sub(r"^envlist = .*$", f"envlist = {envlist}", content, "tox.ini envlist", flags=re.MULTILINE) + + +def _extract_yaml_block(content: str, key: str) -> tuple[int, int]: + """Return (start, end) offsets of the child block under a YAML key. + Finds ``key:`` and collects all subsequent lines indented deeper than it. + """ + match = re.search(rf"^( +){re.escape(key)}:\n", content, flags=re.MULTILINE) + if not match: + return -1, -1 + indent = match.group(1) + block_start = match.end() + block_end = block_start + for line in content[block_start:].splitlines(keepends=True): + if line.startswith(indent + " ") or line.strip() == "": + block_end += len(line) + else: + break + return block_start, block_end + + +def update_ci_yml(content: str, active_versions: set[str], eol_versions: set[str], latest_stable: str) -> str: + sorted_versions = sorted(active_versions, key=version_key) + + # Replace the entire `python: [...]` matrix block, preserving non-EOL PyPy entries + def rebuild_python_list(match: re.Match[str]) -> str: + existing_pypy = re.findall(r'"pypy-(3\.\d+)"', match.group(2)) + valid_pypy = list(dict.fromkeys(version for version in existing_pypy if version not in eol_versions)) + lines = [f' "{version}",' for version in sorted_versions] + lines += [f' "pypy-{version}",' for version in valid_pypy] + return match.group(1) + "\n" + "\n".join(lines) + match.group(3) + + content = _require_sub( + r"( python: \[)(.*?)(\n \])", + rebuild_python_list, + content, + "ci.yml python matrix", + flags=re.DOTALL, + ) + + # Update the checks job python-version pin + content = _require_sub( + r'(python-version: )"3\.\d+"', + f'\\1"{latest_stable}"', + content, + "ci.yml python-version pin", + ) + + # Update include entries - scoped to the include block via indentation + block_start, block_end = _extract_yaml_block(content, "include") + if block_start >= 0: + block = content[block_start:block_end] + include_versions = re.findall(r'python: "(\d+\.\d+)"', block) + if include_versions: + old_include_version = max(include_versions, key=version_key) + if old_include_version != latest_stable: + new_block = block.replace( + f'python: "{old_include_version}"', + f'python: "{latest_stable}"', + ) + content = content[:block_start] + new_block + content[block_end:] + + return content + + +def update_release_yml(content: str, latest_stable: str) -> str: + return _require_sub( + r'(PYTHON_VERSION: )"3\.\d+"', + f'\\1"{latest_stable}"', + content, + "release.yml PYTHON_VERSION", + ) + + +def update_readme(content: str, min_active: str) -> str: + return _require_sub(r"Python>=3\.\d+", f"Python>={min_active}", content, "README.md Python>= mention") + + +def update_pre_commit(content: str, min_active: str) -> str: + compact = min_active.replace(".", "") + return _require_sub(r"--py\d+-plus", f"--py{compact}-plus", content, ".pre-commit-config.yaml --pyXX-plus") + + +def main() -> None: + print("Fetching Python release cycle data...") + try: + cycle = fetch_release_cycle() + except Exception as exc: + print(f"ERROR fetching release cycle: {exc}", file=sys.stderr) + sys.exit(1) + + eol, active = partition_versions(cycle) + if not active: + print("ERROR: No active Python versions found in release cycle data.", file=sys.stderr) + sys.exit(1) + + latest_stable = max(active, key=version_key) + min_active = min(active, key=version_key) + + print(f"Active versions: {sorted(active, key=version_key)}") + print(f"EOL versions: {sorted(eol, key=version_key)}") + print(f"Latest stable: {latest_stable}") + print(f"Minimum active: {min_active}") + + with open("setup.py", encoding="utf-8") as file: + setup_content = file.read() + current = get_current_versions(setup_content) + to_add = active - current + to_remove = current & eol + + print(f"Currently in setup.py: {sorted(current, key=version_key)}") + print(f"To add: {sorted(to_add, key=version_key)}") + print(f"To remove: {sorted(to_remove, key=version_key)}") + + files: dict[str, Callable[[str], str]] = { + "setup.py": lambda content: update_setup_py(content, to_add, to_remove, min_active), + "tox.ini": lambda content: update_tox_ini(content, active), + ".github/workflows/ci.yml": lambda content: update_ci_yml(content, active, eol, latest_stable), + ".github/workflows/release.yml": lambda content: update_release_yml(content, latest_stable), + "README.md": lambda content: update_readme(content, min_active), + ".pre-commit-config.yaml": lambda content: update_pre_commit(content, min_active), + } + + changed = False + for path, updater in files.items(): + with open(path, encoding="utf-8") as file: + original = file.read() + updated = updater(original) + if updated != original: + with open(path, "w", encoding="utf-8") as file: + file.write(updated) + print(f"Updated: {path}") + changed = True + + if not changed: + print("No changes needed.") + else: + print("All files updated successfully.") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/update-python-versions.yml b/.github/workflows/update-python-versions.yml new file mode 100644 index 00000000..b1d76d84 --- /dev/null +++ b/.github/workflows/update-python-versions.yml @@ -0,0 +1,74 @@ +name: Update Python versions + +on: + schedule: + - cron: '0 0 1 * *' # first of each month + workflow_dispatch: + +# Prevent races between overlapping scheduled and manual runs +# on the shared bot/update-python-versions branch. +concurrency: + group: update-python-versions + +permissions: + contents: write + pull-requests: write + +jobs: + update-python-versions: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + + - name: Run version update script + run: python .github/scripts/update_python_versions.py + + - name: Check for changes + id: check_changes + run: | + if git diff --quiet; then + echo "changed=false" >> $GITHUB_OUTPUT + else + echo "changed=true" >> $GITHUB_OUTPUT + fi + + - name: Commit and push to bot branch + if: steps.check_changes.outputs.changed == 'true' + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + git checkout -B bot/update-python-versions + git add \ + setup.py \ + tox.ini \ + .github/workflows/ci.yml \ + .github/workflows/release.yml \ + README.md \ + .pre-commit-config.yaml + git commit -m "Update tested and supported Python versions" + git push --force origin bot/update-python-versions + + - name: Create pull request + if: steps.check_changes.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + EXISTING_PR=$(gh pr list --head bot/update-python-versions --json number --jq '.[0].number // empty' 2>/dev/null || echo "") + if [ -z "$EXISTING_PR" ]; then + gh pr create \ + --title "Update tested and supported Python versions" \ + --body "$(cat <<'EOF' + Automated update of supported Python versions based on the [Python release cycle](https://devguide.python.org/versions/). + + > This PR was automatically created by the [Update Python versions](${{ github.server_url }}/${{ github.repository }}/actions/workflows/update-python-versions.yml) workflow. + EOF + )" \ + --base master \ + --head bot/update-python-versions + else + echo "PR #$EXISTING_PR already exists for bot/update-python-versions — branch updated in place." + fi