Skip to content

Commit 42677a1

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 42677a1

3 files changed

Lines changed: 109 additions & 58 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 & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,25 @@
11
import pathlib
22
import typing
3+
from unittest import mock
34

45
import pytest
56
from packaging import markers
67
from packaging.requirements import Requirement
78
from packaging.version import Version
89

9-
from fromager import constraints
10+
from fromager.constraints import Constraints, InvalidConstraintError
1011

1112

1213
def test_constraint_is_satisfied_by() -> None:
13-
c = constraints.Constraints()
14+
c = Constraints()
1415
c.add_constraint("foo<=1.1")
1516
assert c.is_satisfied_by("foo", Version("1.1"))
1617
assert c.is_satisfied_by("foo", Version("1.0"))
1718
assert c.is_satisfied_by("bar", Version("2.0"))
1819

1920

2021
def test_constraint_canonical_name() -> None:
21-
c = constraints.Constraints()
22+
c = Constraints()
2223
c.add_constraint("flash_attn<=1.1")
2324
assert c.is_satisfied_by("flash_attn", Version("1.1"))
2425
assert c.is_satisfied_by("flash-attn", Version("1.1"))
@@ -27,78 +28,72 @@ def test_constraint_canonical_name() -> None:
2728

2829

2930
def test_constraint_not_is_satisfied_by() -> None:
30-
c = constraints.Constraints()
31+
c = Constraints()
3132
c.add_constraint("foo<=1.1")
3233
c.add_constraint("bar>=2.0")
3334
assert not c.is_satisfied_by("foo", Version("1.2"))
3435
assert not c.is_satisfied_by("foo", Version("2.0"))
3536
assert not c.is_satisfied_by("bar", Version("1.0"))
3637

3738

39+
@mock.patch("platform.machine", mock.Mock(return_value="atari"))
3840
def test_add_constraint_conflict() -> None:
39-
c = constraints.Constraints()
41+
assert markers.default_environment()["platform_machine"] == "atari"
42+
43+
c = Constraints()
4044
c.add_constraint("foo<=1.1")
4145
c.add_constraint("flit_core==2.0rc3")
4246

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

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

5555
# Normalized name conflict should raise error
56-
with pytest.raises(KeyError):
56+
with pytest.raises(InvalidConstraintError):
5757
c.add_constraint("flit-core>2.0.0")
5858

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-
)
59+
# Constraints for other platforms are ignored
60+
c.add_constraint(
61+
"bar==1.0; python_version >= '3.11' and platform_machine == 'amiga'"
62+
)
63+
assert c.get_constraint("bar") is None
64+
65+
c.add_constraint(
66+
"bar==1.0; python_version >= '3.11' and platform_machine == 'atari'"
67+
)
68+
# Make sure correct constraint is added
69+
constraint = c.get_constraint("bar")
70+
assert constraint
71+
assert constraint.name == "bar"
72+
assert constraint.specifier == "==1.0"
73+
assert constraint.marker == markers.Marker(
74+
'python_version >= "3.11" and platform_machine == "atari"'
75+
)
76+
77+
# Different, but equivalent markers should raise error
78+
with pytest.raises(InvalidConstraintError):
79+
c.add_constraint(
80+
"bar==1.1; platform_machine == 'atari' and python_version >= '3.11'"
81+
)
6982

7083
# 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'")
84+
c.add_constraint("baz==1.0; platform_machine != 'amiga'")
85+
c.add_constraint("baz==1.1; platform_machine == 'amiga'")
7386

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

7891
# 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-
)
92+
assert len(c) == 4 # flit_core, foo, bar, and baz
9893

9994

10095
def test_allow_prerelease() -> None:
101-
c = constraints.Constraints()
96+
c = Constraints()
10297
c.add_constraint("foo>=1.1")
10398
assert not c.allow_prerelease("foo")
10499
c.add_constraint("bar>=1.1a0")
@@ -109,15 +104,40 @@ def test_allow_prerelease() -> None:
109104

110105
def test_load_non_existant_constraints_file(tmp_path: pathlib.Path) -> None:
111106
non_existant_file = tmp_path / "non_existant.txt"
112-
c = constraints.Constraints()
107+
c = Constraints()
113108
with pytest.raises(FileNotFoundError):
114109
c.load_constraints_file(non_existant_file)
115110

116111

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

0 commit comments

Comments
 (0)