Skip to content

Commit 63c3709

Browse files
authored
Merge pull request #13876 from sbidoul/install-from-pylock-reqs-sbi
Add initial support for -r pylock.toml (experimental)
2 parents e5fe702 + c335252 commit 63c3709

20 files changed

Lines changed: 1004 additions & 14 deletions

news/13876.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add experimental support to read requirements from standardized pylock.toml files (``-r pylock.toml``).

src/pip/_internal/cli/cmdoptions.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from pip._internal.models.index import PyPI
3232
from pip._internal.models.release_control import ReleaseControl
3333
from pip._internal.models.target_python import TargetPython
34+
from pip._internal.utils import pylock as pylock_utils
3435
from pip._internal.utils.datetime import parse_iso_datetime
3536
from pip._internal.utils.hashes import STRONG_HASHES
3637
from pip._internal.utils.misc import strtobool
@@ -103,6 +104,14 @@ def check_dist_restriction(options: Values, check_target: bool = False) -> None:
103104
"installing via '--target' or using '--dry-run'"
104105
)
105106

107+
for filename in options.requirements:
108+
if dist_restriction_set and pylock_utils.is_valid_pylock_filename(filename):
109+
raise CommandError(
110+
"Patform and interpreter constraints using "
111+
"--python-version, --platform, --abi, or --implementation, "
112+
f"are not supported when selecting requirements from {filename!r}"
113+
)
114+
106115

107116
def check_build_constraints(options: Values) -> None:
108117
"""Function for validating build constraints options.
@@ -542,8 +551,12 @@ def requirements() -> Option:
542551
action="append",
543552
default=[],
544553
metavar="file",
545-
help="Install from the given requirements file. "
546-
"This option can be used multiple times.",
554+
help=(
555+
"Install from the given requirements file. "
556+
"The file or URL can be in pip's requirements.txt format, "
557+
"or pylock.toml format. pylock.toml support is experimental. "
558+
"This option can be used multiple times."
559+
),
547560
)
548561

549562

src/pip/_internal/cli/req_command.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
install_req_from_editable,
4040
install_req_from_line,
4141
install_req_from_parsed_requirement,
42+
install_req_from_pylock_package,
4243
install_req_from_req_string,
4344
)
4445
from pip._internal.req.pep723 import PEP723Exception, pep723_metadata
@@ -47,6 +48,10 @@
4748
from pip._internal.req.req_install import InstallRequirement
4849
from pip._internal.resolution.base import BaseResolver
4950
from pip._internal.utils.packaging import check_requires_python
51+
from pip._internal.utils.pylock import (
52+
is_valid_pylock_filename,
53+
select_from_pylock_path_or_url,
54+
)
5055
from pip._internal.utils.temp_dir import (
5156
TempDirectory,
5257
TempDirectoryTypeRegistry,
@@ -332,6 +337,26 @@ 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 is_valid_pylock_filename(filename):
341+
logger.warning(
342+
"Using pylock.toml as a requirements source "
343+
"is an experimental feature. "
344+
"It may be removed/changed in a future release "
345+
"without prior warning."
346+
)
347+
for package, package_dist in select_from_pylock_path_or_url(
348+
filename, session=session
349+
):
350+
requirements.append(
351+
install_req_from_pylock_package(
352+
package,
353+
package_dist,
354+
filename,
355+
options.format_control,
356+
user_supplied=True,
357+
)
358+
)
359+
continue
335360
for parsed_req in parse_requirements(
336361
filename, finder=finder, options=options, session=session
337362
):

src/pip/_internal/req/constructors.py

Lines changed: 108 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,100 @@ 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+
user_supplied: bool,
599+
) -> InstallRequirement:
600+
pass
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+
user_supplied=user_supplied,
610+
)
611+
elif isinstance(package_dist, pylock.PackageArchive):
612+
return InstallRequirement(
613+
req=Requirement(
614+
f"{package.name} @ "
615+
f"{package_archive_requirement_url(pylock_path_or_url, package_dist)}"
616+
),
617+
comes_from=pylock_path_or_url,
618+
hash_options=_pylock_hashes_to_hash_options(package_dist.hashes),
619+
user_supplied=user_supplied,
620+
)
621+
elif isinstance(package_dist, pylock.PackageDirectory):
622+
req = package_directory_requirement_url(pylock_path_or_url, package_dist)
623+
if package_dist.editable:
624+
return install_req_from_editable(
625+
req,
626+
comes_from=pylock_path_or_url,
627+
user_supplied=user_supplied,
628+
)
629+
else:
630+
return install_req_from_line(
631+
req,
632+
comes_from=pylock_path_or_url,
633+
user_supplied=user_supplied,
634+
)
635+
else:
636+
# wheel or sdist
637+
allowed_formats = format_control.get_allowed_formats(package.name)
638+
if (
639+
isinstance(package_dist, pylock.PackageSdist)
640+
and "source" not in allowed_formats
641+
):
642+
raise InstallationError(
643+
f"source distributions are not permitted for package {package.name!r} "
644+
f"and there is no compatible wheel for it in {pylock_path_or_url!r}"
645+
)
646+
if (
647+
isinstance(package_dist, pylock.PackageWheel)
648+
and "binary" not in allowed_formats
649+
):
650+
if not package.sdist:
651+
raise InstallationError(
652+
f"binaries are not permitted for package {package.name!r} and "
653+
f"there is no source distribution for it in {pylock_path_or_url!r}"
654+
)
655+
package_dist = package.sdist
656+
version = package.version
657+
if isinstance(package_dist, pylock.PackageWheel):
658+
if not version:
659+
_, version, _, _ = parse_wheel_filename(package_dist.filename)
660+
requirement_url = package_wheel_requirement_url(
661+
pylock_path_or_url, package_dist
662+
)
663+
elif isinstance(package_dist, pylock.PackageSdist):
664+
if not version:
665+
_, version = parse_sdist_filename(package_dist.filename)
666+
requirement_url = package_sdist_requirement_url(
667+
pylock_path_or_url, package_dist
668+
)
669+
ireq = InstallRequirement(
670+
req=Requirement(f"{package.name}=={version}"),
671+
comes_from=pylock_path_or_url,
672+
locked_link=Link(requirement_url),
673+
locked_version=version,
674+
hash_options=_pylock_hashes_to_hash_options(package_dist.hashes),
675+
user_supplied=user_supplied,
676+
)
677+
return ireq

src/pip/_internal/req/req_install.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ def __init__(
8181
extras: Collection[str] = (),
8282
user_supplied: bool = False,
8383
permit_editable_wheels: bool = False,
84+
locked_link: Link | None = None,
85+
locked_version: Version | None = None,
8486
) -> None:
8587
assert req is None or isinstance(req, Requirement), req
8688
self.req = req
@@ -107,6 +109,14 @@ def __init__(
107109
link = Link(req.url)
108110
self.link = self.original_link = link
109111

112+
# locked_link is the link from the lock file that must be used.
113+
# A locked link InstallRequirement behaves similarly as a regular requirement
114+
# that would be searched in indexes, except its artifact URL is known
115+
# in advance. Notably, and contrarily to direct URL requirements and direct URL
116+
# constraints, they do not cause the recording of direct_url.json.
117+
self.locked_link = locked_link
118+
self.locked_version = locked_version
119+
110120
# When this InstallRequirement is a wheel obtained from the cache of locally
111121
# built wheels, this is the source link corresponding to the cache entry, which
112122
# was used to download and build the cached wheel.

src/pip/_internal/resolution/resolvelib/factory.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
)
3333
from pip._internal.index.package_finder import PackageFinder
3434
from pip._internal.metadata import BaseDistribution, get_default_environment
35+
from pip._internal.models.candidate import InstallationCandidate
3536
from pip._internal.models.link import Link
3637
from pip._internal.models.wheel import Wheel
3738
from pip._internal.operations.prepare import RequirementPreparer
@@ -242,6 +243,31 @@ def _make_base_candidate_from_link(
242243
return None
243244
return self._link_candidate_cache[link]
244245

246+
def _get_locked_installation_candidate(
247+
self, ireqs: Sequence[InstallRequirement], name: str, specifier: SpecifierSet
248+
) -> InstallationCandidate | None:
249+
locked_ireqs = [ireq for ireq in ireqs if ireq.locked_link]
250+
if not locked_ireqs:
251+
return None
252+
if len(locked_ireqs) > 1:
253+
raise InstallationError(
254+
f"Multiple locks provided for package {name!r} in "
255+
f"{', '.join(str(lir.comes_from) for lir in locked_ireqs)}"
256+
)
257+
locked_ireq = locked_ireqs[0]
258+
assert locked_ireq.locked_link
259+
assert locked_ireq.locked_version
260+
if not specifier.contains(locked_ireq.locked_version):
261+
raise InstallationError(
262+
f"Locked version {locked_ireq.locked_version!s} "
263+
f"for package {name!r} from {locked_ireq.comes_from!r} "
264+
f"is not compatible with other requirements "
265+
f"for the same package ({specifier!s})"
266+
)
267+
return InstallationCandidate(
268+
name, str(locked_ireq.locked_version), locked_ireq.locked_link
269+
)
270+
245271
def _iter_found_candidates(
246272
self,
247273
ireqs: Sequence[InstallRequirement],
@@ -309,12 +335,20 @@ def _get_installed_candidate() -> Candidate | None:
309335
return candidate
310336

311337
def iter_index_candidate_infos() -> Iterator[IndexCandidateInfo]:
312-
result = self._finder.find_best_candidate(
313-
project_name=name,
314-
specifier=specifier,
315-
hashes=hashes,
316-
)
317-
icans = result.applicable_candidates
338+
if locked_ican := self._get_locked_installation_candidate(
339+
ireqs, name, specifier
340+
):
341+
# Locked InstallRequirements must behave as if they would have
342+
# been found on an index, except the link is already known, so we don't
343+
# ask the finder for the best candidate in that case.
344+
icans = [locked_ican]
345+
else:
346+
result = self._finder.find_best_candidate(
347+
project_name=name,
348+
specifier=specifier,
349+
hashes=hashes,
350+
)
351+
icans = result.applicable_candidates
318352

319353
# PEP 592: Yanked releases are ignored unless the specifier
320354
# explicitly pins a version (via '==' or '===') that can be
@@ -731,8 +765,7 @@ def _report_single_requirement_conflict(
731765
version_type = "final version"
732766

733767
logger.critical(
734-
"Could not find a %s that satisfies the requirement %s "
735-
"(from versions: %s)",
768+
"Could not find a %s that satisfies the requirement %s (from versions: %s)",
736769
version_type,
737770
req_disp,
738771
", ".join(versions) or "none",

0 commit comments

Comments
 (0)