|
| 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() |
0 commit comments