From 7e8fc60e541461af3103e816c376a52dcbb4bb7e Mon Sep 17 00:00:00 2001 From: SackOfHacks Date: Sun, 13 Sep 2026 11:37:57 -0600 Subject: [PATCH] Enforce the requirements/pyproject invariant, and stop pointless floor bumps requirements.txt has said "Keep in sync with pyproject.toml [project.dependencies]" since it was written, and nothing enforced it. Dependabot then proposed raising tomli>=2.0.1 to >=2.4.1 in requirements.txt alone (#54), which would have left the two manifests disagreeing on the first line it touched -- silently, since 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. TestRequirementsMatchPyproject compares the two dependency sets with whitespace and quote noise normalised away, so the environment marker on tomli does not produce a false difference. Verified it bites by simulating #54: it names tomli>=2.4.1 as present only in requirements.txt. A second case asserts the parse is non-empty, so an empty read cannot make the comparison vacuous. The second half is the reason that PR existed at all. Dependabot's pip default versioning-strategy is "increase", which raises a `>=` floor on every upstream release whether or not anything needs it -- proposing churn with no security driver, and dropping support for environments the code runs on perfectly well. increase-if-necessary moves a floor only when the existing constraint genuinely excludes the new version. Security updates still raise floors when they have to. --- .github/dependabot.yml | 7 ++++++ tests/test_packaging.py | 50 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) 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