Skip to content

Commit ebc13b4

Browse files
authored
Merge pull request #10675 from uranusjr/metadata-misc
2 parents 3002881 + 496343e commit ebc13b4

16 files changed

+350
-408
lines changed

src/pip/_internal/exceptions.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@
33
import configparser
44
import re
55
from itertools import chain, groupby, repeat
6-
from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Union
6+
from typing import TYPE_CHECKING, Dict, Iterator, List, Optional
77

8-
from pip._vendor.pkg_resources import Distribution
98
from pip._vendor.requests.models import Request, Response
109

1110
if TYPE_CHECKING:
@@ -149,17 +148,17 @@ def __init__(self, *, package: str, reason: str) -> None:
149148

150149

151150
class NoneMetadataError(PipError):
152-
"""
153-
Raised when accessing "METADATA" or "PKG-INFO" metadata for a
154-
pip._vendor.pkg_resources.Distribution object and
155-
`dist.has_metadata('METADATA')` returns True but
156-
`dist.get_metadata('METADATA')` returns None (and similarly for
157-
"PKG-INFO").
151+
"""Raised when accessing a Distribution's "METADATA" or "PKG-INFO".
152+
153+
This signifies an inconsistency, when the Distribution claims to have
154+
the metadata file (if not, raise ``FileNotFoundError`` instead), but is
155+
not actually able to produce its content. This may be due to permission
156+
errors.
158157
"""
159158

160159
def __init__(
161160
self,
162-
dist: Union[Distribution, "BaseDistribution"],
161+
dist: "BaseDistribution",
163162
metadata_name: str,
164163
) -> None:
165164
"""

src/pip/_internal/metadata/base.py

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,19 @@
2323
from pip._vendor.packaging.utils import NormalizedName
2424
from pip._vendor.packaging.version import LegacyVersion, Version
2525

26+
from pip._internal.exceptions import NoneMetadataError
27+
from pip._internal.locations import site_packages, user_site
2628
from pip._internal.models.direct_url import (
2729
DIRECT_URL_METADATA_NAME,
2830
DirectUrl,
2931
DirectUrlValidationError,
3032
)
3133
from pip._internal.utils.compat import stdlib_pkgs # TODO: Move definition here.
32-
from pip._internal.utils.egg_link import egg_link_path_from_sys_path
34+
from pip._internal.utils.egg_link import (
35+
egg_link_path_from_location,
36+
egg_link_path_from_sys_path,
37+
)
38+
from pip._internal.utils.misc import is_local, normalize_path
3339
from pip._internal.utils.urls import url_to_path
3440

3541
if TYPE_CHECKING:
@@ -131,6 +137,26 @@ def editable_project_location(self) -> Optional[str]:
131137
return self.location
132138
return None
133139

140+
@property
141+
def installed_location(self) -> Optional[str]:
142+
"""The distribution's "installed" location.
143+
144+
This should generally be a ``site-packages`` directory. This is
145+
usually ``dist.location``, except for legacy develop-installed packages,
146+
where ``dist.location`` is the source code location, and this is where
147+
the ``.egg-link`` file is.
148+
149+
The returned location is normalized (in particular, with symlinks removed).
150+
"""
151+
egg_link = egg_link_path_from_location(self.raw_name)
152+
if egg_link:
153+
location = egg_link
154+
elif self.location:
155+
location = self.location
156+
else:
157+
return None
158+
return normalize_path(location)
159+
134160
@property
135161
def info_location(self) -> Optional[str]:
136162
"""Location of the .[egg|dist]-info directory or file.
@@ -250,23 +276,41 @@ def direct_url(self) -> Optional[DirectUrl]:
250276

251277
@property
252278
def installer(self) -> str:
253-
raise NotImplementedError()
279+
try:
280+
installer_text = self.read_text("INSTALLER")
281+
except (OSError, ValueError, NoneMetadataError):
282+
return "" # Fail silently if the installer file cannot be read.
283+
for line in installer_text.splitlines():
284+
cleaned_line = line.strip()
285+
if cleaned_line:
286+
return cleaned_line
287+
return ""
254288

255289
@property
256290
def editable(self) -> bool:
257291
return bool(self.editable_project_location)
258292

259293
@property
260294
def local(self) -> bool:
261-
raise NotImplementedError()
295+
"""If distribution is installed in the current virtual environment.
296+
297+
Always True if we're not in a virtualenv.
298+
"""
299+
if self.installed_location is None:
300+
return False
301+
return is_local(self.installed_location)
262302

263303
@property
264304
def in_usersite(self) -> bool:
265-
raise NotImplementedError()
305+
if self.installed_location is None or user_site is None:
306+
return False
307+
return self.installed_location.startswith(normalize_path(user_site))
266308

267309
@property
268310
def in_site_packages(self) -> bool:
269-
raise NotImplementedError()
311+
if self.installed_location is None or site_packages is None:
312+
return False
313+
return self.installed_location.startswith(normalize_path(site_packages))
270314

271315
def is_file(self, path: InfoPath) -> bool:
272316
"""Check whether an entry in the info directory is a file."""
@@ -286,6 +330,8 @@ def read_text(self, path: InfoPath) -> str:
286330
"""Read a file in the info directory.
287331
288332
:raise FileNotFoundError: If ``name`` does not exist in the directory.
333+
:raise NoneMetadataError: If ``name`` exists in the info directory, but
334+
cannot be read.
289335
"""
290336
raise NotImplementedError()
291337

@@ -294,7 +340,13 @@ def iter_entry_points(self) -> Iterable[BaseEntryPoint]:
294340

295341
@property
296342
def metadata(self) -> email.message.Message:
297-
"""Metadata of distribution parsed from e.g. METADATA or PKG-INFO."""
343+
"""Metadata of distribution parsed from e.g. METADATA or PKG-INFO.
344+
345+
This should return an empty message if the metadata file is unavailable.
346+
347+
:raises NoneMetadataError: If the metadata file is available, but does
348+
not contain valid metadata.
349+
"""
298350
raise NotImplementedError()
299351

300352
@property
@@ -402,7 +454,11 @@ def from_paths(cls, paths: Optional[List[str]]) -> "BaseEnvironment":
402454
raise NotImplementedError()
403455

404456
def get_distribution(self, name: str) -> Optional["BaseDistribution"]:
405-
"""Given a requirement name, return the installed distributions."""
457+
"""Given a requirement name, return the installed distributions.
458+
459+
The name may not be normalized. The implementation must canonicalize
460+
it for lookup.
461+
"""
406462
raise NotImplementedError()
407463

408464
def _iter_distributions(self) -> Iterator["BaseDistribution"]:

src/pip/_internal/metadata/pkg_resources.py

Lines changed: 81 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
import email.message
2+
import email.parser
23
import logging
34
import os
45
import pathlib
5-
from typing import Collection, Iterable, Iterator, List, NamedTuple, Optional
6-
from zipfile import BadZipFile
6+
import zipfile
7+
from typing import Collection, Iterable, Iterator, List, Mapping, NamedTuple, Optional
78

89
from pip._vendor import pkg_resources
910
from pip._vendor.packaging.requirements import Requirement
1011
from pip._vendor.packaging.utils import NormalizedName, canonicalize_name
1112
from pip._vendor.packaging.version import parse as parse_version
1213

13-
from pip._internal.exceptions import InvalidWheel
14-
from pip._internal.utils import misc # TODO: Move definition here.
15-
from pip._internal.utils.packaging import get_installer, get_metadata
16-
from pip._internal.utils.wheel import pkg_resources_distribution_for_wheel
14+
from pip._internal.exceptions import InvalidWheel, NoneMetadataError, UnsupportedWheel
15+
from pip._internal.utils.misc import display_path
16+
from pip._internal.utils.wheel import parse_wheel, read_wheel_metadata_file
1717

1818
from .base import (
1919
BaseDistribution,
@@ -33,6 +33,41 @@ class EntryPoint(NamedTuple):
3333
group: str
3434

3535

36+
class WheelMetadata:
37+
"""IMetadataProvider that reads metadata files from a dictionary.
38+
39+
This also maps metadata decoding exceptions to our internal exception type.
40+
"""
41+
42+
def __init__(self, metadata: Mapping[str, bytes], wheel_name: str) -> None:
43+
self._metadata = metadata
44+
self._wheel_name = wheel_name
45+
46+
def has_metadata(self, name: str) -> bool:
47+
return name in self._metadata
48+
49+
def get_metadata(self, name: str) -> str:
50+
try:
51+
return self._metadata[name].decode()
52+
except UnicodeDecodeError as e:
53+
# Augment the default error with the origin of the file.
54+
raise UnsupportedWheel(
55+
f"Error decoding metadata for {self._wheel_name}: {e} in {name} file"
56+
)
57+
58+
def get_metadata_lines(self, name: str) -> Iterable[str]:
59+
return pkg_resources.yield_lines(self.get_metadata(name))
60+
61+
def metadata_isdir(self, name: str) -> bool:
62+
return False
63+
64+
def metadata_listdir(self, name: str) -> List[str]:
65+
return []
66+
67+
def run_script(self, script_name: str, namespace: str) -> None:
68+
pass
69+
70+
3671
class Distribution(BaseDistribution):
3772
def __init__(self, dist: pkg_resources.Distribution) -> None:
3873
self._dist = dist
@@ -63,12 +98,26 @@ def from_wheel(cls, wheel: Wheel, name: str) -> "Distribution":
6398
6499
:raises InvalidWheel: Whenever loading of the wheel causes a
65100
:py:exc:`zipfile.BadZipFile` exception to be thrown.
101+
:raises UnsupportedWheel: If the wheel is a valid zip, but malformed
102+
internally.
66103
"""
67104
try:
68105
with wheel.as_zipfile() as zf:
69-
dist = pkg_resources_distribution_for_wheel(zf, name, wheel.location)
70-
except BadZipFile as e:
106+
info_dir, _ = parse_wheel(zf, name)
107+
metadata_text = {
108+
path.split("/", 1)[-1]: read_wheel_metadata_file(zf, path)
109+
for path in zf.namelist()
110+
if path.startswith(f"{info_dir}/")
111+
}
112+
except zipfile.BadZipFile as e:
71113
raise InvalidWheel(wheel.location, name) from e
114+
except UnsupportedWheel as e:
115+
raise UnsupportedWheel(f"{name} has an invalid wheel, {e}")
116+
dist = pkg_resources.DistInfoDistribution(
117+
location=wheel.location,
118+
metadata=WheelMetadata(metadata_text, wheel.location),
119+
project_name=name,
120+
)
72121
return cls(dist)
73122

74123
@property
@@ -97,25 +146,6 @@ def canonical_name(self) -> NormalizedName:
97146
def version(self) -> DistributionVersion:
98147
return parse_version(self._dist.version)
99148

100-
@property
101-
def installer(self) -> str:
102-
try:
103-
return get_installer(self._dist)
104-
except (OSError, ValueError):
105-
return "" # Fail silently if the installer file cannot be read.
106-
107-
@property
108-
def local(self) -> bool:
109-
return misc.dist_is_local(self._dist)
110-
111-
@property
112-
def in_usersite(self) -> bool:
113-
return misc.dist_in_usersite(self._dist)
114-
115-
@property
116-
def in_site_packages(self) -> bool:
117-
return misc.dist_in_site_packages(self._dist)
118-
119149
def is_file(self, path: InfoPath) -> bool:
120150
return self._dist.has_metadata(str(path))
121151

@@ -132,7 +162,10 @@ def read_text(self, path: InfoPath) -> str:
132162
name = str(path)
133163
if not self._dist.has_metadata(name):
134164
raise FileNotFoundError(name)
135-
return self._dist.get_metadata(name)
165+
content = self._dist.get_metadata(name)
166+
if content is None:
167+
raise NoneMetadataError(self, name)
168+
return content
136169

137170
def iter_entry_points(self) -> Iterable[BaseEntryPoint]:
138171
for group, entries in self._dist.get_entry_map().items():
@@ -142,7 +175,26 @@ def iter_entry_points(self) -> Iterable[BaseEntryPoint]:
142175

143176
@property
144177
def metadata(self) -> email.message.Message:
145-
return get_metadata(self._dist)
178+
"""
179+
:raises NoneMetadataError: if the distribution reports `has_metadata()`
180+
True but `get_metadata()` returns None.
181+
"""
182+
if isinstance(self._dist, pkg_resources.DistInfoDistribution):
183+
metadata_name = "METADATA"
184+
else:
185+
metadata_name = "PKG-INFO"
186+
try:
187+
metadata = self.read_text(metadata_name)
188+
except FileNotFoundError:
189+
if self.location:
190+
displaying_path = display_path(self.location)
191+
else:
192+
displaying_path = repr(self.location)
193+
logger.warning("No metadata found in %s", displaying_path)
194+
metadata = ""
195+
feed_parser = email.parser.FeedParser()
196+
feed_parser.feed(metadata)
197+
return feed_parser.close()
146198

147199
def iter_dependencies(self, extras: Collection[str] = ()) -> Iterable[Requirement]:
148200
if extras: # pkg_resources raises on invalid extras, so we sanitize.
@@ -178,7 +230,6 @@ def _search_distribution(self, name: str) -> Optional[BaseDistribution]:
178230
return None
179231

180232
def get_distribution(self, name: str) -> Optional[BaseDistribution]:
181-
182233
# Search the distribution by looking through the working set.
183234
dist = self._search_distribution(name)
184235
if dist:

0 commit comments

Comments
 (0)