Skip to content

Commit

Permalink
use .metadata distribution info when possible
Browse files Browse the repository at this point in the history
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

- Update src/pip/_internal/commands/install.py
Co-authored-by: Ed Morley <[email protected]>

- Update src/pip/_internal/commands/install.py
Co-authored-by: Pradyun Gedam <[email protected]>
  • Loading branch information
Jonathan Helmus authored and cosmicexplorer committed Aug 1, 2023
1 parent 7e40d4a commit 12835e8
Show file tree
Hide file tree
Showing 21 changed files with 303 additions and 91 deletions.
1 change: 1 addition & 0 deletions news/12186.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Avoid downloading any dists in ``install --dry-run`` if PEP 658 ``.metadata`` files or lazy wheels are available.
4 changes: 3 additions & 1 deletion src/pip/_internal/commands/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ def run(self, options: Values, args: List[str]) -> int:
self.trace_basic_info(finder)

requirement_set = resolver.resolve(reqs, check_supported_wheels=True)
preparer.finalize_linked_requirements(
requirement_set.requirements.values(), hydrate_virtual_reqs=True
)

downloaded: List[str] = []
for req in requirement_set.requirements.values():
Expand All @@ -138,7 +141,6 @@ def run(self, options: Values, args: List[str]) -> int:
preparer.save_linked_requirement(req)
downloaded.append(req.name)

preparer.prepare_linked_requirements_more(requirement_set.requirements.values())
requirement_set.warn_legacy_versions_and_specifiers()

if downloaded:
Expand Down
7 changes: 6 additions & 1 deletion src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ def add_options(self) -> None:
help=(
"Don't actually install anything, just print what would be. "
"Can be used in combination with --ignore-installed "
"to 'resolve' the requirements."
"to 'resolve' the requirements. If PEP 658 or fast-deps metadata is "
"available, --dry-run also avoids downloading the dependency at all."
),
)
self.cmd_opts.add_option(
Expand Down Expand Up @@ -377,6 +378,10 @@ def run(self, options: Values, args: List[str]) -> int:
requirement_set = resolver.resolve(
reqs, check_supported_wheels=not options.target_dir
)
preparer.finalize_linked_requirements(
requirement_set.requirements.values(),
hydrate_virtual_reqs=not options.dry_run,
)

if options.json_report_file:
report = InstallationReport(requirement_set.requirements_to_install)
Expand Down
4 changes: 3 additions & 1 deletion src/pip/_internal/commands/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ def run(self, options: Values, args: List[str]) -> int:
self.trace_basic_info(finder)

requirement_set = resolver.resolve(reqs, check_supported_wheels=True)
preparer.finalize_linked_requirements(
requirement_set.requirements.values(), hydrate_virtual_reqs=True
)

reqs_to_build: List[InstallRequirement] = []
for req in requirement_set.requirements.values():
Expand All @@ -153,7 +156,6 @@ def run(self, options: Values, args: List[str]) -> int:
elif should_build_for_wheel_command(req):
reqs_to_build.append(req)

preparer.prepare_linked_requirements_more(requirement_set.requirements.values())
requirement_set.warn_legacy_versions_and_specifiers()

# build wheels
Expand Down
5 changes: 5 additions & 0 deletions src/pip/_internal/distributions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from pip._internal.distributions.base import AbstractDistribution
from pip._internal.distributions.installed import InstalledDistribution
from pip._internal.distributions.sdist import SourceDistribution
from pip._internal.distributions.wheel import WheelDistribution
from pip._internal.req.req_install import InstallRequirement
Expand All @@ -8,6 +9,10 @@ def make_distribution_for_install_requirement(
install_req: InstallRequirement,
) -> AbstractDistribution:
"""Returns a Distribution for the given InstallRequirement"""
# Only pre-installed requirements will have a .satisfied_by dist.
if install_req.satisfied_by:
return InstalledDistribution(install_req)

# Editable requirements will always be source distributions. They use the
# legacy logic until we create a modern standard for them.
if install_req.editable:
Expand Down
17 changes: 17 additions & 0 deletions src/pip/_internal/distributions/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,18 @@ def __init__(self, req: InstallRequirement) -> None:
super().__init__()
self.req = req

@abc.abstractproperty
def add_to_build_tracker(self) -> bool:
raise NotImplementedError()

@abc.abstractmethod
def get_metadata_distribution(self) -> BaseDistribution:
"""Generate a concrete ``BaseDistribution`` instance for this artifact.
The implementation should also cache the result with
``self.req.cache_concrete_dist()`` so the distribution is available to other
users of the ``InstallRequirement``. This method is not called within the build
tracker context, so it should not identify any new setup requirements."""
raise NotImplementedError()

@abc.abstractmethod
Expand All @@ -36,4 +46,11 @@ def prepare_distribution_metadata(
build_isolation: bool,
check_build_deps: bool,
) -> None:
"""Generate the information necessary to extract metadata from the artifact.
This method will be executed within the context of ``BuildTracker#track()``, so
it needs to fully identify any seutp requirements so they can be added to the
same active set of tracked builds, while ``.get_metadata_distribution()`` takes
care of generating and caching the ``BaseDistribution`` to expose to the rest of
the resolve."""
raise NotImplementedError()
10 changes: 8 additions & 2 deletions src/pip/_internal/distributions/installed.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ class InstalledDistribution(AbstractDistribution):
been computed.
"""

@property
def add_to_build_tracker(self) -> bool:
return False

def get_metadata_distribution(self) -> BaseDistribution:
assert self.req.satisfied_by is not None, "not actually installed"
return self.req.satisfied_by
dist = self.req.satisfied_by
assert dist is not None, "not actually installed"
self.req.cache_concrete_dist(dist)
return dist

def prepare_distribution_metadata(
self,
Expand Down
20 changes: 17 additions & 3 deletions src/pip/_internal/distributions/sdist.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pip._internal.distributions.base import AbstractDistribution
from pip._internal.exceptions import InstallationError
from pip._internal.index.package_finder import PackageFinder
from pip._internal.metadata import BaseDistribution
from pip._internal.metadata import BaseDistribution, get_directory_distribution
from pip._internal.utils.subprocess import runner_with_spinner_message

logger = logging.getLogger(__name__)
Expand All @@ -18,8 +18,18 @@ class SourceDistribution(AbstractDistribution):
generated, either using PEP 517 or using the legacy `setup.py egg_info`.
"""

@property
def add_to_build_tracker(self) -> bool:
return True

def get_metadata_distribution(self) -> BaseDistribution:
return self.req.get_dist()
assert (
self.req.metadata_directory
), "Set as part of .prepare_distribution_metadata()"
dist = get_directory_distribution(self.req.metadata_directory)
self.req.cache_concrete_dist(dist)
self.req.validate_sdist_metadata()
return dist

def prepare_distribution_metadata(
self,
Expand Down Expand Up @@ -58,7 +68,11 @@ def prepare_distribution_metadata(
self._raise_conflicts("the backend dependencies", conflicting)
if missing:
self._raise_missing_reqs(missing)
self.req.prepare_metadata()

# NB: we must still call .cache_concrete_dist() and .validate_sdist_metadata()
# before the InstallRequirement itself has been updated with the metadata from
# this directory!
self.req.prepare_metadata_directory()

def _prepare_build_backend(self, finder: PackageFinder) -> None:
# Isolate in a BuildEnvironment and install the build-time
Expand Down
8 changes: 7 additions & 1 deletion src/pip/_internal/distributions/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ class WheelDistribution(AbstractDistribution):
This does not need any preparation as wheels can be directly unpacked.
"""

@property
def add_to_build_tracker(self) -> bool:
return True

def get_metadata_distribution(self) -> BaseDistribution:
"""Loads the metadata from the wheel file into memory and returns a
Distribution that uses it, not relying on the wheel file or
Expand All @@ -23,7 +27,9 @@ def get_metadata_distribution(self) -> BaseDistribution:
assert self.req.local_file_path, "Set as part of preparation during download"
assert self.req.name, "Wheels are never unnamed"
wheel = FilesystemWheel(self.req.local_file_path)
return get_wheel_distribution(wheel, canonicalize_name(self.req.name))
dist = get_wheel_distribution(wheel, canonicalize_name(self.req.name))
self.req.cache_concrete_dist(dist)
return dist

def prepare_distribution_metadata(
self,
Expand Down
21 changes: 21 additions & 0 deletions src/pip/_internal/metadata/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ class RequiresEntry(NamedTuple):


class BaseDistribution(Protocol):
@property
def is_concrete(self) -> bool:
"""Whether the distribution really exists somewhere on disk.
If this is false, it has been synthesized from metadata, e.g. via
``.from_metadata_file_contents()``, or ``.from_wheel()`` against
a ``MemoryWheel``."""
raise NotImplementedError()

@classmethod
def from_directory(cls, directory: str) -> "BaseDistribution":
"""Load the distribution from a metadata directory.
Expand Down Expand Up @@ -667,6 +676,10 @@ def iter_installed_distributions(
class Wheel(Protocol):
location: str

@property
def is_concrete(self) -> bool:
raise NotImplementedError()

def as_zipfile(self) -> zipfile.ZipFile:
raise NotImplementedError()

Expand All @@ -675,6 +688,10 @@ class FilesystemWheel(Wheel):
def __init__(self, location: str) -> None:
self.location = location

@property
def is_concrete(self) -> bool:
return True

def as_zipfile(self) -> zipfile.ZipFile:
return zipfile.ZipFile(self.location, allowZip64=True)

Expand All @@ -684,5 +701,9 @@ def __init__(self, location: str, stream: IO[bytes]) -> None:
self.location = location
self.stream = stream

@property
def is_concrete(self) -> bool:
return False

def as_zipfile(self) -> zipfile.ZipFile:
return zipfile.ZipFile(self.stream, allowZip64=True)
17 changes: 14 additions & 3 deletions src/pip/_internal/metadata/importlib/_dists.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,22 @@ def __init__(
dist: importlib.metadata.Distribution,
info_location: Optional[BasePath],
installed_location: Optional[BasePath],
concrete: bool,
) -> None:
self._dist = dist
self._info_location = info_location
self._installed_location = installed_location
self._concrete = concrete

@property
def is_concrete(self) -> bool:
return self._concrete

@classmethod
def from_directory(cls, directory: str) -> BaseDistribution:
info_location = pathlib.Path(directory)
dist = importlib.metadata.Distribution.at(info_location)
return cls(dist, info_location, info_location.parent)
return cls(dist, info_location, info_location.parent, concrete=True)

@classmethod
def from_metadata_file_contents(
Expand All @@ -125,7 +131,7 @@ def from_metadata_file_contents(
metadata_path.write_bytes(metadata_contents)
# Construct dist pointing to the newly created directory.
dist = importlib.metadata.Distribution.at(metadata_path.parent)
return cls(dist, metadata_path.parent, None)
return cls(dist, metadata_path.parent, None, concrete=False)

@classmethod
def from_wheel(cls, wheel: Wheel, name: str) -> BaseDistribution:
Expand All @@ -136,7 +142,12 @@ def from_wheel(cls, wheel: Wheel, name: str) -> BaseDistribution:
raise InvalidWheel(wheel.location, name) from e
except UnsupportedWheel as e:
raise UnsupportedWheel(f"{name} has an invalid wheel, {e}")
return cls(dist, dist.info_location, pathlib.PurePosixPath(wheel.location))
return cls(
dist,
dist.info_location,
pathlib.PurePosixPath(wheel.location),
concrete=wheel.is_concrete,
)

@property
def location(self) -> Optional[str]:
Expand Down
8 changes: 4 additions & 4 deletions src/pip/_internal/metadata/importlib/_envs.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def find(self, location: str) -> Iterator[BaseDistribution]:
installed_location: Optional[BasePath] = None
else:
installed_location = info_location.parent
yield Distribution(dist, info_location, installed_location)
yield Distribution(dist, info_location, installed_location, concrete=True)

def find_linked(self, location: str) -> Iterator[BaseDistribution]:
"""Read location in egg-link files and return distributions in there.
Expand All @@ -105,7 +105,7 @@ def find_linked(self, location: str) -> Iterator[BaseDistribution]:
continue
target_location = str(path.joinpath(target_rel))
for dist, info_location in self._find_impl(target_location):
yield Distribution(dist, info_location, path)
yield Distribution(dist, info_location, path, concrete=True)

def _find_eggs_in_dir(self, location: str) -> Iterator[BaseDistribution]:
from pip._vendor.pkg_resources import find_distributions
Expand All @@ -117,7 +117,7 @@ def _find_eggs_in_dir(self, location: str) -> Iterator[BaseDistribution]:
if not entry.name.endswith(".egg"):
continue
for dist in find_distributions(entry.path):
yield legacy.Distribution(dist)
yield legacy.Distribution(dist, concrete=True)

def _find_eggs_in_zip(self, location: str) -> Iterator[BaseDistribution]:
from pip._vendor.pkg_resources import find_eggs_in_zip
Expand All @@ -129,7 +129,7 @@ def _find_eggs_in_zip(self, location: str) -> Iterator[BaseDistribution]:
except zipimport.ZipImportError:
return
for dist in find_eggs_in_zip(importer, location):
yield legacy.Distribution(dist)
yield legacy.Distribution(dist, concrete=True)

def find_eggs(self, location: str) -> Iterator[BaseDistribution]:
"""Find eggs in a location.
Expand Down
15 changes: 10 additions & 5 deletions src/pip/_internal/metadata/pkg_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,13 @@ def run_script(self, script_name: str, namespace: str) -> None:


class Distribution(BaseDistribution):
def __init__(self, dist: pkg_resources.Distribution) -> None:
def __init__(self, dist: pkg_resources.Distribution, concrete: bool) -> None:
self._dist = dist
self._concrete = concrete

@property
def is_concrete(self) -> bool:
return self._concrete

@classmethod
def from_directory(cls, directory: str) -> BaseDistribution:
Expand All @@ -90,7 +95,7 @@ def from_directory(cls, directory: str) -> BaseDistribution:
dist_name = os.path.splitext(dist_dir_name)[0].split("-")[0]

dist = dist_cls(base_dir, project_name=dist_name, metadata=metadata)
return cls(dist)
return cls(dist, concrete=True)

@classmethod
def from_metadata_file_contents(
Expand All @@ -107,7 +112,7 @@ def from_metadata_file_contents(
metadata=InMemoryMetadata(metadata_dict, filename),
project_name=project_name,
)
return cls(dist)
return cls(dist, concrete=False)

@classmethod
def from_wheel(cls, wheel: Wheel, name: str) -> BaseDistribution:
Expand All @@ -128,7 +133,7 @@ def from_wheel(cls, wheel: Wheel, name: str) -> BaseDistribution:
metadata=InMemoryMetadata(metadata_dict, wheel.location),
project_name=name,
)
return cls(dist)
return cls(dist, concrete=wheel.is_concrete)

@property
def location(self) -> Optional[str]:
Expand Down Expand Up @@ -233,7 +238,7 @@ def from_paths(cls, paths: Optional[List[str]]) -> BaseEnvironment:

def _iter_distributions(self) -> Iterator[BaseDistribution]:
for dist in self._ws:
yield Distribution(dist)
yield Distribution(dist, concrete=True)

def _search_distribution(self, name: str) -> Optional[BaseDistribution]:
"""Find a distribution matching the ``name`` in the environment.
Expand Down
2 changes: 1 addition & 1 deletion src/pip/_internal/models/installation_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def _install_req_to_dict(cls, ireq: InstallRequirement) -> Dict[str, Any]:
"requested": ireq.user_supplied,
# PEP 566 json encoding for metadata
# https://www.python.org/dev/peps/pep-0566/#json-compatible-metadata
"metadata": ireq.get_dist().metadata_dict,
"metadata": ireq.cached_dist.metadata_dict,
}
if ireq.user_supplied and ireq.extras:
# For top level requirements, the list of requested extras, if any.
Expand Down
5 changes: 2 additions & 3 deletions src/pip/_internal/operations/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from pip._vendor.packaging.utils import NormalizedName, canonicalize_name
from pip._vendor.packaging.version import LegacyVersion

from pip._internal.distributions import make_distribution_for_install_requirement
from pip._internal.metadata import get_default_environment
from pip._internal.metadata.base import DistributionVersion
from pip._internal.req.req_install import InstallRequirement
Expand Down Expand Up @@ -127,8 +126,8 @@ def _simulate_installation_of(

# Modify it as installing requirement_set would (assuming no errors)
for inst_req in to_install:
abstract_dist = make_distribution_for_install_requirement(inst_req)
dist = abstract_dist.get_metadata_distribution()
assert inst_req.is_concrete
dist = inst_req.cached_dist
name = dist.canonical_name
package_set[name] = PackageDetails(dist.version, list(dist.iter_dependencies()))

Expand Down
Loading

0 comments on commit 12835e8

Please sign in to comment.