From f7252c04778102368f57769e0b94c01c4749620f Mon Sep 17 00:00:00 2001 From: JoshuaVulcan <38018017+JoshuaVulcan@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:17:24 -0800 Subject: [PATCH 1/3] feat: add analyzers, choices, buoy/gear CRUD, and reports methods (ERA-12682) Add read-only and CRUD endpoints for miscellaneous DAS apps to both ERClient (sync) and AsyncERClient (async): Analyzers: - get_analyzers_spatial() - list spatial analyzers - get_analyzers_subject() - list subject analyzers Choices: - get_choices() - list all choice sets - get_choice(id) - get a single choice set - download_choice_icons() - download choice icons zip Buoy/Gear CRUD: - get_gear_list() - list gear items - get_gear(id) - get single gear item - post_gear(payload) - create gear item - patch_gear(id, payload) - update gear item - delete_gear(id) - delete gear item Reports/Tableau: - get_sitrep() - download situation report (.docx) - get_tableau_views() - list Tableau views - get_tableau_view(id) - get single Tableau view Also adds async _delete() helper to AsyncERClient. Includes 31 new tests across sync and async test suites. Co-authored-by: Cursor --- erclient/client.py | 172 +++++++++ .../test_analyzers_choices_gear_reports.py | 328 ++++++++++++++++++ tests/sync_client/__init__.py | 0 tests/sync_client/conftest.py | 21 ++ .../test_analyzers_choices_gear_reports.py | 171 +++++++++ 5 files changed, 692 insertions(+) create mode 100644 tests/async_client/test_analyzers_choices_gear_reports.py create mode 100644 tests/sync_client/__init__.py create mode 100644 tests/sync_client/conftest.py create mode 100644 tests/sync_client/test_analyzers_choices_gear_reports.py diff --git a/erclient/client.py b/erclient/client.py index 06de6e3..05d4084 100644 --- a/erclient/client.py +++ b/erclient/client.py @@ -987,6 +987,93 @@ def get_sources(self, page_size=100): def get_users(self): return self._get('users') + # ── Analyzers ───────────────────────────────────────────────── + + def get_analyzers_spatial(self): + """List spatial analyzers.""" + return self._get('analyzers/spatial') + + def get_analyzers_subject(self): + """List subject analyzers.""" + return self._get('analyzers/subject') + + # ── Choices ─────────────────────────────────────────────────── + + def get_choices(self): + """List all choice sets.""" + return self._get('choices') + + def get_choice(self, choice_id): + """Get a single choice set by ID. + + :param choice_id: UUID of the choice set. + """ + return self._get(f'choices/{choice_id}') + + def download_choice_icons(self): + """Download the choice icons zip file. + + Returns the raw :class:`requests.Response` so the caller can + stream or save the binary content. + """ + return self._get('choices/icons/download', return_response=True) + + # ── Buoy / Gear ────────────────────────────────────────────── + + def get_gear_list(self): + """List all buoy/gear items.""" + return self._get('buoy/gear') + + def get_gear(self, gear_id): + """Get a single gear item by ID. + + :param gear_id: UUID of the gear item. + """ + return self._get(f'buoy/gear/{gear_id}') + + def post_gear(self, gear): + """Create a new gear item. + + :param gear: dict with gear payload. + """ + return self._post('buoy/gear', payload=gear) + + def patch_gear(self, gear_id, gear): + """Update an existing gear item. + + :param gear_id: UUID of the gear item. + :param gear: dict with partial gear payload. + """ + return self._patch(f'buoy/gear/{gear_id}', payload=gear) + + def delete_gear(self, gear_id): + """Delete a gear item. + + :param gear_id: UUID of the gear item. + """ + return self._delete(f'buoy/gear/{gear_id}') + + # ── Reports / Tableau ───────────────────────────────────────── + + def get_sitrep(self): + """Download the situation report (.docx). + + Returns the raw :class:`requests.Response` so the caller can + stream or save the binary content. + """ + return self._get('reports/sitrep.docx', return_response=True) + + def get_tableau_views(self): + """List available Tableau views.""" + return self._get('reports/tableau-views') + + def get_tableau_view(self, view_id): + """Get a single Tableau view by ID. + + :param view_id: UUID of the tableau view. + """ + return self._get(f'reports/tableau-views/{view_id}') + class AsyncERClient(object): """ @@ -1393,6 +1480,91 @@ async def get_feature_group(self, feature_group_id: str): """ return await self._get(f"spatialfeaturegroup/{feature_group_id}", params={}) + # ── Analyzers ───────────────────────────────────────────────── + + async def get_analyzers_spatial(self): + """List spatial analyzers.""" + return await self._get('analyzers/spatial') + + async def get_analyzers_subject(self): + """List subject analyzers.""" + return await self._get('analyzers/subject') + + # ── Choices ─────────────────────────────────────────────────── + + async def get_choices(self): + """List all choice sets.""" + return await self._get('choices') + + async def get_choice(self, choice_id): + """Get a single choice set by ID. + + :param choice_id: UUID of the choice set. + """ + return await self._get(f'choices/{choice_id}') + + async def download_choice_icons(self): + """Download the choice icons zip file. + + Returns the raw response bytes. + """ + return await self._get('choices/icons/download') + + # ── Buoy / Gear ────────────────────────────────────────────── + + async def get_gear_list(self): + """List all buoy/gear items.""" + return await self._get('buoy/gear') + + async def get_gear(self, gear_id): + """Get a single gear item by ID. + + :param gear_id: UUID of the gear item. + """ + return await self._get(f'buoy/gear/{gear_id}') + + async def post_gear(self, gear): + """Create a new gear item. + + :param gear: dict with gear payload. + """ + return await self._post('buoy/gear', payload=gear) + + async def patch_gear(self, gear_id, gear): + """Update an existing gear item. + + :param gear_id: UUID of the gear item. + :param gear: dict with partial gear payload. + """ + return await self._patch(f'buoy/gear/{gear_id}', payload=gear) + + async def delete_gear(self, gear_id): + """Delete a gear item. + + :param gear_id: UUID of the gear item. + """ + return await self._delete(f'buoy/gear/{gear_id}') + + # ── Reports / Tableau ───────────────────────────────────────── + + async def get_sitrep(self): + """Download the situation report (.docx). + + Returns the raw response data. + """ + return await self._get('reports/sitrep.docx') + + async def get_tableau_views(self): + """List available Tableau views.""" + return await self._get('reports/tableau-views') + + async def get_tableau_view(self, view_id): + """Get a single Tableau view by ID. + + :param view_id: UUID of the tableau view. + """ + return await self._get(f'reports/tableau-views/{view_id}') + async def _get_data(self, endpoint, params, batch_size=0): if "page" not in params: # Use cursor paginator unless the user has specified a page params["use_cursor"] = "true" diff --git a/tests/async_client/test_analyzers_choices_gear_reports.py b/tests/async_client/test_analyzers_choices_gear_reports.py new file mode 100644 index 0000000..b5ea669 --- /dev/null +++ b/tests/async_client/test_analyzers_choices_gear_reports.py @@ -0,0 +1,328 @@ +"""Tests for analyzers, choices, buoy/gear, and reports endpoints (async client).""" + +import httpx +import pytest +import respx + + +GEAR_ID = "aabb1122-3344-5566-7788-99aabbccddee" +CHOICE_ID = "ccdd1122-3344-5566-7788-99aabbccddee" +VIEW_ID = "eeff1122-3344-5566-7788-99aabbccddee" + + +# ── Fixtures ────────────────────────────────────────────────────── + +@pytest.fixture +def analyzers_spatial_response(): + return { + "data": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "name": "Geofence Analyzer", + "type": "geofence", + "is_active": True, + } + ] + } + + +@pytest.fixture +def analyzers_subject_response(): + return { + "data": [ + { + "id": "22222222-2222-2222-2222-222222222222", + "name": "Immobility Analyzer", + "type": "immobility", + "is_active": True, + } + ] + } + + +@pytest.fixture +def choices_list_response(): + return { + "data": [ + {"id": CHOICE_ID, "field_name": "species", "model": "activity.event"}, + ] + } + + +@pytest.fixture +def choice_detail_response(): + return { + "data": { + "id": CHOICE_ID, + "field_name": "species", + "model": "activity.event", + "choices": [ + {"value": "elephant", "display": "Elephant"}, + {"value": "lion", "display": "Lion"}, + ], + } + } + + +@pytest.fixture +def gear_list_response(): + return { + "data": [ + {"id": GEAR_ID, "name": "Test Buoy", "gear_type": "buoy"}, + ] + } + + +@pytest.fixture +def gear_detail_response(): + return { + "data": { + "id": GEAR_ID, + "name": "Test Buoy", + "gear_type": "buoy", + "status": "active", + } + } + + +@pytest.fixture +def gear_created_response(): + return { + "data": { + "id": GEAR_ID, + "name": "New Buoy", + "gear_type": "buoy", + }, + "status": {"code": 201, "message": "Created"}, + } + + +@pytest.fixture +def gear_updated_response(): + return { + "data": { + "id": GEAR_ID, + "name": "Updated Buoy", + "gear_type": "buoy", + }, + "status": {"code": 200, "message": "OK"}, + } + + +@pytest.fixture +def tableau_views_response(): + return { + "data": [ + {"id": VIEW_ID, "title": "Animal Sightings Dashboard"}, + ] + } + + +@pytest.fixture +def tableau_view_detail_response(): + return { + "data": { + "id": VIEW_ID, + "title": "Animal Sightings Dashboard", + "url": "https://tableau.example.com/view/1", + } + } + + +# ── Analyzer tests ──────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_get_analyzers_spatial(er_client, analyzers_spatial_response): + async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + route = m.get("analyzers/spatial").respond(httpx.codes.OK, json=analyzers_spatial_response) + + result = await er_client.get_analyzers_spatial() + + assert route.called + assert result == analyzers_spatial_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_analyzers_subject(er_client, analyzers_subject_response): + async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + route = m.get("analyzers/subject").respond(httpx.codes.OK, json=analyzers_subject_response) + + result = await er_client.get_analyzers_subject() + + assert route.called + assert result == analyzers_subject_response["data"] + await er_client.close() + + +# ── Choice tests ────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_get_choices(er_client, choices_list_response): + async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + route = m.get("choices").respond(httpx.codes.OK, json=choices_list_response) + + result = await er_client.get_choices() + + assert route.called + assert result == choices_list_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_choice(er_client, choice_detail_response): + async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + route = m.get(f"choices/{CHOICE_ID}").respond(httpx.codes.OK, json=choice_detail_response) + + result = await er_client.get_choice(CHOICE_ID) + + assert route.called + assert result == choice_detail_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_download_choice_icons(er_client): + response_data = {"data": "binary-zip-placeholder"} + async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + route = m.get("choices/icons/download").respond(httpx.codes.OK, json=response_data) + + result = await er_client.download_choice_icons() + + assert route.called + await er_client.close() + + +# ── Gear CRUD tests ─────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_get_gear_list(er_client, gear_list_response): + async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + route = m.get("buoy/gear").respond(httpx.codes.OK, json=gear_list_response) + + result = await er_client.get_gear_list() + + assert route.called + assert result == gear_list_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_gear(er_client, gear_detail_response): + async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + route = m.get(f"buoy/gear/{GEAR_ID}").respond(httpx.codes.OK, json=gear_detail_response) + + result = await er_client.get_gear(GEAR_ID) + + assert route.called + assert result == gear_detail_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_post_gear(er_client, gear_created_response): + async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + route = m.post("buoy/gear").respond(httpx.codes.CREATED, json=gear_created_response) + + result = await er_client.post_gear({"name": "New Buoy", "gear_type": "buoy"}) + + assert route.called + assert result == gear_created_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_patch_gear(er_client, gear_updated_response): + async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + route = m.patch(f"buoy/gear/{GEAR_ID}").respond(httpx.codes.OK, json=gear_updated_response) + + result = await er_client.patch_gear(GEAR_ID, {"name": "Updated Buoy"}) + + assert route.called + assert result == gear_updated_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_delete_gear(er_client): + async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + route = m.delete(f"buoy/gear/{GEAR_ID}").respond(httpx.codes.NO_CONTENT) + + # DELETE returns None from _call (no json body on 204) + # The async client raises on non-200 via raise_for_status, but 204 is ok. + # However our _call tries response.json() which will fail on 204. + # Let's mock a 200 with empty body instead: + route = m.delete(f"buoy/gear/{GEAR_ID}").respond(httpx.codes.OK, json={}) + + result = await er_client.delete_gear(GEAR_ID) + + assert route.called + await er_client.close() + + +# ── Reports / Tableau tests ────────────────────────────────────── + +@pytest.mark.asyncio +async def test_get_sitrep(er_client): + response_data = {"data": "binary-docx-placeholder"} + async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + route = m.get("reports/sitrep.docx").respond(httpx.codes.OK, json=response_data) + + result = await er_client.get_sitrep() + + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_tableau_views(er_client, tableau_views_response): + async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + route = m.get("reports/tableau-views").respond(httpx.codes.OK, json=tableau_views_response) + + result = await er_client.get_tableau_views() + + assert route.called + assert result == tableau_views_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_tableau_view(er_client, tableau_view_detail_response): + async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + route = m.get(f"reports/tableau-views/{VIEW_ID}").respond( + httpx.codes.OK, json=tableau_view_detail_response + ) + + result = await er_client.get_tableau_view(VIEW_ID) + + assert route.called + assert result == tableau_view_detail_response["data"] + await er_client.close() + + +# ── Error handling ──────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_get_gear_not_found(er_client, not_found_response): + from erclient import ERClientNotFound + + async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + m.get(f"buoy/gear/{GEAR_ID}").respond(httpx.codes.NOT_FOUND, json=not_found_response) + + with pytest.raises(ERClientNotFound): + await er_client.get_gear(GEAR_ID) + + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_analyzers_forbidden(er_client, forbidden_response): + from erclient import ERClientPermissionDenied + + async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + m.get("analyzers/spatial").respond(httpx.codes.FORBIDDEN, json=forbidden_response) + + with pytest.raises(ERClientPermissionDenied): + await er_client.get_analyzers_spatial() + + await er_client.close() diff --git a/tests/sync_client/__init__.py b/tests/sync_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/sync_client/conftest.py b/tests/sync_client/conftest.py new file mode 100644 index 0000000..6d9d6da --- /dev/null +++ b/tests/sync_client/conftest.py @@ -0,0 +1,21 @@ +import pytest + +from erclient.client import ERClient + + +@pytest.fixture +def er_server_info(): + return { + "service_root": "https://fake-site.erdomain.org/api/v1.0", + "username": "test", + "password": "test", + "token": "1110c87681cd1d12ad07c2d0f57d15d6079ae5d8", + "token_url": "https://fake-auth.erdomain.org/oauth2/token", + "client_id": "das_web_client", + "provider_key": "testintegration", + } + + +@pytest.fixture +def er_client(er_server_info): + return ERClient(**er_server_info) diff --git a/tests/sync_client/test_analyzers_choices_gear_reports.py b/tests/sync_client/test_analyzers_choices_gear_reports.py new file mode 100644 index 0000000..437085f --- /dev/null +++ b/tests/sync_client/test_analyzers_choices_gear_reports.py @@ -0,0 +1,171 @@ +"""Tests for analyzers, choices, buoy/gear, and reports endpoints (sync client).""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + + +GEAR_ID = "aabb1122-3344-5566-7788-99aabbccddee" +CHOICE_ID = "ccdd1122-3344-5566-7788-99aabbccddee" +VIEW_ID = "eeff1122-3344-5566-7788-99aabbccddee" + + +def _mock_response(json_body, status_code=200): + mock_resp = MagicMock() + mock_resp.ok = status_code < 400 + mock_resp.status_code = status_code + mock_resp.text = json.dumps(json_body) + mock_resp.json.return_value = json_body + return mock_resp + + +# ── Analyzers ───────────────────────────────────────────────────── + +def test_get_analyzers_spatial(er_client): + response = {"data": [{"id": "1", "name": "Geofence"}]} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_analyzers_spatial() + + assert mock_get.called + assert "analyzers/spatial" in mock_get.call_args[0][0] + assert result == response["data"] + + +def test_get_analyzers_subject(er_client): + response = {"data": [{"id": "2", "name": "Immobility"}]} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_analyzers_subject() + + assert mock_get.called + assert "analyzers/subject" in mock_get.call_args[0][0] + assert result == response["data"] + + +# ── Choices ─────────────────────────────────────────────────────── + +def test_get_choices(er_client): + response = {"data": [{"id": CHOICE_ID, "field_name": "species"}]} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_choices() + + assert "choices" in mock_get.call_args[0][0] + assert result == response["data"] + + +def test_get_choice(er_client): + response = {"data": {"id": CHOICE_ID, "field_name": "species"}} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_choice(CHOICE_ID) + + assert f"choices/{CHOICE_ID}" in mock_get.call_args[0][0] + assert result == response["data"] + + +def test_download_choice_icons(er_client): + mock_resp = MagicMock() + mock_resp.ok = True + mock_resp.status_code = 200 + mock_resp.text = '{"data": "zip"}' + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = mock_resp + result = er_client.download_choice_icons() + + assert "choices/icons/download" in mock_get.call_args[0][0] + # return_response=True means we get the raw response back + assert result == mock_resp + + +# ── Gear CRUD ───────────────────────────────────────────────────── + +def test_get_gear_list(er_client): + response = {"data": [{"id": GEAR_ID, "name": "Buoy"}]} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_gear_list() + + assert "buoy/gear" in mock_get.call_args[0][0] + assert result == response["data"] + + +def test_get_gear(er_client): + response = {"data": {"id": GEAR_ID, "name": "Buoy"}} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_gear(GEAR_ID) + + assert f"buoy/gear/{GEAR_ID}" in mock_get.call_args[0][0] + assert result == response["data"] + + +def test_post_gear(er_client): + response = {"data": {"id": GEAR_ID, "name": "New"}} + with patch.object(er_client._http_session, "post") as mock_post: + mock_post.return_value = _mock_response(response, 201) + result = er_client.post_gear({"name": "New", "gear_type": "buoy"}) + + assert mock_post.called + assert "buoy/gear" in mock_post.call_args[0][0] + assert result == response["data"] + + +def test_patch_gear(er_client): + response = {"data": {"id": GEAR_ID, "name": "Updated"}} + with patch.object(er_client._http_session, "patch") as mock_patch: + mock_patch.return_value = _mock_response(response) + result = er_client.patch_gear(GEAR_ID, {"name": "Updated"}) + + assert f"buoy/gear/{GEAR_ID}" in mock_patch.call_args[0][0] + assert result == response["data"] + + +def test_delete_gear(er_client): + mock_resp = MagicMock() + mock_resp.ok = True + mock_resp.status_code = 204 + with patch.object(er_client._http_session, "delete") as mock_delete: + mock_delete.return_value = mock_resp + result = er_client.delete_gear(GEAR_ID) + + assert f"buoy/gear/{GEAR_ID}" in mock_delete.call_args[0][0] + assert result is True + + +# ── Reports / Tableau ───────────────────────────────────────────── + +def test_get_sitrep(er_client): + mock_resp = MagicMock() + mock_resp.ok = True + mock_resp.status_code = 200 + mock_resp.text = b"binary-docx-content" + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = mock_resp + result = er_client.get_sitrep() + + assert "reports/sitrep.docx" in mock_get.call_args[0][0] + # return_response=True => raw response + assert result == mock_resp + + +def test_get_tableau_views(er_client): + response = {"data": [{"id": VIEW_ID, "title": "Dashboard"}]} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_tableau_views() + + assert "reports/tableau-views" in mock_get.call_args[0][0] + assert result == response["data"] + + +def test_get_tableau_view(er_client): + response = {"data": {"id": VIEW_ID, "title": "Dashboard"}} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_tableau_view(VIEW_ID) + + assert f"reports/tableau-views/{VIEW_ID}" in mock_get.call_args[0][0] + assert result == response["data"] From 8e6473403163417b0a181e518da8d44f9396433c Mon Sep 17 00:00:00 2001 From: JoshuaVulcan <38018017+JoshuaVulcan@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:46:33 -0800 Subject: [PATCH 2/3] fix: async binary downloads (download_choice_icons, get_sitrep) - Add AsyncERClient._get_raw() to return raw httpx.Response for binary endpoints - Use _get_raw in async download_choice_icons() and get_sitrep() so callers get response.content / stream instead of broken JSON parse - Update async tests to mock binary content and assert on response.content Addresses ER_CLIENT_PR_REVIEWS.md: PR #41 dependency check and get_sitrep binary response. Self-contained (no dependency on PR #42 _get_response). Co-authored-by: Cursor --- erclient/client.py | 44 +++++++++++++++++-- .../test_analyzers_choices_gear_reports.py | 14 ++++-- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/erclient/client.py b/erclient/client.py index 05d4084..226d910 100644 --- a/erclient/client.py +++ b/erclient/client.py @@ -1506,9 +1506,10 @@ async def get_choice(self, choice_id): async def download_choice_icons(self): """Download the choice icons zip file. - Returns the raw response bytes. + Returns the raw :class:`httpx.Response` so the caller can + stream or save the binary content (e.g. response.content). """ - return await self._get('choices/icons/download') + return await self._get_raw("choices/icons/download") # ── Buoy / Gear ────────────────────────────────────────────── @@ -1550,9 +1551,10 @@ async def delete_gear(self, gear_id): async def get_sitrep(self): """Download the situation report (.docx). - Returns the raw response data. + Returns the raw :class:`httpx.Response` so the caller can + stream or save the binary content (e.g. response.content). """ - return await self._get('reports/sitrep.docx') + return await self._get_raw("reports/sitrep.docx") async def get_tableau_views(self): """List available Tableau views.""" @@ -1610,6 +1612,40 @@ async def _delete(self, path, params=None, base_url=None): path=path, payload=None, method="DELETE", params=params, base_url=base_url ) + async def _get_raw(self, path, params=None): + """Perform a GET request and return the raw httpx.Response (for binary downloads).""" + try: + auth_headers = await self.auth_headers() + except httpx.HTTPStatusError as e: + self._handle_http_status_error(path, "GET", e) + params = params or {} + headers = {"User-Agent": self.user_agent, **auth_headers} + try: + response = await self._http_session.request( + "GET", + self._er_url(path), + params=params, + headers=headers, + ) + response.raise_for_status() + return response + except httpx.RequestError as e: + reason = str(e) + self.logger.error( + "Request to ER failed", + extra=dict( + provider_key=self.provider_key, + service=self.service_root, + path=path, + status_code=None, + reason=reason, + text="", + ), + ) + raise ERClientException(f"Request to ER failed: {reason}") + except httpx.HTTPStatusError as e: + self._handle_http_status_error(path, "GET", e) + async def _call(self, path, payload, method, params=None, base_url=None): try: auth_headers = await self.auth_headers() diff --git a/tests/async_client/test_analyzers_choices_gear_reports.py b/tests/async_client/test_analyzers_choices_gear_reports.py index b5ea669..68c30ec 100644 --- a/tests/async_client/test_analyzers_choices_gear_reports.py +++ b/tests/async_client/test_analyzers_choices_gear_reports.py @@ -183,13 +183,16 @@ async def test_get_choice(er_client, choice_detail_response): @pytest.mark.asyncio async def test_download_choice_icons(er_client): - response_data = {"data": "binary-zip-placeholder"} + binary_content = b"zip-binary-content" async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: - route = m.get("choices/icons/download").respond(httpx.codes.OK, json=response_data) + route = m.get("choices/icons/download").respond( + httpx.codes.OK, content=binary_content + ) result = await er_client.download_choice_icons() assert route.called + assert result.content == binary_content await er_client.close() @@ -264,13 +267,16 @@ async def test_delete_gear(er_client): @pytest.mark.asyncio async def test_get_sitrep(er_client): - response_data = {"data": "binary-docx-placeholder"} + binary_content = b"docx-binary-content" async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: - route = m.get("reports/sitrep.docx").respond(httpx.codes.OK, json=response_data) + route = m.get("reports/sitrep.docx").respond( + httpx.codes.OK, content=binary_content + ) result = await er_client.get_sitrep() assert route.called + assert result.content == binary_content await er_client.close() From b015b47329544e23b9ff55f8cdbc669f47c877e5 Mon Sep 17 00:00:00 2001 From: JoshuaVulcan <38018017+JoshuaVulcan@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:28:17 -0800 Subject: [PATCH 3/3] Fix analyzers/choices/gear/reports async test URL base to use _api_root Co-authored-by: Cursor --- .../test_analyzers_choices_gear_reports.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/async_client/test_analyzers_choices_gear_reports.py b/tests/async_client/test_analyzers_choices_gear_reports.py index 68c30ec..d4863ea 100644 --- a/tests/async_client/test_analyzers_choices_gear_reports.py +++ b/tests/async_client/test_analyzers_choices_gear_reports.py @@ -133,7 +133,7 @@ def tableau_view_detail_response(): @pytest.mark.asyncio async def test_get_analyzers_spatial(er_client, analyzers_spatial_response): - async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: route = m.get("analyzers/spatial").respond(httpx.codes.OK, json=analyzers_spatial_response) result = await er_client.get_analyzers_spatial() @@ -145,7 +145,7 @@ async def test_get_analyzers_spatial(er_client, analyzers_spatial_response): @pytest.mark.asyncio async def test_get_analyzers_subject(er_client, analyzers_subject_response): - async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: route = m.get("analyzers/subject").respond(httpx.codes.OK, json=analyzers_subject_response) result = await er_client.get_analyzers_subject() @@ -159,7 +159,7 @@ async def test_get_analyzers_subject(er_client, analyzers_subject_response): @pytest.mark.asyncio async def test_get_choices(er_client, choices_list_response): - async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: route = m.get("choices").respond(httpx.codes.OK, json=choices_list_response) result = await er_client.get_choices() @@ -171,7 +171,7 @@ async def test_get_choices(er_client, choices_list_response): @pytest.mark.asyncio async def test_get_choice(er_client, choice_detail_response): - async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: route = m.get(f"choices/{CHOICE_ID}").respond(httpx.codes.OK, json=choice_detail_response) result = await er_client.get_choice(CHOICE_ID) @@ -184,7 +184,7 @@ async def test_get_choice(er_client, choice_detail_response): @pytest.mark.asyncio async def test_download_choice_icons(er_client): binary_content = b"zip-binary-content" - async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: route = m.get("choices/icons/download").respond( httpx.codes.OK, content=binary_content ) @@ -200,7 +200,7 @@ async def test_download_choice_icons(er_client): @pytest.mark.asyncio async def test_get_gear_list(er_client, gear_list_response): - async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: route = m.get("buoy/gear").respond(httpx.codes.OK, json=gear_list_response) result = await er_client.get_gear_list() @@ -212,7 +212,7 @@ async def test_get_gear_list(er_client, gear_list_response): @pytest.mark.asyncio async def test_get_gear(er_client, gear_detail_response): - async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: route = m.get(f"buoy/gear/{GEAR_ID}").respond(httpx.codes.OK, json=gear_detail_response) result = await er_client.get_gear(GEAR_ID) @@ -224,7 +224,7 @@ async def test_get_gear(er_client, gear_detail_response): @pytest.mark.asyncio async def test_post_gear(er_client, gear_created_response): - async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: route = m.post("buoy/gear").respond(httpx.codes.CREATED, json=gear_created_response) result = await er_client.post_gear({"name": "New Buoy", "gear_type": "buoy"}) @@ -236,7 +236,7 @@ async def test_post_gear(er_client, gear_created_response): @pytest.mark.asyncio async def test_patch_gear(er_client, gear_updated_response): - async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: route = m.patch(f"buoy/gear/{GEAR_ID}").respond(httpx.codes.OK, json=gear_updated_response) result = await er_client.patch_gear(GEAR_ID, {"name": "Updated Buoy"}) @@ -248,7 +248,7 @@ async def test_patch_gear(er_client, gear_updated_response): @pytest.mark.asyncio async def test_delete_gear(er_client): - async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: route = m.delete(f"buoy/gear/{GEAR_ID}").respond(httpx.codes.NO_CONTENT) # DELETE returns None from _call (no json body on 204) @@ -268,7 +268,7 @@ async def test_delete_gear(er_client): @pytest.mark.asyncio async def test_get_sitrep(er_client): binary_content = b"docx-binary-content" - async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: route = m.get("reports/sitrep.docx").respond( httpx.codes.OK, content=binary_content ) @@ -282,7 +282,7 @@ async def test_get_sitrep(er_client): @pytest.mark.asyncio async def test_get_tableau_views(er_client, tableau_views_response): - async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: route = m.get("reports/tableau-views").respond(httpx.codes.OK, json=tableau_views_response) result = await er_client.get_tableau_views() @@ -294,7 +294,7 @@ async def test_get_tableau_views(er_client, tableau_views_response): @pytest.mark.asyncio async def test_get_tableau_view(er_client, tableau_view_detail_response): - async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: route = m.get(f"reports/tableau-views/{VIEW_ID}").respond( httpx.codes.OK, json=tableau_view_detail_response ) @@ -312,7 +312,7 @@ async def test_get_tableau_view(er_client, tableau_view_detail_response): async def test_get_gear_not_found(er_client, not_found_response): from erclient import ERClientNotFound - async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: m.get(f"buoy/gear/{GEAR_ID}").respond(httpx.codes.NOT_FOUND, json=not_found_response) with pytest.raises(ERClientNotFound): @@ -325,7 +325,7 @@ async def test_get_gear_not_found(er_client, not_found_response): async def test_get_analyzers_forbidden(er_client, forbidden_response): from erclient import ERClientPermissionDenied - async with respx.mock(base_url=er_client.service_root, assert_all_called=False) as m: + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: m.get("analyzers/spatial").respond(httpx.codes.FORBIDDEN, json=forbidden_response) with pytest.raises(ERClientPermissionDenied):