diff --git a/.github/workflows/check-python-versions.yml b/.github/workflows/check-python-versions.yml index e0163528..ddea16ea 100644 --- a/.github/workflows/check-python-versions.yml +++ b/.github/workflows/check-python-versions.yml @@ -19,42 +19,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + - name: Check supported Python versions + uses: microsoft/pylance-engineering/actions/check-python-versions@a81ceb5dc25ea56bd0c957ccda3a48886682faea 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-version\` matrix in \`.github/workflows/pr-check.yml\` includes Python $VERSION. - - [ ] Ensure every explicit-version \`python-version\` matrix in \`.github/workflows/push-check.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 + files: | + - path: .github/workflows/pr-check.yml + key: python-version + - path: .github/workflows/push-check.yml + key: python-version + token: ${{ github.token }} + issue-labels: debt,python-version diff --git a/build/test_update_python_versions.py b/build/test_update_python_versions.py deleted file mode 100644 index 0970373a..00000000 --- a/build/test_update_python_versions.py +++ /dev/null @@ -1,143 +0,0 @@ -# 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", "push-check.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 deleted file mode 100644 index b6a83b05..00000000 --- a/build/update_python_versions.py +++ /dev/null @@ -1,101 +0,0 @@ -# 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", "push-check.yml")] - version = unconfigured_version(fetch_releases(), workflows) - if version: - print(version) - - -if __name__ == "__main__": - main()