Skip to content

Commit e0c7f24

Browse files
authored
Merge pull request #10511 from uranusjr/metadata-uninstall
2 parents 95a1605 + f9250d2 commit e0c7f24

File tree

18 files changed

+413
-285
lines changed

18 files changed

+413
-285
lines changed

src/pip/_internal/commands/show.py

Lines changed: 2 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import csv
21
import logging
3-
import pathlib
42
from optparse import Values
5-
from typing import Iterator, List, NamedTuple, Optional, Tuple
3+
from typing import Iterator, List, NamedTuple, Optional
64

75
from pip._vendor.packaging.utils import canonicalize_name
86

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

7169

72-
def _convert_legacy_entry(entry: Tuple[str, ...], info: Tuple[str, ...]) -> str:
73-
"""Convert a legacy installed-files.txt path into modern RECORD path.
74-
75-
The legacy format stores paths relative to the info directory, while the
76-
modern format stores paths relative to the package root, e.g. the
77-
site-packages directory.
78-
79-
:param entry: Path parts of the installed-files.txt entry.
80-
:param info: Path parts of the egg-info directory relative to package root.
81-
:returns: The converted entry.
82-
83-
For best compatibility with symlinks, this does not use ``abspath()`` or
84-
``Path.resolve()``, but tries to work with path parts:
85-
86-
1. While ``entry`` starts with ``..``, remove the equal amounts of parts
87-
from ``info``; if ``info`` is empty, start appending ``..`` instead.
88-
2. Join the two directly.
89-
"""
90-
while entry and entry[0] == "..":
91-
if not info or info[-1] == "..":
92-
info += ("..",)
93-
else:
94-
info = info[:-1]
95-
entry = entry[1:]
96-
return str(pathlib.Path(*info, *entry))
97-
98-
9970
def search_packages_info(query: List[str]) -> Iterator[_PackageInfo]:
10071
"""
10172
Gather details from installed distributions. Print distribution name,
@@ -121,34 +92,6 @@ def _get_requiring_packages(current_dist: BaseDistribution) -> Iterator[str]:
12192
in {canonicalize_name(d.name) for d in dist.iter_dependencies()}
12293
)
12394

124-
def _files_from_record(dist: BaseDistribution) -> Optional[Iterator[str]]:
125-
try:
126-
text = dist.read_text("RECORD")
127-
except FileNotFoundError:
128-
return None
129-
# This extra Path-str cast normalizes entries.
130-
return (str(pathlib.Path(row[0])) for row in csv.reader(text.splitlines()))
131-
132-
def _files_from_legacy(dist: BaseDistribution) -> Optional[Iterator[str]]:
133-
try:
134-
text = dist.read_text("installed-files.txt")
135-
except FileNotFoundError:
136-
return None
137-
paths = (p for p in text.splitlines(keepends=False) if p)
138-
root = dist.location
139-
info = dist.info_directory
140-
if root is None or info is None:
141-
return paths
142-
try:
143-
info_rel = pathlib.Path(info).relative_to(root)
144-
except ValueError: # info is not relative to root.
145-
return paths
146-
if not info_rel.parts: # info *is* root.
147-
return paths
148-
return (
149-
_convert_legacy_entry(pathlib.Path(p).parts, info_rel.parts) for p in paths
150-
)
151-
15295
for query_name in query_names:
15396
try:
15497
dist = installed[query_name]
@@ -164,7 +107,7 @@ def _files_from_legacy(dist: BaseDistribution) -> Optional[Iterator[str]]:
164107
except FileNotFoundError:
165108
entry_points = []
166109

167-
files_iter = _files_from_record(dist) or _files_from_legacy(dist)
110+
files_iter = dist.iter_declared_entries()
168111
if files_iter is None:
169112
files: Optional[List[str]] = None
170113
else:

src/pip/_internal/distributions/installed.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@ class InstalledDistribution(AbstractDistribution):
1111
"""
1212

1313
def get_metadata_distribution(self) -> BaseDistribution:
14-
from pip._internal.metadata.pkg_resources import Distribution as _Dist
15-
1614
assert self.req.satisfied_by is not None, "not actually installed"
17-
return _Dist(self.req.satisfied_by)
15+
return self.req.satisfied_by
1816

1917
def prepare_distribution_metadata(
2018
self, finder: PackageFinder, build_isolation: bool

src/pip/_internal/distributions/sdist.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@ class SourceDistribution(AbstractDistribution):
1919
"""
2020

2121
def get_metadata_distribution(self) -> BaseDistribution:
22-
from pip._internal.metadata.pkg_resources import Distribution as _Dist
23-
24-
return _Dist(self.req.get_dist())
22+
return self.req.get_dist()
2523

2624
def prepare_distribution_metadata(
2725
self, finder: PackageFinder, build_isolation: bool

src/pip/_internal/index/package_finder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -881,7 +881,7 @@ def find_requirement(
881881

882882
installed_version: Optional[_BaseVersion] = None
883883
if req.satisfied_by is not None:
884-
installed_version = parse_version(req.satisfied_by.version)
884+
installed_version = req.satisfied_by.version
885885

886886
def _format_versions(cand_iter: Iterable[InstallationCandidate]) -> str:
887887
# This repeated parse_version and str() conversion is needed to

src/pip/_internal/locations/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ def _looks_like_bpo_44860() -> bool:
7979

8080
def _looks_like_red_hat_patched_platlib_purelib(scheme: Dict[str, str]) -> bool:
8181
platlib = scheme["platlib"]
82-
if "/$platlibdir/" in platlib and hasattr(sys, "platlibdir"):
83-
platlib = platlib.replace("/$platlibdir/", f"/{sys.platlibdir}/")
82+
if "/$platlibdir/" in platlib:
83+
platlib = platlib.replace("/$platlibdir/", f"/{_PLATLIBDIR}/")
8484
if "/lib64/" not in platlib:
8585
return False
8686
unpatched = platlib.replace("/lib64/", "/lib/")

src/pip/_internal/metadata/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ def get_environment(paths: Optional[List[str]]) -> BaseEnvironment:
3838
return Environment.from_paths(paths)
3939

4040

41+
def get_directory_distribution(directory: str) -> BaseDistribution:
42+
"""Get the distribution metadata representation in the specified directory.
43+
44+
This returns a Distribution instance from the chosen backend based on
45+
the given on-disk ``.dist-info`` directory.
46+
"""
47+
from .pkg_resources import Distribution
48+
49+
return Distribution.from_directory(directory)
50+
51+
4152
def get_wheel_distribution(wheel: Wheel, canonical_name: str) -> BaseDistribution:
4253
"""Get the representation of the specified wheel's distribution metadata.
4354

src/pip/_internal/metadata/base.py

Lines changed: 166 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import csv
12
import email.message
23
import json
34
import logging
5+
import pathlib
46
import re
57
import zipfile
68
from typing import (
@@ -12,6 +14,7 @@
1214
Iterator,
1315
List,
1416
Optional,
17+
Tuple,
1518
Union,
1619
)
1720

@@ -36,6 +39,8 @@
3639

3740
DistributionVersion = Union[LegacyVersion, Version]
3841

42+
InfoPath = Union[str, pathlib.PurePosixPath]
43+
3944
logger = logging.getLogger(__name__)
4045

4146

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

5560

61+
def _convert_installed_files_path(
62+
entry: Tuple[str, ...],
63+
info: Tuple[str, ...],
64+
) -> str:
65+
"""Convert a legacy installed-files.txt path into modern RECORD path.
66+
67+
The legacy format stores paths relative to the info directory, while the
68+
modern format stores paths relative to the package root, e.g. the
69+
site-packages directory.
70+
71+
:param entry: Path parts of the installed-files.txt entry.
72+
:param info: Path parts of the egg-info directory relative to package root.
73+
:returns: The converted entry.
74+
75+
For best compatibility with symlinks, this does not use ``abspath()`` or
76+
``Path.resolve()``, but tries to work with path parts:
77+
78+
1. While ``entry`` starts with ``..``, remove the equal amounts of parts
79+
from ``info``; if ``info`` is empty, start appending ``..`` instead.
80+
2. Join the two directly.
81+
"""
82+
while entry and entry[0] == "..":
83+
if not info or info[-1] == "..":
84+
info += ("..",)
85+
else:
86+
info = info[:-1]
87+
entry = entry[1:]
88+
return str(pathlib.Path(*info, *entry))
89+
90+
5691
class BaseDistribution(Protocol):
5792
def __repr__(self) -> str:
5893
return f"{self.raw_name} {self.version} ({self.location})"
@@ -97,8 +132,8 @@ def editable_project_location(self) -> Optional[str]:
97132
return None
98133

99134
@property
100-
def info_directory(self) -> Optional[str]:
101-
"""Location of the .[egg|dist]-info directory.
135+
def info_location(self) -> Optional[str]:
136+
"""Location of the .[egg|dist]-info directory or file.
102137
103138
Similarly to ``location``, a string value is not necessarily a
104139
filesystem path. ``None`` means the distribution is created in-memory.
@@ -112,6 +147,65 @@ def info_directory(self) -> Optional[str]:
112147
"""
113148
raise NotImplementedError()
114149

150+
@property
151+
def installed_by_distutils(self) -> bool:
152+
"""Whether this distribution is installed with legacy distutils format.
153+
154+
A distribution installed with "raw" distutils not patched by setuptools
155+
uses one single file at ``info_location`` to store metadata. We need to
156+
treat this specially on uninstallation.
157+
"""
158+
info_location = self.info_location
159+
if not info_location:
160+
return False
161+
return pathlib.Path(info_location).is_file()
162+
163+
@property
164+
def installed_as_egg(self) -> bool:
165+
"""Whether this distribution is installed as an egg.
166+
167+
This usually indicates the distribution was installed by (older versions
168+
of) easy_install.
169+
"""
170+
location = self.location
171+
if not location:
172+
return False
173+
return location.endswith(".egg")
174+
175+
@property
176+
def installed_with_setuptools_egg_info(self) -> bool:
177+
"""Whether this distribution is installed with the ``.egg-info`` format.
178+
179+
This usually indicates the distribution was installed with setuptools
180+
with an old pip version or with ``single-version-externally-managed``.
181+
182+
Note that this ensure the metadata store is a directory. distutils can
183+
also installs an ``.egg-info``, but as a file, not a directory. This
184+
property is *False* for that case. Also see ``installed_by_distutils``.
185+
"""
186+
info_location = self.info_location
187+
if not info_location:
188+
return False
189+
if not info_location.endswith(".egg-info"):
190+
return False
191+
return pathlib.Path(info_location).is_dir()
192+
193+
@property
194+
def installed_with_dist_info(self) -> bool:
195+
"""Whether this distribution is installed with the "modern format".
196+
197+
This indicates a "modern" installation, e.g. storing metadata in the
198+
``.dist-info`` directory. This applies to installations made by
199+
setuptools (but through pip, not directly), or anything using the
200+
standardized build backend interface (PEP 517).
201+
"""
202+
info_location = self.info_location
203+
if not info_location:
204+
return False
205+
if not info_location.endswith(".dist-info"):
206+
return False
207+
return pathlib.Path(info_location).is_dir()
208+
115209
@property
116210
def canonical_name(self) -> NormalizedName:
117211
raise NotImplementedError()
@@ -120,6 +214,14 @@ def canonical_name(self) -> NormalizedName:
120214
def version(self) -> DistributionVersion:
121215
raise NotImplementedError()
122216

217+
@property
218+
def setuptools_filename(self) -> str:
219+
"""Convert a project name to its setuptools-compatible filename.
220+
221+
This is a copy of ``pkg_resources.to_filename()`` for compatibility.
222+
"""
223+
return self.raw_name.replace("-", "_")
224+
123225
@property
124226
def direct_url(self) -> Optional[DirectUrl]:
125227
"""Obtain a DirectUrl from this distribution.
@@ -166,11 +268,24 @@ def in_usersite(self) -> bool:
166268
def in_site_packages(self) -> bool:
167269
raise NotImplementedError()
168270

169-
def read_text(self, name: str) -> str:
170-
"""Read a file in the .dist-info (or .egg-info) directory.
271+
def is_file(self, path: InfoPath) -> bool:
272+
"""Check whether an entry in the info directory is a file."""
273+
raise NotImplementedError()
274+
275+
def iterdir(self, path: InfoPath) -> Iterator[pathlib.PurePosixPath]:
276+
"""Iterate through a directory in the info directory.
277+
278+
Each item yielded would be a path relative to the info directory.
171279
172-
Should raise ``FileNotFoundError`` if ``name`` does not exist in the
173-
metadata directory.
280+
:raise FileNotFoundError: If ``name`` does not exist in the directory.
281+
:raise NotADirectoryError: If ``name`` does not point to a directory.
282+
"""
283+
raise NotImplementedError()
284+
285+
def read_text(self, path: InfoPath) -> str:
286+
"""Read a file in the info directory.
287+
288+
:raise FileNotFoundError: If ``name`` does not exist in the directory.
174289
"""
175290
raise NotImplementedError()
176291

@@ -229,6 +344,51 @@ def iter_provided_extras(self) -> Iterable[str]:
229344
"""
230345
raise NotImplementedError()
231346

347+
def _iter_declared_entries_from_record(self) -> Optional[Iterator[str]]:
348+
try:
349+
text = self.read_text("RECORD")
350+
except FileNotFoundError:
351+
return None
352+
# This extra Path-str cast normalizes entries.
353+
return (str(pathlib.Path(row[0])) for row in csv.reader(text.splitlines()))
354+
355+
def _iter_declared_entries_from_legacy(self) -> Optional[Iterator[str]]:
356+
try:
357+
text = self.read_text("installed-files.txt")
358+
except FileNotFoundError:
359+
return None
360+
paths = (p for p in text.splitlines(keepends=False) if p)
361+
root = self.location
362+
info = self.info_location
363+
if root is None or info is None:
364+
return paths
365+
try:
366+
info_rel = pathlib.Path(info).relative_to(root)
367+
except ValueError: # info is not relative to root.
368+
return paths
369+
if not info_rel.parts: # info *is* root.
370+
return paths
371+
return (
372+
_convert_installed_files_path(pathlib.Path(p).parts, info_rel.parts)
373+
for p in paths
374+
)
375+
376+
def iter_declared_entries(self) -> Optional[Iterator[str]]:
377+
"""Iterate through file entires declared in this distribution.
378+
379+
For modern .dist-info distributions, this is the files listed in the
380+
``RECORD`` metadata file. For legacy setuptools distributions, this
381+
comes from ``installed-files.txt``, with entries normalized to be
382+
compatible with the format used by ``RECORD``.
383+
384+
:return: An iterator for listed entries, or None if the distribution
385+
contains neither ``RECORD`` nor ``installed-files.txt``.
386+
"""
387+
return (
388+
self._iter_declared_entries_from_record()
389+
or self._iter_declared_entries_from_legacy()
390+
)
391+
232392

233393
class BaseEnvironment:
234394
"""An environment containing distributions to introspect."""

0 commit comments

Comments
 (0)