Skip to content

Commit dd1c32f

Browse files
Replace httpx with niquests (#35)
1 parent 5feba17 commit dd1c32f

File tree

8 files changed

+622
-225
lines changed

8 files changed

+622
-225
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- `tilebox-storage`: Replaced `httpx` with `niquests` for ASF HTTP downloads.
13+
1014
## [0.50.1] - 2026-04-01
1115

1216
### Added

tilebox-storage/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ classifiers = [
2222
requires-python = ">=3.10"
2323
dependencies = [
2424
"tilebox-datasets",
25-
"httpx>=0.27",
25+
"niquests>=3.18",
2626
"aiofile>=3.8",
2727
"folium>=0.15",
2828
"shapely>=2",
@@ -33,10 +33,10 @@ dependencies = [
3333
[dependency-groups]
3434
dev = [
3535
"hypothesis>=6.112.1",
36-
"pytest-httpx>=0.30.0",
3736
"pytest-asyncio>=0.24.0",
3837
"pytest-cov>=5.0.0",
3938
"pytest>=8.3.2",
39+
"responses>=0.26.0",
4040
]
4141

4242
[project.urls]

tilebox-storage/tests/conftest.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,186 @@
1+
from collections.abc import Iterator
2+
from inspect import isawaitable
3+
from io import BytesIO
4+
from sys import modules
5+
from typing import Any
6+
from unittest import mock as std_mock
7+
8+
import niquests
9+
import niquests.adapters as niquests_adapters
10+
import niquests.exceptions as niquests_exceptions
11+
import niquests.models as niquests_models
112
import pytest
13+
import requests.compat as requests_compat
14+
from niquests.packages import urllib3
15+
16+
modules["requests"] = niquests
17+
modules["requests.adapters"] = niquests_adapters
18+
modules["requests.models"] = niquests_models
19+
modules["requests.exceptions"] = niquests_exceptions
20+
modules["requests.packages.urllib3"] = urllib3
21+
modules["requests.compat"] = requests_compat
22+
23+
import responses # noqa: E402
24+
25+
26+
# see https://niquests.readthedocs.io/en/latest/community/extensions.html#responses
27+
class _TransferState:
28+
def __init__(self) -> None:
29+
self.data_in_count = 0
30+
31+
32+
class _AsyncRawBody:
33+
def __init__(self, body: bytes) -> None:
34+
self._body = BytesIO(body)
35+
self._fp = _TransferState()
36+
37+
async def read(self, chunk_size: int = -1, decode_content: bool = True) -> bytes:
38+
_ = decode_content
39+
chunk = self._body.read() if chunk_size == -1 else self._body.read(chunk_size)
40+
self._fp.data_in_count += len(chunk)
41+
return chunk
42+
43+
async def close(self) -> None:
44+
self._body.close()
45+
46+
def release_conn(self) -> None:
47+
return None
48+
49+
50+
class NiquestsMock(responses.RequestsMock):
51+
"""Extend responses to patch Niquests' sync and async adapters."""
52+
53+
def __init__(self, *args: Any, **kwargs: Any) -> None:
54+
super().__init__(*args, target="niquests.adapters.HTTPAdapter.send", **kwargs)
55+
self._patcher_async: Any | None = None
56+
57+
def unbound_on_async_send(self) -> Any:
58+
async def send(adapter: Any, request: Any, *args: Any, **kwargs: Any) -> Any:
59+
if args:
60+
try:
61+
kwargs["stream"] = args[0]
62+
kwargs["timeout"] = args[1]
63+
kwargs["verify"] = args[2]
64+
kwargs["cert"] = args[3]
65+
kwargs["proxies"] = args[4]
66+
except IndexError:
67+
pass
68+
69+
stream = bool(kwargs.get("stream"))
70+
resp = self._on_request(adapter, request, **kwargs)
71+
72+
if stream:
73+
body = getattr(getattr(resp, "raw", None), "read", lambda: getattr(resp, "_content", b""))()
74+
if isawaitable(body):
75+
body = await body
76+
if body is None or isinstance(body, bool):
77+
body = b""
78+
if isinstance(body, str):
79+
body = body.encode()
80+
resp.__class__ = niquests.AsyncResponse
81+
resp.raw = _AsyncRawBody(body)
82+
return resp
83+
84+
resp.__class__ = niquests.Response
85+
return resp
86+
87+
return send
88+
89+
def unbound_on_send(self) -> Any:
90+
def send(adapter: Any, request: Any, *args: Any, **kwargs: Any) -> Any:
91+
if args:
92+
try:
93+
kwargs["stream"] = args[0]
94+
kwargs["timeout"] = args[1]
95+
kwargs["verify"] = args[2]
96+
kwargs["cert"] = args[3]
97+
kwargs["proxies"] = args[4]
98+
except IndexError:
99+
pass
100+
101+
return self._on_request(adapter, request, **kwargs)
102+
103+
return send
104+
105+
def start(self) -> None:
106+
if self._patcher:
107+
return
108+
109+
self._patcher = std_mock.patch(target=self.target, new=self.unbound_on_send())
110+
self._patcher_async = std_mock.patch(
111+
target=self.target.replace("HTTPAdapter", "AsyncHTTPAdapter"),
112+
new=self.unbound_on_async_send(),
113+
)
114+
self._patcher.start()
115+
self._patcher_async.start()
116+
117+
def stop(self, allow_assert: bool = True) -> None:
118+
if self._patcher:
119+
self._patcher.stop()
120+
if self._patcher_async is not None:
121+
self._patcher_async.stop()
122+
self._patcher = None
123+
self._patcher_async = None
124+
125+
if not self.assert_all_requests_are_fired or not allow_assert:
126+
return
127+
128+
not_called = [match for match in self.registered() if match.call_count == 0]
129+
if not_called:
130+
raise AssertionError(
131+
f"Not all requests have been executed {[(match.method, match.url) for match in not_called]!r}"
132+
)
133+
134+
135+
mock = _default_mock = NiquestsMock(assert_all_requests_are_fired=False)
136+
responses.mock = mock
137+
responses._default_mock = _default_mock
138+
for kw in [
139+
"activate",
140+
"add",
141+
"_add_from_file",
142+
"add_callback",
143+
"add_passthru",
144+
"assert_call_count",
145+
"calls",
146+
"delete",
147+
"DELETE",
148+
"get",
149+
"GET",
150+
"head",
151+
"HEAD",
152+
"options",
153+
"OPTIONS",
154+
"patch",
155+
"PATCH",
156+
"post",
157+
"POST",
158+
"put",
159+
"PUT",
160+
"registered",
161+
"remove",
162+
"replace",
163+
"reset",
164+
"response_callback",
165+
"start",
166+
"stop",
167+
"upsert",
168+
]:
169+
if hasattr(responses, kw):
170+
setattr(responses, kw, getattr(mock, kw))
2171

3172

4173
@pytest.fixture
5174
def anyio_backend() -> str:
6175
return "asyncio"
176+
177+
178+
@pytest.fixture
179+
def responses_mock() -> Iterator[responses.RequestsMock]:
180+
responses.mock.reset()
181+
responses.mock.start()
182+
try:
183+
yield responses.mock
184+
finally:
185+
responses.mock.stop()
186+
responses.mock.reset()
Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,36 @@
11
import re
2+
from typing import cast
23

34
import pytest
4-
from httpx import AsyncClient, BasicAuth
5-
from pytest_httpx import HTTPXMock
5+
import responses
6+
from niquests import AsyncSession
7+
from niquests.cookies import RequestsCookieJar
68

79
from tilebox.storage.providers import _asf_login
810

11+
pytestmark = pytest.mark.usefixtures("responses_mock")
12+
13+
ASF_LOGIN_URL = "https://urs.earthdata.nasa.gov/oauth/authorize"
14+
915

1016
@pytest.mark.asyncio
11-
async def test_asf_login(httpx_mock: HTTPXMock) -> None:
12-
httpx_mock.add_response(headers={"Set-Cookie": "logged_in=yes"})
17+
async def test_asf_login() -> None:
18+
responses.add(responses.GET, ASF_LOGIN_URL, headers={"Set-Cookie": "logged_in=yes"})
1319

1420
client = await _asf_login(("username", "password"))
15-
assert isinstance(client, AsyncClient)
16-
assert "asf_search" in client.headers["Client-Id"]
17-
assert isinstance(client.auth, BasicAuth)
18-
assert client.cookies["logged_in"] == "yes"
21+
cookies = cast(RequestsCookieJar, client.cookies)
1922

20-
await client.aclose()
23+
assert isinstance(client, AsyncSession)
24+
assert "asf_search" in str(client.headers["Client-Id"])
25+
assert client.auth == ("username", "password")
26+
assert cookies["logged_in"] == "yes"
27+
28+
await client.close()
2129

2230

2331
@pytest.mark.asyncio
24-
async def test_asf_login_invalid_auth(httpx_mock: HTTPXMock) -> None:
25-
httpx_mock.add_response(401)
32+
async def test_asf_login_invalid_auth() -> None:
33+
responses.add(responses.GET, ASF_LOGIN_URL, status=401)
34+
2635
with pytest.raises(ValueError, match=re.escape("Invalid username or password.")):
2736
await _asf_login(("username", "password"))

0 commit comments

Comments
 (0)