Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/13950.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Honor ``--only-final`` when sourcing requirements with ``-r pylock.toml``.
1 change: 1 addition & 0 deletions news/13963.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Better error messages in case of conflicts with requirements from ``-r pylock.toml``.
1 change: 1 addition & 0 deletions news/14168.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for ``pylock.toml`` ``upload-time`` field, so ``--uploaded-prior-to`` works with ``-r pylock.toml``.
16 changes: 8 additions & 8 deletions src/pip/_internal/cli/req_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,15 +370,15 @@ def get_requirements(
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,
)
req_to_add, locked_link = install_req_from_pylock_package(
package,
package_dist,
filename,
user_supplied=True,
)
requirements.append(req_to_add)
if locked_link:
finder.add_locked_link(package.name, locked_link)
continue
for parsed_req in parse_requirements(
filename, finder=finder, options=options, session=session
Expand Down
33 changes: 32 additions & 1 deletion src/pip/_internal/index/package_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,9 @@ def __init__(
BestCandidateResult,
] = {}

# projects for which a link is locked from a pylock
self._locked_links: dict[NormalizedName, Link] = {}

# Don't include an allow_yanked default value to make sure each call
# site considers whether yanked releases are allowed. This also causes
# that decision to be made explicit in the calling code, which helps
Expand Down Expand Up @@ -876,7 +879,8 @@ def process_project_url(
def find_all_candidates(self, project_name: str) -> list[InstallationCandidate]:
"""Find all available InstallationCandidate for project_name

This checks index_urls and find_links.
This checks index_urls and find_links, unless a locked link is known
for that project.
All versions found are returned as an InstallationCandidate list.

See LinkEvaluator.evaluate_link() for details on which files
Expand All @@ -887,6 +891,23 @@ def find_all_candidates(self, project_name: str) -> list[InstallationCandidate]:

link_evaluator = self.make_link_evaluator(project_name)

if locked_link := self._locked_links.get(canonicalize_name(project_name)):
# If a locked link is known for that project, do not check
# index_urls nor find_links. We don't use get_install_candidate here,
# because if a locked link is unsupported (due to format control,
# release control or otherwise), we want to error out immediately
# instead of ignoring it.
result, detail = link_evaluator.evaluate_link(locked_link)
if result != LinkType.candidate:
raise InstallationError(
f"Could not install locked package {project_name!r} "
f"from {locked_link.comes_from!r}: {detail}"
)
self._all_candidates[project_name] = [
InstallationCandidate(project_name, detail, locked_link, locked=True)
]
return self._all_candidates[project_name]

collected_sources = self._link_collector.collect_sources(
project_name=project_name,
candidates_from_page=functools.partial(
Expand Down Expand Up @@ -1076,6 +1097,16 @@ def _should_install_candidate(
)
raise BestVersionAlreadyInstalled

def add_locked_link(self, project_name: NormalizedName, locked_link: Link) -> None:
assert not self._all_candidates
if project_name in self._locked_links:
raise InstallationError(
f"Multiple locked links provided for {project_name}: "
f"{self._locked_links[project_name]} and {locked_link}"
)

self._locked_links[project_name] = locked_link


def _find_name_version_sep(fragment: str, canonical_name: str) -> int:
"""Find the separator's index based on the package's canonical name.
Expand Down
6 changes: 5 additions & 1 deletion src/pip/_internal/models/candidate.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ class InstallationCandidate:
name: str
version: Version
link: Link
locked: bool

def __init__(self, name: str, version: str, link: Link) -> None:
def __init__(
self, name: str, version: str, link: Link, locked: bool = False
) -> None:
object.__setattr__(self, "name", name)
object.__setattr__(self, "version", parse_version(version))
object.__setattr__(self, "link", link)
object.__setattr__(self, "locked", locked)

def __str__(self) -> str:
return f"{self.name!r} candidate (version {self.version} at {self.link})"
90 changes: 42 additions & 48 deletions src/pip/_internal/req/constructors.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
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 Down Expand Up @@ -585,65 +584,57 @@ def install_req_from_pylock_package(
| pylock.PackageWheel
),
pylock_path_or_url: str,
format_control: FormatControl,
user_supplied: bool,
) -> InstallRequirement:
pass
) -> tuple[InstallRequirement, Link | None]:
"""Construct an InstallRequirement from a pylock package and artifact.

If the artifact is a Sdist or Wheel, also return a locked Link which
is meant to override candidates from indexes or --find-links.
"""
# 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)}"
req_url = package_vcs_requirement_url(pylock_path_or_url, package_dist)
return (
InstallRequirement(
req=Requirement(f"{package.name} @ {req_url}"),
comes_from=pylock_path_or_url,
user_supplied=user_supplied,
),
comes_from=pylock_path_or_url,
user_supplied=user_supplied,
None,
)
elif isinstance(package_dist, pylock.PackageArchive):
return InstallRequirement(
req=Requirement(
f"{package.name} @ "
f"{package_archive_requirement_url(pylock_path_or_url, package_dist)}"
req_url = package_archive_requirement_url(pylock_path_or_url, package_dist)
return (
InstallRequirement(
req=Requirement(f"{package.name} @ {req_url}"),
comes_from=pylock_path_or_url,
hash_options=_pylock_hashes_to_hash_options(package_dist.hashes),
user_supplied=user_supplied,
),
comes_from=pylock_path_or_url,
hash_options=_pylock_hashes_to_hash_options(package_dist.hashes),
user_supplied=user_supplied,
None,
)
elif isinstance(package_dist, pylock.PackageDirectory):
req = package_directory_requirement_url(pylock_path_or_url, package_dist)
req_url = 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,
return (
install_req_from_editable(
req_url,
comes_from=pylock_path_or_url,
user_supplied=user_supplied,
),
None,
)
else:
return install_req_from_line(
req,
comes_from=pylock_path_or_url,
user_supplied=user_supplied,
return (
install_req_from_line(
req_url,
comes_from=pylock_path_or_url,
user_supplied=user_supplied,
),
None,
)
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:
Expand All @@ -660,9 +651,12 @@ def install_req_from_pylock_package(
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
locked_link = Link(
requirement_url,
comes_from=pylock_path_or_url,
upload_time=package_dist.upload_time,
)
return ireq, locked_link
10 changes: 0 additions & 10 deletions src/pip/_internal/req/req_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,6 @@ def __init__(
constraint: bool = False,
extras: Collection[str] = (),
user_supplied: 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 @@ -106,14 +104,6 @@ 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
79 changes: 33 additions & 46 deletions src/pip/_internal/resolution/resolvelib/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
)
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 @@ -243,31 +242,6 @@ 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 @@ -335,20 +309,12 @@ def _get_installed_candidate() -> Candidate | None:
return candidate

def iter_index_candidate_infos() -> Iterator[IndexCandidateInfo]:
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
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 @@ -757,19 +723,40 @@ def _report_single_requirement_conflict(

# Check if only final releases are allowed for this package
version_type = "version"
allows_pre = None
if self._finder.release_control is not None:
allows_pre = self._finder.release_control.allows_prereleases(
canonicalize_name(req.project_name)
)
if allows_pre is False:
version_type = "final version"

logger.critical(
"Could not find a %s that satisfies the requirement %s (from versions: %s)",
version_type,
req_disp,
", ".join(versions) or "none",
)
if len(cands) == 1 and cands[0].locked:
# The package finder ensures that requirements from pylock files
# have exactly one candidate. So we can provide a specific error
# message in this case.
if cands[0].version.is_prerelease and allows_pre is False:
logger.critical(
"A pre-release version %s is specified in a provided lock file "
"for %s but only final versions are allowed",
cands[0].version,
cands[0].name,
)
else:
logger.critical(
"The requirement %s is not compatible with "
"version %s specified in a provided lock file",
req_disp,
", ".join(versions),
)
else:
logger.critical(
"Could not find a %s that satisfies the requirement %s "
"(from versions: %s)",
version_type,
req_disp,
", ".join(versions) or "none",
)
if str(req) == "requirements.txt":
logger.info(
"HINT: You are attempting to install a package literally "
Expand Down
2 changes: 2 additions & 0 deletions src/pip/_internal/utils/pylock.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ def select_from_pylock_path_or_url(
) from exc

try:
# TODO: for completeness, pylock.select should support preferring sdist
# over wheels to support --no-binary
yield from lock.select()
except Exception as exc:
raise InstallationError(
Expand Down
8 changes: 8 additions & 0 deletions tests/data/lockfiles/pylock.certifi-with-upload_time.toml
Comment thread
sbidoul marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
lock-version = "1.0"
created-by = "uv"

[[packages]]
name = "certifi"
version = "2026.6.17"
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", upload-time = 2026-06-17T10:31:07Z, size = 134594, hashes = { sha256 = "024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432" } }
wheels = [{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", upload-time = 2026-06-17T10:31:06Z, size = 133289, hashes = { sha256 = "2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db" } }]
8 changes: 8 additions & 0 deletions tests/data/lockfiles/pylock.certifi-without-upload_time.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
lock-version = "1.0"
created-by = "uv"

[[packages]]
name = "certifi"
version = "2026.6.17"
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", size = 134594, hashes = { sha256 = "024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432" } }
wheels = [{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", size = 133289, hashes = { sha256 = "2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db" } }]
Loading
Loading