diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index 9eae5c6..e0913a6 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -56,7 +56,9 @@ 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__)" pytest: diff --git a/pyproject.toml b/pyproject.toml index d5265cc..1982dea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,8 +51,10 @@ module-root = "" [dependency-groups] dev = [ + "packaging", "pytest", "pytest-cov", + "tomli", ] lint = [ "ruff", diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 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_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