diff --git a/news/13950.feature.rst b/news/13950.feature.rst new file mode 100644 index 0000000000..0076860930 --- /dev/null +++ b/news/13950.feature.rst @@ -0,0 +1 @@ +Honor ``--only-final`` when sourcing requirements with ``-r pylock.toml``. diff --git a/news/13963.feature.rst b/news/13963.feature.rst new file mode 100644 index 0000000000..e91dfc4f75 --- /dev/null +++ b/news/13963.feature.rst @@ -0,0 +1 @@ +Better error messages in case of conflicts with requirements from ``-r pylock.toml``. diff --git a/news/14168.feature.rst b/news/14168.feature.rst new file mode 100644 index 0000000000..28ac0b64b7 --- /dev/null +++ b/news/14168.feature.rst @@ -0,0 +1 @@ +Add support for ``pylock.toml`` ``upload-time`` field, so ``--uploaded-prior-to`` works with ``-r pylock.toml``. diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index f09267f35b..f58a970613 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -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 diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py index df786a62b5..cf4a2eeb65 100644 --- a/src/pip/_internal/index/package_finder.py +++ b/src/pip/_internal/index/package_finder.py @@ -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 @@ -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 @@ -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( @@ -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. diff --git a/src/pip/_internal/models/candidate.py b/src/pip/_internal/models/candidate.py index 88ecc99258..0639466c4c 100644 --- a/src/pip/_internal/models/candidate.py +++ b/src/pip/_internal/models/candidate.py @@ -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})" diff --git a/src/pip/_internal/req/constructors.py b/src/pip/_internal/req/constructors.py index 6dcac66737..49158942b7 100644 --- a/src/pip/_internal/req/constructors.py +++ b/src/pip/_internal/req/constructors.py @@ -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 @@ -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: @@ -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 diff --git a/src/pip/_internal/req/req_install.py b/src/pip/_internal/req/req_install.py index 7191f03a1a..f20707f3d2 100644 --- a/src/pip/_internal/req/req_install.py +++ b/src/pip/_internal/req/req_install.py @@ -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 @@ -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. diff --git a/src/pip/_internal/resolution/resolvelib/factory.py b/src/pip/_internal/resolution/resolvelib/factory.py index ff1dab54c3..c9fb839d4a 100644 --- a/src/pip/_internal/resolution/resolvelib/factory.py +++ b/src/pip/_internal/resolution/resolvelib/factory.py @@ -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 @@ -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], @@ -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 @@ -757,6 +723,7 @@ 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) @@ -764,12 +731,32 @@ def _report_single_requirement_conflict( 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 " diff --git a/src/pip/_internal/utils/pylock.py b/src/pip/_internal/utils/pylock.py index cd6c455e05..70df98693b 100644 --- a/src/pip/_internal/utils/pylock.py +++ b/src/pip/_internal/utils/pylock.py @@ -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( diff --git a/tests/data/lockfiles/pylock.certifi-with-upload_time.toml b/tests/data/lockfiles/pylock.certifi-with-upload_time.toml new file mode 100644 index 0000000000..eba72c83d9 --- /dev/null +++ b/tests/data/lockfiles/pylock.certifi-with-upload_time.toml @@ -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" } }] diff --git a/tests/data/lockfiles/pylock.certifi-without-upload_time.toml b/tests/data/lockfiles/pylock.certifi-without-upload_time.toml new file mode 100644 index 0000000000..f19c35c88f --- /dev/null +++ b/tests/data/lockfiles/pylock.certifi-without-upload_time.toml @@ -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" } }] diff --git a/tests/data/lockfiles/pylock.prerelease-archive.toml b/tests/data/lockfiles/pylock.prerelease-archive.toml new file mode 100644 index 0000000000..fa11f5cffd --- /dev/null +++ b/tests/data/lockfiles/pylock.prerelease-archive.toml @@ -0,0 +1,11 @@ +lock-version = "1.0" +created-by = "pip" + +[[packages]] +name = "pkg-prerelease" + +[packages.archive] +path = "../packages/pkg_prerelease-1.0a1-py3-none-any.whl" + +[packages.archive.hashes] +sha256 = "5b170526e52d17d1b2e0162c006142e792293a7975a1f51df39035f1b14768d5" diff --git a/tests/data/lockfiles/pylock.prerelease-wheel.toml b/tests/data/lockfiles/pylock.prerelease-wheel.toml new file mode 100644 index 0000000000..d048720bce --- /dev/null +++ b/tests/data/lockfiles/pylock.prerelease-wheel.toml @@ -0,0 +1,13 @@ +lock-version = "1.0" +created-by = "pip" + +[[packages]] +name = "pkg-prerelease" +version = "1.0a1" + +[[packages.wheels]] +name = "pkg_prerelease-1.0a1-py3-none-any.whl" +path = "../packages/pkg_prerelease-1.0a1-py3-none-any.whl" + +[packages.wheels.hashes] +sha256 = "5b170526e52d17d1b2e0162c006142e792293a7975a1f51df39035f1b14768d5" diff --git a/tests/data/packages/pkg_prerelease-1.0a1-py3-none-any.whl b/tests/data/packages/pkg_prerelease-1.0a1-py3-none-any.whl new file mode 100644 index 0000000000..424f2d7a4f Binary files /dev/null and b/tests/data/packages/pkg_prerelease-1.0a1-py3-none-any.whl differ diff --git a/tests/functional/test_install_pylock_reqs.py b/tests/functional/test_install_pylock_reqs.py index 20ec058546..1f94754cd5 100644 --- a/tests/functional/test_install_pylock_reqs.py +++ b/tests/functional/test_install_pylock_reqs.py @@ -222,10 +222,8 @@ def test_install_pylock_no_binary( "--no-binary=simplewheel", expect_error=True, ) - assert ( - "binaries are not permitted for package 'simplewheel' and " - "there is no source distribution for it in" in result.stderr - ) + assert "Could not install locked package 'simplewheel'" in result.stderr + assert "No binaries permitted for simplewheel" in result.stderr def test_install_pylock_only_binary( @@ -242,10 +240,8 @@ def test_install_pylock_only_binary( "--only-binary=:all:", expect_error=True, ) - assert ( - "source distributions are not permitted for package 'simple' and " - "there is no compatible wheel for it in" in result.stderr - ) + assert "Could not install locked package 'simple'" in result.stderr + assert "No sources permitted for simple" in result.stderr def test_install_pylock_only_binary_ignored_for_archives( @@ -267,3 +263,121 @@ def test_install_pylock_only_binary_ignored_for_archives( ) assert "experimental" in result.stderr assert "Would install simple2-3.0" in result.stdout + + +def test_install_pylock_default_prerelease( + script: PipTestEnvironment, + shared_data: TestData, +) -> None: + """Prereleases are allowed by default.""" + pylock_path = shared_data.lockfiles.joinpath("pylock.prerelease-wheel.toml") + result = script.pip( + "install", + "--no-index", + "--dry-run", + "-r", + pylock_path, + allow_stderr_warning=True, + ) + assert "experimental" in result.stderr + assert "Would install pkg-prerelease-1.0a1" in result.stdout + + +def test_install_pylock_reject_prerelease( + script: PipTestEnvironment, + shared_data: TestData, +) -> None: + """Prereleases are rejected when --only-final is set.""" + pylock_path = shared_data.lockfiles.joinpath("pylock.prerelease-wheel.toml") + result = script.pip( + "install", + "--no-index", + "--dry-run", + "-r", + pylock_path, + "--only-final=pkg-prerelease", + expect_error=True, + ) + assert ( + "A pre-release version 1.0a1 is specified in a provided lock file " + "for pkg-prerelease but only final versions are allowed" in result.stderr + ) + + +def test_install_pylock_allow_archive_prerelease( + script: PipTestEnvironment, + shared_data: TestData, +) -> None: + """--only-final does not influence direct URL requirements.""" + pylock_path = shared_data.lockfiles.joinpath("pylock.prerelease-archive.toml") + result = script.pip( + "install", + "--no-index", + "--dry-run", + "-r", + pylock_path, + "--only-final=pkg-prerelease", + allow_stderr_warning=True, + ) + assert "experimental" in result.stderr + assert "Would install pkg-prerelease-1.0a1" in result.stdout + + +def test_install_pylock_conflict( + script: PipTestEnvironment, + data: TestData, +) -> None: + pylock_path = data.lockfiles.joinpath("pylock.onewheel.toml") + result = script.pip( + "install", + "--no-index", + "--dry-run", + "-r", + pylock_path, + "simplewheel<2", # conflict with simplewheel==2.0 in lock file + expect_error=True, + ) + assert ( + "The requirement simplewheel<2 is not compatible with version 2.0 specified " + "in a provided lock file" in result.stderr + ) + + +def test_install_pylock_uploaded_prior_to( + script: PipTestEnvironment, + data: TestData, +) -> None: + pylock_path = data.lockfiles.joinpath("pylock.certifi-with-upload_time.toml") + result = script.pip( + "install", + "--no-index", + "--dry-run", + "-r", + pylock_path, + "--uploaded-prior-to=2026-01-01", + expect_error=True, + ) + assert "Could not install locked package 'certifi' from " in result.stderr + assert ( + "Upload time 2026-06-17 10:31:06+00:00 not prior to 2026-01-01" in result.stderr + ) + + +def test_install_pylock_uploaded_prior_to_missing_upload_time( + script: PipTestEnvironment, + data: TestData, +) -> None: + pylock_path = data.lockfiles.joinpath("pylock.certifi-without-upload_time.toml") + result = script.pip( + "install", + "--no-index", + "--dry-run", + "-r", + pylock_path, + "--uploaded-prior-to=2026-01-01", + expect_error=True, + ) + assert ( + "pylock.certifi-without-upload_time.toml does not provide upload-time metadata" + in result.stderr + )