Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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``).
8 changes: 6 additions & 2 deletions src/pip/_internal/cli/cmdoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,8 +542,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
38 changes: 38 additions & 0 deletions src/pip/_internal/cli/req_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
import os
from functools import partial
from optparse import Values
from pathlib import Path
from typing import Any, Callable, TypeVar

from pip._vendor.packaging import pylock

from pip._internal.build_env import (
BuildEnvironmentInstaller,
InprocessBuildEnvironmentInstaller,
Expand All @@ -39,6 +42,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 +51,7 @@
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 select_from_pylock_path_or_url
from pip._internal.utils.temp_dir import (
TempDirectory,
TempDirectoryTypeRegistry,
Expand Down Expand Up @@ -332,6 +337,39 @@ def get_requirements(

# NOTE: options.require_hashes may be set if --require-hashes is True
for filename in options.requirements:
if pylock.is_valid_pylock_path(Path(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."
)
if any(
[
options.python_version,
options.platforms,
options.abis,
options.implementation,
]
):
raise CommandError(
"Patform and interpreter constraints using "
"--python-version, --platform, --abi, or --implementation, "
f"are not supported when installing from {filename!r}"
)
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
97 changes: 96 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,88 @@ 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):
if package_dist.editable:
return install_req_from_editable(
package_directory_requirement_url(pylock_path_or_url, package_dist),
comes_from=pylock_path_or_url,
user_supplied=user_supplied,
)
else:
return install_req_from_line(
package_directory_requirement_url(pylock_path_or_url, package_dist),
comes_from=pylock_path_or_url,
user_supplied=user_supplied,
)
Comment thread
sbidoul marked this conversation as resolved.
else:
# wheel or sdist
allow_binary = "binary" in format_control.get_allowed_formats(package.name)
if isinstance(package_dist, pylock.PackageWheel) and not allow_binary:
Comment thread
sbidoul marked this conversation as resolved.
Outdated
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
39 changes: 33 additions & 6 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 @@ -311,12 +312,38 @@ 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
locked_ireqs = [ireq for ireq in ireqs if ireq.locked_link]
Comment thread
sbidoul marked this conversation as resolved.
Outdated
if locked_ireqs:
# 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.
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 constraints "
Comment thread
sbidoul marked this conversation as resolved.
Outdated
f"for the same package ({specifier!s})"
)
icans = [
InstallationCandidate(
name, str(locked_ireq.locked_version), locked_ireq.locked_link
)
]
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
Loading