1from __future__ import annotations
2
3import os
4import re
5from collections.abc import Iterable, Iterator
6from pathlib import Path
7from typing import TYPE_CHECKING
8from urllib.parse import urljoin, urlsplit
9
10from pip._vendor.packaging.pylock import (
11 Package,
12 PackageArchive,
13 PackageDirectory,
14 PackageSdist,
15 PackageVcs,
16 PackageWheel,
17 Pylock,
18 is_valid_pylock_path,
19)
20from pip._vendor.packaging.version import Version
21
22from pip._internal.exceptions import InstallationError
23from pip._internal.models.link import Link
24from pip._internal.utils.compat import tomllib
25from pip._internal.utils.urls import path_to_url, url_to_path
26
27if TYPE_CHECKING:
28 from pip._internal.network.session import PipSession
29 from pip._internal.req.req_install import InstallRequirement
30
31
32def _pylock_package_from_install_requirement(
33 ireq: InstallRequirement, base_dir: Path
34) -> Package:
35 base_dir = base_dir.resolve()
36 dist = ireq.get_dist()
37 download_info = ireq.download_info
38 assert download_info
39 package_version = None
40 package_vcs = None
41 package_directory = None
42 package_archive = None
43 package_sdist = None
44 package_wheels = None
45 if ireq.is_direct:
46 if download_info.vcs_info:
47 package_vcs = PackageVcs(
48 type=download_info.vcs_info.vcs,
49 url=download_info.url,
50 path=None,
51 requested_revision=download_info.vcs_info.requested_revision,
52 commit_id=download_info.vcs_info.commit_id,
53 subdirectory=download_info.subdirectory,
54 )
55 elif download_info.dir_info:
56 package_directory = PackageDirectory(
57 path=(
58 Path(url_to_path(download_info.url))
59 .resolve()
60 .relative_to(base_dir)
61 .as_posix()
62 ),
63 editable=(
64 download_info.dir_info.editable
65 if download_info.dir_info.editable
66 else None
67 ),
68 subdirectory=download_info.subdirectory,
69 )
70 elif download_info.archive_info:
71 if not download_info.archive_info.hashes:
72 raise NotImplementedError()
73 package_archive = PackageArchive(
74 url=download_info.url,
75 path=None,
76 hashes=download_info.archive_info.hashes,
77 subdirectory=download_info.subdirectory,
78 )
79 else:
80 # should never happen
81 raise NotImplementedError()
82 else:
83 package_version = dist.version
84 if download_info.archive_info:
85 if not download_info.archive_info.hashes:
86 raise NotImplementedError()
87 link = Link(download_info.url)
88 if link.is_wheel:
89 package_wheels = [
90 PackageWheel(
91 name=link.filename,
92 url=download_info.url,
93 hashes=download_info.archive_info.hashes,
94 )
95 ]
96 else:
97 package_sdist = PackageSdist(
98 name=link.filename,
99 url=download_info.url,
100 hashes=download_info.archive_info.hashes,
101 )
102 else:
103 # should never happen
104 raise NotImplementedError()
105 return Package(
106 name=dist.canonical_name,
107 version=package_version,
108 vcs=package_vcs,
109 directory=package_directory,
110 archive=package_archive,
111 sdist=package_sdist,
112 wheels=package_wheels,
113 )
114
115
116def pylock_from_install_requirements(
117 install_requirements: Iterable[InstallRequirement], base_dir: Path
118) -> Pylock:
119 return Pylock(
120 lock_version=Version("1.0"),
121 created_by="pip",
122 packages=sorted(
123 (
124 _pylock_package_from_install_requirement(ireq, base_dir)
125 for ireq in install_requirements
126 ),
127 key=lambda p: p.name,
128 ),
129 )
130
131
132_SCHEME_RE = re.compile("^(http|https|file)://", re.IGNORECASE)
133
134
135def _is_url(s: str) -> bool:
136 return bool(_SCHEME_RE.match(s))
137
138
139def is_valid_pylock_filename(filename: str) -> bool:
140 if _is_url(filename):
141 path = Path(urlsplit(filename).path.rpartition("/")[-1])
142 else:
143 path = Path(filename)
144 return is_valid_pylock_path(path)
145
146
147def _package_dist_url(
148 pylock_path_or_url: str, path: str | None, url: str | None
149) -> str:
150 """Compute an url from a Pylock package path and url.
151
152 Give priority to path over url. If path is relative,
153 compute an url using the pylock file location as base.
154 """
155 if path is not None:
156 if not os.path.isabs(path):
157 # relative path, join to pylock location
158 if _is_url(pylock_path_or_url):
159 return urljoin(pylock_path_or_url, path)
160 else:
161 return path_to_url(
162 os.path.join(os.path.dirname(pylock_path_or_url), path)
163 )
164 else:
165 # absolute path, reject if pylock comes from a URL
166 if _is_url(pylock_path_or_url):
167 raise InstallationError(
168 f"Absolute paths are not supported in pylock files obtained "
169 f"from a URL: {path!r} in {pylock_path_or_url!r}"
170 )
171 return path_to_url(path)
172 else:
173 assert url is not None # guaranteed by packaging.pylock validation
174 return url
175
176
177def package_vcs_requirement_url(
178 pylock_path_or_url: str, package_vcs: PackageVcs
179) -> str:
180 dist_url = _package_dist_url(pylock_path_or_url, package_vcs.path, package_vcs.url)
181 url = f"{package_vcs.type}+{dist_url}@{package_vcs.commit_id}"
182 if package_vcs.subdirectory:
183 if "#" in url:
184 raise InstallationError(
185 f"Package URL {url!r} cannot contain fragments in combination "
186 f"with subdirectory field (in {pylock_path_or_url!r})"
187 )
188 url += "#subdirectory=" + package_vcs.subdirectory
189 return url
190
191
192def package_archive_requirement_url(
193 pylock_path_or_url: str, package_archive: PackageArchive
194) -> str:
195 url = _package_dist_url(
196 pylock_path_or_url, package_archive.path, package_archive.url
197 )
198 if package_archive.subdirectory:
199 if "#" in url:
200 raise InstallationError(
201 f"Package URL {url!r} cannot contain fragments in combination "
202 f"with subdirectory field (in {pylock_path_or_url!r})"
203 )
204 url += "#subdirectory=" + package_archive.subdirectory
205 return url
206
207
208def package_directory_requirement_url(
209 pylock_path_or_url: str, package_directory: PackageDirectory
210) -> str:
211 if _is_url(pylock_path_or_url) and not pylock_path_or_url.startswith("file://"):
212 raise InstallationError(
213 f"Directory entries are not supported in remote pylock.toml "
214 f"{pylock_path_or_url!r}"
215 )
216 url = _package_dist_url(pylock_path_or_url, package_directory.path, None)
217 assert url.startswith("file://")
218 if not url.endswith("/"):
219 url += "/"
220 if package_directory.subdirectory:
221 url += package_directory.subdirectory
222 if not url.endswith("/"):
223 url += "/"
224 return url
225
226
227def package_sdist_requirement_url(
228 pylock_path_or_url: str, package_sdist: PackageSdist
229) -> str:
230 return _package_dist_url(pylock_path_or_url, package_sdist.path, package_sdist.url)
231
232
233def package_wheel_requirement_url(
234 pylock_path_or_url: str, package_wheel: PackageWheel
235) -> str:
236 return _package_dist_url(pylock_path_or_url, package_wheel.path, package_wheel.url)
237
238
239def _get_pylock_path_or_url_content(path_or_url: str, session: PipSession) -> str:
240 # TODO: refactor - this is similar to req_file.get_file_content
241 scheme = urlsplit(path_or_url).scheme
242 # Pip has special support for file:// URLs (LocalFSAdapter).
243 if scheme in ["http", "https", "file"]:
244 # Delay importing heavy network modules until absolutely necessary.
245 from pip._internal.network.utils import raise_for_status
246
247 resp = session.get(path_or_url)
248 raise_for_status(resp)
249 return resp.text
250
251 # Assume this is a bare path.
252 return Path(path_or_url).read_text(encoding="utf-8")
253
254
255def select_from_pylock_path_or_url(
256 pylock_path_or_url: str,
257 session: PipSession,
258) -> Iterator[
259 tuple[
260 Package,
261 PackageVcs | PackageDirectory | PackageArchive | PackageWheel | PackageSdist,
262 ]
263]:
264 try:
265 pylock_content = _get_pylock_path_or_url_content(pylock_path_or_url, session)
266 except Exception as exc:
267 raise InstallationError(
268 f"Error reading pylock file {pylock_path_or_url!r}: {exc}"
269 ) from exc
270
271 try:
272 lock = Pylock.from_dict(tomllib.loads(pylock_content))
273 except Exception as exc:
274 raise InstallationError(
275 f"Invalid pylock file {pylock_path_or_url!r}: {exc}"
276 ) from exc
277
278 try:
279 yield from lock.select()
280 except Exception as exc:
281 raise InstallationError(
282 f"Cannot select requirements from pylock file {pylock_path_or_url!r}: {exc}"
283 ) from exc