Repository: pypa/pip

Issue:
Improve how dependency resolver order packages to resolve to avoid trying versions not related to an actual conflict
Edit from maintainer: this issue is also tracking the subthread started in https://github.com/pypa/pip/issues/9187#issuecomment-756652530 that focuses on `get_preference()` improvements.

## Description

I had conflicting dependencies between a private package `package2` and its dependency `package1`.

When installing `package2` with `pip`, it seems it detects the conflict between `package2` and its dependency `package1`, because it starts the backtracking process.

But then it downloads several versions of other sub-dependencies, instead of erroring out right after noticing the actual unresolvable dependency conflict.

## What I expected

I expected it to error out right away after finding the unresolvable conflict between the two `fastapi` versions. Instead of continuing and trying with many versions of sub-dependencies.

## Example

Here's a minimal, self-contained, reproducible example:

Inside some project directory, create a venv, e.g.:

```console
$ python3.7 -m venv .env3.7
```

Activate the venv:

```console
$ source ./.env3.7/bin/activate
```

Upgrade `pip`:

```console
$ python -m pip install --upgrade pip

--->100%
Successfully installed pip-20.3.3
```

Install `wheel`:

```console
$ pip install --upgrade wheel

--->100%
Successfully installed wheel-0.36.2
```

Then, create a first package `package1`, by creating a file:

```
./package1/setup.py
```

with:

```Python
from setuptools import setup, find_packages

setup(
    name="package1",
    version="0.0.1",
    packages=find_packages(),
    install_requires=[
        "fastapi>=0.61.0,<0.62.0",
    ],
)
```

Then create another package `package2`, that depends on `package1`, creating a file:

```
./package2/setup.py
```

with:

```Python
from setuptools import setup, find_packages

setup(
    name="package2",
    version="0.0.2",
    packages=find_packages(),
    install_requires=[
        "fastapi>=0.60.0,<0.61.0",  # <-- Notice the unresolvable conflict with fastapi
        "package1",                 # <-- Notice it depends on package1
    ],
)
```

The current file tree looks like this:

```
.
├── package1
│   └── setup.py
└── package2
    └── setup.py
```

Now, go to `./package1/` and build the wheel for that package (so that it can be used while installing `package2`):

```console
$ cd ./package1/

$ python setup.py sdist bdist_wheel

running sdist
...
...
removing build/bdist.linux-x86_64/wheel
```

Now go to `./package2/` and try to install it with `pip`, pointing to the dependency `package1` wheel directory:

```console
$ pip install -f ../package1/dist/ .

Looking in links: ../package1/dist/
Processing /home/user/code/debugdeps/package2
Collecting fastapi<0.61.0,>=0.60.0
  Using cached fastapi-0.60.2-py3-none-any.whl (50 kB)
Collecting starlette==0.13.6
  Using cached starlette-0.13.6-py3-none-any.whl (59 kB)
Collecting pydantic<2.0.0,>=0.32.2
  Using cached pydantic-1.7.3-cp37-cp37m-manylinux2014_x86_64.whl (9.1 MB)
Processing /home/user/code/debugdeps/package1/dist/package1-0.0.1-py3-none-any.whl
INFO: pip is looking at multiple versions of pydantic to determine which version is compatible with other requirements. This could take a while.
Collecting pydantic<2.0.0,>=0.32.2
  Using cached pydantic-1.7.2-cp37-cp37m-manylinux2014_x86_64.whl (9.1 MB)
  Using cached pydantic-1.7.1-cp37-cp37m-manylinux2014_x86_64.whl (9.1 MB)
  Using cached pydantic-1.7-cp37-cp37m-manylinux2014_x86_64.whl (9.1 MB)
  Using cached pydantic-1.6.1-cp37-cp37m-manylinux2014_x86_64.whl (8.6 MB)
  Using cached pydantic-1.6-cp37-cp37m-manylinux2014_x86_64.whl (8.6 MB)
  Using cached pydantic-1.5.1-cp37-cp37m-manylinux2014_x86_64.whl (7.3 MB)
  Using cached pydantic-1.5-cp37-cp37m-manylinux2014_x86_64.whl (7.3 MB)
INFO: pip is looking at multiple versions of pydantic to determine which version is compatible with other requirements. This could take a while.
  Using cached pydantic-1.4-cp37-cp37m-manylinux2010_x86_64.whl (7.5 MB)
  Using cached pydantic-1.3-cp37-cp37m-manylinux2010_x86_64.whl (7.3 MB)
  Using cached pydantic-1.2-cp37-cp37m-manylinux2010_x86_64.whl (7.1 MB)
  Using cached pydantic-1.1.1-cp37-cp37m-manylinux2010_x86_64.whl (6.9 MB)
  Using cached pydantic-1.1-cp37-cp37m-manylinux1_x86_64.whl (6.3 MB)
INFO: This is taking longer than usual. You might need to provide the dependency resolver with stricter constraints to reduce runtime. If you want to abort this run, you can press Ctrl + C to do so. To improve how pip performs, tell us what happened here: https://pip.pypa.io/surveys/backtracking
  Using cached pydantic-1.0-cp37-cp37m-manylinux1_x86_64.whl (5.7 MB)
  Using cached pydantic-0.32.2-cp37-cp37m-manylinux1_x86_64.whl (5.0 MB)
INFO: pip is looking at multiple versions of starlette to determine which version is compatible with other requirements. This could take a while.
INFO: pip is looking at multiple versions of <Python from Requires-Python> to determine which version is compatible with other requirements. This could take a while.
INFO: pip is looking at multiple versions of fastapi to determine which version is compatible with other requirements. This could take a while.
Collecting fastapi<0.61.0,>=0.60.0
  Using cached fastapi-0.60.1-py3-none-any.whl (49 kB)
  Using cached fastapi-0.60.0-py3-none-any.whl (49 kB)
Collecting starlette==0.13.4
  Using cached starlette-0.13.4-py3-none-any.whl (59 kB)
INFO: pip is looking at multiple versions of package2 to determine which version is compatible with other requirements. This could take a while.
ERROR: Cannot install package2 and package2==0.0.2 because these package versions have conflicting dependencies.

The conflict is caused by:
    package2 0.0.2 depends on fastapi<0.61.0 and >=0.60.0
    package1 0.0.1 depends on fastapi<0.62.0 and >=0.61.0

To fix this you could try to:
1. loosen the range of package versions you've specified
2. remove package versions to allow pip attempt to solve the dependency conflict

ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/user_guide/#fixing-conflicting-dependencies
```

The final error output is perfect, it shows the conflict between `fastapi` versions.

:rotating_light: What is not right, is that it first downloads several versions of `pydantic`, despite already having enough information to know it won't be able to solve the `fastapi` conflict.

After fixing the `fastapi` conflict, it no longer tries to download many versions of Pydantic. But I would think it wouldn't be necessary to download many versions of one dependency to realize a conflict in another one.

## Making it worse

I would think the above are the minimum steps to reproduce the problem. Although in the case above it's not that terrible, as it will take some seconds/minutes to download all the Pydantic versions and then error out the conflict in `fastapi`.

But it gets worse when there are other dependencies. Even non-conflicting dependencies. Because the resolver will download many versions of those dependencies and their sub-dependencies before showing the conflict.

And by downloading many unnecessary versions of many dependencies, it can take a really long time.

And in some cases, even error out something else, completely unrelated to the actual conflict, after not being able to build a very old version of some sub-dependency (this specific point behaves differently in `pip` installed from `master`, more on that in the end).

To see it in the example above, modify the file:

```
./package2/setup.py
```

To include:

```
alembic>=1.4.3,<1.5.0
```

So it would now be:

```Python
from setuptools import setup, find_packages

setup(
    name="package2",
    version="0.0.2",
    packages=find_packages(),
    install_requires=[
        "fastapi>=0.60.0,<0.61.0",  # <-- Notice the unresolvable conflict with fastapi
        "package1",                 # <-- Notice it depends on package1
        "alembic>=1.4.3,<1.5.0",
    ],
)
```

And then try again to install it:

```console
pip install -f ../package1/dist/ .
```

In this very simple example, in my computer that already has all those packages in cache (so not even counting download time), it takes 1m49s.

And the error is not related to the actual dependency conflict, but actually about not being able to build an old version of some dependency down the dependency tree:

```
Collecting Mako
  Using cached Mako-0.3.4.tar.gz (275 kB)
  Using cached Mako-0.3.3.tar.gz (272 kB)
  Using cached Mako-0.3.2.tar.gz (242 kB)
  Using cached Mako-0.3.1.tar.gz (242 kB)
  Using cached Mako-0.3.0.tar.gz (242 kB)
  Using cached Mako-0.2.5.tar.gz (228 kB)
    ERROR: Command errored out with exit status 1:
     command: /home/user/code/debugdeps/.env3.7/bin/python3.7 -c 'import sys, setuptools, tokenize; sys.argv[0] = '"'"'/tmp/pip-install-eu804_lk/mako_691c613f9a1c4dedb342df3544c2a03e/setup.py'"'"'; __file__='"'"'/tmp/pip-install-eu804_lk/mako_691c613f9a1c4dedb342df3544c2a03e/setup.py'"'"';f=getattr(tokenize, '"'"'open'"'"', open)(__file__);code=f.read().replace('"'"'\r\n'"'"', '"'"'\n'"'"');f.close();exec(compile(code, __file__, '"'"'exec'"'"'))' egg_info --egg-base /tmp/pip-pip-egg-info-wcc8z1p4
         cwd: /tmp/pip-install-eu804_lk/mako_691c613f9a1c4dedb342df3544c2a03e/
    Complete output (5 lines):
    Traceback (most recent call last):
      File "<string>", line 1, in <module>
      File "/tmp/pip-install-eu804_lk/mako_691c613f9a1c4dedb342df3544c2a03e/setup.py", line 5, in <module>
        v = file(os.path.join(os.path.dirname(__file__), 'lib', 'mako', '__init__.py'))
    NameError: name 'file' is not defined
    ----------------------------------------
ERROR: Command errored out with exit status 1: python setup.py egg_info Check the logs for full command output.
```

**Output**

Here's the very long output if you want to check it:

<details>

```
Looking in links: ../package1/dist/
Processing /home/user/code/debugdeps/package2
Collecting alembic<1.5.0,>=1.4.3
  Using cached alembic-1.4.3-py2.py3-none-any.whl (159 kB)
Collecting fastapi<0.61.0,>=0.60.0
  Using cached fastapi-0.60.2-py3-none-any.whl (50 kB)
Collecting starlette==0.13.6
  Using cached starlette-0.13.6-py3-none-any.whl (59 kB)
Collecting pydantic<2.0.0,>=0.32.2
  Using cached pydantic-1.7.3-cp37-cp37m-manylinux2014_x86_64.whl (9.1 MB)
Collecting python-editor>=0.3
  Using cached python_editor-1.0.4-py3-none-any.whl (4.9 kB)
Collecting SQLAlchemy>=1.1.0
  Using cached SQLAlchemy-1.3.22-cp37-cp37m-manylinux2010_x86_64.whl (1.3 MB)
Collecting Mako
  Using cached Mako-1.1.3-py2.py3-none-any.whl (75 kB)
Collecting MarkupSafe>=0.9.2
  Using cached MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl (27 kB)
Processing /home/user/code/debugdeps/package1/dist/package1-0.0.1-py3-none-any.whl
INFO: pip is looking at multiple versions of markupsafe to determine which version is compatible with other requirements. This could take a while.
Collecting MarkupSafe>=0.9.2
  Using cached MarkupSafe-1.1.0-cp37-cp37m-manylinux1_x86_64.whl (27 kB)
  Using cached MarkupSafe-1.0.tar.gz (14 kB)
  Using cached MarkupSafe-0.23.tar.gz (13 kB)
  Using cached MarkupSafe-0.22.tar.gz (13 kB)
  Using cached MarkupSafe-0.21.tar.gz (13 kB)
  Using cached MarkupSafe-0.20.tar.gz (11 kB)
  Using cached MarkupSafe-0.19.tar.gz (11 kB)
INFO: pip is looking at multiple versions of markupsafe to determine which version is compatible with other requirements. This could take a while.
  Using cached MarkupSafe-0.18.tar.gz (11 kB)
  Using cached MarkupSafe-0.17.tar.gz (11 kB)
  Using cached MarkupSafe-0.16.tar.gz (11 kB)
  Using cached MarkupSafe-0.15.tar.gz (11 kB)
  Using cached MarkupSafe-0.14.tar.gz (11 kB)
INFO: This is taking longer than usual. You might need to provide the dependency resolver with stricter constraints to reduce runtime. If you want to abort this run, you can press Ctrl + C to do so. To improve how pip performs, tell us what happened here: https://pip.pypa.io/surveys/backtracking
  Using cached MarkupSafe-0.13.tar.gz (11 kB)
  Using cached MarkupSafe-0.12.tar.gz (10 kB)
  Using cached MarkupSafe-0.11.tar.gz (10 kB)
  Using cached MarkupSafe-0.9.3.tar.gz (10 kB)
  Using cached MarkupSafe-0.9.2.tar.gz (10 kB)
INFO: pip is looking at multiple versions of mako to determine which version is compatible with other requirements. This could take a while.
Collecting Mako
  Using cached Mako-1.1.2-py2.py3-none-any.whl (75 kB)
  Using cached Mako-1.1.1.tar.gz (468 kB)
  Using cached Mako-1.1.0.tar.gz (463 kB)
  Using cached Mako-1.0.14.tar.gz (462 kB)
  Using cached Mako-1.0.13.tar.gz (460 kB)
  Using cached Mako-1.0.12.tar.gz (460 kB)
  Using cached Mako-1.0.11.tar.gz (461 kB)
INFO: pip is looking at multiple versions of mako to determine which version is compatible with other requirements. This could take a while.
  Using cached Mako-1.0.10.tar.gz (460 kB)
  Using cached Mako-1.0.9.tar.gz (459 kB)
  Using cached Mako-1.0.8.tar.gz (468 kB)
  Using cached Mako-1.0.7.tar.gz (564 kB)
  Using cached Mako-1.0.6.tar.gz (575 kB)
INFO: This is taking longer than usual. You might need to provide the dependency resolver with stricter constraints to reduce runtime. If you want to abort this run, you can press Ctrl + C to do so. To improve how pip performs, tell us what happened here: https://pip.pypa.io/surveys/backtracking
  Using cached Mako-1.0.5.tar.gz (574 kB)
  Using cached Mako-1.0.4.tar.gz (574 kB)
  Using cached Mako-1.0.3.tar.gz (565 kB)
  Using cached Mako-1.0.2.tar.gz (564 kB)
  Using cached Mako-1.0.1.tar.gz (473 kB)
  Using cached Mako-1.0.0.tar.gz (470 kB)
  Using cached Mako-0.9.1.tar.gz (421 kB)
  Using cached Mako-0.9.0.tar.gz (420 kB)
  Using cached Mako-0.8.1.tar.gz (407 kB)
  Using cached Mako-0.8.0.tar.gz (407 kB)
  Using cached Mako-0.7.3.tar.gz (401 kB)
  Using cached Mako-0.7.2.tar.gz (401 kB)
  Using cached Mako-0.7.1.tar.gz (400 kB)
  Using cached Mako-0.7.0.tar.gz (398 kB)
  Using cached Mako-0.6.2.tar.gz (384 kB)
  Using cached Mako-0.6.1.tar.gz (384 kB)
  Using cached Mako-0.6.0.tar.gz (384 kB)
  Using cached Mako-0.5.0.tar.gz (318 kB)
  Using cached Mako-0.4.2.tar.gz (317 kB)
  Using cached Mako-0.4.1.tar.gz (317 kB)
  Using cached Mako-0.4.0.tar.gz (300 kB)
  Using cached Mako-0.3.6.tar.gz (297 kB)
  Using cached Mako-0.3.5.tar.gz (276 kB)
Collecting Beaker>=1.1
  Using cached Beaker-1.11.0.tar.gz (40 kB)
INFO: pip is looking at multiple versions of beaker to determine which version is compatible with other requirements. This could take a while.
  Using cached Beaker-1.10.1.tar.gz (40 kB)
  Using cached Beaker-1.10.0.tar.gz (41 kB)
  Using cached Beaker-1.9.1.tar.gz (40 kB)
  Using cached Beaker-1.9.0.tar.gz (39 kB)
  Using cached Beaker-1.8.1.tar.gz (37 kB)
  Using cached Beaker-1.8.0.tar.gz (36 kB)
  Using cached Beaker-1.7.0.tar.gz (34 kB)
INFO: pip is looking at multiple versions of beaker to determine which version is compatible with other requirements. This could take a while.
  Using cached Beaker-1.6.5.post1.tar.gz (36 kB)
  Using cached Beaker-1.6.4.tar.gz (54 kB)
  Using cached Beaker-1.6.3.tar.gz (52 kB)
  Using cached Beaker-1.6.2.tar.gz (52 kB)
  Using cached Beaker-1.6.1.tar.gz (51 kB)
INFO: This is taking longer than usual. You might need to provide the dependency resolver with stricter constraints to reduce runtime. If you want to abort this run, you can press Ctrl + C to do so. To improve how pip performs, tell us what happened here: https://pip.pypa.io/surveys/backtracking
  Using cached Beaker-1.6.tar.gz (51 kB)
  Using cached Beaker-1.5.4.tar.gz (46 kB)
  Using cached Beaker-1.5.3.tar.gz (46 kB)
  Using cached Beaker-1.5.2.tar.gz (45 kB)
  Using cached Beaker-1.5.1.tar.gz (45 kB)
  Using cached Beaker-1.5.tar.gz (45 kB)
  Using cached Beaker-1.4.2.tar.gz (45 kB)
  Using cached Beaker-1.4.1.tar.gz (44 kB)
  Using cached Beaker-1.4.tar.gz (43 kB)
  Using cached Beaker-1.3.1.tar.gz (41 kB)
  Using cached Beaker-1.3.tar.gz (41 kB)
  Using cached Beaker-1.2.3.tar.gz (38 kB)
  Using cached Beaker-1.2.2.tar.gz (38 kB)
  Using cached Beaker-1.2.1.tar.gz (38 kB)
  Using cached Beaker-1.2.tar.gz (38 kB)
  Using cached Beaker-1.1.3.tar.gz (37 kB)
  Using cached Beaker-1.1.2.tar.gz (36 kB)
  Using cached Beaker-1.1.1.tar.gz (35 kB)
  Using cached Beaker-1.1.tar.gz (35 kB)
Collecting Mako
  Using cached Mako-0.3.4.tar.gz (275 kB)
  Using cached Mako-0.3.3.tar.gz (272 kB)
  Using cached Mako-0.3.2.tar.gz (242 kB)
  Using cached Mako-0.3.1.tar.gz (242 kB)
  Using cached Mako-0.3.0.tar.gz (242 kB)
  Using cached Mako-0.2.5.tar.gz (228 kB)
    ERROR: Command errored out with exit status 1:
     command: /home/user/code/debugdeps/.env3.7/bin/python3.7 -c 'import sys, setuptools, tokenize; sys.argv[0] = '"'"'/tmp/pip-install-unckp7wk/mako_826db1a2e8764498a609cab88518ebd5/setup.py'"'"'; __file__='"'"'/tmp/pip-install-unckp7wk/mako_826db1a2e8764498a609cab88518ebd5/setup.py'"'"';f=getattr(tokenize, '"'"'open'"'"', open)(__file__);code=f.read().replace('"'"'\r\n'"'"', '"'"'\n'"'"');f.close();exec(compile(code, __file__, '"'"'exec'"'"'))' egg_info --egg-base /tmp/pip-pip-egg-info-ne79o3_k
         cwd: /tmp/pip-install-unckp7wk/mako_826db1a2e8764498a609cab88518ebd5/
    Complete output (5 lines):
    Traceback (most recent call last):
      File "<string>", line 1, in <module>
      File "/tmp/pip-install-unckp7wk/mako_826db1a2e8764498a609cab88518ebd5/setup.py", line 5, in <module>
        v = file(os.path.join(os.path.dirname(__file__), 'lib', 'mako', '__init__.py'))
    NameError: name 'file' is not defined
    ----------------------------------------
ERROR: Command errored out with exit status 1: python setup.py egg_info Check the logs for full command output.
```

</details>

**Additional information**

<!--
It would be great if you could also include your dependency tree. For this you can use pipdeptree: https://pypi.org/project/pipdeptree/

For users installing packages from a private repository or local directory, please try your best to describe your setup. We'd like to understand how to reproduce the error locally, so would need (at a minimum) a description of the packages you are trying to install, and a list of dependencies for each package.
-->

I also tried upgrading `setuptools`, but then I got another unrelated error from one of the sub-dependencies that was trying to import a deprecated module/object from it. So I removed that part to avoid more confusion.

These are the packages I have in my environment:

```console
$ pip list

Package    Version
---------- -------
pip        20.3.3
setuptools 41.2.0
wheel      0.36.2
```

---

I also tried installing pip from `master` with:

```console
$ python -m pip install -U "pip @ https://github.com/pypa/pip/archive/master.zip"
```

And then after that, it still downloads all those versions, shows the same error as above, and continues downloading. So, the final error is not as strange, but it takes even longer (it's still running after more than 10m).




Discussion:
Thanks for the detailed report! The issue here is logically similar to what is described [here](https://github.com/pypa/pip/issues/9187#issuecomment-756652530) (read the discussion started from the linked comment; messages prior are about the “resolver is slow” issue in a broader sense). Quoting myself from the discussion:

> The resolver currently has a special treatment of “top-level” requirements (i.e. what you specify from the command line, including `-r`) and resolve those first. But when you put those into a `setup.py`, the requirements become transitive and only one top-level requirement is registered.
> 
> I’ve been trying to avoid the discussion since it’s difficult to do, but maybe we really should order transitive dependencies by their “depth” as well. This makes sense intuitively since the deeper a dependency is at, the looser it should generally be less likely to require excessive backtracking.

As I mentioned in the same issue, there are various strategies being experiemented on to improve the logic, and this is definitely a valuable example to observe. If only I can find more energy to actually come up with an implementatio…
Thanks for the speedy response @uranusjr ! :bow: 

I'm sorry I ended up creating a duplicate, I swear I searched all the resolver issues before posting this, but I didn't notice it was related to that one. :sweat_smile: :facepalm: 

Feel free to close this one if it doesn't add anything useful and/or you prefer to track it on the other one.
It’s perfectly fine! That thread grew way too long for anyone not following the issue tracker intimately to reasonably read. It’s also unfortunate the discussion is buried so low in the thread. I’ll use this one to track the resolution priority logic improvement specifically.

Resolving patch:
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
@@ -1,3 +1,5 @@
+import collections
+import math
 from typing import TYPE_CHECKING, Dict, Iterable, Iterator, Mapping, Sequence, Union
 
 from pip._vendor.resolvelib.providers import AbstractProvider
@@ -60,6 +62,7 @@ def __init__(
         self._ignore_dependencies = ignore_dependencies
         self._upgrade_strategy = upgrade_strategy
         self._user_requested = user_requested
+        self._known_depths: Dict[str, float] = collections.defaultdict(lambda: math.inf)
 
     def identify(self, requirement_or_candidate):
         # type: (Union[Requirement, Candidate]) -> str
@@ -79,48 +82,43 @@ def get_preference(
 
         Currently pip considers the followings in order:
 
-        * Prefer if any of the known requirements points to an explicit URL.
-        * If equal, prefer if any requirements contain ``===`` and ``==``.
-        * If equal, prefer if requirements include version constraints, e.g.
-          ``>=`` and ``<``.
-        * If equal, prefer user-specified (non-transitive) requirements, and
-          order user-specified requirements by the order they are specified.
+        * Prefer if any of the known requirements is "direct", e.g. points to an
+          explicit URL.
+        * If equal, prefer if any requirement is "pinned", i.e. contains
+          operator ``===`` or ``==``.
+        * If equal, calculate an approximate "depth" and resolve requirements
+          closer to the user-specified requirements first.
+        * Order user-specified requirements by the order they are specified.
+        * If equal, prefers "non-free" requirements, i.e. contains at least one
+          operator, such as ``>=`` or ``<``.
         * If equal, order alphabetically for consistency (helps debuggability).
         """
-
-        def _get_restrictive_rating(requirements):
-            # type: (Iterable[Requirement]) -> int
-            """Rate how restrictive a set of requirements are.
-
-            ``Requirement.get_candidate_lookup()`` returns a 2-tuple for
-            lookup. The first element is ``Optional[Candidate]`` and the
-            second ``Optional[InstallRequirement]``.
-
-            * If the requirement is an explicit one, the explicitly-required
-              candidate is returned as the first element.
-            * If the requirement is based on a PEP 508 specifier, the backing
-              ``InstallRequirement`` is returned as the second element.
-
-            We use the first element to check whether there is an explicit
-            requirement, and the second for equality operator.
-            """
-            lookups = (r.get_candidate_lookup() for r in requirements)
-            cands, ireqs = zip(*lookups)
-            if any(cand is not None for cand in cands):
-                return 0
-            spec_sets = (ireq.specifier for ireq in ireqs if ireq)
-            operators = [
-                specifier.operator for spec_set in spec_sets for specifier in spec_set
-            ]
-            if any(op in ("==", "===") for op in operators):
-                return 1
-            if operators:
-                return 2
-            # A "bare" requirement without any version requirements.
-            return 3
-
-        rating = _get_restrictive_rating(r for r, _ in information[identifier])
-        order = self._user_requested.get(identifier, float("inf"))
+        lookups = (r.get_candidate_lookup() for r, _ in information[identifier])
+        candidate, ireqs = zip(*lookups)
+        operators = [
+            specifier.operator
+            for specifier_set in (ireq.specifier for ireq in ireqs if ireq)
+            for specifier in specifier_set
+        ]
+
+        direct = candidate is not None
+        pinned = any(op[:2] == "==" for op in operators)
+        unfree = bool(operators)
+
+        try:
+            requested_order: Union[int, float] = self._user_requested[identifier]
+        except KeyError:
+            requested_order = math.inf
+            parent_depths = (
+                self._known_depths[parent.name] if parent is not None else 0.0
+                for _, parent in information[identifier]
+            )
+            inferred_depth = min(d for d in parent_depths) + 1.0
+            self._known_depths[identifier] = inferred_depth
+        else:
+            inferred_depth = 1.0
+
+        requested_order = self._user_requested.get(identifier, math.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.
@@ -136,7 +134,16 @@ def _get_restrictive_rating(requirements):
         # while we work on "proper" branch pruning techniques.
         delay_this = identifier == "setuptools"
 
-        return (not requires_python, delay_this, rating, order, identifier)
+        return (
+            not requires_python,
+            delay_this,
+            not direct,
+            not pinned,
+            inferred_depth,
+            requested_order,
+            not unfree,
+            identifier,
+        )
 
     def find_matches(
         self,


Test patch: