spond.base

Shared base class for Spond API clients.

_SpondBase is the abstract parent of both spond.spond.Spond (consumer API) and spond.club.SpondClub (Spond Club finance API). It owns the credentials, the underlying aiohttp ClientSession, the access token, and the lazy login flow used by the require_authentication decorator.

Not intended to be instantiated directly — use a subclass.

  1"""Shared base class for Spond API clients.
  2
  3`_SpondBase` is the abstract parent of both `spond.spond.Spond` (consumer API)
  4and `spond.club.SpondClub` (Spond Club finance API). It owns the credentials,
  5the underlying aiohttp `ClientSession`, the access token, and the lazy login
  6flow used by the `require_authentication` decorator.
  7
  8Not intended to be instantiated directly — use a subclass.
  9"""
 10
 11import functools
 12from abc import ABC
 13from collections.abc import Callable
 14
 15import aiohttp
 16
 17from spond import AuthenticationError
 18
 19# Fields from a login response that are safe to surface in an
 20# `AuthenticationError` message. Anything outside this set (notably 2FA
 21# challenge tokens and `phoneNumber`) is dropped to avoid leaking
 22# sensitive data into application logs.
 23_SAFE_LOGIN_ERROR_FIELDS = ("error", "errorKey", "errorCode", "message")
 24
 25
 26class _SpondBase(ABC):
 27    """Abstract base for Spond API clients.
 28
 29    Subclasses provide the API base URL via the third constructor argument
 30    and inherit lazy authentication, the `auth_headers` property, the
 31    `require_authentication` decorator, and the `login()` flow.
 32    """
 33
 34    def __init__(self, username: str, password: str, api_url: str) -> None:
 35        """Initialise credentials and open the aiohttp session.
 36
 37        Parameters
 38        ----------
 39        username : str
 40            Spond account email address.
 41        password : str
 42            Spond account password.
 43        api_url : str
 44            Base URL for the API family this client targets (consumer or
 45            club). Must end with a trailing slash so relative paths can be
 46            concatenated.
 47        """
 48        self.username = username
 49        self.password = password
 50        self.api_url = api_url
 51        self.clientsession = aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar())
 52        self.token = None
 53
 54    @property
 55    def auth_headers(self) -> dict:
 56        """Headers required for authenticated requests: JSON content-type plus
 57        a Bearer token from `self.token`."""
 58        return {
 59            "content-type": "application/json",
 60            "Authorization": f"Bearer {self.token}",
 61        }
 62
 63    @staticmethod
 64    def require_authentication(func: Callable):
 65        """Decorator that calls `self.login()` before invoking `func` if the
 66        client is not yet authenticated. On `AuthenticationError`, closes the
 67        underlying aiohttp session before re-raising."""
 68
 69        @functools.wraps(func)
 70        async def wrapper(self, *args, **kwargs):
 71            if not self.token:
 72                try:
 73                    await self.login()
 74                except AuthenticationError as e:
 75                    await self.clientsession.close()
 76                    raise e
 77            return await func(self, *args, **kwargs)
 78
 79        return wrapper
 80
 81    async def login(self) -> None:
 82        """Authenticate against the Spond API and store the access token on
 83        `self.token`. Called automatically by the `require_authentication`
 84        decorator; rarely needs to be called explicitly.
 85
 86        Raises
 87        ------
 88        AuthenticationError
 89            If the server response does not include a usable access token.
 90        """
 91        login_url = f"{self.api_url}auth2/login"
 92        data = {"email": self.username, "password": self.password}
 93        async with self.clientsession.post(login_url, json=data) as r:
 94            login_result = await r.json()
 95        self.token = self._extract_access_token(login_result)
 96
 97    @staticmethod
 98    def _extract_access_token(login_result: dict) -> str:
 99        """Pull the access-token string out of a `/auth2/login` response.
100
101        The response shape is
102        `{"accessToken": {"token": "<JWT>", "expiration": "..."}, ...}`.
103        This helper validates that shape and returns the bearer string used
104        for subsequent API calls.
105
106        Parameters
107        ----------
108        login_result : dict
109            Parsed JSON body from the login endpoint.
110
111        Returns
112        -------
113        str
114            The bearer-token string.
115
116        Raises
117        ------
118        AuthenticationError
119            The response is malformed or doesn't carry a usable token (e.g.
120            wrong credentials, account locked, 2FA required).
121        """
122        access = login_result.get("accessToken")
123        if isinstance(access, dict):
124            token = access.get("token")
125            if isinstance(token, str) and token:
126                return token
127        safe = {
128            k: login_result[k] for k in _SAFE_LOGIN_ERROR_FIELDS if k in login_result
129        }
130        diagnostic = safe or "(no recognised diagnostic fields in response)"
131        raise AuthenticationError(f"Login failed. {diagnostic}")