diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f4482e6b..c9b5ea338 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.12", "3.14"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Harden runner uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 diff --git a/tests/test_python_support_contract.py b/tests/test_python_support_contract.py new file mode 100644 index 000000000..85e0fc6b7 --- /dev/null +++ b/tests/test_python_support_contract.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Contract tests for advertised and permanently tested CPython support.""" + +from __future__ import annotations + +import re +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[1] +_PYPROJECT = _ROOT / "pyproject.toml" +_CI_WORKFLOW = _ROOT / ".github" / "workflows" / "ci.yml" +_REQUIRES_PYTHON_RE = re.compile( + r">=(?P\d+)\.(?P\d+)\Z" +) +_REQUIRES_PYTHON_SETTING_RE = re.compile( + r'^requires-python\s*=\s*"(?P[^"]+)"\s*$', re.MULTILINE +) +_MATRIX_RE = re.compile(r'python-version:\s*\[(?P[^\]]+)\]') +_VERSION_RE = re.compile(r'"(?P\d+)\.(?P\d+)"') +_QUALITY_VERSION_RE = re.compile(r'python-version:\s*"(?P\d+)\.(?P\d+)"') + + +def _advertised_lower_bound() -> tuple[int, int]: + """Return the exact CPython lower bound advertised by project metadata.""" + pyproject = _PYPROJECT.read_text(encoding="utf-8") + project_section = pyproject.split("[project]\n", 1)[1].split("\n[", 1)[0] + setting = _REQUIRES_PYTHON_SETTING_RE.search(project_section) + assert setting is not None, "project metadata must declare Requires-Python" + requires_python = setting.group("specifier") + matched = _REQUIRES_PYTHON_RE.fullmatch(requires_python) + assert matched is not None, "Requires-Python must expose one explicit CPython lower bound" + return int(matched.group("major")), int(matched.group("minor")) + + +def _permanent_ci_minor_versions() -> tuple[str, ...]: + """Return the explicit CPython unit-test matrix from the permanent CI workflow.""" + workflow = _CI_WORKFLOW.read_text(encoding="utf-8") + matched = _MATRIX_RE.search(workflow) + assert matched is not None, "CI must declare an explicit Python minor matrix" + return tuple( + f"{version.group('major')}.{version.group('minor')}" + for version in _VERSION_RE.finditer(matched.group("versions")) + ) + + +def _quality_gate_python_version() -> tuple[int, int]: + """Return the CPython minor that runs coverage, docstring, and package gates.""" + workflow = _CI_WORKFLOW.read_text(encoding="utf-8") + quality_section = workflow.split(" quality-gates:\n", 1)[1].split( + " container-builds:\n", 1 + )[0] + matched = _QUALITY_VERSION_RE.search(quality_section) + assert matched is not None, "quality gates must pin an explicit Python minor" + return int(matched.group("major")), int(matched.group("minor")) + + +def test_requires_python_has_gapless_permanent_ci_evidence() -> None: + """Every currently governed supported minor must have a permanent unit-test lane.""" + lower_major, lower_minor = _advertised_lower_bound() + quality_major, quality_minor = _quality_gate_python_version() + assert lower_major == quality_major == 3 + assert quality_minor >= lower_minor + expected = tuple( + f"{lower_major}.{minor}" for minor in range(lower_minor, quality_minor + 1) + ) + assert expected == ("3.10", "3.11", "3.12", "3.13", "3.14") + assert _permanent_ci_minor_versions() == expected diff --git a/tests/test_workflow_contracts.py b/tests/test_workflow_contracts.py index 2bfe9563f..f6c88867d 100644 --- a/tests/test_workflow_contracts.py +++ b/tests/test_workflow_contracts.py @@ -137,7 +137,7 @@ def test_ci_workflow_enforces_supported_versions_and_quality_gates() -> None: assert "pull_request:" in workflow assert "branches: [main]" in workflow - assert 'python-version: ["3.10", "3.12", "3.14"]' in workflow + assert 'python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]' in workflow assert "uv sync --locked" in workflow assert "uv run ruff check pg_llm_batch tests" in workflow assert "interrogate --fail-under 100 pg_llm_batch" in workflow