Skip to content
10 changes: 10 additions & 0 deletions news/13755.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Improved yanking behavior to properly distinguish between file-level and
release-level yanking per PEP 592:

- **File-level yanking**: When individual files are yanked but not all files
for a version, only those specific yanked files are excluded. Other
non-yanked files from the same version can still be selected.

- **Release-level yanking**: When all files for a version are yanked, the
version is considered "yanked" and the pinned version exception (``==`` or
``===``) applies, allowing it to be installed only when explicitly pinned.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This news item is way too long, keep it to one or two short sentances.

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