Repository: pypa/pip

Issue:
pip downloads packages twice if the data-dist-info-metadata is set
### Description

I'm using a custom package index that implements PEP-658. When I install packages through that index specifying the --no-cache-dir flag pip downloads the package twice.

### Expected behavior

_No response_

### pip version

23.0.1

### Python version

3.9.7

### OS

CentOS 7.9

### How to Reproduce

Run pip install --no-cache-dir --index-url PEP-658index -v  package

### Output

```
pip install --no-cache-dir --index-url http://localhost:8000/simple/ -v numba
Using pip 23.0.1 from ... (python 3.9)
Looking in indexes: http://localhost:8000/simple/
Collecting numba
  Obtaining dependency information for numba from http://localhost:8000/.../numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata
  Downloading http://localhost:8000/.../numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (2.8 kB)
Requirement already satisfied: setuptools in /.../site-packages (from numba) (59.5.0)
Requirement already satisfied: llvmlite<0.40,>=0.39.0dev0 in /.../site-packages (from numba) (0.39.1)
Requirement already satisfied: numpy<1.24,>=1.18 in /.../site-packages (from numba) (1.23.5)
Downloading http://localhost:8000/.../numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (3.5 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.5/3.5 MB 105.5 MB/s eta 0:00:00
Downloading http://localhost:8000/.../numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (3.5 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.5/3.5 MB 120.8 MB/s eta 0:00:00
Installing collected packages: numba
Successfully installed numba-0.56.4
```

### Code of Conduct

- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).
pip downloads packages twice if the data-dist-info-metadata is set
### Description

I'm using a custom package index that implements PEP-658. When I install packages through that index specifying the --no-cache-dir flag pip downloads the package twice.

### Expected behavior

_No response_

### pip version

23.0.1

### Python version

3.9.7

### OS

CentOS 7.9

### How to Reproduce

Run pip install --no-cache-dir --index-url PEP-658index -v  package

### Output

```
pip install --no-cache-dir --index-url http://localhost:8000/simple/ -v numba
Using pip 23.0.1 from ... (python 3.9)
Looking in indexes: http://localhost:8000/simple/
Collecting numba
  Obtaining dependency information for numba from http://localhost:8000/.../numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata
  Downloading http://localhost:8000/.../numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (2.8 kB)
Requirement already satisfied: setuptools in /.../site-packages (from numba) (59.5.0)
Requirement already satisfied: llvmlite<0.40,>=0.39.0dev0 in /.../site-packages (from numba) (0.39.1)
Requirement already satisfied: numpy<1.24,>=1.18 in /.../site-packages (from numba) (1.23.5)
Downloading http://localhost:8000/.../numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (3.5 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.5/3.5 MB 105.5 MB/s eta 0:00:00
Downloading http://localhost:8000/.../numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (3.5 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.5/3.5 MB 120.8 MB/s eta 0:00:00
Installing collected packages: numba
Successfully installed numba-0.56.4
```

### Code of Conduct

- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).


Discussion:
What are you setting in `data-dist-info-metadata`? If it is set pip downloads the content it points to for metadata. It looks like your attribute points to the same wheel, which should not be the case.
Could you share the HTML file that triggered this, as well as the contents of the METADATA file served by the host?

The metadata file seems to be distinct: `numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata`

> What are you setting in `data-dist-info-metadata`? If it is set pip downloads the content it points to for metadata. It looks like your attribute points to the same wheel, which should not be the case.

From PEP658:

> The repository SHOULD provide the hash of the Core Metadata file as the data-dist-info-metadata attribute’s value using the syntax hashname=hashvalue, where hashname is the lower cased name of the hash function used, and hashvalue is the hex encoded digest. The repository MAY use true as the attribute’s value if a hash is unavailable.

I am setting the values to true since I do not have access to the hashes. I also think pip is downloading and using the metadata correctly:
`Downloading http://localhost:8000/.../numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (2.8 kB)`
> Could you share the HTML file that triggered this, as well as the contents of the METADATA file served by the host?
> 
> The metadata file seems to be distinct: `numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata`

I'm currently just proxying pypi.org, adding data-dist-info-metadata to all the a tags pointing to a wheel dist and unwrapping the wheels on the fly when a client asks for metatadata. So the html is just the project page provided by pypi.org and the metadata is the METADATA file inside the wheel.
> Could you share the HTML file that triggered this, as well as the contents of the METADATA file served by the host?
> 
> The metadata file seems to be distinct: `numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata`

I've uploaded on [this repo](https://github.com/fiannac/PEP658_pip_bug) an html file that triggers this, you can reproduce that locally following the setps in the readme
Thank you for that @fiannac! That's really useful.
/cc @pfmoore, who is also looking at the PEP 658 + PEP 714 situation in #12078. I reckon this is something we should try to fix in the first release using the new key name.
I'll take a look, but I can't promise I'll have time to work out what the problem is here, much less create a fix. So PRs would be welcome.

Worst case scenario, if no-one comes up with a fix for this, we'll have PEP 658/714 support in 23.2 with this issue unfixed. Does anyone think that's a serious problem (serious enough to pull #12078 from 23.2)? My instinct is to say no, we go ahead with #12078, but a double download does sound like a rather serious performance regression, for something that was supposed to be a performance *improvement* 🙁
I've tried setting a breakpoint in `pip._internal.network.download` in `Downloader.__call__`, just before the HTTP response is read and written to the target file. The breakpoint is only triggered once, but the `Downloading filename.whl (NN kB)` message is printed *twice* before the breakpoint is hit. So I'm pretty sure that the issue here is simply one of the "Downloading" message being logged twice, rather than the file actually being downloaded twice.

Obviously, it's misleading and needs to be fixed, but I'm reasonably confident that there isn't a performance issue here.

Before I downgrade this from release blocker, can anyone demonstrate that my analysis above is wrong?

Note that #12078 has now been merged, and PyPI is serving (some) metadata files, so this issue can be reproduced using pip main and PyPI. The command line I used was `pip install --dry-run --no-deps --no-cache piel -vvv` (I used the `piel` package simply because it has had a very recent release which has metadata available on PyPI).
> I've tried setting a breakpoint in `pip._internal.network.download` in `Downloader.__call__`, just before the HTTP response is read and written to the target file. The breakpoint is only triggered once, but the `Downloading filename.whl (NN kB)` message is printed _twice_ before the breakpoint is hit. So I'm pretty sure that the issue here is simply one of the "Downloading" message being logged twice, rather than the file actually being downloaded twice.

When I run the same command and check the server-side logs, it really looks like the file is being downloaded twice:

```
Client:
pip install --dry-run --no-deps --no-cache --force-reinstall --index-url http://localhost:8082/simple/ abcabc
Collecting abcabc
  Obtaining dependency information for abcabc from http://localhost:8082/simple/abcabc/abcabc-0.0.7-py3-none-any.whl.metadata
  Downloading http://localhost:8082/simple/abcabc/abcabc-0.0.7-py3-none-any.whl.metadata (92 bytes)
Downloading http://localhost:8082/simple/abcabc/abcabc-0.0.7-py3-none-any.whl (2.4 kB)
Downloading http://localhost:8082/simple/abcabc/abcabc-0.0.7-py3-none-any.whl (2.4 kB)

Server:
127.0.0.1 - - [28/Jun/2023 14:12:21] "GET /simple/abcabc/ HTTP/1.1" 200 -
127.0.0.1 - - [28/Jun/2023 14:12:21] "GET /simple/abcabc/abcabc-0.0.7-py3-none-any.whl.metadata HTTP/1.1" 200 -
127.0.0.1 - - [28/Jun/2023 14:12:21] "GET /simple/abcabc/abcabc-0.0.7-py3-none-any.whl HTTP/1.1" 200 -
127.0.0.1 - - [28/Jun/2023 14:12:21] "GET /simple/abcabc/abcabc-0.0.7-py3-none-any.whl HTTP/1.1" 200 -
```


OK. We'll need a complete reproducer that I can use to replicate the issue locally, as I can't confirm the problem here.
> OK. We'll need a complete reproducer that I can use to replicate the issue locally, as I can't confirm the problem here.

To reproduce the problem check [this](https://github.com/fiannac/PEP658_pip_bug)
OK, thanks - I found it (I think).

There's a `prepare_linked_requirements_more` function in `operations.prepare`, which looks at `req.needs_more_preparation`. If I'm understanding things here, it looks like that code path isn't clearing this flag when the "more preparation" has been done. But that logic is incredibly convoluted and I frankly can't follow it.
FWIW, the "needs more preparation" flag was added in #8685 but the code was updated for PEP 658 support in #11111.

@uranusjr @pradyunsg you both seem to have reviewed #11111 - do either of you have any recollection what was going on there?
I didn’t pay much notice to `needs_more_preparation` when I reviewed the PR since the code is already there and seems to do something else, but now looking back I guess yeah that flag should not be set when only the metadata is downloaded, since PEP 658 uses a completely different mechanism than the partial download approach and should not “resume” a wheel download. So I guess we can

1. Change the `needs_more_preparation` resuming message a bit, to say something like _Resuming link ..._ instead so it’s at least clearer to the user this is a continuation to the previous download, and not a separate download.
2. Modify the code to make a metadata-only download not set `needs_more_preparation` at all.
One additional comment, we probably should rename that flag to same something like `has_partially_downloaded_artifact` instead to better reflect what it does.
Phew! I think I found the issue. We weren't recording in the `_downloaded` dictionary the fact that we downloaded and unpacked the URL in `_complete_partial_requirements`, which means that the following call to `_prepare_linked_requirement` downloaded the URL again.

I have a fix in #12120, waiting for the tests to run to see if it actually resolves the issue (it did in my manual tests, but I'm not promising yet that it doesn't break something else!)
Test failures seem to be for the case where we're given metadata for source distributions. I'm inclined to downgrade the "release blocker" status if that's the case, but I don't know how we fix the test suite, so I don't have a practical way to achieve that at the moment 🙁

@uranusjr Your comments on making the code more readable make sense (although the issue doesn't seem directly related to `needs_more_preparation` any more) but I definitely *won't* be doing anything about them right now - all I have time for is the bare minimum of trying to get things functioning[^1]. If anyone wants to look at this code any deeper, they'll need to file a separate PR (and it should be for 23.3, not 23.2, as the *only* thing I want to focus on for 23.2 is getting this issue dealt with).

[^1]: And I'm not even sure I'll manage that - we may end up in a situation where I have to choose between releasing with the performance issue or reverting #12078 and losing PEP 658/714 support for another release cycle.
By limiting the "double download" fix to wheels, the tests pass. That's an awful hack, but it gets us past the "release blocker" problem, as PyPI isn't yet serving PEP 658 metadata for sdists.

Fixing the sdist issue looks like it will require a complete refactoring of the logic that means we need the method `prepare_linked_requirements_more`, and I have no idea how to even start on that. So I'm going to limit the fix to wheels, and leave this issue open for someone else to look at the sdist issue.

The risk, of course, is that no-one will look at this issue for sdists, and we'll just end up with the same problem when PyPI does start serving sdist metadata. Maybe it'll be less of an issue as sdists typically *aren't* huge, but I'm not confident of that (huge data files, or people shipping Javascript runtimes in the sdist, could still be an issue). To be clear I don't personally have any intention of digging into the sdist issue any further - I don't have the time to work out what this code does and produce a full fix.

**Edit:** The largest sdist I can see in a recent PyPI dump is 520Mb in size. The mean sdist size is about 800Kb.

Another option would be to leave the sdist tests failing, but mark them as xfail. I'm not convinced that's a better option, as it doesn't make it any more likely that someone will look at the problem (possibly less so, because there wouldn't be a huge comment in the source explaining how the current situation is a gross hack 🙂)
I've merged my fix, and removed the release blocker label. I'm leaving the 23.2 milestone in case someone else contributes a full fix, but if no-one gets to it, I'll just remove the milestone (I'm leaving it mainly as a reminder to check back on this issue before the release).
I'm going to remove the milestone at this point, as I don't expect any further work on this before 23.2 (even if someone comes up with a PR, I won't have time to review it and I won't approve it for 23.2 as we've had enough problems and I won't risk any more being caused by rushing things in).

We can look at any follow-up in future pip releases.
What are you setting in `data-dist-info-metadata`? If it is set pip downloads the content it points to for metadata. It looks like your attribute points to the same wheel, which should not be the case.
Could you share the HTML file that triggered this, as well as the contents of the METADATA file served by the host?

The metadata file seems to be distinct: `numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata`

> What are you setting in `data-dist-info-metadata`? If it is set pip downloads the content it points to for metadata. It looks like your attribute points to the same wheel, which should not be the case.

From PEP658:

> The repository SHOULD provide the hash of the Core Metadata file as the data-dist-info-metadata attribute’s value using the syntax hashname=hashvalue, where hashname is the lower cased name of the hash function used, and hashvalue is the hex encoded digest. The repository MAY use true as the attribute’s value if a hash is unavailable.

I am setting the values to true since I do not have access to the hashes. I also think pip is downloading and using the metadata correctly:
`Downloading http://localhost:8000/.../numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (2.8 kB)`
> Could you share the HTML file that triggered this, as well as the contents of the METADATA file served by the host?
> 
> The metadata file seems to be distinct: `numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata`

I'm currently just proxying pypi.org, adding data-dist-info-metadata to all the a tags pointing to a wheel dist and unwrapping the wheels on the fly when a client asks for metatadata. So the html is just the project page provided by pypi.org and the metadata is the METADATA file inside the wheel.
> Could you share the HTML file that triggered this, as well as the contents of the METADATA file served by the host?
> 
> The metadata file seems to be distinct: `numba-0.56.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata`

I've uploaded on [this repo](https://github.com/fiannac/PEP658_pip_bug) an html file that triggers this, you can reproduce that locally following the setps in the readme
Thank you for that @fiannac! That's really useful.
/cc @pfmoore, who is also looking at the PEP 658 + PEP 714 situation in #12078. I reckon this is something we should try to fix in the first release using the new key name.
I'll take a look, but I can't promise I'll have time to work out what the problem is here, much less create a fix. So PRs would be welcome.

Worst case scenario, if no-one comes up with a fix for this, we'll have PEP 658/714 support in 23.2 with this issue unfixed. Does anyone think that's a serious problem (serious enough to pull #12078 from 23.2)? My instinct is to say no, we go ahead with #12078, but a double download does sound like a rather serious performance regression, for something that was supposed to be a performance *improvement* 🙁
I've tried setting a breakpoint in `pip._internal.network.download` in `Downloader.__call__`, just before the HTTP response is read and written to the target file. The breakpoint is only triggered once, but the `Downloading filename.whl (NN kB)` message is printed *twice* before the breakpoint is hit. So I'm pretty sure that the issue here is simply one of the "Downloading" message being logged twice, rather than the file actually being downloaded twice.

Obviously, it's misleading and needs to be fixed, but I'm reasonably confident that there isn't a performance issue here.

Before I downgrade this from release blocker, can anyone demonstrate that my analysis above is wrong?

Note that #12078 has now been merged, and PyPI is serving (some) metadata files, so this issue can be reproduced using pip main and PyPI. The command line I used was `pip install --dry-run --no-deps --no-cache piel -vvv` (I used the `piel` package simply because it has had a very recent release which has metadata available on PyPI).
> I've tried setting a breakpoint in `pip._internal.network.download` in `Downloader.__call__`, just before the HTTP response is read and written to the target file. The breakpoint is only triggered once, but the `Downloading filename.whl (NN kB)` message is printed _twice_ before the breakpoint is hit. So I'm pretty sure that the issue here is simply one of the "Downloading" message being logged twice, rather than the file actually being downloaded twice.

When I run the same command and check the server-side logs, it really looks like the file is being downloaded twice:

```
Client:
pip install --dry-run --no-deps --no-cache --force-reinstall --index-url http://localhost:8082/simple/ abcabc
Collecting abcabc
  Obtaining dependency information for abcabc from http://localhost:8082/simple/abcabc/abcabc-0.0.7-py3-none-any.whl.metadata
  Downloading http://localhost:8082/simple/abcabc/abcabc-0.0.7-py3-none-any.whl.metadata (92 bytes)
Downloading http://localhost:8082/simple/abcabc/abcabc-0.0.7-py3-none-any.whl (2.4 kB)
Downloading http://localhost:8082/simple/abcabc/abcabc-0.0.7-py3-none-any.whl (2.4 kB)

Server:
127.0.0.1 - - [28/Jun/2023 14:12:21] "GET /simple/abcabc/ HTTP/1.1" 200 -
127.0.0.1 - - [28/Jun/2023 14:12:21] "GET /simple/abcabc/abcabc-0.0.7-py3-none-any.whl.metadata HTTP/1.1" 200 -
127.0.0.1 - - [28/Jun/2023 14:12:21] "GET /simple/abcabc/abcabc-0.0.7-py3-none-any.whl HTTP/1.1" 200 -
127.0.0.1 - - [28/Jun/2023 14:12:21] "GET /simple/abcabc/abcabc-0.0.7-py3-none-any.whl HTTP/1.1" 200 -
```


OK. We'll need a complete reproducer that I can use to replicate the issue locally, as I can't confirm the problem here.
> OK. We'll need a complete reproducer that I can use to replicate the issue locally, as I can't confirm the problem here.

To reproduce the problem check [this](https://github.com/fiannac/PEP658_pip_bug)
OK, thanks - I found it (I think).

There's a `prepare_linked_requirements_more` function in `operations.prepare`, which looks at `req.needs_more_preparation`. If I'm understanding things here, it looks like that code path isn't clearing this flag when the "more preparation" has been done. But that logic is incredibly convoluted and I frankly can't follow it.
FWIW, the "needs more preparation" flag was added in #8685 but the code was updated for PEP 658 support in #11111.

@uranusjr @pradyunsg you both seem to have reviewed #11111 - do either of you have any recollection what was going on there?
I didn’t pay much notice to `needs_more_preparation` when I reviewed the PR since the code is already there and seems to do something else, but now looking back I guess yeah that flag should not be set when only the metadata is downloaded, since PEP 658 uses a completely different mechanism than the partial download approach and should not “resume” a wheel download. So I guess we can

1. Change the `needs_more_preparation` resuming message a bit, to say something like _Resuming link ..._ instead so it’s at least clearer to the user this is a continuation to the previous download, and not a separate download.
2. Modify the code to make a metadata-only download not set `needs_more_preparation` at all.
One additional comment, we probably should rename that flag to same something like `has_partially_downloaded_artifact` instead to better reflect what it does.
Phew! I think I found the issue. We weren't recording in the `_downloaded` dictionary the fact that we downloaded and unpacked the URL in `_complete_partial_requirements`, which means that the following call to `_prepare_linked_requirement` downloaded the URL again.

I have a fix in #12120, waiting for the tests to run to see if it actually resolves the issue (it did in my manual tests, but I'm not promising yet that it doesn't break something else!)
Test failures seem to be for the case where we're given metadata for source distributions. I'm inclined to downgrade the "release blocker" status if that's the case, but I don't know how we fix the test suite, so I don't have a practical way to achieve that at the moment 🙁

@uranusjr Your comments on making the code more readable make sense (although the issue doesn't seem directly related to `needs_more_preparation` any more) but I definitely *won't* be doing anything about them right now - all I have time for is the bare minimum of trying to get things functioning[^1]. If anyone wants to look at this code any deeper, they'll need to file a separate PR (and it should be for 23.3, not 23.2, as the *only* thing I want to focus on for 23.2 is getting this issue dealt with).

[^1]: And I'm not even sure I'll manage that - we may end up in a situation where I have to choose between releasing with the performance issue or reverting #12078 and losing PEP 658/714 support for another release cycle.
By limiting the "double download" fix to wheels, the tests pass. That's an awful hack, but it gets us past the "release blocker" problem, as PyPI isn't yet serving PEP 658 metadata for sdists.

Fixing the sdist issue looks like it will require a complete refactoring of the logic that means we need the method `prepare_linked_requirements_more`, and I have no idea how to even start on that. So I'm going to limit the fix to wheels, and leave this issue open for someone else to look at the sdist issue.

The risk, of course, is that no-one will look at this issue for sdists, and we'll just end up with the same problem when PyPI does start serving sdist metadata. Maybe it'll be less of an issue as sdists typically *aren't* huge, but I'm not confident of that (huge data files, or people shipping Javascript runtimes in the sdist, could still be an issue). To be clear I don't personally have any intention of digging into the sdist issue any further - I don't have the time to work out what this code does and produce a full fix.

**Edit:** The largest sdist I can see in a recent PyPI dump is 520Mb in size. The mean sdist size is about 800Kb.

Another option would be to leave the sdist tests failing, but mark them as xfail. I'm not convinced that's a better option, as it doesn't make it any more likely that someone will look at the problem (possibly less so, because there wouldn't be a huge comment in the source explaining how the current situation is a gross hack 🙂)
I've merged my fix, and removed the release blocker label. I'm leaving the 23.2 milestone in case someone else contributes a full fix, but if no-one gets to it, I'll just remove the milestone (I'm leaving it mainly as a reminder to check back on this issue before the release).
I'm going to remove the milestone at this point, as I don't expect any further work on this before 23.2 (even if someone comes up with a PR, I won't have time to review it and I won't approve it for 23.2 as we've had enough problems and I won't risk any more being caused by rushing things in).

We can look at any follow-up in future pip releases.

Resolving patch:
diff --git a/src/pip/_internal/operations/prepare.py b/src/pip/_internal/operations/prepare.py
--- a/src/pip/_internal/operations/prepare.py
+++ b/src/pip/_internal/operations/prepare.py
@@ -7,6 +7,7 @@
 import mimetypes
 import os
 import shutil
+from pathlib import Path
 from typing import Dict, Iterable, List, Optional
 
 from pip._vendor.packaging.utils import canonicalize_name
@@ -20,7 +21,6 @@
     InstallationError,
     MetadataInconsistent,
     NetworkConnectionError,
-    PreviousBuildDirError,
     VcsHashUnsupported,
 )
 from pip._internal.index.package_finder import PackageFinder
@@ -47,7 +47,6 @@
     display_path,
     hash_file,
     hide_url,
-    is_installable_dir,
 )
 from pip._internal.utils.temp_dir import TempDirectory
 from pip._internal.utils.unpacking import unpack_file
@@ -319,21 +318,7 @@ def _ensure_link_req_src_dir(
             autodelete=True,
             parallel_builds=parallel_builds,
         )
-
-        # If a checkout exists, it's unwise to keep going.  version
-        # inconsistencies are logged later, but do not fail the
-        # installation.
-        # FIXME: this won't upgrade when there's an existing
-        # package unpacked in `req.source_dir`
-        # TODO: this check is now probably dead code
-        if is_installable_dir(req.source_dir):
-            raise PreviousBuildDirError(
-                "pip can't proceed with requirements '{}' due to a"
-                "pre-existing build directory ({}). This is likely "
-                "due to a previous installation that failed . pip is "
-                "being responsible and not assuming it can delete this. "
-                "Please delete it and try again.".format(req, req.source_dir)
-            )
+        req.ensure_pristine_source_checkout()
 
     def _get_linked_req_hashes(self, req: InstallRequirement) -> Hashes:
         # By the time this is called, the requirement's link should have
@@ -481,20 +466,19 @@ def _complete_partial_requirements(
         for link, (filepath, _) in batch_download:
             logger.debug("Downloading link %s to %s", link, filepath)
             req = links_to_fully_download[link]
+            # Record the downloaded file path so wheel reqs can extract a Distribution
+            # in .get_dist().
             req.local_file_path = filepath
-            # TODO: This needs fixing for sdists
-            # This is an emergency fix for #11847, which reports that
-            # distributions get downloaded twice when metadata is loaded
-            # from a PEP 658 standalone metadata file. Setting _downloaded
-            # fixes this for wheels, but breaks the sdist case (tests
-            # test_download_metadata). As PyPI is currently only serving
-            # metadata for wheels, this is not an immediate issue.
-            # Fixing the problem properly looks like it will require a
-            # complete refactoring of the `prepare_linked_requirements_more`
-            # logic, and I haven't a clue where to start on that, so for now
-            # I have fixed the issue *just* for wheels.
-            if req.is_wheel:
-                self._downloaded[req.link.url] = filepath
+            # Record that the file is downloaded so we don't do it again in
+            # _prepare_linked_requirement().
+            self._downloaded[req.link.url] = filepath
+
+            # If this is an sdist, we need to unpack it after downloading, but the
+            # .source_dir won't be set up until we are in _prepare_linked_requirement().
+            # Add the downloaded archive to the install requirement to unpack after
+            # preparing the source dir.
+            if not req.is_wheel:
+                req.needs_unpacked_archive(Path(filepath))
 
         # This step is necessary to ensure all lazy wheels are processed
         # successfully by the 'download', 'wheel', and 'install' commands.
diff --git a/src/pip/_internal/req/req_install.py b/src/pip/_internal/req/req_install.py
--- a/src/pip/_internal/req/req_install.py
+++ b/src/pip/_internal/req/req_install.py
@@ -6,6 +6,7 @@
 import uuid
 import zipfile
 from optparse import Values
+from pathlib import Path
 from typing import Any, Collection, Dict, Iterable, List, Optional, Sequence, Union
 
 from pip._vendor.packaging.markers import Marker
@@ -17,7 +18,7 @@
 from pip._vendor.pyproject_hooks import BuildBackendHookCaller
 
 from pip._internal.build_env import BuildEnvironment, NoOpBuildEnvironment
-from pip._internal.exceptions import InstallationError
+from pip._internal.exceptions import InstallationError, PreviousBuildDirError
 from pip._internal.locations import get_scheme
 from pip._internal.metadata import (
     BaseDistribution,
@@ -47,11 +48,13 @@
     backup_dir,
     display_path,
     hide_url,
+    is_installable_dir,
     redact_auth_from_url,
 )
 from pip._internal.utils.packaging import safe_extra
 from pip._internal.utils.subprocess import runner_with_spinner_message
 from pip._internal.utils.temp_dir import TempDirectory, tempdir_kinds
+from pip._internal.utils.unpacking import unpack_file
 from pip._internal.utils.virtualenv import running_under_virtualenv
 from pip._internal.vcs import vcs
 
@@ -180,6 +183,9 @@ def __init__(
         # This requirement needs more preparation before it can be built
         self.needs_more_preparation = False
 
+        # This requirement needs to be unpacked before it can be installed.
+        self._archive_source: Optional[Path] = None
+
     def __str__(self) -> str:
         if self.req:
             s = str(self.req)
@@ -645,6 +651,27 @@ def ensure_has_source_dir(
                 parallel_builds=parallel_builds,
             )
 
+    def needs_unpacked_archive(self, archive_source: Path) -> None:
+        assert self._archive_source is None
+        self._archive_source = archive_source
+
+    def ensure_pristine_source_checkout(self) -> None:
+        """Ensure the source directory has not yet been built in."""
+        assert self.source_dir is not None
+        if self._archive_source is not None:
+            unpack_file(str(self._archive_source), self.source_dir)
+        elif is_installable_dir(self.source_dir):
+            # If a checkout exists, it's unwise to keep going.
+            # version inconsistencies are logged later, but do not fail
+            # the installation.
+            raise PreviousBuildDirError(
+                f"pip can't proceed with requirements '{self}' due to a "
+                f"pre-existing build directory ({self.source_dir}). This is likely "
+                "due to a previous installation that failed . pip is "
+                "being responsible and not assuming it can delete this. "
+                "Please delete it and try again."
+            )
+
     # For editable installations
     def update_editable(self) -> None:
         if not self.link:


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,22 +1,32 @@
 import compileall
 import fnmatch
+import http.server
 import io
 import os
 import re
 import shutil
 import subprocess
 import sys
+import threading
 from contextlib import ExitStack, contextmanager
+from dataclasses import dataclass
+from enum import Enum
+from hashlib import sha256
 from pathlib import Path
+from textwrap import dedent
 from typing import (
     TYPE_CHECKING,
+    Any,
     AnyStr,
     Callable,
+    ClassVar,
     Dict,
     Iterable,
     Iterator,
     List,
     Optional,
+    Set,
+    Tuple,
     Union,
 )
 from unittest.mock import patch
@@ -750,3 +760,289 @@ def proxy(request: pytest.FixtureRequest) -> str:
 @pytest.fixture
 def enable_user_site(virtualenv: VirtualEnvironment) -> None:
     virtualenv.user_site_packages = True
+
+
+class MetadataKind(Enum):
+    """All the types of values we might be provided for the data-dist-info-metadata
+    attribute from PEP 658."""
+
+    # Valid: will read metadata from the dist instead.
+    No = "none"
+    # Valid: will read the .metadata file, but won't check its hash.
+    Unhashed = "unhashed"
+    # Valid: will read the .metadata file and check its hash matches.
+    Sha256 = "sha256"
+    # Invalid: will error out after checking the hash.
+    WrongHash = "wrong-hash"
+    # Invalid: will error out after failing to fetch the .metadata file.
+    NoFile = "no-file"
+
+
+@dataclass(frozen=True)
+class FakePackage:
+    """Mock package structure used to generate a PyPI repository.
+
+    FakePackage name and version should correspond to sdists (.tar.gz files) in our test
+    data."""
+
+    name: str
+    version: str
+    filename: str
+    metadata: MetadataKind
+    # This will override any dependencies specified in the actual dist's METADATA.
+    requires_dist: Tuple[str, ...] = ()
+    # This will override the Name specified in the actual dist's METADATA.
+    metadata_name: Optional[str] = None
+
+    def metadata_filename(self) -> str:
+        """This is specified by PEP 658."""
+        return f"{self.filename}.metadata"
+
+    def generate_additional_tag(self) -> str:
+        """This gets injected into the <a> tag in the generated PyPI index page for this
+        package."""
+        if self.metadata == MetadataKind.No:
+            return ""
+        if self.metadata in [MetadataKind.Unhashed, MetadataKind.NoFile]:
+            return 'data-dist-info-metadata="true"'
+        if self.metadata == MetadataKind.WrongHash:
+            return 'data-dist-info-metadata="sha256=WRONG-HASH"'
+        assert self.metadata == MetadataKind.Sha256
+        checksum = sha256(self.generate_metadata()).hexdigest()
+        return f'data-dist-info-metadata="sha256={checksum}"'
+
+    def requires_str(self) -> str:
+        if not self.requires_dist:
+            return ""
+        joined = " and ".join(self.requires_dist)
+        return f"Requires-Dist: {joined}"
+
+    def generate_metadata(self) -> bytes:
+        """This is written to `self.metadata_filename()` and will override the actual
+        dist's METADATA, unless `self.metadata == MetadataKind.NoFile`."""
+        return dedent(
+            f"""\
+        Metadata-Version: 2.1
+        Name: {self.metadata_name or self.name}
+        Version: {self.version}
+        {self.requires_str()}
+        """
+        ).encode("utf-8")
+
+
+@pytest.fixture(scope="session")
+def fake_packages() -> Dict[str, List[FakePackage]]:
+    """The package database we generate for testing PEP 658 support."""
+    return {
+        "simple": [
+            FakePackage("simple", "1.0", "simple-1.0.tar.gz", MetadataKind.Sha256),
+            FakePackage("simple", "2.0", "simple-2.0.tar.gz", MetadataKind.No),
+            # This will raise a hashing error.
+            FakePackage("simple", "3.0", "simple-3.0.tar.gz", MetadataKind.WrongHash),
+        ],
+        "simple2": [
+            # Override the dependencies here in order to force pip to download
+            # simple-1.0.tar.gz as well.
+            FakePackage(
+                "simple2",
+                "1.0",
+                "simple2-1.0.tar.gz",
+                MetadataKind.Unhashed,
+                ("simple==1.0",),
+            ),
+            # This will raise an error when pip attempts to fetch the metadata file.
+            FakePackage("simple2", "2.0", "simple2-2.0.tar.gz", MetadataKind.NoFile),
+            # This has a METADATA file with a mismatched name.
+            FakePackage(
+                "simple2",
+                "3.0",
+                "simple2-3.0.tar.gz",
+                MetadataKind.Sha256,
+                metadata_name="not-simple2",
+            ),
+        ],
+        "colander": [
+            # Ensure we can read the dependencies from a metadata file within a wheel
+            # *without* PEP 658 metadata.
+            FakePackage(
+                "colander",
+                "0.9.9",
+                "colander-0.9.9-py2.py3-none-any.whl",
+                MetadataKind.No,
+            ),
+        ],
+        "compilewheel": [
+            # Ensure we can override the dependencies of a wheel file by injecting PEP
+            # 658 metadata.
+            FakePackage(
+                "compilewheel",
+                "1.0",
+                "compilewheel-1.0-py2.py3-none-any.whl",
+                MetadataKind.Unhashed,
+                ("simple==1.0",),
+            ),
+        ],
+        "has-script": [
+            # Ensure we check PEP 658 metadata hashing errors for wheel files.
+            FakePackage(
+                "has-script",
+                "1.0",
+                "has.script-1.0-py2.py3-none-any.whl",
+                MetadataKind.WrongHash,
+            ),
+        ],
+        "translationstring": [
+            FakePackage(
+                "translationstring",
+                "1.1",
+                "translationstring-1.1.tar.gz",
+                MetadataKind.No,
+            ),
+        ],
+        "priority": [
+            # Ensure we check for a missing metadata file for wheels.
+            FakePackage(
+                "priority",
+                "1.0",
+                "priority-1.0-py2.py3-none-any.whl",
+                MetadataKind.NoFile,
+            ),
+        ],
+        "requires-simple-extra": [
+            # Metadata name is not canonicalized.
+            FakePackage(
+                "requires-simple-extra",
+                "0.1",
+                "requires_simple_extra-0.1-py2.py3-none-any.whl",
+                MetadataKind.Sha256,
+                metadata_name="Requires_Simple.Extra",
+            ),
+        ],
+    }
+
+
+@pytest.fixture(scope="session")
+def html_index_for_packages(
+    shared_data: TestData,
+    fake_packages: Dict[str, List[FakePackage]],
+    tmpdir_factory: pytest.TempPathFactory,
+) -> Path:
+    """Generate a PyPI HTML package index within a local directory pointing to
+    synthetic test data."""
+    html_dir = tmpdir_factory.mktemp("fake_index_html_content")
+
+    # (1) Generate the content for a PyPI index.html.
+    pkg_links = "\n".join(
+        f'    <a href="{pkg}/index.html">{pkg}</a>' for pkg in fake_packages.keys()
+    )
+    # Output won't be nicely indented because dedent() acts after f-string
+    # arg insertion.
+    index_html = dedent(
+        f"""\
+        <!DOCTYPE html>
+        <html>
+          <head>
+            <meta name="pypi:repository-version" content="1.0">
+            <title>Simple index</title>
+          </head>
+          <body>
+          {pkg_links}
+          </body>
+        </html>"""
+    )
+    # (2) Generate the index.html in a new subdirectory of the temp directory.
+    (html_dir / "index.html").write_text(index_html)
+
+    # (3) Generate subdirectories for individual packages, each with their own
+    # index.html.
+    for pkg, links in fake_packages.items():
+        pkg_subdir = html_dir / pkg
+        pkg_subdir.mkdir()
+
+        download_links: List[str] = []
+        for package_link in links:
+            # (3.1) Generate the <a> tag which pip can crawl pointing to this
+            # specific package version.
+            download_links.append(
+                f'    <a href="{package_link.filename}" {package_link.generate_additional_tag()}>{package_link.filename}</a><br/>'  # noqa: E501
+            )
+            # (3.2) Copy over the corresponding file in `shared_data.packages`.
+            shutil.copy(
+                shared_data.packages / package_link.filename,
+                pkg_subdir / package_link.filename,
+            )
+            # (3.3) Write a metadata file, if applicable.
+            if package_link.metadata != MetadataKind.NoFile:
+                with open(pkg_subdir / package_link.metadata_filename(), "wb") as f:
+                    f.write(package_link.generate_metadata())
+
+        # (3.4) After collating all the download links and copying over the files,
+        # write an index.html with the generated download links for each
+        # copied file for this specific package name.
+        download_links_str = "\n".join(download_links)
+        pkg_index_content = dedent(
+            f"""\
+            <!DOCTYPE html>
+            <html>
+              <head>
+                <meta name="pypi:repository-version" content="1.0">
+                <title>Links for {pkg}</title>
+              </head>
+              <body>
+                <h1>Links for {pkg}</h1>
+                {download_links_str}
+              </body>
+            </html>"""
+        )
+        with open(pkg_subdir / "index.html", "w") as f:
+            f.write(pkg_index_content)
+
+    return html_dir
+
+
+class OneTimeDownloadHandler(http.server.SimpleHTTPRequestHandler):
+    """Serve files from the current directory, but error if a file is downloaded more
+    than once."""
+
+    _seen_paths: ClassVar[Set[str]] = set()
+
+    def do_GET(self) -> None:
+        if self.path in self._seen_paths:
+            self.send_error(
+                http.HTTPStatus.NOT_FOUND,
+                f"File {self.path} not available more than once!",
+            )
+            return
+        super().do_GET()
+        if not (self.path.endswith("/") or self.path.endswith(".metadata")):
+            self._seen_paths.add(self.path)
+
+
+@pytest.fixture(scope="function")
+def html_index_with_onetime_server(
+    html_index_for_packages: Path,
+) -> Iterator[http.server.ThreadingHTTPServer]:
+    """Serve files from a generated pypi index, erroring if a file is downloaded more
+    than once.
+
+    Provide `-i http://localhost:8000` to pip invocations to point them at this server.
+    """
+
+    class InDirectoryServer(http.server.ThreadingHTTPServer):
+        def finish_request(self, request: Any, client_address: Any) -> None:
+            self.RequestHandlerClass(
+                request, client_address, self, directory=str(html_index_for_packages)  # type: ignore[call-arg] # noqa: E501
+            )
+
+    class Handler(OneTimeDownloadHandler):
+        _seen_paths: ClassVar[Set[str]] = set()
+
+    with InDirectoryServer(("", 8000), Handler) as httpd:
+        server_thread = threading.Thread(target=httpd.serve_forever)
+        server_thread.start()
+
+        try:
+            yield httpd
+        finally:
+            httpd.shutdown()
+            server_thread.join()
diff --git a/tests/functional/test_download.py b/tests/functional/test_download.py
--- a/tests/functional/test_download.py
+++ b/tests/functional/test_download.py
@@ -1,14 +1,11 @@
+import http.server
 import os
 import re
 import shutil
 import textwrap
-import uuid
-from dataclasses import dataclass
-from enum import Enum
 from hashlib import sha256
 from pathlib import Path
-from textwrap import dedent
-from typing import Callable, Dict, List, Optional, Tuple
+from typing import Callable, List, Tuple
 
 import pytest
 
@@ -1237,181 +1234,47 @@ def test_download_use_pep517_propagation(
     assert len(downloads) == 2
 
 
-class MetadataKind(Enum):
-    """All the types of values we might be provided for the data-dist-info-metadata
-    attribute from PEP 658."""
-
-    # Valid: will read metadata from the dist instead.
-    No = "none"
-    # Valid: will read the .metadata file, but won't check its hash.
-    Unhashed = "unhashed"
-    # Valid: will read the .metadata file and check its hash matches.
-    Sha256 = "sha256"
-    # Invalid: will error out after checking the hash.
-    WrongHash = "wrong-hash"
-    # Invalid: will error out after failing to fetch the .metadata file.
-    NoFile = "no-file"
-
-
-@dataclass(frozen=True)
-class Package:
-    """Mock package structure used to generate a PyPI repository.
-
-    Package name and version should correspond to sdists (.tar.gz files) in our test
-    data."""
-
-    name: str
-    version: str
-    filename: str
-    metadata: MetadataKind
-    # This will override any dependencies specified in the actual dist's METADATA.
-    requires_dist: Tuple[str, ...] = ()
-    # This will override the Name specified in the actual dist's METADATA.
-    metadata_name: Optional[str] = None
-
-    def metadata_filename(self) -> str:
-        """This is specified by PEP 658."""
-        return f"{self.filename}.metadata"
-
-    def generate_additional_tag(self) -> str:
-        """This gets injected into the <a> tag in the generated PyPI index page for this
-        package."""
-        if self.metadata == MetadataKind.No:
-            return ""
-        if self.metadata in [MetadataKind.Unhashed, MetadataKind.NoFile]:
-            return 'data-dist-info-metadata="true"'
-        if self.metadata == MetadataKind.WrongHash:
-            return 'data-dist-info-metadata="sha256=WRONG-HASH"'
-        assert self.metadata == MetadataKind.Sha256
-        checksum = sha256(self.generate_metadata()).hexdigest()
-        return f'data-dist-info-metadata="sha256={checksum}"'
-
-    def requires_str(self) -> str:
-        if not self.requires_dist:
-            return ""
-        joined = " and ".join(self.requires_dist)
-        return f"Requires-Dist: {joined}"
-
-    def generate_metadata(self) -> bytes:
-        """This is written to `self.metadata_filename()` and will override the actual
-        dist's METADATA, unless `self.metadata == MetadataKind.NoFile`."""
-        return dedent(
-            f"""\
-        Metadata-Version: 2.1
-        Name: {self.metadata_name or self.name}
-        Version: {self.version}
-        {self.requires_str()}
-        """
-        ).encode("utf-8")
-
-
 @pytest.fixture(scope="function")
-def write_index_html_content(tmpdir: Path) -> Callable[[str], Path]:
-    """Generate a PyPI package index.html within a temporary local directory."""
-    html_dir = tmpdir / "index_html_content"
-    html_dir.mkdir()
-
-    def generate_index_html_subdir(index_html: str) -> Path:
-        """Create a new subdirectory after a UUID and write an index.html."""
-        new_subdir = html_dir / uuid.uuid4().hex
-        new_subdir.mkdir()
-
-        with open(new_subdir / "index.html", "w") as f:
-            f.write(index_html)
-
-        return new_subdir
-
-    return generate_index_html_subdir
-
-
-@pytest.fixture(scope="function")
-def html_index_for_packages(
-    shared_data: TestData,
-    write_index_html_content: Callable[[str], Path],
-) -> Callable[..., Path]:
-    """Generate a PyPI HTML package index within a local directory pointing to
-    blank data."""
+def download_local_html_index(
+    script: PipTestEnvironment,
+    html_index_for_packages: Path,
+    tmpdir: Path,
+) -> Callable[..., Tuple[TestPipResult, Path]]:
+    """Execute `pip download` against a generated PyPI index."""
+    download_dir = tmpdir / "download_dir"
 
-    def generate_html_index_for_packages(packages: Dict[str, List[Package]]) -> Path:
+    def run_for_generated_index(
+        args: List[str],
+        allow_error: bool = False,
+    ) -> Tuple[TestPipResult, Path]:
         """
-        Produce a PyPI directory structure pointing to the specified packages.
+        Produce a PyPI directory structure pointing to the specified packages, then
+        execute `pip download -i ...` pointing to our generated index.
         """
-        # (1) Generate the content for a PyPI index.html.
-        pkg_links = "\n".join(
-            f'    <a href="{pkg}/index.html">{pkg}</a>' for pkg in packages.keys()
-        )
-        index_html = f"""\
-<!DOCTYPE html>
-<html>
-  <head>
-    <meta name="pypi:repository-version" content="1.0">
-    <title>Simple index</title>
-  </head>
-  <body>
-{pkg_links}
-  </body>
-</html>"""
-        # (2) Generate the index.html in a new subdirectory of the temp directory.
-        index_html_subdir = write_index_html_content(index_html)
-
-        # (3) Generate subdirectories for individual packages, each with their own
-        # index.html.
-        for pkg, links in packages.items():
-            pkg_subdir = index_html_subdir / pkg
-            pkg_subdir.mkdir()
-
-            download_links: List[str] = []
-            for package_link in links:
-                # (3.1) Generate the <a> tag which pip can crawl pointing to this
-                # specific package version.
-                download_links.append(
-                    f'    <a href="{package_link.filename}" {package_link.generate_additional_tag()}>{package_link.filename}</a><br/>'  # noqa: E501
-                )
-                # (3.2) Copy over the corresponding file in `shared_data.packages`.
-                shutil.copy(
-                    shared_data.packages / package_link.filename,
-                    pkg_subdir / package_link.filename,
-                )
-                # (3.3) Write a metadata file, if applicable.
-                if package_link.metadata != MetadataKind.NoFile:
-                    with open(pkg_subdir / package_link.metadata_filename(), "wb") as f:
-                        f.write(package_link.generate_metadata())
-
-            # (3.4) After collating all the download links and copying over the files,
-            # write an index.html with the generated download links for each
-            # copied file for this specific package name.
-            download_links_str = "\n".join(download_links)
-            pkg_index_content = f"""\
-<!DOCTYPE html>
-<html>
-  <head>
-    <meta name="pypi:repository-version" content="1.0">
-    <title>Links for {pkg}</title>
-  </head>
-  <body>
-    <h1>Links for {pkg}</h1>
-{download_links_str}
-  </body>
-</html>"""
-            with open(pkg_subdir / "index.html", "w") as f:
-                f.write(pkg_index_content)
-
-        return index_html_subdir
-
-    return generate_html_index_for_packages
+        pip_args = [
+            "download",
+            "-d",
+            str(download_dir),
+            "-i",
+            path_to_url(str(html_index_for_packages)),
+            *args,
+        ]
+        result = script.pip(*pip_args, allow_error=allow_error)
+        return (result, download_dir)
+
+    return run_for_generated_index
 
 
 @pytest.fixture(scope="function")
-def download_generated_html_index(
+def download_server_html_index(
     script: PipTestEnvironment,
-    html_index_for_packages: Callable[[Dict[str, List[Package]]], Path],
     tmpdir: Path,
+    html_index_with_onetime_server: http.server.ThreadingHTTPServer,
 ) -> Callable[..., Tuple[TestPipResult, Path]]:
     """Execute `pip download` against a generated PyPI index."""
     download_dir = tmpdir / "download_dir"
 
     def run_for_generated_index(
-        packages: Dict[str, List[Package]],
         args: List[str],
         allow_error: bool = False,
     ) -> Tuple[TestPipResult, Path]:
@@ -1419,13 +1282,12 @@ def run_for_generated_index(
         Produce a PyPI directory structure pointing to the specified packages, then
         execute `pip download -i ...` pointing to our generated index.
         """
-        index_dir = html_index_for_packages(packages)
         pip_args = [
             "download",
             "-d",
             str(download_dir),
             "-i",
-            path_to_url(str(index_dir)),
+            "http://localhost:8000",
             *args,
         ]
         result = script.pip(*pip_args, allow_error=allow_error)
@@ -1434,86 +1296,6 @@ def run_for_generated_index(
     return run_for_generated_index
 
 
-# The package database we generate for testing PEP 658 support.
-_simple_packages: Dict[str, List[Package]] = {
-    "simple": [
-        Package("simple", "1.0", "simple-1.0.tar.gz", MetadataKind.Sha256),
-        Package("simple", "2.0", "simple-2.0.tar.gz", MetadataKind.No),
-        # This will raise a hashing error.
-        Package("simple", "3.0", "simple-3.0.tar.gz", MetadataKind.WrongHash),
-    ],
-    "simple2": [
-        # Override the dependencies here in order to force pip to download
-        # simple-1.0.tar.gz as well.
-        Package(
-            "simple2",
-            "1.0",
-            "simple2-1.0.tar.gz",
-            MetadataKind.Unhashed,
-            ("simple==1.0",),
-        ),
-        # This will raise an error when pip attempts to fetch the metadata file.
-        Package("simple2", "2.0", "simple2-2.0.tar.gz", MetadataKind.NoFile),
-        # This has a METADATA file with a mismatched name.
-        Package(
-            "simple2",
-            "3.0",
-            "simple2-3.0.tar.gz",
-            MetadataKind.Sha256,
-            metadata_name="not-simple2",
-        ),
-    ],
-    "colander": [
-        # Ensure we can read the dependencies from a metadata file within a wheel
-        # *without* PEP 658 metadata.
-        Package(
-            "colander", "0.9.9", "colander-0.9.9-py2.py3-none-any.whl", MetadataKind.No
-        ),
-    ],
-    "compilewheel": [
-        # Ensure we can override the dependencies of a wheel file by injecting PEP
-        # 658 metadata.
-        Package(
-            "compilewheel",
-            "1.0",
-            "compilewheel-1.0-py2.py3-none-any.whl",
-            MetadataKind.Unhashed,
-            ("simple==1.0",),
-        ),
-    ],
-    "has-script": [
-        # Ensure we check PEP 658 metadata hashing errors for wheel files.
-        Package(
-            "has-script",
-            "1.0",
-            "has.script-1.0-py2.py3-none-any.whl",
-            MetadataKind.WrongHash,
-        ),
-    ],
-    "translationstring": [
-        Package(
-            "translationstring", "1.1", "translationstring-1.1.tar.gz", MetadataKind.No
-        ),
-    ],
-    "priority": [
-        # Ensure we check for a missing metadata file for wheels.
-        Package(
-            "priority", "1.0", "priority-1.0-py2.py3-none-any.whl", MetadataKind.NoFile
-        ),
-    ],
-    "requires-simple-extra": [
-        # Metadata name is not canonicalized.
-        Package(
-            "requires-simple-extra",
-            "0.1",
-            "requires_simple_extra-0.1-py2.py3-none-any.whl",
-            MetadataKind.Sha256,
-            metadata_name="Requires_Simple.Extra",
-        ),
-    ],
-}
-
-
 @pytest.mark.parametrize(
     "requirement_to_download, expected_outputs",
     [
@@ -1530,19 +1312,69 @@ def run_for_generated_index(
     ],
 )
 def test_download_metadata(
-    download_generated_html_index: Callable[..., Tuple[TestPipResult, Path]],
+    download_local_html_index: Callable[..., Tuple[TestPipResult, Path]],
     requirement_to_download: str,
     expected_outputs: List[str],
 ) -> None:
     """Verify that if a data-dist-info-metadata attribute is present, then it is used
     instead of the actual dist's METADATA."""
-    _, download_dir = download_generated_html_index(
-        _simple_packages,
+    _, download_dir = download_local_html_index(
         [requirement_to_download],
     )
     assert sorted(os.listdir(download_dir)) == expected_outputs
 
 
+@pytest.mark.parametrize(
+    "requirement_to_download, expected_outputs, doubled_path",
+    [
+        (
+            "simple2==1.0",
+            ["simple-1.0.tar.gz", "simple2-1.0.tar.gz"],
+            "/simple2/simple2-1.0.tar.gz",
+        ),
+        ("simple==2.0", ["simple-2.0.tar.gz"], "/simple/simple-2.0.tar.gz"),
+        (
+            "colander",
+            ["colander-0.9.9-py2.py3-none-any.whl", "translationstring-1.1.tar.gz"],
+            "/colander/colander-0.9.9-py2.py3-none-any.whl",
+        ),
+        (
+            "compilewheel",
+            [
+                "compilewheel-1.0-py2.py3-none-any.whl",
+                "simple-1.0.tar.gz",
+            ],
+            "/compilewheel/compilewheel-1.0-py2.py3-none-any.whl",
+        ),
+    ],
+)
+def test_download_metadata_server(
+    download_server_html_index: Callable[..., Tuple[TestPipResult, Path]],
+    requirement_to_download: str,
+    expected_outputs: List[str],
+    doubled_path: str,
+) -> None:
+    """Verify that if a data-dist-info-metadata attribute is present, then it is used
+    instead of the actual dist's METADATA.
+
+    Additionally, verify that each dist is downloaded exactly once using a mock server.
+
+    This is a regression test for issue https://github.com/pypa/pip/issues/11847.
+    """
+    _, download_dir = download_server_html_index(
+        [requirement_to_download, "--no-cache-dir"],
+    )
+    assert sorted(os.listdir(download_dir)) == expected_outputs
+    shutil.rmtree(download_dir)
+    result, _ = download_server_html_index(
+        [requirement_to_download, "--no-cache-dir"],
+        allow_error=True,
+    )
+    assert result.returncode != 0
+    expected_msg = f"File {doubled_path} not available more than once!"
+    assert expected_msg in result.stderr
+
+
 @pytest.mark.parametrize(
     "requirement_to_download, real_hash",
     [
@@ -1557,14 +1389,13 @@ def test_download_metadata(
     ],
 )
 def test_incorrect_metadata_hash(
-    download_generated_html_index: Callable[..., Tuple[TestPipResult, Path]],
+    download_local_html_index: Callable[..., Tuple[TestPipResult, Path]],
     requirement_to_download: str,
     real_hash: str,
 ) -> None:
     """Verify that if a hash for data-dist-info-metadata is provided, it must match the
     actual hash of the metadata file."""
-    result, _ = download_generated_html_index(
-        _simple_packages,
+    result, _ = download_local_html_index(
         [requirement_to_download],
         allow_error=True,
     )
@@ -1583,15 +1414,14 @@ def test_incorrect_metadata_hash(
     ],
 )
 def test_metadata_not_found(
-    download_generated_html_index: Callable[..., Tuple[TestPipResult, Path]],
+    download_local_html_index: Callable[..., Tuple[TestPipResult, Path]],
     requirement_to_download: str,
     expected_url: str,
 ) -> None:
     """Verify that if a data-dist-info-metadata attribute is provided, that pip will
     fetch the .metadata file at the location specified by PEP 658, and error
     if unavailable."""
-    result, _ = download_generated_html_index(
-        _simple_packages,
+    result, _ = download_local_html_index(
         [requirement_to_download],
         allow_error=True,
     )
@@ -1604,11 +1434,10 @@ def test_metadata_not_found(
 
 
 def test_produces_error_for_mismatched_package_name_in_metadata(
-    download_generated_html_index: Callable[..., Tuple[TestPipResult, Path]],
+    download_local_html_index: Callable[..., Tuple[TestPipResult, Path]],
 ) -> None:
     """Verify that the package name from the metadata matches the requested package."""
-    result, _ = download_generated_html_index(
-        _simple_packages,
+    result, _ = download_local_html_index(
         ["simple2==3.0"],
         allow_error=True,
     )
@@ -1628,7 +1457,7 @@ def test_produces_error_for_mismatched_package_name_in_metadata(
     ),
 )
 def test_canonicalizes_package_name_before_verifying_metadata(
-    download_generated_html_index: Callable[..., Tuple[TestPipResult, Path]],
+    download_local_html_index: Callable[..., Tuple[TestPipResult, Path]],
     requirement: str,
 ) -> None:
     """Verify that the package name from the command line and the package's
@@ -1636,8 +1465,7 @@ def test_canonicalizes_package_name_before_verifying_metadata(
 
     Regression test for https://github.com/pypa/pip/issues/12038
     """
-    result, download_dir = download_generated_html_index(
-        _simple_packages,
+    result, download_dir = download_local_html_index(
         [requirement],
         allow_error=True,
     )