Skip to content

Commit 6b0292a

Browse files
committed
feat: Distinguish file-level vs release-level yanking (#13755)
1 parent 30dae2b commit 6b0292a

File tree

5 files changed

+345
-6
lines changed

5 files changed

+345
-6
lines changed

news/13755.feature.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Improved yanking behavior to properly distinguish between file-level and
2+
release-level yanking per PEP 592:
3+
4+
- **File-level yanking**: When individual files are yanked but not all files
5+
for a version, only those specific yanked files are excluded. Other
6+
non-yanked files from the same version can still be selected.
7+
8+
- **Release-level yanking**: When all files for a version are yanked, the
9+
version is considered "yanked" and the pinned version exception (``==`` or
10+
``===``) applies, allowing it to be installed only when explicitly pinned.

src/pip/_internal/resolution/resolvelib/factory.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
)
3232
from pip._internal.index.package_finder import PackageFinder
3333
from pip._internal.metadata import BaseDistribution, get_default_environment
34+
from pip._internal.models.candidate import InstallationCandidate
3435
from pip._internal.models.link import Link
36+
3537
from pip._internal.models.wheel import Wheel
3638
from pip._internal.operations.prepare import RequirementPreparer
3739
from pip._internal.req.constructors import (
@@ -304,10 +306,24 @@ def iter_index_candidate_infos() -> Iterator[IndexCandidateInfo]:
304306
)
305307
icans = result.applicable_candidates
306308

307-
# PEP 592: Yanked releases are ignored unless the specifier
308-
# explicitly pins a version (via '==' or '===') that can be
309-
# solely satisfied by a yanked release.
310-
all_yanked = all(ican.link.is_yanked for ican in icans)
309+
# PEP 592: Distinguish between file-level and release-level yanking.
310+
# - File-level yanking: Individual files are yanked, but not all files
311+
# for that version. These files should be excluded, but non-yanked
312+
# files from the same version can be selected.
313+
# - Release-level yanking: All installable files for a version are
314+
# yanked. The version is considered "yanked" and can only be selected
315+
# if the specifier explicitly pins to that version (== or ===).
316+
317+
# Group candidates by version to determine release-level yanking
318+
version_to_candidates: dict[Version, list[InstallationCandidate]] = {}
319+
for ican in icans:
320+
version_to_candidates.setdefault(ican.version, []).append(ican)
321+
322+
# Determine which versions are fully yanked (release-level yanking)
323+
yanked_versions: set[Version] = set()
324+
for version, version_icans in version_to_candidates.items():
325+
if all(ic.link.is_yanked for ic in version_icans):
326+
yanked_versions.add(version)
311327

312328
def is_pinned(specifier: SpecifierSet) -> bool:
313329
for sp in specifier:
@@ -322,10 +338,31 @@ def is_pinned(specifier: SpecifierSet) -> bool:
322338

323339
pinned = is_pinned(specifier)
324340

341+
# Check if ALL versions are yanked (entire package is yanked)
342+
all_versions_yanked = bool(icans) and all(
343+
v in yanked_versions for v in version_to_candidates
344+
)
345+
325346
# PackageFinder returns earlier versions first, so we reverse.
326347
for ican in reversed(icans):
327-
if not (all_yanked and pinned) and ican.link.is_yanked:
328-
continue
348+
# Determine if this candidate should be included
349+
version_is_yanked = ican.version in yanked_versions
350+
file_is_yanked = ican.link.is_yanked
351+
352+
if file_is_yanked:
353+
# For release-level yanking: allow if pinned and all versions
354+
# are yanked, OR if pinned and this specific version is yanked
355+
if version_is_yanked and pinned:
356+
# Release-level yanking with pinned specifier - allow
357+
pass
358+
elif all_versions_yanked and pinned:
359+
# All versions yanked and pinned - allow
360+
pass
361+
else:
362+
# File-level yanking or unpinned release-level yanking
363+
# Skip this yanked file
364+
continue
365+
329366
func = functools.partial(
330367
self._make_candidate_from_link,
331368
link=ican.link,
@@ -336,6 +373,7 @@ def is_pinned(specifier: SpecifierSet) -> bool:
336373
)
337374
yield ican.version, func
338375

376+
339377
return FoundCandidates(
340378
iter_index_candidate_infos,
341379
_get_installed_candidate(),
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<!doctype html>
2+
<html>
3+
4+
<body>
5+
<a href="partial/">partial</a>
6+
</body>
7+
8+
</html>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!doctype html>
2+
<html>
3+
<body>
4+
<!-- Version 1.0: One file yanked, one not (file-level yanking) -->
5+
<a href="../../../packages/partial-1.0-py3-none-any.whl">partial-1.0-py3-none-any.whl</a>
6+
<a data-yanked="bad wheel" href="../../../packages/partial-1.0.tar.gz">partial-1.0.tar.gz</a>
7+
8+
<!-- Version 2.0: All files yanked (release-level yanking) -->
9+
<a data-yanked="security issue" href="../../../packages/partial-2.0-py3-none-any.whl">partial-2.0-py3-none-any.whl</a>
10+
<a data-yanked="security issue" href="../../../packages/partial-2.0.tar.gz">partial-2.0.tar.gz</a>
11+
12+
<!-- Version 3.0: No files yanked -->
13+
<a href="../../../packages/partial-3.0-py3-none-any.whl">partial-3.0-py3-none-any.whl</a>
14+
<a href="../../../packages/partial-3.0.tar.gz">partial-3.0.tar.gz</a>
15+
</body>
16+
</html>

tests/unit/test_yanking.py

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
"""
2+
Tests for file-level vs release-level yanking behavior per PEP 592.
3+
4+
PEP 592 distinguishes between:
5+
1. File-level yanking: Individual files are yanked, but other files for
6+
the same version are still available and should be selectable.
7+
2. Release-level yanking: All files for a version are yanked. The version
8+
is considered "yanked" and can only be selected if pinned with == or ===.
9+
"""
10+
11+
from unittest.mock import MagicMock
12+
13+
import pytest
14+
from pip._vendor.packaging.specifiers import SpecifierSet
15+
from pip._vendor.packaging.version import Version
16+
17+
from pip._internal.index.package_finder import PackageFinder
18+
from pip._internal.models.candidate import InstallationCandidate
19+
from pip._internal.models.link import Link
20+
from pip._internal.models.selection_prefs import SelectionPreferences
21+
from pip._internal.models.target_python import TargetPython
22+
23+
24+
def make_test_link(
25+
filename: str, version: str, yanked_reason: str | None = None
26+
) -> Link:
27+
"""Create a test Link object."""
28+
return Link(
29+
url=f"https://example.com/packages/{filename}",
30+
yanked_reason=yanked_reason,
31+
)
32+
33+
34+
def make_test_candidate(
35+
name: str, version: str, filename: str, yanked_reason: str | None = None
36+
) -> InstallationCandidate:
37+
"""Create a test InstallationCandidate."""
38+
link = make_test_link(filename, version, yanked_reason)
39+
return InstallationCandidate(name=name, version=version, link=link)
40+
41+
42+
class TestFileLevelYanking:
43+
"""Test file-level yanking: some files for a version are yanked."""
44+
45+
def test_file_level_yanking_excludes_yanked_file(self) -> None:
46+
"""
47+
When only some files for a version are yanked (file-level yanking),
48+
the yanked files should be excluded but non-yanked files should
49+
still be available.
50+
"""
51+
# Version 1.0 has two files: wheel (not yanked) and tarball (yanked)
52+
candidates = [
53+
make_test_candidate(
54+
"example", "1.0", "example-1.0-py3-none-any.whl", None
55+
),
56+
make_test_candidate(
57+
"example", "1.0", "example-1.0.tar.gz", "bad tarball"
58+
),
59+
]
60+
61+
# Group by version
62+
version_to_candidates: dict[Version, list[InstallationCandidate]] = {}
63+
for c in candidates:
64+
version_to_candidates.setdefault(c.version, []).append(c)
65+
66+
# Determine yanked versions (release-level)
67+
yanked_versions: set[Version] = set()
68+
for version, version_cands in version_to_candidates.items():
69+
if all(c.link.is_yanked for c in version_cands):
70+
yanked_versions.add(version)
71+
72+
# Version 1.0 should NOT be in yanked_versions (file-level yanking)
73+
assert Version("1.0") not in yanked_versions
74+
75+
# The wheel should still be available
76+
available = [c for c in candidates if not c.link.is_yanked]
77+
assert len(available) == 1
78+
assert available[0].link.filename == "example-1.0-py3-none-any.whl"
79+
80+
81+
class TestReleaseLevelYanking:
82+
"""Test release-level yanking: all files for a version are yanked."""
83+
84+
def test_release_level_yanking_detects_yanked_version(self) -> None:
85+
"""
86+
When all files for a version are yanked (release-level yanking),
87+
the version should be considered yanked.
88+
"""
89+
# Version 2.0 has all files yanked
90+
candidates = [
91+
make_test_candidate(
92+
"example", "2.0", "example-2.0-py3-none-any.whl", "security issue"
93+
),
94+
make_test_candidate(
95+
"example", "2.0", "example-2.0.tar.gz", "security issue"
96+
),
97+
]
98+
99+
# Group by version
100+
version_to_candidates: dict[Version, list[InstallationCandidate]] = {}
101+
for c in candidates:
102+
version_to_candidates.setdefault(c.version, []).append(c)
103+
104+
# Determine yanked versions
105+
yanked_versions: set[Version] = set()
106+
for version, version_cands in version_to_candidates.items():
107+
if all(c.link.is_yanked for c in version_cands):
108+
yanked_versions.add(version)
109+
110+
# Version 2.0 SHOULD be in yanked_versions (release-level yanking)
111+
assert Version("2.0") in yanked_versions
112+
113+
def test_release_level_yanking_allows_pinned_version(self) -> None:
114+
"""
115+
A yanked release should be selectable if the specifier pins to
116+
that exact version using == or ===.
117+
"""
118+
119+
def is_pinned(specifier: SpecifierSet) -> bool:
120+
for sp in specifier:
121+
if sp.operator == "===":
122+
return True
123+
if sp.operator != "==":
124+
continue
125+
if sp.version.endswith(".*"):
126+
continue
127+
return True
128+
return False
129+
130+
# These specifiers should be considered "pinned"
131+
assert is_pinned(SpecifierSet("==2.0"))
132+
assert is_pinned(SpecifierSet("===2.0"))
133+
134+
# These specifiers should NOT be considered "pinned"
135+
assert not is_pinned(SpecifierSet(">=2.0"))
136+
assert not is_pinned(SpecifierSet("==2.*"))
137+
assert not is_pinned(SpecifierSet("~=2.0"))
138+
assert not is_pinned(SpecifierSet(">1.0,<3.0"))
139+
140+
141+
class TestMixedYankingScenarios:
142+
"""Test scenarios with mixed file-level and release-level yanking."""
143+
144+
def test_mixed_yanking_version_selection(self) -> None:
145+
"""
146+
With multiple versions having different yanking states:
147+
- Version 1.0: file-level yanking (one file yanked)
148+
- Version 2.0: release-level yanking (all files yanked)
149+
- Version 3.0: no yanking
150+
151+
Without pinning, versions 1.0 and 3.0 should be available,
152+
version 2.0 should be excluded.
153+
"""
154+
candidates = [
155+
# Version 1.0: file-level yanking
156+
make_test_candidate(
157+
"example", "1.0", "example-1.0-py3-none-any.whl", None
158+
),
159+
make_test_candidate(
160+
"example", "1.0", "example-1.0.tar.gz", "bad tarball"
161+
),
162+
# Version 2.0: release-level yanking
163+
make_test_candidate(
164+
"example", "2.0", "example-2.0-py3-none-any.whl", "security"
165+
),
166+
make_test_candidate(
167+
"example", "2.0", "example-2.0.tar.gz", "security"
168+
),
169+
# Version 3.0: no yanking
170+
make_test_candidate(
171+
"example", "3.0", "example-3.0-py3-none-any.whl", None
172+
),
173+
make_test_candidate(
174+
"example", "3.0", "example-3.0.tar.gz", None
175+
),
176+
]
177+
178+
# Group by version
179+
version_to_candidates: dict[Version, list[InstallationCandidate]] = {}
180+
for c in candidates:
181+
version_to_candidates.setdefault(c.version, []).append(c)
182+
183+
# Determine yanked versions
184+
yanked_versions: set[Version] = set()
185+
for version, version_cands in version_to_candidates.items():
186+
if all(c.link.is_yanked for c in version_cands):
187+
yanked_versions.add(version)
188+
189+
# Check yanked versions
190+
assert Version("1.0") not in yanked_versions # file-level, not release-level
191+
assert Version("2.0") in yanked_versions # release-level
192+
assert Version("3.0") not in yanked_versions # not yanked
193+
194+
# Available candidates without pinning (release-level yanked excluded)
195+
pinned = False
196+
available = []
197+
for c in candidates:
198+
version_is_yanked = c.version in yanked_versions
199+
file_is_yanked = c.link.is_yanked
200+
201+
if file_is_yanked:
202+
if version_is_yanked and pinned:
203+
available.append(c)
204+
else:
205+
continue # Skip yanked files
206+
else:
207+
available.append(c)
208+
209+
# Should have: 1.0 wheel, 3.0 wheel, 3.0 tarball
210+
assert len(available) == 3
211+
versions = {c.version for c in available}
212+
assert Version("1.0") in versions
213+
assert Version("2.0") not in versions # Excluded (release-level yanked)
214+
assert Version("3.0") in versions
215+
216+
def test_all_versions_yanked_allows_pinned(self) -> None:
217+
"""
218+
When ALL versions are yanked and the specifier is pinned,
219+
the pinned yanked version should be selectable.
220+
"""
221+
candidates = [
222+
# Version 1.0: release-level yanking
223+
make_test_candidate(
224+
"example", "1.0", "example-1.0.tar.gz", "old version"
225+
),
226+
# Version 2.0: release-level yanking
227+
make_test_candidate(
228+
"example", "2.0", "example-2.0.tar.gz", "has bug"
229+
),
230+
]
231+
232+
# Group by version
233+
version_to_candidates: dict[Version, list[InstallationCandidate]] = {}
234+
for c in candidates:
235+
version_to_candidates.setdefault(c.version, []).append(c)
236+
237+
# Determine yanked versions
238+
yanked_versions: set[Version] = set()
239+
for version, version_cands in version_to_candidates.items():
240+
if all(c.link.is_yanked for c in version_cands):
241+
yanked_versions.add(version)
242+
243+
# Both versions are yanked
244+
assert len(yanked_versions) == 2
245+
246+
# With pinned specifier ==2.0, version 2.0 should be allowed
247+
all_versions_yanked = all(v in yanked_versions for v in version_to_candidates)
248+
assert all_versions_yanked
249+
250+
pinned = True # Simulating ==2.0
251+
available = []
252+
for c in candidates:
253+
version_is_yanked = c.version in yanked_versions
254+
file_is_yanked = c.link.is_yanked
255+
256+
if file_is_yanked:
257+
if version_is_yanked and pinned:
258+
available.append(c)
259+
elif all_versions_yanked and pinned:
260+
available.append(c)
261+
else:
262+
continue
263+
else:
264+
available.append(c)
265+
266+
# Both versions should be available when pinned and all are yanked
267+
assert len(available) == 2

0 commit comments

Comments
 (0)