mirror of https://github.com/pypa/pip
Merge pull request #8004 from uranusjr/requires-python-error
New Resolver: Raise UnsupportedPythonVersion for Requires-Python mismatch
This commit is contained in:
commit
92c9f8136a
|
@ -1,5 +1,9 @@
|
|||
from pip._vendor.packaging.utils import canonicalize_name
|
||||
|
||||
from pip._internal.exceptions import (
|
||||
InstallationError,
|
||||
UnsupportedPythonVersion,
|
||||
)
|
||||
from pip._internal.utils.misc import get_installed_distributions
|
||||
from pip._internal.utils.typing import MYPY_CHECK_RUNNING
|
||||
|
||||
|
@ -12,7 +16,7 @@ from .candidates import (
|
|||
)
|
||||
from .requirements import (
|
||||
ExplicitRequirement,
|
||||
NoMatchRequirement,
|
||||
RequiresPythonRequirement,
|
||||
SpecifierRequirement,
|
||||
)
|
||||
|
||||
|
@ -22,6 +26,7 @@ if MYPY_CHECK_RUNNING:
|
|||
from pip._vendor.packaging.specifiers import SpecifierSet
|
||||
from pip._vendor.packaging.version import _BaseVersion
|
||||
from pip._vendor.pkg_resources import Distribution
|
||||
from pip._vendor.resolvelib import ResolutionImpossible
|
||||
|
||||
from pip._internal.index.package_finder import PackageFinder
|
||||
from pip._internal.models.candidate import InstallationCandidate
|
||||
|
@ -152,16 +157,36 @@ class Factory(object):
|
|||
# type: (Optional[SpecifierSet]) -> Optional[Requirement]
|
||||
if self._ignore_requires_python or specifier is None:
|
||||
return None
|
||||
# The logic here is different from SpecifierRequirement, for which we
|
||||
# "find" candidates matching the specifier. But for Requires-Python,
|
||||
# there is always exactly one candidate (the one specified with
|
||||
# py_version_info). Here we decide whether to return that based on
|
||||
# whether Requires-Python matches that one candidate or not.
|
||||
if self._python_candidate.version in specifier:
|
||||
return ExplicitRequirement(self._python_candidate)
|
||||
return NoMatchRequirement(self._python_candidate.name)
|
||||
return RequiresPythonRequirement(specifier, self._python_candidate)
|
||||
|
||||
def should_reinstall(self, candidate):
|
||||
# type: (Candidate) -> bool
|
||||
# TODO: Are there more cases this needs to return True? Editable?
|
||||
return candidate.name in self._installed_dists
|
||||
|
||||
def _report_requires_python_error(
|
||||
self,
|
||||
requirement, # type: RequiresPythonRequirement
|
||||
parent, # type: Candidate
|
||||
):
|
||||
# type: (...) -> UnsupportedPythonVersion
|
||||
template = (
|
||||
"Package {package!r} requires a different Python: "
|
||||
"{version} not in {specifier!r}"
|
||||
)
|
||||
message = template.format(
|
||||
package=parent.name,
|
||||
version=self._python_candidate.version,
|
||||
specifier=str(requirement.specifier),
|
||||
)
|
||||
return UnsupportedPythonVersion(message)
|
||||
|
||||
def get_installation_error(self, e):
|
||||
# type: (ResolutionImpossible) -> Optional[InstallationError]
|
||||
for cause in e.causes:
|
||||
if isinstance(cause.requirement, RequiresPythonRequirement):
|
||||
return self._report_requires_python_error(
|
||||
cause.requirement,
|
||||
cause.parent,
|
||||
)
|
||||
return None
|
||||
|
|
|
@ -7,6 +7,8 @@ from .base import Requirement, format_name
|
|||
if MYPY_CHECK_RUNNING:
|
||||
from typing import Sequence
|
||||
|
||||
from pip._vendor.packaging.specifiers import SpecifierSet
|
||||
|
||||
from pip._internal.req.req_install import InstallRequirement
|
||||
|
||||
from .base import Candidate
|
||||
|
@ -40,37 +42,6 @@ class ExplicitRequirement(Requirement):
|
|||
return candidate == self.candidate
|
||||
|
||||
|
||||
class NoMatchRequirement(Requirement):
|
||||
"""A requirement that never matches anything.
|
||||
|
||||
Note: Similar to ExplicitRequirement, the caller should handle name
|
||||
canonicalisation; this class does not perform it.
|
||||
"""
|
||||
def __init__(self, name):
|
||||
# type: (str) -> None
|
||||
self._name = name
|
||||
|
||||
def __repr__(self):
|
||||
# type: () -> str
|
||||
return "{class_name}(name={name!r})".format(
|
||||
class_name=self.__class__.__name__,
|
||||
name=self._name,
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
# type: () -> str
|
||||
return self._name
|
||||
|
||||
def find_matches(self):
|
||||
# type: () -> Sequence[Candidate]
|
||||
return []
|
||||
|
||||
def is_satisfied_by(self, candidate):
|
||||
# type: (Candidate) -> bool
|
||||
return False
|
||||
|
||||
|
||||
class SpecifierRequirement(Requirement):
|
||||
def __init__(self, ireq, factory):
|
||||
# type: (InstallRequirement, Factory) -> None
|
||||
|
@ -114,3 +85,35 @@ class SpecifierRequirement(Requirement):
|
|||
"Internal issue: Candidate is not for this requirement " \
|
||||
" {} vs {}".format(candidate.name, self.name)
|
||||
return candidate.version in self._ireq.req.specifier
|
||||
|
||||
|
||||
class RequiresPythonRequirement(Requirement):
|
||||
"""A requirement representing Requires-Python metadata.
|
||||
"""
|
||||
def __init__(self, specifier, match):
|
||||
# type: (SpecifierSet, Candidate) -> None
|
||||
self.specifier = specifier
|
||||
self._candidate = match
|
||||
|
||||
def __repr__(self):
|
||||
# type: () -> str
|
||||
return "{class_name}({specifier!r})".format(
|
||||
class_name=self.__class__.__name__,
|
||||
specifier=str(self.specifier),
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
# type: () -> str
|
||||
return self._candidate.name
|
||||
|
||||
def find_matches(self):
|
||||
# type: () -> Sequence[Candidate]
|
||||
if self._candidate.version in self.specifier:
|
||||
return [self._candidate]
|
||||
return []
|
||||
|
||||
def is_satisfied_by(self, candidate):
|
||||
# type: (Candidate) -> bool
|
||||
assert candidate.name == self._candidate.name, "Not Python candidate"
|
||||
return candidate.version in self.specifier
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import functools
|
||||
|
||||
from pip._vendor import six
|
||||
from pip._vendor.packaging.utils import canonicalize_name
|
||||
from pip._vendor.resolvelib import BaseReporter
|
||||
from pip._vendor.resolvelib import BaseReporter, ResolutionImpossible
|
||||
from pip._vendor.resolvelib import Resolver as RLResolver
|
||||
|
||||
from pip._internal.exceptions import InstallationError
|
||||
|
@ -70,7 +71,14 @@ class Resolver(BaseResolver):
|
|||
self.factory.make_requirement_from_install_req(r)
|
||||
for r in root_reqs
|
||||
]
|
||||
self._result = resolver.resolve(requirements)
|
||||
|
||||
try:
|
||||
self._result = resolver.resolve(requirements)
|
||||
except ResolutionImpossible as e:
|
||||
error = self.factory.get_installation_error(e)
|
||||
if not error:
|
||||
raise
|
||||
six.raise_from(error, e)
|
||||
|
||||
req_set = RequirementSet(check_supported_wheels=check_supported_wheels)
|
||||
for candidate in self._result.mapping.values():
|
||||
|
|
|
@ -3,6 +3,7 @@ __all__ = [
|
|||
"AbstractProvider",
|
||||
"AbstractResolver",
|
||||
"BaseReporter",
|
||||
"InconsistentCandidate",
|
||||
"Resolver",
|
||||
"RequirementsConflicted",
|
||||
"ResolutionError",
|
||||
|
@ -10,12 +11,13 @@ __all__ = [
|
|||
"ResolutionTooDeep",
|
||||
]
|
||||
|
||||
__version__ = "0.2.3.dev0"
|
||||
__version__ = "0.3.0"
|
||||
|
||||
|
||||
from .providers import AbstractProvider, AbstractResolver
|
||||
from .reporters import BaseReporter
|
||||
from .resolvers import (
|
||||
InconsistentCandidate,
|
||||
RequirementsConflicted,
|
||||
Resolver,
|
||||
ResolutionError,
|
||||
|
|
|
@ -22,3 +22,15 @@ class BaseReporter(object):
|
|||
def ending(self, state):
|
||||
"""Called before the resolution ends successfully.
|
||||
"""
|
||||
|
||||
def adding_requirement(self, requirement):
|
||||
"""Called when the resolver adds a new requirement into the resolve criteria.
|
||||
"""
|
||||
|
||||
def backtracking(self, candidate):
|
||||
"""Called when the resolver rejects a candidate during backtracking.
|
||||
"""
|
||||
|
||||
def pinning(self, candidate):
|
||||
"""Called when adding a candidate to the potential solution.
|
||||
"""
|
||||
|
|
|
@ -22,6 +22,24 @@ class RequirementsConflicted(ResolverException):
|
|||
super(RequirementsConflicted, self).__init__(criterion)
|
||||
self.criterion = criterion
|
||||
|
||||
def __str__(self):
|
||||
return "Requirements conflict: {}".format(
|
||||
", ".join(repr(r) for r in self.criterion.iter_requirement()),
|
||||
)
|
||||
|
||||
|
||||
class InconsistentCandidate(ResolverException):
|
||||
def __init__(self, candidate, criterion):
|
||||
super(InconsistentCandidate, self).__init__(candidate, criterion)
|
||||
self.candidate = candidate
|
||||
self.criterion = criterion
|
||||
|
||||
def __str__(self):
|
||||
return "Provided candidate {!r} does not satisfy {}".format(
|
||||
self.candidate,
|
||||
", ".join(repr(r) for r in self.criterion.iter_requirement()),
|
||||
)
|
||||
|
||||
|
||||
class Criterion(object):
|
||||
"""Representation of possible resolution results of a package.
|
||||
|
@ -48,6 +66,13 @@ class Criterion(object):
|
|||
self.information = information
|
||||
self.incompatibilities = incompatibilities
|
||||
|
||||
def __repr__(self):
|
||||
requirements = ", ".join(
|
||||
"{!r} from {!r}".format(req, parent)
|
||||
for req, parent in self.information
|
||||
)
|
||||
return "<Criterion {}>".format(requirements)
|
||||
|
||||
@classmethod
|
||||
def from_requirement(cls, provider, requirement, parent):
|
||||
"""Build an instance from a requirement.
|
||||
|
@ -85,13 +110,15 @@ class Criterion(object):
|
|||
|
||||
def excluded_of(self, candidate):
|
||||
"""Build a new instance from this, but excluding specified candidate.
|
||||
|
||||
Returns the new instance, or None if we still have no valid candidates.
|
||||
"""
|
||||
incompats = list(self.incompatibilities)
|
||||
incompats.append(candidate)
|
||||
candidates = [c for c in self.candidates if c != candidate]
|
||||
criterion = type(self)(candidates, list(self.information), incompats)
|
||||
if not candidates:
|
||||
raise RequirementsConflicted(criterion)
|
||||
return None
|
||||
criterion = type(self)(candidates, list(self.information), incompats)
|
||||
return criterion
|
||||
|
||||
|
||||
|
@ -100,9 +127,10 @@ class ResolutionError(ResolverException):
|
|||
|
||||
|
||||
class ResolutionImpossible(ResolutionError):
|
||||
def __init__(self, requirements):
|
||||
super(ResolutionImpossible, self).__init__(requirements)
|
||||
self.requirements = requirements
|
||||
def __init__(self, causes):
|
||||
super(ResolutionImpossible, self).__init__(causes)
|
||||
# causes is a list of RequirementInformation objects
|
||||
self.causes = causes
|
||||
|
||||
|
||||
class ResolutionTooDeep(ResolutionError):
|
||||
|
@ -151,6 +179,7 @@ class Resolution(object):
|
|||
self._states.append(state)
|
||||
|
||||
def _merge_into_criterion(self, requirement, parent):
|
||||
self._r.adding_requirement(requirement)
|
||||
name = self._p.identify(requirement)
|
||||
try:
|
||||
crit = self.state.criteria[name]
|
||||
|
@ -195,11 +224,21 @@ class Resolution(object):
|
|||
except RequirementsConflicted as e:
|
||||
causes.append(e.criterion)
|
||||
continue
|
||||
|
||||
# Put newly-pinned candidate at the end. This is essential because
|
||||
# backtracking looks at this mapping to get the last pin.
|
||||
self._r.pinning(candidate)
|
||||
self.state.mapping.pop(name, None)
|
||||
self.state.mapping[name] = candidate
|
||||
self.state.criteria.update(criteria)
|
||||
|
||||
# Check the newly-pinned candidate actually works. This should
|
||||
# always pass under normal circumstances, but in the case of a
|
||||
# faulty provider, we will raise an error to notify the implementer
|
||||
# to fix find_matches() and/or is_satisfied_by().
|
||||
if not self._is_current_pin_satisfying(name, criterion):
|
||||
raise InconsistentCandidate(candidate, criterion)
|
||||
|
||||
return []
|
||||
|
||||
# All candidates tried, nothing works. This criterion is a dead
|
||||
|
@ -217,12 +256,12 @@ class Resolution(object):
|
|||
|
||||
# Retract the last candidate pin, and create a new (b).
|
||||
name, candidate = self._states.pop().mapping.popitem()
|
||||
self._r.backtracking(candidate)
|
||||
self._push_new_state()
|
||||
|
||||
try:
|
||||
# Mark the retracted candidate as incompatible.
|
||||
criterion = self.state.criteria[name].excluded_of(candidate)
|
||||
except RequirementsConflicted:
|
||||
# Mark the retracted candidate as incompatible.
|
||||
criterion = self.state.criteria[name].excluded_of(candidate)
|
||||
if criterion is None:
|
||||
# This state still does not work. Try the still previous state.
|
||||
continue
|
||||
self.state.criteria[name] = criterion
|
||||
|
@ -240,8 +279,7 @@ class Resolution(object):
|
|||
try:
|
||||
name, crit = self._merge_into_criterion(r, parent=None)
|
||||
except RequirementsConflicted as e:
|
||||
# If initial requirements conflict, nothing would ever work.
|
||||
raise ResolutionImpossible(e.requirements + [r])
|
||||
raise ResolutionImpossible(e.criterion.information)
|
||||
self.state.criteria[name] = crit
|
||||
|
||||
self._r.starting()
|
||||
|
@ -275,12 +313,10 @@ class Resolution(object):
|
|||
if failure_causes:
|
||||
result = self._backtrack()
|
||||
if not result:
|
||||
requirements = [
|
||||
requirement
|
||||
for crit in failure_causes
|
||||
for requirement in crit.iter_requirement()
|
||||
causes = [
|
||||
i for crit in failure_causes for i in crit.information
|
||||
]
|
||||
raise ResolutionImpossible(requirements)
|
||||
raise ResolutionImpossible(causes)
|
||||
|
||||
self._r.ending_round(round_index, curr)
|
||||
|
||||
|
@ -365,7 +401,9 @@ class Resolver(AbstractResolver):
|
|||
The following exceptions may be raised if a resolution cannot be found:
|
||||
|
||||
* `ResolutionImpossible`: A resolution cannot be found for the given
|
||||
combination of requirements.
|
||||
combination of requirements. The `causes` attribute of the
|
||||
exception is a list of (requirement, parent), giving the
|
||||
requirements that could not be satisfied.
|
||||
* `ResolutionTooDeep`: The dependency tree is too deeply nested and
|
||||
the resolver gave up. This is usually caused by a circular
|
||||
dependency, but you can try to resolve this by increasing the
|
||||
|
|
|
@ -17,9 +17,8 @@ requests==2.22.0
|
|||
chardet==3.0.4
|
||||
idna==2.8
|
||||
urllib3==1.25.7
|
||||
resolvelib==0.3.0
|
||||
retrying==1.3.3
|
||||
setuptools==44.0.0
|
||||
six==1.14.0
|
||||
webencodings==0.5.1
|
||||
|
||||
git+https://github.com/sarugaku/resolvelib.git@fbc8bb28d6cff98b2#egg=resolvelib
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
@ -230,6 +231,28 @@ def test_new_resolver_requires_python(
|
|||
assert_installed(script, base="0.1.0", dep=dep_version)
|
||||
|
||||
|
||||
def test_new_resolver_requires_python_error(script):
|
||||
create_basic_wheel_for_package(
|
||||
script,
|
||||
"base",
|
||||
"0.1.0",
|
||||
requires_python="<2",
|
||||
)
|
||||
result = script.pip(
|
||||
"install", "--unstable-feature=resolver",
|
||||
"--no-cache-dir", "--no-index",
|
||||
"--find-links", script.scratch_path,
|
||||
"base",
|
||||
expect_error=True,
|
||||
)
|
||||
|
||||
message = (
|
||||
"Package 'base' requires a different Python: "
|
||||
"{}.{}.{} not in '<2'".format(*sys.version_info[:3])
|
||||
)
|
||||
assert message in result.stderr, str(result)
|
||||
|
||||
|
||||
def test_new_resolver_installed(script):
|
||||
create_basic_wheel_for_package(
|
||||
script,
|
||||
|
|
Loading…
Reference in New Issue