spond.spond

Core Spond API client.

This module contains the Spond class, the main entrypoint to the Spond consumer API: account profile, groups, members, events, posts, and chats. For the separate Spond Club finance API, see spond.club.

  1#!/usr/bin/env python3
  2"""Core Spond API client.
  3
  4This module contains the `Spond` class, the main entrypoint to the
  5[Spond](https://spond.com/) consumer API: account profile, groups, members,
  6events, posts, and chats. For the separate Spond Club finance API, see
  7`spond.club`.
  8"""
  9
 10from __future__ import annotations
 11
 12from typing import TYPE_CHECKING, ClassVar
 13
 14from . import JSONDict
 15from ._event_template import _EVENT_TEMPLATE
 16from .base import _SpondBase
 17
 18if TYPE_CHECKING:
 19    from datetime import datetime
 20
 21
 22class Spond(_SpondBase):
 23    """Async client for the Spond consumer API.
 24
 25    Authentication happens lazily on the first API call (via the
 26    `require_authentication` decorator inherited from `spond.base._SpondBase`);
 27    you do not need to call `login()` explicitly.
 28
 29    Several `get_*` methods cache their last response on the instance
 30    (`self.groups`, `self.events`, `self.posts`, `self.messages`,
 31    `self.profile`). This lets lookup helpers like `get_group(uid)` and
 32    `get_person(user)` avoid re-fetching when called repeatedly. To force a
 33    refresh, set the relevant attribute to `None` and call the `get_*` method
 34    again, or call the underlying `get_*s()` method directly.
 35
 36    Remember to close the underlying aiohttp session when finished:
 37
 38    ```python
 39    s = Spond(username="...", password="...")
 40    try:
 41        groups = await s.get_groups()
 42        ...
 43    finally:
 44        await s.clientsession.close()
 45    ```
 46
 47    Example
 48    -------
 49    ```python
 50    import asyncio
 51    from spond import spond
 52
 53    async def main():
 54        s = spond.Spond(username="me@example.invalid", password="secret")
 55        groups = await s.get_groups() or []
 56        for g in groups:
 57            print(g["name"])
 58        await s.clientsession.close()
 59
 60    asyncio.run(main())
 61    ```
 62    """
 63
 64    _API_BASE_URL: ClassVar = "https://api.spond.com/core/v1/"
 65    _DT_FORMAT: ClassVar = "%Y-%m-%dT00:00:00.000Z"
 66    _EVENT_TEMPLATE: ClassVar = _EVENT_TEMPLATE
 67    _EVENT: ClassVar = "event"
 68    _GROUP: ClassVar = "group"
 69
 70    def __init__(self, username: str, password: str) -> None:
 71        """Construct a Spond client.
 72
 73        The credentials are stored on the instance and used to obtain an access
 74        token on the first authenticated call. An aiohttp `ClientSession` is
 75        opened immediately; close it via `await s.clientsession.close()`
 76        (where `s` is the constructed instance) when finished, to avoid
 77        `Unclosed client session` warnings.
 78
 79        Parameters
 80        ----------
 81        username : str
 82            Spond account email address.
 83        password : str
 84            Spond account password. For accounts with 2FA enabled, login will
 85            currently fail — Spond's TOTP flow is not yet supported.
 86        """
 87        super().__init__(username, password, self._API_BASE_URL)
 88        self._chat_url = None
 89        self._auth = None
 90        self.groups: list[JSONDict] | None = None
 91        self.events: list[JSONDict] | None = None
 92        self.posts: list[JSONDict] | None = None
 93        self.messages: list[JSONDict] | None = None
 94        self.profile: JSONDict | None = None
 95
 96    async def _login_chat(self) -> None:
 97        """Perform the secondary handshake with Spond's chat server.
 98
 99        The chat API lives on a separate host and uses its own short-lived
100        token (`self._auth`) rather than the regular Bearer token used by the
101        core API. This method is called lazily by `get_messages`,
102        `send_message`, and `_continue_chat` on their first use; the resulting
103        `self._chat_url` and `self._auth` are cached for the lifetime of the
104        client.
105        """
106        api_chat_url = f"{self.api_url}chat"
107        r = await self.clientsession.post(api_chat_url, headers=self.auth_headers)
108        result = await r.json()
109        self._chat_url = result["url"]
110        self._auth = result["auth"]
111
112    @_SpondBase.require_authentication
113    async def get_profile(self) -> JSONDict:
114        """Retrieve the authenticated user's profile.
115
116        The profile dict includes at least the user's `id`, `firstName`, and
117        `lastName`, plus contact details and account preferences. The full
118        response is cached on `self.profile`.
119
120        Returns
121        -------
122        JSONDict
123            The profile object as returned by the Spond API.
124        """
125        url = f"{self._API_BASE_URL}profile"
126        async with self.clientsession.get(url, headers=self.auth_headers) as r:
127            self.profile = await r.json()
128            return self.profile
129
130    @_SpondBase.require_authentication
131    async def get_groups(self) -> list[JSONDict] | None:
132        """Retrieve every group the authenticated user is a member of.
133
134        Each group dict includes a `members` list, with each member dict
135        containing `id`, `firstName`, `lastName`, and (for child profiles
136        managed by another account) a nested `guardians` list of the same
137        shape. The full response is cached on `self.groups` and reused by
138        `get_group(uid)` and `get_person(user)`.
139
140        Returns
141        -------
142        list[JSONDict] or None
143            A list of groups, each represented as a dictionary. `None` if the
144            account has no groups at all.
145        """
146        url = f"{self.api_url}groups/"
147        async with self.clientsession.get(url, headers=self.auth_headers) as r:
148            self.groups = await r.json()
149            return self.groups
150
151    async def get_group(self, uid: str) -> JSONDict:
152        """Look up a single group by its unique id.
153
154        Searches the cached `self.groups` (populated by `get_groups()` on
155        first call). To force a refresh, set `self.groups = None` first.
156
157        Parameters
158        ----------
159        uid : str
160            UID of the group.
161
162        Returns
163        -------
164        JSONDict
165            The group's details, with the same shape as elements returned by
166            `get_groups()`.
167
168        Raises
169        ------
170        KeyError
171            If no group with the given id is found (or the user has no groups).
172        """
173        return await self._get_entity(self._GROUP, uid)
174
175    @_SpondBase.require_authentication
176    async def get_person(self, user: str) -> JSONDict:
177        """Look up a member or guardian by any of several identifiers.
178
179        Searches every member of every cached group (and each member's
180        `guardians` list). The first match wins. The cache `self.groups` is
181        populated by `get_groups()` if empty.
182
183        Parameters
184        ----------
185        user : str
186            Identifier to match against. Accepted forms:
187
188            - the member's `id`
189            - the member's email (exact match)
190            - first and last name joined by a single space
191              (e.g. `"Ola Thoresen"`)
192            - the member's `profile.id` (different from `id` for child profiles)
193
194        Returns
195        -------
196        JSONDict
197            The first matching member or guardian dict. Shape matches the
198            entries in a group's `members` list from `get_groups()`.
199
200        Raises
201        ------
202        KeyError
203            If no match is found across any group or guardian.
204        """
205        if not self.groups:
206            await self.get_groups()
207        for group in self.groups:
208            for member in group["members"]:
209                if self._match_person(member, user):
210                    return member
211                if "guardians" in member:
212                    for guardian in member["guardians"]:
213                        if self._match_person(guardian, user):
214                            return guardian
215        errmsg = f"No person matched with identifier '{user}'."
216        raise KeyError(errmsg)
217
218    @staticmethod
219    def _match_person(person: JSONDict, match_str: str) -> bool:
220        """Return True if `match_str` matches any of the person's identifiers.
221
222        Used internally by `get_person` to scan group members and guardians.
223        See `get_person` for the list of accepted identifier forms.
224
225        Parameters
226        ----------
227        person : JSONDict
228            A member or guardian dict from a group's `members` list.
229        match_str : str
230            The identifier to test against.
231
232        Returns
233        -------
234        bool
235            True on first matching identifier; False otherwise.
236        """
237        return (
238            person["id"] == match_str
239            or ("email" in person and person["email"]) == match_str
240            or person["firstName"] + " " + person["lastName"] == match_str
241            or ("profile" in person and person["profile"]["id"] == match_str)
242        )
243
244    @_SpondBase.require_authentication
245    async def get_posts(
246        self,
247        group_id: str | None = None,
248        max_posts: int = 20,
249        include_comments: bool = True,
250    ) -> list[JSONDict] | None:
251        """
252        Retrieve posts from group walls.
253
254        Posts are announcements/messages posted to group walls, as opposed to
255        chat messages or events.
256
257        Parameters
258        ----------
259        group_id : str, optional
260            Filter by group. Uses `groupId` API parameter.
261        max_posts : int, optional
262            Set a limit on the number of posts returned.
263            For performance reasons, defaults to 20.
264            Uses `max` API parameter.
265        include_comments : bool, optional
266            Include comments on posts.
267            Defaults to True.
268            Uses `includeComments` API parameter.
269
270        Returns
271        -------
272        list[JSONDict] or None
273            A list of posts, each represented as a dictionary, or None if no
274            posts are available.
275
276        Raises
277        ------
278        ValueError
279            Raised when the request to the API fails.
280        """
281        url = f"{self.api_url}posts/"
282        params: dict[str, str] = {
283            "type": "PLAIN",
284            "max": str(max_posts),
285            "includeComments": str(include_comments).lower(),
286        }
287        if group_id:
288            params["groupId"] = group_id
289
290        async with self.clientsession.get(
291            url, headers=self.auth_headers, params=params
292        ) as r:
293            if not r.ok:
294                error_details = await r.text()
295                raise ValueError(
296                    f"Request failed with status {r.status}: {error_details}"
297                )
298            self.posts = await r.json()
299            return self.posts
300
301    @_SpondBase.require_authentication
302    async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None:
303        """Retrieve recent chats (one-to-one and group conversations).
304
305        "Chats" here refers to the in-app direct/group messaging feature, not
306        comments on events or posts. Uses Spond's separate chat-server host
307        and chat token (handled internally by `_login_chat`).
308
309        The full response is cached on `self.messages`.
310
311        Parameters
312        ----------
313        max_chats : int, optional
314            Maximum number of chats to return. Defaults to 100 for performance.
315            Uses the `max` API parameter.
316
317        Returns
318        -------
319        list[JSONDict] or None
320            A list of chat objects ordered by most recent activity. `None` if
321            the account has no chats.
322        """
323        if not self._auth:
324            await self._login_chat()
325        url = f"{self._chat_url}/chats/"
326        async with self.clientsession.get(
327            url,
328            headers={"auth": self._auth},
329            params={"max": str(max_chats)},
330        ) as r:
331            self.messages = await r.json()
332        return self.messages
333
334    @_SpondBase.require_authentication
335    async def _continue_chat(self, chat_id: str, text: str) -> JSONDict:
336        """Append a text message to an existing chat thread.
337
338        Internal helper used by `send_message` when called with `chat_id`.
339        Performs the lazy chat-server login (`_login_chat`) on first use.
340
341        Parameters
342        ----------
343        chat_id : str
344            Identifier of the existing chat to continue.
345        text : str
346            Message body to send.
347
348        Returns
349        -------
350        JSONDict
351            The Spond API response for the send operation.
352        """
353        if not self._auth:
354            await self._login_chat()
355        url = f"{self._chat_url}/messages"
356        data = {"chatId": chat_id, "text": text, "type": "TEXT"}
357        r = await self.clientsession.post(url, json=data, headers={"auth": self._auth})
358        return await r.json()
359
360    @_SpondBase.require_authentication
361    async def send_message(
362        self,
363        text: str,
364        user: str | None = None,
365        group_uid: str | None = None,
366        chat_id: str | None = None,
367    ) -> JSONDict:
368        """Send a chat message, either continuing an existing thread or
369        starting a new one.
370
371        Two calling patterns:
372
373        - **Continue an existing chat**: pass `chat_id` (the recipient and
374          group context are inferred from the existing thread). `user` and
375          `group_uid` are ignored.
376        - **Start a new chat**: pass both `user` (the recipient) and
377          `group_uid` (the group context the chat belongs to). The user is
378          resolved via `get_person()` to find the underlying profile id.
379
380        Parameters
381        ----------
382        text : str
383            Message body to send.
384        user : str, optional
385            Recipient identifier when starting a new chat. Accepts the same
386            forms as `get_person()`: member id, email, full name, or
387            profile id. Required when `chat_id` is not given.
388        group_uid : str, optional
389            UID of the group that scopes the new chat. Required when `chat_id`
390            is not given.
391        chat_id : str, optional
392            Identifier of an existing chat to continue. When provided,
393            `user` and `group_uid` are not consulted.
394
395        Returns
396        -------
397        JSONDict
398            The Spond API response for the send operation.
399
400        Raises
401        ------
402        ValueError
403            Neither `chat_id` nor both of `user`/`group_uid` were supplied —
404            the call has no way to identify the target chat.
405        KeyError
406            `user` was given but doesn't match any member or guardian in any
407            of the authenticated user's groups (propagated from
408            `get_person`).
409        """
410        if self._auth is None:
411            await self._login_chat()
412
413        if chat_id is not None:
414            return await self._continue_chat(chat_id, text)
415        if group_uid is None or user is None:
416            raise ValueError(
417                "send_message requires either chat_id (to continue an existing "
418                "chat) or both user and group_uid (to start a new one)."
419            )
420
421        user_obj = await self.get_person(user)
422        user_uid = user_obj["profile"]["id"]
423        url = f"{self._chat_url}/messages"
424        data = {
425            "text": text,
426            "type": "TEXT",
427            "recipient": user_uid,
428            "groupId": group_uid,
429        }
430        r = await self.clientsession.post(url, json=data, headers={"auth": self._auth})
431        return await r.json()
432
433    @_SpondBase.require_authentication
434    async def get_events(
435        self,
436        group_id: str | None = None,
437        subgroup_id: str | None = None,
438        include_scheduled: bool = False,
439        include_hidden: bool = False,
440        max_end: datetime | None = None,
441        min_end: datetime | None = None,
442        max_start: datetime | None = None,
443        min_start: datetime | None = None,
444        max_events: int = 100,
445    ) -> list[JSONDict] | None:
446        """Retrieve events visible to the authenticated user.
447
448        Filters can narrow by group/subgroup, by start/end timestamp window,
449        and by visibility (scheduled, hidden). The full response is cached on
450        `self.events`.
451
452        Note: `get_event(uid)` looks up events via this method's cache, so
453        it inherits these defaults — an event that doesn't appear in the
454        first `max_events` results or is excluded by `include_scheduled=False`
455        is unreachable through `get_event()`. If you need broader visibility,
456        call this method directly with appropriate filters.
457
458        Parameters
459        ----------
460        group_id : str, optional
461            Restrict to events belonging to this group. Uses `groupId` API
462            parameter.
463        subgroup_id : str, optional
464            Restrict to events within this subgroup. Uses `subGroupId` API
465            parameter.
466        include_scheduled : bool, optional
467            Include scheduled events (events whose invitations are queued to be
468            sent in the future).
469            Defaults to False for performance reasons.
470            Uses `scheduled` API parameter.
471        include_hidden : bool, optional
472            Include hidden events.
473            Uses `includeHidden` API parameter.
474            'includeHidden' filter is only available inside a group.
475        max_end : datetime, optional
476            Only include events which end before or at this datetime.
477            Uses `maxEndTimestamp` API parameter; relates to `endTimestamp` event
478            attribute.
479        min_end : datetime, optional
480            Only include events which end after or at this datetime.
481            Uses `minEndTimestamp` API parameter; relates to `endTimestamp` event
482            attribute.
483        max_start : datetime, optional
484            Only include events which start before or at this datetime.
485            Uses `maxStartTimestamp` API parameter; relates to `startTimestamp` event
486            attribute.
487        min_start : datetime, optional
488            Only include events which start after or at this datetime.
489            Uses `minStartTimestamp` API parameter; relates to `startTimestamp` event
490            attribute.
491        max_events : int, optional
492            Set a limit on the number of events returned.
493            For performance reasons, defaults to 100.
494            Uses `max` API parameter.
495
496        Returns
497        -------
498        list[JSONDict] or None
499             A list of events, each represented as a dictionary, or None if no events
500             are available.
501
502        Raises
503        ------
504        ValueError
505            Raised when the request to the API fails. This occurs if the response
506            status code indicates an error (e.g., 4xx or 5xx). The error message
507            includes the HTTP status code and the response body for debugging purposes.
508        """
509        url = f"{self.api_url}sponds/"
510        params = {
511            "max": str(max_events),
512            "scheduled": str(include_scheduled),
513        }
514        if max_end:
515            params["maxEndTimestamp"] = max_end.strftime(self._DT_FORMAT)
516        if max_start:
517            params["maxStartTimestamp"] = max_start.strftime(self._DT_FORMAT)
518        if min_end:
519            params["minEndTimestamp"] = min_end.strftime(self._DT_FORMAT)
520        if min_start:
521            params["minStartTimestamp"] = min_start.strftime(self._DT_FORMAT)
522        if group_id:
523            params["groupId"] = group_id
524        if subgroup_id:
525            params["subGroupId"] = subgroup_id
526        if include_hidden:
527            params["includeHidden"] = "true"
528
529        async with self.clientsession.get(
530            url, headers=self.auth_headers, params=params
531        ) as r:
532            if not r.ok:
533                error_details = await r.text()
534                raise ValueError(
535                    f"Request failed with status {r.status}: {error_details}"
536                )
537            self.events = await r.json()
538            return self.events
539
540    async def get_event(self, uid: str) -> JSONDict:
541        """Look up a single event by its unique id.
542
543        Routes through the cached events list (populated by `get_events()`),
544        which means events outside the `max_events=100` default or those
545        excluded by `include_scheduled=False` may not be findable. To reach
546        those events, call `get_events()` directly with appropriate filters
547        first to populate the cache, then call this method.
548
549        Parameters
550        ----------
551        uid : str
552            UID of the event.
553
554        Returns
555        -------
556        JSONDict
557            The event's details, with the same shape as elements returned by
558            `get_events()`.
559
560        Raises
561        ------
562        KeyError
563            If no event with the given id is found in the cache.
564        """
565        return await self._get_entity(self._EVENT, uid)
566
567    @_SpondBase.require_authentication
568    async def update_event(self, uid: str, updates: JSONDict) -> JSONDict:
569        """Update an existing event by merging changes into the current state.
570
571        The implementation fetches the event via `_get_entity()`, copies the
572        fields present in `_EVENT_TEMPLATE` from the existing event as the
573        base, then overlays any keys provided in `updates`. The merged event
574        is POSTed back to `sponds/{uid}`.
575
576        Parameters
577        ----------
578        uid : str
579            UID of the event to update.
580        updates : JSONDict
581            Mapping of keys to new values. Only keys present in
582            `_EVENT_TEMPLATE` are honoured. Example:
583
584            ```python
585            await s.update_event(uid, {"description": "New description"})
586            ```
587
588        Returns
589        -------
590        JSONDict
591            The Spond API response from the POST — the updated event as
592            persisted server-side.
593        """
594        event = await self._get_entity(self._EVENT, uid)
595        url = f"{self.api_url}sponds/{uid}"
596
597        base_event = self._EVENT_TEMPLATE.copy()
598        for key in base_event:
599            if event.get(key) is not None and not updates.get(key):
600                base_event[key] = event[key]
601            elif updates.get(key) is not None:
602                base_event[key] = updates[key]
603
604        async with self.clientsession.post(
605            url, json=base_event, headers=self.auth_headers
606        ) as r:
607            return await r.json()
608
609    @_SpondBase.require_authentication
610    async def get_event_attendance_xlsx(self, uid: str) -> bytes:
611        """Download the attendance report for an event as XLSX bytes.
612
613        Thin wrapper around Spond's own "Export attendance history" feature
614        in the web UI. The columns and format are determined by Spond, not by
615        this library — for example, the export does not include member ids.
616        For a customisable CSV alternative built from `get_event()` data,
617        see `examples/attendance.py`.
618
619        Parameters
620        ----------
621        uid : str
622            UID of the event whose attendance report to fetch.
623
624        Returns
625        -------
626        bytes
627            Raw XLSX file contents. Typically written directly to disk:
628
629            ```python
630            import pathlib
631
632            data = await s.get_event_attendance_xlsx(uid)
633            pathlib.Path(f"{uid}.xlsx").write_bytes(data)
634            ```
635        """
636        url = f"{self.api_url}sponds/{uid}/export"
637        async with self.clientsession.get(url, headers=self.auth_headers) as r:
638            return await r.read()
639
640    @_SpondBase.require_authentication
641    async def change_response(self, uid: str, user: str, payload: JSONDict) -> JSONDict:
642        """Update a single member's response (accept/decline) for an event.
643
644        Useful for managing attendance on someone else's behalf (e.g. a coach
645        accepting on behalf of a player who can't reach the app). The caller
646        must have permission on the event.
647
648        Parameters
649        ----------
650        uid : str
651            UID of the event.
652        user : str
653            UID of the member whose response to change. Note: this is the
654            *member's* id (as seen in `group["members"][i]["id"]`), not the
655            authenticated user's id.
656        payload : JSONDict
657            The response body. Common shapes:
658
659            - `{"accepted": "true"}` — accept the invitation
660            - `{"accepted": "false"}` — decline (Spond may also accept a
661              `"declineMessage"` field with a reason)
662
663        Returns
664        -------
665        JSONDict
666            The event's `responses` object with the updated id lists
667            (`acceptedIds`, `declinedIds`, `unansweredIds`, etc.).
668        """
669        url = f"{self.api_url}sponds/{uid}/responses/{user}"
670        async with self.clientsession.put(
671            url, headers=self.auth_headers, json=payload
672        ) as r:
673            return await r.json()
674
675    @_SpondBase.require_authentication
676    async def _get_entity(self, entity_type: str, uid: str) -> JSONDict:
677        """Internal lookup helper shared by `get_event` and `get_group`.
678
679        Routes to the relevant cache (`self.events` or `self.groups`),
680        triggers a fetch via `get_events()` / `get_groups()` if the cache is
681        empty, then linearly scans for a matching `id`. Raises `KeyError`
682        cleanly (rather than `TypeError`) when the cache remains empty after
683        the fetch attempt — the underlying `get_*s()` method may legitimately
684        return `None` if the account has no events/groups available.
685
686        Parameters
687        ----------
688        entity_type : str
689            One of `self._EVENT` (`"event"`) or `self._GROUP` (`"group"`).
690        uid : str
691            UID of the entity to find.
692
693        Returns
694        -------
695        JSONDict
696            The matching entity dict.
697
698        Raises
699        ------
700        KeyError
701            No entity with that id was found (either because the relevant
702            cache is empty or because the id doesn't appear in it).
703        NotImplementedError
704            `entity_type` is something other than `"event"` or `"group"`.
705        """
706        if entity_type == self._EVENT:
707            if not self.events:
708                await self.get_events()
709            entities = self.events
710        elif entity_type == self._GROUP:
711            if not self.groups:
712                await self.get_groups()
713            entities = self.groups
714        else:
715            errmsg = f"Entity type '{entity_type}' is not supported."
716            raise NotImplementedError(errmsg)
717
718        errmsg = f"No {entity_type} with id='{uid}'."
719        if not entities:
720            raise KeyError(errmsg)
721
722        for entity in entities:
723            if entity["id"] == uid:
724                return entity
725        raise KeyError(errmsg)
class Spond(spond.base._SpondBase):
 23class Spond(_SpondBase):
 24    """Async client for the Spond consumer API.
 25
 26    Authentication happens lazily on the first API call (via the
 27    `require_authentication` decorator inherited from `spond.base._SpondBase`);
 28    you do not need to call `login()` explicitly.
 29
 30    Several `get_*` methods cache their last response on the instance
 31    (`self.groups`, `self.events`, `self.posts`, `self.messages`,
 32    `self.profile`). This lets lookup helpers like `get_group(uid)` and
 33    `get_person(user)` avoid re-fetching when called repeatedly. To force a
 34    refresh, set the relevant attribute to `None` and call the `get_*` method
 35    again, or call the underlying `get_*s()` method directly.
 36
 37    Remember to close the underlying aiohttp session when finished:
 38
 39    ```python
 40    s = Spond(username="...", password="...")
 41    try:
 42        groups = await s.get_groups()
 43        ...
 44    finally:
 45        await s.clientsession.close()
 46    ```
 47
 48    Example
 49    -------
 50    ```python
 51    import asyncio
 52    from spond import spond
 53
 54    async def main():
 55        s = spond.Spond(username="me@example.invalid", password="secret")
 56        groups = await s.get_groups() or []
 57        for g in groups:
 58            print(g["name"])
 59        await s.clientsession.close()
 60
 61    asyncio.run(main())
 62    ```
 63    """
 64
 65    _API_BASE_URL: ClassVar = "https://api.spond.com/core/v1/"
 66    _DT_FORMAT: ClassVar = "%Y-%m-%dT00:00:00.000Z"
 67    _EVENT_TEMPLATE: ClassVar = _EVENT_TEMPLATE
 68    _EVENT: ClassVar = "event"
 69    _GROUP: ClassVar = "group"
 70
 71    def __init__(self, username: str, password: str) -> None:
 72        """Construct a Spond client.
 73
 74        The credentials are stored on the instance and used to obtain an access
 75        token on the first authenticated call. An aiohttp `ClientSession` is
 76        opened immediately; close it via `await s.clientsession.close()`
 77        (where `s` is the constructed instance) when finished, to avoid
 78        `Unclosed client session` warnings.
 79
 80        Parameters
 81        ----------
 82        username : str
 83            Spond account email address.
 84        password : str
 85            Spond account password. For accounts with 2FA enabled, login will
 86            currently fail — Spond's TOTP flow is not yet supported.
 87        """
 88        super().__init__(username, password, self._API_BASE_URL)
 89        self._chat_url = None
 90        self._auth = None
 91        self.groups: list[JSONDict] | None = None
 92        self.events: list[JSONDict] | None = None
 93        self.posts: list[JSONDict] | None = None
 94        self.messages: list[JSONDict] | None = None
 95        self.profile: JSONDict | None = None
 96
 97    async def _login_chat(self) -> None:
 98        """Perform the secondary handshake with Spond's chat server.
 99
100        The chat API lives on a separate host and uses its own short-lived
101        token (`self._auth`) rather than the regular Bearer token used by the
102        core API. This method is called lazily by `get_messages`,
103        `send_message`, and `_continue_chat` on their first use; the resulting
104        `self._chat_url` and `self._auth` are cached for the lifetime of the
105        client.
106        """
107        api_chat_url = f"{self.api_url}chat"
108        r = await self.clientsession.post(api_chat_url, headers=self.auth_headers)
109        result = await r.json()
110        self._chat_url = result["url"]
111        self._auth = result["auth"]
112
113    @_SpondBase.require_authentication
114    async def get_profile(self) -> JSONDict:
115        """Retrieve the authenticated user's profile.
116
117        The profile dict includes at least the user's `id`, `firstName`, and
118        `lastName`, plus contact details and account preferences. The full
119        response is cached on `self.profile`.
120
121        Returns
122        -------
123        JSONDict
124            The profile object as returned by the Spond API.
125        """
126        url = f"{self._API_BASE_URL}profile"
127        async with self.clientsession.get(url, headers=self.auth_headers) as r:
128            self.profile = await r.json()
129            return self.profile
130
131    @_SpondBase.require_authentication
132    async def get_groups(self) -> list[JSONDict] | None:
133        """Retrieve every group the authenticated user is a member of.
134
135        Each group dict includes a `members` list, with each member dict
136        containing `id`, `firstName`, `lastName`, and (for child profiles
137        managed by another account) a nested `guardians` list of the same
138        shape. The full response is cached on `self.groups` and reused by
139        `get_group(uid)` and `get_person(user)`.
140
141        Returns
142        -------
143        list[JSONDict] or None
144            A list of groups, each represented as a dictionary. `None` if the
145            account has no groups at all.
146        """
147        url = f"{self.api_url}groups/"
148        async with self.clientsession.get(url, headers=self.auth_headers) as r:
149            self.groups = await r.json()
150            return self.groups
151
152    async def get_group(self, uid: str) -> JSONDict:
153        """Look up a single group by its unique id.
154
155        Searches the cached `self.groups` (populated by `get_groups()` on
156        first call). To force a refresh, set `self.groups = None` first.
157
158        Parameters
159        ----------
160        uid : str
161            UID of the group.
162
163        Returns
164        -------
165        JSONDict
166            The group's details, with the same shape as elements returned by
167            `get_groups()`.
168
169        Raises
170        ------
171        KeyError
172            If no group with the given id is found (or the user has no groups).
173        """
174        return await self._get_entity(self._GROUP, uid)
175
176    @_SpondBase.require_authentication
177    async def get_person(self, user: str) -> JSONDict:
178        """Look up a member or guardian by any of several identifiers.
179
180        Searches every member of every cached group (and each member's
181        `guardians` list). The first match wins. The cache `self.groups` is
182        populated by `get_groups()` if empty.
183
184        Parameters
185        ----------
186        user : str
187            Identifier to match against. Accepted forms:
188
189            - the member's `id`
190            - the member's email (exact match)
191            - first and last name joined by a single space
192              (e.g. `"Ola Thoresen"`)
193            - the member's `profile.id` (different from `id` for child profiles)
194
195        Returns
196        -------
197        JSONDict
198            The first matching member or guardian dict. Shape matches the
199            entries in a group's `members` list from `get_groups()`.
200
201        Raises
202        ------
203        KeyError
204            If no match is found across any group or guardian.
205        """
206        if not self.groups:
207            await self.get_groups()
208        for group in self.groups:
209            for member in group["members"]:
210                if self._match_person(member, user):
211                    return member
212                if "guardians" in member:
213                    for guardian in member["guardians"]:
214                        if self._match_person(guardian, user):
215                            return guardian
216        errmsg = f"No person matched with identifier '{user}'."
217        raise KeyError(errmsg)
218
219    @staticmethod
220    def _match_person(person: JSONDict, match_str: str) -> bool:
221        """Return True if `match_str` matches any of the person's identifiers.
222
223        Used internally by `get_person` to scan group members and guardians.
224        See `get_person` for the list of accepted identifier forms.
225
226        Parameters
227        ----------
228        person : JSONDict
229            A member or guardian dict from a group's `members` list.
230        match_str : str
231            The identifier to test against.
232
233        Returns
234        -------
235        bool
236            True on first matching identifier; False otherwise.
237        """
238        return (
239            person["id"] == match_str
240            or ("email" in person and person["email"]) == match_str
241            or person["firstName"] + " " + person["lastName"] == match_str
242            or ("profile" in person and person["profile"]["id"] == match_str)
243        )
244
245    @_SpondBase.require_authentication
246    async def get_posts(
247        self,
248        group_id: str | None = None,
249        max_posts: int = 20,
250        include_comments: bool = True,
251    ) -> list[JSONDict] | None:
252        """
253        Retrieve posts from group walls.
254
255        Posts are announcements/messages posted to group walls, as opposed to
256        chat messages or events.
257
258        Parameters
259        ----------
260        group_id : str, optional
261            Filter by group. Uses `groupId` API parameter.
262        max_posts : int, optional
263            Set a limit on the number of posts returned.
264            For performance reasons, defaults to 20.
265            Uses `max` API parameter.
266        include_comments : bool, optional
267            Include comments on posts.
268            Defaults to True.
269            Uses `includeComments` API parameter.
270
271        Returns
272        -------
273        list[JSONDict] or None
274            A list of posts, each represented as a dictionary, or None if no
275            posts are available.
276
277        Raises
278        ------
279        ValueError
280            Raised when the request to the API fails.
281        """
282        url = f"{self.api_url}posts/"
283        params: dict[str, str] = {
284            "type": "PLAIN",
285            "max": str(max_posts),
286            "includeComments": str(include_comments).lower(),
287        }
288        if group_id:
289            params["groupId"] = group_id
290
291        async with self.clientsession.get(
292            url, headers=self.auth_headers, params=params
293        ) as r:
294            if not r.ok:
295                error_details = await r.text()
296                raise ValueError(
297                    f"Request failed with status {r.status}: {error_details}"
298                )
299            self.posts = await r.json()
300            return self.posts
301
302    @_SpondBase.require_authentication
303    async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None:
304        """Retrieve recent chats (one-to-one and group conversations).
305
306        "Chats" here refers to the in-app direct/group messaging feature, not
307        comments on events or posts. Uses Spond's separate chat-server host
308        and chat token (handled internally by `_login_chat`).
309
310        The full response is cached on `self.messages`.
311
312        Parameters
313        ----------
314        max_chats : int, optional
315            Maximum number of chats to return. Defaults to 100 for performance.
316            Uses the `max` API parameter.
317
318        Returns
319        -------
320        list[JSONDict] or None
321            A list of chat objects ordered by most recent activity. `None` if
322            the account has no chats.
323        """
324        if not self._auth:
325            await self._login_chat()
326        url = f"{self._chat_url}/chats/"
327        async with self.clientsession.get(
328            url,
329            headers={"auth": self._auth},
330            params={"max": str(max_chats)},
331        ) as r:
332            self.messages = await r.json()
333        return self.messages
334
335    @_SpondBase.require_authentication
336    async def _continue_chat(self, chat_id: str, text: str) -> JSONDict:
337        """Append a text message to an existing chat thread.
338
339        Internal helper used by `send_message` when called with `chat_id`.
340        Performs the lazy chat-server login (`_login_chat`) on first use.
341
342        Parameters
343        ----------
344        chat_id : str
345            Identifier of the existing chat to continue.
346        text : str
347            Message body to send.
348
349        Returns
350        -------
351        JSONDict
352            The Spond API response for the send operation.
353        """
354        if not self._auth:
355            await self._login_chat()
356        url = f"{self._chat_url}/messages"
357        data = {"chatId": chat_id, "text": text, "type": "TEXT"}
358        r = await self.clientsession.post(url, json=data, headers={"auth": self._auth})
359        return await r.json()
360
361    @_SpondBase.require_authentication
362    async def send_message(
363        self,
364        text: str,
365        user: str | None = None,
366        group_uid: str | None = None,
367        chat_id: str | None = None,
368    ) -> JSONDict:
369        """Send a chat message, either continuing an existing thread or
370        starting a new one.
371
372        Two calling patterns:
373
374        - **Continue an existing chat**: pass `chat_id` (the recipient and
375          group context are inferred from the existing thread). `user` and
376          `group_uid` are ignored.
377        - **Start a new chat**: pass both `user` (the recipient) and
378          `group_uid` (the group context the chat belongs to). The user is
379          resolved via `get_person()` to find the underlying profile id.
380
381        Parameters
382        ----------
383        text : str
384            Message body to send.
385        user : str, optional
386            Recipient identifier when starting a new chat. Accepts the same
387            forms as `get_person()`: member id, email, full name, or
388            profile id. Required when `chat_id` is not given.
389        group_uid : str, optional
390            UID of the group that scopes the new chat. Required when `chat_id`
391            is not given.
392        chat_id : str, optional
393            Identifier of an existing chat to continue. When provided,
394            `user` and `group_uid` are not consulted.
395
396        Returns
397        -------
398        JSONDict
399            The Spond API response for the send operation.
400
401        Raises
402        ------
403        ValueError
404            Neither `chat_id` nor both of `user`/`group_uid` were supplied —
405            the call has no way to identify the target chat.
406        KeyError
407            `user` was given but doesn't match any member or guardian in any
408            of the authenticated user's groups (propagated from
409            `get_person`).
410        """
411        if self._auth is None:
412            await self._login_chat()
413
414        if chat_id is not None:
415            return await self._continue_chat(chat_id, text)
416        if group_uid is None or user is None:
417            raise ValueError(
418                "send_message requires either chat_id (to continue an existing "
419                "chat) or both user and group_uid (to start a new one)."
420            )
421
422        user_obj = await self.get_person(user)
423        user_uid = user_obj["profile"]["id"]
424        url = f"{self._chat_url}/messages"
425        data = {
426            "text": text,
427            "type": "TEXT",
428            "recipient": user_uid,
429            "groupId": group_uid,
430        }
431        r = await self.clientsession.post(url, json=data, headers={"auth": self._auth})
432        return await r.json()
433
434    @_SpondBase.require_authentication
435    async def get_events(
436        self,
437        group_id: str | None = None,
438        subgroup_id: str | None = None,
439        include_scheduled: bool = False,
440        include_hidden: bool = False,
441        max_end: datetime | None = None,
442        min_end: datetime | None = None,
443        max_start: datetime | None = None,
444        min_start: datetime | None = None,
445        max_events: int = 100,
446    ) -> list[JSONDict] | None:
447        """Retrieve events visible to the authenticated user.
448
449        Filters can narrow by group/subgroup, by start/end timestamp window,
450        and by visibility (scheduled, hidden). The full response is cached on
451        `self.events`.
452
453        Note: `get_event(uid)` looks up events via this method's cache, so
454        it inherits these defaults — an event that doesn't appear in the
455        first `max_events` results or is excluded by `include_scheduled=False`
456        is unreachable through `get_event()`. If you need broader visibility,
457        call this method directly with appropriate filters.
458
459        Parameters
460        ----------
461        group_id : str, optional
462            Restrict to events belonging to this group. Uses `groupId` API
463            parameter.
464        subgroup_id : str, optional
465            Restrict to events within this subgroup. Uses `subGroupId` API
466            parameter.
467        include_scheduled : bool, optional
468            Include scheduled events (events whose invitations are queued to be
469            sent in the future).
470            Defaults to False for performance reasons.
471            Uses `scheduled` API parameter.
472        include_hidden : bool, optional
473            Include hidden events.
474            Uses `includeHidden` API parameter.
475            'includeHidden' filter is only available inside a group.
476        max_end : datetime, optional
477            Only include events which end before or at this datetime.
478            Uses `maxEndTimestamp` API parameter; relates to `endTimestamp` event
479            attribute.
480        min_end : datetime, optional
481            Only include events which end after or at this datetime.
482            Uses `minEndTimestamp` API parameter; relates to `endTimestamp` event
483            attribute.
484        max_start : datetime, optional
485            Only include events which start before or at this datetime.
486            Uses `maxStartTimestamp` API parameter; relates to `startTimestamp` event
487            attribute.
488        min_start : datetime, optional
489            Only include events which start after or at this datetime.
490            Uses `minStartTimestamp` API parameter; relates to `startTimestamp` event
491            attribute.
492        max_events : int, optional
493            Set a limit on the number of events returned.
494            For performance reasons, defaults to 100.
495            Uses `max` API parameter.
496
497        Returns
498        -------
499        list[JSONDict] or None
500             A list of events, each represented as a dictionary, or None if no events
501             are available.
502
503        Raises
504        ------
505        ValueError
506            Raised when the request to the API fails. This occurs if the response
507            status code indicates an error (e.g., 4xx or 5xx). The error message
508            includes the HTTP status code and the response body for debugging purposes.
509        """
510        url = f"{self.api_url}sponds/"
511        params = {
512            "max": str(max_events),
513            "scheduled": str(include_scheduled),
514        }
515        if max_end:
516            params["maxEndTimestamp"] = max_end.strftime(self._DT_FORMAT)
517        if max_start:
518            params["maxStartTimestamp"] = max_start.strftime(self._DT_FORMAT)
519        if min_end:
520            params["minEndTimestamp"] = min_end.strftime(self._DT_FORMAT)
521        if min_start:
522            params["minStartTimestamp"] = min_start.strftime(self._DT_FORMAT)
523        if group_id:
524            params["groupId"] = group_id
525        if subgroup_id:
526            params["subGroupId"] = subgroup_id
527        if include_hidden:
528            params["includeHidden"] = "true"
529
530        async with self.clientsession.get(
531            url, headers=self.auth_headers, params=params
532        ) as r:
533            if not r.ok:
534                error_details = await r.text()
535                raise ValueError(
536                    f"Request failed with status {r.status}: {error_details}"
537                )
538            self.events = await r.json()
539            return self.events
540
541    async def get_event(self, uid: str) -> JSONDict:
542        """Look up a single event by its unique id.
543
544        Routes through the cached events list (populated by `get_events()`),
545        which means events outside the `max_events=100` default or those
546        excluded by `include_scheduled=False` may not be findable. To reach
547        those events, call `get_events()` directly with appropriate filters
548        first to populate the cache, then call this method.
549
550        Parameters
551        ----------
552        uid : str
553            UID of the event.
554
555        Returns
556        -------
557        JSONDict
558            The event's details, with the same shape as elements returned by
559            `get_events()`.
560
561        Raises
562        ------
563        KeyError
564            If no event with the given id is found in the cache.
565        """
566        return await self._get_entity(self._EVENT, uid)
567
568    @_SpondBase.require_authentication
569    async def update_event(self, uid: str, updates: JSONDict) -> JSONDict:
570        """Update an existing event by merging changes into the current state.
571
572        The implementation fetches the event via `_get_entity()`, copies the
573        fields present in `_EVENT_TEMPLATE` from the existing event as the
574        base, then overlays any keys provided in `updates`. The merged event
575        is POSTed back to `sponds/{uid}`.
576
577        Parameters
578        ----------
579        uid : str
580            UID of the event to update.
581        updates : JSONDict
582            Mapping of keys to new values. Only keys present in
583            `_EVENT_TEMPLATE` are honoured. Example:
584
585            ```python
586            await s.update_event(uid, {"description": "New description"})
587            ```
588
589        Returns
590        -------
591        JSONDict
592            The Spond API response from the POST — the updated event as
593            persisted server-side.
594        """
595        event = await self._get_entity(self._EVENT, uid)
596        url = f"{self.api_url}sponds/{uid}"
597
598        base_event = self._EVENT_TEMPLATE.copy()
599        for key in base_event:
600            if event.get(key) is not None and not updates.get(key):
601                base_event[key] = event[key]
602            elif updates.get(key) is not None:
603                base_event[key] = updates[key]
604
605        async with self.clientsession.post(
606            url, json=base_event, headers=self.auth_headers
607        ) as r:
608            return await r.json()
609
610    @_SpondBase.require_authentication
611    async def get_event_attendance_xlsx(self, uid: str) -> bytes:
612        """Download the attendance report for an event as XLSX bytes.
613
614        Thin wrapper around Spond's own "Export attendance history" feature
615        in the web UI. The columns and format are determined by Spond, not by
616        this library — for example, the export does not include member ids.
617        For a customisable CSV alternative built from `get_event()` data,
618        see `examples/attendance.py`.
619
620        Parameters
621        ----------
622        uid : str
623            UID of the event whose attendance report to fetch.
624
625        Returns
626        -------
627        bytes
628            Raw XLSX file contents. Typically written directly to disk:
629
630            ```python
631            import pathlib
632
633            data = await s.get_event_attendance_xlsx(uid)
634            pathlib.Path(f"{uid}.xlsx").write_bytes(data)
635            ```
636        """
637        url = f"{self.api_url}sponds/{uid}/export"
638        async with self.clientsession.get(url, headers=self.auth_headers) as r:
639            return await r.read()
640
641    @_SpondBase.require_authentication
642    async def change_response(self, uid: str, user: str, payload: JSONDict) -> JSONDict:
643        """Update a single member's response (accept/decline) for an event.
644
645        Useful for managing attendance on someone else's behalf (e.g. a coach
646        accepting on behalf of a player who can't reach the app). The caller
647        must have permission on the event.
648
649        Parameters
650        ----------
651        uid : str
652            UID of the event.
653        user : str
654            UID of the member whose response to change. Note: this is the
655            *member's* id (as seen in `group["members"][i]["id"]`), not the
656            authenticated user's id.
657        payload : JSONDict
658            The response body. Common shapes:
659
660            - `{"accepted": "true"}` — accept the invitation
661            - `{"accepted": "false"}` — decline (Spond may also accept a
662              `"declineMessage"` field with a reason)
663
664        Returns
665        -------
666        JSONDict
667            The event's `responses` object with the updated id lists
668            (`acceptedIds`, `declinedIds`, `unansweredIds`, etc.).
669        """
670        url = f"{self.api_url}sponds/{uid}/responses/{user}"
671        async with self.clientsession.put(
672            url, headers=self.auth_headers, json=payload
673        ) as r:
674            return await r.json()
675
676    @_SpondBase.require_authentication
677    async def _get_entity(self, entity_type: str, uid: str) -> JSONDict:
678        """Internal lookup helper shared by `get_event` and `get_group`.
679
680        Routes to the relevant cache (`self.events` or `self.groups`),
681        triggers a fetch via `get_events()` / `get_groups()` if the cache is
682        empty, then linearly scans for a matching `id`. Raises `KeyError`
683        cleanly (rather than `TypeError`) when the cache remains empty after
684        the fetch attempt — the underlying `get_*s()` method may legitimately
685        return `None` if the account has no events/groups available.
686
687        Parameters
688        ----------
689        entity_type : str
690            One of `self._EVENT` (`"event"`) or `self._GROUP` (`"group"`).
691        uid : str
692            UID of the entity to find.
693
694        Returns
695        -------
696        JSONDict
697            The matching entity dict.
698
699        Raises
700        ------
701        KeyError
702            No entity with that id was found (either because the relevant
703            cache is empty or because the id doesn't appear in it).
704        NotImplementedError
705            `entity_type` is something other than `"event"` or `"group"`.
706        """
707        if entity_type == self._EVENT:
708            if not self.events:
709                await self.get_events()
710            entities = self.events
711        elif entity_type == self._GROUP:
712            if not self.groups:
713                await self.get_groups()
714            entities = self.groups
715        else:
716            errmsg = f"Entity type '{entity_type}' is not supported."
717            raise NotImplementedError(errmsg)
718
719        errmsg = f"No {entity_type} with id='{uid}'."
720        if not entities:
721            raise KeyError(errmsg)
722
723        for entity in entities:
724            if entity["id"] == uid:
725                return entity
726        raise KeyError(errmsg)

Async client for the Spond consumer API.

Authentication happens lazily on the first API call (via the require_authentication decorator inherited from spond.base._SpondBase); you do not need to call login() explicitly.

Several get_* methods cache their last response on the instance (self.groups, self.events, self.posts, self.messages, self.profile). This lets lookup helpers like get_group(uid) and get_person(user) avoid re-fetching when called repeatedly. To force a refresh, set the relevant attribute to None and call the get_* method again, or call the underlying get_*s() method directly.

Remember to close the underlying aiohttp session when finished:

s = Spond(username="...", password="...")
try:
    groups = await s.get_groups()
    ...
finally:
    await s.clientsession.close()
Example
import asyncio
from spond import spond

async def main():
    s = spond.Spond(username="me@example.invalid", password="secret")
    groups = await s.get_groups() or []
    for g in groups:
        print(g["name"])
    await s.clientsession.close()

asyncio.run(main())
Spond(username: str, password: str)
71    def __init__(self, username: str, password: str) -> None:
72        """Construct a Spond client.
73
74        The credentials are stored on the instance and used to obtain an access
75        token on the first authenticated call. An aiohttp `ClientSession` is
76        opened immediately; close it via `await s.clientsession.close()`
77        (where `s` is the constructed instance) when finished, to avoid
78        `Unclosed client session` warnings.
79
80        Parameters
81        ----------
82        username : str
83            Spond account email address.
84        password : str
85            Spond account password. For accounts with 2FA enabled, login will
86            currently fail — Spond's TOTP flow is not yet supported.
87        """
88        super().__init__(username, password, self._API_BASE_URL)
89        self._chat_url = None
90        self._auth = None
91        self.groups: list[JSONDict] | None = None
92        self.events: list[JSONDict] | None = None
93        self.posts: list[JSONDict] | None = None
94        self.messages: list[JSONDict] | None = None
95        self.profile: JSONDict | None = None

Construct a Spond client.

The credentials are stored on the instance and used to obtain an access token on the first authenticated call. An aiohttp ClientSession is opened immediately; close it via await s.clientsession.close() (where s is the constructed instance) when finished, to avoid Unclosed client session warnings.

Parameters
  • username (str): Spond account email address.
  • password (str): Spond account password. For accounts with 2FA enabled, login will currently fail — Spond's TOTP flow is not yet supported.
groups: list[dict[str, Any]] | None
events: list[dict[str, Any]] | None
posts: list[dict[str, Any]] | None
messages: list[dict[str, Any]] | None
profile: dict[str, Any] | None
async def get_profile(self) -> dict[str, typing.Any]:
113    @_SpondBase.require_authentication
114    async def get_profile(self) -> JSONDict:
115        """Retrieve the authenticated user's profile.
116
117        The profile dict includes at least the user's `id`, `firstName`, and
118        `lastName`, plus contact details and account preferences. The full
119        response is cached on `self.profile`.
120
121        Returns
122        -------
123        JSONDict
124            The profile object as returned by the Spond API.
125        """
126        url = f"{self._API_BASE_URL}profile"
127        async with self.clientsession.get(url, headers=self.auth_headers) as r:
128            self.profile = await r.json()
129            return self.profile

Retrieve the authenticated user's profile.

The profile dict includes at least the user's id, firstName, and lastName, plus contact details and account preferences. The full response is cached on self.profile.

Returns
  • JSONDict: The profile object as returned by the Spond API.
async def get_groups(self) -> list[dict[str, Any]] | None:
131    @_SpondBase.require_authentication
132    async def get_groups(self) -> list[JSONDict] | None:
133        """Retrieve every group the authenticated user is a member of.
134
135        Each group dict includes a `members` list, with each member dict
136        containing `id`, `firstName`, `lastName`, and (for child profiles
137        managed by another account) a nested `guardians` list of the same
138        shape. The full response is cached on `self.groups` and reused by
139        `get_group(uid)` and `get_person(user)`.
140
141        Returns
142        -------
143        list[JSONDict] or None
144            A list of groups, each represented as a dictionary. `None` if the
145            account has no groups at all.
146        """
147        url = f"{self.api_url}groups/"
148        async with self.clientsession.get(url, headers=self.auth_headers) as r:
149            self.groups = await r.json()
150            return self.groups

Retrieve every group the authenticated user is a member of.

Each group dict includes a members list, with each member dict containing id, firstName, lastName, and (for child profiles managed by another account) a nested guardians list of the same shape. The full response is cached on self.groups and reused by get_group(uid) and get_person(user).

Returns
  • list[JSONDict] or None: A list of groups, each represented as a dictionary. None if the account has no groups at all.
async def get_group(self, uid: str) -> dict[str, typing.Any]:
152    async def get_group(self, uid: str) -> JSONDict:
153        """Look up a single group by its unique id.
154
155        Searches the cached `self.groups` (populated by `get_groups()` on
156        first call). To force a refresh, set `self.groups = None` first.
157
158        Parameters
159        ----------
160        uid : str
161            UID of the group.
162
163        Returns
164        -------
165        JSONDict
166            The group's details, with the same shape as elements returned by
167            `get_groups()`.
168
169        Raises
170        ------
171        KeyError
172            If no group with the given id is found (or the user has no groups).
173        """
174        return await self._get_entity(self._GROUP, uid)

Look up a single group by its unique id.

Searches the cached self.groups (populated by get_groups() on first call). To force a refresh, set self.groups = None first.

Parameters
  • uid (str): UID of the group.
Returns
  • JSONDict: The group's details, with the same shape as elements returned by get_groups().
Raises
  • KeyError: If no group with the given id is found (or the user has no groups).
async def get_person(self, user: str) -> dict[str, typing.Any]:
176    @_SpondBase.require_authentication
177    async def get_person(self, user: str) -> JSONDict:
178        """Look up a member or guardian by any of several identifiers.
179
180        Searches every member of every cached group (and each member's
181        `guardians` list). The first match wins. The cache `self.groups` is
182        populated by `get_groups()` if empty.
183
184        Parameters
185        ----------
186        user : str
187            Identifier to match against. Accepted forms:
188
189            - the member's `id`
190            - the member's email (exact match)
191            - first and last name joined by a single space
192              (e.g. `"Ola Thoresen"`)
193            - the member's `profile.id` (different from `id` for child profiles)
194
195        Returns
196        -------
197        JSONDict
198            The first matching member or guardian dict. Shape matches the
199            entries in a group's `members` list from `get_groups()`.
200
201        Raises
202        ------
203        KeyError
204            If no match is found across any group or guardian.
205        """
206        if not self.groups:
207            await self.get_groups()
208        for group in self.groups:
209            for member in group["members"]:
210                if self._match_person(member, user):
211                    return member
212                if "guardians" in member:
213                    for guardian in member["guardians"]:
214                        if self._match_person(guardian, user):
215                            return guardian
216        errmsg = f"No person matched with identifier '{user}'."
217        raise KeyError(errmsg)

Look up a member or guardian by any of several identifiers.

Searches every member of every cached group (and each member's guardians list). The first match wins. The cache self.groups is populated by get_groups() if empty.

Parameters
  • user (str): Identifier to match against. Accepted forms:

    • the member's id
    • the member's email (exact match)
    • first and last name joined by a single space (e.g. "Ola Thoresen")
    • the member's profile.id (different from id for child profiles)
Returns
  • JSONDict: The first matching member or guardian dict. Shape matches the entries in a group's members list from get_groups().
Raises
  • KeyError: If no match is found across any group or guardian.
async def get_posts( self, group_id: str | None = None, max_posts: int = 20, include_comments: bool = True) -> list[dict[str, Any]] | None:
245    @_SpondBase.require_authentication
246    async def get_posts(
247        self,
248        group_id: str | None = None,
249        max_posts: int = 20,
250        include_comments: bool = True,
251    ) -> list[JSONDict] | None:
252        """
253        Retrieve posts from group walls.
254
255        Posts are announcements/messages posted to group walls, as opposed to
256        chat messages or events.
257
258        Parameters
259        ----------
260        group_id : str, optional
261            Filter by group. Uses `groupId` API parameter.
262        max_posts : int, optional
263            Set a limit on the number of posts returned.
264            For performance reasons, defaults to 20.
265            Uses `max` API parameter.
266        include_comments : bool, optional
267            Include comments on posts.
268            Defaults to True.
269            Uses `includeComments` API parameter.
270
271        Returns
272        -------
273        list[JSONDict] or None
274            A list of posts, each represented as a dictionary, or None if no
275            posts are available.
276
277        Raises
278        ------
279        ValueError
280            Raised when the request to the API fails.
281        """
282        url = f"{self.api_url}posts/"
283        params: dict[str, str] = {
284            "type": "PLAIN",
285            "max": str(max_posts),
286            "includeComments": str(include_comments).lower(),
287        }
288        if group_id:
289            params["groupId"] = group_id
290
291        async with self.clientsession.get(
292            url, headers=self.auth_headers, params=params
293        ) as r:
294            if not r.ok:
295                error_details = await r.text()
296                raise ValueError(
297                    f"Request failed with status {r.status}: {error_details}"
298                )
299            self.posts = await r.json()
300            return self.posts

Retrieve posts from group walls.

Posts are announcements/messages posted to group walls, as opposed to chat messages or events.

Parameters
  • group_id (str, optional): Filter by group. Uses groupId API parameter.
  • max_posts (int, optional): Set a limit on the number of posts returned. For performance reasons, defaults to 20. Uses max API parameter.
  • include_comments (bool, optional): Include comments on posts. Defaults to True. Uses includeComments API parameter.
Returns
  • list[JSONDict] or None: A list of posts, each represented as a dictionary, or None if no posts are available.
Raises
  • ValueError: Raised when the request to the API fails.
async def get_messages(self, max_chats: int = 100) -> list[dict[str, Any]] | None:
302    @_SpondBase.require_authentication
303    async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None:
304        """Retrieve recent chats (one-to-one and group conversations).
305
306        "Chats" here refers to the in-app direct/group messaging feature, not
307        comments on events or posts. Uses Spond's separate chat-server host
308        and chat token (handled internally by `_login_chat`).
309
310        The full response is cached on `self.messages`.
311
312        Parameters
313        ----------
314        max_chats : int, optional
315            Maximum number of chats to return. Defaults to 100 for performance.
316            Uses the `max` API parameter.
317
318        Returns
319        -------
320        list[JSONDict] or None
321            A list of chat objects ordered by most recent activity. `None` if
322            the account has no chats.
323        """
324        if not self._auth:
325            await self._login_chat()
326        url = f"{self._chat_url}/chats/"
327        async with self.clientsession.get(
328            url,
329            headers={"auth": self._auth},
330            params={"max": str(max_chats)},
331        ) as r:
332            self.messages = await r.json()
333        return self.messages

Retrieve recent chats (one-to-one and group conversations).

"Chats" here refers to the in-app direct/group messaging feature, not comments on events or posts. Uses Spond's separate chat-server host and chat token (handled internally by _login_chat).

The full response is cached on self.messages.

Parameters
  • max_chats (int, optional): Maximum number of chats to return. Defaults to 100 for performance. Uses the max API parameter.
Returns
  • list[JSONDict] or None: A list of chat objects ordered by most recent activity. None if the account has no chats.
async def send_message( self, text: str, user: str | None = None, group_uid: str | None = None, chat_id: str | None = None) -> dict[str, typing.Any]:
361    @_SpondBase.require_authentication
362    async def send_message(
363        self,
364        text: str,
365        user: str | None = None,
366        group_uid: str | None = None,
367        chat_id: str | None = None,
368    ) -> JSONDict:
369        """Send a chat message, either continuing an existing thread or
370        starting a new one.
371
372        Two calling patterns:
373
374        - **Continue an existing chat**: pass `chat_id` (the recipient and
375          group context are inferred from the existing thread). `user` and
376          `group_uid` are ignored.
377        - **Start a new chat**: pass both `user` (the recipient) and
378          `group_uid` (the group context the chat belongs to). The user is
379          resolved via `get_person()` to find the underlying profile id.
380
381        Parameters
382        ----------
383        text : str
384            Message body to send.
385        user : str, optional
386            Recipient identifier when starting a new chat. Accepts the same
387            forms as `get_person()`: member id, email, full name, or
388            profile id. Required when `chat_id` is not given.
389        group_uid : str, optional
390            UID of the group that scopes the new chat. Required when `chat_id`
391            is not given.
392        chat_id : str, optional
393            Identifier of an existing chat to continue. When provided,
394            `user` and `group_uid` are not consulted.
395
396        Returns
397        -------
398        JSONDict
399            The Spond API response for the send operation.
400
401        Raises
402        ------
403        ValueError
404            Neither `chat_id` nor both of `user`/`group_uid` were supplied —
405            the call has no way to identify the target chat.
406        KeyError
407            `user` was given but doesn't match any member or guardian in any
408            of the authenticated user's groups (propagated from
409            `get_person`).
410        """
411        if self._auth is None:
412            await self._login_chat()
413
414        if chat_id is not None:
415            return await self._continue_chat(chat_id, text)
416        if group_uid is None or user is None:
417            raise ValueError(
418                "send_message requires either chat_id (to continue an existing "
419                "chat) or both user and group_uid (to start a new one)."
420            )
421
422        user_obj = await self.get_person(user)
423        user_uid = user_obj["profile"]["id"]
424        url = f"{self._chat_url}/messages"
425        data = {
426            "text": text,
427            "type": "TEXT",
428            "recipient": user_uid,
429            "groupId": group_uid,
430        }
431        r = await self.clientsession.post(url, json=data, headers={"auth": self._auth})
432        return await r.json()

Send a chat message, either continuing an existing thread or starting a new one.

Two calling patterns:

  • Continue an existing chat: pass chat_id (the recipient and group context are inferred from the existing thread). user and group_uid are ignored.
  • Start a new chat: pass both user (the recipient) and group_uid (the group context the chat belongs to). The user is resolved via get_person() to find the underlying profile id.
Parameters
  • text (str): Message body to send.
  • user (str, optional): Recipient identifier when starting a new chat. Accepts the same forms as get_person(): member id, email, full name, or profile id. Required when chat_id is not given.
  • group_uid (str, optional): UID of the group that scopes the new chat. Required when chat_id is not given.
  • chat_id (str, optional): Identifier of an existing chat to continue. When provided, user and group_uid are not consulted.
Returns
  • JSONDict: The Spond API response for the send operation.
Raises
  • ValueError: Neither chat_id nor both of user/group_uid were supplied — the call has no way to identify the target chat.
  • KeyError: user was given but doesn't match any member or guardian in any of the authenticated user's groups (propagated from get_person).
async def get_events( self, group_id: str | None = None, subgroup_id: str | None = None, include_scheduled: bool = False, include_hidden: bool = False, max_end: datetime.datetime | None = None, min_end: datetime.datetime | None = None, max_start: datetime.datetime | None = None, min_start: datetime.datetime | None = None, max_events: int = 100) -> list[dict[str, Any]] | None:
434    @_SpondBase.require_authentication
435    async def get_events(
436        self,
437        group_id: str | None = None,
438        subgroup_id: str | None = None,
439        include_scheduled: bool = False,
440        include_hidden: bool = False,
441        max_end: datetime | None = None,
442        min_end: datetime | None = None,
443        max_start: datetime | None = None,
444        min_start: datetime | None = None,
445        max_events: int = 100,
446    ) -> list[JSONDict] | None:
447        """Retrieve events visible to the authenticated user.
448
449        Filters can narrow by group/subgroup, by start/end timestamp window,
450        and by visibility (scheduled, hidden). The full response is cached on
451        `self.events`.
452
453        Note: `get_event(uid)` looks up events via this method's cache, so
454        it inherits these defaults — an event that doesn't appear in the
455        first `max_events` results or is excluded by `include_scheduled=False`
456        is unreachable through `get_event()`. If you need broader visibility,
457        call this method directly with appropriate filters.
458
459        Parameters
460        ----------
461        group_id : str, optional
462            Restrict to events belonging to this group. Uses `groupId` API
463            parameter.
464        subgroup_id : str, optional
465            Restrict to events within this subgroup. Uses `subGroupId` API
466            parameter.
467        include_scheduled : bool, optional
468            Include scheduled events (events whose invitations are queued to be
469            sent in the future).
470            Defaults to False for performance reasons.
471            Uses `scheduled` API parameter.
472        include_hidden : bool, optional
473            Include hidden events.
474            Uses `includeHidden` API parameter.
475            'includeHidden' filter is only available inside a group.
476        max_end : datetime, optional
477            Only include events which end before or at this datetime.
478            Uses `maxEndTimestamp` API parameter; relates to `endTimestamp` event
479            attribute.
480        min_end : datetime, optional
481            Only include events which end after or at this datetime.
482            Uses `minEndTimestamp` API parameter; relates to `endTimestamp` event
483            attribute.
484        max_start : datetime, optional
485            Only include events which start before or at this datetime.
486            Uses `maxStartTimestamp` API parameter; relates to `startTimestamp` event
487            attribute.
488        min_start : datetime, optional
489            Only include events which start after or at this datetime.
490            Uses `minStartTimestamp` API parameter; relates to `startTimestamp` event
491            attribute.
492        max_events : int, optional
493            Set a limit on the number of events returned.
494            For performance reasons, defaults to 100.
495            Uses `max` API parameter.
496
497        Returns
498        -------
499        list[JSONDict] or None
500             A list of events, each represented as a dictionary, or None if no events
501             are available.
502
503        Raises
504        ------
505        ValueError
506            Raised when the request to the API fails. This occurs if the response
507            status code indicates an error (e.g., 4xx or 5xx). The error message
508            includes the HTTP status code and the response body for debugging purposes.
509        """
510        url = f"{self.api_url}sponds/"
511        params = {
512            "max": str(max_events),
513            "scheduled": str(include_scheduled),
514        }
515        if max_end:
516            params["maxEndTimestamp"] = max_end.strftime(self._DT_FORMAT)
517        if max_start:
518            params["maxStartTimestamp"] = max_start.strftime(self._DT_FORMAT)
519        if min_end:
520            params["minEndTimestamp"] = min_end.strftime(self._DT_FORMAT)
521        if min_start:
522            params["minStartTimestamp"] = min_start.strftime(self._DT_FORMAT)
523        if group_id:
524            params["groupId"] = group_id
525        if subgroup_id:
526            params["subGroupId"] = subgroup_id
527        if include_hidden:
528            params["includeHidden"] = "true"
529
530        async with self.clientsession.get(
531            url, headers=self.auth_headers, params=params
532        ) as r:
533            if not r.ok:
534                error_details = await r.text()
535                raise ValueError(
536                    f"Request failed with status {r.status}: {error_details}"
537                )
538            self.events = await r.json()
539            return self.events

Retrieve events visible to the authenticated user.

Filters can narrow by group/subgroup, by start/end timestamp window, and by visibility (scheduled, hidden). The full response is cached on self.events.

Note: get_event(uid) looks up events via this method's cache, so it inherits these defaults — an event that doesn't appear in the first max_events results or is excluded by include_scheduled=False is unreachable through get_event(). If you need broader visibility, call this method directly with appropriate filters.

Parameters
  • group_id (str, optional): Restrict to events belonging to this group. Uses groupId API parameter.
  • subgroup_id (str, optional): Restrict to events within this subgroup. Uses subGroupId API parameter.
  • include_scheduled (bool, optional): Include scheduled events (events whose invitations are queued to be sent in the future). Defaults to False for performance reasons. Uses scheduled API parameter.
  • include_hidden (bool, optional): Include hidden events. Uses includeHidden API parameter. 'includeHidden' filter is only available inside a group.
  • max_end (datetime, optional): Only include events which end before or at this datetime. Uses maxEndTimestamp API parameter; relates to endTimestamp event attribute.
  • min_end (datetime, optional): Only include events which end after or at this datetime. Uses minEndTimestamp API parameter; relates to endTimestamp event attribute.
  • max_start (datetime, optional): Only include events which start before or at this datetime. Uses maxStartTimestamp API parameter; relates to startTimestamp event attribute.
  • min_start (datetime, optional): Only include events which start after or at this datetime. Uses minStartTimestamp API parameter; relates to startTimestamp event attribute.
  • max_events (int, optional): Set a limit on the number of events returned. For performance reasons, defaults to 100. Uses max API parameter.
Returns
  • list[JSONDict] or None: A list of events, each represented as a dictionary, or None if no events are available.
Raises
  • ValueError: Raised when the request to the API fails. This occurs if the response status code indicates an error (e.g., 4xx or 5xx). The error message includes the HTTP status code and the response body for debugging purposes.
async def get_event(self, uid: str) -> dict[str, typing.Any]:
541    async def get_event(self, uid: str) -> JSONDict:
542        """Look up a single event by its unique id.
543
544        Routes through the cached events list (populated by `get_events()`),
545        which means events outside the `max_events=100` default or those
546        excluded by `include_scheduled=False` may not be findable. To reach
547        those events, call `get_events()` directly with appropriate filters
548        first to populate the cache, then call this method.
549
550        Parameters
551        ----------
552        uid : str
553            UID of the event.
554
555        Returns
556        -------
557        JSONDict
558            The event's details, with the same shape as elements returned by
559            `get_events()`.
560
561        Raises
562        ------
563        KeyError
564            If no event with the given id is found in the cache.
565        """
566        return await self._get_entity(self._EVENT, uid)

Look up a single event by its unique id.

Routes through the cached events list (populated by get_events()), which means events outside the max_events=100 default or those excluded by include_scheduled=False may not be findable. To reach those events, call get_events() directly with appropriate filters first to populate the cache, then call this method.

Parameters
  • uid (str): UID of the event.
Returns
  • JSONDict: The event's details, with the same shape as elements returned by get_events().
Raises
  • KeyError: If no event with the given id is found in the cache.
async def update_event(self, uid: str, updates: dict[str, typing.Any]) -> dict[str, typing.Any]:
568    @_SpondBase.require_authentication
569    async def update_event(self, uid: str, updates: JSONDict) -> JSONDict:
570        """Update an existing event by merging changes into the current state.
571
572        The implementation fetches the event via `_get_entity()`, copies the
573        fields present in `_EVENT_TEMPLATE` from the existing event as the
574        base, then overlays any keys provided in `updates`. The merged event
575        is POSTed back to `sponds/{uid}`.
576
577        Parameters
578        ----------
579        uid : str
580            UID of the event to update.
581        updates : JSONDict
582            Mapping of keys to new values. Only keys present in
583            `_EVENT_TEMPLATE` are honoured. Example:
584
585            ```python
586            await s.update_event(uid, {"description": "New description"})
587            ```
588
589        Returns
590        -------
591        JSONDict
592            The Spond API response from the POST — the updated event as
593            persisted server-side.
594        """
595        event = await self._get_entity(self._EVENT, uid)
596        url = f"{self.api_url}sponds/{uid}"
597
598        base_event = self._EVENT_TEMPLATE.copy()
599        for key in base_event:
600            if event.get(key) is not None and not updates.get(key):
601                base_event[key] = event[key]
602            elif updates.get(key) is not None:
603                base_event[key] = updates[key]
604
605        async with self.clientsession.post(
606            url, json=base_event, headers=self.auth_headers
607        ) as r:
608            return await r.json()

Update an existing event by merging changes into the current state.

The implementation fetches the event via _get_entity(), copies the fields present in _EVENT_TEMPLATE from the existing event as the base, then overlays any keys provided in updates. The merged event is POSTed back to sponds/{uid}.

Parameters
  • uid (str): UID of the event to update.
  • updates (JSONDict): Mapping of keys to new values. Only keys present in _EVENT_TEMPLATE are honoured. Example:

    await s.update_event(uid, {"description": "New description"})
    
Returns
  • JSONDict: The Spond API response from the POST — the updated event as persisted server-side.
async def get_event_attendance_xlsx(self, uid: str) -> bytes:
610    @_SpondBase.require_authentication
611    async def get_event_attendance_xlsx(self, uid: str) -> bytes:
612        """Download the attendance report for an event as XLSX bytes.
613
614        Thin wrapper around Spond's own "Export attendance history" feature
615        in the web UI. The columns and format are determined by Spond, not by
616        this library — for example, the export does not include member ids.
617        For a customisable CSV alternative built from `get_event()` data,
618        see `examples/attendance.py`.
619
620        Parameters
621        ----------
622        uid : str
623            UID of the event whose attendance report to fetch.
624
625        Returns
626        -------
627        bytes
628            Raw XLSX file contents. Typically written directly to disk:
629
630            ```python
631            import pathlib
632
633            data = await s.get_event_attendance_xlsx(uid)
634            pathlib.Path(f"{uid}.xlsx").write_bytes(data)
635            ```
636        """
637        url = f"{self.api_url}sponds/{uid}/export"
638        async with self.clientsession.get(url, headers=self.auth_headers) as r:
639            return await r.read()

Download the attendance report for an event as XLSX bytes.

Thin wrapper around Spond's own "Export attendance history" feature in the web UI. The columns and format are determined by Spond, not by this library — for example, the export does not include member ids. For a customisable CSV alternative built from get_event() data, see examples/attendance.py.

Parameters
  • uid (str): UID of the event whose attendance report to fetch.
Returns
  • bytes: Raw XLSX file contents. Typically written directly to disk:
import pathlib

data = await s.get_event_attendance_xlsx(uid)
pathlib.Path(f"{uid}.xlsx").write_bytes(data)
async def change_response( self, uid: str, user: str, payload: dict[str, typing.Any]) -> dict[str, typing.Any]:
641    @_SpondBase.require_authentication
642    async def change_response(self, uid: str, user: str, payload: JSONDict) -> JSONDict:
643        """Update a single member's response (accept/decline) for an event.
644
645        Useful for managing attendance on someone else's behalf (e.g. a coach
646        accepting on behalf of a player who can't reach the app). The caller
647        must have permission on the event.
648
649        Parameters
650        ----------
651        uid : str
652            UID of the event.
653        user : str
654            UID of the member whose response to change. Note: this is the
655            *member's* id (as seen in `group["members"][i]["id"]`), not the
656            authenticated user's id.
657        payload : JSONDict
658            The response body. Common shapes:
659
660            - `{"accepted": "true"}` — accept the invitation
661            - `{"accepted": "false"}` — decline (Spond may also accept a
662              `"declineMessage"` field with a reason)
663
664        Returns
665        -------
666        JSONDict
667            The event's `responses` object with the updated id lists
668            (`acceptedIds`, `declinedIds`, `unansweredIds`, etc.).
669        """
670        url = f"{self.api_url}sponds/{uid}/responses/{user}"
671        async with self.clientsession.put(
672            url, headers=self.auth_headers, json=payload
673        ) as r:
674            return await r.json()

Update a single member's response (accept/decline) for an event.

Useful for managing attendance on someone else's behalf (e.g. a coach accepting on behalf of a player who can't reach the app). The caller must have permission on the event.

Parameters
  • uid (str): UID of the event.
  • user (str): UID of the member whose response to change. Note: this is the member's id (as seen in group["members"][i]["id"]), not the authenticated user's id.
  • payload (JSONDict): The response body. Common shapes:

    • {"accepted": "true"} — accept the invitation
    • {"accepted": "false"} — decline (Spond may also accept a "declineMessage" field with a reason)
Returns
  • JSONDict: The event's responses object with the updated id lists (acceptedIds, declinedIds, unansweredIds, etc.).