From e52d28cce67738afe863c4cf89dcb34734116aaa Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 14:58:01 +0200 Subject: [PATCH 1/3] fix: fetch single event by uid directly, bypassing get_events() filters (#137, #138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get_event()` previously routed through `_get_entity()`, which called `get_events()` (with no args) to populate a cache and then searched that cache. This had two unrelated-looking-but-shared-root-cause bugs: - Events past the `max_events=100` default were unreachable (#137). - Scheduled events (where `include_scheduled=False` is the default) were unreachable (#138). Switch `_get_entity()` for events to fetch directly from the singular `sponds/{uid}` endpoint, which has neither limit. Verified live against the Spond API: returns 200 + event JSON for real ids, 404 for unknown ids. Map 404→KeyError to preserve the existing exception contract. Group lookups are unchanged — `get_groups()` has no max/filter defaults, and the cache+search behaviour is still useful there. Notes: - The singular endpoint omits the `comments` field that the list endpoint returns; this field is not in `_event_template` and nothing internal reads it. Callers that need comments should fetch them separately. - `update_event()` also calls `_get_entity(EVENT, ...)`; this change incidentally fixes a latent same-class bug there too. --- examples/manual_test_functions.py | 7 ++- spond/spond.py | 22 ++++++-- tests/test_spond.py | 89 +++++++++++++++---------------- 3 files changed, 67 insertions(+), 51 deletions(-) diff --git a/examples/manual_test_functions.py b/examples/manual_test_functions.py index 962c7de..2ae0384 100644 --- a/examples/manual_test_functions.py +++ b/examples/manual_test_functions.py @@ -58,9 +58,14 @@ async def main() -> None: for i, post in enumerate(posts): print(f"[{i}] {_post_summary(post)}") + # Single-event fetch (uses the singular `sponds/{uid}` endpoint) + print("\nGetting the first event by id...") + e = events[0] + event = await s.get_event(e["id"]) + print(_event_summary(event)) + # Attendance export print("\nGetting attendance report for the first event...") - e = events[0] data = await s.get_event_attendance_xlsx(e["id"]) with tempfile.NamedTemporaryFile( mode="wb", diff --git a/spond/spond.py b/spond/spond.py index aad7212..6d4dc9e 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -524,10 +524,11 @@ async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: """ if entity_type == self._EVENT: - if not self.events: - await self.get_events() - entities = self.events - elif entity_type == self._GROUP: + # Direct fetch by uid, so this is not constrained by + # `get_events()`'s default `max_events` cap or its + # `include_scheduled=False` filter. + return await self._fetch_event_by_uid(uid) + if entity_type == self._GROUP: if not self.groups: await self.get_groups() entities = self.groups @@ -543,3 +544,16 @@ async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: if entity["id"] == uid: return entity raise KeyError(errmsg) + + async def _fetch_event_by_uid(self, uid: str) -> JSONDict: + """Fetch a single event from the singular endpoint.""" + url = f"{self.api_url}sponds/{uid}" + async with self.clientsession.get(url, headers=self.auth_headers) as r: + if r.status == 404: + raise KeyError(f"No event with id='{uid}'.") + if not r.ok: + error_details = await r.text() + raise ValueError( + f"Request failed with status {r.status}: {error_details}" + ) + return await r.json() diff --git a/tests/test_spond.py b/tests/test_spond.py index 61aa1c4..8f385dc 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -42,75 +42,72 @@ def mock_payload() -> JSONDict: class TestEventMethods: - @pytest.fixture - def mock_events(self) -> list[JSONDict]: - """Mock a minimal list of events.""" - return [ - { - "id": "ID1", - "name": "Event One", - }, - { - "id": "ID2", - "name": "Event Two", - }, - ] + MOCK_EVENT: JSONDict = { + "id": "ID1", + "heading": "Event One", + } @pytest.mark.asyncio - async def test_get_event__happy_path( - self, mock_events: list[JSONDict], mock_token - ) -> None: - """Test that a valid `id` returns the matching event.""" + @patch("aiohttp.ClientSession.get") + async def test_get_event__happy_path(self, mock_get, mock_token) -> None: + """`get_event()` fetches the singular `sponds/{uid}` endpoint + (not the list endpoint with filters), so it is not constrained + by `get_events()`'s default `max_events` cap or + `include_scheduled=False` filter.""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.events = mock_events s.token = mock_token - g = await s.get_event("ID1") - assert g == { - "id": "ID1", - "name": "Event One", - } + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self.MOCK_EVENT + ) - @pytest.mark.asyncio - async def test_get_event__no_match_raises_exception( - self, mock_events: list[JSONDict], mock_token - ) -> None: - """Test that a non-matched `id` raises KeyError.""" + event = await s.get_event("ID1") - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.events = mock_events - s.token = mock_token - - with pytest.raises(KeyError): - await s.get_event("ID3") + mock_get.assert_called_once_with( + "https://api.spond.com/core/v1/sponds/ID1", + headers={ + "content-type": "application/json", + "Authorization": f"Bearer {mock_token}", + }, + ) + assert event == self.MOCK_EVENT @pytest.mark.asyncio - async def test_get_event__blank_id_match_raises_exception( - self, mock_events: list[JSONDict], mock_token + @patch("aiohttp.ClientSession.get") + async def test_get_event__not_found_raises_keyerror( + self, mock_get, mock_token ) -> None: - """Test that a blank `id` raises KeyError.""" + """A 404 from the singular endpoint surfaces as KeyError.""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.events = mock_events s.token = mock_token + mock_get.return_value.__aenter__.return_value.ok = False + mock_get.return_value.__aenter__.return_value.status = 404 + with pytest.raises(KeyError): - await s.get_event("") + await s.get_event("UNKNOWN") @pytest.mark.asyncio - async def test_get_event__no_events_available_raises_keyerror( - self, mock_token + @patch("aiohttp.ClientSession.get") + async def test_get_event__api_error_raises_valueerror( + self, mock_get, mock_token ) -> None: - """`get_events()` is documented to return None when no events exist; - `get_event()` should surface this as KeyError, not TypeError.""" + """Other non-2xx responses surface as ValueError with the status.""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) s.token = mock_token - s.events = None - s.get_events = AsyncMock() # leaves self.events as None - with pytest.raises(KeyError): + mock_get.return_value.__aenter__.return_value.ok = False + mock_get.return_value.__aenter__.return_value.status = 500 + mock_get.return_value.__aenter__.return_value.text = AsyncMock( + return_value="Internal Server Error" + ) + + with pytest.raises(ValueError, match="500"): await s.get_event("ID1") @pytest.mark.asyncio From 586f017043dd580b2d2bffd99def79e540305c60 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 15:02:32 +0200 Subject: [PATCH 2/3] fix: include comments in singular event fetch Pass `?includeComments=true` so the singular endpoint returns the `comments` field that the list endpoint includes by default. Verified live: response shape now matches the list endpoint exactly (29 keys vs 29 keys, set difference is empty). Keeps `get_event()` output a drop-in replacement for `[e for e in get_events() if ...][0]`. --- spond/spond.py | 11 +++++++++-- tests/test_spond.py | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/spond/spond.py b/spond/spond.py index 6d4dc9e..3d62bc7 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -546,9 +546,16 @@ async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: raise KeyError(errmsg) async def _fetch_event_by_uid(self, uid: str) -> JSONDict: - """Fetch a single event from the singular endpoint.""" + """Fetch a single event from the singular endpoint. + + `includeComments=true` makes the response shape match a list-endpoint + element (the singular endpoint otherwise omits the `comments` field). + """ url = f"{self.api_url}sponds/{uid}" - async with self.clientsession.get(url, headers=self.auth_headers) as r: + params = {"includeComments": "true"} + async with self.clientsession.get( + url, headers=self.auth_headers, params=params + ) as r: if r.status == 404: raise KeyError(f"No event with id='{uid}'.") if not r.ok: diff --git a/tests/test_spond.py b/tests/test_spond.py index 8f385dc..9e55564 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -72,6 +72,7 @@ async def test_get_event__happy_path(self, mock_get, mock_token) -> None: "content-type": "application/json", "Authorization": f"Bearer {mock_token}", }, + params={"includeComments": "true"}, ) assert event == self.MOCK_EVENT From c067fcdd783f9cd7287fa38916e08d8d38ac1e92 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 15:08:33 +0200 Subject: [PATCH 3/3] style: use consistent `errmsg` variable name in _get_entity --- spond/spond.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spond/spond.py b/spond/spond.py index 3d62bc7..72d0859 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -533,8 +533,8 @@ async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: await self.get_groups() entities = self.groups else: - err_msg = f"Entity type '{entity_type}' is not supported." - raise NotImplementedError(err_msg) + errmsg = f"Entity type '{entity_type}' is not supported." + raise NotImplementedError(errmsg) errmsg = f"No {entity_type} with id='{uid}'." if not entities: