Skip to content
1 change: 1 addition & 0 deletions news/13755.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improved yanking behavior to properly distinguish between file-level and release-level yanking per PEP 592.
51 changes: 43 additions & 8 deletions src/pip/_internal/resolution/resolvelib/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions tests/data/indexes/partial_yanked/simple/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!doctype html>
<html>

<body>
<a href="partial/">partial</a>
</body>

</html>
18 changes: 18 additions & 0 deletions tests/data/indexes/partial_yanked/simple/partial/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html>

<body>
<!-- Version 1.0: One file yanked, one not (file-level yanking) -->
<a href="../../../packages/partial-1.0-py3-none-any.whl">partial-1.0-py3-none-any.whl</a>
<a data-yanked="bad wheel" href="../../../packages/partial-1.0.tar.gz">partial-1.0.tar.gz</a>

<!-- Version 2.0: All files yanked (release-level yanking) -->
<a data-yanked="security issue" href="../../../packages/partial-2.0-py3-none-any.whl">partial-2.0-py3-none-any.whl</a>
<a data-yanked="security issue" href="../../../packages/partial-2.0.tar.gz">partial-2.0.tar.gz</a>

<!-- Version 3.0: No files yanked -->
<a href="../../../packages/partial-3.0-py3-none-any.whl">partial-3.0-py3-none-any.whl</a>
<a href="../../../packages/partial-3.0.tar.gz">partial-3.0.tar.gz</a>
</body>

</html>
235 changes: 235 additions & 0 deletions tests/unit/test_yanking.py
Original file line number Diff line number Diff line change
@@ -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