Skip to content

Commit

Permalink
Merge pull request #8594 from pradyunsg/improve-install-conflict-warning
Browse files Browse the repository at this point in the history
  • Loading branch information
pradyunsg authored Jul 28, 2020
2 parents b6c99af + 9033824 commit a8edffd
Show file tree
Hide file tree
Showing 7 changed files with 92 additions and 45 deletions.
65 changes: 55 additions & 10 deletions src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from pip._internal.operations.check import check_install_conflicts
from pip._internal.req import install_given_reqs
from pip._internal.req.req_tracker import get_requirement_tracker
from pip._internal.utils.datetime import today_is_later_than
from pip._internal.utils.deprecation import deprecated
from pip._internal.utils.distutils_args import parse_distutils_args
from pip._internal.utils.filesystem import test_writable_dir
Expand Down Expand Up @@ -444,7 +445,10 @@ def run(self, options, args):
items.append(item)

if conflicts is not None:
self._warn_about_conflicts(conflicts)
self._warn_about_conflicts(
conflicts,
new_resolver='2020-resolver' in options.features_enabled,
)

installed_desc = ' '.join(items)
if installed_desc:
Expand Down Expand Up @@ -536,27 +540,68 @@ def _determine_conflicts(self, to_install):
)
return None

def _warn_about_conflicts(self, conflict_details):
# type: (ConflictDetails) -> None
def _warn_about_conflicts(self, conflict_details, new_resolver):
# type: (ConflictDetails, bool) -> None
package_set, (missing, conflicting) = conflict_details
if not missing and not conflicting:
return

parts = [] # type: List[str]
if not new_resolver:
parts.append(
"After October 2020 you may experience errors when installing "
"or updating packages. This is because pip will change the "
"way that it resolves dependency conflicts.\n"
)
parts.append(
"We recommend you use --use-feature=2020-resolver to test "
"your packages with the new resolver before it becomes the "
"default.\n"
)
elif not today_is_later_than(year=2020, month=7, day=31):
# NOTE: trailing newlines here are intentional
parts.append(
"Pip will install or upgrade your package(s) and its "
"dependencies without taking into account other packages you "
"already have installed. This may cause an uncaught "
"dependency conflict.\n"
)
form_link = "https://forms.gle/cWKMoDs8sUVE29hz9"
parts.append(
"If you would like pip to take your other packages into "
"account, please tell us here: {}\n".format(form_link)
)

# NOTE: There is some duplication here, with commands/check.py
for project_name in missing:
version = package_set[project_name][0]
for dependency in missing[project_name]:
logger.critical(
"%s %s requires %s, which is not installed.",
project_name, version, dependency[1],
message = (
"{name} {version} requires {requirement}, "
"which is not installed."
).format(
name=project_name,
version=version,
requirement=dependency[1],
)
parts.append(message)

for project_name in conflicting:
version = package_set[project_name][0]
for dep_name, dep_version, req in conflicting[project_name]:
logger.critical(
"%s %s has requirement %s, but you'll have %s %s which is "
"incompatible.",
project_name, version, req, dep_name, dep_version,
message = (
"{name} {version} requires {requirement}, but you'll have "
"{dep_name} {dep_version} which is incompatible."
).format(
name=project_name,
version=version,
requirement=req,
dep_name=dep_name,
dep_version=dep_version,
)
parts.append(message)

logger.critical("\n".join(parts))


def get_lib_location_guesses(
Expand Down
14 changes: 14 additions & 0 deletions src/pip/_internal/utils/datetime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""For when pip wants to check the date or time.
"""

from __future__ import absolute_import

import datetime


def today_is_later_than(year, month, day):
# type: (int, int, int) -> bool
today = datetime.date.today()
given = datetime.date(year, month, day)

return today > given
8 changes: 5 additions & 3 deletions tests/functional/test_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@

def matches_expected_lines(string, expected_lines):
# Ignore empty lines
output_lines = set(filter(None, string.splitlines()))
# Match regardless of order
return set(output_lines) == set(expected_lines)
output_lines = list(filter(None, string.splitlines()))
# We'll match the last n lines, given n lines to match.
last_few_output_lines = output_lines[-len(expected_lines):]
# And order does not matter
return set(last_few_output_lines) == set(expected_lines)


def test_basic_check_clean(script):
Expand Down
2 changes: 1 addition & 1 deletion tests/functional/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -1697,7 +1697,7 @@ def test_install_conflict_results_in_warning(script, data):
result2 = script.pip(
'install', '--no-index', pkgB_path, allow_stderr_error=True,
)
assert "pkga 1.0 has requirement pkgb==1.0" in result2.stderr, str(result2)
assert "pkga 1.0 requires pkgb==1.0" in result2.stderr, str(result2)
assert "Successfully installed pkgB-2.0" in result2.stdout, str(result2)


Expand Down
37 changes: 14 additions & 23 deletions tests/functional/test_install_check.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
from tests.lib import create_test_package_with_setup


def matches_expected_lines(string, expected_lines, exact=True):
if exact:
return set(string.splitlines()) == set(expected_lines)
# If not exact, check that all expected lines are present
def contains_expected_lines(string, expected_lines):
return set(expected_lines) <= set(string.splitlines())


def test_check_install_canonicalization(script, deprecated_python):
def test_check_install_canonicalization(script):
pkga_path = create_test_package_with_setup(
script,
name='pkgA',
Expand Down Expand Up @@ -38,11 +35,10 @@ def test_check_install_canonicalization(script, deprecated_python):
allow_stderr_error=True,
)
expected_lines = [
"ERROR: pkga 1.0 requires SPECIAL.missing, which is not installed.",
"pkga 1.0 requires SPECIAL.missing, which is not installed.",
]
# Deprecated python versions produce an extra warning on stderr
assert matches_expected_lines(
result.stderr, expected_lines, exact=not deprecated_python)
assert contains_expected_lines(result.stderr, expected_lines)
assert result.returncode == 0

# Install the second missing package and expect that there is no warning
Expand All @@ -51,21 +47,19 @@ def test_check_install_canonicalization(script, deprecated_python):
result = script.pip(
'install', '--no-index', special_path, '--quiet',
)
assert matches_expected_lines(
result.stderr, [], exact=not deprecated_python)
assert "requires" not in result.stderr
assert result.returncode == 0

# Double check that all errors are resolved in the end
result = script.pip('check')
expected_lines = [
"No broken requirements found.",
]
assert matches_expected_lines(result.stdout, expected_lines)
assert contains_expected_lines(result.stdout, expected_lines)
assert result.returncode == 0


def test_check_install_does_not_warn_for_out_of_graph_issues(
script, deprecated_python):
def test_check_install_does_not_warn_for_out_of_graph_issues(script):
pkg_broken_path = create_test_package_with_setup(
script,
name='broken',
Expand All @@ -85,33 +79,30 @@ def test_check_install_does_not_warn_for_out_of_graph_issues(

# Install a package without it's dependencies
result = script.pip('install', '--no-index', pkg_broken_path, '--no-deps')
# Deprecated python versions produce an extra warning on stderr
assert matches_expected_lines(
result.stderr, [], exact=not deprecated_python)
assert "requires" not in result.stderr

# Install conflict package
result = script.pip(
'install', '--no-index', pkg_conflict_path, allow_stderr_error=True,
)
assert matches_expected_lines(result.stderr, [
"ERROR: broken 1.0 requires missing, which is not installed.",
assert contains_expected_lines(result.stderr, [
"broken 1.0 requires missing, which is not installed.",
(
"ERROR: broken 1.0 has requirement conflict<1.0, but "
"broken 1.0 requires conflict<1.0, but "
"you'll have conflict 1.0 which is incompatible."
),
], exact=not deprecated_python)
])

# Install unrelated package
result = script.pip(
'install', '--no-index', pkg_unrelated_path, '--quiet',
)
# should not warn about broken's deps when installing unrelated package
assert matches_expected_lines(
result.stderr, [], exact=not deprecated_python)
assert "requires" not in result.stderr

result = script.pip('check', expect_error=True)
expected_lines = [
"broken 1.0 requires missing, which is not installed.",
"broken 1.0 has requirement conflict<1.0, but you have conflict 1.0.",
]
assert matches_expected_lines(result.stdout, expected_lines)
assert contains_expected_lines(result.stdout, expected_lines)
7 changes: 2 additions & 5 deletions tests/yaml/conflict_1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,9 @@ cases:
response:
- error:
code: 0
stderr: ['requirement', 'is\s+incompatible']
stderr: ['incompatible']
skip: old
# -- currently the error message is:
# a 1.0.0 has requirement B==1.0.0, but you'll have b 2.0.0 which is
# incompatible.
# -- better would be:
# -- a good error message would be:
# A 1.0.0 has incompatible requirements B==1.0.0, B==2.0.0

-
Expand Down
4 changes: 1 addition & 3 deletions tests/yaml/conflicting_triangle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,5 @@ cases:
- C 1.0.0
- error:
code: 0
stderr: ['requirement c==1\.0\.0', 'is incompatible']
stderr: ['c==1\.0\.0', 'incompatible']
skip: old
# -- currently the error message is:
# a 1.0.0 has requirement C==1.0.0, but you'll have c 2.0.0 which is incompatible.

0 comments on commit a8edffd

Please sign in to comment.