Repository: pypa/pip
Pull request: explain why version cannot be retrieved when Requires-Python is not satisfied
<!---
Thank you for your soon to be pull request. Before you submit this, please
double check to make sure that you've added a news file fragment. In pip we
generate our NEWS.rst from multiple news fragment files, and all pull requests
require either a news file fragment or a marker to indicate they don't require
one.

To read more about adding a news file fragment for your PR, please check out
our documentation at: https://pip.pypa.io/en/latest/development/contributing/#news-entries
-->
resolves #9615.

since logs for links skipped when `Requires-Python` is not satisfied are suppressed anyway, I decided to utilize those. the resulting error message changed a bit from my comment in the mentioned issue.


Problem statement:
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

Hints from issue 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!

Commit message: Merge branch 'main' into requires-python

Patch to review:
diff --git a/news/9615.feature.rst b/news/9615.feature.rst
new file mode 100644
index 00000000000..075a6cd4295
--- /dev/null
+++ b/news/9615.feature.rst
@@ -0,0 +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 223d06df67e..2083fdfcbe3 100644
--- 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()  # type: Set[Tuple[Link, str]]
 
     # 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
index 261d8d5605e..d3648e1581f 100644
--- 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)",
diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py
index fa98f28c89c..7fbcd3766e8 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")),
         ],


Code review comments:
diff_hunk: @@ -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()  # type: Set[Tuple[Link, str]]
path: src/pip/_internal/index/package_finder.py
text: @user1:
```suggestion
        self._logged_links: Set[Tuple[Link, str]] = set()
```