Skip to content

More pkg_resources refactoring #10511

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Nov 22, 2021
Merged
61 changes: 2 additions & 59 deletions src/pip/_internal/commands/show.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import csv
import logging
import pathlib
from optparse import Values
from typing import Iterator, List, NamedTuple, Optional, Tuple
from typing import Iterator, List, NamedTuple, Optional

from pip._vendor.packaging.utils import canonicalize_name

Expand Down Expand Up @@ -69,33 +67,6 @@ class _PackageInfo(NamedTuple):
files: Optional[List[str]]


def _convert_legacy_entry(entry: Tuple[str, ...], info: Tuple[str, ...]) -> str:
"""Convert a legacy installed-files.txt path into modern RECORD path.

The legacy format stores paths relative to the info directory, while the
modern format stores paths relative to the package root, e.g. the
site-packages directory.

:param entry: Path parts of the installed-files.txt entry.
:param info: Path parts of the egg-info directory relative to package root.
:returns: The converted entry.

For best compatibility with symlinks, this does not use ``abspath()`` or
``Path.resolve()``, but tries to work with path parts:

1. While ``entry`` starts with ``..``, remove the equal amounts of parts
from ``info``; if ``info`` is empty, start appending ``..`` instead.
2. Join the two directly.
"""
while entry and entry[0] == "..":
if not info or info[-1] == "..":
info += ("..",)
else:
info = info[:-1]
entry = entry[1:]
return str(pathlib.Path(*info, *entry))


def search_packages_info(query: List[str]) -> Iterator[_PackageInfo]:
"""
Gather details from installed distributions. Print distribution name,
Expand All @@ -121,34 +92,6 @@ def _get_requiring_packages(current_dist: BaseDistribution) -> Iterator[str]:
in {canonicalize_name(d.name) for d in dist.iter_dependencies()}
)

def _files_from_record(dist: BaseDistribution) -> Optional[Iterator[str]]:
try:
text = dist.read_text("RECORD")
except FileNotFoundError:
return None
# This extra Path-str cast normalizes entries.
return (str(pathlib.Path(row[0])) for row in csv.reader(text.splitlines()))

def _files_from_legacy(dist: BaseDistribution) -> Optional[Iterator[str]]:
try:
text = dist.read_text("installed-files.txt")
except FileNotFoundError:
return None
paths = (p for p in text.splitlines(keepends=False) if p)
root = dist.location
info = dist.info_directory
if root is None or info is None:
return paths
try:
info_rel = pathlib.Path(info).relative_to(root)
except ValueError: # info is not relative to root.
return paths
if not info_rel.parts: # info *is* root.
return paths
return (
_convert_legacy_entry(pathlib.Path(p).parts, info_rel.parts) for p in paths
)

for query_name in query_names:
try:
dist = installed[query_name]
Expand All @@ -164,7 +107,7 @@ def _files_from_legacy(dist: BaseDistribution) -> Optional[Iterator[str]]:
except FileNotFoundError:
entry_points = []

files_iter = _files_from_record(dist) or _files_from_legacy(dist)
files_iter = dist.iter_declared_entries()
if files_iter is None:
files: Optional[List[str]] = None
else:
Expand Down
4 changes: 1 addition & 3 deletions src/pip/_internal/distributions/installed.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,8 @@ class InstalledDistribution(AbstractDistribution):
"""

def get_metadata_distribution(self) -> BaseDistribution:
from pip._internal.metadata.pkg_resources import Distribution as _Dist

assert self.req.satisfied_by is not None, "not actually installed"
return _Dist(self.req.satisfied_by)
return self.req.satisfied_by

def prepare_distribution_metadata(
self, finder: PackageFinder, build_isolation: bool
Expand Down
4 changes: 1 addition & 3 deletions src/pip/_internal/distributions/sdist.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ class SourceDistribution(AbstractDistribution):
"""

def get_metadata_distribution(self) -> BaseDistribution:
from pip._internal.metadata.pkg_resources import Distribution as _Dist

return _Dist(self.req.get_dist())
return self.req.get_dist()

def prepare_distribution_metadata(
self, finder: PackageFinder, build_isolation: bool
Expand Down
2 changes: 1 addition & 1 deletion src/pip/_internal/index/package_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -881,7 +881,7 @@ def find_requirement(

installed_version: Optional[_BaseVersion] = None
if req.satisfied_by is not None:
installed_version = parse_version(req.satisfied_by.version)
installed_version = req.satisfied_by.version

def _format_versions(cand_iter: Iterable[InstallationCandidate]) -> str:
# This repeated parse_version and str() conversion is needed to
Expand Down
4 changes: 2 additions & 2 deletions src/pip/_internal/locations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ def _looks_like_bpo_44860() -> bool:

def _looks_like_red_hat_patched_platlib_purelib(scheme: Dict[str, str]) -> bool:
platlib = scheme["platlib"]
if "/$platlibdir/" in platlib and hasattr(sys, "platlibdir"):
platlib = platlib.replace("/$platlibdir/", f"/{sys.platlibdir}/")
if "/$platlibdir/" in platlib:
platlib = platlib.replace("/$platlibdir/", f"/{_PLATLIBDIR}/")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems unrelated to the rest of the changes.

Copy link
Member

@pradyunsg pradyunsg Nov 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, but it seems like the right thing to do; and I imagine it's here because TP noticed this bug while working on this. :)

Relevant definition has a fallback to lib if sys.platlibdir isn't defined. (see line 46)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I wasn't suggesting it shouldn't be done. And I have no objection to it being in this PR. I was mostly flagging it in case there was a reason it needed to be here that I'd missed 😉

Copy link
Member Author

@uranusjr uranusjr Nov 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I remember the details correctly (it’s been a while), this was fixing a Mypy error that somehow only shows up when I was working on this. I don’t know why main isn’t complaining, maybe it’s due to me running Mypy on Windows? Not sure, and this new code is margianlly better anyway.

Note that this does not actually change any behaviour because before 3.9, the /$platlibdir/ was just /lib/, and after 3.9 _PLATLIBDIR is always sys.platlibdir. It’s just Mypy is not smart enough to figure that out.

if "/lib64/" not in platlib:
return False
unpatched = platlib.replace("/lib64/", "/lib/")
Expand Down
11 changes: 11 additions & 0 deletions src/pip/_internal/metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ def get_environment(paths: Optional[List[str]]) -> BaseEnvironment:
return Environment.from_paths(paths)


def get_directory_distribution(directory: str) -> BaseDistribution:
"""Get the distribution metadata representation in the specified directory.

This returns a Distribution instance from the chosen backend based on
the given on-disk ``.dist-info`` directory.
"""
from .pkg_resources import Distribution

return Distribution.from_directory(directory)


def get_wheel_distribution(wheel: Wheel, canonical_name: str) -> BaseDistribution:
"""Get the representation of the specified wheel's distribution metadata.

Expand Down
172 changes: 166 additions & 6 deletions src/pip/_internal/metadata/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import csv
import email.message
import json
import logging
import pathlib
import re
import zipfile
from typing import (
Expand All @@ -12,6 +14,7 @@
Iterator,
List,
Optional,
Tuple,
Union,
)

Expand All @@ -36,6 +39,8 @@

DistributionVersion = Union[LegacyVersion, Version]

InfoPath = Union[str, pathlib.PurePosixPath]

logger = logging.getLogger(__name__)


Expand All @@ -53,6 +58,36 @@ def group(self) -> str:
raise NotImplementedError()


def _convert_installed_files_path(
entry: Tuple[str, ...],
info: Tuple[str, ...],
) -> str:
"""Convert a legacy installed-files.txt path into modern RECORD path.

The legacy format stores paths relative to the info directory, while the
modern format stores paths relative to the package root, e.g. the
site-packages directory.

:param entry: Path parts of the installed-files.txt entry.
:param info: Path parts of the egg-info directory relative to package root.
:returns: The converted entry.

For best compatibility with symlinks, this does not use ``abspath()`` or
``Path.resolve()``, but tries to work with path parts:

1. While ``entry`` starts with ``..``, remove the equal amounts of parts
from ``info``; if ``info`` is empty, start appending ``..`` instead.
2. Join the two directly.
"""
while entry and entry[0] == "..":
if not info or info[-1] == "..":
info += ("..",)
else:
info = info[:-1]
entry = entry[1:]
return str(pathlib.Path(*info, *entry))


class BaseDistribution(Protocol):
def __repr__(self) -> str:
return f"{self.raw_name} {self.version} ({self.location})"
Expand Down Expand Up @@ -97,8 +132,8 @@ def editable_project_location(self) -> Optional[str]:
return None

@property
def info_directory(self) -> Optional[str]:
"""Location of the .[egg|dist]-info directory.
def info_location(self) -> Optional[str]:
"""Location of the .[egg|dist]-info directory or file.

Similarly to ``location``, a string value is not necessarily a
filesystem path. ``None`` means the distribution is created in-memory.
Expand All @@ -112,6 +147,65 @@ def info_directory(self) -> Optional[str]:
"""
raise NotImplementedError()

@property
def installed_by_distutils(self) -> bool:
"""Whether this distribution is installed with legacy distutils format.

A distribution installed with "raw" distutils not patched by setuptools
uses one single file at ``info_location`` to store metadata. We need to
treat this specially on uninstallation.
"""
info_location = self.info_location
if not info_location:
return False
return pathlib.Path(info_location).is_file()

@property
def installed_as_egg(self) -> bool:
"""Whether this distribution is installed as an egg.

This usually indicates the distribution was installed by (older versions
of) easy_install.
"""
location = self.location
if not location:
return False
return location.endswith(".egg")

@property
def installed_with_setuptools_egg_info(self) -> bool:
"""Whether this distribution is installed with the ``.egg-info`` format.

This usually indicates the distribution was installed with setuptools
with an old pip version or with ``single-version-externally-managed``.

Note that this ensure the metadata store is a directory. distutils can
also installs an ``.egg-info``, but as a file, not a directory. This
property is *False* for that case. Also see ``installed_by_distutils``.
"""
info_location = self.info_location
if not info_location:
return False
if not info_location.endswith(".egg-info"):
return False
return pathlib.Path(info_location).is_dir()

@property
def installed_with_dist_info(self) -> bool:
"""Whether this distribution is installed with the "modern format".

This indicates a "modern" installation, e.g. storing metadata in the
``.dist-info`` directory. This applies to installations made by
setuptools (but through pip, not directly), or anything using the
standardized build backend interface (PEP 517).
"""
info_location = self.info_location
if not info_location:
return False
if not info_location.endswith(".dist-info"):
return False
return pathlib.Path(info_location).is_dir()

@property
def canonical_name(self) -> NormalizedName:
raise NotImplementedError()
Expand All @@ -120,6 +214,14 @@ def canonical_name(self) -> NormalizedName:
def version(self) -> DistributionVersion:
raise NotImplementedError()

@property
def setuptools_filename(self) -> str:
"""Convert a project name to its setuptools-compatible filename.

This is a copy of ``pkg_resources.to_filename()`` for compatibility.
"""
return self.raw_name.replace("-", "_")

@property
def direct_url(self) -> Optional[DirectUrl]:
"""Obtain a DirectUrl from this distribution.
Expand Down Expand Up @@ -166,11 +268,24 @@ def in_usersite(self) -> bool:
def in_site_packages(self) -> bool:
raise NotImplementedError()

def read_text(self, name: str) -> str:
"""Read a file in the .dist-info (or .egg-info) directory.
def is_file(self, path: InfoPath) -> bool:
"""Check whether an entry in the info directory is a file."""
raise NotImplementedError()

def iterdir(self, path: InfoPath) -> Iterator[pathlib.PurePosixPath]:
"""Iterate through a directory in the info directory.

Each item yielded would be a path relative to the info directory.

Should raise ``FileNotFoundError`` if ``name`` does not exist in the
metadata directory.
:raise FileNotFoundError: If ``name`` does not exist in the directory.
:raise NotADirectoryError: If ``name`` does not point to a directory.
"""
raise NotImplementedError()

def read_text(self, path: InfoPath) -> str:
"""Read a file in the info directory.

:raise FileNotFoundError: If ``name`` does not exist in the directory.
"""
raise NotImplementedError()

Expand Down Expand Up @@ -229,6 +344,51 @@ def iter_provided_extras(self) -> Iterable[str]:
"""
raise NotImplementedError()

def _iter_declared_entries_from_record(self) -> Optional[Iterator[str]]:
try:
text = self.read_text("RECORD")
except FileNotFoundError:
return None
# This extra Path-str cast normalizes entries.
return (str(pathlib.Path(row[0])) for row in csv.reader(text.splitlines()))

def _iter_declared_entries_from_legacy(self) -> Optional[Iterator[str]]:
try:
text = self.read_text("installed-files.txt")
except FileNotFoundError:
return None
paths = (p for p in text.splitlines(keepends=False) if p)
root = self.location
info = self.info_location
if root is None or info is None:
return paths
try:
info_rel = pathlib.Path(info).relative_to(root)
except ValueError: # info is not relative to root.
return paths
if not info_rel.parts: # info *is* root.
return paths
return (
_convert_installed_files_path(pathlib.Path(p).parts, info_rel.parts)
for p in paths
)

def iter_declared_entries(self) -> Optional[Iterator[str]]:
"""Iterate through file entires declared in this distribution.

For modern .dist-info distributions, this is the files listed in the
``RECORD`` metadata file. For legacy setuptools distributions, this
comes from ``installed-files.txt``, with entries normalized to be
compatible with the format used by ``RECORD``.

:return: An iterator for listed entries, or None if the distribution
contains neither ``RECORD`` nor ``installed-files.txt``.
"""
return (
self._iter_declared_entries_from_record()
or self._iter_declared_entries_from_legacy()
)


class BaseEnvironment:
"""An environment containing distributions to introspect."""
Expand Down
Loading