Merge pull request #8004 from uranusjr/requires-python-error

New Resolver: Raise UnsupportedPythonVersion for Requires-Python mismatch
This commit is contained in:
Pradyun Gedam 2020-04-13 19:42:51 +05:30 committed by GitHub
commit 92c9f8136a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 172 additions and 62 deletions

View File

@ -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

View File

@ -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

View File

@ -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():

View File

@ -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,

View File

@ -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.
"""

View File

@ -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

View File

@ -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

View File

@ -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,