Skip to content

Commit b0d4b3f

Browse files
committed
Add support for -r pylock.toml
1 parent c95872b commit b0d4b3f

File tree

6 files changed

+339
-3
lines changed

6 files changed

+339
-3
lines changed

src/pip/_internal/cli/req_command.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@
1111
import os
1212
from functools import partial
1313
from optparse import Values
14+
from pathlib import Path
1415
from typing import Any, Callable, TypeVar
1516

17+
from pip._vendor.packaging import pylock
18+
1619
from pip._internal.build_env import (
1720
BuildEnvironmentInstaller,
1821
InprocessBuildEnvironmentInstaller,
@@ -39,6 +42,7 @@
3942
install_req_from_editable,
4043
install_req_from_line,
4144
install_req_from_parsed_requirement,
45+
install_req_from_pylock_package,
4246
install_req_from_req_string,
4347
)
4448
from pip._internal.req.pep723 import PEP723Exception, pep723_metadata
@@ -47,6 +51,7 @@
4751
from pip._internal.req.req_install import InstallRequirement
4852
from pip._internal.resolution.base import BaseResolver
4953
from pip._internal.utils.packaging import check_requires_python
54+
from pip._internal.utils.pylock import select_from_pylock_path_or_url
5055
from pip._internal.utils.temp_dir import (
5156
TempDirectory,
5257
TempDirectoryTypeRegistry,
@@ -332,6 +337,29 @@ def get_requirements(
332337

333338
# NOTE: options.require_hashes may be set if --require-hashes is True
334339
for filename in options.requirements:
340+
if pylock.is_valid_pylock_path(Path(filename)):
341+
if any(
342+
[
343+
options.python_version,
344+
options.platforms,
345+
options.abis,
346+
options.implementation,
347+
]
348+
):
349+
raise CommandError(
350+
"Patform and interpreter constraints using "
351+
"--python-version, --platform, --abi, or --implementation, "
352+
f"are not supported when installing from {filename!r}"
353+
)
354+
for package, package_dist in select_from_pylock_path_or_url(
355+
filename, session=session
356+
):
357+
requirements.append(
358+
install_req_from_pylock_package(
359+
package, package_dist, filename, options.format_control
360+
)
361+
)
362+
continue
335363
for parsed_req in parse_requirements(
336364
filename, finder=finder, options=options, session=session
337365
):

src/pip/_internal/req/constructors.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@
1414
import logging
1515
import os
1616
import re
17-
from collections.abc import Collection
17+
from collections.abc import Collection, Mapping
1818
from dataclasses import dataclass
1919

20+
from pip._vendor.packaging import pylock
2021
from pip._vendor.packaging.markers import Marker
2122
from pip._vendor.packaging.requirements import InvalidRequirement, Requirement
23+
from pip._vendor.packaging.utils import parse_sdist_filename, parse_wheel_filename
2224

2325
from pip._internal.exceptions import InstallationError
26+
from pip._internal.models.format_control import FormatControl
2427
from pip._internal.models.index import PyPI, TestPyPI
2528
from pip._internal.models.link import Link
2629
from pip._internal.models.wheel import Wheel
@@ -29,6 +32,13 @@
2932
from pip._internal.utils.filetypes import is_archive_file
3033
from pip._internal.utils.misc import is_installable_dir
3134
from pip._internal.utils.packaging import get_requirement
35+
from pip._internal.utils.pylock import (
36+
package_archive_requirement_url,
37+
package_directory_requirement_url,
38+
package_sdist_requirement_url,
39+
package_vcs_requirement_url,
40+
package_wheel_requirement_url,
41+
)
3242
from pip._internal.utils.urls import path_to_url
3343
from pip._internal.vcs import is_url, vcs
3444

@@ -568,3 +578,83 @@ def install_req_extend_extras(
568578
else None
569579
)
570580
return result
581+
582+
583+
def _pylock_hashes_to_hash_options(hashes: Mapping[str, str]) -> dict[str, list[str]]:
584+
return {k: [v] for k, v in hashes.items()}
585+
586+
587+
def install_req_from_pylock_package(
588+
package: pylock.Package,
589+
package_dist: (
590+
pylock.PackageVcs
591+
| pylock.PackageArchive
592+
| pylock.PackageDirectory
593+
| pylock.PackageSdist
594+
| pylock.PackageWheel
595+
),
596+
pylock_path_or_url: str,
597+
format_control: FormatControl,
598+
) -> InstallRequirement:
599+
pass
600+
# TODO: user_supplied
601+
# TODO: validate file size
602+
if isinstance(package_dist, pylock.PackageVcs):
603+
return InstallRequirement(
604+
req=Requirement(
605+
f"{package.name} @ "
606+
f"{package_vcs_requirement_url(pylock_path_or_url, package_dist)}"
607+
),
608+
comes_from=pylock_path_or_url,
609+
)
610+
elif isinstance(package_dist, pylock.PackageArchive):
611+
return InstallRequirement(
612+
req=Requirement(
613+
f"{package.name} @ "
614+
f"{package_archive_requirement_url(pylock_path_or_url, package_dist)}"
615+
),
616+
comes_from=pylock_path_or_url,
617+
hash_options=_pylock_hashes_to_hash_options(package_dist.hashes),
618+
)
619+
elif isinstance(package_dist, pylock.PackageDirectory):
620+
if package_dist.editable:
621+
return install_req_from_editable(
622+
package_directory_requirement_url(pylock_path_or_url, package_dist),
623+
comes_from=pylock_path_or_url,
624+
)
625+
else:
626+
return install_req_from_line(
627+
package_directory_requirement_url(pylock_path_or_url, package_dist),
628+
comes_from=pylock_path_or_url,
629+
)
630+
else:
631+
# wheel or sdist
632+
allow_binary = "binary" in format_control.get_allowed_formats(package.name)
633+
if isinstance(package_dist, pylock.PackageWheel) and not allow_binary:
634+
if not package.sdist:
635+
raise InstallationError(
636+
f"binaries are not permitted for package {package.name!r} and "
637+
f"there is no source distribution for it in {pylock_path_or_url!r}"
638+
)
639+
package_dist = package.sdist
640+
version = package.version
641+
if isinstance(package_dist, pylock.PackageWheel):
642+
if not version:
643+
_, version, _, _ = parse_wheel_filename(package_dist.filename)
644+
requirement_url = package_wheel_requirement_url(
645+
pylock_path_or_url, package_dist
646+
)
647+
elif isinstance(package_dist, pylock.PackageSdist):
648+
if not version:
649+
_, version = parse_sdist_filename(package_dist.filename)
650+
requirement_url = package_sdist_requirement_url(
651+
pylock_path_or_url, package_dist
652+
)
653+
ireq = InstallRequirement(
654+
req=Requirement(f"{package.name}=={version}"),
655+
comes_from=pylock_path_or_url,
656+
link=Link(requirement_url),
657+
hash_options=_pylock_hashes_to_hash_options(package_dist.hashes),
658+
)
659+
ireq.original_link = None # not a direct URL
660+
return ireq

src/pip/_internal/utils/pylock.py

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
from collections.abc import Iterable
1+
from __future__ import annotations
2+
3+
import os
4+
import re
5+
from collections.abc import Iterable, Iterator
26
from pathlib import Path
7+
from typing import TYPE_CHECKING
8+
from urllib.parse import urljoin, urlsplit
39

410
from pip._vendor.packaging.pylock import (
511
Package,
@@ -15,7 +21,11 @@
1521
from pip._internal.models.direct_url import ArchiveInfo, DirInfo, VcsInfo
1622
from pip._internal.models.link import Link
1723
from pip._internal.req.req_install import InstallRequirement
18-
from pip._internal.utils.urls import url_to_path
24+
from pip._internal.utils.compat import tomllib
25+
from pip._internal.utils.urls import path_to_url, url_to_path
26+
27+
if TYPE_CHECKING:
28+
from pip._internal.network.session import PipSession
1929

2030

2131
def _pylock_package_from_install_requirement(
@@ -114,3 +124,122 @@ def pylock_from_install_requirements(
114124
key=lambda p: p.name,
115125
),
116126
)
127+
128+
129+
_SCHEME_RE = re.compile("^(http|https|file)://", re.IGNORECASE)
130+
131+
132+
def _is_url(s: str) -> bool:
133+
return bool(_SCHEME_RE.match(s))
134+
135+
136+
def _package_dist_url(
137+
pylock_path_or_url: str,
138+
path: str | None,
139+
url: str | None,
140+
) -> str:
141+
"""Compute an url from a Pylock package path and url.
142+
143+
Give priority to path over url. If path is relative,
144+
compute an url using the pylock file location as base.
145+
"""
146+
if path is not None:
147+
if not os.path.isabs(path):
148+
if _is_url(pylock_path_or_url):
149+
return urljoin(pylock_path_or_url, path)
150+
else:
151+
return path_to_url(
152+
os.path.join(os.path.dirname(pylock_path_or_url), path)
153+
)
154+
else:
155+
return path_to_url(path)
156+
else:
157+
assert url is not None # guaranteed by packaging.pylock validation
158+
return url
159+
160+
161+
def package_vcs_requirement_url(
162+
pylock_path_or_url: str, package_vcs: PackageVcs
163+
) -> str:
164+
url = (
165+
package_vcs.type
166+
+ "+"
167+
+ _package_dist_url(pylock_path_or_url, package_vcs.path, package_vcs.url)
168+
+ "@"
169+
+ package_vcs.commit_id
170+
)
171+
if package_vcs.subdirectory:
172+
assert "#" not in url # TODO: prorper exception
173+
url += "#" + package_vcs.subdirectory
174+
return url
175+
176+
177+
def package_archive_requirement_url(
178+
pylock_path_or_url: str, package_archive: PackageArchive
179+
) -> str:
180+
url = _package_dist_url(
181+
pylock_path_or_url, package_archive.path, package_archive.url
182+
)
183+
if package_archive.subdirectory:
184+
assert "#" not in url # TODO: prorper exception
185+
url += "#" + package_archive.subdirectory
186+
return url
187+
188+
189+
def package_directory_requirement_url(
190+
pylock_path_or_url: str, package_directory: PackageDirectory
191+
) -> str:
192+
url = _package_dist_url(pylock_path_or_url, package_directory.path, None)
193+
assert url.startswith("file://") # TODO: proper exception
194+
if not url.endswith("/"):
195+
url += "/"
196+
if package_directory.subdirectory:
197+
url += package_directory.subdirectory
198+
if not url.endswith("/"):
199+
url += "/"
200+
return url
201+
202+
203+
def package_sdist_requirement_url(
204+
pylock_path_or_url: str, package_sdist: PackageSdist
205+
) -> str:
206+
return _package_dist_url(pylock_path_or_url, package_sdist.path, package_sdist.url)
207+
208+
209+
def package_wheel_requirement_url(
210+
pylock_path_or_url: str, package_wheel: PackageWheel
211+
) -> str:
212+
return _package_dist_url(pylock_path_or_url, package_wheel.path, package_wheel.url)
213+
214+
215+
def _get_pylock_path_or_url_content(path_or_url: str, session: PipSession) -> str:
216+
# TODO: refactor - this is similar to req_file.get_file_content
217+
scheme = urlsplit(path_or_url).scheme
218+
# Pip has special support for file:// URLs (LocalFSAdapter).
219+
if scheme in ["http", "https", "file"]:
220+
# Delay importing heavy network modules until absolutely necessary.
221+
from pip._internal.network.utils import raise_for_status
222+
223+
resp = session.get(path_or_url)
224+
raise_for_status(resp)
225+
return resp.text
226+
227+
# Assume this is a bare path.
228+
return Path(path_or_url).read_text(encoding="utf-8")
229+
230+
231+
def select_from_pylock_path_or_url(
232+
pylock_path_or_url: str,
233+
session: PipSession,
234+
) -> Iterator[
235+
tuple[
236+
Package,
237+
PackageVcs | PackageDirectory | PackageArchive | PackageWheel | PackageSdist,
238+
]
239+
]:
240+
lock = Pylock.from_dict(
241+
tomllib.loads(
242+
_get_pylock_path_or_url_content(pylock_path_or_url, session),
243+
)
244+
)
245+
yield from lock.select()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
lock-version = "1.0"
2+
created-by = "pip"
3+
packages = [
4+
{ name = "simplewheel", directory = { path = "../src/simplewheel-2.0", editable = true } },
5+
{ name = "singlemodule", directory = { path = "../src/singlemodule" } },
6+
]

tests/data/lockfiles/pylock.toml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
lock-version = "1.0"
2+
created-by = "pip"
3+
4+
[[packages]]
5+
name = "simple"
6+
version = "2.0"
7+
8+
[packages.sdist]
9+
name = "simple-2.0.tar.gz"
10+
path = "../packages/simple-2.0.tar.gz"
11+
12+
[packages.sdist.hashes]
13+
sha256 = "3a084929238d13bcd3bb928af04f3bac7ca2357d419e29f01459dc848e2d69a4"
14+
15+
[[packages]]
16+
name = "simple2"
17+
18+
[packages.archive]
19+
path = "../packages/simple2-3.0.tar.gz"
20+
21+
[packages.archive.hashes]
22+
sha256 = "3ad45e1e9aa48b4462af0123f6a7e44a9115db1ef945d4d92c123dfe21815a06"
23+
24+
[[packages]]
25+
name = "simplewheel"
26+
version = "2.0"
27+
28+
[[packages.wheels]]
29+
name = "simplewheel-2.0-1-py2.py3-none-any.whl"
30+
path = "../packages/simplewheel-2.0-1-py2.py3-none-any.whl"
31+
32+
[packages.wheels.hashes]
33+
sha256 = "71e1ca6b16ae3382a698c284013f66504f2581099b2ce4801f60e9536236ceee"

0 commit comments

Comments
 (0)