Skip to content

Commit aa97b60

Browse files
tiranclaude
andcommitted
feat(constraints): support <0 specifier to block packages
The convention `<0` (also `<0.0` and `<0.0.0`) is intentionally unsatisfiable and used to exclude a package from builds. Skip the `is_unsatisfiable()` rejection for these specifiers and add a `Constraints.is_blocked()` query method. Combining a blocked and a non-blocked constraint for the same package raises an error. Co-Authored-By: Claude <claude@anthropic.com> Signed-off-by: Christian Heimes <cheimes@redhat.com>
1 parent 4f5f4f9 commit aa97b60

3 files changed

Lines changed: 83 additions & 6 deletions

File tree

docs/how-tos/bootstrap-constraints.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,18 @@ Fromager can load constraints from `https://` URLs, too.
5353
.. code-block:: console
5454
5555
$ fromager -c constraints.txt -c local-constraints.txt -c https://company.example/security-constraints.txt bootstrap my-package
56+
57+
Blocking packages
58+
-----------------
59+
60+
To block a package entirely so that no version is accepted, use the special
61+
constraint ``<0`` (or equivalently ``<0.0`` or ``<0.0.0``). No valid Python
62+
version can satisfy this specifier, so the package is effectively excluded from
63+
the build.
64+
65+
.. code-block:: text
66+
67+
unwanted-package<0
68+
69+
A blocked constraint cannot be combined with a regular constraint for the same
70+
package. Adding both will raise an error.

src/fromager/constraints.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from collections.abc import Generator
55

66
from packaging.requirements import Requirement
7+
from packaging.specifiers import SpecifierSet
78
from packaging.utils import NormalizedName, canonicalize_name
89
from packaging.version import Version
910

@@ -12,6 +13,20 @@
1213
logger = logging.getLogger(__name__)
1314

1415

16+
def _is_blocked_specifier(specifier: SpecifierSet) -> bool:
17+
"""Return True if specifier blocks a package entirely.
18+
19+
The convention ``<0``, ``<0.0``, or ``<0.0.0`` is used to mark a
20+
package as blocked so that no version can satisfy the constraint.
21+
"""
22+
specs = list(specifier)
23+
return (
24+
len(specs) == 1
25+
and specs[0].operator == "<"
26+
and Version(specs[0].version) == Version("0")
27+
)
28+
29+
1530
class InvalidConstraintError(ValueError):
1631
pass
1732

@@ -51,23 +66,34 @@ def add_constraint(self, unparsed: str) -> None:
5166
if not req.specifier:
5267
raise InvalidConstraintError(f"Constraint {unparsed!r} has no specifiers")
5368

69+
# A "blocked" specifier (<0, <0.0, <0.0.0) is intentionally
70+
# unsatisfiable and used to exclude a package from builds.
71+
blocked = _is_blocked_specifier(req.specifier)
72+
5473
# verify that incoming constraint is okay by itself
55-
if req.specifier.is_unsatisfiable():
74+
if not blocked and req.specifier.is_unsatisfiable():
5675
raise InvalidConstraintError(f"Constraint {unparsed!r} is unsatisfiable")
5776

5877
if not requirements_file.evaluate_marker(req, req):
5978
logger.debug(f"Constraint {req} does not match environment")
6079
return
6180

6281
if previous is not None:
63-
logger.debug("combining constraints %s and %s", previous, req)
64-
new_specifier = req.specifier & previous.specifier
65-
if new_specifier.is_unsatisfiable():
82+
prev_blocked = _is_blocked_specifier(previous.specifier)
83+
if blocked != prev_blocked:
6684
raise InvalidConstraintError(
67-
f"Combined specifier '{new_specifier}' is not satisfiable "
85+
f"Cannot combine blocked and non-blocked constraints "
6886
f"(existing: {previous}, new: {req})"
6987
)
70-
req.specifier = new_specifier
88+
if not blocked:
89+
logger.debug("combining constraints %s and %s", previous, req)
90+
new_specifier = req.specifier & previous.specifier
91+
if new_specifier.is_unsatisfiable():
92+
raise InvalidConstraintError(
93+
f"Combined specifier '{new_specifier}' is not satisfiable "
94+
f"(existing: {previous}, new: {req})"
95+
)
96+
req.specifier = new_specifier
7197
else:
7298
logger.debug(f"adding constraint {req}")
7399

@@ -97,6 +123,13 @@ def allow_prerelease(self, pkg_name: str) -> bool:
97123
return bool(constraint.specifier.prereleases)
98124
return False
99125

126+
def is_blocked(self, pkg_name: str) -> bool:
127+
"""Return True if the package is blocked by a ``<0`` constraint."""
128+
constraint = self.get_constraint(pkg_name)
129+
if constraint:
130+
return _is_blocked_specifier(constraint.specifier)
131+
return False
132+
100133
def is_satisfied_by(self, pkg_name: str, version: Version) -> bool:
101134
constraint = self.get_constraint(pkg_name)
102135
if constraint:

tests/test_constraints.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,32 @@ def test_combine_constraints() -> None:
173173
assert c.get_constraint("foo") == Requirement("foo<2.0,>=1.0")
174174
c.add_constraint("foo!=1.1.0")
175175
assert c.get_constraint("foo") == Requirement("foo<2.0,>=1.0,!=1.1.0")
176+
177+
178+
@pytest.mark.parametrize("specifier", ["<0", "<0.0", "<0.0.0"])
179+
def test_blocked_package(specifier: str) -> None:
180+
c = Constraints()
181+
c.add_constraint(f"blocked-pkg{specifier}")
182+
assert c.is_blocked("blocked-pkg")
183+
assert not c.is_satisfied_by("blocked-pkg", Version("0"))
184+
assert not c.is_satisfied_by("blocked-pkg", Version("0.0.1"))
185+
assert not c.is_satisfied_by("blocked-pkg", Version("1.0"))
186+
187+
188+
def test_blocked_then_non_blocked_raises() -> None:
189+
c = Constraints()
190+
c.add_constraint("foo<0")
191+
with pytest.raises(InvalidConstraintError, match=r"blocked and non-blocked"):
192+
c.add_constraint("foo>=1.0")
193+
194+
195+
def test_non_blocked_then_blocked_raises() -> None:
196+
c = Constraints()
197+
c.add_constraint("foo>=1.0")
198+
with pytest.raises(InvalidConstraintError, match=r"blocked and non-blocked"):
199+
c.add_constraint("foo<0")
200+
201+
202+
def test_is_blocked_unknown_package() -> None:
203+
c = Constraints()
204+
assert not c.is_blocked("unknown")

0 commit comments

Comments
 (0)