# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Guard the CI test matrix against drifting away from the project metadata.
`repomatic` generates the GitHub Actions test matrix from a hard-coded set of
Python versions, independently of the project's `requires-python`. Nothing
forces the two to agree, so a `requires-python` bump that forgets the matrix,
or a `repomatic` release that shifts its floor, would silently leave the
declared floor untested. These tests turn that drift into a failing check, as
recommended by [repomatic's test-matrix guide](https://kdeldycke.github.io/repomatic/test-matrix.html).
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import pytest
from extra_platforms.pytest import skip_hermetic_build
from .conftest import PROJECT_ROOT, tomllib
pytestmark = pytest.mark.once
"""These read `pyproject.toml` and the generated matrix, so they answer the same
on every OS and Python: the `once-tests` job runs them on a single runner."""
PYTHON_CLASSIFIER_PREFIX = "Programming Language :: Python :: "
"""Prefix of the trove classifiers enumerating supported Python versions."""
def _load_pyproject() -> dict:
"""Parse the project's `pyproject.toml` into a dictionary."""
path = PROJECT_ROOT.joinpath("pyproject.toml")
content = path.read_text(encoding="UTF-8")
return tomllib.loads(content) # type: ignore[no-any-return]
def _major_minor(version: str) -> tuple[int, int]:
"""Extract the `(major, minor)` tuple from a Python version string.
Tolerates a non-numeric suffix like the `t` of free-threaded `3.14t`.
"""
match = re.match(r"(\d+)\.(\d+)", version)
assert match, f"Cannot parse Python version {version!r}."
return int(match.group(1)), int(match.group(2))
def _python_floor_of(matrix: dict) -> tuple[int, int]:
"""Return the lowest `(major, minor)` Python version a matrix tests."""
return min(_major_minor(version) for version in matrix["python-version"])
def _requires_python_floor(pyproject: dict) -> tuple[int, int]:
"""Return the `(major, minor)` lower bound of `requires-python`."""
requirement = pyproject["project"]["requires-python"]
match = re.search(r">=\s*(\d+)\.(\d+)", requirement)
assert match, f"No >= lower bound in requires-python {requirement!r}."
return int(match.group(1)), int(match.group(2))
def _pinned_repomatic() -> str:
"""Read the `repomatic` version pinned by the test-matrix workflow.
Keeps this guard in lock-step with the exact `repomatic` release CI runs
to generate the matrix, rather than resolving a possibly-different latest.
"""
workflow = PROJECT_ROOT.joinpath(".github", "workflows", "tests.yaml")
match = re.search(
r"repomatic==(\d+\.\d+\.\d+)", workflow.read_text(encoding="UTF-8")
)
assert match, "No pinned repomatic version found in tests.yaml."
return match.group(1)
[docs]
def test_requires_python_floor_in_classifiers():
"""The `requires-python` floor equals the lowest declared classifier.
A network-free companion to the matrix check below: it catches a
`requires-python` bump that forgets to update the trove classifiers, or
the reverse, the most common metadata drift.
"""
pyproject = _load_pyproject()
classifier_versions = sorted(
_major_minor(match.group(1))
for classifier in pyproject["project"]["classifiers"]
if (
match := re.fullmatch(
re.escape(PYTHON_CLASSIFIER_PREFIX) + r"(\d+\.\d+)", classifier
)
)
)
assert classifier_versions, "No versioned Python classifiers found."
assert classifier_versions[0] == _requires_python_floor(pyproject)
[docs]
@skip_hermetic_build
def test_matrix_python_floor_matches_requires_python():
"""The generated matrix's lowest Python equals the `requires-python` floor.
Drives the real `repomatic` CLI, the same one CI invokes, so the assertion
reflects what is actually tested rather than a re-derivation. Skips, instead
of failing, when the tool cannot be fetched (offline or sandboxed builds).
The `--exclude-newer-package` escape hatch is the documented per-package
exemption every `uvx` call carrying this pin needs. The workflow sets
`UV_EXCLUDE_NEWER` for all of its jobs, so a pin just moved to a release
published inside that window is unresolvable without it, and uvx exits
non-zero. That lands in the `skip` branch below rather than a failure, which
is the worse outcome: this guard would go quiet for a full cooldown period
after every upstream bump, exactly when the matrix is most likely to have
drifted from `requires-python`.
"""
uvx = shutil.which("uvx")
if uvx is None:
pytest.skip("uvx is required to generate the repomatic test matrix.")
try:
result = subprocess.run(
(
uvx,
"--no-progress",
"--exclude-newer-package",
"repomatic=P0D",
f"repomatic=={_pinned_repomatic()}",
"metadata",
"--format",
"json",
"test_matrix",
"test_matrix_pr",
),
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
encoding="UTF-8",
timeout=300,
check=True,
)
except (OSError, subprocess.SubprocessError) as error:
pytest.skip(f"Could not run repomatic to read the matrix: {error}")
payload = json.loads(result.stdout)
floor = _requires_python_floor(_load_pyproject())
for matrix_id in ("test_matrix", "test_matrix_pr"):
tested_floor = _python_floor_of(payload[matrix_id])
assert tested_floor == floor, (
f"{matrix_id} floor {tested_floor} differs from requires-python {floor}."
)