Skip to content

Commit

Permalink
Merge pull request #10511 from uranusjr/metadata-uninstall
Browse files Browse the repository at this point in the history
  • Loading branch information
uranusjr authored Nov 22, 2021
2 parents 95a1605 + f9250d2 commit e0c7f24
Show file tree
Hide file tree
Showing 18 changed files with 413 additions and 285 deletions.
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 @@ -79,8 +79,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}/")
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

0 comments on commit e0c7f24

Please sign in to comment.