Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ module-root = ""

[dependency-groups]
dev = [
"packaging",
"pytest",
"pytest-cov",
"tomli",
]
lint = [
"ruff",
Expand Down
Empty file added scripts/__init__.py
Empty file.
63 changes: 63 additions & 0 deletions scripts/floor_constraints.py
Original file line number Diff line number Diff line change
@@ -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:]))
78 changes: 78 additions & 0 deletions tests/test_floor_constraints.py
Original file line number Diff line number Diff line change
@@ -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
Loading