Skip to content

Commit 7fd7e49

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 3c2aa93 commit 7fd7e49

File tree

10 files changed

+337
-23
lines changed

10 files changed

+337
-23
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

+57-2
Original file line numberDiff line numberDiff line change
@@ -579,16 +579,71 @@ def prepare_linked_requirement(
579579
# None of the optimizations worked, fully prepare the requirement
580580
return self._prepare_linked_requirement(req, parallel_builds)
581581

582-
def prepare_linked_requirements_more(
582+
def _extract_download_info(self, reqs: Iterable[InstallRequirement]) -> None:
583+
"""
584+
`pip install --report` extracts the download info from each requirement for its
585+
JSON output, so we need to make sure every requirement has this before finishing
586+
the resolve. But .download_info will only be populated by the point this method
587+
is called for requirements already found in the wheel cache, so we need to
588+
synthesize it for uncached results. Luckily, a DirectUrl can be parsed directly
589+
from a url without any other context. However, this also means the download info
590+
will only contain a hash if the link itself declares the hash.
591+
"""
592+
for req in reqs:
593+
if req.download_info is None:
594+
self._ensure_download_info(req)
595+
596+
def _force_fully_prepared(
597+
self, reqs: Iterable[InstallRequirement], assert_has_dist_files: bool
598+
) -> None:
599+
"""
600+
The legacy resolver seems to prepare requirements differently that can leave
601+
them half-done in certain code paths. I'm not quite sure how it's doing things,
602+
but at least we can do this to make sure they do things right.
603+
"""
604+
for req in reqs:
605+
req.prepared = True
606+
if assert_has_dist_files:
607+
assert req.is_concrete
608+
609+
def _ensure_dist_files(
583610
self, reqs: Iterable[InstallRequirement], parallel_builds: bool = False
584611
) -> None:
585-
"""Prepare linked requirements more, if needed."""
612+
"""Download any metadata-only linked requirements."""
586613
metadata_only_reqs = [req for req in reqs if not req.is_concrete]
587614
self._complete_partial_requirements(
588615
metadata_only_reqs,
589616
parallel_builds=parallel_builds,
590617
)
591618

619+
def finalize_linked_requirements(
620+
self,
621+
reqs: Iterable[InstallRequirement],
622+
require_dist_files: bool,
623+
parallel_builds: bool = False,
624+
) -> None:
625+
"""Prepare linked requirements more, if needed.
626+
627+
Neighboring .metadata files as per PEP 658 or lazy wheels via fast-deps will be
628+
preferred to extract metadata from any concrete requirement (one that has been
629+
mapped to a Link) without downloading the underlying wheel or sdist. When ``pip
630+
install --dry-run`` is called, we want to avoid ever downloading the underlying
631+
dist, but we still need to provide all of the results that pip commands expect
632+
from the typical resolve process.
633+
634+
Those expectations vary, but one distinction lies in whether the command needs
635+
an actual physical dist somewhere on the filesystem, or just the metadata about
636+
it from the resolver (as in ``pip install --report``). If the command requires
637+
actual physical filesystem locations for the resolved dists, it must call this
638+
method with ``require_dist_files=True`` to fully download anything
639+
that remains.
640+
"""
641+
if require_dist_files:
642+
self._ensure_dist_files(reqs, parallel_builds=parallel_builds)
643+
else:
644+
self._extract_download_info(reqs)
645+
self._force_fully_prepared(reqs, assert_has_dist_files=require_dist_files)
646+
592647
def _ensure_local_file_path(
593648
self, req: InstallRequirement, hashes: Optional[Hashes]
594649
) -> None:

src/pip/_internal/req/req_install.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -648,10 +648,7 @@ def cache_concrete_dist(self, dist: BaseDistribution) -> None:
648648
# If we set a dist twice for the same requirement, we must be hydrating
649649
# a concrete dist for what was previously virtual. This will occur in the
650650
# case of `install --dry-run` when PEP 658 metadata is available.
651-
652-
# TODO(#12186): avoid setting dist twice!
653-
# assert not self._dist.is_concrete
654-
pass
651+
assert not self._dist.is_concrete
655652
assert dist.is_concrete
656653
self._dist = dist
657654

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)