diff --git a/.github/workflows/check-python-versions.yml b/.github/workflows/check-python-versions.yml new file mode 100644 index 000000000000..39a0d3edd567 --- /dev/null +++ b/.github/workflows/check-python-versions.yml @@ -0,0 +1,60 @@ +name: Check supported Python versions + +on: + schedule: + - cron: '0 12 * * 1' + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: check-supported-python-versions + cancel-in-progress: false + +jobs: + check: + if: github.repository_owner == 'microsoft' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.15' + - name: Check for a new Python version + id: check + shell: bash + run: | + version="$(python build/update_python_versions.py)" + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Create issue + if: steps.check.outputs.version != '' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.check.outputs.version }} + shell: bash + run: | + title="CI Python Versions: Update to Python $VERSION" + issue_number="$( + gh api --paginate \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/repos/$GITHUB_REPOSITORY/issues?state=all&per_page=100" \ + --jq ".[] | select(has(\"pull_request\") | not) | select(.title == \"$title\") | .number" + )" + if [[ -z "$issue_number" ]]; then + gh issue create \ + --title "$title" \ + --label debt \ + --label python-version \ + --body "Python $VERSION has a published release candidate according to the [Python.org downloads API](https://www.python.org/api/v2/downloads/release/?is_published=true&pre_release=true&version=3), but is missing from at least one CI test matrix. + + ### Requested changes + - [ ] Ensure every explicit-version \`python\` matrix in \`.github/workflows/pr-check.yml\` includes Python $VERSION. + - [ ] Ensure every explicit-version \`python\` matrix in \`.github/workflows/build.yml\` includes Python $VERSION. + - [ ] Run the tests on Python $VERSION and resolve any compatibility failures. + + Compare matrix entries by major.minor; patch and prerelease/dev variants of $VERSION already count as coverage. Add the version only where missing and preserve existing entries. Floating \`3.x\` entries do not declare release-candidate coverage. + + Keep minimum-supported-version and build-runtime Python pins unchanged; adding CI coverage does not require raising the minimum supported Python version." + fi diff --git a/build/test_update_python_versions.py b/build/test_update_python_versions.py new file mode 100644 index 000000000000..e7eafa7eebc8 --- /dev/null +++ b/build/test_update_python_versions.py @@ -0,0 +1,143 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import datetime + +import pytest +import update_python_versions + +NOW = datetime.datetime(2026, 8, 2, tzinfo=datetime.timezone.utc) +WORKFLOWS = ("pr-check.yml", "build.yml") + + +def release(name="Python 3.15.0rc1", release_date="2026-08-01T00:00:00Z", **overrides): + result = { + "name": name, + "release_date": release_date, + "is_published": True, + "pre_release": True, + "version": 3, + } + result.update(overrides) + return result + + +def test_latest_release_candidate_filters_and_orders_releases(): + releases = [ + release("Python 3.9.0rc10"), + release("Python 3.10.0rc2"), + release("Python 3.11.0b1"), + release("Python 3.11.0rc1", "2027-08-01T00:00:00Z"), + release("Python 3.12.0rc1", is_published=False), + release("Python 3.13.0rc1", pre_release=False), + None, + ] + + assert update_python_versions.latest_release_candidate(releases, NOW) == "3.10" + + +@pytest.mark.parametrize("release_date", [None, "invalid", "2026-08-01"]) +def test_latest_release_candidate_rejects_malformed_dates(release_date): + with pytest.raises(ValueError, match="release_date"): + update_python_versions.latest_release_candidate( + [release(release_date=release_date)], NOW + ) + + +def test_latest_release_candidate_requires_a_published_rc(): + with pytest.raises(ValueError, match="released Python RC"): + update_python_versions.latest_release_candidate( + [release("Python 3.15.0"), release("Python 3.16.0a1")], NOW + ) + + +@pytest.mark.parametrize("key", ["python", "python-version"]) +def test_configured_versions_normalizes_explicit_series(tmp_path, key): + workflow = tmp_path / "workflow.yml" + workflow.write_text( + f"{key}: ['3.10.9', '3.11-dev', '3.12.0rc1', '3.x']\n", encoding="utf-8" + ) + + assert update_python_versions.configured_versions(workflow) == { + "3.10", + "3.11", + "3.12", + } + + +@pytest.mark.parametrize("text", ["matrix: {}\n", "python: ['3.x']\n"]) +def test_configured_versions_requires_explicit_matrices(tmp_path, text): + workflow = tmp_path / "workflow.yml" + workflow.write_text(text, encoding="utf-8") + + with pytest.raises(RuntimeError, match="Python version"): + update_python_versions.configured_versions(workflow) + + +@pytest.mark.parametrize("key", ["python", "python-version"]) +@pytest.mark.parametrize( + "first, second, expected", + [ + ("3.15", "3.14", set()), + ("3.14", "3.15", set()), + ("3.15.1", "3.15-dev", {"3.15"}), + ], +) +def test_configured_versions_requires_coverage_in_each_matrix( + tmp_path, key, first, second, expected +): + workflow = tmp_path / "workflow.yml" + workflow.write_text( + f"{key}: ['{first}']\n{key}: ['{second}']\n{key}: ['3.x']\n", + encoding="utf-8", + ) + + assert update_python_versions.configured_versions(workflow) == expected + + +@pytest.mark.parametrize( + "pr_version, push_version, expected", + [ + ("3.14", "3.14", "3.15"), + ("3.15", "3.14", "3.15"), + ("3.14", "3.15", "3.15"), + ("3.15", "3.15", None), + ("3.15.1", "3.15.0-rc.2", None), + ("3.150", "3.15", "3.15"), + ], +) +def test_unconfigured_version(tmp_path, pr_version, push_version, expected): + workflows = [tmp_path / name for name in WORKFLOWS] + for workflow, version in zip(workflows, (pr_version, push_version)): + workflow.write_text(f"python: ['{version}']\n", encoding="utf-8") + + assert ( + update_python_versions.unconfigured_version([release()], workflows, NOW) + == expected + ) + + +@pytest.mark.parametrize( + "push_version, expected", [("3.14", "3.15\n"), ("3.15-dev", "")] +) +def test_main_checks_both_workflows( + tmp_path, monkeypatch, capsys, push_version, expected +): + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + for name, version in zip(WORKFLOWS, ("3.15.1", push_version)): + (workflow_dir / name).write_text(f"python: ['{version}']\n", encoding="utf-8") + monkeypatch.setattr( + update_python_versions, + "__file__", + str(tmp_path / "build" / "update_python_versions.py"), + ) + monkeypatch.setattr( + update_python_versions, + "fetch_releases", + lambda: [release(release_date="2020-08-01T00:00:00Z")], + ) + + update_python_versions.main() + + assert capsys.readouterr().out == expected diff --git a/build/update_python_versions.py b/build/update_python_versions.py new file mode 100644 index 000000000000..44e0d737f411 --- /dev/null +++ b/build/update_python_versions.py @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import datetime +import json +import pathlib +import re +import urllib.request + +DOWNLOADS_URL = ( + "https://www.python.org/api/v2/downloads/release/" + "?is_published=true&pre_release=true&version=3" +) +RC_NAME = re.compile(r"Python (?P3\.\d+)\.0rc(?P\d+)") +VERSION_MATRIX = re.compile( + r"^\s*python(?:-version)?:\s*\[(?P[^\]\n]+)\]\s*$", re.MULTILINE +) + + +def fetch_releases(): + """Fetch published Python prereleases from Python.org.""" + request = urllib.request.Request( + DOWNLOADS_URL, headers={"User-Agent": "vscode-python-tools-version-check"} + ) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + + +def latest_release_candidate(releases, now=None): + """Return the newest Python 3 series with a published release candidate.""" + now = now or datetime.datetime.now(datetime.timezone.utc) + candidates = [] + for release in releases: + if ( + not isinstance(release, dict) + or release.get("is_published") is not True + or release.get("pre_release") is not True + or release.get("version") != 3 + or not isinstance(release.get("name"), str) + ): + continue + match = RC_NAME.fullmatch(release["name"]) + if not match: + continue + try: + release_date = datetime.datetime.fromisoformat( + release["release_date"].replace("Z", "+00:00") + ) + except (AttributeError, KeyError, TypeError, ValueError) as exc: + raise ValueError(f"Invalid release_date for {release['name']}") from exc + if release_date.tzinfo is None: + raise ValueError(f"Missing timezone in release_date for {release['name']}") + if release_date <= now: + series = match.group("series") + candidates.append( + ( + tuple(map(int, series.split("."))), + int(match.group("candidate")), + series, + ) + ) + if not candidates: + raise ValueError("The downloads API did not contain a released Python RC") + return max(candidates)[2] + + +def configured_versions(workflow): + """Return major.minor versions covered by every explicit Python matrix.""" + matches = VERSION_MATRIX.findall(workflow.read_text(encoding="utf-8")) + if not matches: + raise RuntimeError(f"Expected a Python version matrix in {workflow}") + matrices = [ + set(re.findall(r"['\"](\d+\.\d+)[^'\"]*['\"]", match)) for match in matches + ] + # Floating-only matrices such as ['3.x'] do not declare RC-series coverage. + explicit_matrices = [versions for versions in matrices if versions] + if not explicit_matrices: + raise RuntimeError(f"No explicit Python versions found in {workflow}") + return set.intersection(*explicit_matrices) + + +def unconfigured_version(releases, workflows, now=None): + """Return the newest RC series when any explicit test matrix lacks it.""" + latest = latest_release_candidate(releases, now) + for workflow in workflows: + if latest not in configured_versions(workflow): + return latest + return None + + +def main(): + """Print the newest RC series if it is missing from the CI test matrices.""" + workflow_dir = pathlib.Path(__file__).parent.parent / ".github" / "workflows" + workflows = [workflow_dir / name for name in ("pr-check.yml", "build.yml")] + version = unconfigured_version(fetch_releases(), workflows) + if version: + print(version) + + +if __name__ == "__main__": + main()