Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/13876.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add experimental support to read requirements from standardized pylock.toml files (``-r pylock.toml``).
17 changes: 15 additions & 2 deletions src/pip/_internal/cli/cmdoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from pip._internal.models.index import PyPI
from pip._internal.models.release_control import ReleaseControl
from pip._internal.models.target_python import TargetPython
from pip._internal.utils import pylock as pylock_utils
from pip._internal.utils.datetime import parse_iso_datetime
from pip._internal.utils.hashes import STRONG_HASHES
from pip._internal.utils.misc import strtobool
Expand Down Expand Up @@ -103,6 +104,14 @@ def check_dist_restriction(options: Values, check_target: bool = False) -> None:
"installing via '--target' or using '--dry-run'"
)

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


def check_build_constraints(options: Values) -> None:
"""Function for validating build constraints options.
Expand Down Expand Up @@ -542,8 +551,12 @@ def requirements() -> Option:
action="append",
default=[],
metavar="file",
help="Install from the given requirements file. "
"This option can be used multiple times.",
help=(
"Install from the given requirements file. "
"The file or URL can be in pip's requirements.txt format, "
"or pylock.toml format. pylock.toml support is experimental. "
"This option can be used multiple times."
),
)


Expand Down
25 changes: 25 additions & 0 deletions src/pip/_internal/cli/req_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
install_req_from_editable,
install_req_from_line,
install_req_from_parsed_requirement,
install_req_from_pylock_package,
install_req_from_req_string,
)
from pip._internal.req.pep723 import PEP723Exception, pep723_metadata
Expand All @@ -47,6 +48,10 @@
from pip._internal.req.req_install import InstallRequirement
from pip._internal.resolution.base import BaseResolver
from pip._internal.utils.packaging import check_requires_python
from pip._internal.utils.pylock import (
is_valid_pylock_filename,
select_from_pylock_path_or_url,
)
from pip._internal.utils.temp_dir import (
TempDirectory,
TempDirectoryTypeRegistry,
Expand Down Expand Up @@ -332,6 +337,26 @@ def get_requirements(

# NOTE: options.require_hashes may be set if --require-hashes is True
for filename in options.requirements:
if is_valid_pylock_filename(filename):
logger.warning(
"Using pylock.toml as a requirements source "
"is an experimental feature. "
"It may be removed/changed in a future release "
"without prior warning."
)
for package, package_dist in select_from_pylock_path_or_url(
filename, session=session
):
requirements.append(
install_req_from_pylock_package(
package,
package_dist,
filename,
options.format_control,
user_supplied=True,
)
)
continue
for parsed_req in parse_requirements(
filename, finder=finder, options=options, session=session
):
Expand Down
109 changes: 108 additions & 1 deletion src/pip/_internal/req/constructors.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@
import logging
import os
import re
from collections.abc import Collection
from collections.abc import Collection, Mapping
from dataclasses import dataclass

from pip._vendor.packaging import pylock
from pip._vendor.packaging.markers import Marker
from pip._vendor.packaging.requirements import InvalidRequirement, Requirement
from pip._vendor.packaging.utils import parse_sdist_filename, parse_wheel_filename

from pip._internal.exceptions import InstallationError
from pip._internal.models.format_control import FormatControl
from pip._internal.models.index import PyPI, TestPyPI
from pip._internal.models.link import Link
from pip._internal.models.wheel import Wheel
Expand All @@ -29,6 +32,13 @@
from pip._internal.utils.filetypes import is_archive_file
from pip._internal.utils.misc import is_installable_dir
from pip._internal.utils.packaging import get_requirement
from pip._internal.utils.pylock import (
package_archive_requirement_url,
package_directory_requirement_url,
package_sdist_requirement_url,
package_vcs_requirement_url,
package_wheel_requirement_url,
)
from pip._internal.utils.urls import path_to_url
from pip._internal.vcs import is_url, vcs

Expand Down Expand Up @@ -568,3 +578,100 @@ def install_req_extend_extras(
else None
)
return result


def _pylock_hashes_to_hash_options(hashes: Mapping[str, str]) -> dict[str, list[str]]:
return {k: [v] for k, v in hashes.items()}


def install_req_from_pylock_package(
package: pylock.Package,
package_dist: (
pylock.PackageVcs
| pylock.PackageArchive
| pylock.PackageDirectory
| pylock.PackageSdist
| pylock.PackageWheel
),
pylock_path_or_url: str,
format_control: FormatControl,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we handle --only-final here, too?

@sbidoul sbidoul Apr 26, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we handle --only-final here, too?

I don't think so. --only-final influences the solver. The lock file provides pinned requirements so it is reasonable to assume the user wants to install exactly what is in it.

Source/binary format control is different as it's a way to force/prevent building from source regardless of binary artifacts available in the lock file for the current platform.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's a way to force/prevent building from source regardless of binary artifacts available in the lock file for the current platform

More accurrately a way to force/prevent using source artifacts regardless of binary artifacts available in the lock file for the current platform (since the cache of locally built wheel may still provide the wheel when --no-binary is used)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we handle --only-final here, too?

I created #13950 to discuss this. I don't have a strong opinion on this.

user_supplied: bool,
Comment thread
sbidoul marked this conversation as resolved.
) -> InstallRequirement:
pass
# TODO: validate file size
if isinstance(package_dist, pylock.PackageVcs):
return InstallRequirement(
req=Requirement(
f"{package.name} @ "
f"{package_vcs_requirement_url(pylock_path_or_url, package_dist)}"
),
comes_from=pylock_path_or_url,
user_supplied=user_supplied,
)
elif isinstance(package_dist, pylock.PackageArchive):
return InstallRequirement(
req=Requirement(
f"{package.name} @ "
f"{package_archive_requirement_url(pylock_path_or_url, package_dist)}"
),
comes_from=pylock_path_or_url,
hash_options=_pylock_hashes_to_hash_options(package_dist.hashes),
user_supplied=user_supplied,
)
elif isinstance(package_dist, pylock.PackageDirectory):
req = package_directory_requirement_url(pylock_path_or_url, package_dist)
if package_dist.editable:
return install_req_from_editable(
req,
comes_from=pylock_path_or_url,
user_supplied=user_supplied,
)
else:
return install_req_from_line(
req,
comes_from=pylock_path_or_url,
user_supplied=user_supplied,
)
Comment thread
sbidoul marked this conversation as resolved.
else:
# wheel or sdist
allowed_formats = format_control.get_allowed_formats(package.name)
if (
isinstance(package_dist, pylock.PackageSdist)
and "source" not in allowed_formats
):
raise InstallationError(
f"source distributions are not permitted for package {package.name!r} "
f"and there is no compatible wheel for it in {pylock_path_or_url!r}"
)
if (
isinstance(package_dist, pylock.PackageWheel)
and "binary" not in allowed_formats
):
if not package.sdist:
raise InstallationError(
f"binaries are not permitted for package {package.name!r} and "
f"there is no source distribution for it in {pylock_path_or_url!r}"
)
package_dist = package.sdist
version = package.version
if isinstance(package_dist, pylock.PackageWheel):
if not version:
_, version, _, _ = parse_wheel_filename(package_dist.filename)
requirement_url = package_wheel_requirement_url(
pylock_path_or_url, package_dist
)
elif isinstance(package_dist, pylock.PackageSdist):
if not version:
_, version = parse_sdist_filename(package_dist.filename)
requirement_url = package_sdist_requirement_url(
pylock_path_or_url, package_dist
)
ireq = InstallRequirement(
req=Requirement(f"{package.name}=={version}"),
comes_from=pylock_path_or_url,
locked_link=Link(requirement_url),
locked_version=version,
hash_options=_pylock_hashes_to_hash_options(package_dist.hashes),
user_supplied=user_supplied,
)
return ireq
10 changes: 10 additions & 0 deletions src/pip/_internal/req/req_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ def __init__(
extras: Collection[str] = (),
user_supplied: bool = False,
permit_editable_wheels: bool = False,
locked_link: Link | None = None,
locked_version: Version | None = None,
) -> None:
assert req is None or isinstance(req, Requirement), req
self.req = req
Expand All @@ -107,6 +109,14 @@ def __init__(
link = Link(req.url)
self.link = self.original_link = link

# locked_link is the link from the lock file that must be used.
# A locked link InstallRequirement behaves similarly as a regular requirement
# that would be searched in indexes, except its artifact URL is known
# in advance. Notably, and contrarily to direct URL requirements and direct URL
# constraints, they do not cause the recording of direct_url.json.
self.locked_link = locked_link
self.locked_version = locked_version

# When this InstallRequirement is a wheel obtained from the cache of locally
# built wheels, this is the source link corresponding to the cache entry, which
# was used to download and build the cached wheel.
Expand Down
49 changes: 41 additions & 8 deletions src/pip/_internal/resolution/resolvelib/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
)
from pip._internal.index.package_finder import PackageFinder
from pip._internal.metadata import BaseDistribution, get_default_environment
from pip._internal.models.candidate import InstallationCandidate
from pip._internal.models.link import Link
from pip._internal.models.wheel import Wheel
from pip._internal.operations.prepare import RequirementPreparer
Expand Down Expand Up @@ -244,6 +245,31 @@ def _make_base_candidate_from_link(
return None
return self._link_candidate_cache[link]

def _get_locked_installation_candidate(
self, ireqs: Sequence[InstallRequirement], name: str, specifier: SpecifierSet
) -> InstallationCandidate | None:
locked_ireqs = [ireq for ireq in ireqs if ireq.locked_link]
if not locked_ireqs:
return None
if len(locked_ireqs) > 1:
raise InstallationError(
f"Multiple locks provided for package {name!r} in "
f"{', '.join(str(lir.comes_from) for lir in locked_ireqs)}"
)
locked_ireq = locked_ireqs[0]
assert locked_ireq.locked_link
assert locked_ireq.locked_version
if not specifier.contains(locked_ireq.locked_version):
raise InstallationError(
f"Locked version {locked_ireq.locked_version!s} "
f"for package {name!r} from {locked_ireq.comes_from!r} "
f"is not compatible with other requirements "
f"for the same package ({specifier!s})"
)
return InstallationCandidate(
name, str(locked_ireq.locked_version), locked_ireq.locked_link
)

def _iter_found_candidates(
self,
ireqs: Sequence[InstallRequirement],
Expand Down Expand Up @@ -311,12 +337,20 @@ def _get_installed_candidate() -> Candidate | None:
return candidate

def iter_index_candidate_infos() -> Iterator[IndexCandidateInfo]:
result = self._finder.find_best_candidate(
project_name=name,
specifier=specifier,
hashes=hashes,
)
icans = result.applicable_candidates
if locked_ican := self._get_locked_installation_candidate(
ireqs, name, specifier
):
# Locked InstallRequirements must behave as if they would have
# been found on an index, except the link is already known, so we don't
# ask the finder for the best candidate in that case.
icans = [locked_ican]
else:
result = self._finder.find_best_candidate(
project_name=name,
specifier=specifier,
hashes=hashes,
)
icans = result.applicable_candidates

# PEP 592: Yanked releases are ignored unless the specifier
# explicitly pins a version (via '==' or '===') that can be
Expand Down Expand Up @@ -733,8 +767,7 @@ def _report_single_requirement_conflict(
version_type = "final version"

logger.critical(
"Could not find a %s that satisfies the requirement %s "
"(from versions: %s)",
"Could not find a %s that satisfies the requirement %s (from versions: %s)",
version_type,
req_disp,
", ".join(versions) or "none",
Expand Down
Loading