Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/scripts/python_versions.py
Original file line number Diff line number Diff line change
@@ -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]}")
19 changes: 17 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,14 +557,25 @@ 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
- **Coverage**: Reports test coverage to Codecov

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.
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -118,7 +118,7 @@ exclude_lines = [

[tool.black]
line-length = 120
target-version = ['py310']
target-version = ['py311']
include = '\.pyi?$'
extend-exclude = '''
/(
Expand Down
80 changes: 80 additions & 0 deletions tests/test_python_versions.py
Original file line number Diff line number Diff line change
@@ -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"
Loading