Skip to content

Commit 8b336e3

Browse files
use .metadata distribution info when possible
When performing `install --dry-run` and PEP 658 .metadata files are available to guide the resolve, do not download the associated wheels. Rather use the distribution information directly from the .metadata files when reporting the results on the CLI and in the --report file. - describe the new --dry-run behavior - finalize linked requirements immediately after resolve - introduce is_concrete - funnel InstalledDistribution through _get_prepared_distribution() too - add test for new install --dry-run functionality (no downloading)
1 parent 311e8cb commit 8b336e3

File tree

10 files changed

+343
-27
lines changed

10 files changed

+343
-27
lines changed

news/12186.bugfix.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Avoid downloading any dists in ``install --dry-run`` if PEP 658 ``.metadata`` files or lazy wheels are available.

src/pip/_internal/commands/download.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ def run(self, options: Values, args: List[str]) -> int:
130130
self.trace_basic_info(finder)
131131

132132
requirement_set = resolver.resolve(reqs, check_supported_wheels=True)
133+
preparer.finalize_linked_requirements(
134+
requirement_set.requirements.values(), require_dist_files=True
135+
)
133136

134137
downloaded: List[str] = []
135138
for req in requirement_set.requirements.values():
@@ -138,8 +141,6 @@ def run(self, options: Values, args: List[str]) -> int:
138141
preparer.save_linked_requirement(req)
139142
downloaded.append(req.name)
140143

141-
preparer.prepare_linked_requirements_more(requirement_set.requirements.values())
142-
143144
if downloaded:
144145
write_output("Successfully downloaded %s", " ".join(downloaded))
145146

src/pip/_internal/commands/install.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ def add_options(self) -> None:
8585
help=(
8686
"Don't actually install anything, just print what would be. "
8787
"Can be used in combination with --ignore-installed "
88-
"to 'resolve' the requirements."
88+
"to 'resolve' the requirements. If package metadata is available "
89+
"or cached, --dry-run also avoids downloading the dependency at all."
8990
),
9091
)
9192
self.cmd_opts.add_option(
@@ -379,6 +380,10 @@ def run(self, options: Values, args: List[str]) -> int:
379380
requirement_set = resolver.resolve(
380381
reqs, check_supported_wheels=not options.target_dir
381382
)
383+
preparer.finalize_linked_requirements(
384+
requirement_set.requirements.values(),
385+
require_dist_files=not options.dry_run,
386+
)
382387

383388
if options.json_report_file:
384389
report = InstallationReport(requirement_set.requirements_to_install)

src/pip/_internal/commands/wheel.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ def run(self, options: Values, args: List[str]) -> int:
145145
self.trace_basic_info(finder)
146146

147147
requirement_set = resolver.resolve(reqs, check_supported_wheels=True)
148+
preparer.finalize_linked_requirements(
149+
requirement_set.requirements.values(), require_dist_files=True
150+
)
148151

149152
reqs_to_build: List[InstallRequirement] = []
150153
for req in requirement_set.requirements.values():
@@ -153,8 +156,6 @@ def run(self, options: Values, args: List[str]) -> int:
153156
elif should_build_for_wheel_command(req):
154157
reqs_to_build.append(req)
155158

156-
preparer.prepare_linked_requirements_more(requirement_set.requirements.values())
157-
158159
# build wheels
159160
build_successes, build_failures = build(
160161
reqs_to_build,

src/pip/_internal/operations/check.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
from pip._vendor.packaging.utils import NormalizedName, canonicalize_name
2424
from pip._vendor.packaging.version import Version
2525

26-
from pip._internal.distributions import make_distribution_for_install_requirement
2726
from pip._internal.metadata import get_default_environment
2827
from pip._internal.metadata.base import BaseDistribution
2928
from pip._internal.req.req_install import InstallRequirement
@@ -154,8 +153,8 @@ def _simulate_installation_of(
154153

155154
# Modify it as installing requirement_set would (assuming no errors)
156155
for inst_req in to_install:
157-
abstract_dist = make_distribution_for_install_requirement(inst_req)
158-
dist = abstract_dist.get_metadata_distribution()
156+
assert inst_req.is_concrete
157+
dist = inst_req.get_dist()
159158
name = dist.canonical_name
160159
package_set[name] = PackageDetails(dist.version, list(dist.iter_dependencies()))
161160

src/pip/_internal/operations/prepare.py

+63-6
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424
VcsHashUnsupported,
2525
)
2626
from pip._internal.index.package_finder import PackageFinder
27-
from pip._internal.metadata import BaseDistribution, get_metadata_distribution
27+
from pip._internal.metadata import (
28+
BaseDistribution,
29+
get_metadata_distribution,
30+
)
2831
from pip._internal.models.direct_url import ArchiveInfo
2932
from pip._internal.models.link import Link
3033
from pip._internal.models.wheel import Wheel
@@ -529,16 +532,42 @@ def prepare_linked_requirement(
529532
# None of the optimizations worked, fully prepare the requirement
530533
return self._prepare_linked_requirement(req, parallel_builds)
531534

532-
def prepare_linked_requirements_more(
533-
self, reqs: Iterable[InstallRequirement], parallel_builds: bool = False
535+
def _extract_download_info(self, reqs: Iterable[InstallRequirement]) -> None:
536+
"""
537+
`pip install --report` extracts the download info from each requirement for its
538+
JSON output, so we need to make sure every requirement has this before finishing
539+
the resolve. But .download_info will only be populated by the point this method
540+
is called for requirements already found in the wheel cache, so we need to
541+
synthesize it for uncached results. Luckily, a DirectUrl can be parsed directly
542+
from a url without any other context. However, this also means the download info
543+
will only contain a hash if the link itself declares the hash.
544+
"""
545+
for req in reqs:
546+
self._populate_download_info(req)
547+
548+
def _force_fully_prepared(
549+
self, reqs: Iterable[InstallRequirement], assert_has_dist_files: bool
550+
) -> None:
551+
"""
552+
The legacy resolver seems to prepare requirements differently that can leave
553+
them half-done in certain code paths. I'm not quite sure how it's doing things,
554+
but at least we can do this to make sure they do things right.
555+
"""
556+
for req in reqs:
557+
req.prepared = True
558+
if assert_has_dist_files:
559+
assert req.is_concrete
560+
561+
def _ensure_dist_files(
562+
self,
563+
reqs: Iterable[InstallRequirement],
564+
parallel_builds: bool = False,
534565
) -> None:
535-
"""Prepare linked requirements more, if needed."""
566+
"""Download any metadata-only linked requirements."""
536567
partially_downloaded_reqs: List[InstallRequirement] = []
537-
# reqs = [req for req in reqs if req.needs_more_preparation]
538568
for req in reqs:
539569
if req.is_concrete:
540570
continue
541-
542571
# Determine if any of these requirements were already downloaded.
543572
if self.download_dir is not None and req.link.is_wheel:
544573
hashes = self._get_linked_req_hashes(req)
@@ -560,6 +589,34 @@ def prepare_linked_requirements_more(
560589
parallel_builds=parallel_builds,
561590
)
562591

592+
def finalize_linked_requirements(
593+
self,
594+
reqs: Iterable[InstallRequirement],
595+
require_dist_files: bool,
596+
parallel_builds: bool = False,
597+
) -> None:
598+
"""Prepare linked requirements more, if needed.
599+
600+
Neighboring .metadata files as per PEP 658 or lazy wheels via fast-deps will be
601+
preferred to extract metadata from any concrete requirement (one that has been
602+
mapped to a Link) without downloading the underlying wheel or sdist. When ``pip
603+
install --dry-run`` is called, we want to avoid ever downloading the underlying
604+
dist, but we still need to provide all of the results that pip commands expect
605+
from the typical resolve process.
606+
607+
Those expectations vary, but one distinction lies in whether the command needs
608+
an actual physical dist somewhere on the filesystem, or just the metadata about
609+
it from the resolver (as in ``pip install --report``). If the command requires
610+
actual physical filesystem locations for the resolved dists, it must call this
611+
method with ``require_dist_files=True`` to fully download anything
612+
that remains.
613+
"""
614+
if require_dist_files:
615+
self._ensure_dist_files(reqs, parallel_builds=parallel_builds)
616+
else:
617+
self._extract_download_info(reqs)
618+
self._force_fully_prepared(reqs, assert_has_dist_files=require_dist_files)
619+
563620
def _prepare_linked_requirement(
564621
self, req: InstallRequirement, parallel_builds: bool
565622
) -> BaseDistribution:

src/pip/_internal/req/req_install.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -639,10 +639,7 @@ def cache_concrete_dist(self, dist: BaseDistribution) -> None:
639639
# If we set a dist twice for the same requirement, we must be hydrating
640640
# a concrete dist for what was previously virtual. This will occur in the
641641
# case of `install --dry-run` when PEP 658 metadata is available.
642-
643-
# TODO(#12186): avoid setting dist twice!
644-
# assert not self._dist.is_concrete
645-
pass
642+
assert not self._dist.is_concrete
646643
assert dist.is_concrete
647644
self._dist = dist
648645

src/pip/_internal/resolution/resolvelib/resolver.py

-5
Original file line numberDiff line numberDiff line change
@@ -175,11 +175,6 @@ def resolve(
175175

176176
req_set.add_named_requirement(ireq)
177177

178-
reqs = req_set.all_requirements
179-
self.factory.preparer.prepare_linked_requirements_more(reqs)
180-
for req in reqs:
181-
req.prepared = True
182-
assert req.is_concrete
183178
return req_set
184179

185180
def get_installation_order(

tests/conftest.py

+28-4
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,9 @@ class FakePackage:
748748
requires_dist: Tuple[str, ...] = ()
749749
# This will override the Name specified in the actual dist's METADATA.
750750
metadata_name: Optional[str] = None
751+
# Whether to delete the file this points to, which causes any attempt to fetch this
752+
# package to fail unless it is processed as a metadata-only dist.
753+
delete_linked_file: bool = False
751754

752755
def metadata_filename(self) -> str:
753756
"""This is specified by PEP 658."""
@@ -837,6 +840,27 @@ def fake_packages() -> Dict[str, List[FakePackage]]:
837840
("simple==1.0",),
838841
),
839842
],
843+
"complex-dist": [
844+
FakePackage(
845+
"complex-dist",
846+
"0.1",
847+
"complex_dist-0.1-py2.py3-none-any.whl",
848+
MetadataKind.Unhashed,
849+
# Validate that the wheel isn't fetched if metadata is available and
850+
# --dry-run is on, when the metadata presents no hash itself.
851+
delete_linked_file=True,
852+
),
853+
],
854+
"corruptwheel": [
855+
FakePackage(
856+
"corruptwheel",
857+
"1.0",
858+
"corruptwheel-1.0-py2.py3-none-any.whl",
859+
# Validate that the wheel isn't fetched if metadata is available and
860+
# --dry-run is on, when the metadata *does* present a hash.
861+
MetadataKind.Sha256,
862+
),
863+
],
840864
"has-script": [
841865
# Ensure we check PEP 658 metadata hashing errors for wheel files.
842866
FakePackage(
@@ -922,10 +946,10 @@ def html_index_for_packages(
922946
f' <a href="{package_link.filename}" {package_link.generate_additional_tag()}>{package_link.filename}</a><br/>' # noqa: E501
923947
)
924948
# (3.2) Copy over the corresponding file in `shared_data.packages`.
925-
shutil.copy(
926-
shared_data.packages / package_link.filename,
927-
pkg_subdir / package_link.filename,
928-
)
949+
cached_file = shared_data.packages / package_link.filename
950+
new_file = pkg_subdir / package_link.filename
951+
if not package_link.delete_linked_file:
952+
shutil.copy(cached_file, new_file)
929953
# (3.3) Write a metadata file, if applicable.
930954
if package_link.metadata != MetadataKind.NoFile:
931955
with open(pkg_subdir / package_link.metadata_filename(), "wb") as f:

0 commit comments

Comments
 (0)