1from __future__ import annotations
2
3import email.message
4import importlib.metadata
5import pathlib
6import zipfile
7from collections.abc import Collection, Iterable, Iterator, Mapping, Sequence
8from os import PathLike
9from typing import (
10 cast,
11)
12
13from pip._vendor.packaging.requirements import Requirement
14from pip._vendor.packaging.utils import NormalizedName, canonicalize_name
15from pip._vendor.packaging.version import Version
16from pip._vendor.packaging.version import parse as parse_version
17
18from pip._internal.exceptions import InvalidWheel, UnsupportedWheel
19from pip._internal.metadata.base import (
20 BaseDistribution,
21 BaseEntryPoint,
22 InfoPath,
23 Wheel,
24)
25from pip._internal.utils.misc import normalize_path
26from pip._internal.utils.packaging import get_requirement
27from pip._internal.utils.temp_dir import TempDirectory
28from pip._internal.utils.wheel import parse_wheel, read_wheel_metadata_file
29
30from ._compat import (
31 BadMetadata,
32 BasePath,
33 get_dist_canonical_name,
34 parse_name_and_version_from_info_directory,
35)
36
37
38class WheelDistribution(importlib.metadata.Distribution):
39 """An ``importlib.metadata.Distribution`` read from a wheel.
40
41 Although ``importlib.metadata.PathDistribution`` accepts ``zipfile.Path``,
42 its implementation is too "lazy" for pip's needs (we can't keep the ZipFile
43 handle open for the entire lifetime of the distribution object).
44
45 This implementation eagerly reads the entire metadata directory into the
46 memory instead, and operates from that.
47 """
48
49 def __init__(
50 self,
51 files: Mapping[pathlib.PurePosixPath, bytes],
52 info_location: pathlib.PurePosixPath,
53 ) -> None:
54 self._files = files
55 self.info_location = info_location
56
57 @classmethod
58 def from_zipfile(
59 cls,
60 zf: zipfile.ZipFile,
61 name: str,
62 location: str,
63 ) -> WheelDistribution:
64 info_dir, _ = parse_wheel(zf, name)
65 paths = (
66 (name, pathlib.PurePosixPath(name.split("/", 1)[-1]))
67 for name in zf.namelist()
68 if name.startswith(f"{info_dir}/")
69 )
70 files = {
71 relpath: read_wheel_metadata_file(zf, fullpath)
72 for fullpath, relpath in paths
73 }
74 info_location = pathlib.PurePosixPath(location, info_dir)
75 return cls(files, info_location)
76
77 def iterdir(self, path: InfoPath) -> Iterator[pathlib.PurePosixPath]:
78 # Only allow iterating through the metadata directory.
79 if pathlib.PurePosixPath(str(path)) in self._files:
80 return iter(self._files)
81 raise FileNotFoundError(path)
82
83 def read_text(self, filename: str) -> str | None:
84 try:
85 data = self._files[pathlib.PurePosixPath(filename)]
86 except KeyError:
87 return None
88 try:
89 text = data.decode("utf-8")
90 except UnicodeDecodeError as e:
91 wheel = self.info_location.parent
92 error = f"Error decoding metadata for {wheel}: {e} in {filename} file"
93 raise UnsupportedWheel(error)
94 return text
95
96 def locate_file(self, path: str | PathLike[str]) -> pathlib.Path:
97 # This method doesn't make sense for our in-memory wheel, but the API
98 # requires us to define it.
99 raise NotImplementedError
100
101
102class Distribution(BaseDistribution):
103 def __init__(
104 self,
105 dist: importlib.metadata.Distribution,
106 info_location: BasePath | None,
107 installed_location: BasePath | None,
108 ) -> None:
109 self._dist = dist
110 self._info_location = info_location
111 self._installed_location = installed_location
112
113 @classmethod
114 def from_directory(cls, directory: str) -> BaseDistribution:
115 info_location = pathlib.Path(directory)
116 dist = importlib.metadata.Distribution.at(info_location)
117 return cls(dist, info_location, info_location.parent)
118
119 @classmethod
120 def from_metadata_file_contents(
121 cls,
122 metadata_contents: bytes,
123 filename: str,
124 project_name: str,
125 ) -> BaseDistribution:
126 # Generate temp dir to contain the metadata file, and write the file contents.
127 temp_dir = pathlib.Path(
128 TempDirectory(kind="metadata", globally_managed=True).path
129 )
130 metadata_path = temp_dir / "METADATA"
131 metadata_path.write_bytes(metadata_contents)
132 # Construct dist pointing to the newly created directory.
133 dist = importlib.metadata.Distribution.at(metadata_path.parent)
134 return cls(dist, metadata_path.parent, None)
135
136 @classmethod
137 def from_wheel(cls, wheel: Wheel, name: str) -> BaseDistribution:
138 try:
139 with wheel.as_zipfile() as zf:
140 dist = WheelDistribution.from_zipfile(zf, name, wheel.location)
141 except zipfile.BadZipFile as e:
142 raise InvalidWheel(wheel.location, name) from e
143 return cls(dist, dist.info_location, pathlib.PurePosixPath(wheel.location))
144
145 @property
146 def location(self) -> str | None:
147 if self._info_location is None:
148 return None
149 return str(self._info_location.parent)
150
151 @property
152 def info_location(self) -> str | None:
153 if self._info_location is None:
154 return None
155 return str(self._info_location)
156
157 @property
158 def installed_location(self) -> str | None:
159 if self._installed_location is None:
160 return None
161 return normalize_path(str(self._installed_location))
162
163 @property
164 def canonical_name(self) -> NormalizedName:
165 return get_dist_canonical_name(self._dist)
166
167 @property
168 def version(self) -> Version:
169 try:
170 version = (
171 parse_name_and_version_from_info_directory(self._dist)[1]
172 or self._dist.version
173 )
174 return parse_version(version)
175 except TypeError:
176 raise BadMetadata(self._dist, reason="invalid metadata entry `version`")
177
178 @property
179 def raw_version(self) -> str:
180 return self._dist.version
181
182 def is_file(self, path: InfoPath) -> bool:
183 return self._dist.read_text(str(path)) is not None
184
185 def iter_distutils_script_names(self) -> Iterator[str]:
186 # A distutils installation is always "flat" (not in e.g. egg form), so
187 # if this distribution's info location is NOT a pathlib.Path (but e.g.
188 # zipfile.Path), it can never contain any distutils scripts.
189 if not isinstance(self._info_location, pathlib.Path):
190 return
191 for child in self._info_location.joinpath("scripts").iterdir():
192 yield child.name
193
194 def read_text(self, path: InfoPath) -> str:
195 content = self._dist.read_text(str(path))
196 if content is None:
197 raise FileNotFoundError(path)
198 return content
199
200 def iter_entry_points(self) -> Iterable[BaseEntryPoint]:
201 # importlib.metadata's EntryPoint structure satisfies BaseEntryPoint.
202 return self._dist.entry_points
203
204 def _metadata_impl(self) -> email.message.Message:
205 # From Python 3.10+, importlib.metadata declares PackageMetadata as the
206 # return type. This protocol is unfortunately a disaster now and misses
207 # a ton of fields that we need, including get() and get_payload(). We
208 # rely on the implementation that the object is actually a Message now,
209 # until upstream can improve the protocol. (python/cpython#94952)
210 return cast(email.message.Message, self._dist.metadata)
211
212 def iter_provided_extras(self) -> Iterable[NormalizedName]:
213 return [
214 canonicalize_name(extra)
215 for extra in self.metadata.get_all("Provides-Extra", [])
216 ]
217
218 def iter_dependencies(self, extras: Collection[str] = ()) -> Iterable[Requirement]:
219 contexts: Sequence[dict[str, str]] = [{"extra": e} for e in extras]
220 for req_string in self.metadata.get_all("Requires-Dist", []):
221 # strip() because email.message.Message.get_all() may return a leading \n
222 # in case a long header was wrapped.
223 req = get_requirement(req_string.strip())
224 if not req.marker:
225 yield req
226 elif not extras and req.marker.evaluate({"extra": ""}):
227 yield req
228 elif any(req.marker.evaluate(context) for context in contexts):
229 yield req