Skip to content

Commit e1be8fe

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 1e3d3ff commit e1be8fe

3 files changed

Lines changed: 76 additions & 27 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: 34 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
@@ -21,22 +25,46 @@ def __iter__(self) -> Generator[NormalizedName, None, None]:
2125
yield from self._data
2226

2327
def add_constraint(self, unparsed: str) -> None:
24-
"""Add new constraint, must not conflict with any existing constraints"""
28+
"""Add new constraint, must not conflict with any existing constraints
29+
30+
.. versionchanged: 0.83.0
31+
Non-conflicting constraints are now combined. Constraints with
32+
conflicts now raise :exc:`InvalidConstraintError`. Inputs without a
33+
version specifier or with extras or url are also refused.
34+
"""
2535
req = Requirement(unparsed)
2636
canon_name = canonicalize_name(req.name)
2737
previous = self._data.get(canon_name)
2838

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

3355
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):
56+
logger.debug("combining constraints %s and %s", previous, req)
57+
new_specifier = req.specifier & previous.specifier
58+
if new_specifier.is_unsatisfiable():
59+
raise InvalidConstraintError(
60+
f"Combined specifier '{new_specifier}' is not satisfiable "
61+
f"(existing: {previous}, new: {req})"
62+
)
63+
req.specifier = new_specifier
64+
else:
3865
logger.debug(f"adding constraint {req}")
39-
self._data[canon_name] = req
66+
67+
self._data[canon_name] = req
4068

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

tests/test_constraints.py

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,19 @@
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,7 +27,7 @@ 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"))
@@ -36,28 +36,24 @@ def test_constraint_not_is_satisfied_by() -> None:
3636

3737

3838
def test_add_constraint_conflict() -> None:
39-
c = constraints.Constraints()
39+
c = Constraints()
4040
c.add_constraint("foo<=1.1")
4141
c.add_constraint("flit_core==2.0rc3")
4242

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):
43+
# Conflicting version, same marker (no marker) should raise error
44+
with pytest.raises(InvalidConstraintError):
4945
c.add_constraint("foo>1.1")
5046

51-
# Different version for flit_core should raise error
52-
with pytest.raises(KeyError):
47+
# Conflicting version for flit_core should raise error
48+
with pytest.raises(InvalidConstraintError):
5349
c.add_constraint("flit_core>2.0.0")
5450

5551
# Normalized name conflict should raise error
56-
with pytest.raises(KeyError):
52+
with pytest.raises(InvalidConstraintError):
5753
c.add_constraint("flit-core>2.0.0")
5854

5955
# Different, but equivalent markers should raise KeyError
60-
with pytest.raises(KeyError):
56+
with pytest.raises(InvalidConstraintError):
6157
# arm64 -> macos; aarch64 -> linux
6258
for arch in ["x86_64", "arm64", "aarch64"]:
6359
c.add_constraint(
@@ -72,7 +68,7 @@ def test_add_constraint_conflict() -> None:
7268
c.add_constraint("baz==1.1; platform_machine == 'ppc64le'")
7369

7470
# But same package with same marker should raise error
75-
with pytest.raises(KeyError):
71+
with pytest.raises(InvalidConstraintError):
7672
c.add_constraint("foo==1.2; platform_machine != 'ppc64le'")
7773

7874
# Verify multiple constraints for same package are stored
@@ -98,7 +94,7 @@ def test_add_constraint_conflict() -> None:
9894

9995

10096
def test_allow_prerelease() -> None:
101-
c = constraints.Constraints()
97+
c = Constraints()
10298
c.add_constraint("foo>=1.1")
10399
assert not c.allow_prerelease("foo")
104100
c.add_constraint("bar>=1.1a0")
@@ -109,15 +105,40 @@ def test_allow_prerelease() -> None:
109105

110106
def test_load_non_existant_constraints_file(tmp_path: pathlib.Path) -> None:
111107
non_existant_file = tmp_path / "non_existant.txt"
112-
c = constraints.Constraints()
108+
c = Constraints()
113109
with pytest.raises(FileNotFoundError):
114110
c.load_constraints_file(non_existant_file)
115111

116112

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

0 commit comments

Comments
 (0)