Repository: pypa/pip

Issue:
Explain why version cannot be retrieved due to python-requires
**What's the problem this feature will solve?**
<!-- What are you trying to do, that you are unable to achieve with pip as it currently stands? -->
I am trying to install a specific version of a package (`ipython==7.20.0` in this case). However my version of python is 3.6 and the python-requires of this version is 3.7 at the latest. Leading to pip displaying this upon install request:

```
ERROR: Could not find a version that satisfies the requirement ipython==7.20.0
ERROR: No matching distribution found for ipython==7.20.0
```

**Describe the solution you'd like**
<!-- Clear and concise description of what you want to happen. -->
I would like for pip to display that even if this version exists, it cannot be installed as it is incompatible with my python version.

A message like the following would be neat:

```
ERROR: Could not find a version that satisfies the requirement ipython==7.20.0
ERROR: ipython==7.20.0 was found, however it cannot be installed as your python version (3.6.X) is incompatible with the requirements of this distribution (>=3.7).
```

<!-- Provide examples of real world use cases that this would enable and how it solves the problem described above. -->
Now that more and more python modules are dropping support for python 3.6 (`pandas`, `numpy`, `werkzeug` from the top of my head) while python 3.6 is still heavily used, I am fairly certain the number of users facing this rejection is high.

The message is not clear enough to understand in one go what the issue was and forces the developer in question to investigate. While pip already did the investigation, but did not shared the reason.

It's maybe a long shot but it would be even better if the message could provide the latest compatible version for such project (if any).


Thanks for your time


Discussion:
xavfernandez: Hello, could you paste the updated output from this PR ?
sbidoul: @xavfernandez this is how it look likes

![image](https://user-images.githubusercontent.com/692075/115110946-24618a80-9f7e-11eb-8ed8-ae61e803a0cd.png)

sbidoul: From the example above I find the displayed message somewhat confusing. The first message says "Could not find a version..." and second says "Found versions that...". But strictly speaking, both are lists of versions that were *found*. They were ignored for different reasons.

Would it make sense to display the second message (with the list of skipped versions) first, and phrase it with something such as `Ignored the following versions that require a different python version: ...`) ?
sbidoul: @ronaudinho do you think you could do the requested change in the next day or so ? So this can go in 21.1.
uranusjr: @ronaudinho Ping on this since we’ll be working on the 21.2 release soon. This can be included if you could address [the comment above](https://github.com/pypa/pip/pull/9708#issuecomment-821821220) and rebase the commit against latest main.
pradyunsg: @ronaudinho Would it be possible for you to update this PR?

Patch:
diff --git a/news/9615.feature.rst b/news/9615.feature.rst
new file mode 100644
index 000000000..075a6cd42
--- /dev/null
+++ b/news/9615.feature.rst
@@ -0,0 +1,1 @@
+Explains why specified version cannot be retrieved when *Requires-Python* is not satisfied.
diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py
index 223d06df6..2dfc03c36 100644
--- a/src/pip/_internal/index/package_finder.py
+++ b/src/pip/_internal/index/package_finder.py
@@ -222,9 +222,8 @@ class LinkEvaluator:
             ignore_requires_python=self._ignore_requires_python,
         )
         if not supports_python:
-            # Return None for the reason text to suppress calling
-            # _log_skipped_link().
-            return (False, None)
+            reason = f"{version} Requires-Python {link.requires_python}"
+            return (False, reason)
 
         logger.debug("Found link %s, version: %s", link, version)
 
@@ -610,7 +609,7 @@ class PackageFinder:
         self.format_control = format_control
 
         # These are boring links that have already been logged somehow.
-        self._logged_links: Set[Link] = set()
+        self._logged_links: Set[Tuple[Link, str]] = set()
 
     # Don't include an allow_yanked default value to make sure each call
     # site considers whether yanked releases are allowed. This also causes
@@ -690,6 +689,11 @@ class PackageFinder:
     def set_prefer_binary(self) -> None:
         self._candidate_prefs.prefer_binary = True
 
+    def skipped_links_requires_python(self) -> List[str]:
+        return sorted(
+            {reason for _, reason in self._logged_links if "Requires-Python" in reason}
+        )
+
     def make_link_evaluator(self, project_name: str) -> LinkEvaluator:
         canonical_name = canonicalize_name(project_name)
         formats = self.format_control.get_allowed_formats(canonical_name)
@@ -720,11 +724,11 @@ class PackageFinder:
         return no_eggs + eggs
 
     def _log_skipped_link(self, link: Link, reason: str) -> None:
-        if link not in self._logged_links:
+        if (link, reason) not in self._logged_links:
             # Put the link at the end so the reason is more visible and because
             # the link string is usually very long.
             logger.debug("Skipping link: %s: %s", reason, link)
-            self._logged_links.add(link)
+            self._logged_links.add((link, reason))
 
     def get_install_candidate(
         self, link_evaluator: LinkEvaluator, link: Link
@@ -735,8 +739,7 @@ class PackageFinder:
         """
         is_candidate, result = link_evaluator.evaluate_link(link)
         if not is_candidate:
-            if result:
-                self._log_skipped_link(link, reason=result)
+            self._log_skipped_link(link, result)
             return None
 
         return InstallationCandidate(
diff --git a/src/pip/_internal/resolution/resolvelib/factory.py b/src/pip/_internal/resolution/resolvelib/factory.py
index 261d8d560..d3648e158 100644
--- a/src/pip/_internal/resolution/resolvelib/factory.py
+++ b/src/pip/_internal/resolution/resolvelib/factory.py
@@ -617,8 +617,15 @@ class Factory:
             req_disp = f"{req} (from {parent.name})"
 
         cands = self._finder.find_all_candidates(req.project_name)
+        skips = self._finder.skipped_links_requires_python()
         versions = [str(v) for v in sorted({c.version for c in cands})]
 
+        if skips:
+            logger.critical(
+                "Ignored the following versions that require a different python "
+                "version: %s",
+                "; ".join(skips) or "none",
+            )
         logger.critical(
             "Could not find a version that satisfies the requirement %s "
             "(from versions: %s)",


Test patch:
diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py
index fa98f28c8..7fbcd3766 100644
--- a/tests/unit/test_index.py
+++ b/tests/unit/test_index.py
@@ -120,7 +120,7 @@ class TestLinkEvaluator:
         [
             ((3, 6, 5), False, (True, "1.12")),
             # Test an incompatible Python.
-            ((3, 6, 4), False, (False, None)),
+            ((3, 6, 4), False, (False, "1.12 Requires-Python == 3.6.5")),
             # Test an incompatible Python with ignore_requires_python=True.
             ((3, 6, 4), True, (True, "1.12")),
         ],