Skip to content

Commit 11f61d2

Browse files
committed
fetch_string_and_headers compat: raise in and out of pyodide
Currently only the not_in_pyodide will raise on non-success, because this is the default behavior of urllib, the in_pyodide will not, so I added a raise_for_status. It is better to raise, as otherwise the package parser will potentially get proper URL and not manage to parse it, and decide there is no wheels, while we actually just got an error (404, or maybe 500). In addition wraps both case in a custom local HttpStatusError, so that we can actually catch these errors in the right places when we encounter them. Also add handling for PyPI 404 Now that warehouse set cors to 404, (pypi/warehouse#16339) we need to change the checked exceptions as there is no more network errors.
1 parent 7ac31c4 commit 11f61d2

9 files changed

Lines changed: 124 additions & 8 deletions

File tree

.github/workflows/main.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ jobs:
5151
shell: bash -l {0}
5252
run: |
5353
pytest -v \
54+
--durations=10 \
5455
--cov=micropip \
56+
--maxfail=5 \
5557
--dist-dir=./dist/ \
5658
--runner=${{ matrix.test-config.runner }} \
5759
--rt ${{ matrix.test-config.runtime }}

micropip/_compat/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434

3535
to_js = compatibility_layer.to_js
3636

37+
HttpStatusError = compatibility_layer.HttpStatusError
38+
3739

3840
__all__ = [
3941
"REPODATA_INFO",
@@ -45,4 +47,5 @@
4547
"loadPackage",
4648
"get_dynlibs",
4749
"to_js",
50+
"HttpStatusError",
4851
]

micropip/_compat/_compat_in_pyodide.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,21 @@
55
if TYPE_CHECKING:
66
pass
77

8+
import pyodide
9+
from packaging.version import parse
810
from pyodide._package_loader import get_dynlibs
911
from pyodide.ffi import IN_BROWSER, to_js
10-
from pyodide.http import pyfetch
12+
13+
if parse(pyodide.__version__) > parse("0.27"):
14+
from pyodide.http import HttpStatusError, pyfetch
15+
else:
16+
17+
class HttpStatusError(Exception): # type: ignore [no-redef]
18+
"""we just want this to be defined, it is never going to be raised"""
19+
20+
pass
21+
22+
from pyodide.http import pyfetch
1123

1224
from .compatibility_layer import CompatibilityLayer
1325

@@ -28,6 +40,15 @@
2840

2941

3042
class CompatibilityInPyodide(CompatibilityLayer):
43+
class HttpStatusError(Exception):
44+
status_code: int
45+
message: str
46+
47+
def __init__(self, status_code: int, message: str):
48+
self.status_code = status_code
49+
self.message = message
50+
super().__init__(message)
51+
3152
@staticmethod
3253
def repodata_info() -> dict[str, str]:
3354
return REPODATA_INFO
@@ -50,7 +71,11 @@ async def fetch_bytes(url: str, kwargs: dict[str, str]) -> bytes:
5071
async def fetch_string_and_headers(
5172
url: str, kwargs: dict[str, str]
5273
) -> tuple[str, dict[str, str]]:
53-
response = await pyfetch(url, **kwargs)
74+
try:
75+
response = await pyfetch(url, **kwargs)
76+
response.raise_for_status()
77+
except HttpStatusError as e:
78+
raise CompatibilityInPyodide.HttpStatusError(e.status, str(e)) from e
5479

5580
content = await response.string()
5681
headers: dict[str, str] = response.headers

micropip/_compat/_compat_not_in_pyodide.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import re
22
from pathlib import Path
33
from typing import IO, TYPE_CHECKING, Any
4+
from urllib.error import HTTPError
45
from urllib.request import Request, urlopen
56
from urllib.response import addinfourl
67

@@ -15,6 +16,15 @@ class CompatibilityNotInPyodide(CompatibilityLayer):
1516
# Vendored from packaging
1617
_canonicalize_regex = re.compile(r"[-_.]+")
1718

19+
class HttpStatusError(Exception):
20+
status_code: int
21+
message: str
22+
23+
def __init__(self, status_code: int, message: str):
24+
self.status_code = status_code
25+
self.message = message
26+
super().__init__(message)
27+
1828
class loadedPackages(CompatibilityLayer.loadedPackages):
1929
@staticmethod
2030
def to_py():
@@ -40,7 +50,11 @@ async def fetch_bytes(url: str, kwargs: dict[str, Any]) -> bytes:
4050
async def fetch_string_and_headers(
4151
url: str, kwargs: dict[str, Any]
4252
) -> tuple[str, dict[str, str]]:
43-
response = CompatibilityNotInPyodide._fetch(url, kwargs=kwargs)
53+
try:
54+
response = CompatibilityNotInPyodide._fetch(url, kwargs=kwargs)
55+
except HTTPError as e:
56+
raise CompatibilityNotInPyodide.HttpStatusError(e.code, str(e)) from e
57+
4458
headers = {k.lower(): v for k, v in response.headers.items()}
4559
return response.read().decode(), headers
4660

micropip/_compat/compatibility_layer.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ class CompatibilityLayer(ABC):
1313
All of the following methods / properties must be implemented for use both inside and outside of pyodide.
1414
"""
1515

16+
class HttpStatusError(ABC, Exception):
17+
status_code: int
18+
message: str
19+
20+
@abstractmethod
21+
def __init__(self, status_code: int, message: str):
22+
pass
23+
1624
class loadedPackages(ABC):
1725
@staticmethod
1826
@abstractmethod

micropip/package_index.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from packaging.utils import InvalidWheelFilename
1111
from packaging.version import InvalidVersion, Version
1212

13-
from ._compat import fetch_string_and_headers
13+
from ._compat import HttpStatusError, fetch_string_and_headers
1414
from ._utils import is_package_compatible, parse_version
1515
from .externals.mousebender.simple import from_project_details_html
1616
from .wheelinfo import WheelInfo
@@ -276,11 +276,33 @@ async def query_package(
276276

277277
try:
278278
metadata, headers = await fetch_string_and_headers(url, _fetch_kwargs)
279+
except HttpStatusError as e:
280+
if e.status_code == 404:
281+
continue
282+
raise
279283
except OSError:
280-
continue
284+
# temporary pyodide compatibility.
285+
# pypi now set proper CORS on 404 (https://github.com/pypi/warehouse/pull/16339),
286+
# but stable pyodide (<0.27) does not yet have proper HttpStatusError exception
287+
# so when: on pyodide and 0.26.x we ignore OSError. Once we drop support for 0.26
288+
# all this OSError except clause should just be gone.
289+
try:
290+
import pyodide
291+
from packaging.version import parse
292+
293+
if parse(pyodide.__version__) > parse("0.27"):
294+
# reraise on more recent pyodide.
295+
raise
296+
continue
297+
except ImportError:
298+
# not in pyodide.
299+
raise
281300

282301
content_type = headers.get("content-type", "").lower()
283-
parser = _select_parser(content_type, name)
302+
try:
303+
parser = _select_parser(content_type, name)
304+
except ValueError as e:
305+
raise ValueError(f"Error trying to decode url: {url}") from e
284306
return parser(metadata)
285307
else:
286308
raise ValueError(

tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ def mock_fetch(monkeypatch, mock_importlib):
340340
def _mock_package_index_gen(
341341
httpserver,
342342
pkgs=("black", "pytest", "numpy", "pytz", "snowballstemmer"),
343+
pkgs_not_found=(),
343344
content_type="application/json",
344345
suffix="_json.json.gz",
345346
):
@@ -355,6 +356,10 @@ def _mock_package_index_gen(
355356
content_type=content_type,
356357
headers={"Access-Control-Allow-Origin": "*"},
357358
)
359+
for pkg in pkgs_not_found:
360+
httpserver.expect_request(f"/{base}/{pkg}/").respond_with_data(
361+
"Not found", status=404, content_type="text/plain"
362+
)
358363

359364
index_url = httpserver.url_for(base)
360365

tests/test_compat.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""
2+
test that function in compati behave the same
3+
4+
"""
5+
6+
import pytest
7+
from pytest_pyodide import run_in_pyodide
8+
9+
10+
@pytest.mark.driver_timeout(10)
11+
def test_404(selenium_standalone_micropip, httpserver, request):
12+
selenium_standalone_micropip.set_script_timeout(11)
13+
14+
@run_in_pyodide(packages=["micropip", "packaging"])
15+
async def _inner_test_404_raise(selenium, url):
16+
import pyodide
17+
import pytest
18+
from packaging.version import parse
19+
20+
from micropip._compat import HttpStatusError, fetch_string_and_headers
21+
22+
if parse(pyodide.__version__) > parse("0.27"):
23+
ExpectedErrorClass = HttpStatusError
24+
else:
25+
ExpectedErrorClass = OSError
26+
27+
with pytest.raises(ExpectedErrorClass):
28+
await fetch_string_and_headers(url, {})
29+
30+
httpserver.expect_request("/404").respond_with_data(
31+
"Not found",
32+
status=404,
33+
content_type="text/plain",
34+
headers={"Access-Control-Allow-Origin": "*"},
35+
)
36+
url_404 = httpserver.url_for("/404")
37+
_inner_test_404_raise(selenium_standalone_micropip, url_404)

tests/test_package_index.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,8 @@ def test_contain_placeholder():
135135
)
136136
async def test_query_package(mock_fixture, pkg1, pkg2, request):
137137
gen_mock_server = request.getfixturevalue(mock_fixture)
138-
pkg1_index_url = gen_mock_server(pkgs=[pkg1])
139-
pkg2_index_url = gen_mock_server(pkgs=[pkg2])
138+
pkg1_index_url = gen_mock_server(pkgs=[pkg1], pkgs_not_found=[pkg2])
139+
pkg2_index_url = gen_mock_server(pkgs=[pkg2], pkgs_not_found=[pkg1])
140140

141141
for _index_urls in (
142142
pkg1_index_url,

0 commit comments

Comments
 (0)