diff --git a/.github/scripts/python_versions.py b/.github/scripts/python_versions.py new file mode 100644 index 00000000..72b659fc --- /dev/null +++ b/.github/scripts/python_versions.py @@ -0,0 +1,44 @@ +"""Discover supported stable releases from the Python Developer's Guide data.""" + +import json +import re +import time +from urllib.request import urlopen + + +def supported_versions(data: dict) -> list[str]: + if not isinstance(data, dict): + raise ValueError("Expected a release-cycle JSON object") + + versions = [] + for version, details in data.items(): + if not isinstance(details, dict) or not isinstance(details.get("status"), str): + raise ValueError(f"Invalid release-cycle entry: {version}") + if details["status"] in {"bugfix", "security"}: + if not re.fullmatch(r"3\.\d+", version): + raise ValueError(f"Invalid supported Python version: {version}") + versions.append(version) + + if not versions: + raise ValueError("No supported stable Python versions found") + return sorted(versions, key=lambda version: tuple(map(int, version.split(".")))) + + +def fetch_versions() -> list[str]: + for attempt in range(3): + try: + with urlopen("https://peps.python.org/api/release-cycle.json", timeout=15) as response: + data = json.load(response) + break + except OSError as error: + if attempt == 2: + raise RuntimeError("Unable to retrieve supported Python versions after 3 attempts") from error + time.sleep(attempt + 1) + + return supported_versions(data) + + +if __name__ == "__main__": + versions = fetch_versions() + print(f"versions={json.dumps(versions)}") + print(f"latest={versions[-1]}") diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6581584c..9f0580e0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,14 +11,29 @@ env: FORCE_COLOR: 1 jobs: + python-versions: + name: Discover supported Python versions + runs-on: ubuntu-latest + outputs: + versions: ${{ steps.versions.outputs.versions }} + latest: ${{ steps.versions.outputs.latest }} + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Get supported stable Python versions + id: versions + run: python3 .github/scripts/python_versions.py >> "$GITHUB_OUTPUT" + test: name: Test Python ${{ matrix.python-version }} on ${{ matrix.os }} + needs: python-versions runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] - python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + python-version: ${{ fromJSON(needs.python-versions.outputs.versions) }} steps: - name: Checkout code @@ -64,7 +79,7 @@ jobs: python -m pytest tests/ -v --tb=short --cov=himl --cov-report=xml --cov-report=term-missing --cov-fail-under=80 - name: Upload coverage to Codecov - if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.14' + if: matrix.os == 'ubuntu-latest' && matrix.python-version == needs.python-versions.outputs.latest uses: codecov/codecov-action@v7 with: files: ./coverage.xml diff --git a/README.md b/README.md index b19286f3..5794f176 100644 --- a/README.md +++ b/README.md @@ -557,7 +557,7 @@ flake8 . This project uses GitHub Actions for continuous integration. The CI pipeline: -- **Tests**: Runs on Python 3.10-3.14 across Ubuntu and macOS +- **Tests**: Discovers all supported stable Python versions on each run and tests them across Ubuntu and macOS - **Security**: Runs bandit security checks and safety dependency checks - **Build**: Builds and validates the package - **Integration**: Tests CLI tools and package installation @@ -565,6 +565,17 @@ This project uses GitHub Actions for continuous integration. The CI pipeline: All tests must pass and maintain good test coverage before merging PRs. +The test matrix uses the [official Python release-cycle feed](https://peps.python.org/api/release-cycle.json) +that powers the [Python Developer's Guide](https://devguide.python.org/versions/). +Only versions in the `bugfix` or `security` phase are included; prerelease and end-of-life versions are excluded. +Coverage is uploaded from the newest discovered version on Ubuntu. +Discovery retries network failures three times and fails CI if the feed is unavailable or invalid; it never +silently skips the test matrix. New Python releases can therefore expose dependency incompatibilities without +a repository change. + +The package requires Python 3.11 or newer. CI discovery does not automatically change this minimum or the +package's version classifiers; those remain explicit release decisions when upstream support changes. + ### Version Management This project uses [bump-my-version](https://github.com/callowayproject/bump-my-version) for version management. The version is automatically determined from Git tags using setuptools_scm. diff --git a/pyproject.toml b/pyproject.toml index 23a3bffe..9e1b7ca8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,16 +16,16 @@ classifiers = [ "Intended Audience :: Developers", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Systems Administration", "Topic :: Utilities", ] -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ "PyYAML==6.0.3", "deepmerge==2.1.0", @@ -118,7 +118,7 @@ exclude_lines = [ [tool.black] line-length = 120 -target-version = ['py310'] +target-version = ['py311'] include = '\.pyi?$' extend-exclude = ''' /( diff --git a/tests/test_python_versions.py b/tests/test_python_versions.py new file mode 100644 index 00000000..879b9650 --- /dev/null +++ b/tests/test_python_versions.py @@ -0,0 +1,80 @@ +import importlib.util +import io +import json +import runpy +from pathlib import Path +from urllib.error import URLError +from unittest.mock import patch + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / ".github" / "scripts" / "python_versions.py" + + +@pytest.fixture +def discovery(): + spec = importlib.util.spec_from_file_location("python_versions", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_selects_supported_stable_versions_in_numeric_order(discovery): + data = { + "3.9": {"status": "security"}, + "3.16": {"status": "feature"}, + "3.15": {"status": "bugfix"}, + "3.14": {"status": "prerelease"}, + "3.10": {"status": "end-of-life"}, + "3.11": {"status": "security"}, + } + + assert discovery.supported_versions(data) == ["3.9", "3.11", "3.15"] + + +@pytest.mark.parametrize( + "data", + [ + [], + {}, + {"3.10": {"status": "end-of-life"}}, + {"3.15": None}, + {"3.15": {}}, + {"main": {"status": "bugfix"}}, + ], +) +def test_rejects_malformed_or_empty_supported_versions(discovery, data): + with pytest.raises(ValueError): + discovery.supported_versions(data) + + +def test_retries_a_transient_fetch_failure(discovery): + response = io.BytesIO(b'{"3.15": {"status": "bugfix"}}') + with patch.object(discovery, "urlopen", side_effect=[URLError("unavailable"), response]): + with patch.object(discovery.time, "sleep"): + assert discovery.fetch_versions() == ["3.15"] + + +def test_stops_after_three_failed_fetches(discovery): + with patch.object( + discovery, "urlopen", side_effect=[URLError("unavailable")] * 3 + [AssertionError("too many attempts")] + ): + with patch.object(discovery.time, "sleep"): + with pytest.raises(RuntimeError, match="retrieve supported Python versions"): + discovery.fetch_versions() + + +def test_does_not_retry_invalid_json(discovery): + with patch.object(discovery, "urlopen", side_effect=[io.BytesIO(b"not JSON"), AssertionError("unexpected retry")]): + with pytest.raises(ValueError): + discovery.fetch_versions() + + +def test_emits_matrix_and_latest_version_for_github_actions(capsys): + response = io.BytesIO(b'{"3.15": {"status": "bugfix"}, "3.11": {"status": "security"}}') + with patch("urllib.request.urlopen", return_value=response): + runpy.run_path(str(SCRIPT), run_name="__main__") + + outputs = dict(line.split("=", 1) for line in capsys.readouterr().out.splitlines()) + assert json.loads(outputs["versions"]) == ["3.11", "3.15"] + assert outputs["latest"] == "3.15"