Repository: pypa/pip

Issue:
Perform Requires-Python check before downloading dependencies 
**What's the problem this feature will solve?**

When installing a package that requires a specific python version its dependencies are installed first, which 
1. downloads a bunch of dependencies unnecessarily
2. can result in a confusing message when those dependencies don't have a distribution 

eg:

myapi has `python_requires=">=3.7"` and depends on mylib. mylib has only been built for python 3.6. 

`pip install myapi` with the Python 3.6 interpreter will download all dependencies and error with:

```
pip._internal.exceptions.DistributionNotFound: No matching distribution found for mylib==1.2.3
```

<!-- What are you trying to do, that you are unable to achieve with pip as it currently stands? -->

**Describe the solution you'd like**

`pip install myapi` with the Python 3.6 interpreter won't download dependencies and will error with the more helpful:

```
ERROR: Package 'myapi' requires a different Python: 3.6.10 not in '>=3.7'
``` 

**Alternative Solutions**
<!-- Have you tried to workaround the problem using pip or other tools? Or a different approach to solving this issue? Please elaborate here. -->

**Additional context**
<!-- Add any other context, links, etc. about the feature here. -->



Discussion:
Are you using PyPI.org? This is already done for all packages from PyPI.org, but an alternative index implementation may not provide enough information to make this possible.
Thanks @uranusjr for looking into this. Yes, this happens when using pypi.org. The undesired behaviour seems to have been introduced in pip 20.3.4.

### Example

_setup.py_
```python
from setuptools import find_packages, setup

setup(
    name="myapp",
    version="0.0.0",
    python_requires=">=3.7",
    install_requires=["black==20.8b1"]
)
```

```
❯ python --version
Python 3.6.10

❯ python -m venv --clear .venv
❯ . .venv/bin/activate
```

pip 20.3.3 (and earlier) will check the package's python requires before downloading dependencies (yay!):
```
❯ pip install pip==20.3.3
...
❯ pip install -e .
Obtaining file:///private/tmp/repo-name
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
    Preparing wheel metadata ... done
ERROR: Package 'myapp' requires a different Python: 3.6.10 not in '>=3.7'
WARNING: You are using pip version 20.3.3; however, version 21.1.1 is available.
You should consider upgrading via the '/private/tmp/repo-name/.venv/bin/python -m pip install --upgrade pip' command.
```

pip 20.3.4 will download dependencies first and then check the package's python requires:
``` 
❯ pip install pip==20.3.4
...
❯ pip install -e .
Obtaining file:///private/tmp/repo-name
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
    Preparing wheel metadata ... done
Collecting black==20.8b1
  Using cached black-20.8b1.tar.gz (1.1 MB)
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
    Preparing wheel metadata ... done
ERROR: Package 'myapp' requires a different Python: 3.6.10 not in '>=3.7'
WARNING: You are using pip version 20.3.4; however, version 21.1.1 is available.
You should consider upgrading via the '/private/tmp/repo-name/.venv/bin/python -m pip install --upgrade pip' command.
```

Same behavior in pip 21.1.1
```
❯ pip install pip==21.1.1
...
❯ pip install -e .       
Obtaining file:///private/tmp/repo-name
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
    Preparing wheel metadata ... done
Collecting black==20.8b1
  Using cached black-20.8b1.tar.gz (1.1 MB)
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
    Preparing wheel metadata ... done
ERROR: Package 'myapp' requires a different Python: 3.6.10 not in '>=3.7'
```
Ah OK, you meant checking `Requires-Python` of a package before downloading the dependencies *of that package*, not dependnecies in general. That makes sense, thanks for the additional context. I think that’s a reasonable optimisation.

Resolving patch:
diff --git a/src/pip/_internal/resolution/resolvelib/candidates.py b/src/pip/_internal/resolution/resolvelib/candidates.py
--- a/src/pip/_internal/resolution/resolvelib/candidates.py
+++ b/src/pip/_internal/resolution/resolvelib/candidates.py
@@ -32,6 +32,9 @@
     "LinkCandidate",
 ]
 
+# Avoid conflicting with the PyPI package "Python".
+REQUIRES_PYTHON_IDENTIFIER = cast(NormalizedName, "<Python from Requires-Python>")
+
 
 def as_base_candidate(candidate: Candidate) -> Optional[BaseCandidate]:
     """The runtime version of BaseCandidate."""
@@ -578,13 +581,12 @@ def __str__(self):
     @property
     def project_name(self):
         # type: () -> NormalizedName
-        # Avoid conflicting with the PyPI package "Python".
-        return cast(NormalizedName, "<Python from Requires-Python>")
+        return REQUIRES_PYTHON_IDENTIFIER
 
     @property
     def name(self):
         # type: () -> str
-        return self.project_name
+        return REQUIRES_PYTHON_IDENTIFIER
 
     @property
     def version(self):
diff --git a/src/pip/_internal/resolution/resolvelib/provider.py b/src/pip/_internal/resolution/resolvelib/provider.py
--- a/src/pip/_internal/resolution/resolvelib/provider.py
+++ b/src/pip/_internal/resolution/resolvelib/provider.py
@@ -3,6 +3,7 @@
 from pip._vendor.resolvelib.providers import AbstractProvider
 
 from .base import Candidate, Constraint, Requirement
+from .candidates import REQUIRES_PYTHON_IDENTIFIER
 from .factory import Factory
 
 if TYPE_CHECKING:
@@ -121,6 +122,10 @@ def _get_restrictive_rating(requirements):
         rating = _get_restrictive_rating(r for r, _ in information[identifier])
         order = self._user_requested.get(identifier, float("inf"))
 
+        # Requires-Python has only one candidate and the check is basically
+        # free, so we always do it first to avoid needless work if it fails.
+        requires_python = identifier == REQUIRES_PYTHON_IDENTIFIER
+
         # HACK: Setuptools have a very long and solid backward compatibility
         # track record, and extremely few projects would request a narrow,
         # non-recent version range of it since that would break a lot things.
@@ -131,7 +136,7 @@ def _get_restrictive_rating(requirements):
         # while we work on "proper" branch pruning techniques.
         delay_this = identifier == "setuptools"
 
-        return (delay_this, rating, order, identifier)
+        return (not requires_python, delay_this, rating, order, identifier)
 
     def find_matches(
         self,


Test patch:
diff --git a/tests/functional/test_new_resolver_errors.py b/tests/functional/test_new_resolver_errors.py
--- a/tests/functional/test_new_resolver_errors.py
+++ b/tests/functional/test_new_resolver_errors.py
@@ -1,3 +1,4 @@
+import pathlib
 import sys
 
 from tests.lib import create_basic_wheel_for_package, create_test_package_with_setup
@@ -73,3 +74,34 @@ def test_new_resolver_requires_python_error(script):
     # conflict, not the compatible one.
     assert incompatible_python in result.stderr, str(result)
     assert compatible_python not in result.stderr, str(result)
+
+
+def test_new_resolver_checks_requires_python_before_dependencies(script):
+    incompatible_python = "<{0.major}.{0.minor}".format(sys.version_info)
+
+    pkg_dep = create_basic_wheel_for_package(
+        script,
+        name="pkg-dep",
+        version="1",
+    )
+    create_basic_wheel_for_package(
+        script,
+        name="pkg-root",
+        version="1",
+        # Refer the dependency by URL to prioritise it as much as possible,
+        # to test that Requires-Python is *still* inspected first.
+        depends=[f"pkg-dep@{pathlib.Path(pkg_dep).as_uri()}"],
+        requires_python=incompatible_python,
+    )
+
+    result = script.pip(
+        "install", "--no-cache-dir",
+        "--no-index", "--find-links", script.scratch_path,
+        "pkg-root",
+        expect_error=True,
+    )
+
+    # Resolution should fail because of pkg-a's Requires-Python.
+    # This check should be done before pkg-b, so pkg-b should never be pulled.
+    assert incompatible_python in result.stderr, str(result)
+    assert "pkg-b" not in result.stderr, str(result)