diff --git a/news/13755.feature.rst b/news/13755.feature.rst new file mode 100644 index 00000000000..c56b7e5948a --- /dev/null +++ b/news/13755.feature.rst @@ -0,0 +1 @@ +Improved yanking behavior to properly distinguish between file-level and release-level yanking per PEP 592. diff --git a/src/pip/_internal/resolution/resolvelib/factory.py b/src/pip/_internal/resolution/resolvelib/factory.py index ede3e6b2b94..bc566f63e93 100644 --- a/src/pip/_internal/resolution/resolvelib/factory.py +++ b/src/pip/_internal/resolution/resolvelib/factory.py @@ -31,6 +31,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 @@ -304,10 +305,24 @@ def iter_index_candidate_infos() -> Iterator[IndexCandidateInfo]: ) icans = result.applicable_candidates - # PEP 592: Yanked releases are ignored unless the specifier - # explicitly pins a version (via '==' or '===') that can be - # solely satisfied by a yanked release. - all_yanked = all(ican.link.is_yanked for ican in icans) + # PEP 592: Distinguish between file-level and release-level yanking. + # - File-level yanking: Individual files are yanked, but not all files + # for that version. These files should be excluded, but non-yanked + # files from the same version can be selected. + # - Release-level yanking: All installable files for a version are + # yanked. The version is considered "yanked" and can only be selected + # if the specifier explicitly pins to that version (== or ===). + + # Group candidates by version to determine release-level yanking + version_to_candidates: dict[Version, list[InstallationCandidate]] = {} + for ican in icans: + version_to_candidates.setdefault(ican.version, []).append(ican) + + # Determine which versions are fully yanked (release-level yanking) + yanked_versions: set[Version] = set() + for version, version_icans in version_to_candidates.items(): + if all(ic.link.is_yanked for ic in version_icans): + yanked_versions.add(version) def is_pinned(specifier: SpecifierSet) -> bool: for sp in specifier: @@ -322,10 +337,31 @@ def is_pinned(specifier: SpecifierSet) -> bool: pinned = is_pinned(specifier) + # Check if ALL versions are yanked (entire package is yanked) + all_versions_yanked = bool(icans) and all( + v in yanked_versions for v in version_to_candidates + ) + # PackageFinder returns earlier versions first, so we reverse. for ican in reversed(icans): - if not (all_yanked and pinned) and ican.link.is_yanked: - continue + # Determine if this candidate should be included + version_is_yanked = ican.version in yanked_versions + file_is_yanked = ican.link.is_yanked + + if file_is_yanked: + # For release-level yanking: allow if pinned and all versions + # are yanked, OR if pinned and this specific version is yanked + if version_is_yanked and pinned: + # Release-level yanking with pinned specifier - allow + pass + elif all_versions_yanked and pinned: + # All versions yanked and pinned - allow + pass + else: + # File-level yanking or unpinned release-level yanking + # Skip this yanked file + continue + func = functools.partial( self._make_candidate_from_link, link=ican.link, @@ -706,8 +742,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", diff --git a/tests/data/indexes/partial_yanked/simple/index.html b/tests/data/indexes/partial_yanked/simple/index.html new file mode 100644 index 00000000000..2c957747fc2 --- /dev/null +++ b/tests/data/indexes/partial_yanked/simple/index.html @@ -0,0 +1,8 @@ + + + + + partial + + + diff --git a/tests/data/indexes/partial_yanked/simple/partial/index.html b/tests/data/indexes/partial_yanked/simple/partial/index.html new file mode 100644 index 00000000000..efc092f6456 --- /dev/null +++ b/tests/data/indexes/partial_yanked/simple/partial/index.html @@ -0,0 +1,18 @@ + + + + + + partial-1.0-py3-none-any.whl + partial-1.0.tar.gz + + + partial-2.0-py3-none-any.whl + partial-2.0.tar.gz + + + partial-3.0-py3-none-any.whl + partial-3.0.tar.gz + + + diff --git a/tests/unit/test_yanking.py b/tests/unit/test_yanking.py new file mode 100644 index 00000000000..bedc5d15134 --- /dev/null +++ b/tests/unit/test_yanking.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from pip._vendor.packaging.specifiers import SpecifierSet +from pip._vendor.packaging.version import Version + +from pip._internal.models.candidate import InstallationCandidate +from pip._internal.models.link import Link + + +def make_test_link( + filename: str, version: str, yanked_reason: str | None = None +) -> Link: + """Create a test Link object.""" + return Link( + url=f"https://example.com/packages/{filename}", + yanked_reason=yanked_reason, + ) + + +def make_test_candidate( + name: str, version: str, filename: str, yanked_reason: str | None = None +) -> InstallationCandidate: + """Create a test InstallationCandidate.""" + link = make_test_link(filename, version, yanked_reason) + return InstallationCandidate(name=name, version=version, link=link) + + +class TestFileLevelYanking: + """Test file-level yanking: some files for a version are yanked.""" + + def test_file_level_yanking_excludes_yanked_file(self) -> None: + """ + When only some files for a version are yanked (file-level yanking), + the yanked files should be excluded but non-yanked files should + still be available. + """ + # Version 1.0 has two files: wheel (not yanked) and tarball (yanked) + candidates = [ + make_test_candidate("example", "1.0", "example-1.0-py3-none-any.whl", None), + make_test_candidate("example", "1.0", "example-1.0.tar.gz", "bad tarball"), + ] + + # Group by version + version_to_candidates: dict[Version, list[InstallationCandidate]] = {} + for c in candidates: + version_to_candidates.setdefault(c.version, []).append(c) + + # Determine yanked versions (release-level) + yanked_versions: set[Version] = set() + for version, version_cands in version_to_candidates.items(): + if all(c.link.is_yanked for c in version_cands): + yanked_versions.add(version) + + # Version 1.0 should NOT be in yanked_versions (file-level yanking) + assert Version("1.0") not in yanked_versions + + # The wheel should still be available + available = [c for c in candidates if not c.link.is_yanked] + assert len(available) == 1 + assert available[0].link.filename == "example-1.0-py3-none-any.whl" + + +class TestReleaseLevelYanking: + """Test release-level yanking: all files for a version are yanked.""" + + def test_release_level_yanking_detects_yanked_version(self) -> None: + """ + When all files for a version are yanked (release-level yanking), + the version should be considered yanked. + """ + # Version 2.0 has all files yanked + candidates = [ + make_test_candidate( + "example", "2.0", "example-2.0-py3-none-any.whl", "security issue" + ), + make_test_candidate( + "example", "2.0", "example-2.0.tar.gz", "security issue" + ), + ] + + # Group by version + version_to_candidates: dict[Version, list[InstallationCandidate]] = {} + for c in candidates: + version_to_candidates.setdefault(c.version, []).append(c) + + # Determine yanked versions + yanked_versions: set[Version] = set() + for version, version_cands in version_to_candidates.items(): + if all(c.link.is_yanked for c in version_cands): + yanked_versions.add(version) + + # Version 2.0 SHOULD be in yanked_versions (release-level yanking) + assert Version("2.0") in yanked_versions + + def test_release_level_yanking_allows_pinned_version(self) -> None: + """ + A yanked release should be selectable if the specifier pins to + that exact version using == or ===. + """ + + def is_pinned(specifier: SpecifierSet) -> bool: + for sp in specifier: + if sp.operator == "===": + return True + if sp.operator != "==": + continue + if sp.version.endswith(".*"): + continue + return True + return False + + # These specifiers should be considered "pinned" + assert is_pinned(SpecifierSet("==2.0")) + assert is_pinned(SpecifierSet("===2.0")) + + # These specifiers should NOT be considered "pinned" + assert not is_pinned(SpecifierSet(">=2.0")) + assert not is_pinned(SpecifierSet("==2.*")) + assert not is_pinned(SpecifierSet("~=2.0")) + assert not is_pinned(SpecifierSet(">1.0,<3.0")) + + +class TestMixedYankingScenarios: + """Test scenarios with mixed file-level and release-level yanking.""" + + def test_mixed_yanking_version_selection(self) -> None: + """ + With multiple versions having different yanking states: + - Version 1.0: file-level yanking (one file yanked) + - Version 2.0: release-level yanking (all files yanked) + - Version 3.0: no yanking + + Without pinning, versions 1.0 and 3.0 should be available, + version 2.0 should be excluded. + """ + candidates = [ + # Version 1.0: file-level yanking + make_test_candidate("example", "1.0", "example-1.0-py3-none-any.whl", None), + make_test_candidate("example", "1.0", "example-1.0.tar.gz", "bad tarball"), + # Version 2.0: release-level yanking + make_test_candidate( + "example", "2.0", "example-2.0-py3-none-any.whl", "security" + ), + make_test_candidate("example", "2.0", "example-2.0.tar.gz", "security"), + # Version 3.0: no yanking + make_test_candidate("example", "3.0", "example-3.0-py3-none-any.whl", None), + make_test_candidate("example", "3.0", "example-3.0.tar.gz", None), + ] + + # Group by version + version_to_candidates: dict[Version, list[InstallationCandidate]] = {} + for c in candidates: + version_to_candidates.setdefault(c.version, []).append(c) + + # Determine yanked versions + yanked_versions: set[Version] = set() + for version, version_cands in version_to_candidates.items(): + if all(c.link.is_yanked for c in version_cands): + yanked_versions.add(version) + + # Check yanked versions + assert Version("1.0") not in yanked_versions # file-level, not release-level + assert Version("2.0") in yanked_versions # release-level + assert Version("3.0") not in yanked_versions # not yanked + + # Available candidates without pinning (release-level yanked excluded) + pinned = False + available = [] + for c in candidates: + version_is_yanked = c.version in yanked_versions + file_is_yanked = c.link.is_yanked + + if file_is_yanked: + if version_is_yanked and pinned: + available.append(c) + else: + continue # Skip yanked files + else: + available.append(c) + + # Should have: 1.0 wheel, 3.0 wheel, 3.0 tarball + assert len(available) == 3 + versions = {c.version for c in available} + assert Version("1.0") in versions + assert Version("2.0") not in versions # Excluded (release-level yanked) + assert Version("3.0") in versions + + def test_all_versions_yanked_allows_pinned(self) -> None: + """ + When ALL versions are yanked and the specifier is pinned, + the pinned yanked version should be selectable. + """ + candidates = [ + # Version 1.0: release-level yanking + make_test_candidate("example", "1.0", "example-1.0.tar.gz", "old version"), + # Version 2.0: release-level yanking + make_test_candidate("example", "2.0", "example-2.0.tar.gz", "has bug"), + ] + + # Group by version + version_to_candidates: dict[Version, list[InstallationCandidate]] = {} + for c in candidates: + version_to_candidates.setdefault(c.version, []).append(c) + + # Determine yanked versions + yanked_versions: set[Version] = set() + for version, version_cands in version_to_candidates.items(): + if all(c.link.is_yanked for c in version_cands): + yanked_versions.add(version) + + # Both versions are yanked + assert len(yanked_versions) == 2 + + # With pinned specifier ==2.0, version 2.0 should be allowed + all_versions_yanked = all(v in yanked_versions for v in version_to_candidates) + assert all_versions_yanked + + pinned = True # Simulating ==2.0 + available = [] + for c in candidates: + version_is_yanked = c.version in yanked_versions + file_is_yanked = c.link.is_yanked + + if file_is_yanked: + if version_is_yanked and pinned: + available.append(c) + elif all_versions_yanked and pinned: + available.append(c) + else: + continue + else: + available.append(c) + + # Both versions should be available when pinned and all are yanked + assert len(available) == 2