Repository: pypa/pip

Issue:
Updating the message when the user hits a conflict w/ installed packages
**Environment**

* pip version: master today.
* Python version: all
* OS: all

<!-- Feel free to add more information about your environment here -->

**Description**

We've decided to update the error message presented to a user, when the final result set of packages will contain dependency conflicts.

Context: #8513, #7744 and [a recent team meeting regarding the new resolver rollout](https://wiki.python.org/psf/PackagingWG/2020-06-24-pip-teamwidemeeting).

**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
The behavior has to be changed in two ways, each related to a different part of the resolver rollout plan:

- The legacy resolver would present a message nudging users to realize that the upcoming new resolver might behave differently (see #8513)
- The new resolver would present a message nudging users "hey, do you want the resolver to always be strict!? tell us!" (see https://github.com/pypa/pip/issues/7744#issuecomment-651411121)

**How to Reproduce / Output**

To get the current message, run:

```sh-session
$ pip install six==1.10.0
$ pip install cherrypy==11.0.0
Collecting cherrypy==11.0.0
  Downloading CherryPy-11.0.0-py2.py3-none-any.whl (435 kB)
     |████████████████████████████████| 435 kB 5.3 MB/s 
Requirement already satisfied: six in /Users/pradyunsg/.virtualenvs/tmp-7af976d02abc202/lib/python3.8/site-packages (from cherrypy==11.0.0) (1.10.0)
Collecting portend>=2.1.1
  Downloading portend-2.6-py2.py3-none-any.whl (5.1 kB)
Collecting cheroot>=5.2.0
  Downloading cheroot-8.3.0-py2.py3-none-any.whl (86 kB)
     |████████████████████████████████| 86 kB 5.9 MB/s 
Collecting tempora>=1.8
  Downloading tempora-3.0.0-py2.py3-none-any.whl (14 kB)
Collecting more-itertools>=2.6
  Using cached more_itertools-8.4.0-py3-none-any.whl (43 kB)
Collecting jaraco.functools
  Downloading jaraco.functools-3.0.1-py3-none-any.whl (6.7 kB)
Collecting pytz
  Using cached pytz-2020.1-py2.py3-none-any.whl (510 kB)
ERROR: cheroot 8.3.0 has requirement six>=1.11.0, but you'll have six 1.10.0 which is incompatible.
Installing collected packages: more-itertools, jaraco.functools, pytz, tempora, portend, cheroot, cherrypy
Successfully installed cheroot-8.3.0 cherrypy-11.0.0 jaraco.functools-3.0.1 more-itertools-8.4.0 portend-2.6 pytz-2020.1 tempora-3.0.0
```

Note the line starting with "ERROR:".

Implement step 1 in resolver rollout process
As per https://github.com/pypa/pip/issues/8371#issuecomment-652422199:

We need to:

- Add a warning on `pip install X` for users who are likely to experience problems with the new resolver
- Allow users to turn on the new resolver with `--use-feature=2020-resolver`
- Remove `--unstable-feature=resolver`
- Raise an error when a user uses `--unstable-feature=resolver` - pointing the users to use `--use-feature=2020-resolver` instead.

UX TODO: @ei8fdb and I can provide the text for the error and warning messages.


Discussion:
/cc @ei8fdb @nlhkabu for their inputs on the messages to present to the users in both these cases.
## Warn users the new behaviour might behave differently

> Warning: Your project may soon experience errors when pip updates the way that it resolves dependency conflicts.
> Use `--use-feature=2020-resolver` to test your project with the new resolver before it becomes the default.


## Nudge users "hey, do you want the resolver to always be strict!? tell us!"

4 variations depending on whether the user is upgrading or installing, and depending on how many packages they are requesting:

> Warning: Pip will install your package and it's dependencies without taking into account other packages you already have installed. This may cause an uncaught dependency conflict.
If you would like pip to take your other packages into account, please tell us here: `issue_tracker_url`

> Warning: Pip will install your packages and their dependencies without taking into account other packages you already have installed. This may cause an uncaught dependency conflict.
If you would like pip to take your other packages into account, please tell us here: `issue_tracker_url`

> Warning: Pip will upgrade your package and it's dependencies without taking into account other packages you already have installed. This may cause an uncaught dependency conflict.
If you would like pip to take your other packages into account, please tell us here: `issue_tracker_url`

> Warning: Pip will upgrade your packages and their dependencies without taking into account other packages you already have installed. This may cause an uncaught dependency conflict.
If you would like pip to take your other packages into account, please tell us here: `issue_tracker_url`
ping @pradyunsg - can you please confirm that these are _accurate_?
ping @ei8fdb - can you please review copy and make suggestions if you feel we can make it better.

Thanks both :)
## Warn users the new behaviour might behave differently

Questions:
- Can we put a date (even just month is OK) to give the maintainer a better indication when "soon" is?

### If the message is for maintainers
I assume:
- the maintainer is the intended audience of this message
- we want the maintainer to test their project with the new resolver ->  I suggest using stronger language; "we recommend you test....", etc
- if the maintainer does test their project and finds issues - we expect them to fix their own issues

Here is my iteration on @nlhkabu's message above:
> Warning: After $MONTH users installing your project may experience errors. This is because pip will update the way that it resolves dependency conflicts.
> We recommend you use `--use-feature=2020-resolver` to test your project with the new resolver before it becomes the default.

### If the message is for any user (maintainer or user installing packages)
If the message is for any user, then we need to frame it for everyone.

Here is my iteration on @nlhkabu's message above:
> Warning: After $MONTH you may experience errors when installing/updating packages. This is because pip will update the way that it resolves dependency conflicts.
> We recommend you use --use-feature=2020-resolver to test packages with the new resolver before it becomes the default.

## Nudge users "hey, do you want the resolver to always be strict!? tell us!"

> If you would like pip to take your other packages into account, please tell us here: issue_tracker_url

For this line, I suggest we create a template for users to help them understand what we expect them to tell us.
Hi @brainwane - as discussed in a team meeting, it would be useful to be able to tell users when the new resolver will become default.  My new iteration on Bernard's error message:

> Warning: After $MONTH you may experience errors when installing or updating packages. This is because pip will change the way that it resolves dependency conflicts.
We recommend you use --use-feature=2020-resolver to test your packages with the new resolver before it becomes the default.

Summary, we need `$MONTH` :)
@pradyunsg instead of nudging users towards the issue tracker, @ei8fdb and I discussed that it might be better to direct them to a survey - I've put one together here: https://forms.gle/JpMP2kBuMDqzbC768

As a bonus we can also publicise this survey on Twitter, mailing groups, etc. to get feedback from a wider group of pip users.

Let me know if you would like any changes to the survey :)
> it would be useful to be able to tell users when the new resolver will become default

I think we should say "October 2020".
Final error messages:

## Warn users the new behaviour might behave differently

> Warning: 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.
We recommend you use --use-feature=2020-resolver to test your packages with the new resolver before it becomes the default.

## Nudge users "hey, do you want the resolver to always be strict!? tell us!"

> Warning: 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.
If you would like pip to take your other packages into account, please tell us here: https://forms.gle/cWKMoDs8sUVE29hz9
> 4 variations depending on whether the user is upgrading or installing, and depending on how many packages they are requesting:

Is the expectation that we'd print a different error message based on some context? (that seems unnecessary to me -- please elaborate on how/why if that's the expectation)

Or... can I just pick one of these? ;)

I’d say it’s good enough to have one message that says “install/upgrade” and always use the plural form. I had to read the messages three times to find out the differences. Odds are the users won’t notice at all if they see different variants of these.
Grammar nit - "it's dependencies" should be "its dependencies".

I agree with @uranusjr about the 4 variations, though. Apart from anything else, we could get into some very complicated discussions if we try to distinguish "install" and "upgrade" (and there's also "downgrade"!!!)

But I'd like @nlhkabu to confirm that's OK. There's not much point getting a UX expert's opinion, and then ignoring it 🙂 
Thanks for your feedback @pfmoore @uranusjr - message updated.
Q: Do we want the error message to stay in the current location or should we move it to be printed *after* installation? If "move", any comments on which of the two alternate locations would be better?

```
Collecting pytz
  Using cached pytz-2020.1-py2.py3-none-any.whl (510 kB)
Processing ./pkgB
Using legacy setup.py install for pkgB, since package 'wheel' is not installed.
<<< current warning location >>>
Installing collected packages: pytz, pkgB
    Running setup.py install for pkgB: started
    Running setup.py install for pkgB: finished with status 'done'
<<< alternative location 1 >>>
Successfully installed pytz-2020.1 pkgB-2.0
<<< alternative location 2 >>>
```

I think changing the location of the message would be a worthwhile change -- if pip's "install step" prints a lot of messages (eg: lots of packages were installed), we'd have a significant distance between the end of the output and the message that has been printed, resulting in users potentially missing it. @ei8fdb have discussed this briefly in the past and (IIRC) he'd agreed that this would be a good change.
> Q: Do we want the error message to stay in the current location or should we move it to be printed _after_ installation? If "move", any comments on which of the two alternate locations would be better?

I've gone ahead and filed #8590 that changes this to alternative location 1.
> - Allow users to turn on the new resolver with --use-feature=2020-resolver
> - Raise an error when a user uses --unstable-feature=resolver - pointing the users to use --use-feature=2020-resolver instead.

#8530 implements this.

> - Remove --unstable-feature=resolver

To me, this means stop handling (and accepting) that like any other command line option. In other words, it would look something like:

```sh-session
$ pip install --random-non-existent-option=whatever

Usage:   
  pip install [options] <requirement specifier> [package-index-options] ...
  pip install [options] -r <requirements file> [package-index-options] ...
  pip install [options] [-e] <vcs project url> ...
  pip install [options] [-e] <local project path> ...
  pip install [options] <archive url/path> ...

no such option: --random-non-existent-option
```

These seem a bit... contradictory to the last bullet's intent, so I've gone ahead and implemented specific handling for `--unstable-feature`, to print the helpful error message. It'll be easy enough to remove the less-than-25 lines of code to drop `--unstable-feature` in pip 20.3.

@nlhkabu @ei8fdb Lemme know if that sounds OK. :)
> - Add a warning on pip install X for users who are likely to experience problems with the new resolver

I'm going to break this out into a separate PR(s?). I take "problems" to mean "change in behavior". As with any change, we can't have a 100% true-positive coverage but I think we can get pretty close with minimal false-negatives.

@pfmoore @uranusjr Need a bit of help here... As far as I can tell, the main areas where the two resolvers' behavior will differ are:

- when there's a conflict in the dependency graph
- handling of constraints

Am I missing something, or are these the only "broad" areas where the behavior differs?

Those are the two I can think of. I'll try to review over the weekend - but this week's been really busy so I have a lot to catch up on, so I'd say add those two and we can always add extra ones as and when we spot them.
@pradyunsg please let me know once those PRs are in progress and we can help you with the warning text for each use case
@nlhkabu there's 2 PRs and 1 issue cross-linked to this issue.

Each PR is adding an error message that could use a review (I merged them eagerly since it's easy enough to iterate in a follow up PR).

The linked open issue is where I'd need inputs, specifically around what the exact messages should be in the situations outlined there.
ok thanks @pradyunsg - just read #8546 and realised that's where you want our input :)

Resolving patch:
diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py
--- a/src/pip/_internal/commands/install.py
+++ b/src/pip/_internal/commands/install.py
@@ -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
@@ -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:
@@ -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(
diff --git a/src/pip/_internal/utils/datetime.py b/src/pip/_internal/utils/datetime.py
new file mode 100644
--- /dev/null
+++ b/src/pip/_internal/utils/datetime.py
@@ -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


Test patch:
diff --git a/tests/functional/test_check.py b/tests/functional/test_check.py
--- a/tests/functional/test_check.py
+++ b/tests/functional/test_check.py
@@ -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):
diff --git a/tests/functional/test_install.py b/tests/functional/test_install.py
--- a/tests/functional/test_install.py
+++ b/tests/functional/test_install.py
@@ -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)
 
 
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
@@ -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',
@@ -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
@@ -51,8 +47,7 @@ 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
@@ -60,12 +55,11 @@ def test_check_install_canonicalization(script, deprecated_python):
     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',
@@ -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)
diff --git a/tests/yaml/conflict_1.yml b/tests/yaml/conflict_1.yml
--- a/tests/yaml/conflict_1.yml
+++ b/tests/yaml/conflict_1.yml
@@ -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
 
 -
diff --git a/tests/yaml/conflicting_triangle.yml b/tests/yaml/conflicting_triangle.yml
--- a/tests/yaml/conflicting_triangle.yml
+++ b/tests/yaml/conflicting_triangle.yml
@@ -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.