Repository: pypa/pip

Issue:
[10.0.0beta] Installation emits extra warnings for unrelated packages
* Pip version: 516adb4793b1d89ea6bfe19a5e892681d131f427 (current `release/10.0.0` HEAD)
* Python version: `3.6.4`
* Operating system: `Darwin 17.5.0 x86_64 i386`

### Description:

Running `pip install <package>` will emit warnings about missing dependencies for unrelated packages (essentially, the output of `pip check`). I would expect to see warnings only related to the package I'm attempting to install.

This seems similar to some fixed issues (#5134, #5160, #5195) but still occurs after the fixes for those issues.

### What I've run:
The actual packages used don't matter: when a package A has unmet dependency B, installing C (with no shared dependencies with A) will warn that B is missing.
```
$ pip install freezegun
Collecting freezegun
  Using cached freezegun-0.3.10-py2.py3-none-any.whl
Collecting six (from freezegun)
  Using cached six-1.11.0-py2.py3-none-any.whl
Collecting python-dateutil!=2.0,>=1.0 (from freezegun)
  Using cached python_dateutil-2.7.2-py2.py3-none-any.whl
Installing collected packages: six, python-dateutil, freezegun
Successfully installed freezegun-0.3.10 python-dateutil-2.7.2 six-1.11.0

$ pip uninstall six
Uninstalling six-1.11.0:
  Would remove:
    /private/tmp/env/lib/python3.6/site-packages/six-1.11.0.dist-info/*
    /private/tmp/env/lib/python3.6/site-packages/six.py
Proceed (y/n)? y
  Successfully uninstalled six-1.11.0

$ pip install black
Collecting black
  Using cached black-18.4a0-py36-none-any.whl
Collecting attrs>=17.4.0 (from black)
  Using cached attrs-17.4.0-py2.py3-none-any.whl
Collecting click (from black)
  Using cached click-6.7-py2.py3-none-any.whl
python-dateutil 2.7.2 requires six>=1.5, which is not installed. <============== I don't expect to see this line
freezegun 0.3.10 requires six, which is not installed.           <============== I don't expect to see this line
Installing collected packages: attrs, click, black
Successfully installed attrs-17.4.0 black-18.4a0 click-6.7
```



Discussion:
Does this occur with 9.x, too?
Hey @di!

This is intentional - to cover a user with an already broken environment, by telling them that they have a broken dependency if the operation they perform doesn't fix it.

I'm curious, is there some reason that you don't want to know that there's a broken dependency?

@cjerdonek This is new behaviour in pip 10. #5000
@pradyunsg I guess I was just surprised by it since it's new. This warning made me think that something went wrong with the command I had run, when in fact I had just botched up my environment days ago, and was just seeing the results much later.

Fine with me if this was planned all along and should be closed, I just think it's probably going to catch a lot of people off guard when they see it for the first time.
I looked at the original PR, and doesn’t its news item suggest that only
errors about what’s being installed will now be printed?

> pip install now prints an error message when it installs an incompatible
version of a dependency.

Otherwise, shouldn’t it say something like, “when it finds an error in the
dependencies in your environment”?


On Tue, Apr 10, 2018 at 9:18 AM Dustin Ingram <notifications@github.com>
wrote:

> @pradyunsg <https://github.com/pradyunsg> I guess I was just surprised by
> it since it's new. This warning made me think that something went wrong
> with the command I had run, when in fact I had just botched up my
> environment days ago, and was just seeing the results much later.
>
> Fine with me if this was planned all along and should be closed, I just
> think it's probably going to catch a lot of people off guard when they see
> it for the first time.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/pypa/pip/issues/5196#issuecomment-380160608>, or mute
> the thread
> <https://github.com/notifications/unsubscribe-auth/AAVt7r5ZN14seezKPwPoSiI6YBrVYkGkks5tnNs9gaJpZM4TNJZS>
> .
>

Until this issue came up, I never really considered the possibility of unrelated incompatibilities being flagged when something was installed. I doubt it's a particularly common situation - once your environment has no broken dependencies, anything new that gets flagged *will* be because of what you've just installed - but I agree the message is a little surprising if it's reporting an existing issue.

I don't think it's a major issue, personally - and in the long run we'll be getting a proper resolver which will avoid such broken dependencies in the first place - so I don't think it needs to be a release blocker. If someone wants to submit a PR fixing this for pip 10.1, I'd be fine with that.
I will agree that my various environments are probably uncommon -- I usually have a few unmet or broken dependencies. 🙂 

Even with a proper resolver, though, won't this still be possible to reproduce? In the example above, what would change? Would `pip uninstall` refuse to uninstall the dependency without installing what depends on it? Or would it just uninstall everything that depends on it? Or would it work just like it does now?
I've added an issue against the resolver project to consider how to handle that gracefully. You're right that people can have unmet or broken dependencies. My assumption is that when they do, it's because they didn't realise it, and they would fix it once informed. And in the context of a full resolver, pip itself would in effect make sure that once an environment is "good", it will stay that way But there's certainly a usability question over how `pip install foo` with a full resolver should should handle such broken environments when first encountered - "helpfully" tidying up unrelated issues is a bit presumptuous, but conversely, not doing so means that we can't state unequivocally that pip won't leave you with a broken environment.

Going back to the current situation, I guess a key question is - when you saw those warnings (not in your test case, but in your actual environment), were you motivated to fix them, or did you want to leave things as they were? And if the latter, was that because you didn't believe the warnings mattered ("I'm sure it will work anyway") or something else (I find it hard to think of another reason you'd *want* to leave your environment broken, apart from "I don't want to deal with this now" - but that's fine, you're motivated to fix them, but we're not forcing you to do so immediately).
Well, I wasn't exactly in prod, so once I got past "Huh, why didn't that work" and "Wait, why does X even depend on Y?", I didn't care, but mostly because it was just a one-off environment that I was doing some testing in.

If it had been a more important environment I might have cared, but usually I'm either a) not `pip installing` willy nilly in said environments, or b) not actually looking at the output of `pip install` unless it has failed with an exit code. So I think this warning is either going to confuse me unnecessarily or just never get noticed, but again, I realize that I might not be the typical user.
Thanks for the feedback - it's definitely something we should monitor in real use, so it's good to get people's reactions. (And personally, I'm not that convinced you're particularly atypical - I suspect my reaction would be similar).
My 2 cents: I don’t think “pip install foo” should report errors about
dependencies not in foo’s dependency graph, unless perhaps it’s in a
verbose mode and pip needs to compute that information anyways. For
example, it could get annoying if you have a known issue with a dependency
in your environment, and you’re individually installing a number of things.
It could also serve to distract from any errors that _are_ a result of what
you’re installing.

On Tue, Apr 10, 2018 at 12:46 PM Dustin Ingram <notifications@github.com>
wrote:

> Well, I wasn't exactly in prod, so once I got past "Huh, why didn't that
> work" and "Wait, why does X even depend on Y?", I didn't care, but mostly
> because it was just a one-off environment that I was doing some testing in.
>
> If it had been a more important environment I might have cared, but
> usually I'm either a) not pip installing willy nilly in said
> environments, and b) not actually looking at the output of pip install
> unless it has failed with an exit code. So I think this warning is either
> going to confuse me unnecessarily or just never get noticed, but again, I
> realize that I might not be the typical user.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/pypa/pip/issues/5196#issuecomment-380224178>, or mute
> the thread
> <https://github.com/notifications/unsubscribe-auth/AAVt7ulc-sb-0khNOzL-LeHGuSegkTY8ks5tnQwLgaJpZM4TNJZS>
> .
>

Upgrade pip using.
```
curl https://bootstrap.pypa.io/get-pip.py | python3
```
Instead of  `pip install -U pip`
> This is intentional - to cover a user with an already broken environment, by telling them that they have a broken dependency if the operation they perform doesn't fix it.

> I'm curious, is there some reason that you don't want to know that there's a broken dependency?

As another use-case, our project builds packages in a local virtualenv as part of the installation. During the build, users will get warnings about broken package dependencies in their pip environment, which is irrelevant to the local virtualenv build that is taking place and creates confusion as to whether the warnings are related to installing the software or not.

I agree that these warnings are generally helpful, but it would be nice to have a way to suppress them for specific use-cases (I could not find a flag in the help output, if it already exists).
There's a --no-warn-
​<​
something
​>​
flag I'd added for this in the same PR.

Would a separate option to specify packages to ignore (when they occurs as
dependencies) would be useful?

On Wed, 18 Apr 2018, 22:14 Ben Albrecht, <notifications@github.com> wrote:

> This is intentional - to cover a user with an already broken environment,
> by telling them that they have a broken dependency if the operation they
> perform doesn't fix it.
>
> I'm curious, is there some reason that you don't want to know that there's
> a broken dependency?
>
> As another use-case, our project builds packages in a local virtualenv as
> part of the installation. During the build, users will get warnings about
> broken package dependencies in their pip environment, which is irrelevant
> to the local virtualenv build that is taking place and creates confusion as
> to whether the warnings are related to installing the software or not.
>
> I agree that these warnings are generally helpful, but it would be nice to
> have a way to suppress them for specific use-cases (I could not find an
> existing flag, if it already exists).
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/pypa/pip/issues/5196#issuecomment-382452453>, or mute
> the thread
> <https://github.com/notifications/unsubscribe-auth/ADH7ScsXYLc2kHxx9xtthMgY9vtx0wBbks5tp211gaJpZM4TNJZS>
> .
>

> Would a separate option to specify packages to ignore (when they occurs as dependencies) would be useful?

Personally I don't think so. This issue is more about avoiding initial confusion than dealing w/ an annoying warning. Being able to explicitly ignore warnings for some set of packages doesn't really help with that first case.
So... Any actionable ideas here?
> Would a separate option to specify packages to ignore (when they occurs as
dependencies) would be useful?

For the use-case I described above, a flag for ignoring this warning altogether (rather than a package-specific warning) would suffice as a solution.
> For the use-case I described above, a flag for ignoring this warning altogether (rather than a package-specific warning) would suffice as a solution.

We have `--no-warn-conflicts`.
I think one actionable idea is what @cjerdonek mentions here:

> My 2 cents: I don’t think “pip install foo” should report errors about dependencies not in foo’s dependency graph

I'm not familiar enough with pip to be able to say _how_ actionable that is, but I think that this would resolve the original issue, at least for me.
> We have `--no-warn-conflicts`

@pradyunsg - I was not aware of this. This is acceptable for my specific use-case. Thanks!
> > My 2 cents: I don’t think “pip install foo” should report errors about dependencies not in foo’s dependency graph
>
> I'm not familiar enough with pip to be able to say how actionable that is, but I think that this would resolve the original issue, at least for me.

Indeed. I'll explore this avenue.

It also brings up the warning for packages found by an egg info not yet installed. In particular it also warns about things required for setup (setup_required) and it warns for any system python 2.7 on mac because of global missing dependencies of matplotlib. 

Resolving patch:
diff --git a/src/pip/_internal/operations/check.py b/src/pip/_internal/operations/check.py
--- a/src/pip/_internal/operations/check.py
+++ b/src/pip/_internal/operations/check.py
@@ -12,7 +12,9 @@
 
 if MYPY_CHECK_RUNNING:
     from pip._internal.req.req_install import InstallRequirement  # noqa: F401
-    from typing import Any, Dict, Iterator, Set, Tuple, List  # noqa: F401
+    from typing import (  # noqa: F401
+        Any, Callable, Dict, Iterator, Optional, Set, Tuple, List
+    )
 
     # Shorthands
     PackageSet = Dict[str, 'PackageDetails']
@@ -33,17 +35,25 @@ def create_package_set_from_installed(**kwargs):
     # Default to using all packages installed on the system
     if kwargs == {}:
         kwargs = {"local_only": False, "skip": ()}
-    retval = {}
+
+    package_set = {}
     for dist in get_installed_distributions(**kwargs):
         name = canonicalize_name(dist.project_name)
-        retval[name] = PackageDetails(dist.version, dist.requires())
-    return retval
+        package_set[name] = PackageDetails(dist.version, dist.requires())
+    return package_set
 
 
-def check_package_set(package_set):
-    # type: (PackageSet) -> CheckResult
+def check_package_set(package_set, should_ignore=None):
+    # type: (PackageSet, Optional[Callable[[str], bool]]) -> CheckResult
     """Check if a package set is consistent
+
+    If should_ignore is passed, it should be a callable that takes a
+    package name and returns a boolean.
     """
+    if should_ignore is None:
+        def should_ignore(name):
+            return False
+
     missing = dict()
     conflicting = dict()
 
@@ -52,6 +62,9 @@ def check_package_set(package_set):
         missing_deps = set()  # type: Set[Missing]
         conflicting_deps = set()  # type: Set[Conflicting]
 
+        if should_ignore(package_name):
+            continue
+
         for req in package_set[package_name].requires:
             name = canonicalize_name(req.project_name)  # type: str
 
@@ -69,13 +82,10 @@ def check_package_set(package_set):
             if not req.specifier.contains(version, prereleases=True):
                 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)
+            missing[package_name] = sorted(missing_deps, key=str)
         if conflicting_deps:
-            conflicting[package_name] = sorted(conflicting_deps, key=str_key)
+            conflicting[package_name] = sorted(conflicting_deps, key=str)
 
     return missing, conflicting
 
@@ -86,21 +96,54 @@ def check_install_conflicts(to_install):
     installing given requirements
     """
     # Start from the current state
-    state = create_package_set_from_installed()
-    _simulate_installation_of(to_install, state)
-    return state, check_package_set(state)
+    package_set = create_package_set_from_installed()
+    # Install packages
+    would_be_installed = _simulate_installation_of(to_install, package_set)
+
+    # Only warn about directly-dependent packages; create a whitelist of them
+    whitelist = _create_whitelist(would_be_installed, package_set)
+
+    return (
+        package_set,
+        check_package_set(
+            package_set, should_ignore=lambda name: name not in whitelist
+        )
+    )
 
 
 # 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
+def _simulate_installation_of(to_install, package_set):
+    # type: (List[InstallRequirement], PackageSet) -> Set[str]
     """Computes the version of packages after installing to_install.
     """
 
+    # Keep track of packages that were installed
+    installed = set()
+
     # 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)
         name = canonicalize_name(dist.key)
-        state[name] = PackageDetails(dist.version, dist.requires())
+        package_set[name] = PackageDetails(dist.version, dist.requires())
+
+        installed.add(name)
+
+    return installed
+
+
+def _create_whitelist(would_be_installed, package_set):
+    # type: (Set[str], PackageSet) -> Set[str]
+    packages_affected = set(would_be_installed)
+
+    for package_name in package_set:
+        if package_name in packages_affected:
+            continue
+
+        for req in package_set[package_name].requires:
+            if canonicalize_name(req.name) in packages_affected:
+                packages_affected.add(package_name)
+                break
+
+    return packages_affected


Test patch:
diff --git a/tests/functional/test_install_check.py b/tests/functional/test_install_check.py
--- a/tests/functional/test_install_check.py
+++ b/tests/functional/test_install_check.py
@@ -2,58 +2,102 @@
 
 
 def matches_expected_lines(string, expected_lines):
-    def predicate(line):
-        return line and not line.startswith('DEPRECATION')
+    return set(string.splitlines()) == set(expected_lines)
 
-    output_lines = set(filter(predicate, string.splitlines()))
-    # Match regardless of order
-    return set(output_lines) == set(expected_lines)
 
-
-def test_check_install_warnings(script):
+def test_check_install_canonicalization(script):
     pkga_path = create_test_package_with_setup(
         script,
-        name='pkga',
+        name='pkgA',
         version='1.0',
-        install_requires=['normal-missing', 'special.missing'],
+        install_requires=['normal-missing', 'SPECIAL.missing'],
     )
-    # Let's install pkga without its dependency
-    result = script.pip('install', '--no-index', pkga_path, '--no-deps')
-    assert "Successfully installed pkga-1.0" in result.stdout, str(result)
-
-    # Install the first missing dependency. Only an error for the
-    # second dependency should remain.
     normal_path = create_test_package_with_setup(
         script,
         name='normal-missing', version='0.1',
     )
+    special_path = create_test_package_with_setup(
+        script,
+        name='SPECIAL.missing', version='0.1',
+    )
+
+    # Let's install pkgA without its dependency
+    result = script.pip('install', '--no-index', pkga_path, '--no-deps')
+    assert "Successfully installed pkgA-1.0" in result.stdout, str(result)
+
+    # Install the first missing dependency. Only an error for the
+    # second dependency should remain.
     result = script.pip(
         'install', '--no-index', normal_path, '--quiet', expect_error=True
     )
-    expected_lines = (
-        "pkga 1.0 requires special.missing, which is not installed.",
-    )
+    expected_lines = [
+        "pkga 1.0 requires SPECIAL.missing, which is not installed.",
+    ]
     assert matches_expected_lines(result.stderr, expected_lines)
     assert result.returncode == 0
 
     # Install the second missing package and expect that there is no warning
     # during the installation. This is special as the package name requires
     # name normalization (as in https://github.com/pypa/pip/issues/5134)
-    missing_path = create_test_package_with_setup(
-        script,
-        name='special.missing', version='0.1',
-    )
     result = script.pip(
-        'install', '--no-index', missing_path, '--quiet',
+        'install', '--no-index', special_path, '--quiet',
     )
-    assert matches_expected_lines(result.stdout, [])
     assert matches_expected_lines(result.stderr, [])
     assert result.returncode == 0
 
     # Double check that all errors are resolved in the end
     result = script.pip('check')
-    expected_lines = (
+    expected_lines = [
         "No broken requirements found.",
-    )
+    ]
     assert matches_expected_lines(result.stdout, expected_lines)
     assert result.returncode == 0
+
+
+def test_check_install_does_not_warn_for_out_of_graph_issues(script):
+    pkg_broken_path = create_test_package_with_setup(
+        script,
+        name='broken',
+        version='1.0',
+        install_requires=['missing', 'conflict < 1.0'],
+    )
+    pkg_unrelated_path = create_test_package_with_setup(
+        script,
+        name='unrelated',
+        version='1.0',
+    )
+    pkg_conflict_path = create_test_package_with_setup(
+        script,
+        name='conflict',
+        version='1.0',
+    )
+
+    # Install a package without it's dependencies
+    result = script.pip('install', '--no-index', pkg_broken_path, '--no-deps')
+    assert matches_expected_lines(result.stderr, [])
+
+    # Install conflict package
+    result = script.pip(
+        'install', '--no-index', pkg_conflict_path, expect_error=True,
+    )
+    assert matches_expected_lines(result.stderr, [
+        "broken 1.0 requires missing, which is not installed.",
+        (
+            "broken 1.0 has requirement conflict<1.0, but "
+            "you'll have conflict 1.0 which is incompatible."
+        ),
+    ])
+
+    # 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, [])
+
+    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)