diff --git a/newsfragments/152.changed.rst b/newsfragments/152.changed.rst
new file mode 100644
index 0000000..7741c29
--- /dev/null
+++ b/newsfragments/152.changed.rst
@@ -0,0 +1,2 @@
+Replace `HTTPX `_ with the maintained `HTTPX2 `_ fork.
+Users that provide a custom ``httpx_client`` must now pass an ``httpx2.Client`` or ``httpx2.AsyncClient``, and exception handling should catch exceptions from ``httpx2`` instead of ``httpx``.
diff --git a/pyproject.toml b/pyproject.toml
index d9f6311..8e5f8d4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,7 +23,7 @@ classifiers = [
]
dependencies = [
"filelock>=3.17.0",
- "httpx>=0.28.1",
+ "httpx2>=2.3.0",
"logbook>=1.8.0",
"outcome>=1.3.0.post0",
"platformdirs>=4.3.6",
@@ -71,8 +71,8 @@ nox = [
test = [
"pytest>=9.0.3",
"pytest-asyncio>=1.4.0",
+ "httpx2-pytest>=1.0.1",
"pytest-trio>=0.8.0",
- "respx>=0.23.1",
]
towncrier = [
"towncrier>=25.8.0",
diff --git a/src/spacetrack/aio.py b/src/spacetrack/aio.py
index 6e4b438..2378ef5 100644
--- a/src/spacetrack/aio.py
+++ b/src/spacetrack/aio.py
@@ -2,11 +2,11 @@
import time
import weakref
-import httpx
+import httpx2
import outcome
import sniffio
from filelock import AsyncFileLock
-from httpx import USE_CLIENT_DEFAULT
+from httpx2 import USE_CLIENT_DEFAULT
from .base import (
BASE_URL,
@@ -35,10 +35,11 @@ class AsyncSpaceTrackClient(SpaceTrackClient):
Refer to the :class:`~spacetrack.base.SpaceTrackClient` documentation for
more information. Note that if passed, the ``httpx_client`` parameter must
- be an ``httpx.AsyncClient``.
+ be an ``httpx2.AsyncClient``.
"""
_file_lock_cls = AsyncFileLock
+ _httpx_client_cls = httpx2.AsyncClient
def __init__(
self,
@@ -51,10 +52,6 @@ def __init__(
additional_rate_limit=None,
cache_path=None,
):
- if httpx_client is None:
- httpx_client = httpx.AsyncClient(timeout=30)
- elif not isinstance(httpx_client, httpx.AsyncClient):
- raise TypeError("httpx_client must be an httpx.AsyncClient instance")
super().__init__(
identity=identity,
password=password,
diff --git a/src/spacetrack/base.py b/src/spacetrack/base.py
index cd4f44d..70a5429 100644
--- a/src/spacetrack/base.py
+++ b/src/spacetrack/base.py
@@ -15,10 +15,10 @@
from urllib.parse import quote
import attr
-import httpx
+import httpx2
import outcome
from filelock import FileLock
-from httpx import USE_CLIENT_DEFAULT
+from httpx2 import USE_CLIENT_DEFAULT
from logbook import Logger
from platformdirs import user_cache_path
from represent import ReprHelper, ReprHelperMixin
@@ -175,8 +175,8 @@ class SpaceTrackClient:
follow rate limits from multiple instances.
rush_key_prefix: You may choose a prefix for the keys that will be
stored in `rush_store`, e.g. to avoid conflicts in a redis db.
- httpx_client: Provide a custom ``httpx.Client` instance.
- ``SpaceTrackClient`` takes ownership of the httpx client. You should
+ httpx_client: Provide a custom ``httpx2.Client`` instance.
+ ``SpaceTrackClient`` takes ownership of the HTTPX2 client. You should
only provide your own client if you need to configure it first (e.g.
for a proxy).
additional_rate_limit: Optionally, a :class:`rush.quota.Quota` if you
@@ -300,6 +300,7 @@ class SpaceTrackClient:
}
_file_lock_cls = FileLock
+ _httpx_client_cls = httpx2.Client
def __init__(
self,
@@ -313,7 +314,12 @@ def __init__(
cache_path=None,
):
if httpx_client is None:
- httpx_client = httpx.Client(timeout=30)
+ httpx_client = self._httpx_client_cls(timeout=30)
+ elif not isinstance(httpx_client, self._httpx_client_cls):
+ raise TypeError(
+ "httpx_client must be an "
+ f"httpx2.{self._httpx_client_cls.__name__} instance"
+ )
self.client = httpx_client
self.identity = identity
self.password = password
@@ -590,7 +596,7 @@ def _generic_request_generator(
request, stream=iter_lines or iter_content
)
- if httpx.codes.is_error(resp.status_code):
+ if httpx2.codes.is_error(resp.status_code):
# If we're about to raise an error, fetch the full response in case
# we're streaming
yield ReadResponse(resp)
@@ -695,7 +701,7 @@ def generic_request(
Passing ``format='json'`` will return the JSON **unparsed**. Do
not set ``format`` if you want the parsed JSON object returned!
- .. _`HTTPX Timeouts`: https://www.python-httpx.org/advanced/timeouts/
+ .. _`HTTPX Timeouts`: https://httpx2.pydantic.dev/advanced/timeouts/
"""
return self._run_event_generator(
self._generic_request_generator(
@@ -1109,13 +1115,13 @@ def _iter_content_generator(response, decode_unicode):
def _raise_for_status(response):
"""Raise the `HTTPStatusError` if one occurred.
- This is the :meth:`httpx.Response.raise_for_status` method, modified to add
+ This is the :meth:`httpx2.Response.raise_for_status` method, modified to add
the response from Space-Track, if given.
"""
try:
response.raise_for_status()
- except httpx.HTTPStatusError as exc:
+ except httpx2.HTTPStatusError as exc:
message = exc.args[0]
spacetrack_error_msg = None
@@ -1123,18 +1129,18 @@ def _raise_for_status(response):
json = response.json()
if isinstance(json, Mapping):
spacetrack_error_msg = json["error"]
- except (ValueError, KeyError, httpx.ResponseNotRead):
+ except (ValueError, KeyError, httpx2.ResponseNotRead):
pass
if not spacetrack_error_msg:
try:
spacetrack_error_msg = response.text
- except httpx.ResponseNotRead:
+ except httpx2.ResponseNotRead:
pass
if spacetrack_error_msg:
message += "\nSpace-Track response:\n" + spacetrack_error_msg
- raise httpx.HTTPStatusError(
+ raise httpx2.HTTPStatusError(
message, request=exc.request, response=exc.response
) from exc
diff --git a/tests/conftest.py b/tests/conftest.py
index e3136e6..6c03f59 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -4,22 +4,13 @@
from unittest.mock import patch
import pytest
-import respx
from spacetrack import SpaceTrackClient
from spacetrack.base import BASE_URL, CACHE_VERSION, PREDICATE_CACHE_EXPIRY_TIME
-@pytest.fixture(scope="session")
-def respx_router():
- # Create an instance of MockRouter with our settings.
- return respx.mock(assert_all_called=False, base_url=BASE_URL)
-
-
-@pytest.fixture
-def respx_mock(respx_router):
- with respx_router:
- yield respx_router
+def api_url(path):
+ return f"{BASE_URL}{path}"
@pytest.fixture(autouse=True)
@@ -32,23 +23,41 @@ def user_cache_path(appname):
@pytest.fixture
-def mock_auth(respx_mock):
- respx_mock.post("ajaxauth/login").respond(json="")
- respx_mock.get("ajaxauth/logout").respond(json="Successfully logged out")
+def mock_auth(httpx2_mock):
+ def add_auth_responses():
+ httpx2_mock.add_response(
+ method="POST",
+ url=api_url("ajaxauth/login"),
+ json="",
+ )
+ httpx2_mock.add_response(
+ method="GET",
+ url=api_url("ajaxauth/logout"),
+ json="Successfully logged out",
+ )
+
+ add_auth_responses()
+ return add_auth_responses
@pytest.fixture
-def mock_predicates_empty(respx_mock):
+def mock_predicates_empty(httpx2_mock):
for controller, classes in SpaceTrackClient.request_controllers.items():
for class_ in classes:
- respx_mock.get(f"{controller}/modeldef/class/{class_}").respond(
- json={"data": []}
+ httpx2_mock.add_response(
+ method="GET",
+ url=api_url(f"{controller}/modeldef/class/{class_}"),
+ json={"data": []},
+ is_optional=True,
)
@pytest.fixture
-def mock_gp_predicates(respx_mock):
- respx_mock.get("basicspacedata/modeldef/class/gp").respond(
+def mock_gp_predicates(httpx2_mock):
+ httpx2_mock.add_response(
+ method="GET",
+ url=api_url("basicspacedata/modeldef/class/gp"),
+ is_optional=True,
json={
"controller": "basicspacedata",
"data": [
@@ -378,8 +387,11 @@ def mock_gp_predicates(respx_mock):
@pytest.fixture
-def mock_download_predicates(respx_mock):
- respx_mock.get("fileshare/modeldef/class/download").respond(
+def mock_download_predicates(httpx2_mock):
+ httpx2_mock.add_response(
+ method="GET",
+ url=api_url("fileshare/modeldef/class/download"),
+ is_optional=True,
json={
"controller": "fileshare",
"data": [
diff --git a/tests/test_aio.py b/tests/test_aio.py
index 4f51e4c..8d208ae 100644
--- a/tests/test_aio.py
+++ b/tests/test_aio.py
@@ -1,12 +1,17 @@
from unittest.mock import call, patch
-import httpx
+import httpx2
import pytest
import pytest_asyncio
from rush.quota import Quota
from spacetrack import AsyncSpaceTrackClient
from spacetrack.aio import _iter_content_generator
+from spacetrack.base import BASE_URL
+
+
+def api_url(path):
+ return f"{BASE_URL}{path}"
@pytest.fixture(
@@ -20,11 +25,23 @@ def async_runner(request):
@pytest_asyncio.fixture
-async def client(respx_mock):
+async def client(httpx2_mock):
async with AsyncSpaceTrackClient("identity", "password") as st:
yield st
+async def test_custom_httpx_client(async_runner):
+ httpx_client = httpx2.AsyncClient()
+
+ async with AsyncSpaceTrackClient(
+ "identity", "password", httpx_client=httpx_client
+ ) as client:
+ assert client.client is httpx_client
+
+ with pytest.raises(TypeError, match=r"httpx2\.AsyncClient"):
+ AsyncSpaceTrackClient("identity", "password", httpx_client=object())
+
+
async def test_authenticate(client, async_runner, mock_auth):
await client.authenticate()
@@ -61,7 +78,7 @@ async def test_get_predicates(async_runner, client, mock_auth, mock_gp_predicate
async def test_generic_request(
- client, async_runner, respx_mock, mock_auth, mock_gp_predicates
+ client, async_runner, httpx2_mock, mock_auth, mock_gp_predicates
):
tle = (
"1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927\r\n"
@@ -70,11 +87,15 @@ async def test_generic_request(
normalised_tle = tle.replace("\r\n", "\n")
- respx_mock.get("basicspacedata/query/class/gp/format/tle").respond(text=tle)
+ httpx2_mock.add_response(
+ method="GET", url=api_url("basicspacedata/query/class/gp/format/tle"), text=tle
+ )
assert await client.gp(format="tle") == normalised_tle
- respx_mock.get("basicspacedata/query/class/gp").respond(json={"a": 5})
+ httpx2_mock.add_response(
+ method="GET", url=api_url("basicspacedata/query/class/gp"), json={"a": 5}
+ )
result = await client.gp()
assert result["a"] == 5
@@ -91,7 +112,7 @@ async def mock_aiter_text():
async for chunk in mock_aiter_bytes():
yield chunk.decode("utf-8")
- response = httpx.Response(200)
+ response = httpx2.Response(200)
with patch.object(response, "aiter_text", mock_aiter_text):
result = [
c
@@ -112,16 +133,15 @@ async def mock_aiter_text():
async def test_ratelimit_error(
- async_runner, client, respx_mock, mock_auth, mock_gp_predicates
+ async_runner, client, httpx2_mock, mock_auth, mock_gp_predicates
):
from unittest.mock import AsyncMock
- route = respx_mock.get("basicspacedata/query/class/gp").mock(
- side_effect=[
- httpx.Response(500, text="violated your query rate limit"),
- httpx.Response(200, json={"a": 1}),
- ]
+ url = api_url("basicspacedata/query/class/gp")
+ httpx2_mock.add_response(
+ method="GET", url=url, status_code=500, text="violated your query rate limit"
)
+ httpx2_mock.add_response(method="GET", url=url, json={"a": 1})
# Change ratelimiter period to speed up test
client._per_minute_throttle.rate = Quota.per_second(30)
@@ -129,33 +149,39 @@ async def test_ratelimit_error(
# Do it first without our own callback, then with.
assert await client.gp() == {"a": 1}
- assert route.call_count == 2
- assert route.calls[0].response.status_code == 500
+ assert len(httpx2_mock.get_requests(method="GET", url=url)) == 2
mock_callback = AsyncMock()
client.callback = mock_callback
- route.reset()
- route.side_effect = [
- httpx.Response(500, text="violated your query rate limit"),
- httpx.Response(200, json={"a": 1}),
- ]
+ httpx2_mock.add_response(
+ method="GET", url=url, status_code=500, text="violated your query rate limit"
+ )
+ httpx2_mock.add_response(method="GET", url=url, json={"a": 1})
assert await client.gp() == {"a": 1}
- assert route.call_count == 2
- assert route.calls[0].response.status_code == 500
+ assert len(httpx2_mock.get_requests(method="GET", url=url)) == 4
assert mock_callback.call_count == 1
mock_callback.assert_awaited()
@pytest.mark.asyncio
-async def test_modeldef_cache(respx_mock, mock_auth, cache_file_mangler):
- respx_mock.get("basicspacedata/query/class/gp/norad_cat_id/25541").respond(
- json="dummy"
+async def test_modeldef_cache(httpx2_mock, mock_auth, cache_file_mangler):
+ # This test creates three independently authenticated clients.
+ mock_auth()
+ mock_auth()
+
+ query_url = api_url("basicspacedata/query/class/gp/norad_cat_id/25541")
+ httpx2_mock.add_response(
+ method="GET", url=query_url, json="dummy", is_reusable=True
)
- modeldef_route = respx_mock.get("basicspacedata/modeldef/class/gp").respond(
+ modeldef_url = api_url("basicspacedata/modeldef/class/gp")
+ httpx2_mock.add_response(
+ method="GET",
+ url=modeldef_url,
+ is_reusable=True,
json={
"controller": "fileshare",
"data": [
@@ -173,14 +199,14 @@ async def test_modeldef_cache(respx_mock, mock_auth, cache_file_mangler):
async with AsyncSpaceTrackClient("identity", "password") as client:
assert await client.gp(norad_cat_id=25541) == "dummy"
- assert modeldef_route.call_count == 1
+ assert len(httpx2_mock.get_requests(method="GET", url=modeldef_url)) == 1
assert await client.gp(norad_cat_id=25541) == "dummy"
- assert modeldef_route.call_count == 1
+ assert len(httpx2_mock.get_requests(method="GET", url=modeldef_url)) == 1
async with AsyncSpaceTrackClient("identity", "password") as client:
assert await client.gp(norad_cat_id=25541) == "dummy"
- assert modeldef_route.call_count == 1
+ assert len(httpx2_mock.get_requests(method="GET", url=modeldef_url)) == 1
cache_files = list(client._cache_path.glob("*.json"))
assert len(cache_files) == 1
@@ -193,21 +219,25 @@ async def test_modeldef_cache(respx_mock, mock_auth, cache_file_mangler):
# Even though cache file is gone, client still has it in memory so there
# should be no new modeldef request
assert await client.gp(norad_cat_id=25541) == "dummy"
- assert modeldef_route.call_count == 1
+ assert len(httpx2_mock.get_requests(method="GET", url=modeldef_url)) == 1
async with AsyncSpaceTrackClient("identity", "password") as client:
# There should be a new modeldef request because we deleted the cache file
assert await client.gp(norad_cat_id=25541) == "dummy"
- assert modeldef_route.call_count == 2
+ assert len(httpx2_mock.get_requests(method="GET", url=modeldef_url)) == 2
@pytest.mark.trio
-async def test_modeldef_not_used_trio(respx_mock, mock_auth):
- respx_mock.get("basicspacedata/query/class/gp/norad_cat_id/25541").respond(
- json="dummy"
+async def test_modeldef_not_used_trio(httpx2_mock, mock_auth):
+ query_url = api_url("basicspacedata/query/class/gp/norad_cat_id/25541")
+ httpx2_mock.add_response(
+ method="GET", url=query_url, json="dummy", is_reusable=True
)
- modeldef_route = respx_mock.get("basicspacedata/modeldef/class/gp").respond(
+ modeldef_url = api_url("basicspacedata/modeldef/class/gp")
+ httpx2_mock.add_response(
+ method="GET",
+ url=modeldef_url,
json={
"controller": "fileshare",
"data": [
@@ -225,22 +255,22 @@ async def test_modeldef_not_used_trio(respx_mock, mock_auth):
async with AsyncSpaceTrackClient("identity", "password") as client:
assert await client.gp(norad_cat_id=25541) == "dummy"
- assert modeldef_route.call_count == 0
+ assert httpx2_mock.get_requests(method="GET", url=modeldef_url) == []
# If predicates are requested explicitly, they should be cached (in
# client only) and used
await client.gp.get_predicates()
- assert modeldef_route.call_count == 1
+ assert len(httpx2_mock.get_requests(method="GET", url=modeldef_url)) == 1
assert await client.gp(norad_cat_id=25541) == "dummy"
- assert modeldef_route.call_count == 1
+ assert len(httpx2_mock.get_requests(method="GET", url=modeldef_url)) == 1
cache_path = client._cache_path
cache_files = list(cache_path.glob("*.json"))
assert cache_files == []
-async def test_custom_cache_path(async_runner, respx_mock, tmp_path):
+async def test_custom_cache_path(async_runner, httpx2_mock, tmp_path):
async with AsyncSpaceTrackClient(
"identity", "password", cache_path=tmp_path
) as client:
diff --git a/tests/test_spacetrack.py b/tests/test_spacetrack.py
index d10f462..4287fac 100644
--- a/tests/test_spacetrack.py
+++ b/tests/test_spacetrack.py
@@ -1,8 +1,9 @@
import datetime as dt
from unittest.mock import Mock, call, patch
-import httpx
+import httpx2
import pytest
+from pytest_httpx2 import IteratorStream
from rush.quota import Quota
from spacetrack import (
@@ -10,15 +11,34 @@
SpaceTrackClient,
UnknownPredicateTypeWarning,
)
-from spacetrack.base import Predicate, _iter_content_generator, _raise_for_status
+from spacetrack.base import (
+ BASE_URL,
+ Predicate,
+ _iter_content_generator,
+ _raise_for_status,
+)
+
+
+def api_url(path):
+ return f"{BASE_URL}{path}"
@pytest.fixture
-def client(respx_mock):
+def client(httpx2_mock):
with SpaceTrackClient("identity", "password") as st:
yield st
+def test_custom_httpx_client():
+ httpx_client = httpx2.Client()
+
+ with SpaceTrackClient("identity", "password", httpx_client=httpx_client) as client:
+ assert client.client is httpx_client
+
+ with pytest.raises(TypeError, match=r"httpx2\.Client"):
+ SpaceTrackClient("identity", "password", httpx_client=object())
+
+
def test_iter_content_generator():
"""Test CRLF -> LF newline conversion."""
@@ -29,7 +49,7 @@ def mock_iter_text():
for chunk in mock_iter_bytes():
yield chunk.decode("utf-8")
- response = httpx.Response(200)
+ response = httpx2.Response(200)
with patch.object(response, "iter_text", mock_iter_text):
result = list(_iter_content_generator(response=response, decode_unicode=True))
assert result == ["1\n2\n", "3", "\n4", "\n5"]
@@ -88,7 +108,7 @@ def test_get_predicates(client):
assert mock_get_predicates.call_args_list == expected_calls
-def test_generic_request(respx_mock, client, mock_auth, mock_gp_predicates):
+def test_generic_request(httpx2_mock, client, mock_auth, mock_gp_predicates):
tle = (
"1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927\r\n"
"2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537\r\n"
@@ -96,7 +116,12 @@ def test_generic_request(respx_mock, client, mock_auth, mock_gp_predicates):
normalised_tle = tle.replace("\r\n", "\n")
- respx_mock.get("basicspacedata/query/class/gp/format/tle").respond(text=tle)
+ httpx2_mock.add_response(
+ method="GET",
+ url=api_url("basicspacedata/query/class/gp/format/tle"),
+ text=tle,
+ is_reusable=True,
+ )
assert client.gp(format="tle") == normalised_tle
@@ -107,12 +132,18 @@ def test_generic_request(respx_mock, client, mock_auth, mock_gp_predicates):
"2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537",
]
- respx_mock.get("basicspacedata/query/class/gp").respond(json={"a": 5})
+ httpx2_mock.add_response(
+ method="GET", url=api_url("basicspacedata/query/class/gp"), json={"a": 5}
+ )
result = client.gp()
assert result["a"] == 5
- respx_mock.get("basicspacedata/query/class/gp").respond(stream=[b"abc", b"def"])
+ httpx2_mock.add_response(
+ method="GET",
+ url=api_url("basicspacedata/query/class/gp"),
+ stream=IteratorStream([b"abc", b"def"]),
+ )
result = list(client.gp(iter_content=True))
@@ -124,11 +155,11 @@ def test_predicate_error(client, mock_auth, mock_predicates_empty):
client.gp(banana=4)
-def test_bytes_response(client, respx_mock, mock_auth, mock_download_predicates):
+def test_bytes_response(client, httpx2_mock, mock_auth, mock_download_predicates):
data = b"bytes response \r\n"
url = "fileshare/query/class/download/format/stream"
- respx_mock.get(url).respond(content=data)
+ httpx2_mock.add_response(method="GET", url=api_url(url), content=data)
assert client.download(format="stream") == data
@@ -136,20 +167,21 @@ def test_bytes_response(client, respx_mock, mock_auth, mock_download_predicates)
client.download(iter_lines=True, format="stream")
# Just use file_id to disambiguate URL from those above
- respx_mock.get(url).respond(stream=[b"abc", b"def"])
+ httpx2_mock.add_response(
+ method="GET", url=api_url(url), stream=IteratorStream([b"abc", b"def"])
+ )
result = list(client.download(format="stream", iter_content=True))
assert b"".join(result) == b"abcdef"
-def test_ratelimit_error(client, respx_mock, mock_auth, mock_gp_predicates):
- route = respx_mock.get("basicspacedata/query/class/gp").mock(
- side_effect=[
- httpx.Response(500, text="violated your query rate limit"),
- httpx.Response(200, json={"a": 1}),
- ]
+def test_ratelimit_error(client, httpx2_mock, mock_auth, mock_gp_predicates):
+ url = api_url("basicspacedata/query/class/gp")
+ httpx2_mock.add_response(
+ method="GET", url=url, status_code=500, text="violated your query rate limit"
)
+ httpx2_mock.add_response(method="GET", url=url, json={"a": 1})
# Change ratelimiter period to speed up test
client._per_minute_throttle.rate = Quota.per_second(30)
@@ -157,37 +189,37 @@ def test_ratelimit_error(client, respx_mock, mock_auth, mock_gp_predicates):
# Do it first without our own callback, then with.
assert client.gp() == {"a": 1}
- assert route.call_count == 2
- assert route.calls[0].response.status_code == 500
+ assert len(httpx2_mock.get_requests(method="GET", url=url)) == 2
mock_callback = Mock()
client.callback = mock_callback
- route.reset()
- route.side_effect = [
- httpx.Response(500, text="violated your query rate limit"),
- httpx.Response(200, json={"a": 1}),
- ]
+ httpx2_mock.add_response(
+ method="GET", url=url, status_code=500, text="violated your query rate limit"
+ )
+ httpx2_mock.add_response(method="GET", url=url, json={"a": 1})
assert client.gp() == {"a": 1}
- assert route.call_count == 2
- assert route.calls[0].response.status_code == 500
+ assert len(httpx2_mock.get_requests(method="GET", url=url)) == 4
assert mock_callback.call_count == 1
-def test_non_ratelimit_error(client, respx_mock, mock_auth, mock_gp_predicates):
+def test_non_ratelimit_error(client, httpx2_mock, mock_auth, mock_gp_predicates):
# Change ratelimiter period to speed up test
client._per_minute_throttle.rate = Quota.per_second(30)
mock_callback = Mock()
client.callback = mock_callback
- respx_mock.get("basicspacedata/query/class/gp").respond(
- 500, text="some other error"
+ httpx2_mock.add_response(
+ method="GET",
+ url=api_url("basicspacedata/query/class/gp"),
+ status_code=500,
+ text="some other error",
)
- with pytest.raises(httpx.HTTPStatusError):
+ with pytest.raises(httpx2.HTTPStatusError):
client.gp()
assert not mock_callback.called
@@ -309,25 +341,33 @@ def test_controller_spacetrack_methods(client):
assert mock_generic_request.call_args == expected
-def test_authenticate(respx_mock):
+def test_authenticate(httpx2_mock):
def request_callback(request):
if b"wrongpassword" in request.content:
- return httpx.Response(200, json={"Login": "Failed"})
+ return httpx2.Response(200, json={"Login": "Failed"})
elif b"unknownresponse" in request.content:
# Space-Track doesn't respond like this, but make sure anything
# other than {'Login': 'Failed'} doesn't raise AuthenticationError
- return httpx.Response(200, json={"Login": "Successful"})
+ return httpx2.Response(200, json={"Login": "Successful"})
else:
- return httpx.Response(200, json="")
+ return httpx2.Response(200, json="")
- route = respx_mock.post("ajaxauth/login").mock(side_effect=request_callback)
- respx_mock.get("ajaxauth/logout").respond(json="Successfully logged out")
+ login_url = api_url("ajaxauth/login")
+ httpx2_mock.add_callback(
+ request_callback, method="POST", url=login_url, is_reusable=True
+ )
+ httpx2_mock.add_response(
+ method="GET",
+ url=api_url("ajaxauth/logout"),
+ json="Successfully logged out",
+ is_reusable=True,
+ )
with SpaceTrackClient("identity", "wrongpassword") as client:
with pytest.raises(AuthenticationError):
client.authenticate()
- assert route.call_count == 1
+ assert len(httpx2_mock.get_requests(method="POST", url=login_url)) == 1
client.password = "correctpassword"
client.authenticate()
@@ -335,57 +375,72 @@ def request_callback(request):
# Check that only one login request was made since successful
# authentication
- assert route.call_count == 2
+ assert len(httpx2_mock.get_requests(method="POST", url=login_url)) == 2
with SpaceTrackClient("identity", "unknownresponse") as client:
client.authenticate()
-def test_base_url(respx_mock):
- route = respx_mock.post("https://example.com/ajaxauth/login").respond(json='""')
- respx_mock.get("https://example.com/ajaxauth/logout").respond(
- json="Successfully logged out"
+def test_base_url(httpx2_mock):
+ login_url = "https://example.com/ajaxauth/login"
+ httpx2_mock.add_response(method="POST", url=login_url, json='""')
+ httpx2_mock.add_response(
+ method="GET",
+ url="https://example.com/ajaxauth/logout",
+ json="Successfully logged out",
)
with SpaceTrackClient(
"identity", "password", base_url="https://example.com"
) as client:
client.authenticate()
- assert route.call_count == 1
+ assert len(httpx2_mock.get_requests(method="POST", url=login_url)) == 1
-def test_raise_for_status(respx_mock):
- respx_mock.get("http://example.com/1").respond(400, json={"error": "problem"})
- respx_mock.get("http://example.com/2").respond(400, json={"wrongkey": "problem"})
- respx_mock.get("http://example.com/3").respond(400, json="problem")
- respx_mock.get("http://example.com/4").respond(400)
+def test_raise_for_status(httpx2_mock):
+ httpx2_mock.add_response(
+ method="GET",
+ url="http://example.com/1",
+ status_code=400,
+ json={"error": "problem"},
+ )
+ httpx2_mock.add_response(
+ method="GET",
+ url="http://example.com/2",
+ status_code=400,
+ json={"wrongkey": "problem"},
+ )
+ httpx2_mock.add_response(
+ method="GET", url="http://example.com/3", status_code=400, json="problem"
+ )
+ httpx2_mock.add_response(method="GET", url="http://example.com/4", status_code=400)
- response1 = httpx.get("http://example.com/1")
- response2 = httpx.get("http://example.com/2")
- response3 = httpx.get("http://example.com/3")
- response4 = httpx.get("http://example.com/4")
+ response1 = httpx2.get("http://example.com/1")
+ response2 = httpx2.get("http://example.com/2")
+ response3 = httpx2.get("http://example.com/3")
+ response4 = httpx2.get("http://example.com/4")
- with pytest.raises(httpx.HTTPStatusError) as exc:
+ with pytest.raises(httpx2.HTTPStatusError) as exc:
_raise_for_status(response1)
assert "Space-Track" in str(exc.value)
assert "\nproblem" in str(exc.value)
- with pytest.raises(httpx.HTTPStatusError) as exc:
+ with pytest.raises(httpx2.HTTPStatusError) as exc:
_raise_for_status(response2)
assert "Space-Track" in str(exc.value)
assert '{"wrongkey":"problem"}' in str(exc.value)
- with pytest.raises(httpx.HTTPStatusError) as exc:
+ with pytest.raises(httpx2.HTTPStatusError) as exc:
_raise_for_status(response3)
assert "Space-Track" in str(exc.value)
assert '\n"problem"' in str(exc.value)
- with pytest.raises(httpx.HTTPStatusError) as exc:
+ with pytest.raises(httpx2.HTTPStatusError) as exc:
_raise_for_status(response4)
assert "Space-Track" not in str(exc.value)
-def test_repr(respx_mock):
+def test_repr(httpx2_mock):
with SpaceTrackClient("hello@example.com", "mypassword") as client:
assert repr(client) == "SpaceTrackClient"
assert "mypassword" not in repr(client)
@@ -468,8 +523,10 @@ def test_predicate_parse_type(predicate, input, output):
assert predicate.parse(input) == output
-def test_parse_types(client, respx_mock, mock_auth):
- respx_mock.get("basicspacedata/modeldef/class/gp").respond(
+def test_parse_types(client, httpx2_mock, mock_auth):
+ httpx2_mock.add_response(
+ method="GET",
+ url=api_url("basicspacedata/modeldef/class/gp"),
json={
"controller": "basicspacedata",
"data": [
@@ -509,7 +566,9 @@ def test_parse_types(client, respx_mock, mock_auth):
},
)
- respx_mock.get("basicspacedata/query/class/gp").respond(
+ httpx2_mock.add_response(
+ method="GET",
+ url=api_url("basicspacedata/query/class/gp"),
json=[
{
# Test a type that is parsed.
@@ -535,11 +594,12 @@ def test_parse_types(client, respx_mock, mock_auth):
assert "parse_types" in exc_info.value.args[0]
-def test_params(respx_mock, mock_auth):
+def test_params(httpx2_mock, mock_auth):
data = b"hello\n"
- respx_mock.get(
- "publicfiles/query/class/download", params={"name": "filename.txt"}
- ).respond(
+ httpx2_mock.add_response(
+ method="GET",
+ url=api_url("publicfiles/query/class/download"),
+ match_params={"name": "filename.txt"},
content=data,
)
@@ -549,12 +609,21 @@ def test_params(respx_mock, mock_auth):
assert b"".join(result) == data
-def test_modeldef_cache(respx_mock, mock_auth, cache_file_mangler):
- respx_mock.get("basicspacedata/query/class/gp/norad_cat_id/25541").respond(
- json="dummy"
+def test_modeldef_cache(httpx2_mock, mock_auth, cache_file_mangler):
+ # This test creates three independently authenticated clients.
+ mock_auth()
+ mock_auth()
+
+ query_url = api_url("basicspacedata/query/class/gp/norad_cat_id/25541")
+ httpx2_mock.add_response(
+ method="GET", url=query_url, json="dummy", is_reusable=True
)
- modeldef_route = respx_mock.get("basicspacedata/modeldef/class/gp").respond(
+ modeldef_url = api_url("basicspacedata/modeldef/class/gp")
+ httpx2_mock.add_response(
+ method="GET",
+ url=modeldef_url,
+ is_reusable=True,
json={
"controller": "fileshare",
"data": [
@@ -572,14 +641,14 @@ def test_modeldef_cache(respx_mock, mock_auth, cache_file_mangler):
with SpaceTrackClient("identity", "password") as client:
assert client.gp(norad_cat_id=25541) == "dummy"
- assert modeldef_route.call_count == 1
+ assert len(httpx2_mock.get_requests(method="GET", url=modeldef_url)) == 1
assert client.gp(norad_cat_id=25541) == "dummy"
- assert modeldef_route.call_count == 1
+ assert len(httpx2_mock.get_requests(method="GET", url=modeldef_url)) == 1
with SpaceTrackClient("identity", "password") as client:
assert client.gp(norad_cat_id=25541) == "dummy"
- assert modeldef_route.call_count == 1
+ assert len(httpx2_mock.get_requests(method="GET", url=modeldef_url)) == 1
cache_files = list(client._cache_path.glob("*.json"))
assert len(cache_files) == 1
@@ -592,12 +661,12 @@ def test_modeldef_cache(respx_mock, mock_auth, cache_file_mangler):
# Even though cache file is gone, client still has it in memory so there
# should be no new modeldef request
assert client.gp(norad_cat_id=25541) == "dummy"
- assert modeldef_route.call_count == 1
+ assert len(httpx2_mock.get_requests(method="GET", url=modeldef_url)) == 1
with SpaceTrackClient("identity", "password") as client:
# There should be a new modeldef request because we deleted the cache file
assert client.gp(norad_cat_id=25541) == "dummy"
- assert modeldef_route.call_count == 2
+ assert len(httpx2_mock.get_requests(method="GET", url=modeldef_url)) == 2
def test_implicit_cleanup_warning():
@@ -605,6 +674,6 @@ def test_implicit_cleanup_warning():
SpaceTrackClient("identity", "password")
-def test_custom_cache_path(respx_mock, tmp_path):
+def test_custom_cache_path(httpx2_mock, tmp_path):
with SpaceTrackClient("identity", "password", cache_path=tmp_path) as client:
assert client._cache_path == tmp_path
diff --git a/uv.lock b/uv.lock
index 0f28202..95e97da 100644
--- a/uv.lock
+++ b/uv.lock
@@ -510,31 +510,44 @@ wheels = [
]
[[package]]
-name = "httpcore"
-version = "1.0.9"
+name = "httpcore2"
+version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "certifi" },
{ name = "h11" },
+ { name = "truststore" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e6/34/18f1c596e677962f040284246f393b10a1f8ce440b3a7e69c637d0f1c7ad/httpcore2-2.3.0.tar.gz", hash = "sha256:07327e251560960eea8e969d92d4c6a325feb13cca39e25340731336c3baf924", size = 64300, upload-time = "2026-06-01T13:15:02.998Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl", hash = "sha256:477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415", size = 80024, upload-time = "2026-06-01T13:15:00.001Z" },
]
[[package]]
-name = "httpx"
-version = "0.28.1"
+name = "httpx2"
+version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
- { name = "certifi" },
- { name = "httpcore" },
+ { name = "httpcore2" },
{ name = "idna" },
+ { name = "truststore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9f/9a/cca0b9145f13d8ae34b885ae28d403a1469a433abc78e0f94f4ce94e650b/httpx2-2.3.0.tar.gz", hash = "sha256:227e7c41d95a76d4077a52640564132777215fc3394e07b66a3116c33d668fa9", size = 81115, upload-time = "2026-06-01T13:15:04.324Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl", hash = "sha256:6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7", size = 74538, upload-time = "2026-06-01T13:15:01.566Z" },
+]
+
+[[package]]
+name = "httpx2-pytest"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx2" },
+ { name = "pytest" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/c9/bc6405b80e0d8a2b0e155aa9976864a79a96fef5478c564cc0d89d23cb86/httpx2_pytest-1.0.1.tar.gz", hash = "sha256:e5d36bbac673bae6e11d3520e74bd360c3e392b56270efcb95f1d5369cbd1ec1", size = 59136, upload-time = "2026-05-22T15:02:54.982Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+ { url = "https://files.pythonhosted.org/packages/46/ef/e9b6fb576f1f518f68e9f9cce9776870452ae2b8cfbba53b4127701b9a55/httpx2_pytest-1.0.1-py3-none-any.whl", hash = "sha256:09ff3365101508c5b311301c6ee8e1a96dbb676028b19e00da66199d2e759a99", size = 20975, upload-time = "2026-05-22T15:02:54.007Z" },
]
[[package]]
@@ -951,18 +964,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
-[[package]]
-name = "respx"
-version = "0.23.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "httpx" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
-]
-
[[package]]
name = "restructuredtext-lint"
version = "2.0.2"
@@ -1072,7 +1073,7 @@ version = "1.4.0"
source = { editable = "." }
dependencies = [
{ name = "filelock" },
- { name = "httpx" },
+ { name = "httpx2" },
{ name = "logbook" },
{ name = "outcome" },
{ name = "platformdirs" },
@@ -1094,12 +1095,12 @@ dev = [
{ name = "coverage", extra = ["toml"] },
{ name = "doc8" },
{ name = "furo" },
+ { name = "httpx2-pytest" },
{ name = "nox" },
{ name = "parver" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-trio" },
- { name = "respx" },
{ name = "ruff" },
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
@@ -1125,10 +1126,10 @@ nox = [
{ name = "nox" },
]
test = [
+ { name = "httpx2-pytest" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-trio" },
- { name = "respx" },
]
towncrier = [
{ name = "towncrier" },
@@ -1137,7 +1138,7 @@ towncrier = [
[package.metadata]
requires-dist = [
{ name = "filelock", specifier = ">=3.17.0" },
- { name = "httpx", specifier = ">=0.28.1" },
+ { name = "httpx2", specifier = ">=2.3.0" },
{ name = "logbook", specifier = ">=1.8.0" },
{ name = "outcome", specifier = ">=1.3.0.post0" },
{ name = "platformdirs", specifier = ">=4.3.6" },
@@ -1155,12 +1156,12 @@ dev = [
{ name = "coverage", extras = ["toml"], specifier = ">=7.14.1" },
{ name = "doc8", specifier = ">=1.1.2" },
{ name = "furo", specifier = ">=2024.8.6" },
+ { name = "httpx2-pytest", specifier = ">=1.0.1" },
{ name = "nox", specifier = ">=2026.4.10" },
{ name = "parver", specifier = ">=0.5" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
{ name = "pytest-trio", specifier = ">=0.8.0" },
- { name = "respx", specifier = ">=0.23.1" },
{ name = "ruff", specifier = "==0.15.16" },
{ name = "sphinx", specifier = ">=7.4.7" },
{ name = "towncrier", specifier = ">=25.8.0" },
@@ -1178,10 +1179,10 @@ docstest = [
]
nox = [{ name = "nox", specifier = ">=2026.4.10" }]
test = [
+ { name = "httpx2-pytest", specifier = ">=1.0.1" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
{ name = "pytest-trio", specifier = ">=0.8.0" },
- { name = "respx", specifier = ">=0.23.1" },
]
towncrier = [{ name = "towncrier", specifier = ">=25.8.0" }]
@@ -1441,6 +1442,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" },
]
+[[package]]
+name = "truststore"
+version = "0.10.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
+]
+
[[package]]
name = "typing-extensions"
version = "4.15.0"