diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cb545fc..1afb17c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,6 +11,13 @@ updates: open-pull-requests-limit: 5 commit-message: prefix: "deps" + # Only move a floor when the current constraint genuinely excludes the new + # version. The default ("increase") raises `>=` floors on every release, + # which for this project means proposing a bump with no security driver, + # touching requirements.txt alone, and dropping support for older + # environments that the code runs on perfectly well. Security updates still + # raise a floor when they have to. + versioning-strategy: increase-if-necessary # CI pins actions by commit SHA; without this the pins never move. - package-ecosystem: github-actions diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 613beb6..b7322e5 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -111,3 +111,53 @@ def test_declared_packages_cover_the_tree(self) -> None: def test_reporting_is_declared(self) -> None: declared = set(_pyproject()["tool"]["setuptools"]["packages"]) assert "pcapper.reporting" in declared + + +class TestRequirementsMatchPyproject: + """``requirements.txt`` and ``pyproject.toml`` must list the same runtime + dependencies, with the same specifiers. + + The header of ``requirements.txt`` has always said "Keep in sync with + pyproject.toml [project.dependencies]", and nothing enforced it. A + Dependabot PR then proposed raising ``tomli>=2.0.1`` to ``>=2.4.1`` in + ``requirements.txt`` alone, which would have left the two disagreeing on + the first line it touched -- and silently, because installs resolve from + ``pyproject.toml``, so the requirements floor has no effect on what a user + actually gets. A manual-sync comment is not a control; this is. + """ + + @staticmethod + def _normalise(spec: str) -> str: + """Compare requirement strings without whitespace or quote noise. + + ``tomli>=2.0.1; python_version < "3.11"`` and the pyproject spelling of + the same line differ only in spacing, so neither survives into the + comparison. + """ + return re.sub(r"\s+", "", spec).replace("'", '"') + + def _requirements(self) -> set[str]: + out = set() + text = (REPO_ROOT / "requirements.txt").read_text(encoding="utf-8") + for line in text.splitlines(): + line = line.split("#", 1)[0].strip() + if line: + out.add(self._normalise(line)) + return out + + def test_the_two_manifests_agree(self) -> None: + declared = {self._normalise(d) for d in _pyproject()["project"]["dependencies"]} + listed = self._requirements() + only_in_requirements = listed - declared + only_in_pyproject = declared - listed + assert not (only_in_requirements or only_in_pyproject), ( + "requirements.txt and pyproject.toml have drifted.\n" + f" only in requirements.txt: {sorted(only_in_requirements)}\n" + f" only in pyproject.toml: {sorted(only_in_pyproject)}\n" + "Both files must be updated together -- note that installs resolve " + "from pyproject.toml, so a requirements-only change has no effect." + ) + + def test_requirements_is_not_empty(self) -> None: + """Guard the guard: an empty parse would make the test above vacuous.""" + assert len(self._requirements()) >= 5