Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion spond/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@


class AuthenticationError(Exception):
"""Error raised on Spond authentication failure."""
"""Raised when login to the Spond API fails.

Typical causes:

- Incorrect username/password.
- 2FA enabled on the account (the library does not currently support
Spond's TOTP flow — see #205).
- The account has hit Spond's login rate limit (`outOfLoginAttempts`).
- The Spond login API has changed shape and the response no longer
contains an `accessToken`.

The exception message includes the raw login response, which usually
indicates which of the above applies.
Comment thread
Olen marked this conversation as resolved.
Outdated
"""

pass
53 changes: 53 additions & 0 deletions spond/base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
"""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.
"""

from abc import ABC
from collections.abc import Callable

Expand All @@ -7,7 +17,27 @@


class _SpondBase(ABC):
"""Abstract base for Spond API clients.

Subclasses provide the API base URL via the third constructor argument
and inherit lazy authentication, the `auth_headers` property, the
`require_authentication` decorator, and the `login()` flow.
"""

def __init__(self, username: str, password: str, api_url: str) -> None:
"""Initialise credentials and open the aiohttp session.

Parameters
----------
username : str
Spond account email address.
password : str
Spond account password.
api_url : str
Base URL for the API family this client targets (consumer or
club). Must end with a trailing slash so relative paths can be
concatenated.
"""
self.username = username
self.password = password
self.api_url = api_url
Expand Down Expand Up @@ -58,6 +88,29 @@ async def login(self) -> None:

@staticmethod
def _extract_access_token(login_result: dict) -> str:
"""Pull the access-token string out of a `/auth2/login` response.

The response shape is
`{"accessToken": {"token": "<JWT>", "expiration": "..."}, ...}`.
This helper validates that shape and returns the bearer string used
for subsequent API calls.

Parameters
----------
login_result : dict
Parsed JSON body from the login endpoint.

Returns
-------
str
The bearer-token string.

Raises
------
AuthenticationError
The response is malformed or doesn't carry a usable token (e.g.
wrong credentials, account locked, 2FA required).
"""
access = login_result.get("accessToken")
if isinstance(access, dict):
token = access.get("token")
Expand Down
77 changes: 66 additions & 11 deletions spond/club.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
"""Client for the Spond Club finance API.

Spond Club is the paid administration tier sold to clubs/teams alongside the
free consumer app. It exposes a separate API (`api.spond.com/club/v1/`) for
finance-flavoured data such as transactions/payments. Use the `SpondClub`
class for this API and `spond.spond.Spond` for everything else.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, ClassVar
Expand All @@ -9,37 +17,84 @@


class SpondClub(_SpondBase):
"""Async client for the Spond Club finance API.

Authentication is shared with the consumer API — the same email/password
credentials work, but the user must belong to at least one Spond Club
organisation and the `club_id` passed to each method must be one they
have access to. The `club_id` here is distinct from the consumer-API
`groupId`.

Example
-------
```python
import asyncio
from spond import club

async def main():
sc = club.SpondClub(username="me@example.invalid", password="secret")
txs = await sc.get_transactions(club_id="ABCD1234...", max_items=50)
for t in txs:
print(t["paidAt"], t["paymentName"], t["paidByName"])
await sc.clientsession.close()

asyncio.run(main())
```
"""

_API_BASE_URL: ClassVar = "https://api.spond.com/club/v1/"

def __init__(self, username: str, password: str) -> None:
"""Construct a Spond Club client.

Parameters
----------
username : str
Spond account email. Same credentials as the consumer API; the
account must have access to at least one Spond Club organisation
for the API calls to return data.
password : str
Spond account password.
"""
super().__init__(username, password, self._API_BASE_URL)
self.transactions: list[JSONDict] | None = None

@_SpondBase.require_authentication
async def get_transactions(
self, club_id: str, skip: int | None = None, max_items: int = 100
) -> list[JSONDict]:
"""
Retrieves a list of transactions/payments for a specified club.
"""Retrieve transactions/payments for a Spond Club.

Spond's transactions endpoint returns at most 25 records per request,
so this method paginates internally (via recursion on `skip`) until
either `max_items` is reached or the server returns an empty page.
Results are accumulated on `self.transactions`; subsequent calls
append to it unless you reset `self.transactions = None` first.

Comment thread
Olen marked this conversation as resolved.
Each transaction dict typically includes at least `id`, `paidAt`,
`paymentName`, and `paidByName`. See `examples/transactions.py` for
a usage example.

Parameters
----------
club_id : str
Identifier for the club. Note that this is different from the Group ID used
in the core API.
Identifier for the club. Note that this is **different** from the
`groupId` used elsewhere in the Spond API — find it in the URL
of the Spond Club web UI.
skip : int, optional
This endpoint only returns 25 transactions at a time (page scrolling).
Therefore, we need to increment this `skip` param to grab the next
25 etc. Defaults to None. It's better to keep `skip` at None
and specify `max_items` instead. This param is only here for the
recursion implementation.
Pagination cursor (number of records to skip). Normally left as
`None`; the method increments it itself on recursive calls. Only
override if you know what you're doing.
max_items : int, optional
The maximum number of transactions to retrieve. Defaults to 100.
Stop fetching once at least this many transactions are
accumulated. Defaults to 100. The final list may be slightly
longer than `max_items` since the server returns pages of 25.

Returns
-------
list[JSONDict]
A list of transactions, each represented as a dictionary.
All transactions accumulated so far (across recursive page
fetches). Empty list if the club has no transactions.
"""
if self.transactions is None:
self.transactions = []
Expand Down
Loading