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

Improve install conflict warning #8594

Merged
merged 14 commits into from
Jul 28, 2020
Merged
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.