Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Present found conflicts when trying to resolve them #10258

Closed
wants to merge 11 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/10210.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Display found conflicts before pip delves into version resolution.
112 changes: 70 additions & 42 deletions src/pip/_internal/resolution/resolvelib/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from pip._vendor.packaging.specifiers import SpecifierSet
from pip._vendor.packaging.utils import NormalizedName, canonicalize_name
from pip._vendor.resolvelib import ResolutionImpossible
from pip._vendor.resolvelib.resolvers import RequirementInformation

from pip._internal.cache import CacheEntry, WheelCache
from pip._internal.exceptions import (
Expand Down Expand Up @@ -73,11 +74,15 @@ class ConflictCause(Protocol):
requirement: RequiresPythonRequirement
parent: Candidate

Causes = Sequence[RequirementInformation[Requirement, Candidate]]
else:
Causes = Sequence

logger = logging.getLogger(__name__)

C = TypeVar("C")
Cache = Dict[Link, C]
Constraints = Dict[str, Constraint]


class CollectedRootRequirements(NamedTuple):
Expand Down Expand Up @@ -600,19 +605,15 @@ def _report_single_requirement_conflict(
def get_installation_error(
self,
e: "ResolutionImpossible[Requirement, Candidate]",
constraints: Dict[str, Constraint],
constraints: Constraints,
) -> InstallationError:

assert e.causes, "Installation error reported with no cause"
failure_causes = e.causes
assert failure_causes, "Installation error reported with no cause"

# If one of the things we can't solve is "we need Python X.Y",
# that is what we report.
requires_python_causes = [
cause
for cause in e.causes
if isinstance(cause.requirement, RequiresPythonRequirement)
and not cause.requirement.is_satisfied_by(self._python_candidate)
]
requires_python_causes = self.extract_requires_python_causes(failure_causes)
if requires_python_causes:
# The comprehension above makes sure all Requirement instances are
# RequiresPythonRequirement, so let's cast for convenience.
Expand All @@ -625,14 +626,53 @@ def get_installation_error(

# The simplest case is when we have *one* cause that can't be
# satisfied. We just report that case.
if len(e.causes) == 1:
req, parent = e.causes[0]
if len(failure_causes) == 1:
req, parent = failure_causes[0]
if req.name not in constraints:
return self._report_single_requirement_conflict(req, parent)

# OK, we now have a list of requirements that can't all be
# satisfied at once.

logger.critical(self.triggers_message(failure_causes))

msg = (
self.causes_message(failure_causes, constraints)
+ "\n\n"
+ "To fix this you could try to:\n"
+ "1. loosen the range of package versions you've specified\n"
+ "2. remove package versions to allow pip attempt to solve "
+ "the dependency conflict\n"
)

logger.info(msg)

return DistributionNotFound(
"ResolutionImpossible: for help visit "
"https://pip.pypa.io/en/latest/topics/dependency-resolution/"
"#dealing-with-dependency-conflicts"
)

@staticmethod
def causes_message(causes: Causes, constraints: Constraints) -> str:
msg = "\nThe conflict is caused by:"
relevant_constraints = set()
for req, parent in causes:
if req.name in constraints:
relevant_constraints.add(req.name)
msg = msg + "\n "
if parent:
msg = msg + f"{parent.name} {parent.version} depends on "
else:
msg = msg + "The user requested "
msg = msg + req.format_for_error()
for key in relevant_constraints:
spec = constraints[key].specifier
msg += f"\n The user requested (constraint) {key}{spec}"
return msg

@staticmethod
def triggers_message(causes: Causes) -> str:
# A couple of formatting helpers
def text_join(parts: List[str]) -> str:
if len(parts) == 1:
Expand All @@ -649,53 +689,41 @@ def describe_trigger(parent: Candidate) -> str:
return str(ireq.comes_from)

triggers = set()
for req, parent in e.causes:
for req, parent in causes:
if parent is None:
# This is a root requirement, so we can report it directly
trigger = req.format_for_error()
else:
trigger = describe_trigger(parent)
triggers.add(trigger)

if triggers:
info = text_join(sorted(triggers))
else:
info = "the requested packages"

msg = (
"Cannot install {} because these package versions "
"have conflicting dependencies.".format(info)
)
logger.critical(msg)
msg = "\nThe conflict is caused by:"
return msg

relevant_constraints = set()
for req, parent in e.causes:
if req.name in constraints:
relevant_constraints.add(req.name)
msg = msg + "\n "
if parent:
msg = msg + f"{parent.name} {parent.version} depends on "
else:
msg = msg + "The user requested "
msg = msg + req.format_for_error()
for key in relevant_constraints:
spec = constraints[key].specifier
msg += f"\n The user requested (constraint) {key}{spec}"
def extract_requires_python_causes(self, causes: Causes) -> Causes:
return [
cause
for cause in causes
if isinstance(cause.requirement, RequiresPythonRequirement)
and not cause.requirement.is_satisfied_by(self._python_candidate)
]

msg = (
msg
+ "\n\n"
+ "To fix this you could try to:\n"
+ "1. loosen the range of package versions you've specified\n"
+ "2. remove package versions to allow pip attempt to solve "
+ "the dependency conflict\n"
)
def get_conflict_message(
self, causes: Causes, constraints: Constraints
) -> Optional[str]:
requires_python_causes = self.extract_requires_python_causes(causes)
if requires_python_causes or len(causes) == 1:
# no message when python causes or a single failure
# since this is probably a genuine problem
return None

logger.info(msg)
# OK, we now have a list of requirements that can't all be
# satisfied at once.

return DistributionNotFound(
"ResolutionImpossible: for help visit "
"https://pip.pypa.io/en/latest/topics/dependency-resolution/"
"#dealing-with-dependency-conflicts"
)
return self.triggers_message(causes) + self.causes_message(causes, constraints)
23 changes: 21 additions & 2 deletions src/pip/_internal/resolution/resolvelib/reporter.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
from collections import defaultdict
from logging import getLogger
from typing import Any, DefaultDict
from typing import TYPE_CHECKING, Any, Callable, DefaultDict, Optional, Sequence

from pip._vendor.resolvelib.reporters import BaseReporter
from pip._vendor.resolvelib.resolvers import RequirementInformation

from .base import Candidate, Requirement

if TYPE_CHECKING:
Causes = Sequence[RequirementInformation[Requirement, Candidate]]
else:
Causes = Sequence


logger = getLogger(__name__)


class PipReporter(BaseReporter):
def __init__(self) -> None:
def __init__(
self, conflicts_message_generator: Callable[[Causes], Optional[str]]
) -> None:
self.conflicts_message_generator = conflicts_message_generator
self.resolved_conflicts = False
self.backtracks_by_package: DefaultDict[str, int] = defaultdict(int)

self._messages_at_backtrack = {
Expand Down Expand Up @@ -42,6 +53,11 @@ def backtracking(self, candidate: Candidate) -> None:
message = self._messages_at_backtrack[count]
logger.info("INFO: %s", message.format(package_name=candidate.name))

def resolving_conflicts(self, causes: Any) -> None:
if not self.resolved_conflicts:
self.resolved_conflicts = True
logger.info("INFO: %s", self.conflicts_message_generator(causes))


class PipDebuggingReporter(BaseReporter):
"""A reporter that does an info log for every event it sees."""
Expand All @@ -64,5 +80,8 @@ def adding_requirement(self, requirement: Requirement, parent: Candidate) -> Non
def backtracking(self, candidate: Candidate) -> None:
logger.info("Reporter.backtracking(%r)", candidate)

def resolving_conflicts(self, causes: Any) -> None:
logger.info("Reporter.resolving_conflicts(%r)", causes)

def pinning(self, candidate: Candidate) -> None:
logger.info("Reporter.pinning(%r)", candidate)
7 changes: 6 additions & 1 deletion src/pip/_internal/resolution/resolvelib/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,12 @@ def resolve(
if "PIP_RESOLVER_DEBUG" in os.environ:
reporter: BaseReporter = PipDebuggingReporter()
else:
reporter = PipReporter()
reporter = PipReporter(
functools.partial(
self.factory.get_conflict_message,
constraints=collected.constraints,
)
)
resolver: RLResolver[Requirement, Candidate, str] = RLResolver(
provider,
reporter,
Expand Down
45 changes: 45 additions & 0 deletions tests/functional/test_new_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,51 @@ def test_new_resolver_presents_messages_when_backtracking_a_lot(script, N):
assert "press Ctrl + C" in result.stdout


def test_new_resolver_presents_conflicts_when_resolving_conflicts_for_the_first_time(
script,
):
def conflict_message(v):
return (
f"INFO: Cannot install a=={v}.0.0 and b==1.0.0 "
f"because these package versions have conflicting dependencies.\n"
f"The conflict is caused by:\n"
f" a {v}.0.0 depends on c=={v}.0.0\n"
f" b 1.0.0 depends on c==1.0.0"
)

packages = [
("a", "3.0.0", ["c==3.0.0"]),
("a", "2.0.0", ["c==2.0.0"]),
("a", "1.0.0", ["c==1.0.0"]),
("b", "1.0.0", ["c==1.0.0"]),
("c", "1.0.0", []),
("c", "2.0.0", []),
("c", "3.0.0", []),
]

for name, version, depends in packages:
create_basic_wheel_for_package(script, name, version, depends=depends)

# Install A and B
result = script.pip(
"install",
"--no-cache-dir",
"--no-index",
"--find-links",
script.scratch_path,
"a",
"b",
)

script.assert_installed(A="1.0.0", B="1.0.0", C="1.0.0")
first_message = conflict_message(3)
second_message = conflict_message(2)

stdout = result.stdout
assert first_message in stdout
assert second_message not in stdout


@pytest.mark.parametrize(
"metadata_version",
[
Expand Down