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:
Sounds reasonable, but the implementation would be much less straightforward than it would seem at first glance. Python package distribution is file-based, not version-based, so it does not actually make sense to say “ipython==7.20.0 was found but cannot be installed due to `Requires-Python`” because different files for the specified version may have different specifiers. The logic to come up with a comprehensive message may not be too difficult, but will be tedious and require time. I would welcome anyone interested to try their hands on this; it’d be a fun excersise to walk you through some interesting aspects of Python packaging.
![image](https://user-images.githubusercontent.com/10264710/111024190-de118c80-840f-11eb-9186-1572d40c390b.png)
I guess something like this would do?
> ![image](https://user-images.githubusercontent.com/10264710/111024190-de118c80-840f-11eb-9186-1572d40c390b.png)
> I guess something like this would do?

Yes it would!

Resolving patch:
diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py
--- a/src/pip/_internal/index/package_finder.py
+++ b/src/pip/_internal/index/package_finder.py
@@ -222,9 +222,8 @@ def evaluate_link(self, link: Link) -> Tuple[bool, Optional[str]]:
             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 @@ def __init__(
         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 @@ def prefer_binary(self) -> bool:
     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 @@ def _sort_links(self, links: Iterable[Link]) -> List[Link]:
         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 @@ def get_install_candidate(
         """
         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
--- a/src/pip/_internal/resolution/resolvelib/factory.py
+++ b/src/pip/_internal/resolution/resolvelib/factory.py
@@ -617,8 +617,15 @@ def _report_single_requirement_conflict(
             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
--- 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")),
         ],