From aa97b602a43ba473153befa52eaaa1432c4184b6 Mon Sep 17 00:00:00 2001 From: Christian Heimes Date: Mon, 11 May 2026 16:14:52 +0200 Subject: [PATCH] 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 Signed-off-by: Christian Heimes --- docs/how-tos/bootstrap-constraints.rst | 15 +++++++++ src/fromager/constraints.py | 45 ++++++++++++++++++++++---- tests/test_constraints.py | 29 +++++++++++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/docs/how-tos/bootstrap-constraints.rst b/docs/how-tos/bootstrap-constraints.rst index 6fcfb4eb..329daa5f 100644 --- a/docs/how-tos/bootstrap-constraints.rst +++ b/docs/how-tos/bootstrap-constraints.rst @@ -53,3 +53,18 @@ Fromager can load constraints from `https://` URLs, too. .. code-block:: console $ fromager -c constraints.txt -c local-constraints.txt -c https://company.example/security-constraints.txt bootstrap my-package + +Blocking packages +----------------- + +To block a package entirely so that no version is accepted, use the special +constraint ``<0`` (or equivalently ``<0.0`` or ``<0.0.0``). No valid Python +version can satisfy this specifier, so the package is effectively excluded from +the build. + +.. code-block:: text + + unwanted-package<0 + +A blocked constraint cannot be combined with a regular constraint for the same +package. Adding both will raise an error. diff --git a/src/fromager/constraints.py b/src/fromager/constraints.py index 99b0e15f..1d1530a5 100644 --- a/src/fromager/constraints.py +++ b/src/fromager/constraints.py @@ -4,6 +4,7 @@ from collections.abc import Generator from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet from packaging.utils import NormalizedName, canonicalize_name from packaging.version import Version @@ -12,6 +13,20 @@ logger = logging.getLogger(__name__) +def _is_blocked_specifier(specifier: SpecifierSet) -> bool: + """Return True if specifier blocks a package entirely. + + The convention ``<0``, ``<0.0``, or ``<0.0.0`` is used to mark a + package as blocked so that no version can satisfy the constraint. + """ + specs = list(specifier) + return ( + len(specs) == 1 + and specs[0].operator == "<" + and Version(specs[0].version) == Version("0") + ) + + class InvalidConstraintError(ValueError): pass @@ -51,8 +66,12 @@ def add_constraint(self, unparsed: str) -> None: if not req.specifier: raise InvalidConstraintError(f"Constraint {unparsed!r} has no specifiers") + # A "blocked" specifier (<0, <0.0, <0.0.0) is intentionally + # unsatisfiable and used to exclude a package from builds. + blocked = _is_blocked_specifier(req.specifier) + # verify that incoming constraint is okay by itself - if req.specifier.is_unsatisfiable(): + if not blocked and req.specifier.is_unsatisfiable(): raise InvalidConstraintError(f"Constraint {unparsed!r} is unsatisfiable") if not requirements_file.evaluate_marker(req, req): @@ -60,14 +79,21 @@ def add_constraint(self, unparsed: str) -> None: return if previous is not None: - logger.debug("combining constraints %s and %s", previous, req) - new_specifier = req.specifier & previous.specifier - if new_specifier.is_unsatisfiable(): + prev_blocked = _is_blocked_specifier(previous.specifier) + if blocked != prev_blocked: raise InvalidConstraintError( - f"Combined specifier '{new_specifier}' is not satisfiable " + f"Cannot combine blocked and non-blocked constraints " f"(existing: {previous}, new: {req})" ) - req.specifier = new_specifier + if not blocked: + logger.debug("combining constraints %s and %s", previous, req) + new_specifier = req.specifier & previous.specifier + if new_specifier.is_unsatisfiable(): + raise InvalidConstraintError( + f"Combined specifier '{new_specifier}' is not satisfiable " + f"(existing: {previous}, new: {req})" + ) + req.specifier = new_specifier else: logger.debug(f"adding constraint {req}") @@ -97,6 +123,13 @@ def allow_prerelease(self, pkg_name: str) -> bool: return bool(constraint.specifier.prereleases) return False + def is_blocked(self, pkg_name: str) -> bool: + """Return True if the package is blocked by a ``<0`` constraint.""" + constraint = self.get_constraint(pkg_name) + if constraint: + return _is_blocked_specifier(constraint.specifier) + return False + def is_satisfied_by(self, pkg_name: str, version: Version) -> bool: constraint = self.get_constraint(pkg_name) if constraint: diff --git a/tests/test_constraints.py b/tests/test_constraints.py index 219e1ce0..d17485a4 100644 --- a/tests/test_constraints.py +++ b/tests/test_constraints.py @@ -173,3 +173,32 @@ def test_combine_constraints() -> None: assert c.get_constraint("foo") == Requirement("foo<2.0,>=1.0") c.add_constraint("foo!=1.1.0") assert c.get_constraint("foo") == Requirement("foo<2.0,>=1.0,!=1.1.0") + + +@pytest.mark.parametrize("specifier", ["<0", "<0.0", "<0.0.0"]) +def test_blocked_package(specifier: str) -> None: + c = Constraints() + c.add_constraint(f"blocked-pkg{specifier}") + assert c.is_blocked("blocked-pkg") + assert not c.is_satisfied_by("blocked-pkg", Version("0")) + assert not c.is_satisfied_by("blocked-pkg", Version("0.0.1")) + assert not c.is_satisfied_by("blocked-pkg", Version("1.0")) + + +def test_blocked_then_non_blocked_raises() -> None: + c = Constraints() + c.add_constraint("foo<0") + with pytest.raises(InvalidConstraintError, match=r"blocked and non-blocked"): + c.add_constraint("foo>=1.0") + + +def test_non_blocked_then_blocked_raises() -> None: + c = Constraints() + c.add_constraint("foo>=1.0") + with pytest.raises(InvalidConstraintError, match=r"blocked and non-blocked"): + c.add_constraint("foo<0") + + +def test_is_blocked_unknown_package() -> None: + c = Constraints() + assert not c.is_blocked("unknown")