Skip to content

Commit b75a01e

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>
1 parent 87a7aa9 commit b75a01e

3 files changed

Lines changed: 82 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
@@ -35,3 +35,18 @@ production packages.
3535
3636
This will use the constraints in the ``constraints.txt`` file to build the
3737
production packages for ``my-package``.
38+
39+
Blocking packages
40+
-----------------
41+
42+
To block a package entirely so that no version is accepted, use the special
43+
constraint ``<0`` (or equivalently ``<0.0`` or ``<0.0.0``). No valid Python
44+
version can satisfy this specifier, so the package is effectively excluded from
45+
the build.
46+
47+
.. code-block:: text
48+
49+
unwanted-package<0
50+
51+
A blocked constraint cannot be combined with a regular constraint for the same
52+
package. Adding both will raise an error.

src/fromager/constraints.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,28 @@
33
from collections.abc import Generator
44

55
from packaging.requirements import Requirement
6+
from packaging.specifiers import SpecifierSet
67
from packaging.utils import NormalizedName, canonicalize_name
78
from packaging.version import Version
89

910
from . import requirements_file
1011

12+
#: Specifiers that block a package entirely (no version can satisfy them).
13+
#: Used as a convention to exclude packages from builds.
14+
_BLOCKED_SPECIFIERS: frozenset[str] = frozenset({"<0", "<0.0", "<0.0.0"})
15+
1116
logger = logging.getLogger(__name__)
1217

1318

19+
def _is_blocked_specifier(specifier: SpecifierSet) -> bool:
20+
"""Return True if specifier blocks a package entirely.
21+
22+
The convention ``<0``, ``<0.0``, or ``<0.0.0`` is used to mark a
23+
package as blocked so that no version can satisfy the constraint.
24+
"""
25+
return str(specifier) in _BLOCKED_SPECIFIERS
26+
27+
1428
class InvalidConstraintError(ValueError):
1529
pass
1630

@@ -47,23 +61,34 @@ def add_constraint(self, unparsed: str) -> None:
4761
if not req.specifier:
4862
raise InvalidConstraintError(f"Constraint {unparsed!r} has no specifiers")
4963

64+
# A "blocked" specifier (<0, <0.0, <0.0.0) is intentionally
65+
# unsatisfiable and used to exclude a package from builds.
66+
blocked = _is_blocked_specifier(req.specifier)
67+
5068
# verify that incoming constraint is okay by itself
51-
if req.specifier.is_unsatisfiable():
69+
if not blocked and req.specifier.is_unsatisfiable():
5270
raise InvalidConstraintError(f"Constraint {unparsed!r} is unsatisfiable")
5371

5472
if not requirements_file.evaluate_marker(req, req):
5573
logger.debug(f"Constraint {req} does not match environment")
5674
return
5775

5876
if previous is not None:
59-
logger.debug("combining constraints %s and %s", previous, req)
60-
new_specifier = req.specifier & previous.specifier
61-
if new_specifier.is_unsatisfiable():
77+
prev_blocked = _is_blocked_specifier(previous.specifier)
78+
if blocked != prev_blocked:
6279
raise InvalidConstraintError(
63-
f"Combined specifier '{new_specifier}' is not satisfiable "
80+
f"Cannot combine blocked and non-blocked constraints "
6481
f"(existing: {previous}, new: {req})"
6582
)
66-
req.specifier = new_specifier
83+
if not blocked:
84+
logger.debug("combining constraints %s and %s", previous, req)
85+
new_specifier = req.specifier & previous.specifier
86+
if new_specifier.is_unsatisfiable():
87+
raise InvalidConstraintError(
88+
f"Combined specifier '{new_specifier}' is not satisfiable "
89+
f"(existing: {previous}, new: {req})"
90+
)
91+
req.specifier = new_specifier
6792
else:
6893
logger.debug(f"adding constraint {req}")
6994

@@ -85,6 +110,13 @@ def allow_prerelease(self, pkg_name: str) -> bool:
85110
return bool(constraint.specifier.prereleases)
86111
return False
87112

113+
def is_blocked(self, pkg_name: str) -> bool:
114+
"""Return True if the package is blocked by a ``<0`` constraint."""
115+
constraint = self.get_constraint(pkg_name)
116+
if constraint:
117+
return _is_blocked_specifier(constraint.specifier)
118+
return False
119+
88120
def is_satisfied_by(self, pkg_name: str, version: Version) -> bool:
89121
constraint = self.get_constraint(pkg_name)
90122
if constraint:

tests/test_constraints.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,32 @@ def test_combine_constraints() -> None:
140140
assert c.get_constraint("foo") == Requirement("foo<2.0,>=1.0")
141141
c.add_constraint("foo!=1.1.0")
142142
assert c.get_constraint("foo") == Requirement("foo<2.0,>=1.0,!=1.1.0")
143+
144+
145+
@pytest.mark.parametrize("specifier", ["<0", "<0.0", "<0.0.0"])
146+
def test_blocked_package(specifier: str) -> None:
147+
c = Constraints()
148+
c.add_constraint(f"blocked-pkg{specifier}")
149+
assert c.is_blocked("blocked-pkg")
150+
assert not c.is_satisfied_by("blocked-pkg", Version("0"))
151+
assert not c.is_satisfied_by("blocked-pkg", Version("0.0.1"))
152+
assert not c.is_satisfied_by("blocked-pkg", Version("1.0"))
153+
154+
155+
def test_blocked_then_non_blocked_raises() -> None:
156+
c = Constraints()
157+
c.add_constraint("foo<0")
158+
with pytest.raises(InvalidConstraintError, match=r"blocked and non-blocked"):
159+
c.add_constraint("foo>=1.0")
160+
161+
162+
def test_non_blocked_then_blocked_raises() -> None:
163+
c = Constraints()
164+
c.add_constraint("foo>=1.0")
165+
with pytest.raises(InvalidConstraintError, match=r"blocked and non-blocked"):
166+
c.add_constraint("foo<0")
167+
168+
169+
def test_is_blocked_unknown_package() -> None:
170+
c = Constraints()
171+
assert not c.is_blocked("unknown")

0 commit comments

Comments
 (0)