From 936ea1d5dd919a11a710fc0f9e2a226111e7f929 Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Mon, 20 Jul 2026 13:00:04 +0100 Subject: [PATCH 1/2] Migrate to httpx2 --- pyproject.toml | 4 +- src/spacetrack/aio.py | 11 +- src/spacetrack/base.py | 30 +++--- tests/conftest.py | 54 ++++++---- tests/test_aio.py | 106 ++++++++++++------- tests/test_spacetrack.py | 217 ++++++++++++++++++++++++++------------- uv.lock | 68 ++++++------ 7 files changed, 307 insertions(+), 183 deletions(-) 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" From 0877aa6b86fd21aaabe20d281dfb57e7b36d7a01 Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Sat, 25 Jul 2026 14:57:46 +0100 Subject: [PATCH 2/2] Add newsfragment --- newsfragments/152.changed.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 newsfragments/152.changed.rst 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``.