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}")