From 7df98b6d82d972ea01a9c8cb7a9e3e162700a7f2 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 25 Sep 2026 19:20:34 +0300 Subject: [PATCH 1/2] ci: fail the floors job when a floor resolves above its declaration --- .github/workflows/_checks.yml | 3 ++ pyproject.toml | 1 + scripts/__init__.py | 0 scripts/check_floors.py | 68 ++++++++++++++++++++++++++++ tests/test_check_floors.py | 85 +++++++++++++++++++++++++++++++++++ 5 files changed, 157 insertions(+) create mode 100644 scripts/__init__.py create mode 100644 scripts/check_floors.py create mode 100644 tests/test_check_floors.py diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index 9eae5c6..bb563b7 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -58,6 +58,9 @@ jobs: - run: uv venv --python ${{ matrix.python-version }} - run: uv pip install --resolution lowest-direct --only-binary PyYAML ".[yaml]" - run: uv run --no-project python -c "import compose2pod, yaml; print(yaml.__version__)" + - run: uv pip install packaging + - name: Confirm each floor resolved as declared + run: .venv/bin/python scripts/check_floors.py compose2pod yaml pytest: runs-on: ubuntu-latest diff --git a/pyproject.toml b/pyproject.toml index d5265cc..20e5eac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ module-root = "" [dependency-groups] dev = [ + "packaging", "pytest", "pytest-cov", ] diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/check_floors.py b/scripts/check_floors.py new file mode 100644 index 0000000..8ab7f60 --- /dev/null +++ b/scripts/check_floors.py @@ -0,0 +1,68 @@ +"""Usage: python scripts/check_floors.py [extra ...].""" + +import collections.abc +import importlib.metadata +import sys + +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name +from packaging.version import Version + + +_FLOOR_OPERATORS: collections.abc.Set[str] = frozenset({">=", "==", "~="}) + + +def _declared_floor(requirement: Requirement) -> Version | None: + floors = [Version(spec.version) for spec in requirement.specifier if spec.operator in _FLOOR_OPERATORS] + return max(floors, default=None) + + +def _applies(requirement: Requirement, extras: collections.abc.Iterable[str], environment: dict[str, str]) -> bool: + if requirement.marker is None: + return True + return any(requirement.marker.evaluate({**environment, "extra": extra}) for extra in ["", *extras]) + + +def floor_mismatches( + requirements: collections.abc.Iterable[str], + extras: collections.abc.Iterable[str], + installed: collections.abc.Mapping[str, str], + environment: collections.abc.Mapping[str, str] | None = None, +) -> list[str]: + extras = list(extras) + mismatches: list[str] = [] + for line in requirements: + requirement = Requirement(line) + if not _applies(requirement, extras, dict(environment or {})): + continue + floor = _declared_floor(requirement) + resolved = installed.get(canonicalize_name(requirement.name)) + if floor is None: + mismatches.append(f"{requirement.name}: declares no floor") + elif resolved is None: + mismatches.append(f"{requirement.name}: not installed") + elif Version(resolved) != floor: + mismatches.append(f"{requirement.name}: declared floor {floor}, resolved {resolved}") + return mismatches + + +def installed_versions() -> dict[str, str]: + return { + canonicalize_name(distribution.metadata["Name"]): distribution.version + for distribution in importlib.metadata.distributions() + } + + +def main(argv: collections.abc.Sequence[str]) -> int: + distribution, *extras = argv + mismatches = floor_mismatches(importlib.metadata.requires(distribution) or [], extras, installed_versions()) + for mismatch in mismatches: + print(mismatch) # noqa: T201 + if mismatches: + return 1 + print(f"every declared floor of {distribution} resolved as declared") # noqa: T201 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_check_floors.py b/tests/test_check_floors.py new file mode 100644 index 0000000..589d945 --- /dev/null +++ b/tests/test_check_floors.py @@ -0,0 +1,85 @@ +import pathlib +import runpy +import sys + +import pytest + +from scripts import check_floors + + +_PYYAML_SPLIT: list[str] = [ + "pyyaml>=6; python_version < '3.12' and extra == 'yaml'", + "pyyaml>=6.0.1; python_version == '3.12' and extra == 'yaml'", + "pyyaml>=6.0.3; python_version >= '3.14' and extra == 'yaml'", +] + + +def test_a_floor_resolved_as_declared_passes() -> None: + assert check_floors.floor_mismatches(["pyyaml>=6"], [], {"pyyaml": "6.0"}) == [] + + +def test_a_floor_resolved_above_its_declaration_is_reported() -> None: + assert check_floors.floor_mismatches(["PyYAML>=6"], [], {"pyyaml": "6.0.1"}) == [ + "PyYAML: declared floor 6, resolved 6.0.1", + ] + + +def test_the_marker_for_the_running_interpreter_picks_the_floor() -> None: + environment = {"python_version": "3.12"} + + assert check_floors.floor_mismatches(_PYYAML_SPLIT, ["yaml"], {"pyyaml": "6.0.1"}, environment) == [] + assert check_floors.floor_mismatches(_PYYAML_SPLIT, ["yaml"], {"pyyaml": "6.0.3"}, environment) == [ + "pyyaml: declared floor 6.0.1, resolved 6.0.3", + ] + + +def test_a_requirement_behind_an_unrequested_extra_is_skipped() -> None: + assert check_floors.floor_mismatches(_PYYAML_SPLIT, [], {}, {"python_version": "3.12"}) == [] + + +def test_an_unbounded_requirement_is_reported() -> None: + assert check_floors.floor_mismatches(["pyyaml<7"], [], {"pyyaml": "6.0"}) == ["pyyaml: declares no floor"] + + +def test_an_exact_pin_is_its_own_floor() -> None: + assert check_floors.floor_mismatches(["pyyaml==6.0.1"], [], {"pyyaml": "6.0.1"}) == [] + + +def test_a_required_distribution_that_is_not_installed_is_reported() -> None: + assert check_floors.floor_mismatches(["pyyaml>=6"], [], {}) == ["pyyaml: not installed"] + + +def test_main_reports_mismatches_and_fails( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(check_floors.importlib.metadata, "requires", lambda _: ["pyyaml>=6; extra == 'yaml'"]) + monkeypatch.setattr(check_floors, "installed_versions", lambda: {"pyyaml": "6.0.3"}) + + assert check_floors.main(["compose2pod", "yaml"]) == 1 + assert capsys.readouterr().out == "pyyaml: declared floor 6, resolved 6.0.3\n" + + +def test_main_passes_when_every_floor_resolved( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(check_floors.importlib.metadata, "requires", lambda _: ["pyyaml>=6; extra == 'yaml'"]) + monkeypatch.setattr(check_floors, "installed_versions", lambda: {"pyyaml": "6.0"}) + + assert check_floors.main(["compose2pod", "yaml"]) == 0 + assert capsys.readouterr().out == "every declared floor of compose2pod resolved as declared\n" + + +def test_installed_versions_reads_the_running_environment() -> None: + assert check_floors.installed_versions()["pytest"] + + +def test_running_the_script_exits_with_the_verdict(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(check_floors.importlib.metadata, "requires", lambda _: []) + monkeypatch.setattr(sys, "argv", ["check_floors.py", "compose2pod"]) + + with pytest.raises(SystemExit) as exit_info: + runpy.run_path(str(pathlib.Path(check_floors.__file__)), run_name="__main__") + + assert exit_info.value.code == 0 From ad78755b62660dde2b757a5ae2846715fe45b0e6 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 25 Sep 2026 20:36:48 +0300 Subject: [PATCH 2/2] ci: pin the declared floors exactly instead of reading the resolution back --- .github/workflows/_checks.yml | 7 ++- pyproject.toml | 1 + scripts/check_floors.py | 68 -------------------------- scripts/floor_constraints.py | 63 ++++++++++++++++++++++++ tests/test_check_floors.py | 85 --------------------------------- tests/test_floor_constraints.py | 78 ++++++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 157 deletions(-) delete mode 100644 scripts/check_floors.py create mode 100644 scripts/floor_constraints.py delete mode 100644 tests/test_check_floors.py create mode 100644 tests/test_floor_constraints.py diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index bb563b7..e0913a6 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -56,11 +56,10 @@ jobs: cache-dependency-glob: "**/pyproject.toml" - run: uv python install ${{ matrix.python-version }} - run: uv venv --python ${{ matrix.python-version }} - - run: uv pip install --resolution lowest-direct --only-binary PyYAML ".[yaml]" + - name: Pin every declared floor + run: uv run --no-project --with packaging --with tomli python scripts/floor_constraints.py > floors.txt + - run: uv pip install --resolution lowest-direct --constraints floors.txt --only-binary PyYAML ".[yaml]" - run: uv run --no-project python -c "import compose2pod, yaml; print(yaml.__version__)" - - run: uv pip install packaging - - name: Confirm each floor resolved as declared - run: .venv/bin/python scripts/check_floors.py compose2pod yaml pytest: runs-on: ubuntu-latest diff --git a/pyproject.toml b/pyproject.toml index 20e5eac..1982dea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dev = [ "packaging", "pytest", "pytest-cov", + "tomli", ] lint = [ "ruff", diff --git a/scripts/check_floors.py b/scripts/check_floors.py deleted file mode 100644 index 8ab7f60..0000000 --- a/scripts/check_floors.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Usage: python scripts/check_floors.py [extra ...].""" - -import collections.abc -import importlib.metadata -import sys - -from packaging.requirements import Requirement -from packaging.utils import canonicalize_name -from packaging.version import Version - - -_FLOOR_OPERATORS: collections.abc.Set[str] = frozenset({">=", "==", "~="}) - - -def _declared_floor(requirement: Requirement) -> Version | None: - floors = [Version(spec.version) for spec in requirement.specifier if spec.operator in _FLOOR_OPERATORS] - return max(floors, default=None) - - -def _applies(requirement: Requirement, extras: collections.abc.Iterable[str], environment: dict[str, str]) -> bool: - if requirement.marker is None: - return True - return any(requirement.marker.evaluate({**environment, "extra": extra}) for extra in ["", *extras]) - - -def floor_mismatches( - requirements: collections.abc.Iterable[str], - extras: collections.abc.Iterable[str], - installed: collections.abc.Mapping[str, str], - environment: collections.abc.Mapping[str, str] | None = None, -) -> list[str]: - extras = list(extras) - mismatches: list[str] = [] - for line in requirements: - requirement = Requirement(line) - if not _applies(requirement, extras, dict(environment or {})): - continue - floor = _declared_floor(requirement) - resolved = installed.get(canonicalize_name(requirement.name)) - if floor is None: - mismatches.append(f"{requirement.name}: declares no floor") - elif resolved is None: - mismatches.append(f"{requirement.name}: not installed") - elif Version(resolved) != floor: - mismatches.append(f"{requirement.name}: declared floor {floor}, resolved {resolved}") - return mismatches - - -def installed_versions() -> dict[str, str]: - return { - canonicalize_name(distribution.metadata["Name"]): distribution.version - for distribution in importlib.metadata.distributions() - } - - -def main(argv: collections.abc.Sequence[str]) -> int: - distribution, *extras = argv - mismatches = floor_mismatches(importlib.metadata.requires(distribution) or [], extras, installed_versions()) - for mismatch in mismatches: - print(mismatch) # noqa: T201 - if mismatches: - return 1 - print(f"every declared floor of {distribution} resolved as declared") # noqa: T201 - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/scripts/floor_constraints.py b/scripts/floor_constraints.py new file mode 100644 index 0000000..8287fbe --- /dev/null +++ b/scripts/floor_constraints.py @@ -0,0 +1,63 @@ +"""Usage: python scripts/floor_constraints.py [pyproject.toml] > floors.txt.""" + +import collections.abc +import pathlib +import sys +import typing + +import tomli +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name +from packaging.version import Version + + +_FLOOR_OPERATORS: collections.abc.Set[str] = frozenset({">=", "==", "~="}) + + +class UnboundedError(ValueError): + pass + + +def _declared_floor(requirement: Requirement) -> Version | None: + floors = [Version(spec.version) for spec in requirement.specifier if spec.operator in _FLOOR_OPERATORS] + return max(floors, default=None) + + +def declared_requirements(project: collections.abc.Mapping[str, typing.Any]) -> list[str]: + requirements = list(project.get("dependencies", [])) + for extra_requirements in project.get("optional-dependencies", {}).values(): + requirements.extend(extra_requirements) + return requirements + + +def floor_constraints(requirements: collections.abc.Iterable[str]) -> list[str]: + floors: dict[tuple[str, str], tuple[str, Version]] = {} + unbounded: list[str] = [] + for line in requirements: + requirement = Requirement(line) + floor = _declared_floor(requirement) + if floor is None: + unbounded.append(requirement.name) + continue + key = (canonicalize_name(requirement.name), str(requirement.marker or "")) + name, known = floors.get(key, (requirement.name, floor)) + floors[key] = (name, max(known, floor)) + if unbounded: + raise UnboundedError("declares no floor: " + ", ".join(unbounded)) + return [f"{name}=={floor}" + (f"; {marker}" if marker else "") for (_, marker), (name, floor) in floors.items()] + + +def main(argv: collections.abc.Sequence[str]) -> int: + path = pathlib.Path(argv[0] if argv else "pyproject.toml") + project = tomli.loads(path.read_text(encoding="utf-8")).get("project", {}) + try: + constraints = floor_constraints(declared_requirements(project)) + except UnboundedError as error: + print(error, file=sys.stderr) # noqa: T201 + return 1 + print("\n".join(constraints)) # noqa: T201 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_check_floors.py b/tests/test_check_floors.py deleted file mode 100644 index 589d945..0000000 --- a/tests/test_check_floors.py +++ /dev/null @@ -1,85 +0,0 @@ -import pathlib -import runpy -import sys - -import pytest - -from scripts import check_floors - - -_PYYAML_SPLIT: list[str] = [ - "pyyaml>=6; python_version < '3.12' and extra == 'yaml'", - "pyyaml>=6.0.1; python_version == '3.12' and extra == 'yaml'", - "pyyaml>=6.0.3; python_version >= '3.14' and extra == 'yaml'", -] - - -def test_a_floor_resolved_as_declared_passes() -> None: - assert check_floors.floor_mismatches(["pyyaml>=6"], [], {"pyyaml": "6.0"}) == [] - - -def test_a_floor_resolved_above_its_declaration_is_reported() -> None: - assert check_floors.floor_mismatches(["PyYAML>=6"], [], {"pyyaml": "6.0.1"}) == [ - "PyYAML: declared floor 6, resolved 6.0.1", - ] - - -def test_the_marker_for_the_running_interpreter_picks_the_floor() -> None: - environment = {"python_version": "3.12"} - - assert check_floors.floor_mismatches(_PYYAML_SPLIT, ["yaml"], {"pyyaml": "6.0.1"}, environment) == [] - assert check_floors.floor_mismatches(_PYYAML_SPLIT, ["yaml"], {"pyyaml": "6.0.3"}, environment) == [ - "pyyaml: declared floor 6.0.1, resolved 6.0.3", - ] - - -def test_a_requirement_behind_an_unrequested_extra_is_skipped() -> None: - assert check_floors.floor_mismatches(_PYYAML_SPLIT, [], {}, {"python_version": "3.12"}) == [] - - -def test_an_unbounded_requirement_is_reported() -> None: - assert check_floors.floor_mismatches(["pyyaml<7"], [], {"pyyaml": "6.0"}) == ["pyyaml: declares no floor"] - - -def test_an_exact_pin_is_its_own_floor() -> None: - assert check_floors.floor_mismatches(["pyyaml==6.0.1"], [], {"pyyaml": "6.0.1"}) == [] - - -def test_a_required_distribution_that_is_not_installed_is_reported() -> None: - assert check_floors.floor_mismatches(["pyyaml>=6"], [], {}) == ["pyyaml: not installed"] - - -def test_main_reports_mismatches_and_fails( - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - monkeypatch.setattr(check_floors.importlib.metadata, "requires", lambda _: ["pyyaml>=6; extra == 'yaml'"]) - monkeypatch.setattr(check_floors, "installed_versions", lambda: {"pyyaml": "6.0.3"}) - - assert check_floors.main(["compose2pod", "yaml"]) == 1 - assert capsys.readouterr().out == "pyyaml: declared floor 6, resolved 6.0.3\n" - - -def test_main_passes_when_every_floor_resolved( - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - monkeypatch.setattr(check_floors.importlib.metadata, "requires", lambda _: ["pyyaml>=6; extra == 'yaml'"]) - monkeypatch.setattr(check_floors, "installed_versions", lambda: {"pyyaml": "6.0"}) - - assert check_floors.main(["compose2pod", "yaml"]) == 0 - assert capsys.readouterr().out == "every declared floor of compose2pod resolved as declared\n" - - -def test_installed_versions_reads_the_running_environment() -> None: - assert check_floors.installed_versions()["pytest"] - - -def test_running_the_script_exits_with_the_verdict(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(check_floors.importlib.metadata, "requires", lambda _: []) - monkeypatch.setattr(sys, "argv", ["check_floors.py", "compose2pod"]) - - with pytest.raises(SystemExit) as exit_info: - runpy.run_path(str(pathlib.Path(check_floors.__file__)), run_name="__main__") - - assert exit_info.value.code == 0 diff --git a/tests/test_floor_constraints.py b/tests/test_floor_constraints.py new file mode 100644 index 0000000..bacfe4d --- /dev/null +++ b/tests/test_floor_constraints.py @@ -0,0 +1,78 @@ +import pathlib +import runpy +import sys + +import pytest + +from scripts import floor_constraints + + +def test_a_lower_bound_becomes_an_exact_pin() -> None: + assert floor_constraints.floor_constraints(["PyYAML>=6,<7"]) == ["PyYAML==6"] + + +def test_a_marker_is_kept_so_the_resolver_evaluates_it() -> None: + assert floor_constraints.floor_constraints(["PyYAML>=6.0.1; python_version == '3.12'"]) == [ + 'PyYAML==6.0.1; python_version == "3.12"', + ] + + +def test_compatible_release_and_exact_pins_are_floors() -> None: + assert floor_constraints.floor_constraints(["a~=1.4", "b==2.0.1"]) == ["a==1.4", "b==2.0.1"] + + +def test_the_same_requirement_declared_twice_pins_the_higher_floor() -> None: + assert floor_constraints.floor_constraints(["a>=1", "A>=1.2"]) == ["a==1.2"] + + +def test_differently_marked_declarations_each_keep_their_floor() -> None: + assert floor_constraints.floor_constraints( + ["a>=1; python_version < '3.12'", "a>=2; python_version >= '3.12'"], + ) == ['a==1; python_version < "3.12"', 'a==2; python_version >= "3.12"'] + + +def test_an_unbounded_requirement_is_refused() -> None: + with pytest.raises(floor_constraints.UnboundedError, match=r"^declares no floor: a, b$"): + floor_constraints.floor_constraints(["a<2", "b", "c>=1"]) + + +def test_every_extra_contributes_its_requirements() -> None: + project = {"dependencies": ["a>=1"], "optional-dependencies": {"x": ["b>=2"], "y": ["c>=3"]}} + + assert floor_constraints.declared_requirements(project) == ["a>=1", "b>=2", "c>=3"] + + +def test_a_project_without_dependencies_declares_none() -> None: + assert floor_constraints.declared_requirements({}) == [] + + +def test_main_prints_this_repos_constraints(capsys: pytest.CaptureFixture[str]) -> None: + assert floor_constraints.main([str(pathlib.Path(__file__).parent.parent / "pyproject.toml")]) == 0 + assert capsys.readouterr().out.splitlines() == [ + 'PyYAML==6; python_version < "3.12"', + 'PyYAML==6.0.1; python_version == "3.12"', + 'PyYAML==6.0.2; python_version == "3.13"', + 'PyYAML==6.0.3; python_version >= "3.14"', + ] + + +def test_main_fails_on_an_unbounded_requirement( + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\ndependencies = ["a"]\n', encoding="utf-8") + + assert floor_constraints.main([str(pyproject)]) == 1 + assert capsys.readouterr().err == "declares no floor: a\n" + + +def test_running_the_script_exits_with_the_verdict(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\ndependencies = ["a>=1"]\n', encoding="utf-8") + monkeypatch.setattr(sys, "argv", ["floor_constraints.py", str(pyproject)]) + + with pytest.raises(SystemExit) as exit_info: + runpy.run_path(str(pathlib.Path(floor_constraints.__file__)), run_name="__main__") + + assert exit_info.value.code == 0