Skip to content

Commit 987a564

Browse files
committed
feat: combine and validator constraints
Fromager's `Constraints` class now supports combining multiple constraints and performs stricter validation of constraints. The class now refuses constraints without a version specifier, extras, or an URL. It packaging's new `SpecifierSet.is_unsatisfiable()` feature to detect constraints that are not satisfiable. Example: The constraints `foo>=1.0`, `foo<2.0`, and `foo!=1.1.0` are combined into to `foo<2.0,>=1.0,!=1.1.0`. A combination of `foo<1.0` and `foo>=2.0` would result in a `InvalidConstraintError` instead. See: #1096 Signed-off-by: Christian Heimes <cheimes@redhat.com>
1 parent 5fd5d20 commit 987a564

3 files changed

Lines changed: 109 additions & 59 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ dependencies = [
3434
"click>=8.1.7",
3535
"elfdeps>=0.2.0",
3636
"license-expression",
37-
"packaging",
37+
"packaging>=26.1",
3838
"packageurl-python",
3939
"psutil",
4040
"pydantic>=2.12",

src/fromager/constraints.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
logger = logging.getLogger(__name__)
1212

1313

14+
class InvalidConstraintError(ValueError):
15+
pass
16+
17+
1418
class Constraints:
1519
def __init__(self) -> None:
1620
# mapping of canonical names to requirements
@@ -20,23 +24,50 @@ def __init__(self) -> None:
2024
def __iter__(self) -> Generator[NormalizedName, None, None]:
2125
yield from self._data
2226

27+
def __len__(self) -> int:
28+
return len(self._data)
29+
2330
def add_constraint(self, unparsed: str) -> None:
24-
"""Add new constraint, must not conflict with any existing constraints"""
31+
"""Add new constraint, must not conflict with any existing constraints
32+
33+
.. versionchanged: 0.83.0
34+
Non-conflicting constraints are now combined. Constraints with
35+
conflicts now raise :exc:`InvalidConstraintError`. Inputs without a
36+
version specifier or with extras or url are also refused.
37+
"""
2538
req = Requirement(unparsed)
2639
canon_name = canonicalize_name(req.name)
2740
previous = self._data.get(canon_name)
2841

42+
# validator properties: must have a specifier, must not have extras or URL
43+
if req.extras:
44+
raise InvalidConstraintError(f"Constraint {unparsed!r} has extras")
45+
if req.url:
46+
raise InvalidConstraintError(f"Constraint {unparsed!r} has an url")
47+
if not req.specifier:
48+
raise InvalidConstraintError(f"Constraint {unparsed!r} has no specifiers")
49+
50+
# verify that incoming constraint is okay by itself
51+
if req.specifier.is_unsatisfiable():
52+
raise InvalidConstraintError(f"Constraint {unparsed!r} is unsatisfiable")
53+
2954
if not requirements_file.evaluate_marker(req, req):
3055
logger.debug(f"Constraint {req} does not match environment")
3156
return
3257

3358
if previous is not None:
34-
raise KeyError(
35-
f"{canon_name}: new constraint '{req}' conflicts with '{previous}'"
36-
)
37-
if requirements_file.evaluate_marker(req, req):
59+
logger.debug("combining constraints %s and %s", previous, req)
60+
new_specifier = req.specifier & previous.specifier
61+
if new_specifier.is_unsatisfiable():
62+
raise InvalidConstraintError(
63+
f"Combined specifier '{new_specifier}' is not satisfiable "
64+
f"(existing: {previous}, new: {req})"
65+
)
66+
req.specifier = new_specifier
67+
else:
3868
logger.debug(f"adding constraint {req}")
39-
self._data[canon_name] = req
69+
70+
self._data[canon_name] = req
4071

4172
def load_constraints_file(self, constraints_file: str | pathlib.Path) -> None:
4273
"""Load constraints from a constraints file"""

tests/test_constraints.py

Lines changed: 71 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
11
import pathlib
2-
import typing
2+
from unittest import mock
33

44
import pytest
55
from packaging import markers
66
from packaging.requirements import Requirement
77
from packaging.version import Version
88

9-
from fromager import constraints
9+
from fromager.constraints import Constraints, InvalidConstraintError
1010

1111

1212
def test_constraint_is_satisfied_by() -> None:
13-
c = constraints.Constraints()
13+
c = Constraints()
1414
c.add_constraint("foo<=1.1")
1515
assert c.is_satisfied_by("foo", Version("1.1"))
1616
assert c.is_satisfied_by("foo", Version("1.0"))
1717
assert c.is_satisfied_by("bar", Version("2.0"))
1818

1919

2020
def test_constraint_canonical_name() -> None:
21-
c = constraints.Constraints()
21+
c = Constraints()
2222
c.add_constraint("flash_attn<=1.1")
2323
assert c.is_satisfied_by("flash_attn", Version("1.1"))
2424
assert c.is_satisfied_by("flash-attn", Version("1.1"))
@@ -27,78 +27,72 @@ def test_constraint_canonical_name() -> None:
2727

2828

2929
def test_constraint_not_is_satisfied_by() -> None:
30-
c = constraints.Constraints()
30+
c = Constraints()
3131
c.add_constraint("foo<=1.1")
3232
c.add_constraint("bar>=2.0")
3333
assert not c.is_satisfied_by("foo", Version("1.2"))
3434
assert not c.is_satisfied_by("foo", Version("2.0"))
3535
assert not c.is_satisfied_by("bar", Version("1.0"))
3636

3737

38+
@mock.patch("platform.machine", mock.Mock(return_value="atari"))
3839
def test_add_constraint_conflict() -> None:
39-
c = constraints.Constraints()
40+
assert markers.default_environment()["platform_machine"] == "atari"
41+
42+
c = Constraints()
4043
c.add_constraint("foo<=1.1")
4144
c.add_constraint("flit_core==2.0rc3")
4245

43-
# Exact duplicate should raise error (same package, same marker)
44-
with pytest.raises(KeyError):
45-
c.add_constraint("foo<=1.1")
46-
47-
# Different version, same marker (no marker) should raise error
48-
with pytest.raises(KeyError):
46+
# Conflicting version, same marker (no marker) should raise error
47+
with pytest.raises(InvalidConstraintError):
4948
c.add_constraint("foo>1.1")
5049

51-
# Different version for flit_core should raise error
52-
with pytest.raises(KeyError):
50+
# Conflicting version for flit_core should raise error
51+
with pytest.raises(InvalidConstraintError):
5352
c.add_constraint("flit_core>2.0.0")
5453

5554
# Normalized name conflict should raise error
56-
with pytest.raises(KeyError):
55+
with pytest.raises(InvalidConstraintError):
5756
c.add_constraint("flit-core>2.0.0")
5857

59-
# Different, but equivalent markers should raise KeyError
60-
with pytest.raises(KeyError):
61-
# arm64 -> macos; aarch64 -> linux
62-
for arch in ["x86_64", "arm64", "aarch64"]:
63-
c.add_constraint(
64-
f"bar==1.0; python_version >= '3.11' and platform_machine == '{arch}'"
65-
)
66-
c.add_constraint(
67-
f"bar==1.1; platform_machine == '{arch}' and python_version >= '3.11'"
68-
)
58+
# Constraints for other platforms are ignored
59+
c.add_constraint(
60+
"bar==1.0; python_version >= '3.11' and platform_machine == 'amiga'"
61+
)
62+
assert c.get_constraint("bar") is None
63+
64+
c.add_constraint(
65+
"bar==1.0; python_version >= '3.11' and platform_machine == 'atari'"
66+
)
67+
# Make sure correct constraint is added
68+
constraint = c.get_constraint("bar")
69+
assert constraint
70+
assert constraint.name == "bar"
71+
assert constraint.specifier == "==1.0"
72+
assert constraint.marker == markers.Marker(
73+
'python_version >= "3.11" and platform_machine == "atari"'
74+
)
75+
76+
# Different, but equivalent markers should raise error
77+
with pytest.raises(InvalidConstraintError):
78+
c.add_constraint(
79+
"bar==1.1; platform_machine == 'atari' and python_version >= '3.11'"
80+
)
6981

7082
# Same package with different markers should NOT raise error
71-
c.add_constraint("baz==1.0; platform_machine != 'ppc64le'")
72-
c.add_constraint("baz==1.1; platform_machine == 'ppc64le'")
83+
c.add_constraint("baz==1.0; platform_machine != 'amiga'")
84+
c.add_constraint("baz==1.1; platform_machine == 'amiga'")
7385

7486
# But same package with same marker should raise error
75-
with pytest.raises(KeyError):
76-
c.add_constraint("foo==1.2; platform_machine != 'ppc64le'")
87+
with pytest.raises(InvalidConstraintError):
88+
c.add_constraint("foo==1.2; platform_machine != 'amiga'")
7789

7890
# Verify multiple constraints for same package are stored
79-
assert len(c._data) == 4 # flit_core, foo, bar, and baz
80-
81-
# Make sure correct constraint is added
82-
env = typing.cast(dict[str, str], markers.default_environment())
83-
constraint = c.get_constraint("bar")
84-
85-
if env.get("platform_machine") == "x86_64" and constraint is not None:
86-
assert constraint.name == "bar"
87-
assert constraint.specifier == "==1.0"
88-
assert constraint.marker == markers.Marker(
89-
'python_version >= "3.11" and platform_machine == "x86_64"'
90-
)
91-
92-
if env.get("platform_machine") == "arm64" and constraint is not None:
93-
assert constraint.name == "bar"
94-
assert constraint.specifier == "==1.0"
95-
assert constraint.marker == markers.Marker(
96-
'python_version >= "3.11" and platform_machine == "arm64"'
97-
)
91+
assert len(c) == 4 # flit_core, foo, bar, and baz
9892

9993

10094
def test_allow_prerelease() -> None:
101-
c = constraints.Constraints()
95+
c = Constraints()
10296
c.add_constraint("foo>=1.1")
10397
assert not c.allow_prerelease("foo")
10498
c.add_constraint("bar>=1.1a0")
@@ -109,15 +103,40 @@ def test_allow_prerelease() -> None:
109103

110104
def test_load_non_existant_constraints_file(tmp_path: pathlib.Path) -> None:
111105
non_existant_file = tmp_path / "non_existant.txt"
112-
c = constraints.Constraints()
106+
c = Constraints()
113107
with pytest.raises(FileNotFoundError):
114108
c.load_constraints_file(non_existant_file)
115109

116110

117111
def test_load_constraints_file(tmp_path: pathlib.Path) -> None:
118112
constraint_file = tmp_path / "constraint.txt"
119-
constraint_file.write_text("egg\ntorch==3.1.0 # comment\n")
120-
c = constraints.Constraints()
113+
constraint_file.write_text("egg==1.0\ntorch==3.1.0 # comment\n")
114+
c = Constraints()
121115
c.load_constraints_file(constraint_file)
122116
assert list(c) == ["egg", "torch"] # type: ignore
123117
assert c.get_constraint("torch") == Requirement("torch==3.1.0")
118+
119+
120+
def test_invalid_constraints() -> None:
121+
c = Constraints()
122+
with pytest.raises(InvalidConstraintError, match=r".*no specifier"):
123+
c.add_constraint("foo")
124+
with pytest.raises(InvalidConstraintError, match=r".*has extras"):
125+
c.add_constraint("foo[extra]>=1.0")
126+
with pytest.raises(InvalidConstraintError, match=r".*has an url"):
127+
c.add_constraint("foo@https://foo.test")
128+
129+
130+
def test_unsatisfiable() -> None:
131+
c = Constraints()
132+
with pytest.raises(InvalidConstraintError):
133+
c.add_constraint("foo<1.0,>2.0")
134+
135+
136+
def test_combine_constraints() -> None:
137+
c = Constraints()
138+
c.add_constraint("foo>=1.0")
139+
c.add_constraint("foo<2.0")
140+
assert c.get_constraint("foo") == Requirement("foo<2.0,>=1.0")
141+
c.add_constraint("foo!=1.1.0")
142+
assert c.get_constraint("foo") == Requirement("foo<2.0,>=1.0,!=1.1.0")

0 commit comments

Comments
 (0)