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

Warn about broken dependencies during pip install #5000

Merged
merged 24 commits into from
Mar 30, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dcdc323
Merge branch 'misc/move-build-requirement' into resolver/warn-after-r…
pradyunsg Jan 24, 2018
0d68bc4
Merge branch 'misc/move-pytoml-to-installreq' into resolver/warn-afte…
pradyunsg Jan 24, 2018
48e50f6
Merge branch 'fix/fork-bombing' into resolver/warn-after-resolution
pradyunsg Jan 24, 2018
afb3a2a
Merge branch 'resolver/move-dependency-info-and-install-order' into r…
pradyunsg Jan 24, 2018
5ae396d
Merge branch 'refactor/reduce-action-at-distance' into resolver/warn-…
pradyunsg Jan 24, 2018
09f0941
Rework pip check to not use Distribution objects
pradyunsg Oct 28, 2017
0fa2736
Improve operations.check
pradyunsg Oct 28, 2017
980b3ff
Use canonical names
pradyunsg Jan 24, 2018
a33846b
Use namedtuple attributes
pradyunsg Jan 24, 2018
045e244
Run pip check in pip install
pradyunsg Oct 28, 2017
2c86cc3
Add tests related to normalization of names
pradyunsg Nov 1, 2017
3013f74
Fix the normalization in test
pradyunsg Jan 26, 2018
1c911bf
:art: Make the linters happy again
pradyunsg Jan 26, 2018
5fb74cb
Merge branch 'master' into resolver/warn-after-resolution
pradyunsg Jan 26, 2018
cdb8d71
Merge branch 'master' into resolver/warn-after-resolution
pradyunsg Mar 1, 2018
851518b
Merge branch 'refactor/reduce-action-at-distance' into resolver/warn-…
pradyunsg Mar 2, 2018
25124c0
Merge branch 'resolver/move-dependency-info-and-install-order' into r…
pradyunsg Mar 2, 2018
3d7fbb3
Merge branch 'master' into resolver/warn-after-resolution
pradyunsg Mar 27, 2018
cde3f4c
Verify returncode in all pip check tests
pradyunsg Mar 28, 2018
e04e5a6
mypy gets more data
pradyunsg Mar 28, 2018
f286fb8
pip check should care about the markers
pradyunsg Mar 28, 2018
49767ec
Use string representation for comparing
pradyunsg Mar 28, 2018
da2d7ce
Merge branch 'master' into resolver/warn-after-resolution
pradyunsg Mar 30, 2018
6bdc73d
:newspaper:
pradyunsg Mar 30, 2018
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/5000.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pip install now prints an error message when it installs an incompatible version of a dependency.
25 changes: 14 additions & 11 deletions src/pip/_internal/commands/check.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import logging

from pip._internal.basecommand import Command
from pip._internal.operations.check import check_requirements
from pip._internal.operations.check import (
check_package_set, create_package_set_from_installed,
)
from pip._internal.utils.misc import get_installed_distributions

logger = logging.getLogger(__name__)
Expand All @@ -15,25 +17,26 @@ class CheckCommand(Command):
summary = 'Verify installed packages have compatible dependencies.'

def run(self, options, args):
dists = get_installed_distributions(local_only=False, skip=())
missing_reqs_dict, incompatible_reqs_dict = check_requirements(dists)
package_set = create_package_set_from_installed()
missing, conflicting = check_package_set(package_set)

for dist in dists:
for requirement in missing_reqs_dict.get(dist.key, []):
for project_name in missing:
version = package_set[project_name].version
for dependency in missing[project_name]:
logger.info(
"%s %s requires %s, which is not installed.",
dist.project_name, dist.version, requirement.project_name,
project_name, version, dependency[0],
)

for requirement, actual in incompatible_reqs_dict.get(
dist.key, []):
for project_name in conflicting:
version = package_set[project_name].version
for dep_name, dep_version, req in conflicting[project_name]:
logger.info(
"%s %s has requirement %s, but you have %s %s.",
dist.project_name, dist.version, requirement,
actual.project_name, actual.version,
project_name, version, req, dep_name, dep_version,
)

if missing_reqs_dict or incompatible_reqs_dict:
if missing or conflicting:
return 1
else:
logger.info("No broken requirements found.")
39 changes: 39 additions & 0 deletions src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
CommandError, InstallationError, PreviousBuildDirError,
)
from pip._internal.locations import distutils_scheme, virtualenv_no_global
from pip._internal.operations.check import check_install_conflicts
from pip._internal.operations.prepare import RequirementPreparer
from pip._internal.req import RequirementSet, install_given_reqs
from pip._internal.resolve import Resolver
Expand Down Expand Up @@ -172,6 +173,13 @@ def __init__(self, *args, **kw):
default=True,
help="Do not warn when installing scripts outside PATH",
)
cmd_opts.add_option(
"--no-warn-conflicts",
action="store_false",
dest="warn_about_conflicts",
default=True,
help="Do not warn about broken dependencies",
)

cmd_opts.add_option(cmdoptions.no_binary())
cmd_opts.add_option(cmdoptions.only_binary())
Expand Down Expand Up @@ -300,6 +308,15 @@ def run(self, options, args):
to_install = resolver.get_installation_order(
requirement_set
)

# Consistency Checking of the package set we're installing.
should_warn_about_conflicts = (
not options.ignore_dependencies and
options.warn_about_conflicts
)
if should_warn_about_conflicts:
self._warn_about_conflicts(to_install)

installed = install_given_reqs(
to_install,
install_options,
Expand Down Expand Up @@ -426,6 +443,28 @@ def _handle_target_dir(self, target_dir, target_temp_dir, upgrade):
target_item_dir
)

def _warn_about_conflicts(self, to_install):
package_set, _dep_info = check_install_conflicts(to_install)
missing, conflicting = _dep_info

# NOTE: There is some duplication here from pip check
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],
)

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


def get_lib_location_guesses(*args, **kwargs):
scheme = distutils_scheme('', *args, **kwargs)
Expand Down
116 changes: 86 additions & 30 deletions src/pip/_internal/operations/check.py
Original file line number Diff line number Diff line change
@@ -1,46 +1,102 @@
"""Validation of dependencies of packages
"""

from collections import namedtuple

def check_requirements(installed_dists):
missing_reqs_dict = {}
incompatible_reqs_dict = {}
from pip._vendor.packaging.utils import canonicalize_name

for dist in installed_dists:
missing_reqs = list(get_missing_reqs(dist, installed_dists))
if missing_reqs:
missing_reqs_dict[dist.key] = missing_reqs
from pip._internal.operations.prepare import make_abstract_dist

incompatible_reqs = list(get_incompatible_reqs(dist, installed_dists))
if incompatible_reqs:
incompatible_reqs_dict[dist.key] = incompatible_reqs
from pip._internal.utils.misc import get_installed_distributions
from pip._internal.utils.typing import MYPY_CHECK_RUNNING

return (missing_reqs_dict, incompatible_reqs_dict)
if MYPY_CHECK_RUNNING:
from pip._internal.req.req_install import InstallRequirement
from typing import Any, Dict, Iterator, Set, Tuple, List

# Shorthands
PackageSet = Dict[str, 'PackageDetails']
Missing = Tuple[str, Any]
Conflicting = Tuple[str, str, Any]

def get_missing_reqs(dist, installed_dists):
"""Return all of the requirements of `dist` that aren't present in
`installed_dists`.
MissingDict = Dict[str, List[Missing]]
ConflictingDict = Dict[str, List[Conflicting]]
CheckResult = Tuple[MissingDict, ConflictingDict]

PackageDetails = namedtuple('PackageDetails', ['version', 'requires'])


def create_package_set_from_installed(**kwargs):
# type: (**Any) -> PackageSet
"""Converts a list of distributions into a PackageSet.
"""
retval = {}
for dist in get_installed_distributions(**kwargs):
name = canonicalize_name(dist.project_name)
retval[name] = PackageDetails(dist.version, dist.requires())
return retval


def check_package_set(package_set):
# type: (PackageSet) -> CheckResult
"""Check if a package set is consistent
"""
installed_names = {d.project_name.lower() for d in installed_dists}
missing_requirements = set()
missing = dict()
conflicting = dict()

for package_name in package_set:
# Info about dependencies of package_name
missing_deps = set() # type: Set[Missing]
conflicting_deps = set() # type: Set[Conflicting]

for requirement in dist.requires():
if requirement.project_name.lower() not in installed_names:
missing_requirements.add(requirement)
yield requirement
for req in package_set[package_name].requires:
name = canonicalize_name(req.project_name) # type: str

# Check if it's missing
if name not in package_set:
missed = True
if req.marker is not None:
missed = req.marker.evaluate()
if missed:
missing_deps.add((name, req))
continue

def get_incompatible_reqs(dist, installed_dists):
"""Return all of the requirements of `dist` that are present in
`installed_dists`, but have incompatible versions.
# Check if there's a conflict
version = package_set[name].version # type: str
if version not in req.specifier:
conflicting_deps.add((name, version, req))

def str_key(x):
return str(x)

if missing_deps:
missing[package_name] = sorted(missing_deps, key=str_key)
if conflicting_deps:
conflicting[package_name] = sorted(conflicting_deps, key=str_key)

return missing, conflicting


def check_install_conflicts(to_install):
# type: (List[InstallRequirement]) -> Tuple[PackageSet, CheckResult]
"""For checking if the dependency graph would be consistent after \
installing given requirements
"""
installed_dists_by_name = {}
for installed_dist in installed_dists:
installed_dists_by_name[installed_dist.project_name] = installed_dist
# Start from the current state
state = create_package_set_from_installed()
_simulate_installation_of(to_install, state)
return state, check_package_set(state)


for requirement in dist.requires():
present_dist = installed_dists_by_name.get(requirement.project_name)
# NOTE from @pradyunsg
# This required a minor update in dependency link handling logic over at
# operations.prepare.IsSDist.dist() to get it working
def _simulate_installation_of(to_install, state):
# type: (List[InstallRequirement], PackageSet) -> None
"""Computes the version of packages after installing to_install.
"""

if present_dist and present_dist not in requirement:
yield (requirement, present_dist)
# Modify it as installing requirement_set would (assuming no errors)
for inst_req in to_install:
dist = make_abstract_dist(inst_req).dist(finder=None)
state[dist.key] = PackageDetails(dist.version, dist.requires())
2 changes: 1 addition & 1 deletion src/pip/_internal/operations/prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ class IsSDist(DistAbstraction):
def dist(self, finder):
dist = self.req.get_dist()
# FIXME: shouldn't be globally added.
if dist.has_metadata('dependency_links.txt'):
if finder and dist.has_metadata('dependency_links.txt'):
finder.add_dependency_links(
dist.get_metadata_lines('dependency_links.txt')
)
Expand Down
1 change: 0 additions & 1 deletion src/pip/_internal/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,6 @@ def _get_abstract_dist_for(self, req):
if req.editable:
return self.preparer.prepare_editable_requirement(
req, self.require_hashes, self.use_user_site, self.finder,

)

# satisfied_by is only evaluated by calling _check_skip_installed,
Expand Down
105 changes: 90 additions & 15 deletions tests/functional/test_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def test_basic_check_clean(script):
"No broken requirements found.",
)
assert matches_expected_lines(result.stdout, expected_lines)
assert result.returncode == 0


def test_basic_check_missing_dependency(script):
Expand Down Expand Up @@ -55,7 +56,9 @@ def test_basic_check_broken_dependency(script):
name='broken', version='0.1',
)
# Let's install broken==0.1
res = script.pip('install', '--no-index', broken_path)
res = script.pip(
'install', '--no-index', broken_path, '--no-warn-conflicts',
)
assert "Successfully installed broken-0.1" in res.stdout, str(res)

result = script.pip('check', expect_error=True)
Expand Down Expand Up @@ -96,25 +99,97 @@ def test_basic_check_broken_dependency_and_missing_dependency(script):
assert result.returncode == 1


def test_check_complex_names(script):
# Check that uppercase letters and '-' are dealt with
# Setup two small projects
pkga_path = create_test_package_with_setup(
def test_check_complicated_name_missing(script):
package_a_path = create_test_package_with_setup(
script,
name='pkga', version='1.0', install_requires=['Complex_Name==0.1'],
name='package_A', version='1.0',
install_requires=['Dependency-B>=1.0'],
)

# Without dependency
result = script.pip('install', '--no-index', package_a_path, '--no-deps')
assert "Successfully installed package-A-1.0" in result.stdout, str(result)

result = script.pip('check', expect_error=True)
expected_lines = (
"package-a 1.0 requires dependency-b, which is not installed.",
)
assert matches_expected_lines(result.stdout, expected_lines)
assert result.returncode == 1

complex_path = create_test_package_with_setup(

def test_check_complicated_name_broken(script):
package_a_path = create_test_package_with_setup(
script,
name='package_A', version='1.0',
install_requires=['Dependency-B>=1.0'],
)
dependency_b_path_incompatible = create_test_package_with_setup(
script,
name='Complex-Name', version='0.1',
name='dependency-b', version='0.1',
)

res = script.pip('install', '--no-index', complex_path)
assert "Successfully installed Complex-Name-0.1" in res.stdout, str(res)
# With broken dependency
result = script.pip('install', '--no-index', package_a_path, '--no-deps')
assert "Successfully installed package-A-1.0" in result.stdout, str(result)

res = script.pip('install', '--no-index', pkga_path, '--no-deps')
assert "Successfully installed pkga-1.0" in res.stdout, str(res)
result = script.pip(
'install', '--no-index', dependency_b_path_incompatible, '--no-deps',
)
assert "Successfully installed dependency-b-0.1" in result.stdout

result = script.pip('check', expect_error=True)
expected_lines = (
"package-a 1.0 has requirement Dependency-B>=1.0, but you have "
"dependency-b 0.1.",
)
assert matches_expected_lines(result.stdout, expected_lines)
assert result.returncode == 1


def test_check_complicated_name_clean(script):
package_a_path = create_test_package_with_setup(
script,
name='package_A', version='1.0',
install_requires=['Dependency-B>=1.0'],
)
dependency_b_path = create_test_package_with_setup(
script,
name='dependency-b', version='1.0',
)

result = script.pip('install', '--no-index', package_a_path, '--no-deps')
assert "Successfully installed package-A-1.0" in result.stdout, str(result)

result = script.pip(
'install', '--no-index', dependency_b_path, '--no-deps',
)
assert "Successfully installed dependency-b-1.0" in result.stdout

# Check that Complex_Name is correctly dealt with
res = script.pip('check')
assert "No broken requirements found." in res.stdout, str(res)
result = script.pip('check', expect_error=True)
expected_lines = (
"No broken requirements found.",
)
assert matches_expected_lines(result.stdout, expected_lines)
assert result.returncode == 0


def test_check_considers_conditional_reqs(script):
package_a_path = create_test_package_with_setup(
script,
name='package_A', version='1.0',
install_requires=[
"Dependency-B>=1.0; python_version != '2.7'",
"Dependency-B>=2.0; python_version == '2.7'",
],
)

result = script.pip('install', '--no-index', package_a_path, '--no-deps')
assert "Successfully installed package-A-1.0" in result.stdout, str(result)

result = script.pip('check', expect_error=True)
expected_lines = (
"package-a 1.0 requires dependency-b, which is not installed.",
)
assert matches_expected_lines(result.stdout, expected_lines)
assert result.returncode == 1
Loading