1
+ import csv
1
2
import email .message
2
3
import json
3
4
import logging
5
+ import pathlib
4
6
import re
5
7
import zipfile
6
8
from typing import (
12
14
Iterator ,
13
15
List ,
14
16
Optional ,
17
+ Tuple ,
15
18
Union ,
16
19
)
17
20
36
39
37
40
DistributionVersion = Union [LegacyVersion , Version ]
38
41
42
+ InfoPath = Union [str , pathlib .PurePosixPath ]
43
+
39
44
logger = logging .getLogger (__name__ )
40
45
41
46
@@ -53,6 +58,36 @@ def group(self) -> str:
53
58
raise NotImplementedError ()
54
59
55
60
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
+
56
91
class BaseDistribution (Protocol ):
57
92
def __repr__ (self ) -> str :
58
93
return f"{ self .raw_name } { self .version } ({ self .location } )"
@@ -97,8 +132,8 @@ def editable_project_location(self) -> Optional[str]:
97
132
return None
98
133
99
134
@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 .
102
137
103
138
Similarly to ``location``, a string value is not necessarily a
104
139
filesystem path. ``None`` means the distribution is created in-memory.
@@ -112,6 +147,65 @@ def info_directory(self) -> Optional[str]:
112
147
"""
113
148
raise NotImplementedError ()
114
149
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
+
115
209
@property
116
210
def canonical_name (self ) -> NormalizedName :
117
211
raise NotImplementedError ()
@@ -120,6 +214,14 @@ def canonical_name(self) -> NormalizedName:
120
214
def version (self ) -> DistributionVersion :
121
215
raise NotImplementedError ()
122
216
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
+
123
225
@property
124
226
def direct_url (self ) -> Optional [DirectUrl ]:
125
227
"""Obtain a DirectUrl from this distribution.
@@ -166,11 +268,24 @@ def in_usersite(self) -> bool:
166
268
def in_site_packages (self ) -> bool :
167
269
raise NotImplementedError ()
168
270
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.
171
279
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.
174
289
"""
175
290
raise NotImplementedError ()
176
291
@@ -229,6 +344,51 @@ def iter_provided_extras(self) -> Iterable[str]:
229
344
"""
230
345
raise NotImplementedError ()
231
346
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
+
232
392
233
393
class BaseEnvironment :
234
394
"""An environment containing distributions to introspect."""
0 commit comments