Skip to content

Commit 1b1828a

Browse files
FIX Respect yanked flag (#208)
* Respect yanked flag * changelog * fix generator * Update CHANGELOG.md Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Address comments * [integration] --------- Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com>
1 parent a0a5b54 commit 1b1828a

7 files changed

+131
-10
lines changed

CHANGELOG.md

+7
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## Unreleased
8+
9+
### Fixed
10+
11+
- micropip now respects the `yanked` flag in the PyPI Simple API.
12+
[#208](https://github.com/pyodide/micropip/pull/208)
13+
714
## [0.9.0] - 2024/02/01
815

916
### Fixed

micropip/constants.py

+7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
FAQ_URLS = {
22
"cant_find_wheel": "https://pyodide.org/en/stable/usage/faq.html#why-can-t-micropip-find-a-pure-python-wheel-for-a-package"
33
}
4+
5+
# https://github.com/pypa/pip/blob/de44d991024ca8a03e9433ca6178f9a5f661754f/src/pip/_internal/resolution/resolvelib/resolver.py#L164-L167
6+
YANKED_WARNING_MESSAGE = (
7+
"The candidate selected for download or install is a "
8+
"yanked version: '%s' candidate (version %s "
9+
"at %s)\nReason for being yanked: %s"
10+
)

micropip/package_index.py

+6
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,11 @@ def _compatible_wheels(
163163
# Size of the file in bytes, if available (PEP 700)
164164
# This key is not available in the Simple API HTML response, so this field may be None
165165
size = file.get("size")
166+
167+
# PEP-592:
168+
# yanked can be an arbitrary string (reason) or bool.
169+
yanked_reason = file.get("yanked", False)
170+
166171
yield WheelInfo.from_package_index(
167172
name=name,
168173
filename=filename,
@@ -171,6 +176,7 @@ def _compatible_wheels(
171176
sha256=sha256,
172177
size=size,
173178
core_metadata=core_metadata,
179+
yanked_reason=yanked_reason,
174180
)
175181

176182
@classmethod

micropip/transaction.py

+44-10
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import importlib.metadata
33
import logging
44
import warnings
5+
from collections.abc import Iterable
56
from dataclasses import dataclass, field
67
from importlib.metadata import PackageNotFoundError
78
from urllib.parse import urlparse
@@ -19,7 +20,7 @@
1920
Requirement,
2021
)
2122
from ._vendored.packaging.src.packaging.utils import canonicalize_name
22-
from .constants import FAQ_URLS
23+
from .constants import FAQ_URLS, YANKED_WARNING_MESSAGE
2324
from .package import PackageMetadata
2425
from .package_index import ProjectInfo
2526
from .wheelinfo import WheelInfo
@@ -238,6 +239,16 @@ async def _add_requirement_from_package_index(self, req: Requirement):
238239

239240
logger.debug("Transaction: Selected wheel: %r", wheel)
240241

242+
if wheel.yanked:
243+
yanked_reason = wheel.yanked_reason if wheel.yanked_reason else "None"
244+
logger.info(
245+
YANKED_WARNING_MESSAGE,
246+
wheel.name,
247+
str(wheel.version),
248+
wheel.url,
249+
yanked_reason,
250+
)
251+
241252
# Maybe while we were downloading pypi_json some other branch
242253
# installed the wheel?
243254
satisfied, ver = self.check_version_satisfied(req)
@@ -327,6 +338,8 @@ def find_wheel(metadata: ProjectInfo, req: Requirement) -> WheelInfo:
327338
reverse=True,
328339
)
329340

341+
yanked_versions: list[list[WheelInfo]] = []
342+
330343
for ver in candidate_versions:
331344
if ver not in releases:
332345
warnings.warn(
@@ -335,22 +348,43 @@ def find_wheel(metadata: ProjectInfo, req: Requirement) -> WheelInfo:
335348
)
336349
continue
337350

338-
best_wheel = None
339-
best_tag_index = float("infinity")
351+
wheels = list(releases[ver])
340352

341-
wheels = releases[ver]
342-
for wheel in wheels:
343-
tag_index = best_compatible_tag_index(wheel.tags)
344-
if tag_index is not None and tag_index < best_tag_index:
345-
best_wheel = wheel
346-
best_tag_index = tag_index
353+
# If the version is yanked, put it in the end of the candidate list.
354+
# If we can't find a wheel that satisfies the requirement,
355+
# install the yanked version as a last resort.
356+
# when the version is yanked, all wheels are yanked, so we can check only the first wheel.
357+
yanked = wheels and wheels[0].yanked
358+
if yanked:
359+
yanked_versions.append(wheels)
360+
continue
361+
362+
best_wheel = _find_best_wheel(wheels)
347363

348364
if best_wheel is not None:
349-
return wheel
365+
return best_wheel
366+
367+
for wheels in yanked_versions:
368+
best_wheel = _find_best_wheel(wheels)
369+
370+
if best_wheel is not None:
371+
return best_wheel
350372

351373
raise ValueError(
352374
f"Can't find a pure Python 3 wheel for '{req}'.\n"
353375
f"See: {FAQ_URLS['cant_find_wheel']}\n"
354376
"You can use `await micropip.install(..., keep_going=True)` "
355377
"to get a list of all packages with missing wheels."
356378
)
379+
380+
381+
def _find_best_wheel(wheels: Iterable[WheelInfo]) -> WheelInfo | None:
382+
best_wheel = None
383+
best_tag_index = float("infinity")
384+
for wheel in wheels:
385+
tag_index = best_compatible_tag_index(wheel.tags)
386+
if tag_index is not None and tag_index < best_tag_index:
387+
best_wheel = wheel
388+
best_tag_index = tag_index
389+
390+
return best_wheel

micropip/wheelinfo.py

+6
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ class WheelInfo:
4444
sha256: str | None = None
4545
size: int | None = None # Size in bytes, if available (PEP 700)
4646
core_metadata: DistributionMetadata = None # Wheel's metadata (PEP 658 / PEP-714)
47+
yanked_reason: str | bool = (
48+
False # Whether the wheel has been yanked and the reason (if given) (PEP-592)
49+
)
4750

4851
# Fields below are only available after downloading the wheel, i.e. after calling `download()`.
4952

@@ -61,6 +64,7 @@ def __post_init__(self):
6164
), self.url
6265
self._project_name = safe_name(self.name)
6366
self.metadata_url = self.url + ".metadata"
67+
self.yanked = bool(self.yanked_reason)
6468

6569
@classmethod
6670
def from_url(cls, url: str) -> "WheelInfo":
@@ -100,6 +104,7 @@ def from_package_index(
100104
sha256: str | None,
101105
size: int | None,
102106
core_metadata: DistributionMetadata = None,
107+
yanked_reason: str | bool = False,
103108
) -> "WheelInfo":
104109
"""Extract available metadata from response received from package index"""
105110
parsed_url = urlparse(url)
@@ -116,6 +121,7 @@ def from_package_index(
116121
sha256=sha256,
117122
size=size,
118123
core_metadata=core_metadata,
124+
yanked_reason=yanked_reason,
119125
)
120126

121127
async def install(self, target: Path) -> None:

tests/integration/test_integration.py

+20
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,26 @@ async def _run(selenium):
5252
_run(selenium_standalone_micropip)
5353

5454

55+
@integration_test_only
56+
def test_integration_install_yanked(selenium_standalone_micropip, pytestconfig):
57+
@run_in_pyodide
58+
async def _run(selenium):
59+
import contextlib
60+
import io
61+
62+
import micropip
63+
64+
with io.StringIO() as buf, contextlib.redirect_stdout(buf):
65+
# install yanked version
66+
await micropip.install("black==21.11b0", verbose=True)
67+
68+
captured = buf.getvalue()
69+
assert "The candidate selected for download or install is a" in captured
70+
assert "'black' candidate (version 21.11b0" in captured
71+
72+
_run(selenium_standalone_micropip)
73+
74+
5575
@integration_test_only
5676
def test_integration_list_basic(selenium_standalone_micropip, pytestconfig):
5777
@run_in_pyodide

tests/test_transaction.py

+41
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,47 @@ def test_find_wheel_invalid_version():
215215
assert str(wheel.version) == "0.15.5"
216216

217217

218+
def test_yanked_version():
219+
from micropip._vendored.packaging.src.packaging.requirements import Requirement
220+
from micropip.transaction import find_wheel
221+
222+
versions = ["0.0.1", "0.15.5", "0.9.1"]
223+
224+
# Mark 0.15.5 as yanked
225+
# convert generator --> list and monkeypatch the yanked value
226+
metadata = _pypi_metadata("dummy_module", {v: ["py3"] for v in versions})
227+
for version in list(metadata.releases):
228+
wheels = list(metadata.releases[version])
229+
for wheel in wheels:
230+
if str(wheel.version) == "0.15.5":
231+
wheel.yanked = True
232+
233+
metadata.releases[version] = wheels
234+
235+
# case 1: yanked version should be skipped and the next best version should be selected
236+
237+
requirement1 = Requirement("dummy_module")
238+
wheel = find_wheel(metadata, requirement1)
239+
240+
assert str(wheel.version) == "0.9.1"
241+
242+
# case 2: yanked version is explicitly requested, so it should be selected
243+
244+
requirement2 = Requirement("dummy_module==0.15.5")
245+
wheel = find_wheel(metadata, requirement2)
246+
247+
assert str(wheel.version) == "0.15.5"
248+
249+
# case 3: yanked version is not explicitly requested, but it is the only version available
250+
# so it should be selected
251+
252+
requirement3 = Requirement("dummy_module>0.10.0")
253+
254+
wheel = find_wheel(metadata, requirement3)
255+
256+
assert str(wheel.version) == "0.15.5"
257+
258+
218259
_best_tag_test_cases = (
219260
"package, version, incompatible_tags, compatible_tags",
220261
# Tests assume that `compatible_tags` is sorted from least to most compatible:

0 commit comments

Comments
 (0)