Skip to content

Commit

Permalink
Pre-emptively cull unsatisfiable interpreter constraints. (#2542)
Browse files Browse the repository at this point in the history
When `--interpreter-constraint`s are specified that are unsatisfiable,
Pex now either errors if all given interpreter constraints are
unsatisfiable or else warns and continues with only the remaining valid
interpreter constraints after culling the unsatisfiable ones.

Fixes #432

---------

Co-authored-by: Benjy Weinberger <[email protected]>
  • Loading branch information
jsirois and benjyw authored Sep 19, 2024
1 parent ea0dfba commit da262e7
Show file tree
Hide file tree
Showing 7 changed files with 198 additions and 18 deletions.
12 changes: 12 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Release Notes

## 2.20.1

This release fixes Pex `--interpreter-constraint` handling such that
any supplied interpreter constraints which are in principle
unsatisfiable either raise an error or else cause a warning to be issued
when other viable interpreter constraints have also been specified. For
example, `--interpreter-constraint ==3.11.*,==3.12.*` now errors and
`--interpreter-constraint '>=3.8,<3.8' --interpreter-constraint ==3.9.*`
now warns, culling `>3.8,<3.8` and continuing using only `==3.9.*`.

* Pre-emptively cull unsatisfiable interpreter constraints. (#2542)

## 2.20.0

This release adds the `--pip-log` alias for the existing
Expand Down
63 changes: 58 additions & 5 deletions pex/interpreter_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@

import itertools

from pex import pex_warnings
from pex.common import pluralize
from pex.compatibility import indent
from pex.dist_metadata import Requirement, RequirementParseError
from pex.enum import Enum
from pex.interpreter import PythonInterpreter
from pex.orderedset import OrderedSet
from pex.specifier_sets import UnsatisfiableSpecifierSet, as_range
from pex.third_party.packaging.specifiers import SpecifierSet
from pex.typing import TYPE_CHECKING

if TYPE_CHECKING:
from typing import Iterable, Iterator, Optional, Tuple
from typing import Any, Iterable, Iterator, List, Optional, Tuple

import attr # vendor:skip

Expand All @@ -25,6 +28,10 @@
from pex.third_party import attr


class UnsatisfiableError(ValueError):
"""Indicates an unsatisfiable interpreter constraint, e.g. `>=3.8,<3.8`."""


@attr.s(frozen=True)
class InterpreterConstraint(object):
@classmethod
Expand Down Expand Up @@ -75,6 +82,18 @@ def exact_version(cls, interpreter=None):
specifier = attr.ib() # type: SpecifierSet
name = attr.ib(default=None) # type: Optional[str]

@specifier.validator
def _validate_specifier(
self,
_attribute, # type: Any
value, # type: SpecifierSet
):
# type: (...) -> None
if isinstance(as_range(value), UnsatisfiableSpecifierSet):
raise UnsatisfiableError(
"The interpreter constraint {constraint} is unsatisfiable.".format(constraint=self)
)

def iter_matching(self, paths=None):
# type: (Optional[Iterable[str]]) -> Iterator[PythonInterpreter]
for interp in PythonInterpreter.iter(paths=paths):
Expand Down Expand Up @@ -103,11 +122,45 @@ class InterpreterConstraints(object):
@classmethod
def parse(cls, *constraints):
# type: (str) -> InterpreterConstraints
return cls(
constraints=tuple(
InterpreterConstraint.parse(constraint) for constraint in OrderedSet(constraints)

interpreter_constraints = [] # type: List[InterpreterConstraint]
unsatisfiable = [] # type: List[Tuple[str, UnsatisfiableError]]
all_constraints = OrderedSet(constraints)
for constraint in all_constraints:
try:
interpreter_constraints.append(InterpreterConstraint.parse(constraint))
except UnsatisfiableError as e:
unsatisfiable.append((constraint, e))

if unsatisfiable:
if len(unsatisfiable) == 1:
_, err = unsatisfiable[0]
if not interpreter_constraints:
raise err
message = str(err)
else:
message = (
"Given interpreter constraints are unsatisfiable:\n{unsatisfiable}".format(
unsatisfiable="\n".join(constraint for constraint, _ in unsatisfiable)
)
)

if not interpreter_constraints:
raise UnsatisfiableError(message)
pex_warnings.warn(
"Only {count} interpreter {constraints} {are} valid amongst: {all_constraints}.\n"
"{message}\n"
"Continuing using only {interpreter_constraints}".format(
count=len(interpreter_constraints),
constraints=pluralize(interpreter_constraints, "constraint"),
are="is" if len(interpreter_constraints) == 1 else "are",
all_constraints=" or ".join(all_constraints),
message=message,
interpreter_constraints=" or ".join(map(str, interpreter_constraints)),
)
)
)

return cls(constraints=tuple(interpreter_constraints))

constraints = attr.ib(default=()) # type: Tuple[InterpreterConstraint, ...]

Expand Down
27 changes: 26 additions & 1 deletion pex/specifier_sets.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@
from pex.third_party import attr


def _ensure_specifier_set(specifier_set):
# type: (Union[str, SpecifierSet]) -> SpecifierSet
return specifier_set if isinstance(specifier_set, SpecifierSet) else SpecifierSet(specifier_set)


@attr.s(frozen=True)
class UnsatisfiableSpecifierSet(object):
specifier_set = attr.ib(converter=_ensure_specifier_set) # type: SpecifierSet


@attr.s(frozen=True)
class ArbitraryEquality(object):
version = attr.ib() # type: str
Expand Down Expand Up @@ -239,7 +249,7 @@ def _bounds(specifier_set):


def as_range(specifier_set):
# type: (Union[str, SpecifierSet]) -> Union[ArbitraryEquality, Range]
# type: (Union[str, SpecifierSet]) -> Union[ArbitraryEquality, Range, UnsatisfiableSpecifierSet]

lower_bounds = [] # type: List[LowerBound]
upper_bounds = [] # type: List[UpperBound]
Expand Down Expand Up @@ -281,6 +291,14 @@ def as_range(specifier_set):
upper = new_upper
excludes.remove(exclude)

# N.B.: Since we went through exclude merging above, there is no need to consider those here
# when checking for unsatisfiable specifier sets.
if lower and upper:
if lower.version > upper.version:
return UnsatisfiableSpecifierSet(specifier_set)
if lower.version == upper.version and (not lower.inclusive or not upper.inclusive):
return UnsatisfiableSpecifierSet(specifier_set)

return Range(
lower=lower,
upper=upper,
Expand All @@ -295,9 +313,16 @@ def includes(
# type: (...) -> bool

included_range = as_range(specifier)
if isinstance(included_range, UnsatisfiableSpecifierSet):
return False

candidate_range = as_range(candidate)
if isinstance(candidate_range, UnsatisfiableSpecifierSet):
return False

if isinstance(included_range, ArbitraryEquality) or isinstance(
candidate_range, ArbitraryEquality
):
return included_range == candidate_range

return candidate_range in included_range
2 changes: 1 addition & 1 deletion pex/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2015 Pex project contributors.
# Licensed under the Apache License, Version 2.0 (see LICENSE).

__version__ = "2.20.0"
__version__ = "2.20.1"
24 changes: 17 additions & 7 deletions tests/resolve/test_target_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@
from pex.platforms import Platform
from pex.resolve import abbreviated_platforms, target_options
from pex.resolve.resolver_configuration import PipConfiguration
from pex.resolve.target_configuration import InterpreterConstraintsNotSatisfied
from pex.targets import CompletePlatform, Targets
from pex.typing import TYPE_CHECKING
from pex.variables import ENV
from testing import IS_MAC, environment_as

if TYPE_CHECKING:
from typing import Any, Dict, Iterable, List, Optional, Tuple
from typing import Any, Dict, Iterable, List, Optional, Tuple, Type


def compute_target_configuration(
Expand Down Expand Up @@ -318,16 +319,25 @@ def assert_interpreter_constraint(
assert_interpreter_constraint([">=3.8,<3.9"], [py38], expected_interpreter=py38)
assert_interpreter_constraint(["==3.10.*", "==2.7.*"], [py310, py27], expected_interpreter=py27)

def assert_interpreter_constraint_not_satisfied(interpreter_constraints):
# type: (List[str]) -> None
with pytest.raises(pex.resolve.target_configuration.InterpreterConstraintsNotSatisfied):
def assert_interpreter_constraint_not_satisfied(
interpreter_constraints, # type: List[str]
expected_error_type, # type: Type[Exception]
):
# type: (...) -> None
with pytest.raises(expected_error_type):
compute_target_configuration(
parser, interpreter_constraint_args(interpreter_constraints)
)

assert_interpreter_constraint_not_satisfied(["==3.9.*"])
assert_interpreter_constraint_not_satisfied(["==3.8.*,!=3.8.*"])
assert_interpreter_constraint_not_satisfied(["==3.9.*", "==2.6.*"])
assert_interpreter_constraint_not_satisfied(
["==3.9.*"], expected_error_type=InterpreterConstraintsNotSatisfied
)
assert_interpreter_constraint_not_satisfied(
["==3.8.*,!=3.8.*"], expected_error_type=ArgumentTypeError
)
assert_interpreter_constraint_not_satisfied(
["==3.9.*", "==2.6.*"], expected_error_type=InterpreterConstraintsNotSatisfied
)


def test_configure_resolve_local_platforms(
Expand Down
60 changes: 58 additions & 2 deletions tests/test_interpreter_constraints.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
# Copyright 2022 Pex project contributors.
# Licensed under the Apache License, Version 2.0 (see LICENSE).

import itertools
import sys
from textwrap import dedent

import pytest

from pex import interpreter_constraints
from pex.interpreter import PythonInterpreter
from pex.interpreter_constraints import COMPATIBLE_PYTHON_VERSIONS, InterpreterConstraint, Lifecycle
from pex.interpreter_constraints import (
COMPATIBLE_PYTHON_VERSIONS,
InterpreterConstraint,
InterpreterConstraints,
Lifecycle,
UnsatisfiableError,
)
from pex.pex_warnings import PEXWarning
from pex.typing import TYPE_CHECKING
from testing import PY38, ensure_python_interpreter

Expand All @@ -23,6 +32,53 @@ def test_parse():
assert py38 not in InterpreterConstraint.parse("==3.8.*", default_interpreter="PyPy")
assert py38 not in InterpreterConstraint.parse("PyPy==3.8.*")

with pytest.raises(
UnsatisfiableError, match="The interpreter constraint ==3.8.*,==3.9.* is unsatisfiable."
):
InterpreterConstraint.parse("==3.8.*,==3.9.*")

with pytest.raises(
UnsatisfiableError, match="The interpreter constraint ==3.8.*,==3.9.* is unsatisfiable."
):
InterpreterConstraints.parse("==3.8.*,==3.9.*")

with pytest.raises(
UnsatisfiableError,
match=dedent(
"""\
Given interpreter constraints are unsatisfiable:
==3.8.*,==3.9.*
==3.9.*,<3.9
"""
).strip(),
):
InterpreterConstraints.parse("==3.8.*,==3.9.*", "==3.9.*,<3.9")

with pytest.warns(
PEXWarning,
match=dedent(
"""\
Only 2 interpreter constraints are valid amongst: CPython==3.10.*,==3.11.* or CPython==3.10.*,==3.12.* or CPython==3.11.* or CPython==3.11.*,==3.12.* or CPython==3.11.*,==3.9.* or CPython==3.12.* or CPython==3.12.*,==3.9.*.
Given interpreter constraints are unsatisfiable:
CPython==3.10.*,==3.11.*
CPython==3.10.*,==3.12.*
CPython==3.11.*,==3.12.*
CPython==3.11.*,==3.9.*
CPython==3.12.*,==3.9.*
Continuing using only CPython==3.11.* or CPython==3.12.*
"""
).strip(),
):
InterpreterConstraints.parse(
"CPython==3.10.*,==3.11.*",
"CPython==3.10.*,==3.12.*",
"CPython==3.11.*",
"CPython==3.11.*,==3.12.*",
"CPython==3.11.*,==3.9.*",
"CPython==3.12.*",
"CPython==3.12.*,==3.9.*",
)


def iter_compatible_versions(*requires_python):
# type: (*str) -> List[Tuple[int, int, int]]
Expand Down
28 changes: 26 additions & 2 deletions tests/test_specifier_sets.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@
import pytest

from pex.pep_440 import Version
from pex.specifier_sets import ExcludedRange, LowerBound, Range, UpperBound, as_range, includes
from pex.specifier_sets import (
ExcludedRange,
LowerBound,
Range,
UnsatisfiableSpecifierSet,
UpperBound,
as_range,
includes,
)
from pex.third_party.packaging.specifiers import InvalidSpecifier
from pex.typing import TYPE_CHECKING
from pex.typing import TYPE_CHECKING, cast

if TYPE_CHECKING:
pass
Expand Down Expand Up @@ -302,3 +310,19 @@ def test_wildcard_version_suffix_handling():

# Local-release specifiers.
assert_wildcard_handling("+bob")


def test_unsatisfiable():
# type: () -> None

assert isinstance(as_range(">3,<2"), UnsatisfiableSpecifierSet)

assert isinstance(as_range(">=2.7,<2.7"), UnsatisfiableSpecifierSet)
assert isinstance(as_range(">2.7,<=2.7"), UnsatisfiableSpecifierSet)
assert isinstance(as_range(">2.7,<2.7"), UnsatisfiableSpecifierSet)
assert cast(Range, as_range("==2.7")) in cast(Range, as_range(">=2.7,<=2.7"))

assert isinstance(as_range(">=3.8,!=3.8.*,!=3.9.*,<3.10"), UnsatisfiableSpecifierSet)

assert not includes(">2,<3", ">=2.7,<2.7")
assert not includes(">=2.7,<2.7", ">2,<3")

0 comments on commit da262e7

Please sign in to comment.