From 0fcb9f99ed28fa92aa40ded2f9f2c60220af5edb Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 17:33:17 +0200 Subject: [PATCH 01/42] design: spec for object-oriented rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DESIGN-oo-rewrite.md at the repo root capturing the design for the OO rewrite: ActiveRecord-style behaviour on typed objects, Pydantic v2 foundation, side-by-side deprecation, Person → Member/ Guardian split. Also bumps `pydantic = ">=2.0"` into runtime deps so subsequent commits on this branch can start implementing the typed models. --- DESIGN-oo-rewrite.md | 209 +++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 2 files changed, 210 insertions(+) create mode 100644 DESIGN-oo-rewrite.md diff --git a/DESIGN-oo-rewrite.md b/DESIGN-oo-rewrite.md new file mode 100644 index 0000000..06bae06 --- /dev/null +++ b/DESIGN-oo-rewrite.md @@ -0,0 +1,209 @@ +# Spond OO rewrite — design + +**Status:** open for feedback — work in progress on branch `feat/oo-rewrite` +**Last updated:** 2026-05-14 +**Scope:** First-class typed objects with ActiveRecord behaviour, replacing the dict-based return surface across `spond.spond.Spond` and `spond.club.SpondClub`. + +## Feedback welcome + +This document is the design we're proposing for the long-discussed object-oriented rewrite of the SDK. It's the spec — not the code yet. Comments, pushback, and suggestions on any section are welcome before the implementation lands. + +- **For high-level concerns** (API shape, scope, deprecation path) — open an issue titled `OO rewrite: …` or comment on the tracking PR. +- **For specific wording or examples** — review the draft PR (link will be added here once it's open) and comment inline. +- **For deeper design questions** — see the "Open questions" section near the end; we'd like to settle those before the implementation locks them in. + +Decisions captured here have been agreed in principle but are still revisable as long as v1.3 hasn't shipped. + +## Motivation + +The current SDK exposes one big `Spond` class with ~13 methods, all of them taking and returning `dict[str, Any]` (`JSONDict`). Callers must navigate raw dicts (`event["responses"]["acceptedIds"][0]`), there's no validation of API responses, and operations on a single event are scattered across `update_event(uid, ...)`, `change_response(uid, user, ...)`, `get_event_attendance_xlsx(uid)`. + +The OO rewrite addresses three things at once: + +1. **Self-contained objects.** Operations on an event live on the event: `event.update(...)`, `event.change_response(...)`, `event.attendance_xlsx()`. +2. **Typed navigation.** `group.members[i].guardians[j].first_name` works with autocomplete and type-checker support. +3. **Validation on construction.** Pydantic raises loudly if Spond's API drifts, instead of silently passing through unexpected shapes. + +## Decisions locked during brainstorming + +| Decision | Choice | Why | +|---|---|---| +| OO shape | **ActiveRecord** (instance owns operations) | Closest to "self-contained, well-behaved"; smaller refactor than Manager pattern; keeps `Spond.get_event(uid)` etc. on the client | +| Migration | **Side-by-side with deprecation** | Old method-on-Spond surface stays, emits `DeprecationWarning`, gets removed in a future major bump | +| Data-class tech | **Pydantic v2** | Runtime validation + clean snake_case ↔ camelCase aliasing; elliot-100's stale `oo-rewrite` branch already proved this works; ~5MB dep is fine alongside aiohttp | +| Pilot scope | **All types at once** | Half-OO state leaves inter-dependency gaps (e.g., `Group.members` returning Member objects requires Member to also be typed) | +| PR cadence | **Single draft PR** | One coherent change; most of the diff is new files | +| Person hierarchy | **Person base, Member/Guardian derived** | Members and Guardians have different behaviour: members are invited and respond to events; guardians manage a child member and may respond on their behalf | + +## Type inventory + +``` +Person (base, BaseModel + DictCompatMixin) + ├─ uid, first_name, last_name, email (optional), profile (optional) + ├─ full_name (property) + │ + ├─ Member(Person) + │ ├─ guardians: list[Guardian] + │ ├─ (subgroup memberships, roles — TBD during impl based on actual API shape) + │ └─ methods: send_message(text, group_uid) + │ + └─ Guardian(Person) + ├─ (link to managed member if API exposes it) + └─ methods: send_message(text, group_uid) — routes to guardian + +Event(BaseModel + DictCompatMixin) + ├─ uid, heading, start_time, end_time, type: EventType, owners, recipients, ... + ├─ responses: Responses + ├─ methods: update(**fields), change_response(member_uid, *, accepted, decline_message=None), + │ attendance_xlsx() -> bytes + +Responses (sub-object of Event) + ├─ accepted_uids, declined_uids, unanswered_uids, waiting_list_uids, unconfirmed_uids + │ — all list[str] (raw UIDs) + └─ (no methods; resolution to Member objects requires Group context — see Open Questions) + +EventType (Enum) + └─ AVAILABILITY, EVENT, RECURRING (extend as we encounter more) + +Group(BaseModel + DictCompatMixin) + ├─ uid, name, members: list[Member], subgroups: list[Subgroup], roles: list[Role] + └─ methods: find_member(*, email=None, name=None, uid=None) -> Member | None + +Subgroup, Role (BaseModel + DictCompatMixin) + └─ uid, name (passive data, no methods) + +Profile(BaseModel + DictCompatMixin) + └─ uid, first_name, last_name (passive) + +Post(BaseModel + DictCompatMixin) + ├─ uid, title, body, timestamp, comments: list[Comment] + └─ (no methods yet; add_comment(...) deferred until we verify the Spond API supports it) + +Comment (sub-object of Post) + └─ uid, text, timestamp, author + +Transaction(BaseModel + DictCompatMixin) + └─ uid, paid_at, payment_name, paid_by_name (passive, Spond Club only) +``` + +Each typed model has a Pydantic `PrivateAttr` for the Spond/SpondClub client: + +```python +_client: Optional["Spond"] = PrivateAttr(default=None) +``` + +Construction sites set this via a `from_api(data, client)` classmethod. PrivateAttr keeps it out of `model_dump()` and pdoc. + +## Backward compatibility + +### Dict-subscript shim + +`DictCompatMixin` gives every typed model dict-like behaviour: + +- `event["heading"]` works, emits `DeprecationWarning` +- `event["startTimestamp"]` works (alias-aware), emits `DeprecationWarning` +- `event.get("heading", default)` works, emits warning +- `"heading" in event` works +- `for key in event` iterates the API-field names (camelCase) +- `len(event)` returns the number of fields + +Implementation: the mixin reads `cls.model_fields` to discover both the Python attribute name and the alias, and dispatches subscript access through to attribute access. + +### Strict-equality test patterns + +A small number of existing tests compare returned objects to raw dicts with `==`: + +```python +assert g == {"id": "ID1", "name": "Event One"} +``` + +These need adapting to one of: + +```python +assert g.uid == "ID1" and g.heading == "Event One" +# or +assert g.model_dump(by_alias=True) == {"id": "ID1", "heading": "Event One", ...} +``` + +This is part of the PR (one test class affected, ~5 assertions). + +### Legacy write methods + +`Spond.update_event`, `Spond.change_response`, `Spond.get_event_attendance_xlsx`, `Spond.send_message` stay in v1.x — they emit `DeprecationWarning` pointing at the new method, then delegate internally: + +```python +async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: + warnings.warn( + "Spond.update_event is deprecated; use Event.update() instead", + DeprecationWarning, + stacklevel=2, + ) + event = await self.get_event(uid) + return await event.update(**updates) +``` + +All four are removed in v2.0. + +## Spond.get_* return-type changes + +The seven methods that currently return `JSONDict` / `list[JSONDict] | None` change their return type but keep their names and signatures: + +| Method | Before | After | +|---|---|---| +| `get_profile()` | `JSONDict` | `Profile` | +| `get_groups()` | `list[JSONDict] \| None` | `list[Group] \| None` | +| `get_group(uid)` | `JSONDict` | `Group` | +| `get_person(user)` | `JSONDict` | `Person` (concretely Member or Guardian) | +| `get_events(...)` | `list[JSONDict] \| None` | `list[Event] \| None` | +| `get_event(uid)` | `JSONDict` | `Event` | +| `get_posts(...)` | `list[JSONDict] \| None` | `list[Post] \| None` | +| `SpondClub.get_transactions(...)` | `list[JSONDict]` | `list[Transaction]` | + +Dict-style consumers still work through the DictCompatMixin (with warning). + +## Open questions (not blocking) + +1. **Member ↔ UID resolution in Responses.** `Event.responses.accepted_uids` is `list[str]` not `list[Member]`. Resolving requires Group context, which Events only have via `recipients`/`groupId`. Add a helper `await event.accepted_members(spond)` that fetches the group and walks members — lazy, opt-in, no surprise HTTP from attribute reads. +2. **Guardian.managed_member.** If the API doesn't expose a back-link, Guardian is constructed inside `Member.guardians` and the parent reference can be added post-hoc by the Member constructor. Decide during impl based on actual API shape. +3. **Post.add_comment.** Probe whether Spond's API supports comment-add via `POST sponds/posts/{uid}/comments` or similar. If yes, add the method; if no, document as read-only. +4. **Send-message semantics for Guardian vs Member.** Verify whether the message routes differently based on recipient kind — may require different payload shapes. + +All four are answerable mid-impl with live API probing using credentials at `/home/olen/prog/spond-kalender/config.py`. + +## Files + +**New:** +- `spond/_compat.py` — `DictCompatMixin` +- `spond/event.py` — `Event`, `Responses`, `EventType` +- `spond/person.py` — `Person`, `Member`, `Guardian` +- `spond/group.py` — `Group` +- `spond/subgroup.py` — `Subgroup` +- `spond/role.py` — `Role` +- `spond/profile.py` — `Profile` +- `spond/post.py` — `Post`, `Comment` + +**Changed:** +- `spond/spond.py` — `get_*` methods return typed objects; legacy write methods get deprecation wrappers +- `spond/club.py` — `Transaction` model added; `get_transactions` returns `list[Transaction]` +- `pyproject.toml` — `pydantic = ">=2.0"` added to runtime deps +- `tests/test_spond.py` — strict-equality assertions adapted; new tests for ActiveRecord methods, dict-compat, inter-dependencies +- `README.md` — examples updated to OO style + +## Out of scope + +- `Spond.get_messages` and the chat machinery — chats are tangled, leave on the dict-based path. Possible v1.4 follow-up. +- Removing `self.events_update` (already removed in #243). +- Adding new HTTP endpoints. This is a re-shaping of the existing surface only. +- Renaming any `Spond.get_*` method — name stability matters more than name perfection. + +## Test plan + +- All existing tests pass (with the strict-equality adaptations). +- New tests for each ActiveRecord method (HTTP-mocked, asserting URL + payload + return value). +- New tests for `DictCompatMixin`: subscript works, warning fires, alias-mapped subscripts work. +- New tests for inter-dep navigation: `group.members[0]` is a `Member`, `member.guardians[0]` is a `Guardian`, etc. +- Manual smoke test of `examples/manual_test_functions.py` against live API. + +## Versioning + +Land as v1.3 — minor bump (return-type change is technically breaking, but the DictCompatMixin makes it soft). Legacy `Spond.*_event*` methods removed in v2.0 after a grace period. diff --git a/pyproject.toml b/pyproject.toml index f625499..ca50922 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ repository = 'https://github.com/Olen/Spond' [tool.poetry.dependencies] python = ">=3.11" aiohttp = ">=3.8.5" +pydantic = ">=2.0" [tool.poetry.group.dev.dependencies] # Constraint on `python` is required: pdoc's transitive `markdown2` declares From 4af213d812410275ae3ac34520bb26b024434898 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 17:46:36 +0200 Subject: [PATCH 02/42] feat(oo-rewrite): add typed model classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the per-type Pydantic models that the OO rewrite returns from Spond.get_*(). No call-site changes yet — Spond still returns raw dicts; these classes are added in isolation so they can be wired up and tested incrementally. New files: - spond/_compat.py — DictCompatModel base with dict-style read access (subscript, get, contains, iter, keys/values/items, len). Emits DeprecationWarning from __getitem__ and .get() so existing dict-style callers get a one-time nudge per call site. - spond/event.py — Event (+ Responses, EventType StrEnum) with the three ActiveRecord methods: update(**fields), change_response( member_uid, *, accepted, decline_message=None), attendance_xlsx(). - spond/person.py — Person base + Member(Person) + Guardian(Person). Both flavours have send_message(text, group_uid) routed via the shared _send_message_to_person helper. - spond/group.py — Group with members/subgroups/roles as typed lists; find_member(*, uid|email|name) lookup helper; from_api() wires the client reference through to nested members + guardians. - spond/subgroup.py, spond/role.py — passive data classes. - spond/profile.py — Profile (the rich account record, distinct from the sparse `profile` reference dict on Member/Guardian). - spond/post.py — Post (Comment left as raw dict for now, future refinement). - spond/club.py — Transaction added. All classes use ConfigDict(populate_by_name=True, extra="ignore") so Spond API drift adds unknown fields silently rather than breaking validation. Live-probed against the real API to confirm field names and types. --- spond/_compat.py | 104 +++++++++++++++++ spond/club.py | 29 +++++ spond/event.py | 289 ++++++++++++++++++++++++++++++++++++++++++++++ spond/group.py | 144 +++++++++++++++++++++++ spond/person.py | 195 +++++++++++++++++++++++++++++++ spond/post.py | 51 ++++++++ spond/profile.py | 56 +++++++++ spond/role.py | 26 +++++ spond/subgroup.py | 28 +++++ 9 files changed, 922 insertions(+) create mode 100644 spond/_compat.py create mode 100644 spond/event.py create mode 100644 spond/group.py create mode 100644 spond/person.py create mode 100644 spond/post.py create mode 100644 spond/profile.py create mode 100644 spond/role.py create mode 100644 spond/subgroup.py diff --git a/spond/_compat.py b/spond/_compat.py new file mode 100644 index 0000000..2e2e3aa --- /dev/null +++ b/spond/_compat.py @@ -0,0 +1,104 @@ +"""Internal helpers for backward-compatible dict-style access on typed models. + +The pre-OO public API returned raw `dict[str, Any]` from every `get_*` method. +The OO rewrite returns Pydantic models instead. To avoid breaking existing +callers that subscript the result (`event["heading"]`, `event.get("id")`, +`"heading" in event`, …), every typed model inherits from `DictCompatModel`, +which adds dict-style read access. + +A `DeprecationWarning` is emitted from `__getitem__` and `get()` so callers +can find their dict-style sites and migrate to attribute access. The other +dict-compat surface (`__iter__`, `keys`, `values`, `items`, `__len__`, +`__contains__`) does not warn — it's noisier and provides less signal. + +Mutation via subscript is intentionally not supported: the typed models are +read-only at the dict-compat layer, and writes go through the ActiveRecord +methods on each type (`event.update(...)`, `event.change_response(...)`, …). +""" + +from __future__ import annotations + +import warnings +from collections.abc import Iterator +from typing import Any + +from pydantic import BaseModel + + +class DictCompatModel(BaseModel): + """Pydantic base class with dict-style read access for backward compatibility. + + Subscript access (`obj["key"]`) maps either the API-side camelCase alias or + the Python-side snake_case attribute name to the underlying attribute, + emitting a `DeprecationWarning` so callers are nudged toward attribute + access. Other dict-style operations work without warning. + + Subclasses should inherit from this instead of directly from + `pydantic.BaseModel`. + """ + + def _resolve_dict_key(self, key: str) -> str | None: + """Return the Python attribute name matching `key`, or None. + + Matches either the field's API alias or its Python name. + """ + for field_name, field_info in self.__class__.model_fields.items(): + if field_info.alias == key or field_name == key: + return field_name + return None + + def __getitem__(self, key: str) -> Any: + field_name = self._resolve_dict_key(key) + if field_name is None: + raise KeyError(key) + warnings.warn( + f"{self.__class__.__name__}[{key!r}] uses deprecated dict-style " + f"access; use attribute access (`.{field_name}`) instead", + DeprecationWarning, + stacklevel=2, + ) + return getattr(self, field_name) + + def get(self, key: str, default: Any = None) -> Any: + """Dict-style `.get(key, default)` with deprecation warning.""" + field_name = self._resolve_dict_key(key) + if field_name is None: + return default + warnings.warn( + f"{self.__class__.__name__}.get({key!r}) uses deprecated dict-style " + f"access; use attribute access (`.{field_name}`) instead", + DeprecationWarning, + stacklevel=2, + ) + return getattr(self, field_name) + + def __contains__(self, key: object) -> bool: + return isinstance(key, str) and self._resolve_dict_key(key) is not None + + def __iter__(self) -> Iterator[str]: # type: ignore[override] + """Yield API-shaped keys (alias if defined, else field name). + + This deliberately overrides `pydantic.BaseModel.__iter__`, which yields + `(name, value)` tuples — dict-compat callers expect just the keys. + """ + for field_name, field_info in self.__class__.model_fields.items(): + yield field_info.alias or field_name + + def __len__(self) -> int: + return len(self.__class__.model_fields) + + def keys(self) -> list[str]: + """Dict-style `.keys()` — returns the API-shaped key names.""" + return list(iter(self)) + + def values(self) -> list[Any]: + """Dict-style `.values()` — returns the attribute values in field order.""" + return [getattr(self, name) for name in self.__class__.model_fields] + + def items(self) -> list[tuple[str, Any]]: + """Dict-style `.items()` — returns (api-key, value) pairs in field order.""" + result = [] + for field_name, field_info in self.__class__.model_fields.items(): + key = field_info.alias or field_name + result.append((key, getattr(self, field_name))) + return result diff --git a/spond/club.py b/spond/club.py index b5c2121..0ee1d39 100644 --- a/spond/club.py +++ b/spond/club.py @@ -8,14 +8,43 @@ class for this API and `spond.spond.Spond` for everything else. from __future__ import annotations +from datetime import datetime from typing import TYPE_CHECKING, ClassVar +from pydantic import ConfigDict, Field + +from ._compat import DictCompatModel from .base import _SpondBase if TYPE_CHECKING: from . import JSONDict +class Transaction(DictCompatModel): + """A Spond Club payment/transaction record. + + Returned as elements of `SpondClub.get_transactions()`. Conservatively + modelled around the four fields the Spond Club UI guarantees on every + transaction: `id`, `paidAt`, `paymentName`, `paidByName`. Everything else + Spond emits passes through `extra="ignore"` and is accessible via the + dict-compat fallback (`transaction["someField"]`) until those fields are + explicitly modelled. + """ + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + uid: str = Field(alias="id") + paid_at: datetime = Field(alias="paidAt") + payment_name: str = Field(alias="paymentName") + paid_by_name: str = Field(alias="paidByName") + + def __str__(self) -> str: + return ( + f"Transaction(uid={self.uid!r}, payment={self.payment_name!r}, " + f"paid_by={self.paid_by_name!r})" + ) + + class SpondClub(_SpondBase): """Async client for the Spond Club finance API. diff --git a/spond/event.py b/spond/event.py new file mode 100644 index 0000000..f26b306 --- /dev/null +++ b/spond/event.py @@ -0,0 +1,289 @@ +"""Typed `Event` model with ActiveRecord-style behaviour. + +`Event` instances are returned from `spond.spond.Spond.get_event()` and +`Spond.get_events()`. Each instance carries a reference back to the Spond +client (`_client`) so its methods can issue HTTP calls without the caller +having to thread the client through. + +The class inherits from `DictCompatModel`, so existing dict-style consumers +(`event["heading"]`, `event["startTimestamp"]`, `event.get("id")`) keep +working with a `DeprecationWarning`. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import TYPE_CHECKING, Any + +from pydantic import ConfigDict, Field, PrivateAttr + +from ._compat import DictCompatModel +from ._event_template import _EVENT_TEMPLATE + +if TYPE_CHECKING: + from .spond import Spond + + +class EventType(StrEnum): + """Kind of event, as reported by Spond's `type` field. + + Values may extend over time as Spond introduces new event variants. + """ + + EVENT = "EVENT" + """A one-off event.""" + RECURRING = "RECURRING" + """An occurrence of a recurring event.""" + AVAILABILITY = "AVAILABILITY" + """An availability request (no fixed time).""" + + +class Responses(DictCompatModel): + """The attendance-response lists attached to an `Event`. + + Each list holds raw member UIDs (`str`), not `Member` objects — resolving + them to members requires a `Group` context which an Event doesn't carry + standalone. To get `Member` objects, walk the parent group's `members` + list and filter, or call `await event.accepted_members(spond)` (planned + follow-up). + """ + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + accepted_uids: list[str] = Field(default_factory=list, alias="acceptedIds") + """UIDs of members who accepted the invitation.""" + declined_uids: list[str] = Field(default_factory=list, alias="declinedIds") + """UIDs of members who declined.""" + unanswered_uids: list[str] = Field(default_factory=list, alias="unansweredIds") + """UIDs of members who have not yet responded.""" + waiting_list_uids: list[str] = Field(default_factory=list, alias="waitinglistIds") + """UIDs of members on the waiting list (event is full).""" + unconfirmed_uids: list[str] = Field(default_factory=list, alias="unconfirmedIds") + """UIDs of members whose response needs confirmation.""" + + +class Event(DictCompatModel): + """A Spond event with attached operations. + + Construct via `Spond.get_event(uid)` or as elements of + `Spond.get_events()` — both wire `_client` for you. Don't instantiate + directly unless you also set `_client` (the ActiveRecord methods need + it for HTTP). + + Example + ------- + ```python + event = await spond.get_event(uid) + print(event.heading, event.start_time) + for member_uid in event.responses.accepted_uids: + print(member_uid) + + await event.update(description="Updated description") + await event.change_response(member_uid, accepted=True) + xlsx = await event.attendance_xlsx() + ``` + """ + + model_config = ConfigDict( + populate_by_name=True, + extra="ignore", + arbitrary_types_allowed=True, + ) + + # Core fields (always present in API data) + uid: str = Field(alias="id") + heading: str + start_time: datetime = Field(alias="startTimestamp") + end_time: datetime = Field(alias="endTimestamp") + created_time: datetime = Field(alias="createdTime") + type: EventType + responses: Responses + + # Owner / creator metadata + creator_uid: str | None = Field(default=None, alias="creatorId") + owners: list[dict[str, Any]] = Field(default_factory=list) + """Raw owner objects (typed `Owner` class is a possible future refinement).""" + + # Commonly present but treated as optional + description: str | None = None + visibility: str = "INVITEES" + expired: bool = False + hidden: bool = False + cancelled: bool = False + auto_accept: bool = Field(default=False, alias="autoAccept") + auto_reminder_type: str = Field(default="DISABLED", alias="autoReminderType") + participants_hidden: bool = Field(default=False, alias="participantsHidden") + registered: bool = False + comments_disabled: bool = Field(default=False, alias="commentsDisabled") + match_event: bool = Field(default=False, alias="matchEvent") + modified_from_series: bool = Field(default=False, alias="modifiedFromSeries") + + # Series fields (only for recurring events) + series_uid: str | None = Field(default=None, alias="seriesId") + series_ordinal: int | None = Field(default=None, alias="seriesOrdinal") + + # Update timestamp (Spond uses milliseconds since epoch here) + updated: int | None = None + + # Behalf-of (members someone else can respond for) + behalf_of_uids: list[str] = Field(default_factory=list, alias="behalfOfIds") + + # Nested data we keep as raw dicts for now (modelling these is follow-up work) + location: dict[str, Any] | None = None + """Location dict with `address`, `latitude`, `longitude`, etc. Unmodelled for now.""" + recipients: dict[str, Any] | None = None + """Recipients dict with `group`, `profiles`, `guardians`. Unmodelled for now.""" + tasks: dict[str, Any] | None = None + """Tasks dict with `openTasks`, `assignedTasks`. Unmodelled for now.""" + attachments: list[Any] = Field(default_factory=list) + """Attachment objects. Unmodelled for now.""" + comments: list[Any] = Field(default_factory=list) + """Comment objects. Only populated when fetched with `?includeComments=true`.""" + + # Non-serialised reference back to the Spond client for HTTP calls. + _client: Any = PrivateAttr(default=None) + + def __str__(self) -> str: + return ( + f"Event(uid={self.uid!r}, heading={self.heading!r}, " + f"start_time={self.start_time.isoformat()})" + ) + + @property + def url(self) -> str: + """Web URL of the event (for opening in a browser).""" + return f"https://spond.com/client/sponds/{self.uid}/" + + @classmethod + def from_api(cls, data: dict[str, Any], client: Spond) -> Event: + """Construct an `Event` from a raw API response and bind the client. + + Used internally by `Spond.get_event()` and `Spond.get_events()`. + Sets `_client` on the instance so the ActiveRecord methods can + issue HTTP calls. + """ + instance = cls.model_validate(data) + instance._client = client + return instance + + def _resolve_field_for_update(self, key: str) -> tuple[str, str]: + """Translate either a Python attribute name or an API alias to both. + + Returns `(python_name, api_name)`. Raises `ValueError` if neither + form matches a field in this model. + """ + py_name = self._resolve_dict_key(key) + if py_name is None: + raise ValueError( + f"Event has no field {key!r}; valid names are " + f"{sorted(self.__class__.model_fields)}" + ) + field_info = self.__class__.model_fields[py_name] + api_name = field_info.alias or py_name + return py_name, api_name + + async def update(self, **fields: Any) -> Event: + """POST changes to this event back to Spond and return the updated event. + + Accepts either Python-style attribute names (`description="..."`) or + API-style aliases (`startTimestamp="..."`). Unknown keys raise + `ValueError`. + + Parameters + ---------- + **fields + Field updates to send. Only fields present in `_EVENT_TEMPLATE` + actually reach the server — others are silently dropped by Spond. + + Returns + ------- + Event + A new `Event` reflecting the persisted state. The original + instance is **not** mutated. + """ + api_updates: dict[str, Any] = {} + for key, value in fields.items(): + _, api_name = self._resolve_field_for_update(key) + api_updates[api_name] = value + + # Build the payload from _EVENT_TEMPLATE, falling back to current state + # for fields the caller didn't provide. Matches the existing + # `Spond.update_event` semantics exactly. + current = self.model_dump(by_alias=True) + payload = _EVENT_TEMPLATE.copy() + for key in payload: + if api_updates.get(key) is not None: + payload[key] = api_updates[key] + elif current.get(key) is not None: + payload[key] = current[key] + + url = f"{self._client.api_url}sponds/{self.uid}" + async with self._client.clientsession.post( + url, json=payload, headers=self._client.auth_headers + ) as r: + new_data = await r.json() + + return Event.from_api(new_data, self._client) + + async def change_response( + self, + member_uid: str, + *, + accepted: bool, + decline_message: str | None = None, + ) -> dict[str, Any]: + """Set a member's response on this event. + + Parameters + ---------- + member_uid : str + UID of the member whose response to set. This is the **member's** + id (`group["members"][i]["id"]`), not a profile id and not the + authenticated user's id. + accepted : bool + True to accept, False to decline. + decline_message : str, optional + Reason for declining. Ignored unless `accepted=False`. + + Returns + ------- + dict + The event's `responses` object as returned by the API, with the + updated id lists (`acceptedIds`, `declinedIds`, …). + """ + payload: dict[str, Any] = {"accepted": str(accepted).lower()} + if not accepted and decline_message is not None: + payload["declineMessage"] = decline_message + + url = f"{self._client.api_url}sponds/{self.uid}/responses/{member_uid}" + async with self._client.clientsession.put( + url, headers=self._client.auth_headers, json=payload + ) as r: + return await r.json() + + async def attendance_xlsx(self) -> bytes: + """Download Spond's attendance-history XLSX for this event. + + Thin wrapper around Spond's web-UI "Export attendance history" + feature — the columns and format are determined by Spond, not by + this library, and notably the export does not include member ids. + For a customisable CSV alternative, see `examples/attendance.py`. + + Returns + ------- + bytes + Raw XLSX bytes, typically written directly to disk: + + ```python + import pathlib + + data = await event.attendance_xlsx() + pathlib.Path(f"{event.uid}.xlsx").write_bytes(data) + ``` + """ + url = f"{self._client.api_url}sponds/{self.uid}/export" + async with self._client.clientsession.get( + url, headers=self._client.auth_headers + ) as r: + return await r.read() diff --git a/spond/group.py b/spond/group.py new file mode 100644 index 0000000..9b8984c --- /dev/null +++ b/spond/group.py @@ -0,0 +1,144 @@ +"""Typed `Group` model with `find_member()` helper. + +A `Group` is a Spond group the authenticated user belongs to. Each group +carries lists of `Member`s, `Subgroup`s, and `Role`s — all materialised as +their respective typed objects when the Group is constructed via +`Group.from_api()`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pydantic import ConfigDict, Field, PrivateAttr + +from ._compat import DictCompatModel +from .person import Member +from .role import Role +from .subgroup import Subgroup + +if TYPE_CHECKING: + from .spond import Spond + + +class Group(DictCompatModel): + """A Spond group, with its members, subgroups, and roles as typed lists. + + Construct via `Spond.get_group(uid)` or as elements of + `Spond.get_groups()`. Direct instantiation works but won't attach a + `_client` reference (which Member.send_message and other behaviour + methods on contained objects need). + + Example + ------- + ```python + group = await spond.get_group(uid) + print(group.name) + for member in group.members: + print(f" {member.full_name}") + for guardian in member.guardians: + print(f" guardian: {guardian.full_name}") + + coach = group.find_member(name="Ola Thoresen") + if coach is not None: + await coach.send_message("Practice cancelled", group_uid=group.uid) + ``` + """ + + model_config = ConfigDict( + populate_by_name=True, + extra="ignore", + arbitrary_types_allowed=True, + ) + + uid: str = Field(alias="id") + name: str + activity: str | None = None + """The group's sport/activity tag, e.g. `"football"`.""" + + members: list[Member] = Field(default_factory=list) + """Members of the group. Each member's nested `guardians` are typed too.""" + + subgroups: list[Subgroup] = Field(default_factory=list, alias="subGroups") + roles: list[Role] = Field(default_factory=list) + + contact_person: dict[str, Any] | None = Field(default=None, alias="contactPerson") + """Profile reference dict for the group's primary contact. Unmodelled.""" + + age_group: str | None = Field(default=None, alias="ageGroup") + organization_type: str | None = Field(default=None, alias="organizationType") + event_visibility: str | None = Field(default=None, alias="eventVisibility") + country_code: str | None = Field(default=None, alias="countryCode") + type: int | None = None + + _client: Any = PrivateAttr(default=None) + + def __str__(self) -> str: + return ( + f"Group(uid={self.uid!r}, name={self.name!r}, members={len(self.members)})" + ) + + @classmethod + def from_api(cls, data: dict[str, Any], client: Spond | None) -> Group: + """Construct a `Group` from raw API data and wire `_client` through. + + Sets `_client` on the group and on every nested member (which in turn + wires it onto each member's guardians via `Member.from_api`). This + lets `member.send_message(...)` and any future per-member behaviour + work without further plumbing. + """ + instance = cls.model_validate(data) + instance._client = client + for member in instance.members: + member._client = client + for guardian in member.guardians: + guardian._client = client + return instance + + def find_member( + self, + *, + uid: str | None = None, + email: str | None = None, + name: str | None = None, + ) -> Member | None: + """Find a single member by uid, email, or full name. + + Exactly one of `uid`, `email`, `name` must be provided. Returns the + first match in `self.members`, or `None` if no member matches. + + For full-name matching, `name` is compared against + `member.full_name` (first + space + last). + + Parameters + ---------- + uid : str, optional + Match against `member.uid`. + email : str, optional + Match against `member.email` (exact). + name : str, optional + Match against `member.full_name` (exact). + + Returns + ------- + Member or None + + Raises + ------ + ValueError + Zero or more than one search criterion was supplied. + """ + supplied = [k for k in ("uid", "email", "name") if locals()[k] is not None] + if len(supplied) != 1: + raise ValueError( + f"find_member requires exactly one of uid/email/name; got {supplied}" + ) + + for member in self.members: + if uid is not None and member.uid == uid: + return member + if email is not None and member.email == email: + return member + if name is not None and member.full_name == name: + return member + return None diff --git a/spond/person.py b/spond/person.py new file mode 100644 index 0000000..abcd284 --- /dev/null +++ b/spond/person.py @@ -0,0 +1,195 @@ +"""Typed `Person` base class with `Member` and `Guardian` subclasses. + +The Spond API uses two flavours of person attached to a `Group`: + +- **Member** — a person invited to events, who can respond, hold roles, + and (for child members) have one or more guardians. +- **Guardian** — a person attached to a Member to receive notifications + and respond on the member's behalf. Has fewer fields than Member + (no email, no roles, no nested guardians, no `respondent` flag). + +Both share a common shape (uid, name, profile, phone) which we model as +the abstract base `Person`. `Member` and `Guardian` extend it with their +respective specifics. + +Construct via `Spond.get_groups()` and walk `group.members`; or via +`Spond.get_person(user)`, which can return either kind. +""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import TYPE_CHECKING, Any + +from pydantic import ConfigDict, Field, PrivateAttr + +from ._compat import DictCompatModel + +if TYPE_CHECKING: + from .spond import Spond + + +class Person(DictCompatModel): + """Shared base for `Member` and `Guardian`. + + Carries only the fields that both flavours include (uid, names, profile, + phone). Subclasses add their specifics. Not intended to be instantiated + directly — use `Spond.get_person()` to obtain a Person of the right + concrete type. + """ + + model_config = ConfigDict( + populate_by_name=True, + extra="ignore", + arbitrary_types_allowed=True, + ) + + uid: str = Field(alias="id") + first_name: str = Field(alias="firstName") + last_name: str = Field(alias="lastName") + profile: dict[str, Any] | None = None + """Profile reference dict — `{id, contactMethod, ...}`. Unmodelled for now; + use `Spond.get_profile()` to fetch the full profile of the authenticated + user.""" + phone_number: str | None = Field(default=None, alias="phoneNumber") + + # Non-serialised reference back to the Spond client for HTTP calls. + _client: Any = PrivateAttr(default=None) + + @property + def full_name(self) -> str: + """Convenience: `first_name` + ` ` + `last_name`.""" + return f"{self.first_name} {self.last_name}" + + def __str__(self) -> str: + return f"{self.__class__.__name__}(uid={self.uid!r}, name={self.full_name!r})" + + +class Guardian(Person): + """A guardian attached to a `Member`. + + Guardians receive notifications about the member they care for and may + respond to events on the member's behalf. They have a strictly smaller + field set than Members (no `email`, no `roles`, no nested `guardians`). + """ + + @classmethod + def from_api(cls, data: dict[str, Any], client: Spond | None) -> Guardian: + instance = cls.model_validate(data) + instance._client = client + return instance + + async def send_message(self, text: str, group_uid: str) -> dict[str, Any]: + """Send a chat message directly to this guardian. + + Spond routes the message via the guardian's profile id. Requires the + group context (`group_uid`) the guardian is reachable through. + + Returns + ------- + dict + The Spond chat API's response for the send operation. + """ + return await _send_message_to_person(self, text, group_uid) + + +class Member(Person): + """A member of a Group — someone who can be invited to events. + + Carries Member-specific fields (email, date of birth, roles, guardians, + subgroup memberships) on top of the shared `Person` base. + """ + + model_config = ConfigDict( + populate_by_name=True, + extra="ignore", + arbitrary_types_allowed=True, + ) + + email: str | None = None + """Email address. May be absent on minor members.""" + + date_of_birth: date | None = Field(default=None, alias="dateOfBirth") + + created_time: datetime | None = Field(default=None, alias="createdTime") + + guardians: list[Guardian] = Field(default_factory=list) + """Guardians attached to this member. Empty for adult members.""" + + role_uids: list[str] = Field(default_factory=list, alias="roles") + """UIDs of `Role`s this member holds within the group.""" + + subgroup_uids: list[str] = Field(default_factory=list, alias="subGroups") + """UIDs of `Subgroup`s this member belongs to within the group.""" + + respondent: bool = False + """Whether this member personally responds to events (False for child + members whose guardians respond on their behalf).""" + + fields: dict[str, Any] = Field(default_factory=dict) + """Custom fields defined on the group. Unmodelled for now.""" + + @classmethod + def from_api(cls, data: dict[str, Any], client: Spond | None) -> Member: + instance = cls.model_validate(data) + instance._client = client + # Wire client into nested Guardians too + for g in instance.guardians: + g._client = client + return instance + + async def send_message(self, text: str, group_uid: str) -> dict[str, Any]: + """Send a chat message directly to this member. + + Parameters + ---------- + text : str + Message body. + group_uid : str + UID of the group context the chat belongs to. + + Returns + ------- + dict + The Spond chat API's response for the send operation. + """ + return await _send_message_to_person(self, text, group_uid) + + +async def _send_message_to_person( + person: Person, text: str, group_uid: str +) -> dict[str, Any]: + """Send a chat message to any Person (Member or Guardian). + + Implementation shared between `Member.send_message` and + `Guardian.send_message` — Spond routes by the recipient's profile id, + which is the same field on both kinds. + """ + client = person._client + if client is None: + raise RuntimeError( + f"{type(person).__name__} has no client attached; instantiate via " + f"Spond.get_person() or walk Spond.get_groups()." + ) + + # Lazy chat handshake (Spond's chat API uses a separate host + token). + if client._auth is None: + await client._login_chat() + + if person.profile is None or "id" not in person.profile: + raise ValueError( + f"{type(person).__name__} {person.uid} has no profile id; " + f"Spond cannot route a message without one." + ) + + payload = { + "text": text, + "type": "TEXT", + "recipient": person.profile["id"], + "groupId": group_uid, + } + url = f"{client._chat_url}/messages" + r = await client.clientsession.post( + url, json=payload, headers={"auth": client._auth} + ) + return await r.json() diff --git a/spond/post.py b/spond/post.py new file mode 100644 index 0000000..21dbf84 --- /dev/null +++ b/spond/post.py @@ -0,0 +1,51 @@ +"""Typed `Post` model — group-wall announcements and their comments. + +`Post`s are the announcement-style messages posted to a Group's wall (as +opposed to chat messages or events). Returned by `Spond.get_posts()`. + +Comments on posts are not yet modelled as a separate class — they're +exposed as a `list[dict]`. Modelling them is a follow-up (the comment +shape is small but varies by Spond version). +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import ConfigDict, Field + +from ._compat import DictCompatModel + + +class Post(DictCompatModel): + """A post on a Group's wall (announcement, not a chat message).""" + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + uid: str = Field(alias="id") + type: str = "PLAIN" + title: str | None = None + body: str | None = None + timestamp: datetime + group_uid: str | None = Field(default=None, alias="groupId") + subgroup_uids: list[str] = Field(default_factory=list, alias="subGroupIds") + owner_uid: str | None = Field(default=None, alias="ownerId") + visibility: str | None = None + """e.g. \"ADULTS_GUARDIANS_ONLY\", \"ALL\".""" + unread: bool = False + muted: bool = False + comments_disabled: bool = Field(default=False, alias="commentsDisabled") + select_member_poll: bool = Field(default=False, alias="selectMemberPoll") + media: list[Any] = Field(default_factory=list) + attachments: list[Any] = Field(default_factory=list) + comments: list[dict[str, Any]] = Field(default_factory=list) + """Comment dicts. Only populated when fetched with + `include_comments=True` (the default for `Spond.get_posts()`). Currently + typed as raw `dict` — a `Comment` class is a possible future refinement.""" + + def __str__(self) -> str: + return ( + f"Post(uid={self.uid!r}, title={self.title!r}, " + f"timestamp={self.timestamp.isoformat()})" + ) diff --git a/spond/profile.py b/spond/profile.py new file mode 100644 index 0000000..2165165 --- /dev/null +++ b/spond/profile.py @@ -0,0 +1,56 @@ +"""Typed `Profile` model for the authenticated user's account. + +`Profile` represents the rich user record returned by `Spond.get_profile()`. +It carries account-level details (email, phone, timezone, locale, +preferences) — strictly more than the `profile` dict referenced inside +Members and Guardians, which is a sparse subset. +""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +from pydantic import ConfigDict, Field + +from ._compat import DictCompatModel + + +class Profile(DictCompatModel): + """The authenticated user's full account profile. + + Returned by `Spond.get_profile()`. Not the same as the `profile` dict + nested inside Member/Guardian — that's a sparse reference shape. + """ + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + uid: str = Field(alias="id") + first_name: str = Field(alias="firstName") + last_name: str = Field(alias="lastName") + primary_email: str | None = Field(default=None, alias="primaryEmail") + phone_number: str | None = Field(default=None, alias="phoneNumber") + formatted_phone_number: str | None = Field( + default=None, alias="formattedPhoneNumber" + ) + image_url: str | None = Field(default=None, alias="imageUrl") + date_of_birth: date | None = Field(default=None, alias="dateOfBirth") + gender: str | None = None + locale: str | None = None + country_code: str | None = Field(default=None, alias="countryCode") + timezone: str | None = None + contact_method: str | None = Field(default=None, alias="contactMethod") + deleted: bool = False + internal: bool = False + dummy: bool = False + unable_to_reach: bool = Field(default=False, alias="unableToReach") + preferences: dict[str, Any] | None = None + """Nested preference settings (push, email, locale, etc.). Unmodelled.""" + + @property + def full_name(self) -> str: + """Convenience: `first_name` + ` ` + `last_name`.""" + return f"{self.first_name} {self.last_name}" + + def __str__(self) -> str: + return f"Profile(uid={self.uid!r}, name={self.full_name!r})" diff --git a/spond/role.py b/spond/role.py new file mode 100644 index 0000000..78a7a02 --- /dev/null +++ b/spond/role.py @@ -0,0 +1,26 @@ +"""Typed `Role` model — a permission role within a `Group`. + +Roles are passive data — they have no behaviour. Members reference them by +UID via `Member.role_uids`; resolve to `Role` objects by walking +`group.roles`. +""" + +from __future__ import annotations + +from pydantic import ConfigDict, Field + +from ._compat import DictCompatModel + + +class Role(DictCompatModel): + """A named permission role within a `Group` (e.g. \"Coach\", \"Treasurer\").""" + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + uid: str = Field(alias="id") + name: str + permissions: list[str] = Field(default_factory=list) + """API permission strings, e.g. `["members", "events", "posts"]`.""" + + def __str__(self) -> str: + return f"Role(uid={self.uid!r}, name={self.name!r})" diff --git a/spond/subgroup.py b/spond/subgroup.py new file mode 100644 index 0000000..f5d1342 --- /dev/null +++ b/spond/subgroup.py @@ -0,0 +1,28 @@ +"""Typed `Subgroup` model — a sub-division of a `Group`. + +Subgroups are passive data — they have no behaviour of their own. Surface +them via `group.subgroups`. +""" + +from __future__ import annotations + +from pydantic import ConfigDict, Field + +from ._compat import DictCompatModel + + +class Subgroup(DictCompatModel): + """A sub-division within a `Group` (e.g. a team within a club). + + Members reference subgroups by UID via `Member.subgroup_uids`. + """ + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + uid: str = Field(alias="id") + name: str + color: str | None = None + image_url: str | None = Field(default=None, alias="imageUrl") + + def __str__(self) -> str: + return f"Subgroup(uid={self.uid!r}, name={self.name!r})" From 205df75e654cc5fb9f29a810228f57b61cf47047 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 17:58:05 +0200 Subject: [PATCH 03/42] feat(oo-rewrite): wire Spond.get_* to typed objects + deprecate legacy writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spond.get_profile/get_groups/get_group/get_person/get_events/get_event/ get_posts now return typed objects (Profile / list[Group] | None / Group / Person / list[Event] | None / Event / list[Post] | None). The caches on self.profile/groups/events/posts hold typed objects too. SpondClub.get_transactions returns list[Transaction]. Legacy write methods (Spond.update_event, change_response, get_event_attendance_xlsx) stay for backward compatibility but emit DeprecationWarning and delegate to the new ActiveRecord methods on the typed objects. _get_entity now uses .uid on cached typed objects instead of ["id"]. _match_person takes a Person and uses attribute access. A tolerant LenientDate validator was added to _compat.py because real Spond data occasionally has impossible dateOfBirth values (e.g. '2012-03-99'); strict ISO-8601 parsing would raise on those. Members and Profiles use it for date_of_birth. Existing tests adapted to construct typed objects via Event.from_api / Group.model_validate and assert via attribute access rather than dict-equality. 14 new tests added covering: DictCompatModel subscript + get + contains + iter behaviour, Event.update/change_response/ attendance_xlsx happy paths, Group → Member → Guardian materialisation, Group.find_member. 44 tests total, all green. Live-tested end-to-end against the real Spond API: 8 groups / 1405 members / 2 events / 2 posts parsed cleanly into typed objects, with the dict-compat shim emitting deprecation warnings on subscript access. --- spond/_compat.py | 31 +++- spond/club.py | 22 ++- spond/person.py | 6 +- spond/profile.py | 5 +- spond/spond.py | 314 ++++++++++++++++++++-------------------- tests/test_spond.py | 338 ++++++++++++++++++++++++++++++++++++-------- 6 files changed, 478 insertions(+), 238 deletions(-) diff --git a/spond/_compat.py b/spond/_compat.py index 2e2e3aa..5c65b3f 100644 --- a/spond/_compat.py +++ b/spond/_compat.py @@ -20,9 +20,36 @@ import warnings from collections.abc import Iterator -from typing import Any +from datetime import date +from typing import Annotated, Any -from pydantic import BaseModel +from pydantic import BaseModel, BeforeValidator + + +def _parse_date_lenient(value: Any) -> date | None: + """Parse a date string tolerantly — return `None` for unparseable input. + + Spond's API occasionally returns malformed `dateOfBirth` values (e.g. + `'2012-03-99'` with an impossible day). Strict ISO-8601 parsing would + raise; we want callers to keep working with `None` for that field. + """ + if value is None or isinstance(value, date): + return value + if not isinstance(value, str): + return None + try: + return date.fromisoformat(value) + except ValueError: + return None + + +LenientDate = Annotated[date | None, BeforeValidator(_parse_date_lenient)] +"""Type alias for `dateOfBirth`-shaped fields that may contain malformed data. + +Use this in place of `date | None` for any field where Spond's API has been +observed to return values that fail strict ISO-8601 parsing. Unparseable +values become `None` rather than raising `ValidationError`. +""" class DictCompatModel(BaseModel): diff --git a/spond/club.py b/spond/club.py index 0ee1d39..b051840 100644 --- a/spond/club.py +++ b/spond/club.py @@ -9,16 +9,13 @@ class for this API and `spond.spond.Spond` for everything else. from __future__ import annotations from datetime import datetime -from typing import TYPE_CHECKING, ClassVar +from typing import ClassVar from pydantic import ConfigDict, Field from ._compat import DictCompatModel from .base import _SpondBase -if TYPE_CHECKING: - from . import JSONDict - class Transaction(DictCompatModel): """A Spond Club payment/transaction record. @@ -86,12 +83,12 @@ def __init__(self, username: str, password: str) -> None: Spond account password. """ super().__init__(username, password, self._API_BASE_URL) - self.transactions: list[JSONDict] | None = None + self.transactions: list[Transaction] | None = None @_SpondBase.require_authentication async def get_transactions( self, club_id: str, skip: int | None = None, max_items: int = 100 - ) -> list[JSONDict]: + ) -> list[Transaction]: """Retrieve transactions/payments for a Spond Club. Spond's transactions endpoint returns at most 25 records per request, @@ -127,9 +124,10 @@ async def get_transactions( Returns ------- - list[JSONDict] + list[Transaction] All transactions accumulated so far (across recursive page - fetches). Empty list if the club has no transactions. + fetches), as typed `Transaction` instances. Empty list if the + club has no transactions. """ if self.transactions is None: self.transactions = [] @@ -140,15 +138,15 @@ async def get_transactions( async with self.clientsession.get(url, headers=headers, params=params) as r: if r.status == 200: - t = await r.json() - if len(t) == 0: + raw = await r.json() + if len(raw) == 0: return self.transactions - self.transactions.extend(t) + self.transactions.extend(Transaction.model_validate(t) for t in raw) if len(self.transactions) < max_items: return await self.get_transactions( club_id=club_id, - skip=len(t) if skip is None else skip + len(t), + skip=len(raw) if skip is None else skip + len(raw), max_items=max_items, ) diff --git a/spond/person.py b/spond/person.py index abcd284..2569af2 100644 --- a/spond/person.py +++ b/spond/person.py @@ -18,12 +18,12 @@ from __future__ import annotations -from datetime import date, datetime +from datetime import datetime from typing import TYPE_CHECKING, Any from pydantic import ConfigDict, Field, PrivateAttr -from ._compat import DictCompatModel +from ._compat import DictCompatModel, LenientDate if TYPE_CHECKING: from .spond import Spond @@ -109,7 +109,7 @@ class Member(Person): email: str | None = None """Email address. May be absent on minor members.""" - date_of_birth: date | None = Field(default=None, alias="dateOfBirth") + date_of_birth: LenientDate = Field(default=None, alias="dateOfBirth") created_time: datetime | None = Field(default=None, alias="createdTime") diff --git a/spond/profile.py b/spond/profile.py index 2165165..4621c33 100644 --- a/spond/profile.py +++ b/spond/profile.py @@ -8,12 +8,11 @@ from __future__ import annotations -from datetime import date from typing import Any from pydantic import ConfigDict, Field -from ._compat import DictCompatModel +from ._compat import DictCompatModel, LenientDate class Profile(DictCompatModel): @@ -34,7 +33,7 @@ class Profile(DictCompatModel): default=None, alias="formattedPhoneNumber" ) image_url: str | None = Field(default=None, alias="imageUrl") - date_of_birth: date | None = Field(default=None, alias="dateOfBirth") + date_of_birth: LenientDate = Field(default=None, alias="dateOfBirth") gender: str | None = None locale: str | None = None country_code: str | None = Field(default=None, alias="countryCode") diff --git a/spond/spond.py b/spond/spond.py index 5570406..e78cc3f 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -9,11 +9,17 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING, ClassVar from . import JSONDict from ._event_template import _EVENT_TEMPLATE from .base import _SpondBase +from .event import Event +from .group import Group +from .person import Member, Person +from .post import Post +from .profile import Profile if TYPE_CHECKING: from datetime import datetime @@ -87,11 +93,11 @@ def __init__(self, username: str, password: str) -> None: super().__init__(username, password, self._API_BASE_URL) self._chat_url = None self._auth = None - self.groups: list[JSONDict] | None = None - self.events: list[JSONDict] | None = None - self.posts: list[JSONDict] | None = None + self.groups: list[Group] | None = None + self.events: list[Event] | None = None + self.posts: list[Post] | None = None self.messages: list[JSONDict] | None = None - self.profile: JSONDict | None = None + self.profile: Profile | None = None async def _login_chat(self) -> None: """Perform the secondary handshake with Spond's chat server. @@ -110,45 +116,48 @@ async def _login_chat(self) -> None: self._auth = result["auth"] @_SpondBase.require_authentication - async def get_profile(self) -> JSONDict: + async def get_profile(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 + Returns a `Profile` instance with the user's account details (id, + first/last name, email, phone, timezone, preferences). The full response is cached on `self.profile`. Returns ------- - JSONDict - The profile object as returned by the Spond API. + Profile + The authenticated user's profile. """ url = f"{self._API_BASE_URL}profile" async with self.clientsession.get(url, headers=self.auth_headers) as r: - self.profile = await r.json() - return self.profile + raw = await r.json() + self.profile = Profile.model_validate(raw) + return self.profile @_SpondBase.require_authentication - async def get_groups(self) -> list[JSONDict] | None: + async def get_groups(self) -> list[Group] | None: """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)`. + Each `Group` carries its `members` (typed `Member` instances, each + with their own `guardians: list[Guardian]`), `subgroups: list[Subgroup]`, + and `roles: list[Role]`. The full list 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. + list[Group] or None + A list of groups, or `None` if the account has no groups at all. """ url = f"{self.api_url}groups/" async with self.clientsession.get(url, headers=self.auth_headers) as r: - self.groups = await r.json() - return self.groups - - async def get_group(self, uid: str) -> JSONDict: + raw = await r.json() + if not raw: + self.groups = None + return None + self.groups = [Group.from_api(g, self) for g in raw] + return self.groups + + async def get_group(self, uid: str) -> Group: """Look up a single group by its unique id. Searches the cached `self.groups` (populated by `get_groups()` on @@ -161,9 +170,9 @@ async def get_group(self, uid: str) -> JSONDict: Returns ------- - JSONDict - The group's details, with the same shape as elements returned by - `get_groups()`. + Group + The group, with members/subgroups/roles materialised as typed + objects. Raises ------ @@ -173,7 +182,7 @@ async def get_group(self, uid: str) -> JSONDict: return await self._get_entity(self._GROUP, uid) @_SpondBase.require_authentication - async def get_person(self, user: str) -> JSONDict: + async def get_person(self, user: str) -> Person: """Look up a member or guardian by any of several identifiers. Searches every member of every cached group (and each member's @@ -185,17 +194,18 @@ async def get_person(self, user: str) -> JSONDict: user : str Identifier to match against. Accepted forms: - - the member's `id` - - the member's email (exact match) + - the person's `uid` + - the person's `email` (Members only — 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) + - the person's `profile.id` (different from `uid` for child + members managed by another account) Returns ------- - JSONDict - The first matching member or guardian dict. Shape matches the - entries in a group's `members` list from `get_groups()`. + Person + The first matching `Member` or `Guardian`. Use `isinstance` to + distinguish if needed. Raises ------ @@ -204,19 +214,18 @@ async def get_person(self, user: str) -> JSONDict: """ if not self.groups: await self.get_groups() - for group in self.groups: - for member in group["members"]: + for group in self.groups or []: + for member in group.members: if self._match_person(member, user): return member - if "guardians" in member: - for guardian in member["guardians"]: - if self._match_person(guardian, user): - return guardian + for guardian in member.guardians: + if self._match_person(guardian, user): + return guardian errmsg = f"No person matched with identifier '{user}'." raise KeyError(errmsg) @staticmethod - def _match_person(person: JSONDict, match_str: str) -> bool: + def _match_person(person: Person, match_str: str) -> bool: """Return True if `match_str` matches any of the person's identifiers. Used internally by `get_person` to scan group members and guardians. @@ -224,8 +233,9 @@ def _match_person(person: JSONDict, match_str: str) -> bool: Parameters ---------- - person : JSONDict - A member or guardian dict from a group's `members` list. + person : Person + A `Member` or `Guardian` from a group's `members` list (or one + of its nested `guardians`). match_str : str The identifier to test against. @@ -234,12 +244,14 @@ def _match_person(person: JSONDict, match_str: str) -> bool: bool True on first matching identifier; False otherwise. """ - return ( - person["id"] == match_str - or ("email" in person and person["email"]) == match_str - or person["firstName"] + " " + person["lastName"] == match_str - or ("profile" in person and person["profile"]["id"] == match_str) - ) + if person.uid == match_str: + return True + if person.full_name == match_str: + return True + if person.profile is not None and person.profile.get("id") == match_str: + return True + # Members have email; Guardians don't. + return isinstance(person, Member) and person.email == match_str @_SpondBase.require_authentication async def get_posts( @@ -247,9 +259,8 @@ async def get_posts( group_id: str | None = None, max_posts: int = 20, include_comments: bool = True, - ) -> list[JSONDict] | None: - """ - Retrieve posts from group walls. + ) -> list[Post] | None: + """Retrieve posts from group walls. Posts are announcements/messages posted to group walls, as opposed to chat messages or events. @@ -269,9 +280,8 @@ async def get_posts( Returns ------- - list[JSONDict] or None - A list of posts, each represented as a dictionary, or None if no - posts are available. + list[Post] or None + A list of posts, or `None` if the account has no posts. Raises ------ @@ -295,8 +305,12 @@ async def get_posts( raise ValueError( f"Request failed with status {r.status}: {error_details}" ) - self.posts = await r.json() - return self.posts + raw = await r.json() + if not raw: + self.posts = None + return None + self.posts = [Post.model_validate(p) for p in raw] + return self.posts @_SpondBase.require_authentication async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None: @@ -419,7 +433,12 @@ async def send_message( ) user_obj = await self.get_person(user) - user_uid = user_obj["profile"]["id"] + if user_obj.profile is None or "id" not in user_obj.profile: + raise ValueError( + f"Person {user_obj.uid} has no profile id; Spond cannot route " + f"a message without one." + ) + user_uid = user_obj.profile["id"] url = f"{self._chat_url}/messages" data = { "text": text, @@ -442,7 +461,7 @@ async def get_events( max_start: datetime | None = None, min_start: datetime | None = None, max_events: int = 100, - ) -> list[JSONDict] | None: + ) -> list[Event] | None: """Retrieve events visible to the authenticated user. Filters can narrow by group/subgroup, by start/end timestamp window, @@ -495,9 +514,8 @@ async def get_events( Returns ------- - list[JSONDict] or None - A list of events, each represented as a dictionary, or None if no events - are available. + list[Event] or None + A list of `Event` instances, or `None` if no events match. Raises ------ @@ -534,10 +552,14 @@ async def get_events( raise ValueError( f"Request failed with status {r.status}: {error_details}" ) - self.events = await r.json() - return self.events - - async def get_event(self, uid: str) -> JSONDict: + raw = await r.json() + if not raw: + self.events = None + return None + self.events = [Event.from_api(e, self) for e in raw] + return self.events + + async def get_event(self, uid: str) -> Event: """Look up a single event by its unique id. Routes through the cached events list (populated by `get_events()`), @@ -553,9 +575,8 @@ async def get_event(self, uid: str) -> JSONDict: Returns ------- - JSONDict - The event's details, with the same shape as elements returned by - `get_events()`. + Event + The matching event. Raises ------ @@ -564,113 +585,86 @@ async def get_event(self, uid: str) -> JSONDict: """ return await self._get_entity(self._EVENT, uid) - @_SpondBase.require_authentication async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: - """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}`. + """Deprecated — use `Event.update()` on the typed object instead. - 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: + ```python + # Old: + await spond.update_event(uid, {"description": "..."}) - ```python - await s.update_event(uid, {"description": "New description"}) - ``` + # New: + event = await spond.get_event(uid) + await event.update(description="...") + ``` - Returns - ------- - JSONDict - The Spond API response from the POST — the updated event as - persisted server-side. + Kept temporarily for backward compatibility; emits `DeprecationWarning`. + Internally delegates to `Event.update()` and returns the updated + event as a dict (via `model_dump(by_alias=True)`) for shape parity + with the pre-OO API. """ - event = await self._get_entity(self._EVENT, uid) - url = f"{self.api_url}sponds/{uid}" - - base_event = self._EVENT_TEMPLATE.copy() - for key in base_event: - if event.get(key) is not None and not updates.get(key): - base_event[key] = event[key] - elif updates.get(key) is not None: - base_event[key] = updates[key] - - async with self.clientsession.post( - url, json=base_event, headers=self.auth_headers - ) as r: - return await r.json() + warnings.warn( + "Spond.update_event() is deprecated; use Event.update() on the " + "object returned by Spond.get_event() instead.", + DeprecationWarning, + stacklevel=2, + ) + event = await self.get_event(uid) + new_event = await event.update(**updates) + return new_event.model_dump(by_alias=True) - @_SpondBase.require_authentication async def get_event_attendance_xlsx(self, uid: str) -> bytes: - """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. + """Deprecated — use `Event.attendance_xlsx()` on the typed object instead. - Returns - ------- - bytes - Raw XLSX file contents. Typically written directly to disk: + ```python + # Old: + data = await spond.get_event_attendance_xlsx(uid) - ```python - import pathlib + # New: + event = await spond.get_event(uid) + data = await event.attendance_xlsx() + ``` - data = await s.get_event_attendance_xlsx(uid) - pathlib.Path(f"{uid}.xlsx").write_bytes(data) - ``` + Kept temporarily for backward compatibility; emits `DeprecationWarning`. + Internally delegates to `Event.attendance_xlsx()`. """ - url = f"{self.api_url}sponds/{uid}/export" - async with self.clientsession.get(url, headers=self.auth_headers) as r: - return await r.read() + warnings.warn( + "Spond.get_event_attendance_xlsx() is deprecated; use " + "Event.attendance_xlsx() on the object returned by " + "Spond.get_event() instead.", + DeprecationWarning, + stacklevel=2, + ) + event = await self.get_event(uid) + return await event.attendance_xlsx() - @_SpondBase.require_authentication async def change_response(self, uid: str, user: str, payload: JSONDict) -> JSONDict: - """Update a single member's response (accept/decline) for an event. + """Deprecated — use `Event.change_response()` on the typed object instead. - 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. + ```python + # Old: + await spond.change_response(uid, member_uid, {"accepted": "true"}) - 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) + # New: + event = await spond.get_event(uid) + await event.change_response(member_uid, accepted=True) + ``` - Returns - ------- - JSONDict - The event's `responses` object with the updated id lists - (`acceptedIds`, `declinedIds`, `unansweredIds`, etc.). + Kept temporarily for backward compatibility; emits `DeprecationWarning`. + Internally delegates to `Event.change_response()`. """ - url = f"{self.api_url}sponds/{uid}/responses/{user}" - async with self.clientsession.put( - url, headers=self.auth_headers, json=payload - ) as r: - return await r.json() + warnings.warn( + "Spond.change_response() is deprecated; use Event.change_response() " + "on the object returned by Spond.get_event() instead.", + DeprecationWarning, + stacklevel=2, + ) + event = await self.get_event(uid) + accepted_raw = payload.get("accepted", "false") + accepted = str(accepted_raw).lower() in ("true", "1", "yes") + decline_message = payload.get("declineMessage") + return await event.change_response( + user, accepted=accepted, decline_message=decline_message + ) @_SpondBase.require_authentication async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: @@ -720,6 +714,6 @@ async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: raise KeyError(errmsg) for entity in entities: - if entity["id"] == uid: + if entity.uid == uid: return entity raise KeyError(errmsg) diff --git a/tests/test_spond.py b/tests/test_spond.py index 8c99583..3c49efe 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -9,12 +9,33 @@ from spond import AuthenticationError from spond.base import _SpondBase +from spond.event import Event +from spond.group import Group from spond.spond import Spond if TYPE_CHECKING: from spond import JSONDict +# Minimum-viable Event API payload — all required fields filled with placeholder +# data. Tests that need an Event object can spread/override. +_MIN_EVENT_PAYLOAD: dict = { + "id": "ID1", + "heading": "Event One", + "startTimestamp": "2026-01-01T10:00:00Z", + "endTimestamp": "2026-01-01T11:00:00Z", + "createdTime": "2025-12-01T10:00:00Z", + "type": "EVENT", + "responses": { + "acceptedIds": [], + "declinedIds": [], + "unansweredIds": [], + "waitinglistIds": [], + "unconfirmedIds": [], + }, +} + + MOCK_USERNAME, MOCK_PASSWORD = "MOCK_USERNAME", "MOCK_PASSWORD" MOCK_TOKEN = "MOCK_TOKEN" MOCK_PAYLOAD = {"accepted": "false", "declineMessage": "sick cannot make it"} @@ -43,22 +64,20 @@ def mock_payload() -> JSONDict: class TestEventMethods: @pytest.fixture - def mock_events(self) -> list[JSONDict]: - """Mock a minimal list of events.""" + def mock_events(self) -> list[Event]: + """Two typed Event instances with placeholder data.""" return [ - { - "id": "ID1", - "name": "Event One", - }, - { - "id": "ID2", - "name": "Event Two", - }, + Event.model_validate( + {**_MIN_EVENT_PAYLOAD, "id": "ID1", "heading": "Event One"} + ), + Event.model_validate( + {**_MIN_EVENT_PAYLOAD, "id": "ID2", "heading": "Event Two"} + ), ] @pytest.mark.asyncio async def test_get_event__happy_path( - self, mock_events: list[JSONDict], mock_token + self, mock_events: list[Event], mock_token ) -> None: """Test that a valid `id` returns the matching event.""" @@ -67,14 +86,13 @@ async def test_get_event__happy_path( s.token = mock_token g = await s.get_event("ID1") - assert g == { - "id": "ID1", - "name": "Event One", - } + assert isinstance(g, Event) + assert g.uid == "ID1" + assert g.heading == "Event One" @pytest.mark.asyncio async def test_get_event__no_match_raises_exception( - self, mock_events: list[JSONDict], mock_token + self, mock_events: list[Event], mock_token ) -> None: """Test that a non-matched `id` raises KeyError.""" @@ -87,7 +105,7 @@ async def test_get_event__no_match_raises_exception( @pytest.mark.asyncio async def test_get_event__blank_id_match_raises_exception( - self, mock_events: list[JSONDict], mock_token + self, mock_events: list[Event], mock_token ) -> None: """Test that a blank `id` raises KeyError.""" @@ -118,32 +136,37 @@ async def test_get_event__no_events_available_raises_keyerror( async def test_update_event__returns_api_response( self, mock_post, mock_token ) -> None: - """`update_event()` should return the POST response, not the cached - events list (regression test for #239).""" + """Deprecated `Spond.update_event()` should still return the POST response + as a dict for backward compatibility (delegates to `Event.update()`).""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) s.token = mock_token - s.events = [{"id": "ID1", "heading": "Old"}] # cached event for _get_entity + s.events = [Event.from_api(_MIN_EVENT_PAYLOAD, s)] - api_response = { - "id": "ID1", - "heading": "New", - "updated": "2026-05-14T13:00:00Z", - } + api_response = {**_MIN_EVENT_PAYLOAD, "heading": "New"} mock_post.return_value.__aenter__.return_value.json = AsyncMock( return_value=api_response ) - result = await s.update_event(uid="ID1", updates={"heading": "New"}) + import warnings as _warnings - assert result == api_response - # The cached events list should NOT be what we returned. - assert result is not s.events + with _warnings.catch_warnings(record=True) as caught: + _warnings.simplefilter("always") + result = await s.update_event(uid="ID1", updates={"heading": "New"}) + + # Deprecation warning fired + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + # Result is a dict (model_dump output), with the updated heading + assert isinstance(result, dict) + assert result["heading"] == "New" @pytest.mark.asyncio @patch("aiohttp.ClientSession.put") async def test_change_response(self, mock_put, mock_payload, mock_token) -> None: + """Deprecated `Spond.change_response()` should still PUT to the same URL + and return the API response (delegates to `Event.change_response()`).""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) s.token = mock_token + s.events = [Event.from_api(_MIN_EVENT_PAYLOAD, s)] mock_response_data = { "acceptedIds": ["PID1", "PID2"], @@ -158,38 +181,37 @@ async def test_change_response(self, mock_put, mock_payload, mock_token) -> None return_value=mock_response_data ) - response = await s.change_response(uid="ID1", user="PID3", payload=mock_payload) + import warnings as _warnings + + with _warnings.catch_warnings(record=True) as caught: + _warnings.simplefilter("always") + response = await s.change_response( + uid="ID1", user="PID3", payload=mock_payload + ) + assert any(issubclass(w.category, DeprecationWarning) for w in caught) mock_url = "https://api.spond.com/core/v1/sponds/ID1/responses/PID3" - mock_put.assert_called_once_with( - mock_url, - headers={ - "content-type": "application/json", - "Authorization": f"Bearer {mock_token}", - }, - json=mock_payload, - ) + # The wrapper translates the payload dict into Event.change_response kwargs, + # which rebuilds the payload — so the json= arg here is the rebuilt form. + call_args = mock_put.call_args + assert call_args[0][0] == mock_url + assert call_args[1]["json"]["accepted"] == "false" + assert call_args[1]["json"]["declineMessage"] == "sick cannot make it" assert response == mock_response_data class TestGroupMethods: @pytest.fixture - def mock_groups(self) -> list[JSONDict]: - """Mock a minimal list of groups.""" + def mock_groups(self) -> list[Group]: + """Two typed Group instances with placeholder data.""" return [ - { - "id": "ID1", - "name": "Group One", - }, - { - "id": "ID2", - "name": "Group Two", - }, + Group.model_validate({"id": "ID1", "name": "Group One"}), + Group.model_validate({"id": "ID2", "name": "Group Two"}), ] @pytest.mark.asyncio async def test_get_group__happy_path( - self, mock_groups: list[JSONDict], mock_token + self, mock_groups: list[Group], mock_token ) -> None: """Test that a valid `id` returns the matching group.""" @@ -198,14 +220,13 @@ async def test_get_group__happy_path( s.token = mock_token g = await s.get_group("ID2") - assert g == { - "id": "ID2", - "name": "Group Two", - } + assert isinstance(g, Group) + assert g.uid == "ID2" + assert g.name == "Group Two" @pytest.mark.asyncio async def test_get_group__no_match_raises_exception( - self, mock_groups: list[JSONDict], mock_token + self, mock_groups: list[Group], mock_token ) -> None: """Test that a non-matched `id` raises KeyError.""" @@ -218,7 +239,7 @@ async def test_get_group__no_match_raises_exception( @pytest.mark.asyncio async def test_get_group__blank_id_raises_exception( - self, mock_groups: list[JSONDict], mock_token + self, mock_groups: list[Group], mock_token ) -> None: """Test that a blank `id` raises KeyError.""" @@ -297,8 +318,12 @@ class TestExportMethod: @pytest.mark.asyncio @patch("aiohttp.ClientSession.get") async def test_get_export(self, mock_get, mock_token) -> None: + """Deprecated `Spond.get_event_attendance_xlsx()` should still GET the + export endpoint and return raw bytes (delegates to + `Event.attendance_xlsx()`).""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) s.token = mock_token + s.events = [Event.from_api(_MIN_EVENT_PAYLOAD, s)] mock_binary = b"\x68\x65\x6c\x6c\x6f\x77\x6f\x72\x6c\x64" # helloworld mock_get.return_value.__aenter__.return_value.status = 200 @@ -306,8 +331,13 @@ async def test_get_export(self, mock_get, mock_token) -> None: return_value=mock_binary ) - data = await s.get_event_attendance_xlsx(uid="ID1") + import warnings as _warnings + with _warnings.catch_warnings(record=True) as caught: + _warnings.simplefilter("always") + data = await s.get_event_attendance_xlsx(uid="ID1") + + assert any(issubclass(w.category, DeprecationWarning) for w in caught) mock_url = "https://api.spond.com/core/v1/sponds/ID1/export" mock_get.assert_called_once_with( mock_url, @@ -368,8 +398,12 @@ async def test_get_posts__happy_path(self, mock_get, mock_token) -> None: "includeComments": "true", }, ) - assert posts == self.MOCK_POSTS - assert s.posts == self.MOCK_POSTS + assert posts is not None + assert len(posts) == 2 + assert posts[0].uid == "POST1" + assert posts[0].title == "Post One" + assert posts[1].uid == "POST2" + assert s.posts is posts # cache identity @pytest.mark.asyncio @patch("aiohttp.ClientSession.get") @@ -553,3 +587,191 @@ def test_decorator_preserves_docstring(self) -> None: def test_decorator_preserves_name(self) -> None: """`__name__` must be the method's, not 'wrapper'.""" assert Spond.get_events.__name__ == "get_events" + + +# ============================================================================= +# OO rewrite tests — typed-object ActiveRecord surface, dict-compat shim, +# inter-dependency navigation. See DESIGN-oo-rewrite.md for context. +# ============================================================================= + + +class TestDictCompat: + """The DictCompatModel shim makes typed models behave like the dicts they + replaced, with a DeprecationWarning on subscript and `.get()`.""" + + def test_subscript_via_alias_warns_and_returns_value(self) -> None: + import warnings as _w + + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + with _w.catch_warnings(record=True) as caught: + _w.simplefilter("always") + value = e["id"] # API alias + assert value == "ID1" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + def test_subscript_via_python_name_warns_and_returns_value(self) -> None: + import warnings as _w + + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + with _w.catch_warnings(record=True) as caught: + _w.simplefilter("always") + value = e["heading"] + assert value == "Event One" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + def test_subscript_missing_key_raises_keyerror(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + with pytest.raises(KeyError): + _ = e["does_not_exist"] + + def test_get_with_default_returns_default_for_missing_key(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + sentinel = object() + assert e.get("does_not_exist", sentinel) is sentinel + + def test_contains_works_for_alias_and_python_name(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + assert "id" in e # alias + assert "heading" in e # python name + assert "startTimestamp" in e # alias + assert "start_time" in e # python name + assert "does_not_exist" not in e + + def test_iter_yields_api_shaped_keys(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + keys = list(e) + assert "id" in keys # alias, not "uid" + assert "startTimestamp" in keys # alias, not "start_time" + + +class TestEventOOMethods: + """ActiveRecord methods on Event.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_returns_new_event(self, mock_post, mock_token) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + api_response = {**_MIN_EVENT_PAYLOAD, "heading": "Updated"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + result = await event.update(heading="Updated") + assert isinstance(result, Event) + assert result.heading == "Updated" + assert result is not event # immutable: returns new + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + async def test_event_change_response_accepts(self, mock_put, mock_token) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value={"acceptedIds": ["MID1"]} + ) + result = await event.change_response("MID1", accepted=True) + assert result == {"acceptedIds": ["MID1"]} + call_args = mock_put.call_args + assert call_args[0][0].endswith("/sponds/ID1/responses/MID1") + assert call_args[1]["json"]["accepted"] == "true" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + async def test_event_change_response_declines_with_message( + self, mock_put, mock_token + ) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value={"declinedIds": ["MID1"]} + ) + await event.change_response("MID1", accepted=False, decline_message="busy") + sent = mock_put.call_args[1]["json"] + assert sent["accepted"] == "false" + assert sent["declineMessage"] == "busy" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_event_attendance_xlsx_returns_bytes( + self, mock_get, mock_token + ) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + mock_get.return_value.__aenter__.return_value.read = AsyncMock( + return_value=b"PKxlsx-bytes" + ) + data = await event.attendance_xlsx() + assert data == b"PKxlsx-bytes" + assert mock_get.call_args[0][0].endswith("/sponds/ID1/export") + + +class TestGroupNavigation: + """Group → Member → Guardian wiring.""" + + def test_group_materializes_typed_members_and_guardians(self) -> None: + raw = { + "id": "GID", + "name": "Test Group", + "members": [ + { + "id": "M1", + "firstName": "Alice", + "lastName": "Smith", + "email": "alice@example.invalid", + "guardians": [ + { + "id": "G1", + "firstName": "Bob", + "lastName": "Smith", + "phoneNumber": "+1", + } + ], + }, + ], + } + from spond.person import Guardian, Member + + group = Group.model_validate(raw) + assert isinstance(group.members[0], Member) + assert group.members[0].full_name == "Alice Smith" + assert isinstance(group.members[0].guardians[0], Guardian) + assert group.members[0].guardians[0].full_name == "Bob Smith" + + def test_find_member_by_email(self) -> None: + group = Group.model_validate( + { + "id": "GID", + "name": "G", + "members": [ + { + "id": "M1", + "firstName": "A", + "lastName": "B", + "email": "a@b.invalid", + }, + ], + } + ) + found = group.find_member(email="a@b.invalid") + assert found is not None + assert found.uid == "M1" + + def test_find_member_returns_none_when_no_match(self) -> None: + group = Group.model_validate({"id": "GID", "name": "G", "members": []}) + assert group.find_member(uid="missing") is None + + def test_find_member_requires_exactly_one_criterion(self) -> None: + group = Group.model_validate({"id": "GID", "name": "G", "members": []}) + with pytest.raises(ValueError, match="exactly one"): + group.find_member() + with pytest.raises(ValueError, match="exactly one"): + group.find_member(uid="X", email="a@b.invalid") From 5000d69488233e01e1694bcb89f8d778b8572c0d Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 17:59:14 +0200 Subject: [PATCH 04/42] docs: README points at typed-object API + DESIGN-oo-rewrite.md Updates the README "Example code" snippet to use attribute access (group.name, member.full_name) instead of dict subscripting, and adds a callout box explaining that v1.3 returns typed objects, that the dict-style fallback works with a DeprecationWarning, and pointing readers at DESIGN-oo-rewrite.md for the full design. --- README.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d6273f4..61966ce 100644 --- a/README.md +++ b/README.md @@ -13,24 +13,35 @@ You need a username and password from Spond ### Example code -``` +```python import asyncio from spond import spond username = 'my@mail.invalid' password = 'Pa55worD' -group_id = 'C9DC791FFE63D7914D6952BE10D97B46' # fake +group_id = 'C9DC791FFE63D7914D6952BE10D97B46' # fake async def main(): s = spond.Spond(username=username, password=password) group = await s.get_group(group_id) - print(group['name']) + print(group.name) + for member in group.members: + print(f" {member.full_name}") + for guardian in member.guardians: + print(f" guardian: {guardian.full_name}") await s.clientsession.close() asyncio.run(main()) - ``` +> **Typed objects from v1.3 onwards.** `get_groups()`, `get_event()`, `get_posts()`, +> etc. now return typed `Group` / `Event` / `Post` objects with attribute access +> and per-instance methods (`event.update(...)`, `event.change_response(...)`, +> `member.send_message(...)`). Existing dict-style access (`group["name"]`) +> still works for one major version with a `DeprecationWarning`. See +> [`DESIGN-oo-rewrite.md`](DESIGN-oo-rewrite.md) for the full design and +> migration story. + ## Key methods ### get_groups() From 4e9bb7203f9071ad5994d9f5f5e5e92663245c1f Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 18:13:02 +0200 Subject: [PATCH 05/42] fix: don't use locals() inside a comprehension (broke Python 3.11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Group.find_member` validated that exactly one search criterion was supplied via: supplied = [k for k in ("uid", "email", "name") if locals()[k] is not None] This worked on Python 3.12+ because PEP 709 inlined comprehensions — locals() inside the comprehension sees the enclosing function's scope. On 3.11, comprehensions still have their own scope, so locals() inside the comprehension is empty, and the indexed lookup raises KeyError. Replaced with an explicit dict mapping that doesn't depend on the comprehension's scoping behaviour. Cleaner anyway. --- spond/group.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spond/group.py b/spond/group.py index 9b8984c..20ddfce 100644 --- a/spond/group.py +++ b/spond/group.py @@ -128,7 +128,8 @@ def find_member( ValueError Zero or more than one search criterion was supplied. """ - supplied = [k for k in ("uid", "email", "name") if locals()[k] is not None] + criteria = {"uid": uid, "email": email, "name": name} + supplied = [k for k, v in criteria.items() if v is not None] if len(supplied) != 1: raise ValueError( f"find_member requires exactly one of uid/email/name; got {supplied}" From 2490b3082626b584b07c8070a6c8571769b0a054 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 18:19:29 +0200 Subject: [PATCH 06/42] fix(oo-rewrite): address Copilot review on #246 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six issues raised: - Event.update() builds the POST payload via model_dump(by_alias=True), but that emits native datetime objects which aiohttp's json.dumps can't serialise — confirmed locally with a hard TypeError. Switched to mode="json" so datetimes serialise as ISO strings. Same fix applied to Spond.update_event's deprecation-wrapper round-trip. - Event.type was annotated as the strict EventType enum, which would ValidationError on any new event variant Spond introduces. Relaxed to plain str; EventType kept as a constants reference for callers who want to compare against known values. - Spond.update_event / change_response / get_event_attendance_xlsx deprecation wrappers now act as thin pass-throughs that preserve the pre-OO byte-for-byte behaviour rather than delegating through Event.* methods. This fixes: * change_response: previously dropped any payload keys outside {accepted, declineMessage} and defaulted accepted to "false" when missing. Now forwards payload verbatim. * update_event: previously raised ValueError on unknown keys (via Event.update's strict _resolve_field_for_update). Now silently ignores keys outside _EVENT_TEMPLATE, matching the old semantics. Also re-added @_SpondBase.require_authentication so the deprecation warning fires after auth, not before. - Member.from_api / Guardian.from_api were dead code — Group.from_api constructs them via Pydantic and walks them to set _client. Removed the unreachable methods; updated the Guardian class docstring to point at Group.from_api as the construction site. - get_groups / get_events / get_posts were caching `[]` as `None` when the API returned an empty list, changing cache semantics from the pre-OO behaviour. Switched to `raw is None` check so empty lists are cached as `[]` exactly as before. Not actioned (verified false alarm): - DictCompatModel.__iter__ yields keys (overriding Pydantic's tuple iter), which the reviewer worried would break `dict(event)` and `{**event}`. Live-tested: both work fine because Python's `dict()` recognises the `.keys()` + `__getitem__` Mapping-like interface and uses that path instead of __iter__. Left as-is. 44 tests pass; ruff clean; format clean. --- spond/event.py | 19 ++++++++++----- spond/person.py | 23 ++++-------------- spond/spond.py | 62 +++++++++++++++++++++++++++++++------------------ 3 files changed, 57 insertions(+), 47 deletions(-) diff --git a/spond/event.py b/spond/event.py index f26b306..5c5f1f3 100644 --- a/spond/event.py +++ b/spond/event.py @@ -26,9 +26,13 @@ class EventType(StrEnum): - """Kind of event, as reported by Spond's `type` field. + """Known canonical values for `Event.type`. - Values may extend over time as Spond introduces new event variants. + `Event.type` is typed as `str` rather than `EventType` because Spond may + introduce new event kinds at any time — constraining the field to this + enum would crash validation whenever an unknown value appears. Use these + constants when comparing (`event.type == EventType.RECURRING`) but expect + `event.type` itself to be a string. """ EVENT = "EVENT" @@ -97,7 +101,10 @@ class Event(DictCompatModel): start_time: datetime = Field(alias="startTimestamp") end_time: datetime = Field(alias="endTimestamp") created_time: datetime = Field(alias="createdTime") - type: EventType + type: str + """Spond's event-kind string. Common values are listed on `EventType`, + but unknown values pass through unchanged so the SDK doesn't crash if + Spond adds new variants.""" responses: Responses # Owner / creator metadata @@ -208,9 +215,9 @@ async def update(self, **fields: Any) -> Event: api_updates[api_name] = value # Build the payload from _EVENT_TEMPLATE, falling back to current state - # for fields the caller didn't provide. Matches the existing - # `Spond.update_event` semantics exactly. - current = self.model_dump(by_alias=True) + # for fields the caller didn't provide. `mode="json"` converts datetimes + # to ISO strings so aiohttp's json.dumps can serialise the payload. + current = self.model_dump(by_alias=True, mode="json") payload = _EVENT_TEMPLATE.copy() for key in payload: if api_updates.get(key) is not None: diff --git a/spond/person.py b/spond/person.py index 2569af2..1553235 100644 --- a/spond/person.py +++ b/spond/person.py @@ -19,15 +19,12 @@ from __future__ import annotations from datetime import datetime -from typing import TYPE_CHECKING, Any +from typing import Any from pydantic import ConfigDict, Field, PrivateAttr from ._compat import DictCompatModel, LenientDate -if TYPE_CHECKING: - from .spond import Spond - class Person(DictCompatModel): """Shared base for `Member` and `Guardian`. @@ -71,13 +68,10 @@ class Guardian(Person): Guardians receive notifications about the member they care for and may respond to events on the member's behalf. They have a strictly smaller field set than Members (no `email`, no `roles`, no nested `guardians`). - """ - @classmethod - def from_api(cls, data: dict[str, Any], client: Spond | None) -> Guardian: - instance = cls.model_validate(data) - instance._client = client - return instance + Constructed by `Group.from_api()` (via Pydantic) and wired with + `_client` post-validation. Don't instantiate directly. + """ async def send_message(self, text: str, group_uid: str) -> dict[str, Any]: """Send a chat message directly to this guardian. @@ -129,15 +123,6 @@ class Member(Person): fields: dict[str, Any] = Field(default_factory=dict) """Custom fields defined on the group. Unmodelled for now.""" - @classmethod - def from_api(cls, data: dict[str, Any], client: Spond | None) -> Member: - instance = cls.model_validate(data) - instance._client = client - # Wire client into nested Guardians too - for g in instance.guardians: - g._client = client - return instance - async def send_message(self, text: str, group_uid: str) -> dict[str, Any]: """Send a chat message directly to this member. diff --git a/spond/spond.py b/spond/spond.py index e78cc3f..a0dbeee 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -151,7 +151,7 @@ async def get_groups(self) -> list[Group] | None: url = f"{self.api_url}groups/" async with self.clientsession.get(url, headers=self.auth_headers) as r: raw = await r.json() - if not raw: + if raw is None: self.groups = None return None self.groups = [Group.from_api(g, self) for g in raw] @@ -306,7 +306,7 @@ async def get_posts( f"Request failed with status {r.status}: {error_details}" ) raw = await r.json() - if not raw: + if raw is None: self.posts = None return None self.posts = [Post.model_validate(p) for p in raw] @@ -553,7 +553,7 @@ async def get_events( f"Request failed with status {r.status}: {error_details}" ) raw = await r.json() - if not raw: + if raw is None: self.events = None return None self.events = [Event.from_api(e, self) for e in raw] @@ -585,6 +585,7 @@ async def get_event(self, uid: str) -> Event: """ return await self._get_entity(self._EVENT, uid) + @_SpondBase.require_authentication async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: """Deprecated — use `Event.update()` on the typed object instead. @@ -597,10 +598,11 @@ async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: await event.update(description="...") ``` - Kept temporarily for backward compatibility; emits `DeprecationWarning`. - Internally delegates to `Event.update()` and returns the updated - event as a dict (via `model_dump(by_alias=True)`) for shape parity - with the pre-OO API. + Kept as a **thin pass-through** for backward compatibility: it + performs the same fetch + `_EVENT_TEMPLATE` merge + POST that the + pre-OO version did, byte-for-byte. Keys in `updates` that aren't in + `_EVENT_TEMPLATE` are silently ignored, matching the old semantics. + Emits `DeprecationWarning`. """ warnings.warn( "Spond.update_event() is deprecated; use Event.update() on the " @@ -608,10 +610,24 @@ async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: DeprecationWarning, stacklevel=2, ) + # Fetch the existing event as a dict for the merge. event = await self.get_event(uid) - new_event = await event.update(**updates) - return new_event.model_dump(by_alias=True) + event_dict = event.model_dump(by_alias=True, mode="json") + + base_event = self._EVENT_TEMPLATE.copy() + for key in base_event: + if event_dict.get(key) is not None and not updates.get(key): + base_event[key] = event_dict[key] + elif updates.get(key) is not None: + base_event[key] = updates[key] + + url = f"{self.api_url}sponds/{uid}" + async with self.clientsession.post( + url, json=base_event, headers=self.auth_headers + ) as r: + return await r.json() + @_SpondBase.require_authentication async def get_event_attendance_xlsx(self, uid: str) -> bytes: """Deprecated — use `Event.attendance_xlsx()` on the typed object instead. @@ -624,8 +640,8 @@ async def get_event_attendance_xlsx(self, uid: str) -> bytes: data = await event.attendance_xlsx() ``` - Kept temporarily for backward compatibility; emits `DeprecationWarning`. - Internally delegates to `Event.attendance_xlsx()`. + Kept as a thin pass-through for backward compatibility. Emits + `DeprecationWarning`. """ warnings.warn( "Spond.get_event_attendance_xlsx() is deprecated; use " @@ -634,9 +650,11 @@ async def get_event_attendance_xlsx(self, uid: str) -> bytes: DeprecationWarning, stacklevel=2, ) - event = await self.get_event(uid) - return await event.attendance_xlsx() + url = f"{self.api_url}sponds/{uid}/export" + async with self.clientsession.get(url, headers=self.auth_headers) as r: + return await r.read() + @_SpondBase.require_authentication async def change_response(self, uid: str, user: str, payload: JSONDict) -> JSONDict: """Deprecated — use `Event.change_response()` on the typed object instead. @@ -649,8 +667,10 @@ async def change_response(self, uid: str, user: str, payload: JSONDict) -> JSOND await event.change_response(member_uid, accepted=True) ``` - Kept temporarily for backward compatibility; emits `DeprecationWarning`. - Internally delegates to `Event.change_response()`. + Kept as a **thin pass-through** for backward compatibility: forwards + `payload` verbatim to the API. Any extra keys the caller supplies + (beyond `accepted` / `declineMessage`) reach the server unchanged, + matching the old semantics. Emits `DeprecationWarning`. """ warnings.warn( "Spond.change_response() is deprecated; use Event.change_response() " @@ -658,13 +678,11 @@ async def change_response(self, uid: str, user: str, payload: JSONDict) -> JSOND DeprecationWarning, stacklevel=2, ) - event = await self.get_event(uid) - accepted_raw = payload.get("accepted", "false") - accepted = str(accepted_raw).lower() in ("true", "1", "yes") - decline_message = payload.get("declineMessage") - return await event.change_response( - user, accepted=accepted, decline_message=decline_message - ) + url = f"{self.api_url}sponds/{uid}/responses/{user}" + async with self.clientsession.put( + url, headers=self.auth_headers, json=payload + ) as r: + return await r.json() @_SpondBase.require_authentication async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: From 7ad9f92d894fee718aae88269767b140154ccb7c Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 18:31:43 +0200 Subject: [PATCH 07/42] =?UTF-8?q?refactor:=20drop=20=5FEVENT=5FTEMPLATE=20?= =?UTF-8?q?=E2=80=94=20Event=20class=20is=20the=20canonical=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-OO, Spond.update_event used a hand-maintained `_EVENT_TEMPLATE` dict to define which fields the update payload should contain. That's exactly the kind of duplication the OO rewrite should eliminate: the Event class is now the schema of an event, and `Event.update()` builds its POST payload via `model_dump(by_alias=True, mode="json")` directly. Changes: - spond/event.py: Event.update() builds the payload from the instance's current state via model_dump (with mode="json" for datetime serialisation), overlays caller-supplied **fields, and POSTs. Unknown keys pass through to Spond verbatim rather than raising ValueError — Spond is the ultimate arbiter of what the API accepts, the SDK shouldn't gate. - spond/spond.py: Deprecated Spond.update_event wrapper now delegates straight to Event.update (no manual template merge). Same behaviour for callers: emit DeprecationWarning, fetch event, apply updates, POST. - spond/_event_template.py: DELETED. Was the technical-debt fixture this refactor exists to remove. - spond.py: dropped the `_EVENT_TEMPLATE: ClassVar` and the import. Note on field coverage: the old template included 4 fields the real-event GET response doesn't return — `spondType`, `maxAccepted`, `rsvpDate`, `payment`. Trusting Spond to fill in server-side defaults for these on update; if a user reports update breakage we can add them back as Event fields with explicit defaults. All 44 tests pass; ruff clean; format clean. --- spond/_event_template.py | 42 ----------------------------- spond/event.py | 57 ++++++++++++++++------------------------ spond/spond.py | 28 +++++--------------- 3 files changed, 29 insertions(+), 98 deletions(-) delete mode 100644 spond/_event_template.py diff --git a/spond/_event_template.py b/spond/_event_template.py deleted file mode 100644 index 39a03df..0000000 --- a/spond/_event_template.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Module contains template event data, to be used as a base when updating events.""" - -from spond import JSONDict - -_EVENT_TEMPLATE: JSONDict = { - "heading": None, - "description": None, - "spondType": "EVENT", - "startTimestamp": None, - "endTimestamp": None, - "commentsDisabled": False, - "maxAccepted": 0, - "rsvpDate": None, - "location": { - "id": None, - "feature": None, - "address": None, - "latitude": None, - "longitude": None, - }, - "owners": [{"id": None}], - "visibility": "INVITEES", - "participantsHidden": False, - "autoReminderType": "DISABLED", - "autoAccept": False, - "payment": {}, - "attachments": [], - "id": None, - "tasks": { - "openTasks": [], - "assignedTasks": [ - { - "name": None, - "description": "", - "type": "ASSIGNED", - "id": None, - "adultsOnly": True, - "assignments": {"memberIds": [], "profiles": [], "remove": []}, - } - ], - }, -} diff --git a/spond/event.py b/spond/event.py index 5c5f1f3..6b11538 100644 --- a/spond/event.py +++ b/spond/event.py @@ -19,7 +19,6 @@ from pydantic import ConfigDict, Field, PrivateAttr from ._compat import DictCompatModel -from ._event_template import _EVENT_TEMPLATE if TYPE_CHECKING: from .spond import Spond @@ -174,34 +173,26 @@ def from_api(cls, data: dict[str, Any], client: Spond) -> Event: instance._client = client return instance - def _resolve_field_for_update(self, key: str) -> tuple[str, str]: - """Translate either a Python attribute name or an API alias to both. - - Returns `(python_name, api_name)`. Raises `ValueError` if neither - form matches a field in this model. - """ - py_name = self._resolve_dict_key(key) - if py_name is None: - raise ValueError( - f"Event has no field {key!r}; valid names are " - f"{sorted(self.__class__.model_fields)}" - ) - field_info = self.__class__.model_fields[py_name] - api_name = field_info.alias or py_name - return py_name, api_name - async def update(self, **fields: Any) -> Event: """POST changes to this event back to Spond and return the updated event. Accepts either Python-style attribute names (`description="..."`) or - API-style aliases (`startTimestamp="..."`). Unknown keys raise - `ValueError`. + API-style aliases (`startTimestamp="..."`). Unknown keys are passed + through to Spond unchanged — Spond is the ultimate arbiter of what + the event API accepts, not this SDK. + + The POST payload is built from this Event's current state via + `model_dump(by_alias=True, mode="json")`, then overlaid with the + caller-supplied `fields`. `mode="json"` converts datetimes to ISO + strings so aiohttp's `json.dumps` can serialise the payload. Parameters ---------- **fields - Field updates to send. Only fields present in `_EVENT_TEMPLATE` - actually reach the server — others are silently dropped by Spond. + Field updates to send. Use the Python attribute name + (`description`, `start_time`, …) or the API alias + (`startTimestamp`, …) — either resolves correctly. Unknown keys + pass through to the API verbatim. Returns ------- @@ -209,21 +200,19 @@ async def update(self, **fields: Any) -> Event: A new `Event` reflecting the persisted state. The original instance is **not** mutated. """ + # Translate caller-supplied keys to API names. Unknown keys pass + # through as-is so Spond-side changes don't get blocked client-side. api_updates: dict[str, Any] = {} for key, value in fields.items(): - _, api_name = self._resolve_field_for_update(key) - api_updates[api_name] = value - - # Build the payload from _EVENT_TEMPLATE, falling back to current state - # for fields the caller didn't provide. `mode="json"` converts datetimes - # to ISO strings so aiohttp's json.dumps can serialise the payload. - current = self.model_dump(by_alias=True, mode="json") - payload = _EVENT_TEMPLATE.copy() - for key in payload: - if api_updates.get(key) is not None: - payload[key] = api_updates[key] - elif current.get(key) is not None: - payload[key] = current[key] + py_name = self._resolve_dict_key(key) + if py_name is None: + api_updates[key] = value + else: + field_info = self.__class__.model_fields[py_name] + api_updates[field_info.alias or py_name] = value + + payload = self.model_dump(by_alias=True, mode="json") + payload.update(api_updates) url = f"{self._client.api_url}sponds/{self.uid}" async with self._client.clientsession.post( diff --git a/spond/spond.py b/spond/spond.py index a0dbeee..82060c3 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -13,7 +13,6 @@ from typing import TYPE_CHECKING, ClassVar from . import JSONDict -from ._event_template import _EVENT_TEMPLATE from .base import _SpondBase from .event import Event from .group import Group @@ -69,7 +68,6 @@ async def main(): _API_BASE_URL: ClassVar = "https://api.spond.com/core/v1/" _DT_FORMAT: ClassVar = "%Y-%m-%dT00:00:00.000Z" - _EVENT_TEMPLATE: ClassVar = _EVENT_TEMPLATE _EVENT: ClassVar = "event" _GROUP: ClassVar = "group" @@ -598,11 +596,10 @@ async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: await event.update(description="...") ``` - Kept as a **thin pass-through** for backward compatibility: it - performs the same fetch + `_EVENT_TEMPLATE` merge + POST that the - pre-OO version did, byte-for-byte. Keys in `updates` that aren't in - `_EVENT_TEMPLATE` are silently ignored, matching the old semantics. - Emits `DeprecationWarning`. + Delegates to `Event.update()`; unknown keys in `updates` pass through + to Spond verbatim (Spond decides what it accepts, the SDK doesn't + gate). Returns the updated event as a dict for shape parity with the + pre-OO API. Emits `DeprecationWarning`. """ warnings.warn( "Spond.update_event() is deprecated; use Event.update() on the " @@ -610,22 +607,9 @@ async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: DeprecationWarning, stacklevel=2, ) - # Fetch the existing event as a dict for the merge. event = await self.get_event(uid) - event_dict = event.model_dump(by_alias=True, mode="json") - - base_event = self._EVENT_TEMPLATE.copy() - for key in base_event: - if event_dict.get(key) is not None and not updates.get(key): - base_event[key] = event_dict[key] - elif updates.get(key) is not None: - base_event[key] = updates[key] - - url = f"{self.api_url}sponds/{uid}" - async with self.clientsession.post( - url, json=base_event, headers=self.auth_headers - ) as r: - return await r.json() + new_event = await event.update(**updates) + return new_event.model_dump(by_alias=True, mode="json") @_SpondBase.require_authentication async def get_event_attendance_xlsx(self, uid: str) -> bytes: From 636dbc0b8489a67467bc7dbabb32a332f64dc127 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 18:42:54 +0200 Subject: [PATCH 08/42] fix(oo-rewrite): address second-round Copilot review on #246 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight of ten findings actioned: - Event required fields could crash on Spond API drift if a documented field was ever dropped. Made heading/start_time/end_time/ created_time/type/responses optional with sentinel defaults. Only `uid` stays required (every method addresses an event by id). __str__ guards against start_time=None. - Event.update() assumed Spond returns a full event on POST. If the response is partial (status-only, error wrapper) Pydantic would crash. Wrapped Event.from_api in a try/except ValidationError and fall back to a fresh `self._client.get_event(self.uid)` on failure. - Event.update(**fields) couldn't accept keys that clash with reserved kwargs like `self` / `cls`. Added a positional-only `_updates` dict parameter so callers with arbitrary keys can pass them as a dict. Spond.update_event wrapper now uses the positional form to avoid `**updates` collision risk entirely. - DictCompatModel.__len__ returned the count of fields declared on the class (~30 for Event), not the count of fields actually present in the source data. Switched to `self.model_fields_set` which mirrors the original dict's `len()` semantics. - _match_person could falsely match `None == None` if a member had no email on record and match_str was somehow None. Added an explicit bool() truthiness guard on person.email. - Group.from_api accepted `client: Spond | None` but propagated it unconditionally. Tightened the signature to `client: Spond` and documented that the SDK refuses to construct a no-client Group. - Stale comment in test_change_response — the wrapper is now a pure pass-through, so the test now asserts call args against `mock_payload` verbatim via `mock_put.assert_called_once_with(...)`. - Asymmetric `subGroups` (Member) vs `subGroupIds` (Post) aliases — Copilot suspected one was wrong; both match the live API. Added a docstring explaining the API itself is asymmetric. Not actioned (intentional design / non-issue): - `s.events` / `s.groups` etc. cache typed models, not dicts. Anyone serialising the cache via json.dumps directly would break. This is the intended OO direction; DictCompatModel provides the migration path. Not a regression to fix. - `Member.role_uids` typed as list[str] — Copilot worried it might sometimes be list[dict]. Verified against live data: it's list[str] for the account we tested. Leaving as-is; can revisit on reports. Added regression test `test_event_update_accepts_positional_dict` locking in the new dict form and unknown-key pass-through. 45 tests pass; ruff clean; format clean. Live-tested end-to-end. --- spond/_compat.py | 6 +++++- spond/event.py | 51 ++++++++++++++++++++++++++++++--------------- spond/group.py | 11 +++++----- spond/person.py | 10 ++++++++- spond/spond.py | 15 ++++++++++--- tests/test_spond.py | 39 ++++++++++++++++++++++++++++------ 6 files changed, 99 insertions(+), 33 deletions(-) diff --git a/spond/_compat.py b/spond/_compat.py index 5c65b3f..d8e680d 100644 --- a/spond/_compat.py +++ b/spond/_compat.py @@ -112,7 +112,11 @@ def __iter__(self) -> Iterator[str]: # type: ignore[override] yield field_info.alias or field_name def __len__(self) -> int: - return len(self.__class__.model_fields) + # Mirror dict semantics: report the number of fields that were + # actually present in the source data, not the count of fields + # declared on the class. Pydantic tracks this via `model_fields_set` + # for instances built from `model_validate(...)`. + return len(self.model_fields_set) def keys(self) -> list[str]: """Dict-style `.keys()` — returns the API-shaped key names.""" diff --git a/spond/event.py b/spond/event.py index 6b11538..8693b38 100644 --- a/spond/event.py +++ b/spond/event.py @@ -16,7 +16,7 @@ from enum import StrEnum from typing import TYPE_CHECKING, Any -from pydantic import ConfigDict, Field, PrivateAttr +from pydantic import ConfigDict, Field, PrivateAttr, ValidationError from ._compat import DictCompatModel @@ -94,17 +94,21 @@ class Event(DictCompatModel): arbitrary_types_allowed=True, ) - # Core fields (always present in API data) + # Core fields. Only `uid` is truly required for the SDK to be useful + # at all (every method addresses an event by id); the others have + # defaults so the SDK doesn't hard-fail if Spond ever drops a field + # from its response shape. Defaults are deliberately sentinel-ish so + # callers can distinguish "field absent" from "field genuinely empty". uid: str = Field(alias="id") - heading: str - start_time: datetime = Field(alias="startTimestamp") - end_time: datetime = Field(alias="endTimestamp") - created_time: datetime = Field(alias="createdTime") - type: str + heading: str = "" + start_time: datetime | None = Field(default=None, alias="startTimestamp") + end_time: datetime | None = Field(default=None, alias="endTimestamp") + created_time: datetime | None = Field(default=None, alias="createdTime") + type: str = "" """Spond's event-kind string. Common values are listed on `EventType`, but unknown values pass through unchanged so the SDK doesn't crash if Spond adds new variants.""" - responses: Responses + responses: Responses = Field(default_factory=lambda: Responses()) # Owner / creator metadata creator_uid: str | None = Field(default=None, alias="creatorId") @@ -151,10 +155,8 @@ class Event(DictCompatModel): _client: Any = PrivateAttr(default=None) def __str__(self) -> str: - return ( - f"Event(uid={self.uid!r}, heading={self.heading!r}, " - f"start_time={self.start_time.isoformat()})" - ) + start = self.start_time.isoformat() if self.start_time else "?" + return f"Event(uid={self.uid!r}, heading={self.heading!r}, start_time={start})" @property def url(self) -> str: @@ -173,7 +175,9 @@ def from_api(cls, data: dict[str, Any], client: Spond) -> Event: instance._client = client return instance - async def update(self, **fields: Any) -> Event: + async def update( + self, _updates: dict[str, Any] | None = None, /, **fields: Any + ) -> Event: """POST changes to this event back to Spond and return the updated event. Accepts either Python-style attribute names (`description="..."`) or @@ -183,16 +187,21 @@ async def update(self, **fields: Any) -> Event: The POST payload is built from this Event's current state via `model_dump(by_alias=True, mode="json")`, then overlaid with the - caller-supplied `fields`. `mode="json"` converts datetimes to ISO + caller-supplied updates. `mode="json"` converts datetimes to ISO strings so aiohttp's `json.dumps` can serialise the payload. Parameters ---------- + _updates : dict, positional-only, optional + Dict of updates to apply. Useful when keys clash with Python + reserved kwarg names like `self` or `cls` (which `**fields` + can't carry), or when callers already have a dict in hand. **fields Field updates to send. Use the Python attribute name (`description`, `start_time`, …) or the API alias (`startTimestamp`, …) — either resolves correctly. Unknown keys - pass through to the API verbatim. + pass through to the API verbatim. Merged on top of `_updates` + if both are supplied. Returns ------- @@ -202,8 +211,9 @@ async def update(self, **fields: Any) -> Event: """ # Translate caller-supplied keys to API names. Unknown keys pass # through as-is so Spond-side changes don't get blocked client-side. + combined: dict[str, Any] = {**(_updates or {}), **fields} api_updates: dict[str, Any] = {} - for key, value in fields.items(): + for key, value in combined.items(): py_name = self._resolve_dict_key(key) if py_name is None: api_updates[key] = value @@ -220,7 +230,14 @@ async def update(self, **fields: Any) -> Event: ) as r: new_data = await r.json() - return Event.from_api(new_data, self._client) + # Spond usually returns the updated event on POST, but if the + # response is partial (status-only, an error wrapper, etc.) the + # construction below would crash with ValidationError. Fall back to + # a fresh GET in that case so callers always get a coherent Event. + try: + return Event.from_api(new_data, self._client) + except ValidationError: + return await self._client.get_event(self.uid) async def change_response( self, diff --git a/spond/group.py b/spond/group.py index 20ddfce..da50705 100644 --- a/spond/group.py +++ b/spond/group.py @@ -79,13 +79,14 @@ def __str__(self) -> str: ) @classmethod - def from_api(cls, data: dict[str, Any], client: Spond | None) -> Group: + def from_api(cls, data: dict[str, Any], client: Spond) -> Group: """Construct a `Group` from raw API data and wire `_client` through. - Sets `_client` on the group and on every nested member (which in turn - wires it onto each member's guardians via `Member.from_api`). This - lets `member.send_message(...)` and any future per-member behaviour - work without further plumbing. + Sets `_client` on the group and on every nested member and guardian, + so per-instance methods like `member.send_message(...)` can issue + HTTP calls without further plumbing. `client` is required — passing + a no-client Group around would crash any subsequent behaviour call + with a confusing late-stage error. """ instance = cls.model_validate(data) instance._client = client diff --git a/spond/person.py b/spond/person.py index 1553235..53cd8eb 100644 --- a/spond/person.py +++ b/spond/person.py @@ -114,7 +114,15 @@ class Member(Person): """UIDs of `Role`s this member holds within the group.""" subgroup_uids: list[str] = Field(default_factory=list, alias="subGroups") - """UIDs of `Subgroup`s this member belongs to within the group.""" + """UIDs of `Subgroup`s this member belongs to within the group. + + Note: the API alias here is `subGroups` (not `subGroupIds`) because + that's the field name Spond's groups endpoint actually returns inside + each member object. `Post.subgroup_uids` aliases `subGroupIds` for the + same reason — Spond's posts endpoint uses a different key name for + what is conceptually the same kind of list. The asymmetry is in the + API, not in this SDK. + """ respondent: bool = False """Whether this member personally responds to events (False for child diff --git a/spond/spond.py b/spond/spond.py index 82060c3..00e9c3b 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -248,8 +248,15 @@ def _match_person(person: Person, match_str: str) -> bool: return True if person.profile is not None and person.profile.get("id") == match_str: return True - # Members have email; Guardians don't. - return isinstance(person, Member) and person.email == match_str + # Members have email; Guardians don't. Guard against the + # `None == None` trap — if the member has no email on record and + # the caller (somehow) supplied None as match_str, we don't want + # to claim a match. + return ( + isinstance(person, Member) + and bool(person.email) + and person.email == match_str + ) @_SpondBase.require_authentication async def get_posts( @@ -608,7 +615,9 @@ async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: stacklevel=2, ) event = await self.get_event(uid) - new_event = await event.update(**updates) + # Pass as positional dict, not **kwargs — `updates` may contain keys + # like `self` or `cls` that would clash with bound-method calling. + new_event = await event.update(updates) return new_event.model_dump(by_alias=True, mode="json") @_SpondBase.require_authentication diff --git a/tests/test_spond.py b/tests/test_spond.py index 3c49efe..0628f24 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -191,12 +191,16 @@ async def test_change_response(self, mock_put, mock_payload, mock_token) -> None assert any(issubclass(w.category, DeprecationWarning) for w in caught) mock_url = "https://api.spond.com/core/v1/sponds/ID1/responses/PID3" - # The wrapper translates the payload dict into Event.change_response kwargs, - # which rebuilds the payload — so the json= arg here is the rebuilt form. - call_args = mock_put.call_args - assert call_args[0][0] == mock_url - assert call_args[1]["json"]["accepted"] == "false" - assert call_args[1]["json"]["declineMessage"] == "sick cannot make it" + # The wrapper forwards `payload` verbatim — same bytes that go on + # the wire on the pre-OO code path. + mock_put.assert_called_once_with( + mock_url, + headers={ + "content-type": "application/json", + "Authorization": f"Bearer {mock_token}", + }, + json=mock_payload, + ) assert response == mock_response_data @@ -664,6 +668,29 @@ async def test_event_update_returns_new_event(self, mock_post, mock_token) -> No assert result.heading == "Updated" assert result is not event # immutable: returns new + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_accepts_positional_dict( + self, mock_post, mock_token + ) -> None: + """`event.update(updates_dict)` works for keys that would clash with + reserved kwargs when passed via `**` (e.g. `self`, `cls`).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + api_response = {**_MIN_EVENT_PAYLOAD, "heading": "New"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + # Use the positional dict form + result = await event.update({"heading": "New", "selfish": "any"}) + assert result.heading == "New" + # Unknown key "selfish" was passed through to the API payload + posted = mock_post.call_args[1]["json"] + assert posted["selfish"] == "any" + @pytest.mark.asyncio @patch("aiohttp.ClientSession.put") async def test_event_change_response_accepts(self, mock_put, mock_token) -> None: From f53853ee550d7fbb1ef141153db098efd9513ac8 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 18:59:14 +0200 Subject: [PATCH 09/42] fix(oo-rewrite): address third-round Copilot review on #246 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten findings actioned (the eleventh — subtle _match_person behavioural nuance — was a no-op note, not a bug). DictCompatModel consistency (findings #1, #2): - __len__, __contains__, __iter__, keys, values, items now all use `_present_api_keys()` which returns the field names actually present in the source data. Pre-OO callers got `len(d) == len(list(d)) == sum(1 for _ in d)` on raw dicts; this restores that invariant. - New `_pydantic_extras()` helper surfaces fields preserved by `extra="allow"` (see below) so they participate in all dict-compat views, including __getitem__ (with its own targeted deprecation warning explaining the field is unmodelled). Event.update payload (findings #3, #4): - Added `_EVENT_READ_ONLY_FIELDS` whitelist of fields the update body should NOT carry: creator_uid, created_time, updated, expired, registered, modified_from_series, series_uid, series_ordinal, responses, recipients, comments. These are server-managed, derived, or have their own dedicated endpoints (e.g. change_response for responses). Critical: sending `responses` back risked Spond overwriting concurrent attendance changes with stale local state. - `Event.update()` now does `model_dump(exclude=_EVENT_READ_ONLY_FIELDS)` before overlaying caller updates, so the POST body matches the spirit of the pre-OO _EVENT_TEMPLATE whitelist without needing a separate template fixture. Forward compatibility with API drift (finding #7): - Switched every DictCompatModel subclass from `extra="ignore"` to `extra="allow"` (Profile, Member, Guardian, Group, Post, Subgroup, Role, Event, Responses, plus Transaction in club.py). Unknown fields Spond emits are now preserved on the instance and accessible both as attributes (Pydantic native) and via the dict-compat shim. Avoids silently dropping data on API drift. SpondClub.get_transactions atomicity (finding #6): - Build the page list before extending self.transactions, so a mid-page Pydantic validation failure can't leave the cache half-populated. Test fixes (findings #8, #9): - Removed misleading `s.events = [...]` from test_get_export — the deprecated wrapper doesn't consult the cache. - Restored the `assert result is not s.events` identity guard in test_update_event__returns_api_response — it's the explicit regression check for the original #239 bug. - Added `test_len_contains_and_iter_agree` locking in the new dict-compat consistency invariant, and `test_extra_allow_preserves_unmodelled_fields` covering the forward-compat behaviour. Documentation (findings #10, #11): - Event.update docstring tightened to make resolution bounds explicit (`bounded to Event.model_fields`). - README's "from v1.3 onwards" softened to "next minor release" since pyproject.toml still says 1.2.1. - DESIGN-oo-rewrite.md `#243` reference removed (rotting issue link). 47 tests pass; ruff clean; format clean. Live-tested end-to-end: real event has 29 present keys (matches API response), `len()` and iteration agree, the description field present in the API correctly shows in `in`/iter — the inconsistency between length and iteration is gone. --- DESIGN-oo-rewrite.md | 2 +- README.md | 7 +-- spond/_compat.py | 124 +++++++++++++++++++++++++++++-------------- spond/club.py | 6 ++- spond/event.py | 41 +++++++++++--- spond/group.py | 2 +- spond/person.py | 4 +- spond/post.py | 2 +- spond/profile.py | 2 +- spond/role.py | 2 +- spond/subgroup.py | 2 +- tests/test_spond.py | 38 ++++++++++++- 12 files changed, 174 insertions(+), 58 deletions(-) diff --git a/DESIGN-oo-rewrite.md b/DESIGN-oo-rewrite.md index 06bae06..913228c 100644 --- a/DESIGN-oo-rewrite.md +++ b/DESIGN-oo-rewrite.md @@ -192,7 +192,7 @@ All four are answerable mid-impl with live API probing using credentials at `/ho ## Out of scope - `Spond.get_messages` and the chat machinery — chats are tangled, leave on the dict-based path. Possible v1.4 follow-up. -- Removing `self.events_update` (already removed in #243). +- Removing `self.events_update` (a pre-existing latent attribute that was already cleaned up before this PR — out of scope here). - Adding new HTTP endpoints. This is a re-shaping of the existing surface only. - Renaming any `Spond.get_*` method — name stability matters more than name perfection. diff --git a/README.md b/README.md index 61966ce..e9a908a 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,10 @@ async def main(): asyncio.run(main()) ``` -> **Typed objects from v1.3 onwards.** `get_groups()`, `get_event()`, `get_posts()`, -> etc. now return typed `Group` / `Event` / `Post` objects with attribute access -> and per-instance methods (`event.update(...)`, `event.change_response(...)`, +> **Typed objects from the next minor release onwards.** `get_groups()`, +> `get_event()`, `get_posts()`, etc. now return typed `Group` / `Event` / +> `Post` objects with attribute access and per-instance methods +> (`event.update(...)`, `event.change_response(...)`, > `member.send_message(...)`). Existing dict-style access (`group["name"]`) > still works for one major version with a `DeprecationWarning`. See > [`DESIGN-oo-rewrite.md`](DESIGN-oo-rewrite.md) for the full design and diff --git a/spond/_compat.py b/spond/_compat.py index d8e680d..2d356a1 100644 --- a/spond/_compat.py +++ b/spond/_compat.py @@ -65,71 +65,117 @@ class DictCompatModel(BaseModel): """ def _resolve_dict_key(self, key: str) -> str | None: - """Return the Python attribute name matching `key`, or None. + """Return the Python attribute name for a declared field matching `key`. - Matches either the field's API alias or its Python name. + Matches either the field's API alias or its Python name. Resolution + is bounded to `self.__class__.model_fields` — never reaches parent + attributes that aren't declared as fields. Does **not** resolve + keys against `__pydantic_extra__`; callers wanting that should + check `_pydantic_extras()` separately. """ for field_name, field_info in self.__class__.model_fields.items(): if field_info.alias == key or field_name == key: return field_name return None + def _pydantic_extras(self) -> dict[str, Any]: + """Unknown fields preserved by `model_config = extra="allow"`. + + Empty dict for models with `extra="ignore"` (the older config), since + Pydantic discards unknown fields there. + """ + return getattr(self, "__pydantic_extra__", None) or {} + + def _present_api_keys(self) -> list[str]: + """API-shaped key names actually present in the source data. + + Returns declared fields that were set during validation (using + their alias if defined, else Python name) plus any extras + preserved via `extra="allow"`. Mirrors pre-OO dict semantics where + iterating a parsed JSON response yielded only the keys the API + actually sent. + """ + keys: list[str] = [] + present_declared = set(self.model_fields_set) + for field_name, field_info in self.__class__.model_fields.items(): + if field_name in present_declared: + keys.append(field_info.alias or field_name) + keys.extend(self._pydantic_extras().keys()) + return keys + def __getitem__(self, key: str) -> Any: field_name = self._resolve_dict_key(key) - if field_name is None: - raise KeyError(key) - warnings.warn( - f"{self.__class__.__name__}[{key!r}] uses deprecated dict-style " - f"access; use attribute access (`.{field_name}`) instead", - DeprecationWarning, - stacklevel=2, - ) - return getattr(self, field_name) + if field_name is not None and field_name in self.model_fields_set: + warnings.warn( + f"{self.__class__.__name__}[{key!r}] uses deprecated dict-style " + f"access; use attribute access (`.{field_name}`) instead", + DeprecationWarning, + stacklevel=2, + ) + return getattr(self, field_name) + extras = self._pydantic_extras() + if key in extras: + warnings.warn( + f"{self.__class__.__name__}[{key!r}] accesses an unmodelled " + f"field preserved via extra='allow'; dict-style access is " + f"deprecated — use `obj.{key}` instead", + DeprecationWarning, + stacklevel=2, + ) + return extras[key] + raise KeyError(key) def get(self, key: str, default: Any = None) -> Any: """Dict-style `.get(key, default)` with deprecation warning.""" - field_name = self._resolve_dict_key(key) - if field_name is None: + try: + return self[key] + except KeyError: return default - warnings.warn( - f"{self.__class__.__name__}.get({key!r}) uses deprecated dict-style " - f"access; use attribute access (`.{field_name}`) instead", - DeprecationWarning, - stacklevel=2, - ) - return getattr(self, field_name) def __contains__(self, key: object) -> bool: - return isinstance(key, str) and self._resolve_dict_key(key) is not None + if not isinstance(key, str): + return False + field_name = self._resolve_dict_key(key) + if field_name is not None and field_name in self.model_fields_set: + return True + return key in self._pydantic_extras() def __iter__(self) -> Iterator[str]: # type: ignore[override] - """Yield API-shaped keys (alias if defined, else field name). + """Yield API-shaped keys for fields actually present in the source data. - This deliberately overrides `pydantic.BaseModel.__iter__`, which yields - `(name, value)` tuples — dict-compat callers expect just the keys. + Overrides `pydantic.BaseModel.__iter__` (which yields `(name, value)` + tuples) so `for k in obj` matches dict semantics. Only yields keys + for fields populated during validation plus any extras preserved + via `extra="allow"` — not defaulted fields. """ - for field_name, field_info in self.__class__.model_fields.items(): - yield field_info.alias or field_name + yield from self._present_api_keys() def __len__(self) -> int: - # Mirror dict semantics: report the number of fields that were - # actually present in the source data, not the count of fields - # declared on the class. Pydantic tracks this via `model_fields_set` - # for instances built from `model_validate(...)`. - return len(self.model_fields_set) + return len(self._present_api_keys()) def keys(self) -> list[str]: - """Dict-style `.keys()` — returns the API-shaped key names.""" - return list(iter(self)) + """Dict-style `.keys()` — API-shaped names of fields present in the source.""" + return list(self._present_api_keys()) def values(self) -> list[Any]: - """Dict-style `.values()` — returns the attribute values in field order.""" - return [getattr(self, name) for name in self.__class__.model_fields] + """Dict-style `.values()` — values for fields present in the source.""" + present_declared = set(self.model_fields_set) + result = [ + getattr(self, name) + for name in self.__class__.model_fields + if name in present_declared + ] + result.extend(self._pydantic_extras().values()) + return result def items(self) -> list[tuple[str, Any]]: - """Dict-style `.items()` — returns (api-key, value) pairs in field order.""" - result = [] + """Dict-style `.items()` — (api-key, value) pairs for fields present.""" + present_declared = set(self.model_fields_set) + result: list[tuple[str, Any]] = [] for field_name, field_info in self.__class__.model_fields.items(): - key = field_info.alias or field_name - result.append((key, getattr(self, field_name))) + if field_name in present_declared: + key = field_info.alias or field_name + result.append((key, getattr(self, field_name))) + for extra_key, extra_value in self._pydantic_extras().items(): + result.append((extra_key, extra_value)) return result diff --git a/spond/club.py b/spond/club.py index b051840..f1aa084 100644 --- a/spond/club.py +++ b/spond/club.py @@ -142,7 +142,11 @@ async def get_transactions( if len(raw) == 0: return self.transactions - self.transactions.extend(Transaction.model_validate(t) for t in raw) + # Validate the whole page first, then extend, so a mid-page + # validation failure can't leave `self.transactions` + # partially populated. + page = [Transaction.model_validate(t) for t in raw] + self.transactions.extend(page) if len(self.transactions) < max_items: return await self.get_transactions( club_id=club_id, diff --git a/spond/event.py b/spond/event.py index 8693b38..b8de6db 100644 --- a/spond/event.py +++ b/spond/event.py @@ -52,7 +52,7 @@ class Responses(DictCompatModel): follow-up). """ - model_config = ConfigDict(populate_by_name=True, extra="ignore") + model_config = ConfigDict(populate_by_name=True, extra="allow") accepted_uids: list[str] = Field(default_factory=list, alias="acceptedIds") """UIDs of members who accepted the invitation.""" @@ -66,6 +66,28 @@ class Responses(DictCompatModel): """UIDs of members whose response needs confirmation.""" +# Python field names that `Event.update()` strips from the POST payload — +# they're either server-managed (creator/timestamps), derived (`expired`), +# or have their own dedicated endpoint (`responses` goes through +# `change_response`). Sending these back in an update body risks Spond +# treating stale local state as authoritative. +_EVENT_READ_ONLY_FIELDS = frozenset( + { + "creator_uid", + "created_time", + "updated", + "expired", + "registered", + "modified_from_series", + "series_uid", + "series_ordinal", + "responses", + "recipients", + "comments", + } +) + + class Event(DictCompatModel): """A Spond event with attached operations. @@ -90,7 +112,7 @@ class Event(DictCompatModel): model_config = ConfigDict( populate_by_name=True, - extra="ignore", + extra="allow", arbitrary_types_allowed=True, ) @@ -181,9 +203,11 @@ async def update( """POST changes to this event back to Spond and return the updated event. Accepts either Python-style attribute names (`description="..."`) or - API-style aliases (`startTimestamp="..."`). Unknown keys are passed - through to Spond unchanged — Spond is the ultimate arbiter of what - the event API accepts, not this SDK. + API-style aliases (`startTimestamp="..."`) — resolution is bounded + to `Event.model_fields`, so keys that don't match a declared field + in either form pass through to Spond verbatim under their original + name. Spond is the ultimate arbiter of what the event API accepts, + not this SDK. The POST payload is built from this Event's current state via `model_dump(by_alias=True, mode="json")`, then overlaid with the @@ -221,7 +245,12 @@ async def update( field_info = self.__class__.model_fields[py_name] api_updates[field_info.alias or py_name] = value - payload = self.model_dump(by_alias=True, mode="json") + # Dump the current state, then strip read-only fields (creator, + # timestamps, server-managed flags, responses) so the update body + # only carries fields the API actually accepts on POST. + payload = self.model_dump( + by_alias=True, mode="json", exclude=_EVENT_READ_ONLY_FIELDS + ) payload.update(api_updates) url = f"{self._client.api_url}sponds/{self.uid}" diff --git a/spond/group.py b/spond/group.py index da50705..768a118 100644 --- a/spond/group.py +++ b/spond/group.py @@ -47,7 +47,7 @@ class Group(DictCompatModel): model_config = ConfigDict( populate_by_name=True, - extra="ignore", + extra="allow", arbitrary_types_allowed=True, ) diff --git a/spond/person.py b/spond/person.py index 53cd8eb..26fb8f6 100644 --- a/spond/person.py +++ b/spond/person.py @@ -37,7 +37,7 @@ class Person(DictCompatModel): model_config = ConfigDict( populate_by_name=True, - extra="ignore", + extra="allow", arbitrary_types_allowed=True, ) @@ -96,7 +96,7 @@ class Member(Person): model_config = ConfigDict( populate_by_name=True, - extra="ignore", + extra="allow", arbitrary_types_allowed=True, ) diff --git a/spond/post.py b/spond/post.py index 21dbf84..430c164 100644 --- a/spond/post.py +++ b/spond/post.py @@ -21,7 +21,7 @@ class Post(DictCompatModel): """A post on a Group's wall (announcement, not a chat message).""" - model_config = ConfigDict(populate_by_name=True, extra="ignore") + model_config = ConfigDict(populate_by_name=True, extra="allow") uid: str = Field(alias="id") type: str = "PLAIN" diff --git a/spond/profile.py b/spond/profile.py index 4621c33..da6f7ef 100644 --- a/spond/profile.py +++ b/spond/profile.py @@ -22,7 +22,7 @@ class Profile(DictCompatModel): nested inside Member/Guardian — that's a sparse reference shape. """ - model_config = ConfigDict(populate_by_name=True, extra="ignore") + model_config = ConfigDict(populate_by_name=True, extra="allow") uid: str = Field(alias="id") first_name: str = Field(alias="firstName") diff --git a/spond/role.py b/spond/role.py index 78a7a02..d28bc4b 100644 --- a/spond/role.py +++ b/spond/role.py @@ -15,7 +15,7 @@ class Role(DictCompatModel): """A named permission role within a `Group` (e.g. \"Coach\", \"Treasurer\").""" - model_config = ConfigDict(populate_by_name=True, extra="ignore") + model_config = ConfigDict(populate_by_name=True, extra="allow") uid: str = Field(alias="id") name: str diff --git a/spond/subgroup.py b/spond/subgroup.py index f5d1342..aeb1299 100644 --- a/spond/subgroup.py +++ b/spond/subgroup.py @@ -17,7 +17,7 @@ class Subgroup(DictCompatModel): Members reference subgroups by UID via `Member.subgroup_uids`. """ - model_config = ConfigDict(populate_by_name=True, extra="ignore") + model_config = ConfigDict(populate_by_name=True, extra="allow") uid: str = Field(alias="id") name: str diff --git a/tests/test_spond.py b/tests/test_spond.py index 0628f24..4d0eacd 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -158,6 +158,9 @@ async def test_update_event__returns_api_response( # Result is a dict (model_dump output), with the updated heading assert isinstance(result, dict) assert result["heading"] == "New" + # Regression guard for issue #239: the result must NOT be the + # cached events list (the bug that was: `return self.events`). + assert result is not s.events @pytest.mark.asyncio @patch("aiohttp.ClientSession.put") @@ -327,7 +330,9 @@ async def test_get_export(self, mock_get, mock_token) -> None: `Event.attendance_xlsx()`).""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) s.token = mock_token - s.events = [Event.from_api(_MIN_EVENT_PAYLOAD, s)] + # Note: `s.events` is intentionally not pre-populated — the + # deprecated wrapper does a direct GET on the export endpoint, it + # doesn't consult the events cache. mock_binary = b"\x68\x65\x6c\x6c\x6f\x77\x6f\x72\x6c\x64" # helloworld mock_get.return_value.__aenter__.return_value.status = 200 @@ -647,6 +652,37 @@ def test_iter_yields_api_shaped_keys(self) -> None: assert "id" in keys # alias, not "uid" assert "startTimestamp" in keys # alias, not "start_time" + def test_len_contains_and_iter_agree(self) -> None: + """`__len__`, `__contains__`, and `__iter__` must all reflect the + same view of "what's actually in this object" — pre-OO callers + relied on dict semantics where these three are always consistent.""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + keys = list(e) + assert len(e) == len(keys) + for k in keys: + assert k in e + # A field with a default that wasn't in the source data must NOT + # appear in any of the three views. + assert "description" not in e # not in _MIN_EVENT_PAYLOAD + assert "description" not in keys + + def test_extra_allow_preserves_unmodelled_fields(self) -> None: + """With `model_config = extra="allow"`, Spond-side fields the SDK + doesn't model are preserved on the instance and accessible via the + dict-compat shim (with deprecation warning).""" + import warnings as _w + + payload = {**_MIN_EVENT_PAYLOAD, "futureSpondField": "preserved"} + e = Event.model_validate(payload) + # Iteration includes the extra + assert "futureSpondField" in e + assert "futureSpondField" in list(e) + with _w.catch_warnings(record=True) as caught: + _w.simplefilter("always") + value = e["futureSpondField"] + assert value == "preserved" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + class TestEventOOMethods: """ActiveRecord methods on Event.""" From 6cfdba0f9d9fbcda878a7b350e66c47ad84f9250 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 19:11:47 +0200 Subject: [PATCH 10/42] fix(oo-rewrite): address fourth-round Copilot review on #246 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six new findings, all actioned, plus proactive coverage. Member class cleanup (findings #1, #2): - Renamed `Member.fields` → `Member.custom_fields` with `alias="fields"`. The previous name collided with Pydantic's own `model_fields` vocabulary and risked confusion at call sites like `member.fields["height"]`. Live API serves it under the key `fields`; alias preserves the wire format. - Removed the redundant `model_config = ConfigDict(...)` redeclaration on `Member` — it duplicated `Person`'s config exactly and would have been a maintenance hazard if the two drifted out of sync. Pydantic v2 inherits `model_config` from the base. Event.update payload narrowed further (finding #3): - Added hidden, cancelled, match_event, behalf_of_uids to _EVENT_READ_ONLY_FIELDS, plus a doc comment grouping all excluded fields by reason (server-managed timestamps / derived flags / series wiring / nested sub-resources / not-in-pre-OO-template). The payload now matches the pre-OO template's writable set, so an `event.update(description="…")` no longer round-trips a stale local `cancelled=False` or `hidden=False` back over server state. Event.update cache refresh (finding #4): - After `Event.update()` returns the persisted Event, the client's `self.events` cache is updated in-place (index-based replacement preserving the list's identity for any caller holding a reference). Stops `spond.get_event(uid)` from returning the stale pre-update instance after an update. Defensive isinstance check on Person.profile (finding #5): - `_match_person` and `Spond.send_message`'s legacy path both used `person.profile is None or "id" not in person.profile`, which would raise `AttributeError` mid-scan if Spond ever returned a non-dict value for `profile`. Switched to `isinstance(person.profile, dict)` for parity with the rest of the lenient matcher. DESIGN-oo-rewrite.md / Comment status (finding #6): - Aligned the doc with the actual implementation: `Post.comments` is `list[dict[str, Any]]`, and a typed `Comment` class is explicitly deferred. Also reflected in the Files section. Proactive: docstring + test coverage: - Replaced `g["name"]` with `g.name` in the `Spond` class docstring's usage example — pre-OO dict-style would have emitted DeprecationWarning if a user copy-pasted it. - Added tests covering: extra="allow" on Profile and Group (not just Event); the new cache refresh after `Event.update()`; Member's custom_fields alias works via both Python name and API name. 50 tests pass; ruff clean; format clean. Live-tested: a real Member with `custom_fields` populated; Event.update payload now narrowed to 15 of 30 fields (matching the pre-OO template scope). --- DESIGN-oo-rewrite.md | 10 ++++--- spond/event.py | 38 +++++++++++++++++++++----- spond/person.py | 17 ++++++------ spond/spond.py | 10 ++++--- tests/test_spond.py | 63 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 23 deletions(-) diff --git a/DESIGN-oo-rewrite.md b/DESIGN-oo-rewrite.md index 913228c..bdbe02b 100644 --- a/DESIGN-oo-rewrite.md +++ b/DESIGN-oo-rewrite.md @@ -76,11 +76,13 @@ Profile(BaseModel + DictCompatMixin) └─ uid, first_name, last_name (passive) Post(BaseModel + DictCompatMixin) - ├─ uid, title, body, timestamp, comments: list[Comment] + ├─ uid, title, body, timestamp, comments: list[dict] └─ (no methods yet; add_comment(...) deferred until we verify the Spond API supports it) -Comment (sub-object of Post) - └─ uid, text, timestamp, author +Comment — deferred to a follow-up + └─ Modelling Post comments as a typed `Comment` class is on the roadmap + but not in this PR; `Post.comments` currently exposes them as raw + dicts (`list[dict[str, Any]]`). Transaction(BaseModel + DictCompatMixin) └─ uid, paid_at, payment_name, paid_by_name (passive, Spond Club only) @@ -180,7 +182,7 @@ All four are answerable mid-impl with live API probing using credentials at `/ho - `spond/subgroup.py` — `Subgroup` - `spond/role.py` — `Role` - `spond/profile.py` — `Profile` -- `spond/post.py` — `Post`, `Comment` +- `spond/post.py` — `Post` (Comment deferred — see Type Inventory above) **Changed:** - `spond/spond.py` — `get_*` methods return typed objects; legacy write methods get deprecation wrappers diff --git a/spond/event.py b/spond/event.py index b8de6db..16e6685 100644 --- a/spond/event.py +++ b/spond/event.py @@ -66,24 +66,35 @@ class Responses(DictCompatModel): """UIDs of members whose response needs confirmation.""" -# Python field names that `Event.update()` strips from the POST payload — -# they're either server-managed (creator/timestamps), derived (`expired`), -# or have their own dedicated endpoint (`responses` goes through -# `change_response`). Sending these back in an update body risks Spond -# treating stale local state as authoritative. +# Python field names that `Event.update()` strips from the POST payload. +# The set mirrors the pre-OO `_EVENT_TEMPLATE`'s implicit policy: only +# the curated set of writable fields the template included goes back on +# update. Anything else is read-only, server-managed, derived, or has its +# own dedicated endpoint — sending those back risks Spond treating stale +# local state as authoritative (the most concerning case being +# `responses`, which would clobber concurrent attendance changes). _EVENT_READ_ONLY_FIELDS = frozenset( { + # Server-managed identifiers / timestamps "creator_uid", "created_time", "updated", + # Derived / boolean state flags "expired", "registered", + "hidden", + "cancelled", + "match_event", "modified_from_series", + # Series wiring (set when the event is part of a recurrence) "series_uid", "series_ordinal", + # Nested objects with their own update paths "responses", "recipients", "comments", + # Behalf-of list: not in the pre-OO writable template + "behalf_of_uids", } ) @@ -264,9 +275,22 @@ async def update( # construction below would crash with ValidationError. Fall back to # a fresh GET in that case so callers always get a coherent Event. try: - return Event.from_api(new_data, self._client) + new_event = Event.from_api(new_data, self._client) except ValidationError: - return await self._client.get_event(self.uid) + new_event = await self._client.get_event(self.uid) + + # Keep the client's events cache coherent — replace the matching + # entry in-place so subsequent `spond.get_event(uid)` calls don't + # serve the stale pre-update instance. Index-based replacement + # preserves the cache's list identity (callers holding a + # reference to `spond.events` keep their list). + if self._client.events is not None: + for i, cached in enumerate(self._client.events): + if cached.uid == self.uid: + self._client.events[i] = new_event + break + + return new_event async def change_response( self, diff --git a/spond/person.py b/spond/person.py index 26fb8f6..366511b 100644 --- a/spond/person.py +++ b/spond/person.py @@ -92,13 +92,9 @@ class Member(Person): Carries Member-specific fields (email, date of birth, roles, guardians, subgroup memberships) on top of the shared `Person` base. - """ - model_config = ConfigDict( - populate_by_name=True, - extra="allow", - arbitrary_types_allowed=True, - ) + `model_config` is inherited from `Person` — no redeclaration needed. + """ email: str | None = None """Email address. May be absent on minor members.""" @@ -128,8 +124,11 @@ class Member(Person): """Whether this member personally responds to events (False for child members whose guardians respond on their behalf).""" - fields: dict[str, Any] = Field(default_factory=dict) - """Custom fields defined on the group. Unmodelled for now.""" + custom_fields: dict[str, Any] = Field(default_factory=dict, alias="fields") + """Per-member custom fields the group admin has defined (e.g. shirt + size, dietary requirements). Maps the API's `fields` key, but exposed + here as `custom_fields` to avoid confusion with Pydantic's + `model_fields` metadata vocabulary.""" async def send_message(self, text: str, group_uid: str) -> dict[str, Any]: """Send a chat message directly to this member. @@ -169,7 +168,7 @@ async def _send_message_to_person( if client._auth is None: await client._login_chat() - if person.profile is None or "id" not in person.profile: + if not isinstance(person.profile, dict) or "id" not in person.profile: raise ValueError( f"{type(person).__name__} {person.uid} has no profile id; " f"Spond cannot route a message without one." diff --git a/spond/spond.py b/spond/spond.py index 00e9c3b..e79746b 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -59,7 +59,7 @@ 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"]) + print(g.name) await s.clientsession.close() asyncio.run(main()) @@ -246,7 +246,11 @@ def _match_person(person: Person, match_str: str) -> bool: return True if person.full_name == match_str: return True - if person.profile is not None and person.profile.get("id") == match_str: + # `person.profile` is typed `dict | None`, but `extra="allow"` and + # API drift mean we shouldn't trust the shape at runtime — guard + # against Spond ever returning a non-dict (string id, null wrapper) + # rather than raising AttributeError mid-scan. + if isinstance(person.profile, dict) and person.profile.get("id") == match_str: return True # Members have email; Guardians don't. Guard against the # `None == None` trap — if the member has no email on record and @@ -438,7 +442,7 @@ async def send_message( ) user_obj = await self.get_person(user) - if user_obj.profile is None or "id" not in user_obj.profile: + if not isinstance(user_obj.profile, dict) or "id" not in user_obj.profile: raise ValueError( f"Person {user_obj.uid} has no profile id; Spond cannot route " f"a message without one." diff --git a/tests/test_spond.py b/tests/test_spond.py index 4d0eacd..bfbe261 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -683,6 +683,22 @@ def test_extra_allow_preserves_unmodelled_fields(self) -> None: assert value == "preserved" assert any(issubclass(w.category, DeprecationWarning) for w in caught) + def test_extra_allow_on_profile_and_group(self) -> None: + """`extra="allow"` must work uniformly across top-level types, not + just Event — this is the forward-compat invariant for the whole + public surface.""" + from spond.profile import Profile + + p = Profile.model_validate( + {"id": "P1", "firstName": "A", "lastName": "B", "newSpondField": 42} + ) + assert "newSpondField" in p + assert p.newSpondField == 42 # native attribute access via Pydantic + + g = Group.model_validate({"id": "G1", "name": "GG", "newGroupAttr": ["x"]}) + assert "newGroupAttr" in g + assert g.newGroupAttr == ["x"] + class TestEventOOMethods: """ActiveRecord methods on Event.""" @@ -727,6 +743,32 @@ async def test_event_update_accepts_positional_dict( posted = mock_post.call_args[1]["json"] assert posted["selfish"] == "any" + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_refreshes_client_cache( + self, mock_post, mock_token + ) -> None: + """After `event.update()`, the client's events cache must hold the + new instance — not the stale pre-update one — so subsequent + `spond.get_event(uid)` calls return current state.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + original = Event.from_api(_MIN_EVENT_PAYLOAD, s) + s.events = [original] + + api_response = {**_MIN_EVENT_PAYLOAD, "heading": "Refreshed"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + result = await original.update(heading="Refreshed") + # Cache must now point at the new instance (in-place replacement + # preserves the list identity for callers holding `s.events`). + assert s.events is not None + assert s.events[0] is result + assert s.events[0].heading == "Refreshed" + assert s.events[0] is not original + @pytest.mark.asyncio @patch("aiohttp.ClientSession.put") async def test_event_change_response_accepts(self, mock_put, mock_token) -> None: @@ -838,3 +880,24 @@ def test_find_member_requires_exactly_one_criterion(self) -> None: group.find_member() with pytest.raises(ValueError, match="exactly one"): group.find_member(uid="X", email="a@b.invalid") + + def test_member_custom_fields_alias_works_via_either_name(self) -> None: + """`Member.custom_fields` aliases the API's `"fields"` key — both + forms must populate the attribute identically.""" + from spond.person import Member + + # API-style (via alias): + m1 = Member.model_validate( + {"id": "M1", "firstName": "A", "lastName": "B", "fields": {"height": "175"}} + ) + # Python-style (via name): + m2 = Member.model_validate( + { + "id": "M2", + "firstName": "C", + "lastName": "D", + "custom_fields": {"height": "180"}, + } + ) + assert m1.custom_fields == {"height": "175"} + assert m2.custom_fields == {"height": "180"} From b9ea50eae80b5fa4a30063389657cf017a7c0e56 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 19:23:21 +0200 Subject: [PATCH 11/42] fix(oo-rewrite): address fifth-round Copilot review on #246 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five new findings actioned (one verified-as-incorrect, replied to): Stale-cache fallback in Event.update (finding #1): - The ValidationError fallback `await self._client.get_event(self.uid)` routes through `_get_entity`, which scans `self._client.events` first. At that point the cache still held the stale `self` (the cache replacement loop hadn't run yet), so the "fallback" returned the exact same stale event whose POST response had just failed to parse — a no-op. Reordered: invalidate `self._client.events = None` BEFORE the fallback fetch so it actually re-fetches from the API. The subsequent cache-replacement loop then writes the fresh event back (or no-ops harmlessly if `events` was None). Cosmetic Pydantic (finding #2): - `Field(default_factory=lambda: Responses())` → `default_factory=Responses`. Same default, less indirection. Test portability (finding #3): - The `extra="allow"` test asserted `p.newSpondField == 42` via native attribute access. Pydantic v2 supports this for `extra="allow"` but via `BaseModel.__getattr__` falling through to `__pydantic_extra__`, and behaviour varies subtly across minor versions especially for field names colliding with model_config-derived names. Switched the test to `p.__pydantic_extra__["..."]` for version-independence. Resource cleanup on all chat HTTP (finding #5): - `Spond._login_chat`, `Spond._continue_chat`, `Spond.send_message` (user/group_uid path), and `_send_message_to_person` in person.py all did `r = await session.post(...)` instead of `async with session.post(...) as r:`. The non-context-manager pattern doesn't release the response object back to the connection pool immediately and was inconsistent with every other HTTP call in the package. Wrapped all four. Updated the `test_send_message__continues _chat_when_chat_id_given` mock to use the standard `__aenter__` pattern that the rest of the test suite already uses. Live-tested the chat path (get_messages) after the rewrite — no resource warnings, response shape unchanged. Finding #4 (Member.email == email matching) — verified the existing guard in `Group.find_member` requires `email is not None` before the comparison, so the documented case is already safe. No action. Finding #6 (Spond.update_event wrapper field set) — Copilot's specific examples (`auto_reminder_type`, `participants_hidden`, `auto_accept`, `description`, `comments_disabled`) were all in the pre-OO `_EVENT_TEMPLATE`. Verified against the original `_event_template.py`. No action — will note in reply. Proactive sweep also caught: - No other `r = await session.post()` patterns elsewhere in the codebase. - No other `default_factory=lambda` patterns. - No other reliance on attribute-access for `__pydantic_extra__` keys. 50 tests pass; ruff clean; format clean. --- spond/event.py | 10 ++++++++-- spond/person.py | 6 +++--- spond/spond.py | 18 ++++++++++++------ tests/test_spond.py | 15 +++++++++------ 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/spond/event.py b/spond/event.py index 16e6685..bba46aa 100644 --- a/spond/event.py +++ b/spond/event.py @@ -141,7 +141,7 @@ class Event(DictCompatModel): """Spond's event-kind string. Common values are listed on `EventType`, but unknown values pass through unchanged so the SDK doesn't crash if Spond adds new variants.""" - responses: Responses = Field(default_factory=lambda: Responses()) + responses: Responses = Field(default_factory=Responses) # Owner / creator metadata creator_uid: str | None = Field(default=None, alias="creatorId") @@ -273,10 +273,16 @@ async def update( # Spond usually returns the updated event on POST, but if the # response is partial (status-only, an error wrapper, etc.) the # construction below would crash with ValidationError. Fall back to - # a fresh GET in that case so callers always get a coherent Event. + # a fresh fetch in that case. **Order matters**: invalidate the + # cache entry first so the fallback `get_events()` actually + # re-fetches from the API rather than returning the stale `self` + # that's still in the cache. try: new_event = Event.from_api(new_data, self._client) except ValidationError: + # Drop the whole events cache so `get_event()` re-fetches via + # `get_events()` instead of resolving from the stale cache. + self._client.events = None new_event = await self._client.get_event(self.uid) # Keep the client's events cache coherent — replace the matching diff --git a/spond/person.py b/spond/person.py index 366511b..8d0ea50 100644 --- a/spond/person.py +++ b/spond/person.py @@ -181,7 +181,7 @@ async def _send_message_to_person( "groupId": group_uid, } url = f"{client._chat_url}/messages" - r = await client.clientsession.post( + async with client.clientsession.post( url, json=payload, headers={"auth": client._auth} - ) - return await r.json() + ) as r: + return await r.json() diff --git a/spond/spond.py b/spond/spond.py index e79746b..09989b5 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -108,8 +108,10 @@ async def _login_chat(self) -> None: client. """ api_chat_url = f"{self.api_url}chat" - r = await self.clientsession.post(api_chat_url, headers=self.auth_headers) - result = await r.json() + async with self.clientsession.post( + api_chat_url, headers=self.auth_headers + ) as r: + result = await r.json() self._chat_url = result["url"] self._auth = result["auth"] @@ -377,8 +379,10 @@ async def _continue_chat(self, chat_id: str, text: str) -> JSONDict: await self._login_chat() url = f"{self._chat_url}/messages" data = {"chatId": chat_id, "text": text, "type": "TEXT"} - r = await self.clientsession.post(url, json=data, headers={"auth": self._auth}) - return await r.json() + async with self.clientsession.post( + url, json=data, headers={"auth": self._auth} + ) as r: + return await r.json() @_SpondBase.require_authentication async def send_message( @@ -455,8 +459,10 @@ async def send_message( "recipient": user_uid, "groupId": group_uid, } - r = await self.clientsession.post(url, json=data, headers={"auth": self._auth}) - return await r.json() + async with self.clientsession.post( + url, json=data, headers={"auth": self._auth} + ) as r: + return await r.json() @_SpondBase.require_authentication async def get_events( diff --git a/tests/test_spond.py b/tests/test_spond.py index bfbe261..863a840 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -277,25 +277,28 @@ class TestSendMessage: """Tests for `Spond.send_message()` — covers the fixes in #238.""" @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post", new_callable=AsyncMock) + @patch("aiohttp.ClientSession.post") async def test_send_message__continues_chat_when_chat_id_given( self, mock_post, mock_token ) -> None: """With `chat_id`, the call should route through `_continue_chat()` - and properly await it (regression: the await was missing).""" + and properly await it (regression: the await was missing). + `_continue_chat` now uses `async with`, so the post mock follows + the standard context-manager pattern used elsewhere in this file. + """ s = Spond(MOCK_USERNAME, MOCK_PASSWORD) s.token = mock_token s._auth = "MOCK_CHAT_AUTH" s._chat_url = "https://chat.example.invalid" api_response = {"ok": True, "messageId": "MID1"} - # _continue_chat does `r = await session.post(...)` (no `async with`), - # so the post mock must be AsyncMock and r.json must be AsyncMock too. - mock_post.return_value.json = AsyncMock(return_value=api_response) + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) result = await s.send_message(text="hello", chat_id="CHAT1") - assert result == api_response # was a coroutine before the fix + assert result == api_response mock_post.assert_called_once() _, kwargs = mock_post.call_args assert kwargs["json"] == {"chatId": "CHAT1", "text": "hello", "type": "TEXT"} From 0bb524571886c582e970ac0ba17e2a6b07c9f2d8 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 19:32:23 +0200 Subject: [PATCH 12/42] fix(oo-rewrite): address sixth-round Copilot review on #246 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five actionable findings, one verified-as-non-issue: Transaction parity with the rest of the OO surface (finding #1): - Transaction was the only DictCompatModel still declared `extra="ignore"` after the prior bulk-switch to `extra="allow"`. The docstring claimed unmodelled fields were accessible via `transaction["someField"]` — that was wrong under `extra="ignore"` (Pydantic discards). Switched to `extra="allow"` for parity; the docstring claim is now accurate. Also defaulted paid_at / payment_name / paid_by_name so a single malformed transaction doesn't crash the pagination over the whole club's history. API-drift resilience for required scalar fields (findings #2, #3): - Same regression class addressed for Event in earlier rounds: required `str` / `datetime` fields without defaults crash the entire `get_*()` call if Spond ever drops one. Extended the sentinel-default pattern to: - Person.first_name / last_name (Member + Guardian inherit) — "" - Profile.first_name / last_name — "" - Group.name — "" - Subgroup.name — "" - Role.name — "" - Post.timestamp — None - Transaction.paid_at / payment_name / paid_by_name (above) Only the strictly-load-bearing `uid` stays required across all typed models. Event.update doesn't send `null` for unset optionals (finding #4): - model_dump now uses `exclude_none=True` in addition to the read-only field exclusion. Spond could interpret `"description": null` as "clear this field" rather than "leave unchanged"; the pre-OO _EVENT_TEMPLATE-merge approach never sent None for fields the existing event didn't have. New regression test `test_event_update_excludes_none_fields` locks this in. Person.profile vs Profile asymmetry doc (finding #5): - `get_person()` returns `Person` whose `.profile` is a sparse raw dict, NOT a typed `Profile` (which only `get_profile()` returns). Added an explicit "Note:" paragraph to the `get_person` docstring explaining the difference. Also tightened the field docstring on `Person.profile` to explain *why* it's not a typed Profile (the two shapes diverge in Spond's API). LenientDate "duplication" (finding #6) — verified non-issue: - The `LenientDate` annotated type is defined once in `_compat.py` and imported by both `Profile` and `Member`. What looks like duplication is just two consumers of the same import. No action. Proactive: the same "required field without default" pattern was checked across every typed model and relaxed where it occurred. New test `test_models_survive_missing_optional_fields` covers Member/Post/Profile construction from minimal payloads. 52 tests pass; ruff clean; format clean. Live-tested: real API responses parse cleanly (8 groups, events, posts with populated timestamps). --- spond/club.py | 21 ++++++++++++--------- spond/event.py | 16 ++++++++++++---- spond/group.py | 2 +- spond/person.py | 15 ++++++++++----- spond/post.py | 2 +- spond/profile.py | 6 ++++-- spond/role.py | 2 +- spond/spond.py | 6 ++++++ spond/subgroup.py | 2 +- tests/test_spond.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 92 insertions(+), 24 deletions(-) diff --git a/spond/club.py b/spond/club.py index f1aa084..c9402ea 100644 --- a/spond/club.py +++ b/spond/club.py @@ -21,19 +21,22 @@ class Transaction(DictCompatModel): """A Spond Club payment/transaction record. Returned as elements of `SpondClub.get_transactions()`. Conservatively - modelled around the four fields the Spond Club UI guarantees on every - transaction: `id`, `paidAt`, `paymentName`, `paidByName`. Everything else - Spond emits passes through `extra="ignore"` and is accessible via the - dict-compat fallback (`transaction["someField"]`) until those fields are - explicitly modelled. + modelled around the four fields the Spond Club UI typically shows for + every transaction: `id`, `paidAt`, `paymentName`, `paidByName`. All + other fields Spond emits are preserved via `extra="allow"` and remain + accessible both as attributes (`transaction.someExtra`) and through the + dict-compat shim (`transaction["someExtra"]`) until they're explicitly + modelled. Only `uid` is strictly required so an API drift that drops + one of the other fields doesn't crash the entire `get_transactions()` + call. """ - model_config = ConfigDict(populate_by_name=True, extra="ignore") + model_config = ConfigDict(populate_by_name=True, extra="allow") uid: str = Field(alias="id") - paid_at: datetime = Field(alias="paidAt") - payment_name: str = Field(alias="paymentName") - paid_by_name: str = Field(alias="paidByName") + paid_at: datetime | None = Field(default=None, alias="paidAt") + payment_name: str = Field(default="", alias="paymentName") + paid_by_name: str = Field(default="", alias="paidByName") def __str__(self) -> str: return ( diff --git a/spond/event.py b/spond/event.py index bba46aa..ea2bff8 100644 --- a/spond/event.py +++ b/spond/event.py @@ -256,11 +256,19 @@ async def update( field_info = self.__class__.model_fields[py_name] api_updates[field_info.alias or py_name] = value - # Dump the current state, then strip read-only fields (creator, - # timestamps, server-managed flags, responses) so the update body - # only carries fields the API actually accepts on POST. + # Dump the current state, then strip: + # * read-only fields (creator, timestamps, server-managed flags, + # `responses`) — sending these back risks Spond treating stale + # local state as authoritative. + # * `None`-valued declared fields — Spond may interpret an + # explicit JSON `null` as "clear this field" rather than + # "leave unchanged", so we only send fields that have a real + # value or have been explicitly provided by the caller below. payload = self.model_dump( - by_alias=True, mode="json", exclude=_EVENT_READ_ONLY_FIELDS + by_alias=True, + mode="json", + exclude=_EVENT_READ_ONLY_FIELDS, + exclude_none=True, ) payload.update(api_updates) diff --git a/spond/group.py b/spond/group.py index 768a118..bda8e8d 100644 --- a/spond/group.py +++ b/spond/group.py @@ -52,7 +52,7 @@ class Group(DictCompatModel): ) uid: str = Field(alias="id") - name: str + name: str = "" activity: str | None = None """The group's sport/activity tag, e.g. `"football"`.""" diff --git a/spond/person.py b/spond/person.py index 8d0ea50..800127b 100644 --- a/spond/person.py +++ b/spond/person.py @@ -42,12 +42,17 @@ class Person(DictCompatModel): ) uid: str = Field(alias="id") - first_name: str = Field(alias="firstName") - last_name: str = Field(alias="lastName") + # `first_name` and `last_name` are present on every member/guardian + # we've seen in the wild, but defaulted to "" so a missing field in + # one record can't crash the entire `get_groups()` payload. + first_name: str = Field(default="", alias="firstName") + last_name: str = Field(default="", alias="lastName") profile: dict[str, Any] | None = None - """Profile reference dict — `{id, contactMethod, ...}`. Unmodelled for now; - use `Spond.get_profile()` to fetch the full profile of the authenticated - user.""" + """Profile reference dict — `{id, contactMethod, ...}`. Kept as a raw + dict (not a typed `Profile` model) because Spond's `members[].profile` + payload is a sparse reference, not the rich account record that + `Spond.get_profile()` returns. The two have different shapes; modelling + them as one type would create false equivalence.""" phone_number: str | None = Field(default=None, alias="phoneNumber") # Non-serialised reference back to the Spond client for HTTP calls. diff --git a/spond/post.py b/spond/post.py index 430c164..339e6b5 100644 --- a/spond/post.py +++ b/spond/post.py @@ -27,7 +27,7 @@ class Post(DictCompatModel): type: str = "PLAIN" title: str | None = None body: str | None = None - timestamp: datetime + timestamp: datetime | None = None group_uid: str | None = Field(default=None, alias="groupId") subgroup_uids: list[str] = Field(default_factory=list, alias="subGroupIds") owner_uid: str | None = Field(default=None, alias="ownerId") diff --git a/spond/profile.py b/spond/profile.py index da6f7ef..b562cdb 100644 --- a/spond/profile.py +++ b/spond/profile.py @@ -25,8 +25,10 @@ class Profile(DictCompatModel): model_config = ConfigDict(populate_by_name=True, extra="allow") uid: str = Field(alias="id") - first_name: str = Field(alias="firstName") - last_name: str = Field(alias="lastName") + # Defaulted to "" so a missing field on the authenticated user's + # profile response can't crash `get_profile()` outright. + first_name: str = Field(default="", alias="firstName") + last_name: str = Field(default="", alias="lastName") primary_email: str | None = Field(default=None, alias="primaryEmail") phone_number: str | None = Field(default=None, alias="phoneNumber") formatted_phone_number: str | None = Field( diff --git a/spond/role.py b/spond/role.py index d28bc4b..614d236 100644 --- a/spond/role.py +++ b/spond/role.py @@ -18,7 +18,7 @@ class Role(DictCompatModel): model_config = ConfigDict(populate_by_name=True, extra="allow") uid: str = Field(alias="id") - name: str + name: str = "" permissions: list[str] = Field(default_factory=list) """API permission strings, e.g. `["members", "events", "posts"]`.""" diff --git a/spond/spond.py b/spond/spond.py index 09989b5..5791bfa 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -207,6 +207,12 @@ async def get_person(self, user: str) -> Person: The first matching `Member` or `Guardian`. Use `isinstance` to distinguish if needed. + Note: the returned object's `.profile` attribute is a raw + `dict[str, Any] | None` (a sparse reference shape Spond serves + inside `members[]`), NOT a typed `Profile` model. That latter + shape is only what `Spond.get_profile()` returns for the + authenticated user — different endpoint, richer response. + Raises ------ KeyError diff --git a/spond/subgroup.py b/spond/subgroup.py index aeb1299..d1d68fe 100644 --- a/spond/subgroup.py +++ b/spond/subgroup.py @@ -20,7 +20,7 @@ class Subgroup(DictCompatModel): model_config = ConfigDict(populate_by_name=True, extra="allow") uid: str = Field(alias="id") - name: str + name: str = "" color: str | None = None image_url: str | None = Field(default=None, alias="imageUrl") diff --git a/tests/test_spond.py b/tests/test_spond.py index 863a840..5f50f85 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -686,6 +686,50 @@ def test_extra_allow_preserves_unmodelled_fields(self) -> None: assert value == "preserved" assert any(issubclass(w.category, DeprecationWarning) for w in caught) + def test_models_survive_missing_optional_fields(self) -> None: + """A previously-required field dropping to a default must not crash + the typed model. Locks in the resilience relaxation done after + rounds of review feedback.""" + from spond.person import Member + from spond.post import Post + from spond.profile import Profile + + # Member with no name fields — used to crash, now defaults to "" + m = Member.model_validate({"id": "M1"}) + assert m.first_name == "" + assert m.last_name == "" + # Post without timestamp — used to crash, now None + p = Post.model_validate({"id": "P1"}) + assert p.timestamp is None + # Profile with no name fields — same relaxation + pr = Profile.model_validate({"id": "PR1"}) + assert pr.first_name == "" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_excludes_none_fields( + self, mock_post, mock_token + ) -> None: + """`Event.update()` must NOT send `null` for optional fields that + Spond didn't populate — Spond could interpret `null` as 'clear this + field' rather than 'leave unchanged'.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + # _MIN_EVENT_PAYLOAD has no `description`, so Event.description=None. + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + assert event.description is None + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_MIN_EVENT_PAYLOAD + ) + await event.update(heading="Renamed") + + # The POST payload must contain `heading` (caller updated it) but + # NOT `description` (it was None and shouldn't be cleared). + posted = mock_post.call_args[1]["json"] + assert posted["heading"] == "Renamed" + assert "description" not in posted + def test_extra_allow_on_profile_and_group(self) -> None: """`extra="allow"` must work uniformly across top-level types, not just Event — this is the forward-compat invariant for the whole From f69b4c7b3418a615f1d812a1bda7de3c305dc7d2 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 19:40:36 +0200 Subject: [PATCH 13/42] fix(oo-rewrite): guard Post.__str__ against None timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-7 review caught a self-inflicted regression from making `Post.timestamp` optional in the previous round: `Post.__str__` still called `self.timestamp.isoformat()` unconditionally, so `str(post)` would raise `AttributeError: 'NoneType' object has no attribute 'isoformat'` on any Post where Spond omitted the field. Applied the same defensive guard already in `Event.__str__`: `ts = self.timestamp.isoformat() if self.timestamp else \"?\"`. Proactive sweep across every other `__str__` confirmed Post was the only one with this risk — all the other relaxed scalar fields are strings that work fine when defaulted to \"\" (no method calls on the defaulted value). Test `test_models_survive_missing_optional_fields` now also asserts `str(post)` returns a string when timestamp is None. --- spond/post.py | 9 +++++---- tests/test_spond.py | 3 +++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/spond/post.py b/spond/post.py index 339e6b5..3783ccf 100644 --- a/spond/post.py +++ b/spond/post.py @@ -45,7 +45,8 @@ class Post(DictCompatModel): typed as raw `dict` — a `Comment` class is a possible future refinement.""" def __str__(self) -> str: - return ( - f"Post(uid={self.uid!r}, title={self.title!r}, " - f"timestamp={self.timestamp.isoformat()})" - ) + # `timestamp` is optional after the resilience relaxation, so guard + # the .isoformat() call the same way `Event.__str__` guards + # `start_time`. Avoids `AttributeError` when Spond omits the field. + ts = self.timestamp.isoformat() if self.timestamp else "?" + return f"Post(uid={self.uid!r}, title={self.title!r}, timestamp={ts})" diff --git a/tests/test_spond.py b/tests/test_spond.py index 5f50f85..9f42909 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -701,6 +701,9 @@ def test_models_survive_missing_optional_fields(self) -> None: # Post without timestamp — used to crash, now None p = Post.model_validate({"id": "P1"}) assert p.timestamp is None + # __str__ must not raise even though timestamp is None — guards the + # AttributeError that resilience relaxation otherwise re-introduced. + assert "?" in str(p) # Profile with no name fields — same relaxation pr = Profile.model_validate({"id": "PR1"}) assert pr.first_name == "" From 477a97ed81a635a772f31ffdccae58023ced2ca5 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 20:12:51 +0200 Subject: [PATCH 14/42] feat(oo-rewrite): add Match as an Event subclass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "match" in Spond is an Event with `matchEvent=True` plus a `matchInfo` sub-object carrying team/opponent names, scores, and HOME/AWAY status. The long-standing request to model these has waited on the OO rewrite landing the Event class first (per the design notes in the existing tracking issue and the unmerged community PR that proposed the matchInfo template fields). New typed surface (spond/match.py): - `MatchInfo` — Pydantic model for the score/opponent payload. Fields: type (HOME/AWAY), team_name/team_score, opponent_name/ opponent_score, scores_set/scores_set_ever/scores_final/scores_public. All optional with sensible defaults so a fixture without scores (typical pre-match state) doesn't crash construction. The scores_public field was observed in live data but missing from the earlier community PR's template draft. - `Match(Event)` — adds `match_info: MatchInfo | None` to Event's field set, otherwise inherits everything (uid, heading, timings, responses, update/change_response/attendance_xlsx, …). Dispatch: - New module-level helper `_typed_event(data, client)` in spond.py picks `Match` if `data["matchEvent"]` is True, else `Event`. `Spond.get_events()` routes through this so the events cache contains the right concrete type per record. Callers discriminate via `isinstance(event, Match)`. Update path: - `match_info` is intentionally NOT in `_EVENT_READ_ONLY_FIELDS`, so `Match.update(matchInfo={"teamScore": 3, "scoresFinal": True})` works through the existing Event.update machinery. The deprecated `Spond.update_event()` wrapper also routes match updates correctly because `Match` inherits the method. Live-tested against the user's 26 real matches in the test account: all parsed correctly with team/opponent names, scores (including None for un-played fixtures), HOME/AWAY type, and the various score-state flags. Regular non-match events still return plain `Event` instances. Tests (new TestMatch class, 3 tests): - dispatch returns Match when matchEvent True, Event otherwise - match_info is in Match.model_fields and excluded from _EVENT_READ_ONLY_FIELDS (so updates carry it) - a half-populated match record (matchEvent=True without matchInfo) parses cleanly with match_info=None DESIGN-oo-rewrite.md updated to show Match as an Event subclass. 55 tests pass; ruff clean; format clean. --- DESIGN-oo-rewrite.md | 6 ++++ spond/match.py | 84 ++++++++++++++++++++++++++++++++++++++++++++ spond/spond.py | 13 ++++++- tests/test_spond.py | 65 ++++++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 spond/match.py diff --git a/DESIGN-oo-rewrite.md b/DESIGN-oo-rewrite.md index bdbe02b..5338f51 100644 --- a/DESIGN-oo-rewrite.md +++ b/DESIGN-oo-rewrite.md @@ -56,6 +56,11 @@ Event(BaseModel + DictCompatMixin) ├─ responses: Responses ├─ methods: update(**fields), change_response(member_uid, *, accepted, decline_message=None), │ attendance_xlsx() -> bytes + │ + └─ Match(Event) — sports fixtures + ├─ match_info: MatchInfo | None — team/opponent names, scores, HOME/AWAY + └─ Spond.get_events() / get_event() return Match (not plain Event) + when the API record has matchEvent=True. Responses (sub-object of Event) ├─ accepted_uids, declined_uids, unanswered_uids, waiting_list_uids, unconfirmed_uids @@ -177,6 +182,7 @@ All four are answerable mid-impl with live API probing using credentials at `/ho **New:** - `spond/_compat.py` — `DictCompatMixin` - `spond/event.py` — `Event`, `Responses`, `EventType` +- `spond/match.py` — `Match` (Event subclass), `MatchInfo` - `spond/person.py` — `Person`, `Member`, `Guardian` - `spond/group.py` — `Group` - `spond/subgroup.py` — `Subgroup` diff --git a/spond/match.py b/spond/match.py new file mode 100644 index 0000000..0f6e158 --- /dev/null +++ b/spond/match.py @@ -0,0 +1,84 @@ +"""Typed `Match` model — a sports-fixture `Event` subclass. + +A "match" in Spond is an Event with `matchEvent=True` plus a `matchInfo` +sub-object carrying opponent name, team name, scores, and home/away status. +`Spond.get_events()` and `Spond.get_event()` automatically return `Match` +instances (instead of plain `Event`) when the underlying API record has +`matchEvent=True`, so callers can `isinstance(event, Match)` to discriminate. + +The `matchInfo` shape was verified against real Spond fixtures during +implementation. +""" + +from __future__ import annotations + +from pydantic import ConfigDict, Field + +from ._compat import DictCompatModel +from .event import Event + + +class MatchInfo(DictCompatModel): + """Score and opponent metadata attached to a `Match`. + + All fields are optional with sensible defaults so a fixture without + scores yet (the typical pre-match state) doesn't crash construction. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + type: str | None = None + """`"HOME"` or `"AWAY"` — whether the team plays on its own ground.""" + + team_name: str | None = Field(default=None, alias="teamName") + """Name of the authenticated user's team for this fixture.""" + team_score: int | None = Field(default=None, alias="teamScore") + """Goals/points scored by the user's team. `None` until scores entered.""" + + opponent_name: str | None = Field(default=None, alias="opponentName") + """Name of the opposing team.""" + opponent_score: int | None = Field(default=None, alias="opponentScore") + """Goals/points scored by the opposing team. `None` until scores entered.""" + + scores_set: bool = Field(default=False, alias="scoresSet") + """True once any score is recorded (either team's) for this fixture.""" + scores_set_ever: bool = Field(default=False, alias="scoresSetEver") + """Server-tracked: True if scores have ever been set, even if later cleared.""" + scores_final: bool = Field(default=False, alias="scoresFinal") + """True once the result has been marked final (no further edits expected).""" + scores_public: bool = Field(default=False, alias="scoresPublic") + """True if the scores are visible to non-admin members of the group.""" + + +class Match(Event): + """A Spond Event that represents a sports fixture. + + Carries everything an `Event` does, plus `match_info` with the opponent + and score metadata. Construct via `Spond.get_events()` / + `Spond.get_event()` — they pick `Match` or `Event` automatically based + on the underlying `matchEvent` flag. + + ```python + events = await spond.get_events() + for e in events: + if isinstance(e, Match) and e.match_info: + print(f"{e.match_info.team_name} {e.match_info.team_score} - " + f"{e.match_info.opponent_score} {e.match_info.opponent_name}") + ``` + + Updates to score fields go through the same `Event.update()` machinery: + + ```python + match = await spond.get_event(uid) # returns Match when matchEvent is true + await match.update(matchInfo={"teamScore": 3, "opponentScore": 1, + "scoresFinal": True}) + ``` + + The `matchInfo` dict is forwarded to Spond verbatim; the score-related + booleans `scoresSetEver` etc. are server-tracked so callers shouldn't + set them directly. + """ + + match_info: MatchInfo | None = Field(default=None, alias="matchInfo") + """Per-fixture opponent/score metadata. Always present in the API for a + real match (`match_event=True`), but defaulted to `None` for resilience.""" diff --git a/spond/spond.py b/spond/spond.py index 5791bfa..e27ffbb 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -16,10 +16,21 @@ from .base import _SpondBase from .event import Event from .group import Group +from .match import Match from .person import Member, Person from .post import Post from .profile import Profile + +def _typed_event(data: JSONDict, client: Spond) -> Event: + """Construct a `Match` if `matchEvent` is True on the raw payload, else + a plain `Event`. Centralises the typed-vs-typed dispatch so both + `get_events()` and any future singular-fetch path use the same logic. + """ + cls = Match if data.get("matchEvent") else Event + return cls.from_api(data, client) + + if TYPE_CHECKING: from datetime import datetime @@ -577,7 +588,7 @@ async def get_events( if raw is None: self.events = None return None - self.events = [Event.from_api(e, self) for e in raw] + self.events = [_typed_event(e, self) for e in raw] return self.events async def get_event(self, uid: str) -> Event: diff --git a/tests/test_spond.py b/tests/test_spond.py index 9f42909..3dd3740 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -951,3 +951,68 @@ def test_member_custom_fields_alias_works_via_either_name(self) -> None: ) assert m1.custom_fields == {"height": "175"} assert m2.custom_fields == {"height": "180"} + + +class TestMatch: + """Match (Event subclass) dispatch and field parsing.""" + + _MATCH_PAYLOAD = { + **_MIN_EVENT_PAYLOAD, + "matchEvent": True, + "matchInfo": { + "teamName": "Home FC", + "opponentName": "Away FC", + "type": "HOME", + "teamScore": 2, + "opponentScore": 1, + "scoresSet": True, + "scoresFinal": True, + "scoresSetEver": True, + "scoresPublic": True, + }, + } + + def test_dispatch_returns_match_when_match_event_true(self) -> None: + """`Spond.get_events()` constructs `Match` (Event subclass) when the + raw payload has `matchEvent=True`, plain `Event` otherwise.""" + from spond.match import Match, MatchInfo + from spond.spond import _typed_event + + # _typed_event doesn't call methods on the client, so None is fine + # for the dispatch + parsing assertions below. + regular = _typed_event(_MIN_EVENT_PAYLOAD, None) + m = _typed_event(self._MATCH_PAYLOAD, None) + + assert type(regular) is Event + assert isinstance(m, Match) + assert isinstance(m, Event) # subclass relationship + assert isinstance(m.match_info, MatchInfo) + assert m.match_info.team_name == "Home FC" + assert m.match_info.opponent_name == "Away FC" + assert m.match_info.type == "HOME" + assert m.match_info.team_score == 2 + assert m.match_info.opponent_score == 1 + assert m.match_info.scores_final is True + assert m.match_info.scores_public is True + + def test_match_score_update_path_is_through_event_update(self) -> None: + """Match inherits Event.update; the `match_info` field must be + included in the POST payload (not in _EVENT_READ_ONLY_FIELDS), so + callers can edit scores via `match.update(matchInfo={...})`.""" + from spond.event import _EVENT_READ_ONLY_FIELDS + from spond.match import Match + + assert "match_info" not in _EVENT_READ_ONLY_FIELDS + # And it really is a declared field on Match (vs an unmodelled + # passthrough via extra="allow"): + assert "match_info" in Match.model_fields + + def test_match_info_optional_for_resilience(self) -> None: + """A future API variant emitting matchEvent=True without matchInfo + (or a half-populated match record) must not crash construction.""" + from spond.match import Match + from spond.spond import _typed_event + + bare = _typed_event({**_MIN_EVENT_PAYLOAD, "matchEvent": True}, None) + assert isinstance(bare, Match) + assert bare.match_info is None From ed93e2395f6e8576ea446d479b5613c98e8384ec Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 20:23:44 +0200 Subject: [PATCH 15/42] fix(oo-rewrite): round-9 review + live API field-drift audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four Copilot findings actioned plus a proactive endpoint-by-endpoint audit closing 22 field-coverage gaps the original reverse-engineered shape had missed. Copilot review (round 9): - `Event.update()` hardcoded `Event.from_api(...)` for the return instance, silently demoting `Match` instances to plain `Event` and losing `match_info`. Subsequent `spond.get_event(uid)` would then serve the demoted entry from cache. Fixed by using `type(self).from_api(...)` so subclass identity survives the round-trip. The same `type(self)` discipline applies in the ValidationError fallback's cache-invalidation branch — that path routes through `_typed_event` via `get_event`, which already picks the right subclass. Added `test_match_update_preserves_match_type` as the regression guard. - `_typed_event(client: Spond)` was being called with `None` from tests, violating the declared signature. Widened to `Spond | None` and documented the test-time accommodation. - `Event.change_response()` previously dropped `decline_message` silently when `accepted=True`, diverging from the pre-OO payload-passthrough behaviour. Now forwards `decline_message` unconditionally when explicitly provided — lets callers pass `decline_message=""` to clear a prior message when flipping to accepted=True. Docstring updated. - `Spond.get_person()` collapsed two distinct failure modes ("account has no groups at all" vs "scanned groups but no match") into the same opaque KeyError. Split into two distinct messages telling the caller what actually happened, both still KeyError so existing exception-handlers keep working. Proactive API field-drift audit (the bigger change): Wrote a one-shot probe that compared every modelled Pydantic shape against the live Spond API response keys. Found three models with unmodelled fields preserved by `extra="allow"` but invisible to attribute access and pdoc — fields Spond has added in the years since the original reverse-engineering: Profile: 4 fields (tos_version, contact, tracking_id, unsubscribe_code) Group: 17 fields (created_time, member_permissions, guardian_permissions, membership_requests, chat_age_limit, share_contact_info, contact_info_hidden, admins_can_add_members, address_format, allow_sms_nag, bonus_enabled, invited_to_app_time, field_defs, default_fields, payout_accounts, allow_private_payout_accounts, experiments) Responses: 1 field (decline_messages — the per-member decline reason map) All 22 added as proper Pydantic fields with sentinel defaults, aliases to match Spond's camelCase, and docstrings categorising each. Less user-facing internals (tracking_id, unsubscribe_code, experiments, default_fields, field_defs, payout_accounts) get an explicit "Internal" / "Unmodelled — opaque structure" note so callers know not to rely on their shape. Post-audit: all 10 modelled endpoints (Profile, Group, Member, Guardian, Subgroup, Role, Event, Responses, MatchInfo, Post) report zero missing fields against the live API. The dict-compat shim was already preserving these values via `extra="allow"`, but they're now discoverable in pdoc and type-checked. 56 tests pass; ruff clean; format clean. --- spond/event.py | 27 +++++++++++++++++++++++--- spond/group.py | 46 +++++++++++++++++++++++++++++++++++++++++++++ spond/profile.py | 15 +++++++++++++++ spond/spond.py | 25 ++++++++++++++++++++---- tests/test_spond.py | 29 ++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 7 deletions(-) diff --git a/spond/event.py b/spond/event.py index ea2bff8..ab2fdb2 100644 --- a/spond/event.py +++ b/spond/event.py @@ -64,6 +64,12 @@ class Responses(DictCompatModel): """UIDs of members on the waiting list (event is full).""" unconfirmed_uids: list[str] = Field(default_factory=list, alias="unconfirmedIds") """UIDs of members whose response needs confirmation.""" + decline_messages: dict[str, dict[str, Any]] = Field( + default_factory=dict, alias="declineMessages" + ) + """Per-member decline-reason map. Keys are member UIDs (subset of + `declined_uids`); each value is a `{profileId, message}` dict + describing who entered the message and what they said.""" # Python field names that `Event.update()` strips from the POST payload. @@ -285,11 +291,18 @@ async def update( # cache entry first so the fallback `get_events()` actually # re-fetches from the API rather than returning the stale `self` # that's still in the cache. + # + # `type(self)` (not literal `Event`) preserves subclass identity — + # a `Match` updated via Event.update stays a `Match` so subsequent + # `spond.get_event(uid)` doesn't silently demote to plain Event + # (which would lose `match_info`). try: - new_event = Event.from_api(new_data, self._client) + new_event = type(self).from_api(new_data, self._client) except ValidationError: # Drop the whole events cache so `get_event()` re-fetches via # `get_events()` instead of resolving from the stale cache. + # That re-fetch routes through `_typed_event` and picks the + # right subclass automatically. self._client.events = None new_event = await self._client.get_event(self.uid) @@ -324,7 +337,12 @@ async def change_response( accepted : bool True to accept, False to decline. decline_message : str, optional - Reason for declining. Ignored unless `accepted=False`. + Reason for declining. When `accepted=False`, the message is + forwarded to Spond if provided. When `accepted=True`, the + message is **not** auto-cleared — any prior decline message + stays on the response server-side unless you explicitly pass + `decline_message=""` to clear it, or follow up with a separate + edit through Spond's UI. Returns ------- @@ -333,7 +351,10 @@ async def change_response( updated id lists (`acceptedIds`, `declinedIds`, …). """ payload: dict[str, Any] = {"accepted": str(accepted).lower()} - if not accepted and decline_message is not None: + if decline_message is not None: + # Forward unconditionally if explicitly provided — lets callers + # pass `decline_message=""` to clear a prior message when + # flipping accepted=True. payload["declineMessage"] = decline_message url = f"{self._client.api_url}sponds/{self.uid}/responses/{member_uid}" diff --git a/spond/group.py b/spond/group.py index bda8e8d..a8e366f 100644 --- a/spond/group.py +++ b/spond/group.py @@ -8,6 +8,7 @@ from __future__ import annotations +from datetime import datetime from typing import TYPE_CHECKING, Any from pydantic import ConfigDict, Field, PrivateAttr @@ -71,6 +72,51 @@ class Group(DictCompatModel): country_code: str | None = Field(default=None, alias="countryCode") type: int | None = None + # Fields observed in the live API but absent from the original Spond + # SDK's reverse-engineered shape. Most are admin-relevant metadata + # (permissions, contact policy, address layout); all are optional so a + # future field-drop doesn't crash get_groups(). + created_time: datetime | None = Field(default=None, alias="createdTime") + member_permissions: list[str] = Field( + default_factory=list, alias="memberPermissions" + ) + """Permission strings granted to regular members (e.g. `["posts"]`).""" + guardian_permissions: list[str] = Field( + default_factory=list, alias="guardianPermissions" + ) + """Permission strings granted to guardians (e.g. `["posts"]`).""" + membership_requests: list[dict[str, Any]] = Field( + default_factory=list, alias="membershipRequests" + ) + """Pending join requests. Each entry is unmodelled.""" + chat_age_limit: int | None = Field(default=None, alias="chatAgeLimit") + """Minimum age allowed in group chats.""" + share_contact_info: bool = Field(default=False, alias="shareContactInfo") + """Whether member contact info is visible to other members.""" + contact_info_hidden: bool = Field(default=False, alias="contactInfoHidden") + admins_can_add_members: bool = Field(default=False, alias="adminsCanAddMembers") + address_format: list[str] = Field(default_factory=list, alias="addressFormat") + """Field-order hint for displaying member addresses, e.g. + `["street", "zip", "city"]`.""" + allow_sms_nag: bool = Field(default=False, alias="allowSmsNag") + bonus_enabled: bool = Field(default=False, alias="bonusEnabled") + invited_to_app_time: datetime | None = Field(default=None, alias="invitedToAppTime") + + # Less user-facing — admin/finance internals. Kept as raw containers + # because their nested shapes vary by Spond Club configuration and + # we don't want to over-promise a structure. + field_defs: list[Any] = Field(default_factory=list, alias="fieldDefs") + """Custom-field definitions configured on the group. Unmodelled.""" + default_fields: dict[str, Any] = Field(default_factory=dict, alias="defaultFields") + """Default per-member field metadata. Unmodelled.""" + payout_accounts: list[Any] = Field(default_factory=list, alias="payoutAccounts") + """Spond Club payout accounts attached to the group.""" + allow_private_payout_accounts: bool = Field( + default=False, alias="allowPrivatePayoutAccounts" + ) + experiments: dict[str, Any] = Field(default_factory=dict) + """A/B experiment flags Spond has enabled for this group. Internal.""" + _client: Any = PrivateAttr(default=None) def __str__(self) -> str: diff --git a/spond/profile.py b/spond/profile.py index b562cdb..dcc028d 100644 --- a/spond/profile.py +++ b/spond/profile.py @@ -48,6 +48,21 @@ class Profile(DictCompatModel): preferences: dict[str, Any] | None = None """Nested preference settings (push, email, locale, etc.). Unmodelled.""" + # Fields observed in the live API but absent from the original Spond SDK's + # reverse-engineered shape. All optional so a future field-drop doesn't + # crash get_profile(). + tos_version: int | None = Field(default=None, alias="tosVersion") + """Version of the Spond Terms of Service the user has accepted.""" + contact: bool = False + """Whether the user can be listed as a group contact person.""" + + # Internal/analytics fields — preserved for completeness but not part of + # the user-meaningful API surface. Treat as opaque. + tracking_id: str | None = Field(default=None, alias="trackingId") + """Internal analytics identifier. Opaque; do not rely on the shape.""" + unsubscribe_code: str | None = Field(default=None, alias="unsubscribeCode") + """Email-unsubscribe token. Internal.""" + @property def full_name(self) -> str: """Convenience: `first_name` + ` ` + `last_name`.""" diff --git a/spond/spond.py b/spond/spond.py index e27ffbb..2fcaf73 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -22,10 +22,15 @@ from .profile import Profile -def _typed_event(data: JSONDict, client: Spond) -> Event: +def _typed_event(data: JSONDict, client: Spond | None) -> Event: """Construct a `Match` if `matchEvent` is True on the raw payload, else a plain `Event`. Centralises the typed-vs-typed dispatch so both `get_events()` and any future singular-fetch path use the same logic. + + `client` is optional to make this helper usable in tests (which + construct events without a live Spond client). At runtime, every + production call site passes a real `Spond`; only tests pass `None`, + and they never invoke methods that would dereference `_client`. """ cls = Match if data.get("matchEvent") else Event return cls.from_api(data, client) @@ -231,15 +236,27 @@ async def get_person(self, user: str) -> Person: """ if not self.groups: await self.get_groups() - for group in self.groups or []: + # Distinct error path for "the account has no groups at all" vs. + # "groups exist but no member/guardian matched" — same exception + # type so existing `except KeyError:` callers keep working, but + # the message tells the caller which situation they're in. + if not self.groups: + raise KeyError( + f"No person matched with identifier {user!r}: account has " + f"no groups, so there are no members to search." + ) + for group in self.groups: for member in group.members: if self._match_person(member, user): return member for guardian in member.guardians: if self._match_person(guardian, user): return guardian - errmsg = f"No person matched with identifier '{user}'." - raise KeyError(errmsg) + raise KeyError( + f"No person matched with identifier {user!r}: scanned " + f"{sum(len(g.members) for g in self.groups)} member(s) across " + f"{len(self.groups)} group(s)." + ) @staticmethod def _match_person(person: Person, match_str: str) -> bool: diff --git a/tests/test_spond.py b/tests/test_spond.py index 3dd3740..cfa3d9e 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -1016,3 +1016,32 @@ def test_match_info_optional_for_resilience(self) -> None: bare = _typed_event({**_MIN_EVENT_PAYLOAD, "matchEvent": True}, None) assert isinstance(bare, Match) assert bare.match_info is None + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_match_update_preserves_match_type( + self, mock_post, mock_token + ) -> None: + """`match.update(...)` must return a `Match` instance, not a plain + `Event` — otherwise subclass identity is silently dropped and the + cache replacement loop demotes the entry to a non-Match. Regression + guard for the `type(self).from_api(...)` fix.""" + from spond.match import Match + from spond.spond import _typed_event + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + original = _typed_event(self._MATCH_PAYLOAD, s) + assert isinstance(original, Match) + s.events = [original] + + response = {**self._MATCH_PAYLOAD, "heading": "Updated Match"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=response + ) + + result = await original.update(heading="Updated Match") + assert isinstance(result, Match), f"Got {type(result).__name__}, expected Match" + # And the cache entry got swapped to the new Match instance, not a demoted Event. + assert isinstance(s.events[0], Match) + assert s.events[0] is result From 1ad187c1ddffb2f2d6020a84aa324f7b20a154d4 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 20:25:02 +0200 Subject: [PATCH 16/42] docs: add maintainer notes for periodic field-drift audit + subclass discipline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the patterns established in recent rounds so future maintainers don't re-derive them: - The mechanical recipe for the periodic API field-drift audit (compare live API keys against model_fields aliases per endpoint). Today's run found 22 gaps across 3 models; the technique scales to whatever Spond adds next. - The `type(self).from_api(...)` discipline in Event.update for preserving Match (and any future Event subclass) identity through the update + cache-replacement round trip. - The role of _EVENT_READ_ONLY_FIELDS as a curated whitelist's inverse, with the categorisation reasoning for each excluded field. Helps anyone adding new Event fields decide where the new field belongs. All three are practices, not code — but they're load-bearing enough that documenting them in the design saves the next maintainer (or future-me) from rediscovering them. --- DESIGN-oo-rewrite.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/DESIGN-oo-rewrite.md b/DESIGN-oo-rewrite.md index 5338f51..973b969 100644 --- a/DESIGN-oo-rewrite.md +++ b/DESIGN-oo-rewrite.md @@ -215,3 +215,25 @@ All four are answerable mid-impl with live API probing using credentials at `/ho ## Versioning Land as v1.3 — minor bump (return-type change is technically breaking, but the DictCompatMixin makes it soft). Legacy `Spond.*_event*` methods removed in v2.0 after a grace period. + +## Implementation notes for maintainers + +### Periodic API field-drift audit + +Spond keeps adding fields to its responses. The original SDK reverse-engineering captured what was visible at the time, but several models had accumulated gaps by the time the OO rewrite landed (Profile was missing 4 fields, Group was missing 17, Responses was missing 1). Two-thirds of those gaps were invisible to ordinary use because `extra="allow"` silently preserves unknown fields — they were stored on `__pydantic_extra__` but not surfaced in pdoc, attribute autocompletion, or static type-checking. + +A periodic audit closes the gap. The technique is mechanical: + +1. Authenticate a live `Spond` client against a real account. +2. For each modelled endpoint, fetch the raw response and compute `set(api_keys) - set(model_field_aliases)`. +3. Add any newly-discovered fields to the model with sentinel defaults (so the model stays resilient when the field eventually disappears too), with API aliases preserved, and a one-line docstring categorising each (user-facing vs internal). + +`Spond.get_*` methods naturally surface the data needed for the audit, and `.model_fields[name].alias or name` enumerates the model's expected key set. Re-run before each minor release, or whenever Spond ships a noticeable app update. + +### Subclass identity in `Event.update()` + +`Event.update()` builds the next instance via `type(self).from_api(new_data, self._client)` — **not** the literal `Event.from_api`. This preserves subclass identity for the `Match` subclass (and any future subclasses). Without `type(self)`, a `Match.update(...)` call returns a plain `Event`, the cache replacement loop writes the demoted instance, and subsequent `spond.get_event(uid)` silently serves the wrong type. Regression test: `TestMatch.test_match_update_preserves_match_type`. + +### Read-only field policy on Event + +`spond/event.py` defines `_EVENT_READ_ONLY_FIELDS` — a frozenset of Python field names that `Event.update()` strips from the POST payload before sending. The list mirrors the pre-OO `_EVENT_TEMPLATE` writable scope (with reasoning grouped by category: server-managed timestamps, derived flags, series wiring, nested sub-resources with their own endpoints). When adding new fields to `Event`, decide at declaration time whether they should be writable on update, and add them to the frozenset if not. `Match.match_info` deliberately does **not** appear in this set — score updates flow through `Event.update()`. From 3f59a34b16e473d0a456259a6f424371e220f354 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 20:45:21 +0200 Subject: [PATCH 17/42] =?UTF-8?q?fix(oo-rewrite):=20round-10=20review=20?= =?UTF-8?q?=E2=80=94=20narrow=20update=20payload=20further?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings actioned, plus a proactive sweep on the same patterns. Critical (finding #3): Event.update() round-tripped defaulted empty collections The previous `exclude_none=True` correctly handled None-valued fields, but `owners=[]`, `attachments=[]`, and other empty-list defaults weren't None — they're empty lists. So when source API didn't include `owners`, the local Event still defaulted to `[]`, and `event.update(heading="X")` would POST `"owners": []` back to Spond. If Spond interprets explicit `[]` as "clear all owners", this could overwrite concurrent server-side changes — same risk class as the earlier `responses` round-trip concern. Fix: added `exclude_unset=True` alongside the existing exclusions. Pydantic's `model_fields_set` tracks exactly which fields were populated during validation, so anything Spond didn't include in the GET response gets excluded from the subsequent POST. Caller- supplied updates are overlaid on top, so explicit modifications still reach the API. Verified live: dumping a real event's update payload now carries 15 keys (the actual API-returned shape) vs ~22 before. Matches the pre-OO _EVENT_TEMPLATE-merge behaviour much more closely. New regression test `test_event_update_excludes_unset_default_ collections` locks the invariant in: empty-list defaults that weren't in the source don't appear in the POST. Signature consistency (finding #2): Widened `Event.from_api(client: Spond)` → `Spond | None` so the chain `_typed_event(client: Spond | None)` → `cls.from_api(...)` type-checks cleanly. Documented in the docstring that None is for test fixtures only. Test portability (finding #1): `test_extra_allow_on_profile_and_group` still used native attribute access for extras (`p.newSpondField`, `g.newGroupAttr`) — the same Pydantic-version-dependent path flagged earlier. Switched both to `p.__pydantic_extra__["newSpondField"]` for portability across Pydantic 2.x minor versions. Proactive sweep also caught: - `Spond.update_event()` deprecation wrapper was dumping the return value with full defaults. Applied `exclude_unset=True` to that too, so the dict returned to legacy callers reflects what Spond actually sent back (not "every class field with defaults"). DESIGN-oo-rewrite.md updated with a new "Update-payload discipline: exclude_unset=True" section under maintainer notes, explaining the field-tracking guard so the next maintainer doesn't undo it. 57 tests pass; ruff clean; format clean. --- DESIGN-oo-rewrite.md | 4 ++++ spond/event.py | 26 +++++++++++++++++++------- spond/spond.py | 6 +++++- tests/test_spond.py | 38 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 64 insertions(+), 10 deletions(-) diff --git a/DESIGN-oo-rewrite.md b/DESIGN-oo-rewrite.md index 973b969..db171ee 100644 --- a/DESIGN-oo-rewrite.md +++ b/DESIGN-oo-rewrite.md @@ -237,3 +237,7 @@ A periodic audit closes the gap. The technique is mechanical: ### Read-only field policy on Event `spond/event.py` defines `_EVENT_READ_ONLY_FIELDS` — a frozenset of Python field names that `Event.update()` strips from the POST payload before sending. The list mirrors the pre-OO `_EVENT_TEMPLATE` writable scope (with reasoning grouped by category: server-managed timestamps, derived flags, series wiring, nested sub-resources with their own endpoints). When adding new fields to `Event`, decide at declaration time whether they should be writable on update, and add them to the frozenset if not. `Match.match_info` deliberately does **not** appear in this set — score updates flow through `Event.update()`. + +### Update-payload discipline: `exclude_unset=True` + +`Event.update()` dumps with `exclude_unset=True` (in addition to `exclude_none=True` and the read-only frozenset). This is the critical guard against round-tripping defaulted state back to Spond as authoritative. Pydantic's `model_fields_set` tracks exactly which fields were populated during validation — so a field that defaulted to `[]` (e.g. `owners`, `attachments`) or `None` because Spond's GET response didn't include it gets correctly excluded from the subsequent POST. Without this, calling `event.update(heading="X")` on an event whose source response omitted `owners` would send `"owners": []`, and Spond could interpret an explicit empty list as "clear all owners." Caller-supplied updates are overlaid on top of the dump, so explicit modifications always reach Spond regardless of source-data presence. diff --git a/spond/event.py b/spond/event.py index ab2fdb2..66b8f90 100644 --- a/spond/event.py +++ b/spond/event.py @@ -203,12 +203,16 @@ def url(self) -> str: return f"https://spond.com/client/sponds/{self.uid}/" @classmethod - def from_api(cls, data: dict[str, Any], client: Spond) -> Event: + def from_api(cls, data: dict[str, Any], client: Spond | None) -> Event: """Construct an `Event` from a raw API response and bind the client. Used internally by `Spond.get_event()` and `Spond.get_events()`. Sets `_client` on the instance so the ActiveRecord methods can - issue HTTP calls. + issue HTTP calls. `client` is `Optional` only so test fixtures + can build typed instances without a live Spond — production + callers always pass a real client, and any ActiveRecord method + called on a `_client is None` instance raises `RuntimeError` + (or `AttributeError` in the dereference path). """ instance = cls.model_validate(data) instance._client = client @@ -262,18 +266,26 @@ async def update( field_info = self.__class__.model_fields[py_name] api_updates[field_info.alias or py_name] = value - # Dump the current state, then strip: + # Dump the current state, then strip three classes of field: # * read-only fields (creator, timestamps, server-managed flags, # `responses`) — sending these back risks Spond treating stale # local state as authoritative. - # * `None`-valued declared fields — Spond may interpret an - # explicit JSON `null` as "clear this field" rather than - # "leave unchanged", so we only send fields that have a real - # value or have been explicitly provided by the caller below. + # * fields that weren't in the source API data (`exclude_unset`) + # — Pydantic tracks `model_fields_set` exactly so we know + # which fields came from Spond vs are class-level defaults. + # This is the critical guard: without it, defaulted empty + # collections (`owners=[]`, `attachments=[]`) and other + # sentinel-defaulted fields would round-trip back to Spond + # and could be interpreted as "clear this". + # * `None`-valued fields (`exclude_none`) — belt-and-suspenders + # for the same risk on dict-typed nested fields. + # The caller's `api_updates` are overlaid afterwards, so explicit + # updates always reach Spond regardless of source-data presence. payload = self.model_dump( by_alias=True, mode="json", exclude=_EVENT_READ_ONLY_FIELDS, + exclude_unset=True, exclude_none=True, ) payload.update(api_updates) diff --git a/spond/spond.py b/spond/spond.py index 2fcaf73..ee8a5b7 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -662,7 +662,11 @@ async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: # Pass as positional dict, not **kwargs — `updates` may contain keys # like `self` or `cls` that would clash with bound-method calling. new_event = await event.update(updates) - return new_event.model_dump(by_alias=True, mode="json") + # `exclude_unset=True` so the returned dict matches the shape Spond + # actually sent back (only the fields populated during validation), + # not "every class field including defaults" — closer to pre-OO + # behaviour where the return was the raw POST-response JSON. + return new_event.model_dump(by_alias=True, mode="json", exclude_unset=True) @_SpondBase.require_authentication async def get_event_attendance_xlsx(self, uid: str) -> bytes: diff --git a/tests/test_spond.py b/tests/test_spond.py index cfa3d9e..6135ebc 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -708,6 +708,36 @@ def test_models_survive_missing_optional_fields(self) -> None: pr = Profile.model_validate({"id": "PR1"}) assert pr.first_name == "" + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_excludes_unset_default_collections( + self, mock_post, mock_token + ) -> None: + """`Event.update()` must NOT send empty-list defaults for fields + the source API didn't include (e.g. `owners=[]`, `attachments=[]`). + Spond could interpret an explicit empty list as 'clear all + owners', overwriting concurrent server-side changes. + Regression guard for the `exclude_unset=True` fix.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + # _MIN_EVENT_PAYLOAD doesn't include `owners` or `attachments`, so + # the Event has them at their default ([]). They must NOT round-trip. + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + assert event.owners == [] + assert event.attachments == [] + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_MIN_EVENT_PAYLOAD + ) + await event.update(heading="Renamed") + + posted = mock_post.call_args[1]["json"] + # The caller's update was applied + assert posted["heading"] == "Renamed" + # Empty-list defaults were NOT sent (because they weren't in the source) + assert "owners" not in posted + assert "attachments" not in posted + @pytest.mark.asyncio @patch("aiohttp.ClientSession.post") async def test_event_update_excludes_none_fields( @@ -743,11 +773,15 @@ def test_extra_allow_on_profile_and_group(self) -> None: {"id": "P1", "firstName": "A", "lastName": "B", "newSpondField": 42} ) assert "newSpondField" in p - assert p.newSpondField == 42 # native attribute access via Pydantic + # `__pydantic_extra__` is the portable accessor for `extra="allow"` + # extras — native attribute access (`p.newSpondField`) works on + # current Pydantic 2.x but behaviour varies subtly across minor + # versions for camelCase keys, so this is the version-stable form. + assert p.__pydantic_extra__["newSpondField"] == 42 g = Group.model_validate({"id": "G1", "name": "GG", "newGroupAttr": ["x"]}) assert "newGroupAttr" in g - assert g.newGroupAttr == ["x"] + assert g.__pydantic_extra__["newGroupAttr"] == ["x"] class TestEventOOMethods: From daf565e8e9d30cd2faec1f9d91bf0ab1a12044a5 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 21:11:01 +0200 Subject: [PATCH 18/42] feat(oo-rewrite): typed Chat and Message models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the dict-shaped return from `Spond.get_messages()` with typed `Chat` instances (each carrying an optional embedded `Message`), and add an ActiveRecord-style `Chat.send(text)` that issues against the chat-server host using the existing lazy `_login_chat` handshake. `Message.type` discriminates between TEXT, IMAGES, RENAME, SPOND, INTERNAL_PROMO, CAMPAIGN — type-specific extras are declared as optional fields so consumers get IDE hints. Unknown variants pass through `extra="allow"` for forward compatibility. Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/chat.py | 164 +++++++++++++++++++++++++++++++++++++++++++++++++ spond/spond.py | 27 +++++--- 2 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 spond/chat.py diff --git a/spond/chat.py b/spond/chat.py new file mode 100644 index 0000000..43f22ce --- /dev/null +++ b/spond/chat.py @@ -0,0 +1,164 @@ +"""Typed `Chat` and `Message` models for the in-app chat API. + +Spond's chat lives on a separate host (`self._chat_url`) with a separate +auth token (`self._auth`), set up lazily by `Spond._login_chat`. Each +chat thread carries a single embedded `message` representing the most +recent post in the thread; full message history isn't exposed by the +core API surface. + +The `message.type` field discriminates between several payload shapes: + +| message.type | Sender | Extra payload field | +|-------------------|--------------------|----------------------------| +| `TEXT` | regular user | `text` | +| `IMAGES` | regular user | `images: list` | +| `RENAME` | regular user | `new_name` | +| `SPOND` | regular user | `spond` (event share) | +| `INTERNAL_PROMO` | none (system) | `internal_promo` | +| `CAMPAIGN` | none (system) | `campaign` | + +Common fields (`chat_id`, `msg_num`, `timestamp`, `type`, `reactions`) +are modelled directly; the type-specific extras are declared as +optional fields. Anything Spond adds later passes through `extra="allow"`. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from pydantic import ConfigDict, Field, PrivateAttr + +from ._compat import DictCompatModel + +if TYPE_CHECKING: + from .spond import Spond + + +class Message(DictCompatModel): + """A single chat message — almost always the most recent in a `Chat`. + + Only the common header fields (`chat_id`, `msg_num`, `timestamp`, + `type`, `reactions`) are guaranteed across all message variants. + Type-specific extras (`text`, `images`, `new_name`, etc.) are + optional; consumers should branch on `type` before reading them. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + chat_id: str | None = Field(default=None, alias="chatId") + msg_num: int | None = Field(default=None, alias="msgNum") + type: str = "" + """One of `TEXT`, `IMAGES`, `RENAME`, `SPOND`, `INTERNAL_PROMO`, + `CAMPAIGN` (and likely more — Spond extends this set over time; + unknown values pass through unchanged).""" + timestamp: datetime | None = None + reactions: dict[str, Any] = Field(default_factory=dict) + """Emoji-reactions map. Empty for messages without reactions.""" + + # Often present — most user-sent message types carry these + text: str | None = None + user: str | None = None + """Sender's profile UID. Absent on system messages (INTERNAL_PROMO, + CAMPAIGN, etc.).""" + + # Type-specific payloads — declared so they get docs and IDE hints, + # but each is `None` unless the message is of the matching `type`. + new_name: str | None = Field(default=None, alias="newName") + """Set when `type=="RENAME"` — the chat's new title.""" + images: list[Any] = Field(default_factory=list) + """Set when `type=="IMAGES"` — attached image objects (raw).""" + internal_promo: dict[str, Any] | None = Field(default=None, alias="internalPromo") + """Set when `type=="INTERNAL_PROMO"` — Spond's own promotional content.""" + campaign: dict[str, Any] | None = None + """Set when `type=="CAMPAIGN"` — community campaign payload.""" + spond: dict[str, Any] | None = None + """Set when `type=="SPOND"` — embedded event share.""" + + +class Chat(DictCompatModel): + """A chat thread — group, direct message, system channel, or campaign. + + Construct via `Spond.get_messages()` — both wire `_client` for you. + Direct instantiation works but `chat.send(...)` will refuse to run + without a client attached. + + Example + ------- + ```python + chats = await spond.get_messages() + for chat in chats: + if chat.unread and chat.message and chat.message.type == "TEXT": + print(f"unread in {chat.name!r}: {chat.message.text!r}") + await chat.send("ack") + ``` + """ + + model_config = ConfigDict( + populate_by_name=True, + extra="allow", + arbitrary_types_allowed=True, + ) + + uid: str = Field(alias="id") + name: str = "" + type: str = "" + """One of `GROUP`, `DM`, `INTERNAL_PROMO`, `CAMPAIGN` (and similar — + unknown values pass through unchanged).""" + participants: list[str] = Field(default_factory=list) + """Profile UIDs of members participating in this thread.""" + newest_timestamp: datetime | None = Field(default=None, alias="newestTimestamp") + """Timestamp of the most recent message in the thread.""" + unread: bool = False + muted: bool = False + community: dict[str, Any] | None = None + """Community-channel metadata (`{type: "NONE"}` for non-community + chats). Unmodelled — varies by chat kind.""" + message: Message | None = None + """The most recent message in this thread — the only one the chat + list endpoint includes. Full history isn't exposed by this SDK.""" + + _client: Any = PrivateAttr(default=None) + + def __str__(self) -> str: + return f"Chat(uid={self.uid!r}, name={self.name!r}, type={self.type!r})" + + @classmethod + def from_api(cls, data: dict[str, Any], client: Spond | None) -> Chat: + """Construct a `Chat` from raw API data and bind the client. + + Used internally by `Spond.get_messages()`. Sets `_client` so the + `send()` method can issue HTTP calls. + """ + instance = cls.model_validate(data) + instance._client = client + return instance + + async def send(self, text: str) -> dict[str, Any]: + """Post a TEXT message to this chat thread. + + Performs the lazy chat-server handshake on first call (same + machinery `Spond.send_message` uses for the `chat_id` path). + + Parameters + ---------- + text : str + Message body. Spond sends it as `type=TEXT`. + + Returns + ------- + dict + The Spond chat API's response for the send operation. + """ + if self._client is None: + raise RuntimeError( + "Chat has no client attached; instantiate via Spond.get_messages()." + ) + if self._client._auth is None: + await self._client._login_chat() + payload = {"chatId": self.uid, "text": text, "type": "TEXT"} + url = f"{self._client._chat_url}/messages" + async with self._client.clientsession.post( + url, json=payload, headers={"auth": self._client._auth} + ) as r: + return await r.json() diff --git a/spond/spond.py b/spond/spond.py index ee8a5b7..defbc18 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -14,6 +14,7 @@ from . import JSONDict from .base import _SpondBase +from .chat import Chat from .event import Event from .group import Group from .match import Match @@ -110,7 +111,7 @@ def __init__(self, username: str, password: str) -> None: self.groups: list[Group] | None = None self.events: list[Event] | None = None self.posts: list[Post] | None = None - self.messages: list[JSONDict] | None = None + self.messages: list[Chat] | None = None self.profile: Profile | None = None async def _login_chat(self) -> None: @@ -358,12 +359,14 @@ async def get_posts( return self.posts @_SpondBase.require_authentication - async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None: - """Retrieve recent chats (one-to-one and group conversations). + async def get_messages(self, max_chats: int = 100) -> list[Chat] | None: + """Retrieve recent chat threads (one-to-one, group, system, campaign). - "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`). + "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`). Each + returned `Chat` carries its most recent embedded `Message`; full + thread history isn't exposed by the underlying API. The full response is cached on `self.messages`. @@ -375,9 +378,9 @@ async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None: Returns ------- - list[JSONDict] or None - A list of chat objects ordered by most recent activity. `None` if - the account has no chats. + list[Chat] or None + Typed `Chat` instances ordered by most recent activity. `None` + if the account has no chats. """ if not self._auth: await self._login_chat() @@ -387,7 +390,11 @@ async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None: headers={"auth": self._auth}, params={"max": str(max_chats)}, ) as r: - self.messages = await r.json() + raw = await r.json() + if raw is None: + self.messages = None + return None + self.messages = [Chat.from_api(c, self) for c in raw] return self.messages @_SpondBase.require_authentication From 61a1af5e8191297995487620896ebb11114f611e Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 21:11:14 +0200 Subject: [PATCH 19/42] refactor(tests): split test_spond.py by domain + add Chat tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single test_spond.py had grown past ~1100 lines and mixed seven unrelated concerns. Split into eight files keyed by what they exercise; shared constants, fixtures, and the `_SpondBase.require_authentication` monkey-patch move to conftest.py where pytest auto-loads them before any test module imports. Also lands the TestChat suite (5 tests for the typed Chat/Message surface added in the previous commit), placed in test_messaging.py alongside the existing TestSendMessage tests. New layout: - conftest.py — shared infrastructure - test_auth.py — login + decorator metadata - test_compat.py — DictCompatModel shim + update-payload guards - test_events.py — Event read/write, OO methods, Match subclass - test_export.py — deprecated xlsx export wrapper - test_groups.py — Group read + Member/Guardian navigation - test_messaging.py — send_message + Chat/Message - test_posts.py — get_posts queries + caching 62 tests pass; no behavioural changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/conftest.py | 70 +++ tests/test_auth.py | 122 +++++ tests/test_compat.py | 196 +++++++ tests/test_events.py | 372 ++++++++++++++ tests/test_export.py | 53 ++ tests/test_groups.py | 163 ++++++ tests/test_messaging.py | 159 ++++++ tests/test_posts.py | 148 ++++++ tests/test_spond.py | 1081 --------------------------------------- 9 files changed, 1283 insertions(+), 1081 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_compat.py create mode 100644 tests/test_events.py create mode 100644 tests/test_export.py create mode 100644 tests/test_groups.py create mode 100644 tests/test_messaging.py create mode 100644 tests/test_posts.py delete mode 100644 tests/test_spond.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e5e55a2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,70 @@ +"""Shared fixtures, constants, and module-import-time setup for the Spond +test suite. + +pytest auto-discovers this file, so fixtures defined here (e.g. `mock_token`) +are available to every test file without explicit import. Constants and +helpers that test files need to reference directly are imported via +`from .conftest import ...`. + +The `_SpondBase.require_authentication` monkey-patch must happen before any +test module is imported — `conftest.py` is the canonical place for that. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from spond.base import _SpondBase +from spond.spond import Spond + +if TYPE_CHECKING: + from spond import JSONDict + + +# Minimum-viable Event API payload — all required fields filled with placeholder +# data. Tests that need an Event object can spread/override. +_MIN_EVENT_PAYLOAD: dict = { + "id": "ID1", + "heading": "Event One", + "startTimestamp": "2026-01-01T10:00:00Z", + "endTimestamp": "2026-01-01T11:00:00Z", + "createdTime": "2025-12-01T10:00:00Z", + "type": "EVENT", + "responses": { + "acceptedIds": [], + "declinedIds": [], + "unansweredIds": [], + "waitinglistIds": [], + "unconfirmedIds": [], + }, +} + + +MOCK_USERNAME, MOCK_PASSWORD = "MOCK_USERNAME", "MOCK_PASSWORD" +MOCK_TOKEN = "MOCK_TOKEN" +MOCK_PAYLOAD = {"accepted": "false", "declineMessage": "sick cannot make it"} + + +# Mock the `require_authentication` decorator to bypass authentication. +# Replaces the real decorator on `_SpondBase` so every test that calls a +# decorated method skips the real auth roundtrip. +def mock_require_authentication(func): + async def wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return wrapper + + +_SpondBase.require_authentication = mock_require_authentication(Spond.get_event) + + +@pytest.fixture +def mock_token() -> str: + return MOCK_TOKEN + + +@pytest.fixture +def mock_payload() -> JSONDict: + return MOCK_PAYLOAD diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..c161c66 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,122 @@ +"""Tests for authentication — `_extract_access_token` parsing, login flow, +and the `require_authentication` decorator's metadata preservation.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from spond import AuthenticationError +from spond.base import _SpondBase +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_USERNAME + + +class TestLogin: + @pytest.mark.parametrize( + ("login_result", "expected"), + [ + ( + {"accessToken": {"token": "ABC", "expiration": "2026-05-14T12:00:00Z"}}, + "ABC", + ), + ], + ) + def test_extract_access_token__happy_path(self, login_result, expected) -> None: + assert _SpondBase._extract_access_token(login_result) == expected + + @pytest.mark.parametrize( + "login_result", + [ + {"error": "Invalid credentials"}, + {"accessToken": None}, + {"accessToken": {}}, + {"accessToken": {"token": ""}}, + {"accessToken": {"token": None}}, + ], + ) + def test_extract_access_token__bad_shape_raises(self, login_result) -> None: + with pytest.raises(AuthenticationError): + _SpondBase._extract_access_token(login_result) + + def test_extract_access_token__error_message_drops_sensitive_fields( + self, + ) -> None: + """The exception message must not leak unwhitelisted fields from the + login response (e.g. a 2FA challenge `token` or `phoneNumber`).""" + login_result = { + "token": "TWO_FA_CHALLENGE_TOKEN_VALUE", + "phoneNumber": "****12", + "errorKey": "twoFactorRequired", + } + with pytest.raises(AuthenticationError) as exc_info: + _SpondBase._extract_access_token(login_result) + + message = str(exc_info.value) + assert "TWO_FA_CHALLENGE_TOKEN_VALUE" not in message + assert "phoneNumber" not in message + assert "twoFactorRequired" in message # whitelisted field surfaces + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_login__happy_path(self, mock_post) -> None: + mock_response = { + "accessToken": {"token": "ABC", "expiration": "2026-05-14T12:00:00Z"}, + "refreshToken": {"token": "REF", "expiration": "2026-08-11T12:00:00Z"}, + "passwordToken": {"token": "PWD", "expiration": "2026-05-13T13:00:00Z"}, + } + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=mock_response + ) + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + await s.login() + + mock_post.assert_called_once_with( + "https://api.spond.com/core/v1/auth2/login", + json={"email": MOCK_USERNAME, "password": MOCK_PASSWORD}, + ) + assert s.token == "ABC" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_login__error_response_raises(self, mock_post) -> None: + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={"error": "Invalid credentials"} + ) + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + with pytest.raises(AuthenticationError): + await s.login() + assert s.token is None + + +class TestRequireAuthenticationDecorator: + """The `require_authentication` decorator must preserve the wrapped + method's metadata (signature, docstring, name) so `inspect`-based + tools — pdoc, IDE help, tab completion — see the real method + rather than the wrapper's `(*args, **kwargs)` shim. + """ + + def test_decorator_preserves_signature(self) -> None: + """Decorated methods must expose their real parameter list.""" + import inspect + + # `get_posts` is decorated and has a distinctive signature + params = list(inspect.signature(Spond.get_posts).parameters) + assert params == ["self", "group_id", "max_posts", "include_comments"] + + def test_decorator_preserves_docstring(self) -> None: + """Decorated methods must expose their own docstring, not the + wrapper's.""" + import inspect + + doc = inspect.getdoc(Spond.get_profile) or "" + # Wrapper docstring would start with 'Decorator that...' if leaked. + assert "Retrieve the authenticated user's profile." in doc + + def test_decorator_preserves_name(self) -> None: + """`__name__` must be the method's, not 'wrapper'.""" + assert Spond.get_events.__name__ == "get_events" diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 0000000..0f7b05e --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,196 @@ +"""Tests for the `DictCompatModel` shim — the bridge that lets pre-OO +callers keep using dict-style subscript/`.get()`/`in`/`len()`/`iter()` +against typed Pydantic models while emitting `DeprecationWarning`s. + +Also covers `extra="allow"` forward-compat behaviour and a couple of +Event-update-payload regression guards (which exercise the same +shim machinery via `model_dump(exclude_unset=True, exclude_none=True)`).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from spond.event import Event +from spond.group import Group +from spond.spond import Spond + +from .conftest import _MIN_EVENT_PAYLOAD, MOCK_PASSWORD, MOCK_USERNAME + + +class TestDictCompat: + """The DictCompatModel shim makes typed models behave like the dicts they + replaced, with a DeprecationWarning on subscript and `.get()`.""" + + def test_subscript_via_alias_warns_and_returns_value(self) -> None: + import warnings as _w + + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + with _w.catch_warnings(record=True) as caught: + _w.simplefilter("always") + value = e["id"] # API alias + assert value == "ID1" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + def test_subscript_via_python_name_warns_and_returns_value(self) -> None: + import warnings as _w + + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + with _w.catch_warnings(record=True) as caught: + _w.simplefilter("always") + value = e["heading"] + assert value == "Event One" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + def test_subscript_missing_key_raises_keyerror(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + with pytest.raises(KeyError): + _ = e["does_not_exist"] + + def test_get_with_default_returns_default_for_missing_key(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + sentinel = object() + assert e.get("does_not_exist", sentinel) is sentinel + + def test_contains_works_for_alias_and_python_name(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + assert "id" in e # alias + assert "heading" in e # python name + assert "startTimestamp" in e # alias + assert "start_time" in e # python name + assert "does_not_exist" not in e + + def test_iter_yields_api_shaped_keys(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + keys = list(e) + assert "id" in keys # alias, not "uid" + assert "startTimestamp" in keys # alias, not "start_time" + + def test_len_contains_and_iter_agree(self) -> None: + """`__len__`, `__contains__`, and `__iter__` must all reflect the + same view of "what's actually in this object" — pre-OO callers + relied on dict semantics where these three are always consistent.""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + keys = list(e) + assert len(e) == len(keys) + for k in keys: + assert k in e + # A field with a default that wasn't in the source data must NOT + # appear in any of the three views. + assert "description" not in e # not in _MIN_EVENT_PAYLOAD + assert "description" not in keys + + def test_extra_allow_preserves_unmodelled_fields(self) -> None: + """With `model_config = extra="allow"`, Spond-side fields the SDK + doesn't model are preserved on the instance and accessible via the + dict-compat shim (with deprecation warning).""" + import warnings as _w + + payload = {**_MIN_EVENT_PAYLOAD, "futureSpondField": "preserved"} + e = Event.model_validate(payload) + # Iteration includes the extra + assert "futureSpondField" in e + assert "futureSpondField" in list(e) + with _w.catch_warnings(record=True) as caught: + _w.simplefilter("always") + value = e["futureSpondField"] + assert value == "preserved" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + def test_models_survive_missing_optional_fields(self) -> None: + """A previously-required field dropping to a default must not crash + the typed model. Locks in the resilience relaxation done after + rounds of review feedback.""" + from spond.person import Member + from spond.post import Post + from spond.profile import Profile + + # Member with no name fields — used to crash, now defaults to "" + m = Member.model_validate({"id": "M1"}) + assert m.first_name == "" + assert m.last_name == "" + # Post without timestamp — used to crash, now None + p = Post.model_validate({"id": "P1"}) + assert p.timestamp is None + # __str__ must not raise even though timestamp is None — guards the + # AttributeError that resilience relaxation otherwise re-introduced. + assert "?" in str(p) + # Profile with no name fields — same relaxation + pr = Profile.model_validate({"id": "PR1"}) + assert pr.first_name == "" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_excludes_unset_default_collections( + self, mock_post, mock_token + ) -> None: + """`Event.update()` must NOT send empty-list defaults for fields + the source API didn't include (e.g. `owners=[]`, `attachments=[]`). + Spond could interpret an explicit empty list as 'clear all + owners', overwriting concurrent server-side changes. + Regression guard for the `exclude_unset=True` fix.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + # _MIN_EVENT_PAYLOAD doesn't include `owners` or `attachments`, so + # the Event has them at their default ([]). They must NOT round-trip. + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + assert event.owners == [] + assert event.attachments == [] + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_MIN_EVENT_PAYLOAD + ) + await event.update(heading="Renamed") + + posted = mock_post.call_args[1]["json"] + # The caller's update was applied + assert posted["heading"] == "Renamed" + # Empty-list defaults were NOT sent (because they weren't in the source) + assert "owners" not in posted + assert "attachments" not in posted + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_excludes_none_fields( + self, mock_post, mock_token + ) -> None: + """`Event.update()` must NOT send `null` for optional fields that + Spond didn't populate — Spond could interpret `null` as 'clear this + field' rather than 'leave unchanged'.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + # _MIN_EVENT_PAYLOAD has no `description`, so Event.description=None. + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + assert event.description is None + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_MIN_EVENT_PAYLOAD + ) + await event.update(heading="Renamed") + + # The POST payload must contain `heading` (caller updated it) but + # NOT `description` (it was None and shouldn't be cleared). + posted = mock_post.call_args[1]["json"] + assert posted["heading"] == "Renamed" + assert "description" not in posted + + def test_extra_allow_on_profile_and_group(self) -> None: + """`extra="allow"` must work uniformly across top-level types, not + just Event — this is the forward-compat invariant for the whole + public surface.""" + from spond.profile import Profile + + p = Profile.model_validate( + {"id": "P1", "firstName": "A", "lastName": "B", "newSpondField": 42} + ) + assert "newSpondField" in p + # `__pydantic_extra__` is the portable accessor for `extra="allow"` + # extras — native attribute access (`p.newSpondField`) works on + # current Pydantic 2.x but behaviour varies subtly across minor + # versions for camelCase keys, so this is the version-stable form. + assert p.__pydantic_extra__["newSpondField"] == 42 + + g = Group.model_validate({"id": "G1", "name": "GG", "newGroupAttr": ["x"]}) + assert "newGroupAttr" in g + assert g.__pydantic_extra__["newGroupAttr"] == ["x"] diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..fd59055 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,372 @@ +"""Tests for Event surface — read APIs, deprecated wrappers, and the +ActiveRecord-style methods on the `Event` typed model (including the +`Match` subclass).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from spond.event import Event +from spond.spond import Spond + +from .conftest import _MIN_EVENT_PAYLOAD, MOCK_PASSWORD, MOCK_USERNAME + + +class TestEventMethods: + @pytest.fixture + def mock_events(self) -> list[Event]: + """Two typed Event instances with placeholder data.""" + return [ + Event.model_validate( + {**_MIN_EVENT_PAYLOAD, "id": "ID1", "heading": "Event One"} + ), + Event.model_validate( + {**_MIN_EVENT_PAYLOAD, "id": "ID2", "heading": "Event Two"} + ), + ] + + @pytest.mark.asyncio + async def test_get_event__happy_path( + self, mock_events: list[Event], mock_token + ) -> None: + """Test that a valid `id` returns the matching event.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.events = mock_events + s.token = mock_token + g = await s.get_event("ID1") + + assert isinstance(g, Event) + assert g.uid == "ID1" + assert g.heading == "Event One" + + @pytest.mark.asyncio + async def test_get_event__no_match_raises_exception( + self, mock_events: list[Event], mock_token + ) -> None: + """Test that a non-matched `id` raises KeyError.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.events = mock_events + s.token = mock_token + + with pytest.raises(KeyError): + await s.get_event("ID3") + + @pytest.mark.asyncio + async def test_get_event__blank_id_match_raises_exception( + self, mock_events: list[Event], mock_token + ) -> None: + """Test that a blank `id` raises KeyError.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.events = mock_events + s.token = mock_token + + with pytest.raises(KeyError): + await s.get_event("") + + @pytest.mark.asyncio + async def test_get_event__no_events_available_raises_keyerror( + self, mock_token + ) -> None: + """`get_events()` is documented to return None when no events exist; + `get_event()` should surface this as KeyError, not TypeError.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.events = None + s.get_events = AsyncMock() # leaves self.events as None + + with pytest.raises(KeyError): + await s.get_event("ID1") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_update_event__returns_api_response( + self, mock_post, mock_token + ) -> None: + """Deprecated `Spond.update_event()` should still return the POST response + as a dict for backward compatibility (delegates to `Event.update()`).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.events = [Event.from_api(_MIN_EVENT_PAYLOAD, s)] + + api_response = {**_MIN_EVENT_PAYLOAD, "heading": "New"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + import warnings as _warnings + + with _warnings.catch_warnings(record=True) as caught: + _warnings.simplefilter("always") + result = await s.update_event(uid="ID1", updates={"heading": "New"}) + + # Deprecation warning fired + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + # Result is a dict (model_dump output), with the updated heading + assert isinstance(result, dict) + assert result["heading"] == "New" + # Regression guard for issue #239: the result must NOT be the + # cached events list (the bug that was: `return self.events`). + assert result is not s.events + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + async def test_change_response(self, mock_put, mock_payload, mock_token) -> None: + """Deprecated `Spond.change_response()` should still PUT to the same URL + and return the API response (delegates to `Event.change_response()`).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.events = [Event.from_api(_MIN_EVENT_PAYLOAD, s)] + + mock_response_data = { + "acceptedIds": ["PID1", "PID2"], + "declinedIds": ["PID3"], + "unansweredIds": [], + "waitinglistIds": [], + "unconfirmedIds": [], + "declineMessages": {"PID3": "sick cannot make it"}, + } + mock_put.return_value.__aenter__.return_value.status = 200 + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value=mock_response_data + ) + + import warnings as _warnings + + with _warnings.catch_warnings(record=True) as caught: + _warnings.simplefilter("always") + response = await s.change_response( + uid="ID1", user="PID3", payload=mock_payload + ) + + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + mock_url = "https://api.spond.com/core/v1/sponds/ID1/responses/PID3" + # The wrapper forwards `payload` verbatim — same bytes that go on + # the wire on the pre-OO code path. + mock_put.assert_called_once_with( + mock_url, + headers={ + "content-type": "application/json", + "Authorization": f"Bearer {mock_token}", + }, + json=mock_payload, + ) + assert response == mock_response_data + + +class TestEventOOMethods: + """ActiveRecord methods on Event.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_returns_new_event(self, mock_post, mock_token) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + api_response = {**_MIN_EVENT_PAYLOAD, "heading": "Updated"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + result = await event.update(heading="Updated") + assert isinstance(result, Event) + assert result.heading == "Updated" + assert result is not event # immutable: returns new + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_accepts_positional_dict( + self, mock_post, mock_token + ) -> None: + """`event.update(updates_dict)` works for keys that would clash with + reserved kwargs when passed via `**` (e.g. `self`, `cls`).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + api_response = {**_MIN_EVENT_PAYLOAD, "heading": "New"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + # Use the positional dict form + result = await event.update({"heading": "New", "selfish": "any"}) + assert result.heading == "New" + # Unknown key "selfish" was passed through to the API payload + posted = mock_post.call_args[1]["json"] + assert posted["selfish"] == "any" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_refreshes_client_cache( + self, mock_post, mock_token + ) -> None: + """After `event.update()`, the client's events cache must hold the + new instance — not the stale pre-update one — so subsequent + `spond.get_event(uid)` calls return current state.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + original = Event.from_api(_MIN_EVENT_PAYLOAD, s) + s.events = [original] + + api_response = {**_MIN_EVENT_PAYLOAD, "heading": "Refreshed"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + result = await original.update(heading="Refreshed") + # Cache must now point at the new instance (in-place replacement + # preserves the list identity for callers holding `s.events`). + assert s.events is not None + assert s.events[0] is result + assert s.events[0].heading == "Refreshed" + assert s.events[0] is not original + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + async def test_event_change_response_accepts(self, mock_put, mock_token) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value={"acceptedIds": ["MID1"]} + ) + result = await event.change_response("MID1", accepted=True) + assert result == {"acceptedIds": ["MID1"]} + call_args = mock_put.call_args + assert call_args[0][0].endswith("/sponds/ID1/responses/MID1") + assert call_args[1]["json"]["accepted"] == "true" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + async def test_event_change_response_declines_with_message( + self, mock_put, mock_token + ) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value={"declinedIds": ["MID1"]} + ) + await event.change_response("MID1", accepted=False, decline_message="busy") + sent = mock_put.call_args[1]["json"] + assert sent["accepted"] == "false" + assert sent["declineMessage"] == "busy" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_event_attendance_xlsx_returns_bytes( + self, mock_get, mock_token + ) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + mock_get.return_value.__aenter__.return_value.read = AsyncMock( + return_value=b"PKxlsx-bytes" + ) + data = await event.attendance_xlsx() + assert data == b"PKxlsx-bytes" + assert mock_get.call_args[0][0].endswith("/sponds/ID1/export") + + +class TestMatch: + """Match (Event subclass) dispatch and field parsing.""" + + _MATCH_PAYLOAD = { + **_MIN_EVENT_PAYLOAD, + "matchEvent": True, + "matchInfo": { + "teamName": "Home FC", + "opponentName": "Away FC", + "type": "HOME", + "teamScore": 2, + "opponentScore": 1, + "scoresSet": True, + "scoresFinal": True, + "scoresSetEver": True, + "scoresPublic": True, + }, + } + + def test_dispatch_returns_match_when_match_event_true(self) -> None: + """`Spond.get_events()` constructs `Match` (Event subclass) when the + raw payload has `matchEvent=True`, plain `Event` otherwise.""" + from spond.match import Match, MatchInfo + from spond.spond import _typed_event + + # _typed_event doesn't call methods on the client, so None is fine + # for the dispatch + parsing assertions below. + regular = _typed_event(_MIN_EVENT_PAYLOAD, None) + m = _typed_event(self._MATCH_PAYLOAD, None) + + assert type(regular) is Event + assert isinstance(m, Match) + assert isinstance(m, Event) # subclass relationship + assert isinstance(m.match_info, MatchInfo) + assert m.match_info.team_name == "Home FC" + assert m.match_info.opponent_name == "Away FC" + assert m.match_info.type == "HOME" + assert m.match_info.team_score == 2 + assert m.match_info.opponent_score == 1 + assert m.match_info.scores_final is True + assert m.match_info.scores_public is True + + def test_match_score_update_path_is_through_event_update(self) -> None: + """Match inherits Event.update; the `match_info` field must be + included in the POST payload (not in _EVENT_READ_ONLY_FIELDS), so + callers can edit scores via `match.update(matchInfo={...})`.""" + from spond.event import _EVENT_READ_ONLY_FIELDS + from spond.match import Match + + assert "match_info" not in _EVENT_READ_ONLY_FIELDS + # And it really is a declared field on Match (vs an unmodelled + # passthrough via extra="allow"): + assert "match_info" in Match.model_fields + + def test_match_info_optional_for_resilience(self) -> None: + """A future API variant emitting matchEvent=True without matchInfo + (or a half-populated match record) must not crash construction.""" + from spond.match import Match + from spond.spond import _typed_event + + bare = _typed_event({**_MIN_EVENT_PAYLOAD, "matchEvent": True}, None) + assert isinstance(bare, Match) + assert bare.match_info is None + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_match_update_preserves_match_type( + self, mock_post, mock_token + ) -> None: + """`match.update(...)` must return a `Match` instance, not a plain + `Event` — otherwise subclass identity is silently dropped and the + cache replacement loop demotes the entry to a non-Match. Regression + guard for the `type(self).from_api(...)` fix.""" + from spond.match import Match + from spond.spond import _typed_event + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + original = _typed_event(self._MATCH_PAYLOAD, s) + assert isinstance(original, Match) + s.events = [original] + + response = {**self._MATCH_PAYLOAD, "heading": "Updated Match"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=response + ) + + result = await original.update(heading="Updated Match") + assert isinstance(result, Match), f"Got {type(result).__name__}, expected Match" + # And the cache entry got swapped to the new Match instance, not a demoted Event. + assert isinstance(s.events[0], Match) + assert s.events[0] is result diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..bbf6bea --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,53 @@ +"""Tests for the deprecated event-attendance xlsx export wrapper. + +The OO-rewrite path is covered by `TestEventOOMethods` (the `Event.attendance_xlsx()` +method) — this file pins down the *backward-compat shape* of +`Spond.get_event_attendance_xlsx()` so callers on the old API surface keep +working until the deprecation cycle completes.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_USERNAME + + +class TestExportMethod: + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_export(self, mock_get, mock_token) -> None: + """Deprecated `Spond.get_event_attendance_xlsx()` should still GET the + export endpoint and return raw bytes (delegates to + `Event.attendance_xlsx()`).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + # Note: `s.events` is intentionally not pre-populated — the + # deprecated wrapper does a direct GET on the export endpoint, it + # doesn't consult the events cache. + + mock_binary = b"\x68\x65\x6c\x6c\x6f\x77\x6f\x72\x6c\x64" # helloworld + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.read = AsyncMock( + return_value=mock_binary + ) + + import warnings as _warnings + + with _warnings.catch_warnings(record=True) as caught: + _warnings.simplefilter("always") + data = await s.get_event_attendance_xlsx(uid="ID1") + + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + mock_url = "https://api.spond.com/core/v1/sponds/ID1/export" + mock_get.assert_called_once_with( + mock_url, + headers={ + "content-type": "application/json", + "Authorization": f"Bearer {mock_token}", + }, + ) + assert data == mock_binary diff --git a/tests/test_groups.py b/tests/test_groups.py new file mode 100644 index 0000000..89e4185 --- /dev/null +++ b/tests/test_groups.py @@ -0,0 +1,163 @@ +"""Tests for Group surface — read APIs and the inter-dependency navigation +(Group → Member → Guardian).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from spond.group import Group +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_USERNAME + + +class TestGroupMethods: + @pytest.fixture + def mock_groups(self) -> list[Group]: + """Two typed Group instances with placeholder data.""" + return [ + Group.model_validate({"id": "ID1", "name": "Group One"}), + Group.model_validate({"id": "ID2", "name": "Group Two"}), + ] + + @pytest.mark.asyncio + async def test_get_group__happy_path( + self, mock_groups: list[Group], mock_token + ) -> None: + """Test that a valid `id` returns the matching group.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.groups = mock_groups + s.token = mock_token + g = await s.get_group("ID2") + + assert isinstance(g, Group) + assert g.uid == "ID2" + assert g.name == "Group Two" + + @pytest.mark.asyncio + async def test_get_group__no_match_raises_exception( + self, mock_groups: list[Group], mock_token + ) -> None: + """Test that a non-matched `id` raises KeyError.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.groups = mock_groups + s.token = mock_token + + with pytest.raises(KeyError): + await s.get_group("ID3") + + @pytest.mark.asyncio + async def test_get_group__blank_id_raises_exception( + self, mock_groups: list[Group], mock_token + ) -> None: + """Test that a blank `id` raises KeyError.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.groups = mock_groups + s.token = mock_token + + with pytest.raises(KeyError): + await s.get_group("") + + @pytest.mark.asyncio + async def test_get_group__no_groups_available_raises_keyerror( + self, mock_token + ) -> None: + """`get_groups()` is documented to return None when no groups exist; + `get_group()` should surface this as KeyError, not TypeError.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.groups = None + s.get_groups = AsyncMock() # leaves self.groups as None + + with pytest.raises(KeyError): + await s.get_group("ID1") + + +class TestGroupNavigation: + """Group → Member → Guardian wiring.""" + + def test_group_materializes_typed_members_and_guardians(self) -> None: + raw = { + "id": "GID", + "name": "Test Group", + "members": [ + { + "id": "M1", + "firstName": "Alice", + "lastName": "Smith", + "email": "alice@example.invalid", + "guardians": [ + { + "id": "G1", + "firstName": "Bob", + "lastName": "Smith", + "phoneNumber": "+1", + } + ], + }, + ], + } + from spond.person import Guardian, Member + + group = Group.model_validate(raw) + assert isinstance(group.members[0], Member) + assert group.members[0].full_name == "Alice Smith" + assert isinstance(group.members[0].guardians[0], Guardian) + assert group.members[0].guardians[0].full_name == "Bob Smith" + + def test_find_member_by_email(self) -> None: + group = Group.model_validate( + { + "id": "GID", + "name": "G", + "members": [ + { + "id": "M1", + "firstName": "A", + "lastName": "B", + "email": "a@b.invalid", + }, + ], + } + ) + found = group.find_member(email="a@b.invalid") + assert found is not None + assert found.uid == "M1" + + def test_find_member_returns_none_when_no_match(self) -> None: + group = Group.model_validate({"id": "GID", "name": "G", "members": []}) + assert group.find_member(uid="missing") is None + + def test_find_member_requires_exactly_one_criterion(self) -> None: + group = Group.model_validate({"id": "GID", "name": "G", "members": []}) + with pytest.raises(ValueError, match="exactly one"): + group.find_member() + with pytest.raises(ValueError, match="exactly one"): + group.find_member(uid="X", email="a@b.invalid") + + def test_member_custom_fields_alias_works_via_either_name(self) -> None: + """`Member.custom_fields` aliases the API's `"fields"` key — both + forms must populate the attribute identically.""" + from spond.person import Member + + # API-style (via alias): + m1 = Member.model_validate( + {"id": "M1", "firstName": "A", "lastName": "B", "fields": {"height": "175"}} + ) + # Python-style (via name): + m2 = Member.model_validate( + { + "id": "M2", + "firstName": "C", + "lastName": "D", + "custom_fields": {"height": "180"}, + } + ) + assert m1.custom_fields == {"height": "175"} + assert m2.custom_fields == {"height": "180"} diff --git a/tests/test_messaging.py b/tests/test_messaging.py new file mode 100644 index 0000000..3109713 --- /dev/null +++ b/tests/test_messaging.py @@ -0,0 +1,159 @@ +"""Tests for messaging — both the low-level `Spond.send_message()` entrypoint +and the typed `Chat`/`Message` surface returned by `Spond.get_messages()`.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_USERNAME + + +class TestSendMessage: + """Tests for `Spond.send_message()` — covers the fixes in #238.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_send_message__continues_chat_when_chat_id_given( + self, mock_post, mock_token + ) -> None: + """With `chat_id`, the call should route through `_continue_chat()` + and properly await it (regression: the await was missing). + `_continue_chat` now uses `async with`, so the post mock follows + the standard context-manager pattern used elsewhere in this file. + """ + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + api_response = {"ok": True, "messageId": "MID1"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + result = await s.send_message(text="hello", chat_id="CHAT1") + + assert result == api_response + mock_post.assert_called_once() + _, kwargs = mock_post.call_args + assert kwargs["json"] == {"chatId": "CHAT1", "text": "hello", "type": "TEXT"} + + @pytest.mark.asyncio + async def test_send_message__missing_args_raises_valueerror( + self, mock_token + ) -> None: + """Without `chat_id` and without both `user` and `group_uid`, the + call should raise rather than silently return a sentinel dict.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + with pytest.raises(ValueError, match="chat_id"): + await s.send_message(text="hello") + + with pytest.raises(ValueError, match="user and group_uid"): + await s.send_message(text="hello", user="USER1") + + with pytest.raises(ValueError, match="user and group_uid"): + await s.send_message(text="hello", group_uid="GROUP1") + + +class TestChat: + """Chat/Message typed surface — replaces the old `list[JSONDict]` return + from `Spond.get_messages()`.""" + + _CHAT_PAYLOAD = { + "id": "CHAT1", + "name": "Demo Group", + "type": "GROUP", + "participants": ["P1", "P2"], + "newestTimestamp": "2026-05-14T12:00:00Z", + "unread": True, + "muted": False, + "message": { + "chatId": "CHAT1", + "msgNum": 42, + "type": "TEXT", + "timestamp": "2026-05-14T12:00:00Z", + "text": "hello", + "user": "P1", + }, + } + + def test_chat_parses_with_typed_message(self) -> None: + from spond.chat import Chat, Message + + c = Chat.from_api(self._CHAT_PAYLOAD, None) + assert c.uid == "CHAT1" + assert c.name == "Demo Group" + assert c.type == "GROUP" + assert c.unread is True + assert c.participants == ["P1", "P2"] + assert isinstance(c.message, Message) + assert c.message.type == "TEXT" + assert c.message.text == "hello" + assert c.message.user == "P1" + + def test_chat_message_optional_for_resilience(self) -> None: + """A chat with no embedded message (rare but possible) must not crash + — the only required field on Chat is `uid`.""" + from spond.chat import Chat + + c = Chat.from_api({"id": "X"}, None) + assert c.uid == "X" + assert c.message is None + + def test_message_type_specific_extras_default_to_empty(self) -> None: + """A TEXT message must not falsely report `new_name` or `images` — + those are RENAME/IMAGES-specific.""" + from spond.chat import Message + + m = Message.model_validate(self._CHAT_PAYLOAD["message"]) + assert m.new_name is None + assert m.images == [] + assert m.internal_promo is None + assert m.campaign is None + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_chat_send_routes_through_chat_server( + self, mock_post, mock_token + ) -> None: + """`chat.send(text)` posts to the chat-server host with the chat-server + auth token (not the regular Bearer), preserving the same wire shape + the deprecated `Spond.send_message(chat_id=...)` path uses.""" + from spond.chat import Chat + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + chat = Chat.from_api(self._CHAT_PAYLOAD, s) + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={"ok": True} + ) + result = await chat.send("ack") + assert result == {"ok": True} + url, _ = mock_post.call_args[0], mock_post.call_args[1] + assert url[0] == "https://chat.example.invalid/messages" + kwargs = mock_post.call_args[1] + assert kwargs["json"] == {"chatId": "CHAT1", "text": "ack", "type": "TEXT"} + assert kwargs["headers"] == {"auth": "MOCK_CHAT_AUTH"} + + def test_chat_send_refuses_without_client(self) -> None: + """A Chat constructed without a client (test fixture, direct + instantiation) must raise rather than crashing with an attribute + error inside the send path.""" + import asyncio + + from spond.chat import Chat + + c = Chat.from_api(self._CHAT_PAYLOAD, None) + with pytest.raises(RuntimeError, match="no client attached"): + asyncio.run(c.send("hello")) diff --git a/tests/test_posts.py b/tests/test_posts.py new file mode 100644 index 0000000..754c3e3 --- /dev/null +++ b/tests/test_posts.py @@ -0,0 +1,148 @@ +"""Tests for `Spond.get_posts()` — query-parameter construction, caching, +and error surfacing.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, patch + +import pytest + +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_USERNAME + +if TYPE_CHECKING: + from spond import JSONDict + + +class TestPostMethods: + MOCK_POSTS: list[JSONDict] = [ + { + "id": "POST1", + "type": "PLAIN", + "groupId": "GID1", + "title": "Post One", + "body": "Body of post one", + "timestamp": "2026-03-03T19:20:00.270Z", + "comments": [], + }, + { + "id": "POST2", + "type": "PLAIN", + "groupId": "GID2", + "title": "Post Two", + "body": "Body of post two", + "timestamp": "2026-02-20T19:21:20.447Z", + "comments": [{"id": "C1", "text": "A comment"}], + }, + ] + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__happy_path(self, mock_get, mock_token) -> None: + """Test that get_posts returns posts from the API.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self.MOCK_POSTS + ) + + posts = await s.get_posts() + + mock_url = "https://api.spond.com/core/v1/posts/" + mock_get.assert_called_once_with( + mock_url, + headers={ + "content-type": "application/json", + "Authorization": f"Bearer {mock_token}", + }, + params={ + "type": "PLAIN", + "max": "20", + "includeComments": "true", + }, + ) + assert posts is not None + assert len(posts) == 2 + assert posts[0].uid == "POST1" + assert posts[0].title == "Post One" + assert posts[1].uid == "POST2" + assert s.posts is posts # cache identity + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__with_group_id(self, mock_get, mock_token) -> None: + """Test that group_id is passed as a query parameter.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=[self.MOCK_POSTS[0]] + ) + + posts = await s.get_posts(group_id="GID1") + + mock_get.assert_called_once_with( + "https://api.spond.com/core/v1/posts/", + headers={ + "content-type": "application/json", + "Authorization": f"Bearer {mock_token}", + }, + params={ + "type": "PLAIN", + "max": "20", + "includeComments": "true", + "groupId": "GID1", + }, + ) + assert len(posts) == 1 + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__custom_max(self, mock_get, mock_token) -> None: + """Test that max_posts parameter is respected.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + await s.get_posts(max_posts=5) + + call_params = mock_get.call_args[1]["params"] + assert call_params["max"] == "5" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__no_comments(self, mock_get, mock_token) -> None: + """Test that include_comments=False is passed correctly.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + await s.get_posts(include_comments=False) + + call_params = mock_get.call_args[1]["params"] + assert call_params["includeComments"] == "false" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__api_error_raises(self, mock_get, mock_token) -> None: + """Test that a failed API response raises ValueError.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = False + mock_get.return_value.__aenter__.return_value.status = 401 + mock_get.return_value.__aenter__.return_value.text = AsyncMock( + return_value="Unauthorized" + ) + + with pytest.raises(ValueError, match="401"): + await s.get_posts() diff --git a/tests/test_spond.py b/tests/test_spond.py deleted file mode 100644 index 6135ebc..0000000 --- a/tests/test_spond.py +++ /dev/null @@ -1,1081 +0,0 @@ -"""Test suite for Spond class.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import AsyncMock, patch - -import pytest - -from spond import AuthenticationError -from spond.base import _SpondBase -from spond.event import Event -from spond.group import Group -from spond.spond import Spond - -if TYPE_CHECKING: - from spond import JSONDict - - -# Minimum-viable Event API payload — all required fields filled with placeholder -# data. Tests that need an Event object can spread/override. -_MIN_EVENT_PAYLOAD: dict = { - "id": "ID1", - "heading": "Event One", - "startTimestamp": "2026-01-01T10:00:00Z", - "endTimestamp": "2026-01-01T11:00:00Z", - "createdTime": "2025-12-01T10:00:00Z", - "type": "EVENT", - "responses": { - "acceptedIds": [], - "declinedIds": [], - "unansweredIds": [], - "waitinglistIds": [], - "unconfirmedIds": [], - }, -} - - -MOCK_USERNAME, MOCK_PASSWORD = "MOCK_USERNAME", "MOCK_PASSWORD" -MOCK_TOKEN = "MOCK_TOKEN" -MOCK_PAYLOAD = {"accepted": "false", "declineMessage": "sick cannot make it"} - - -# Mock the `require_authentication` decorator to bypass authentication -def mock_require_authentication(func): - async def wrapper(*args, **kwargs): - return await func(*args, **kwargs) - - return wrapper - - -_SpondBase.require_authentication = mock_require_authentication(Spond.get_event) - - -@pytest.fixture -def mock_token() -> str: - return MOCK_TOKEN - - -@pytest.fixture -def mock_payload() -> JSONDict: - return MOCK_PAYLOAD - - -class TestEventMethods: - @pytest.fixture - def mock_events(self) -> list[Event]: - """Two typed Event instances with placeholder data.""" - return [ - Event.model_validate( - {**_MIN_EVENT_PAYLOAD, "id": "ID1", "heading": "Event One"} - ), - Event.model_validate( - {**_MIN_EVENT_PAYLOAD, "id": "ID2", "heading": "Event Two"} - ), - ] - - @pytest.mark.asyncio - async def test_get_event__happy_path( - self, mock_events: list[Event], mock_token - ) -> None: - """Test that a valid `id` returns the matching event.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.events = mock_events - s.token = mock_token - g = await s.get_event("ID1") - - assert isinstance(g, Event) - assert g.uid == "ID1" - assert g.heading == "Event One" - - @pytest.mark.asyncio - async def test_get_event__no_match_raises_exception( - self, mock_events: list[Event], mock_token - ) -> None: - """Test that a non-matched `id` raises KeyError.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.events = mock_events - s.token = mock_token - - with pytest.raises(KeyError): - await s.get_event("ID3") - - @pytest.mark.asyncio - async def test_get_event__blank_id_match_raises_exception( - self, mock_events: list[Event], mock_token - ) -> None: - """Test that a blank `id` raises KeyError.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.events = mock_events - s.token = mock_token - - with pytest.raises(KeyError): - await s.get_event("") - - @pytest.mark.asyncio - async def test_get_event__no_events_available_raises_keyerror( - self, mock_token - ) -> None: - """`get_events()` is documented to return None when no events exist; - `get_event()` should surface this as KeyError, not TypeError.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - s.events = None - s.get_events = AsyncMock() # leaves self.events as None - - with pytest.raises(KeyError): - await s.get_event("ID1") - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post") - async def test_update_event__returns_api_response( - self, mock_post, mock_token - ) -> None: - """Deprecated `Spond.update_event()` should still return the POST response - as a dict for backward compatibility (delegates to `Event.update()`).""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - s.events = [Event.from_api(_MIN_EVENT_PAYLOAD, s)] - - api_response = {**_MIN_EVENT_PAYLOAD, "heading": "New"} - mock_post.return_value.__aenter__.return_value.json = AsyncMock( - return_value=api_response - ) - - import warnings as _warnings - - with _warnings.catch_warnings(record=True) as caught: - _warnings.simplefilter("always") - result = await s.update_event(uid="ID1", updates={"heading": "New"}) - - # Deprecation warning fired - assert any(issubclass(w.category, DeprecationWarning) for w in caught) - # Result is a dict (model_dump output), with the updated heading - assert isinstance(result, dict) - assert result["heading"] == "New" - # Regression guard for issue #239: the result must NOT be the - # cached events list (the bug that was: `return self.events`). - assert result is not s.events - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.put") - async def test_change_response(self, mock_put, mock_payload, mock_token) -> None: - """Deprecated `Spond.change_response()` should still PUT to the same URL - and return the API response (delegates to `Event.change_response()`).""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - s.events = [Event.from_api(_MIN_EVENT_PAYLOAD, s)] - - mock_response_data = { - "acceptedIds": ["PID1", "PID2"], - "declinedIds": ["PID3"], - "unansweredIds": [], - "waitinglistIds": [], - "unconfirmedIds": [], - "declineMessages": {"PID3": "sick cannot make it"}, - } - mock_put.return_value.__aenter__.return_value.status = 200 - mock_put.return_value.__aenter__.return_value.json = AsyncMock( - return_value=mock_response_data - ) - - import warnings as _warnings - - with _warnings.catch_warnings(record=True) as caught: - _warnings.simplefilter("always") - response = await s.change_response( - uid="ID1", user="PID3", payload=mock_payload - ) - - assert any(issubclass(w.category, DeprecationWarning) for w in caught) - mock_url = "https://api.spond.com/core/v1/sponds/ID1/responses/PID3" - # The wrapper forwards `payload` verbatim — same bytes that go on - # the wire on the pre-OO code path. - mock_put.assert_called_once_with( - mock_url, - headers={ - "content-type": "application/json", - "Authorization": f"Bearer {mock_token}", - }, - json=mock_payload, - ) - assert response == mock_response_data - - -class TestGroupMethods: - @pytest.fixture - def mock_groups(self) -> list[Group]: - """Two typed Group instances with placeholder data.""" - return [ - Group.model_validate({"id": "ID1", "name": "Group One"}), - Group.model_validate({"id": "ID2", "name": "Group Two"}), - ] - - @pytest.mark.asyncio - async def test_get_group__happy_path( - self, mock_groups: list[Group], mock_token - ) -> None: - """Test that a valid `id` returns the matching group.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.groups = mock_groups - s.token = mock_token - g = await s.get_group("ID2") - - assert isinstance(g, Group) - assert g.uid == "ID2" - assert g.name == "Group Two" - - @pytest.mark.asyncio - async def test_get_group__no_match_raises_exception( - self, mock_groups: list[Group], mock_token - ) -> None: - """Test that a non-matched `id` raises KeyError.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.groups = mock_groups - s.token = mock_token - - with pytest.raises(KeyError): - await s.get_group("ID3") - - @pytest.mark.asyncio - async def test_get_group__blank_id_raises_exception( - self, mock_groups: list[Group], mock_token - ) -> None: - """Test that a blank `id` raises KeyError.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.groups = mock_groups - s.token = mock_token - - with pytest.raises(KeyError): - await s.get_group("") - - @pytest.mark.asyncio - async def test_get_group__no_groups_available_raises_keyerror( - self, mock_token - ) -> None: - """`get_groups()` is documented to return None when no groups exist; - `get_group()` should surface this as KeyError, not TypeError.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - s.groups = None - s.get_groups = AsyncMock() # leaves self.groups as None - - with pytest.raises(KeyError): - await s.get_group("ID1") - - -class TestSendMessage: - """Tests for `Spond.send_message()` — covers the fixes in #238.""" - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post") - async def test_send_message__continues_chat_when_chat_id_given( - self, mock_post, mock_token - ) -> None: - """With `chat_id`, the call should route through `_continue_chat()` - and properly await it (regression: the await was missing). - `_continue_chat` now uses `async with`, so the post mock follows - the standard context-manager pattern used elsewhere in this file. - """ - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - s._auth = "MOCK_CHAT_AUTH" - s._chat_url = "https://chat.example.invalid" - - api_response = {"ok": True, "messageId": "MID1"} - mock_post.return_value.__aenter__.return_value.json = AsyncMock( - return_value=api_response - ) - - result = await s.send_message(text="hello", chat_id="CHAT1") - - assert result == api_response - mock_post.assert_called_once() - _, kwargs = mock_post.call_args - assert kwargs["json"] == {"chatId": "CHAT1", "text": "hello", "type": "TEXT"} - - @pytest.mark.asyncio - async def test_send_message__missing_args_raises_valueerror( - self, mock_token - ) -> None: - """Without `chat_id` and without both `user` and `group_uid`, the - call should raise rather than silently return a sentinel dict.""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - s._auth = "MOCK_CHAT_AUTH" - s._chat_url = "https://chat.example.invalid" - - with pytest.raises(ValueError, match="chat_id"): - await s.send_message(text="hello") - - with pytest.raises(ValueError, match="user and group_uid"): - await s.send_message(text="hello", user="USER1") - - with pytest.raises(ValueError, match="user and group_uid"): - await s.send_message(text="hello", group_uid="GROUP1") - - -class TestExportMethod: - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.get") - async def test_get_export(self, mock_get, mock_token) -> None: - """Deprecated `Spond.get_event_attendance_xlsx()` should still GET the - export endpoint and return raw bytes (delegates to - `Event.attendance_xlsx()`).""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - # Note: `s.events` is intentionally not pre-populated — the - # deprecated wrapper does a direct GET on the export endpoint, it - # doesn't consult the events cache. - - mock_binary = b"\x68\x65\x6c\x6c\x6f\x77\x6f\x72\x6c\x64" # helloworld - mock_get.return_value.__aenter__.return_value.status = 200 - mock_get.return_value.__aenter__.return_value.read = AsyncMock( - return_value=mock_binary - ) - - import warnings as _warnings - - with _warnings.catch_warnings(record=True) as caught: - _warnings.simplefilter("always") - data = await s.get_event_attendance_xlsx(uid="ID1") - - assert any(issubclass(w.category, DeprecationWarning) for w in caught) - mock_url = "https://api.spond.com/core/v1/sponds/ID1/export" - mock_get.assert_called_once_with( - mock_url, - headers={ - "content-type": "application/json", - "Authorization": f"Bearer {mock_token}", - }, - ) - assert data == mock_binary - - -class TestPostMethods: - MOCK_POSTS: list[JSONDict] = [ - { - "id": "POST1", - "type": "PLAIN", - "groupId": "GID1", - "title": "Post One", - "body": "Body of post one", - "timestamp": "2026-03-03T19:20:00.270Z", - "comments": [], - }, - { - "id": "POST2", - "type": "PLAIN", - "groupId": "GID2", - "title": "Post Two", - "body": "Body of post two", - "timestamp": "2026-02-20T19:21:20.447Z", - "comments": [{"id": "C1", "text": "A comment"}], - }, - ] - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.get") - async def test_get_posts__happy_path(self, mock_get, mock_token) -> None: - """Test that get_posts returns posts from the API.""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - - mock_get.return_value.__aenter__.return_value.ok = True - mock_get.return_value.__aenter__.return_value.json = AsyncMock( - return_value=self.MOCK_POSTS - ) - - posts = await s.get_posts() - - mock_url = "https://api.spond.com/core/v1/posts/" - mock_get.assert_called_once_with( - mock_url, - headers={ - "content-type": "application/json", - "Authorization": f"Bearer {mock_token}", - }, - params={ - "type": "PLAIN", - "max": "20", - "includeComments": "true", - }, - ) - assert posts is not None - assert len(posts) == 2 - assert posts[0].uid == "POST1" - assert posts[0].title == "Post One" - assert posts[1].uid == "POST2" - assert s.posts is posts # cache identity - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.get") - async def test_get_posts__with_group_id(self, mock_get, mock_token) -> None: - """Test that group_id is passed as a query parameter.""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - - mock_get.return_value.__aenter__.return_value.ok = True - mock_get.return_value.__aenter__.return_value.json = AsyncMock( - return_value=[self.MOCK_POSTS[0]] - ) - - posts = await s.get_posts(group_id="GID1") - - mock_get.assert_called_once_with( - "https://api.spond.com/core/v1/posts/", - headers={ - "content-type": "application/json", - "Authorization": f"Bearer {mock_token}", - }, - params={ - "type": "PLAIN", - "max": "20", - "includeComments": "true", - "groupId": "GID1", - }, - ) - assert len(posts) == 1 - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.get") - async def test_get_posts__custom_max(self, mock_get, mock_token) -> None: - """Test that max_posts parameter is respected.""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - - mock_get.return_value.__aenter__.return_value.ok = True - mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) - - await s.get_posts(max_posts=5) - - call_params = mock_get.call_args[1]["params"] - assert call_params["max"] == "5" - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.get") - async def test_get_posts__no_comments(self, mock_get, mock_token) -> None: - """Test that include_comments=False is passed correctly.""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - - mock_get.return_value.__aenter__.return_value.ok = True - mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) - - await s.get_posts(include_comments=False) - - call_params = mock_get.call_args[1]["params"] - assert call_params["includeComments"] == "false" - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.get") - async def test_get_posts__api_error_raises(self, mock_get, mock_token) -> None: - """Test that a failed API response raises ValueError.""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - - mock_get.return_value.__aenter__.return_value.ok = False - mock_get.return_value.__aenter__.return_value.status = 401 - mock_get.return_value.__aenter__.return_value.text = AsyncMock( - return_value="Unauthorized" - ) - - with pytest.raises(ValueError, match="401"): - await s.get_posts() - - -class TestLogin: - @pytest.mark.parametrize( - ("login_result", "expected"), - [ - ( - {"accessToken": {"token": "ABC", "expiration": "2026-05-14T12:00:00Z"}}, - "ABC", - ), - ], - ) - def test_extract_access_token__happy_path(self, login_result, expected) -> None: - assert _SpondBase._extract_access_token(login_result) == expected - - @pytest.mark.parametrize( - "login_result", - [ - {"error": "Invalid credentials"}, - {"accessToken": None}, - {"accessToken": {}}, - {"accessToken": {"token": ""}}, - {"accessToken": {"token": None}}, - ], - ) - def test_extract_access_token__bad_shape_raises(self, login_result) -> None: - with pytest.raises(AuthenticationError): - _SpondBase._extract_access_token(login_result) - - def test_extract_access_token__error_message_drops_sensitive_fields( - self, - ) -> None: - """The exception message must not leak unwhitelisted fields from the - login response (e.g. a 2FA challenge `token` or `phoneNumber`).""" - login_result = { - "token": "TWO_FA_CHALLENGE_TOKEN_VALUE", - "phoneNumber": "****12", - "errorKey": "twoFactorRequired", - } - with pytest.raises(AuthenticationError) as exc_info: - _SpondBase._extract_access_token(login_result) - - message = str(exc_info.value) - assert "TWO_FA_CHALLENGE_TOKEN_VALUE" not in message - assert "phoneNumber" not in message - assert "twoFactorRequired" in message # whitelisted field surfaces - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post") - async def test_login__happy_path(self, mock_post) -> None: - mock_response = { - "accessToken": {"token": "ABC", "expiration": "2026-05-14T12:00:00Z"}, - "refreshToken": {"token": "REF", "expiration": "2026-08-11T12:00:00Z"}, - "passwordToken": {"token": "PWD", "expiration": "2026-05-13T13:00:00Z"}, - } - mock_post.return_value.__aenter__.return_value.json = AsyncMock( - return_value=mock_response - ) - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - await s.login() - - mock_post.assert_called_once_with( - "https://api.spond.com/core/v1/auth2/login", - json={"email": MOCK_USERNAME, "password": MOCK_PASSWORD}, - ) - assert s.token == "ABC" - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post") - async def test_login__error_response_raises(self, mock_post) -> None: - mock_post.return_value.__aenter__.return_value.json = AsyncMock( - return_value={"error": "Invalid credentials"} - ) - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - with pytest.raises(AuthenticationError): - await s.login() - assert s.token is None - - -class TestRequireAuthenticationDecorator: - """The `require_authentication` decorator must preserve the wrapped - method's metadata (signature, docstring, name) so `inspect`-based - tools — pdoc, IDE help, tab completion — see the real method - rather than the wrapper's `(*args, **kwargs)` shim. - """ - - def test_decorator_preserves_signature(self) -> None: - """Decorated methods must expose their real parameter list.""" - import inspect - - # `get_posts` is decorated and has a distinctive signature - params = list(inspect.signature(Spond.get_posts).parameters) - assert params == ["self", "group_id", "max_posts", "include_comments"] - - def test_decorator_preserves_docstring(self) -> None: - """Decorated methods must expose their own docstring, not the - wrapper's.""" - import inspect - - doc = inspect.getdoc(Spond.get_profile) or "" - # Wrapper docstring would start with 'Decorator that...' if leaked. - assert "Retrieve the authenticated user's profile." in doc - - def test_decorator_preserves_name(self) -> None: - """`__name__` must be the method's, not 'wrapper'.""" - assert Spond.get_events.__name__ == "get_events" - - -# ============================================================================= -# OO rewrite tests — typed-object ActiveRecord surface, dict-compat shim, -# inter-dependency navigation. See DESIGN-oo-rewrite.md for context. -# ============================================================================= - - -class TestDictCompat: - """The DictCompatModel shim makes typed models behave like the dicts they - replaced, with a DeprecationWarning on subscript and `.get()`.""" - - def test_subscript_via_alias_warns_and_returns_value(self) -> None: - import warnings as _w - - e = Event.model_validate(_MIN_EVENT_PAYLOAD) - with _w.catch_warnings(record=True) as caught: - _w.simplefilter("always") - value = e["id"] # API alias - assert value == "ID1" - assert any(issubclass(w.category, DeprecationWarning) for w in caught) - - def test_subscript_via_python_name_warns_and_returns_value(self) -> None: - import warnings as _w - - e = Event.model_validate(_MIN_EVENT_PAYLOAD) - with _w.catch_warnings(record=True) as caught: - _w.simplefilter("always") - value = e["heading"] - assert value == "Event One" - assert any(issubclass(w.category, DeprecationWarning) for w in caught) - - def test_subscript_missing_key_raises_keyerror(self) -> None: - e = Event.model_validate(_MIN_EVENT_PAYLOAD) - with pytest.raises(KeyError): - _ = e["does_not_exist"] - - def test_get_with_default_returns_default_for_missing_key(self) -> None: - e = Event.model_validate(_MIN_EVENT_PAYLOAD) - sentinel = object() - assert e.get("does_not_exist", sentinel) is sentinel - - def test_contains_works_for_alias_and_python_name(self) -> None: - e = Event.model_validate(_MIN_EVENT_PAYLOAD) - assert "id" in e # alias - assert "heading" in e # python name - assert "startTimestamp" in e # alias - assert "start_time" in e # python name - assert "does_not_exist" not in e - - def test_iter_yields_api_shaped_keys(self) -> None: - e = Event.model_validate(_MIN_EVENT_PAYLOAD) - keys = list(e) - assert "id" in keys # alias, not "uid" - assert "startTimestamp" in keys # alias, not "start_time" - - def test_len_contains_and_iter_agree(self) -> None: - """`__len__`, `__contains__`, and `__iter__` must all reflect the - same view of "what's actually in this object" — pre-OO callers - relied on dict semantics where these three are always consistent.""" - e = Event.model_validate(_MIN_EVENT_PAYLOAD) - keys = list(e) - assert len(e) == len(keys) - for k in keys: - assert k in e - # A field with a default that wasn't in the source data must NOT - # appear in any of the three views. - assert "description" not in e # not in _MIN_EVENT_PAYLOAD - assert "description" not in keys - - def test_extra_allow_preserves_unmodelled_fields(self) -> None: - """With `model_config = extra="allow"`, Spond-side fields the SDK - doesn't model are preserved on the instance and accessible via the - dict-compat shim (with deprecation warning).""" - import warnings as _w - - payload = {**_MIN_EVENT_PAYLOAD, "futureSpondField": "preserved"} - e = Event.model_validate(payload) - # Iteration includes the extra - assert "futureSpondField" in e - assert "futureSpondField" in list(e) - with _w.catch_warnings(record=True) as caught: - _w.simplefilter("always") - value = e["futureSpondField"] - assert value == "preserved" - assert any(issubclass(w.category, DeprecationWarning) for w in caught) - - def test_models_survive_missing_optional_fields(self) -> None: - """A previously-required field dropping to a default must not crash - the typed model. Locks in the resilience relaxation done after - rounds of review feedback.""" - from spond.person import Member - from spond.post import Post - from spond.profile import Profile - - # Member with no name fields — used to crash, now defaults to "" - m = Member.model_validate({"id": "M1"}) - assert m.first_name == "" - assert m.last_name == "" - # Post without timestamp — used to crash, now None - p = Post.model_validate({"id": "P1"}) - assert p.timestamp is None - # __str__ must not raise even though timestamp is None — guards the - # AttributeError that resilience relaxation otherwise re-introduced. - assert "?" in str(p) - # Profile with no name fields — same relaxation - pr = Profile.model_validate({"id": "PR1"}) - assert pr.first_name == "" - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post") - async def test_event_update_excludes_unset_default_collections( - self, mock_post, mock_token - ) -> None: - """`Event.update()` must NOT send empty-list defaults for fields - the source API didn't include (e.g. `owners=[]`, `attachments=[]`). - Spond could interpret an explicit empty list as 'clear all - owners', overwriting concurrent server-side changes. - Regression guard for the `exclude_unset=True` fix.""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - # _MIN_EVENT_PAYLOAD doesn't include `owners` or `attachments`, so - # the Event has them at their default ([]). They must NOT round-trip. - event = Event.from_api(_MIN_EVENT_PAYLOAD, s) - assert event.owners == [] - assert event.attachments == [] - - mock_post.return_value.__aenter__.return_value.json = AsyncMock( - return_value=_MIN_EVENT_PAYLOAD - ) - await event.update(heading="Renamed") - - posted = mock_post.call_args[1]["json"] - # The caller's update was applied - assert posted["heading"] == "Renamed" - # Empty-list defaults were NOT sent (because they weren't in the source) - assert "owners" not in posted - assert "attachments" not in posted - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post") - async def test_event_update_excludes_none_fields( - self, mock_post, mock_token - ) -> None: - """`Event.update()` must NOT send `null` for optional fields that - Spond didn't populate — Spond could interpret `null` as 'clear this - field' rather than 'leave unchanged'.""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - # _MIN_EVENT_PAYLOAD has no `description`, so Event.description=None. - event = Event.from_api(_MIN_EVENT_PAYLOAD, s) - assert event.description is None - - mock_post.return_value.__aenter__.return_value.json = AsyncMock( - return_value=_MIN_EVENT_PAYLOAD - ) - await event.update(heading="Renamed") - - # The POST payload must contain `heading` (caller updated it) but - # NOT `description` (it was None and shouldn't be cleared). - posted = mock_post.call_args[1]["json"] - assert posted["heading"] == "Renamed" - assert "description" not in posted - - def test_extra_allow_on_profile_and_group(self) -> None: - """`extra="allow"` must work uniformly across top-level types, not - just Event — this is the forward-compat invariant for the whole - public surface.""" - from spond.profile import Profile - - p = Profile.model_validate( - {"id": "P1", "firstName": "A", "lastName": "B", "newSpondField": 42} - ) - assert "newSpondField" in p - # `__pydantic_extra__` is the portable accessor for `extra="allow"` - # extras — native attribute access (`p.newSpondField`) works on - # current Pydantic 2.x but behaviour varies subtly across minor - # versions for camelCase keys, so this is the version-stable form. - assert p.__pydantic_extra__["newSpondField"] == 42 - - g = Group.model_validate({"id": "G1", "name": "GG", "newGroupAttr": ["x"]}) - assert "newGroupAttr" in g - assert g.__pydantic_extra__["newGroupAttr"] == ["x"] - - -class TestEventOOMethods: - """ActiveRecord methods on Event.""" - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post") - async def test_event_update_returns_new_event(self, mock_post, mock_token) -> None: - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - event = Event.from_api(_MIN_EVENT_PAYLOAD, s) - - api_response = {**_MIN_EVENT_PAYLOAD, "heading": "Updated"} - mock_post.return_value.__aenter__.return_value.json = AsyncMock( - return_value=api_response - ) - - result = await event.update(heading="Updated") - assert isinstance(result, Event) - assert result.heading == "Updated" - assert result is not event # immutable: returns new - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post") - async def test_event_update_accepts_positional_dict( - self, mock_post, mock_token - ) -> None: - """`event.update(updates_dict)` works for keys that would clash with - reserved kwargs when passed via `**` (e.g. `self`, `cls`).""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - event = Event.from_api(_MIN_EVENT_PAYLOAD, s) - - api_response = {**_MIN_EVENT_PAYLOAD, "heading": "New"} - mock_post.return_value.__aenter__.return_value.json = AsyncMock( - return_value=api_response - ) - - # Use the positional dict form - result = await event.update({"heading": "New", "selfish": "any"}) - assert result.heading == "New" - # Unknown key "selfish" was passed through to the API payload - posted = mock_post.call_args[1]["json"] - assert posted["selfish"] == "any" - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post") - async def test_event_update_refreshes_client_cache( - self, mock_post, mock_token - ) -> None: - """After `event.update()`, the client's events cache must hold the - new instance — not the stale pre-update one — so subsequent - `spond.get_event(uid)` calls return current state.""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - original = Event.from_api(_MIN_EVENT_PAYLOAD, s) - s.events = [original] - - api_response = {**_MIN_EVENT_PAYLOAD, "heading": "Refreshed"} - mock_post.return_value.__aenter__.return_value.json = AsyncMock( - return_value=api_response - ) - - result = await original.update(heading="Refreshed") - # Cache must now point at the new instance (in-place replacement - # preserves the list identity for callers holding `s.events`). - assert s.events is not None - assert s.events[0] is result - assert s.events[0].heading == "Refreshed" - assert s.events[0] is not original - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.put") - async def test_event_change_response_accepts(self, mock_put, mock_token) -> None: - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - event = Event.from_api(_MIN_EVENT_PAYLOAD, s) - - mock_put.return_value.__aenter__.return_value.json = AsyncMock( - return_value={"acceptedIds": ["MID1"]} - ) - result = await event.change_response("MID1", accepted=True) - assert result == {"acceptedIds": ["MID1"]} - call_args = mock_put.call_args - assert call_args[0][0].endswith("/sponds/ID1/responses/MID1") - assert call_args[1]["json"]["accepted"] == "true" - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.put") - async def test_event_change_response_declines_with_message( - self, mock_put, mock_token - ) -> None: - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - event = Event.from_api(_MIN_EVENT_PAYLOAD, s) - - mock_put.return_value.__aenter__.return_value.json = AsyncMock( - return_value={"declinedIds": ["MID1"]} - ) - await event.change_response("MID1", accepted=False, decline_message="busy") - sent = mock_put.call_args[1]["json"] - assert sent["accepted"] == "false" - assert sent["declineMessage"] == "busy" - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.get") - async def test_event_attendance_xlsx_returns_bytes( - self, mock_get, mock_token - ) -> None: - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - event = Event.from_api(_MIN_EVENT_PAYLOAD, s) - - mock_get.return_value.__aenter__.return_value.read = AsyncMock( - return_value=b"PKxlsx-bytes" - ) - data = await event.attendance_xlsx() - assert data == b"PKxlsx-bytes" - assert mock_get.call_args[0][0].endswith("/sponds/ID1/export") - - -class TestGroupNavigation: - """Group → Member → Guardian wiring.""" - - def test_group_materializes_typed_members_and_guardians(self) -> None: - raw = { - "id": "GID", - "name": "Test Group", - "members": [ - { - "id": "M1", - "firstName": "Alice", - "lastName": "Smith", - "email": "alice@example.invalid", - "guardians": [ - { - "id": "G1", - "firstName": "Bob", - "lastName": "Smith", - "phoneNumber": "+1", - } - ], - }, - ], - } - from spond.person import Guardian, Member - - group = Group.model_validate(raw) - assert isinstance(group.members[0], Member) - assert group.members[0].full_name == "Alice Smith" - assert isinstance(group.members[0].guardians[0], Guardian) - assert group.members[0].guardians[0].full_name == "Bob Smith" - - def test_find_member_by_email(self) -> None: - group = Group.model_validate( - { - "id": "GID", - "name": "G", - "members": [ - { - "id": "M1", - "firstName": "A", - "lastName": "B", - "email": "a@b.invalid", - }, - ], - } - ) - found = group.find_member(email="a@b.invalid") - assert found is not None - assert found.uid == "M1" - - def test_find_member_returns_none_when_no_match(self) -> None: - group = Group.model_validate({"id": "GID", "name": "G", "members": []}) - assert group.find_member(uid="missing") is None - - def test_find_member_requires_exactly_one_criterion(self) -> None: - group = Group.model_validate({"id": "GID", "name": "G", "members": []}) - with pytest.raises(ValueError, match="exactly one"): - group.find_member() - with pytest.raises(ValueError, match="exactly one"): - group.find_member(uid="X", email="a@b.invalid") - - def test_member_custom_fields_alias_works_via_either_name(self) -> None: - """`Member.custom_fields` aliases the API's `"fields"` key — both - forms must populate the attribute identically.""" - from spond.person import Member - - # API-style (via alias): - m1 = Member.model_validate( - {"id": "M1", "firstName": "A", "lastName": "B", "fields": {"height": "175"}} - ) - # Python-style (via name): - m2 = Member.model_validate( - { - "id": "M2", - "firstName": "C", - "lastName": "D", - "custom_fields": {"height": "180"}, - } - ) - assert m1.custom_fields == {"height": "175"} - assert m2.custom_fields == {"height": "180"} - - -class TestMatch: - """Match (Event subclass) dispatch and field parsing.""" - - _MATCH_PAYLOAD = { - **_MIN_EVENT_PAYLOAD, - "matchEvent": True, - "matchInfo": { - "teamName": "Home FC", - "opponentName": "Away FC", - "type": "HOME", - "teamScore": 2, - "opponentScore": 1, - "scoresSet": True, - "scoresFinal": True, - "scoresSetEver": True, - "scoresPublic": True, - }, - } - - def test_dispatch_returns_match_when_match_event_true(self) -> None: - """`Spond.get_events()` constructs `Match` (Event subclass) when the - raw payload has `matchEvent=True`, plain `Event` otherwise.""" - from spond.match import Match, MatchInfo - from spond.spond import _typed_event - - # _typed_event doesn't call methods on the client, so None is fine - # for the dispatch + parsing assertions below. - regular = _typed_event(_MIN_EVENT_PAYLOAD, None) - m = _typed_event(self._MATCH_PAYLOAD, None) - - assert type(regular) is Event - assert isinstance(m, Match) - assert isinstance(m, Event) # subclass relationship - assert isinstance(m.match_info, MatchInfo) - assert m.match_info.team_name == "Home FC" - assert m.match_info.opponent_name == "Away FC" - assert m.match_info.type == "HOME" - assert m.match_info.team_score == 2 - assert m.match_info.opponent_score == 1 - assert m.match_info.scores_final is True - assert m.match_info.scores_public is True - - def test_match_score_update_path_is_through_event_update(self) -> None: - """Match inherits Event.update; the `match_info` field must be - included in the POST payload (not in _EVENT_READ_ONLY_FIELDS), so - callers can edit scores via `match.update(matchInfo={...})`.""" - from spond.event import _EVENT_READ_ONLY_FIELDS - from spond.match import Match - - assert "match_info" not in _EVENT_READ_ONLY_FIELDS - # And it really is a declared field on Match (vs an unmodelled - # passthrough via extra="allow"): - assert "match_info" in Match.model_fields - - def test_match_info_optional_for_resilience(self) -> None: - """A future API variant emitting matchEvent=True without matchInfo - (or a half-populated match record) must not crash construction.""" - from spond.match import Match - from spond.spond import _typed_event - - bare = _typed_event({**_MIN_EVENT_PAYLOAD, "matchEvent": True}, None) - assert isinstance(bare, Match) - assert bare.match_info is None - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post") - async def test_match_update_preserves_match_type( - self, mock_post, mock_token - ) -> None: - """`match.update(...)` must return a `Match` instance, not a plain - `Event` — otherwise subclass identity is silently dropped and the - cache replacement loop demotes the entry to a non-Match. Regression - guard for the `type(self).from_api(...)` fix.""" - from spond.match import Match - from spond.spond import _typed_event - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - original = _typed_event(self._MATCH_PAYLOAD, s) - assert isinstance(original, Match) - s.events = [original] - - response = {**self._MATCH_PAYLOAD, "heading": "Updated Match"} - mock_post.return_value.__aenter__.return_value.json = AsyncMock( - return_value=response - ) - - result = await original.update(heading="Updated Match") - assert isinstance(result, Match), f"Got {type(result).__name__}, expected Match" - # And the cache entry got swapped to the new Match instance, not a demoted Event. - assert isinstance(s.events[0], Match) - assert s.events[0] is result From 22fb02d8d2ee72f24a3b06f681cf9a2aaffe4149 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 21:11:24 +0200 Subject: [PATCH 20/42] docs(oo-rewrite): align design doc with shipped implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc was written pre-implementation; six review rounds and a chat addition later, several sections had drifted from reality. Bring it back in sync: - Status: "open for feedback" → "implementation complete; under review" - Class name: DictCompatMixin → DictCompatModel everywhere - Type inventory: add Chat/Message; expand Member, Group, Profile with fields surfaced during the live-API drift audit; note LenientDate and _EVENT_READ_ONLY_FIELDS; capture EventType-as-StrEnum-vs-Event.type-as-str - Backward compat: clarify that send_message stays (chat-thread send lives on Chat.send), only the three event wrappers deprecate - get_* table: add get_messages → list[Chat]; note Match dispatch on get_event/get_events - Open questions: drop resolved ones, add the new follow-ups (typed Comment, full chat history) - Files: add spond/chat.py; replace the tests/test_spond.py line with the new split layout Co-Authored-By: Claude Opus 4.7 (1M context) --- DESIGN-oo-rewrite.md | 188 ++++++++++++++++++++++++++----------------- 1 file changed, 114 insertions(+), 74 deletions(-) diff --git a/DESIGN-oo-rewrite.md b/DESIGN-oo-rewrite.md index db171ee..1cc7cc3 100644 --- a/DESIGN-oo-rewrite.md +++ b/DESIGN-oo-rewrite.md @@ -1,18 +1,16 @@ # Spond OO rewrite — design -**Status:** open for feedback — work in progress on branch `feat/oo-rewrite` +**Status:** implementation complete on branch `feat/oo-rewrite`; under review **Last updated:** 2026-05-14 **Scope:** First-class typed objects with ActiveRecord behaviour, replacing the dict-based return surface across `spond.spond.Spond` and `spond.club.SpondClub`. ## Feedback welcome -This document is the design we're proposing for the long-discussed object-oriented rewrite of the SDK. It's the spec — not the code yet. Comments, pushback, and suggestions on any section are welcome before the implementation lands. +This document captured the design as it was brainstormed, and has been kept in sync with what shipped. The shape, deprecation path, and inventory in this file now reflect the implementation merged onto `feat/oo-rewrite`. The "Open questions" section near the end lists what was deliberately deferred to a follow-up release. - **For high-level concerns** (API shape, scope, deprecation path) — open an issue titled `OO rewrite: …` or comment on the tracking PR. -- **For specific wording or examples** — review the draft PR (link will be added here once it's open) and comment inline. -- **For deeper design questions** — see the "Open questions" section near the end; we'd like to settle those before the implementation locks them in. - -Decisions captured here have been agreed in principle but are still revisable as long as v1.3 hasn't shipped. +- **For deeper design questions** — see the "Open questions" section near the end. +- **For implementer-facing notes** (field-drift audit, subclass discipline, update-payload rules) — see "Implementation notes for maintainers" at the bottom. ## Motivation @@ -37,84 +35,110 @@ The OO rewrite addresses three things at once: ## Type inventory +Every typed model extends `DictCompatModel`, which itself extends Pydantic's `BaseModel`. The dict-compatibility shim and the `LenientDate` annotated type live in `spond/_compat.py`; everything below inherits from it. + ``` -Person (base, BaseModel + DictCompatMixin) - ├─ uid, first_name, last_name, email (optional), profile (optional) +Person (base, DictCompatModel) + ├─ uid, first_name, last_name, email (optional), profile (optional), + │ phone_number (optional) ├─ full_name (property) │ ├─ Member(Person) - │ ├─ guardians: list[Guardian] - │ ├─ (subgroup memberships, roles — TBD during impl based on actual API shape) + │ ├─ email, date_of_birth (LenientDate — tolerates Spond's malformed + │ │ "2012-03-99"-style values), created_time, guardians, + │ │ role_uids, subgroup_uids, respondent, custom_fields (alias "fields") │ └─ methods: send_message(text, group_uid) │ └─ Guardian(Person) - ├─ (link to managed member if API exposes it) - └─ methods: send_message(text, group_uid) — routes to guardian - -Event(BaseModel + DictCompatMixin) - ├─ uid, heading, start_time, end_time, type: EventType, owners, recipients, ... - ├─ responses: Responses - ├─ methods: update(**fields), change_response(member_uid, *, accepted, decline_message=None), - │ attendance_xlsx() -> bytes + └─ methods: send_message(text, group_uid) + +Event(DictCompatModel) + ├─ uid, heading, start_time, end_time, type: str (compared against EventType), + │ owners, recipients, responses, comments, behalf_of_uids, ... + ├─ methods: update(_updates=None, /, **fields), change_response(member_uid, *, + │ accepted, decline_message=None), attendance_xlsx() -> bytes │ └─ Match(Event) — sports fixtures ├─ match_info: MatchInfo | None — team/opponent names, scores, HOME/AWAY └─ Spond.get_events() / get_event() return Match (not plain Event) - when the API record has matchEvent=True. + when the API record has matchEvent=True. Dispatch lives in + spond.spond._typed_event. Responses (sub-object of Event) - ├─ accepted_uids, declined_uids, unanswered_uids, waiting_list_uids, unconfirmed_uids - │ — all list[str] (raw UIDs) - └─ (no methods; resolution to Member objects requires Group context — see Open Questions) - -EventType (Enum) - └─ AVAILABILITY, EVENT, RECURRING (extend as we encounter more) - -Group(BaseModel + DictCompatMixin) - ├─ uid, name, members: list[Member], subgroups: list[Subgroup], roles: list[Role] + ├─ accepted_uids, declined_uids, unanswered_uids, waiting_list_uids, + │ unconfirmed_uids — all list[str] (raw UIDs) + ├─ decline_messages: dict + └─ (no methods; resolution to Member objects requires Group context — + see Open Questions) + +EventType (StrEnum, canonical reference) + └─ AVAILABILITY, EVENT, RECURRING + The `Event.type` field itself stays a `str` so Spond can introduce + new variants without crashing validation; EventType is a typed lookup + for callers writing comparisons. + +Group(DictCompatModel) + ├─ uid, name, members: list[Member], subgroups: list[Subgroup], roles: list[Role], + │ plus the full set of fields surfaced by the live API audit + │ (created_time, member_permissions, guardian_permissions, chat_age_limit, + │ share_contact_info, address_format, ...) └─ methods: find_member(*, email=None, name=None, uid=None) -> Member | None + (`from_api` wires `_client` through nested Members/Guardians) -Subgroup, Role (BaseModel + DictCompatMixin) +Subgroup, Role (DictCompatModel) └─ uid, name (passive data, no methods) -Profile(BaseModel + DictCompatMixin) - └─ uid, first_name, last_name (passive) +Profile(DictCompatModel) + └─ uid, first_name, last_name, plus the live-audited extras (passive) -Post(BaseModel + DictCompatMixin) +Post(DictCompatModel) ├─ uid, title, body, timestamp, comments: list[dict] - └─ (no methods yet; add_comment(...) deferred until we verify the Spond API supports it) - -Comment — deferred to a follow-up - └─ Modelling Post comments as a typed `Comment` class is on the roadmap - but not in this PR; `Post.comments` currently exposes them as raw - dicts (`list[dict[str, Any]]`). - -Transaction(BaseModel + DictCompatMixin) - └─ uid, paid_at, payment_name, paid_by_name (passive, Spond Club only) + └─ (no methods yet; add_comment(...) deferred — see Open Questions) + +Chat(DictCompatModel) + ├─ uid, name, type, participants, newest_timestamp, unread, muted, + │ community, message: Message | None + └─ methods: send(text) -> dict + (Routes through the chat-server host and chat-auth token; lazy + handshake on first call via the existing `Spond._login_chat`.) + +Message(DictCompatModel) — sub-object of Chat (most-recent message only) + ├─ chat_id, msg_num, type: str, timestamp, reactions, text, user + └─ type-specific optional payload fields: new_name (RENAME), images (IMAGES), + internal_promo (INTERNAL_PROMO), campaign (CAMPAIGN), spond (SPOND). + Anything Spond adds later passes through extra="allow". + +Comment — deferred to a follow-up (still applies) + └─ `Post.comments` exposes them as raw dicts (`list[dict[str, Any]]`). + +Transaction(DictCompatModel) + └─ uid, paid_at, payment_name, paid_by_name (passive, Spond Club only; + uses extra="allow" like every other top-level type for forward-compat). ``` -Each typed model has a Pydantic `PrivateAttr` for the Spond/SpondClub client: +Each typed model with operations carries a Pydantic `PrivateAttr` for the Spond/SpondClub client: ```python -_client: Optional["Spond"] = PrivateAttr(default=None) +_client: Any = PrivateAttr(default=None) ``` -Construction sites set this via a `from_api(data, client)` classmethod. PrivateAttr keeps it out of `model_dump()` and pdoc. +Construction sites set this via a `from_api(data, client)` classmethod. `PrivateAttr` keeps it out of `model_dump()` and pdoc. Passive types (Subgroup, Role, Profile, Responses, MatchInfo) omit the client since they don't issue HTTP themselves. ## Backward compatibility ### Dict-subscript shim -`DictCompatMixin` gives every typed model dict-like behaviour: +`DictCompatModel` (in `spond/_compat.py`) gives every typed model dict-like behaviour: - `event["heading"]` works, emits `DeprecationWarning` - `event["startTimestamp"]` works (alias-aware), emits `DeprecationWarning` - `event.get("heading", default)` works, emits warning - `"heading" in event` works -- `for key in event` iterates the API-field names (camelCase) -- `len(event)` returns the number of fields +- `for key in event` iterates the API-field names (camelCase) actually populated on this instance +- `len(event)` returns the same count as the iterator +- `keys()` / `values()` / `items()` mirror dict semantics, scoped to populated fields -Implementation: the mixin reads `cls.model_fields` to discover both the Python attribute name and the alias, and dispatches subscript access through to attribute access. +Implementation: the base class reads `cls.model_fields` to discover both the Python attribute name and the alias, then routes subscript access through to attribute access. The "what's actually present" view is built from `model_fields_set ∪ __pydantic_extra__` so iteration and `len()` reflect only fields that were populated from the source data — fields sitting at their default values don't leak into the dict-compat surface. ### Strict-equality test patterns @@ -136,7 +160,7 @@ This is part of the PR (one test class affected, ~5 assertions). ### Legacy write methods -`Spond.update_event`, `Spond.change_response`, `Spond.get_event_attendance_xlsx`, `Spond.send_message` stay in v1.x — they emit `DeprecationWarning` pointing at the new method, then delegate internally: +`Spond.update_event`, `Spond.change_response`, `Spond.get_event_attendance_xlsx` stay in v1.x — they emit `DeprecationWarning` pointing at the new method, then delegate internally. `Spond.send_message` is **not** deprecated: it remains the entrypoint for sending a one-off message to a user (the chat-thread send is exposed on `Chat.send(text)` for callers already holding a `Chat` object). ```python async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: @@ -149,11 +173,11 @@ async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: return await event.update(**updates) ``` -All four are removed in v2.0. +The three deprecated wrappers are removed in v2.0. ## Spond.get_* return-type changes -The seven methods that currently return `JSONDict` / `list[JSONDict] | None` change their return type but keep their names and signatures: +Every `get_*` method now returns typed objects; names and signatures are unchanged: | Method | Before | After | |---|---|---| @@ -161,60 +185,76 @@ The seven methods that currently return `JSONDict` / `list[JSONDict] | None` cha | `get_groups()` | `list[JSONDict] \| None` | `list[Group] \| None` | | `get_group(uid)` | `JSONDict` | `Group` | | `get_person(user)` | `JSONDict` | `Person` (concretely Member or Guardian) | -| `get_events(...)` | `list[JSONDict] \| None` | `list[Event] \| None` | -| `get_event(uid)` | `JSONDict` | `Event` | +| `get_events(...)` | `list[JSONDict] \| None` | `list[Event] \| None` (`Match` for match events) | +| `get_event(uid)` | `JSONDict` | `Event` or `Match` | | `get_posts(...)` | `list[JSONDict] \| None` | `list[Post] \| None` | +| `get_messages(...)` | `list[JSONDict] \| None` | `list[Chat] \| None` | | `SpondClub.get_transactions(...)` | `list[JSONDict]` | `list[Transaction]` | -Dict-style consumers still work through the DictCompatMixin (with warning). +Dict-style consumers still work through `DictCompatModel` (with warning). -## Open questions (not blocking) +## Open questions / follow-up -1. **Member ↔ UID resolution in Responses.** `Event.responses.accepted_uids` is `list[str]` not `list[Member]`. Resolving requires Group context, which Events only have via `recipients`/`groupId`. Add a helper `await event.accepted_members(spond)` that fetches the group and walks members — lazy, opt-in, no surprise HTTP from attribute reads. -2. **Guardian.managed_member.** If the API doesn't expose a back-link, Guardian is constructed inside `Member.guardians` and the parent reference can be added post-hoc by the Member constructor. Decide during impl based on actual API shape. -3. **Post.add_comment.** Probe whether Spond's API supports comment-add via `POST sponds/posts/{uid}/comments` or similar. If yes, add the method; if no, document as read-only. -4. **Send-message semantics for Guardian vs Member.** Verify whether the message routes differently based on recipient kind — may require different payload shapes. +These were deferred from this PR; they're tracked here as roadmap items. -All four are answerable mid-impl with live API probing using credentials at `/home/olen/prog/spond-kalender/config.py`. +1. **Member ↔ UID resolution in Responses.** `Event.responses.accepted_uids` is still `list[str]`, not `list[Member]`. Resolving requires Group context, which Events only have via `recipients` / `groupId`. A future helper `await event.accepted_members(spond)` that fetches the group and walks members — lazy, opt-in, no surprise HTTP from attribute reads — is the planned shape. +2. **Guardian.managed_member back-link.** Not yet exposed. Guardians are currently constructed inside `Member.guardians`; a post-hoc parent reference can be added if a downstream caller asks for it. +3. **Post.add_comment.** Not modelled. `Post.comments` is read-only `list[dict]`. Adding the write side depends on probing the API for the right endpoint. +4. **Typed `Comment`.** Modelling comments themselves as a typed class (rather than `list[dict]`) is a natural next step once `Post.add_comment` is in. +5. **Full chat history.** `Chat.message` only carries the most-recent message; the chat API has additional endpoints for older messages that aren't modelled yet. + +All five remain answerable with live API probing using the credentials at `/home/olen/prog/spond-kalender/config.py`. ## Files **New:** -- `spond/_compat.py` — `DictCompatMixin` -- `spond/event.py` — `Event`, `Responses`, `EventType` +- `spond/_compat.py` — `DictCompatModel`, `LenientDate` +- `spond/event.py` — `Event`, `Responses`, `EventType`, `_EVENT_READ_ONLY_FIELDS` - `spond/match.py` — `Match` (Event subclass), `MatchInfo` - `spond/person.py` — `Person`, `Member`, `Guardian` - `spond/group.py` — `Group` - `spond/subgroup.py` — `Subgroup` - `spond/role.py` — `Role` - `spond/profile.py` — `Profile` -- `spond/post.py` — `Post` (Comment deferred — see Type Inventory above) +- `spond/post.py` — `Post` (typed `Comment` deferred — see Open Questions) +- `spond/chat.py` — `Chat`, `Message` **Changed:** -- `spond/spond.py` — `get_*` methods return typed objects; legacy write methods get deprecation wrappers +- `spond/spond.py` — `get_*` methods return typed objects; legacy write methods get deprecation wrappers; `_typed_event` dispatches Event vs. Match - `spond/club.py` — `Transaction` model added; `get_transactions` returns `list[Transaction]` - `pyproject.toml` — `pydantic = ">=2.0"` added to runtime deps -- `tests/test_spond.py` — strict-equality assertions adapted; new tests for ActiveRecord methods, dict-compat, inter-dependencies - `README.md` — examples updated to OO style +**Tests:** +The previous monolithic `tests/test_spond.py` has been split by domain. The new layout: +- `tests/conftest.py` — shared fixtures, constants, the `_SpondBase.require_authentication` monkey-patch +- `tests/test_auth.py` — login flow + `require_authentication` decorator metadata +- `tests/test_compat.py` — `DictCompatModel` shim + Event-update payload regression guards +- `tests/test_events.py` — `Event.get_event`, deprecated wrappers, OO `Event` methods, `Match` subclass +- `tests/test_export.py` — deprecated `get_event_attendance_xlsx` wrapper +- `tests/test_groups.py` — `get_group` + Group → Member → Guardian navigation +- `tests/test_messaging.py` — `Spond.send_message` + `Chat`/`Message` +- `tests/test_posts.py` — `get_posts` query construction, caching, error surfacing + ## Out of scope -- `Spond.get_messages` and the chat machinery — chats are tangled, leave on the dict-based path. Possible v1.4 follow-up. -- Removing `self.events_update` (a pre-existing latent attribute that was already cleaned up before this PR — out of scope here). +- Removing `self.events_update` (a pre-existing latent attribute that was already cleaned up before this PR). - Adding new HTTP endpoints. This is a re-shaping of the existing surface only. - Renaming any `Spond.get_*` method — name stability matters more than name perfection. -## Test plan +## Test plan (what shipped) -- All existing tests pass (with the strict-equality adaptations). -- New tests for each ActiveRecord method (HTTP-mocked, asserting URL + payload + return value). -- New tests for `DictCompatMixin`: subscript works, warning fires, alias-mapped subscripts work. -- New tests for inter-dep navigation: `group.members[0]` is a `Member`, `member.guardians[0]` is a `Guardian`, etc. -- Manual smoke test of `examples/manual_test_functions.py` against live API. +- All pre-existing tests pass (with strict-equality assertions adapted). +- ActiveRecord methods: HTTP-mocked tests asserting URL + payload + return value (`tests/test_events.py`, `tests/test_messaging.py`, `tests/test_export.py`). +- `DictCompatModel` shim: subscript works, warning fires, alias-mapped subscripts work, `__len__`/`__contains__`/`__iter__` agree (`tests/test_compat.py`). +- Inter-dep navigation: `group.members[0]` is a `Member`, `member.guardians[0]` is a `Guardian`, etc. (`tests/test_groups.py`). +- Subclass identity through update: `Match.update(...)` returns a `Match`, not a demoted `Event` (`tests/test_events.py::TestMatch::test_match_update_preserves_match_type`). +- Forward-compat: unmodelled fields survive a roundtrip via `extra="allow"` and are reachable through `__pydantic_extra__` (`tests/test_compat.py`). +- Manual smoke test of `examples/manual_test_functions.py` against the live API, plus the periodic field-drift audit (see implementation notes). ## Versioning -Land as v1.3 — minor bump (return-type change is technically breaking, but the DictCompatMixin makes it soft). Legacy `Spond.*_event*` methods removed in v2.0 after a grace period. +Land as v1.3 — minor bump (return-type change is technically breaking, but `DictCompatModel` makes it soft). Legacy `Spond.*_event*` methods removed in v2.0 after a grace period. ## Implementation notes for maintainers From d692fab4ca07458eeb82d57804cf87055db6c1fa Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 21:22:12 +0200 Subject: [PATCH 21/42] fix(oo-rewrite): address round-11 Copilot review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three actionable items from the review of the chat/test-split push. 1. `tests/conftest.py` — drop the broken `_SpondBase.require_authentication = mock_require_authentication(...)` monkey-patch. It was a no-op (the decorator was already applied at class-definition time before conftest runs) and reassigning the attribute afterwards has no effect. Tests pass without it because every test sets `s.token = mock_token`, which short-circuits the real decorator's auth check. 2. `spond/event.py` — clarify in the `Event.update()` docstring that `_EVENT_READ_ONLY_FIELDS` filters only the dumped current state, not caller-supplied kwargs. A caller who explicitly passes `responses={...}` or `creatorId="X"` is on their own; the filter exists to stop the SDK from silently round-tripping stale local state, not to police explicit caller intent. 3. `spond/spond.py` — extend the deprecated `Spond.update_event()` docstring to spell out the return-shape semantics (`model_dump(..., exclude_unset=True)` reflects "fields populated during Pydantic validation of the response plus `extra="allow"` extras", not literally "every key Spond sent"). Equivalent in current config, but worth pinning against future drift. Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/event.py | 9 +++++++++ spond/spond.py | 8 +++++++- tests/conftest.py | 31 ++++++++++--------------------- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/spond/event.py b/spond/event.py index 66b8f90..3c610e0 100644 --- a/spond/event.py +++ b/spond/event.py @@ -235,6 +235,15 @@ async def update( caller-supplied updates. `mode="json"` converts datetimes to ISO strings so aiohttp's `json.dumps` can serialise the payload. + `_EVENT_READ_ONLY_FIELDS` (server-managed timestamps, derived flags, + nested sub-resources like `responses` and `comments`) are stripped + from the *dumped current state* only. Caller-supplied kwargs are + **not** gated — a caller who explicitly passes `responses={...}` or + `creatorId="X"` will see those keys reach Spond, and Spond decides + whether to accept them. The filter exists to prevent the SDK from + silently round-tripping stale local state, not to police explicit + caller intent. + Parameters ---------- _updates : dict, positional-only, optional diff --git a/spond/spond.py b/spond/spond.py index defbc18..0f8d2cd 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -657,7 +657,13 @@ async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: Delegates to `Event.update()`; unknown keys in `updates` pass through to Spond verbatim (Spond decides what it accepts, the SDK doesn't gate). Returns the updated event as a dict for shape parity with the - pre-OO API. Emits `DeprecationWarning`. + pre-OO API. The returned dict reflects the fields populated during + Pydantic validation of Spond's POST response — declared fields plus + any unmodelled extras preserved via `extra="allow"`. With current + config (every top-level type uses `extra="allow"`) that's + equivalent to "everything Spond sent back"; if a future model + switched to `extra="ignore"`, unknown keys would be dropped from + this return value. Emits `DeprecationWarning`. """ warnings.warn( "Spond.update_event() is deprecated; use Event.update() on the " diff --git a/tests/conftest.py b/tests/conftest.py index e5e55a2..fbfeaa0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,9 @@ -"""Shared fixtures, constants, and module-import-time setup for the Spond -test suite. +"""Shared fixtures and constants for the Spond test suite. pytest auto-discovers this file, so fixtures defined here (e.g. `mock_token`) -are available to every test file without explicit import. Constants and -helpers that test files need to reference directly are imported via +are available to every test file without explicit import. Constants that +test files need to reference directly are imported via `from .conftest import ...`. - -The `_SpondBase.require_authentication` monkey-patch must happen before any -test module is imported — `conftest.py` is the canonical place for that. """ from __future__ import annotations @@ -16,9 +12,6 @@ import pytest -from spond.base import _SpondBase -from spond.spond import Spond - if TYPE_CHECKING: from spond import JSONDict @@ -47,17 +40,13 @@ MOCK_PAYLOAD = {"accepted": "false", "declineMessage": "sick cannot make it"} -# Mock the `require_authentication` decorator to bypass authentication. -# Replaces the real decorator on `_SpondBase` so every test that calls a -# decorated method skips the real auth roundtrip. -def mock_require_authentication(func): - async def wrapper(*args, **kwargs): - return await func(*args, **kwargs) - - return wrapper - - -_SpondBase.require_authentication = mock_require_authentication(Spond.get_event) +# Authentication is bypassed by setting `s.token = mock_token` on every test +# `Spond` instance before invoking a decorated method. The real +# `require_authentication` decorator then short-circuits its `if not +# self.token: await self.login()` check without issuing HTTP. No +# class-level monkey-patch is needed (and wouldn't work anyway — +# `@_SpondBase.require_authentication` is applied at class-definition +# time, so reassigning the attribute later doesn't re-decorate methods). @pytest.fixture From 35b0374bf13f89a9b357c055d8fc8c0e943c837b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 19:36:21 +0000 Subject: [PATCH 22/42] =?UTF-8?q?test:=20improve=20coverage=20from=2076%?= =?UTF-8?q?=20to=2099%=20=E2=80=94=2070=20new=20tests=20across=206=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/Olen/Spond/sessions/66d7255a-a216-4ea9-9fd6-0fddd352f405 Co-authored-by: Olen <203184+Olen@users.noreply.github.com> --- tests/test_auth.py | 30 +++ tests/test_club.py | 192 +++++++++++++++++ tests/test_compat.py | 101 +++++++++ tests/test_events.py | 201 ++++++++++++++++- tests/test_groups.py | 220 ++++++++++++++++++- tests/test_messaging.py | 465 +++++++++++++++++++++++++++++++++++++++- tests/test_posts.py | 17 ++ 7 files changed, 1223 insertions(+), 3 deletions(-) create mode 100644 tests/test_club.py diff --git a/tests/test_auth.py b/tests/test_auth.py index c161c66..c159889 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -120,3 +120,33 @@ def test_decorator_preserves_docstring(self) -> None: def test_decorator_preserves_name(self) -> None: """`__name__` must be the method's, not 'wrapper'.""" assert Spond.get_events.__name__ == "get_events" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_require_auth_closes_session_on_auth_error( + self, mock_post + ) -> None: + """When `login()` raises `AuthenticationError`, the `require_authentication` + decorator must close the aiohttp session before re-raising — prevents + `Unclosed client session` warnings and resource leaks.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={"error": "Bad credentials"} + ) + + # Spy on the close method so we can assert it was called + close_called = [] + real_close = s.clientsession.close + + async def spy_close(): + close_called.append(True) + await real_close() + + s.clientsession.close = spy_close + + with pytest.raises(AuthenticationError): + await s.get_profile() + + assert close_called, "clientsession.close() was not called on AuthenticationError" diff --git a/tests/test_club.py b/tests/test_club.py new file mode 100644 index 0000000..14a2ee0 --- /dev/null +++ b/tests/test_club.py @@ -0,0 +1,192 @@ +"""Tests for the Spond Club finance API — `Transaction` model and +`SpondClub.get_transactions()` pagination.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from spond.club import SpondClub, Transaction + +from .conftest import MOCK_PASSWORD, MOCK_TOKEN, MOCK_USERNAME + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_TX1 = { + "id": "TX1", + "paidAt": "2026-01-15T10:30:00Z", + "paymentName": "Season fee", + "paidByName": "Alice Smith", +} +_TX2 = { + "id": "TX2", + "paidAt": "2026-02-01T08:00:00Z", + "paymentName": "Kit", + "paidByName": "Bob Jones", +} + + +class TestTransaction: + """Unit tests for the `Transaction` typed model.""" + + def test_transaction_parses_all_fields(self) -> None: + t = Transaction.model_validate(_TX1) + assert t.uid == "TX1" + assert t.payment_name == "Season fee" + assert t.paid_by_name == "Alice Smith" + assert t.paid_at is not None + + def test_transaction_str(self) -> None: + t = Transaction.model_validate(_TX1) + s = str(t) + assert "TX1" in s + assert "Season fee" in s + assert "Alice Smith" in s + + def test_transaction_minimal_only_id_required(self) -> None: + """All non-uid fields have defaults; only `id` is required.""" + t = Transaction.model_validate({"id": "TX_MIN"}) + assert t.uid == "TX_MIN" + assert t.payment_name == "" + assert t.paid_by_name == "" + assert t.paid_at is None + + def test_transaction_extra_fields_preserved(self) -> None: + """Unknown Spond fields survive via `extra='allow'` and are accessible + through the dict-compat shim.""" + import warnings as _w + + t = Transaction.model_validate({**_TX1, "futureField": "value"}) + assert "futureField" in t + with _w.catch_warnings(record=True) as caught: + _w.simplefilter("always") + assert t["futureField"] == "value" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + def test_transaction_dict_compat_subscript_warns(self) -> None: + import warnings as _w + + t = Transaction.model_validate(_TX1) + with _w.catch_warnings(record=True) as caught: + _w.simplefilter("always") + val = t["id"] + assert val == "TX1" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + +class TestSpondClubGetTransactions: + """Integration tests for `SpondClub.get_transactions()` pagination.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_transactions_single_page(self, mock_get) -> None: + """A single page of results (< max_items) is returned without + further recursive calls.""" + sc = SpondClub(MOCK_USERNAME, MOCK_PASSWORD) + sc.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[[_TX1, _TX2], []] + ) + + txs = await sc.get_transactions(club_id="CLUB1", max_items=100) + + assert len(txs) == 2 + assert all(isinstance(t, Transaction) for t in txs) + assert txs[0].uid == "TX1" + assert txs[1].uid == "TX2" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_transactions_empty_result(self, mock_get) -> None: + """If Spond returns an empty first page, an empty list comes back.""" + sc = SpondClub(MOCK_USERNAME, MOCK_PASSWORD) + sc.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + txs = await sc.get_transactions(club_id="CLUB1") + + assert txs == [] + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_transactions_paginates_until_empty(self, mock_get) -> None: + """When the first page is full the method fetches additional pages + until an empty page is returned.""" + sc = SpondClub(MOCK_USERNAME, MOCK_PASSWORD) + sc.token = MOCK_TOKEN + + # Page 1: 2 items; page 2: 1 item; page 3: empty (stops) + page3 = [] + page2 = [_TX2] + page1 = [_TX1, _TX2] + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[page1, page2, page3] + ) + + txs = await sc.get_transactions(club_id="CLUB1", max_items=100) + + # 2 + 1 = 3 calls produced 2+1=3 Transaction objects + assert len(txs) == 3 + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_transactions_stops_at_max_items(self, mock_get) -> None: + """Once `len(self.transactions) >= max_items`, no further page is + fetched even if the last page was full.""" + sc = SpondClub(MOCK_USERNAME, MOCK_PASSWORD) + sc.token = MOCK_TOKEN + + # Two-item page; max_items=2 means we must NOT recurse after page 1. + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=[_TX1, _TX2] + ) + + txs = await sc.get_transactions(club_id="CLUB1", max_items=2) + + assert len(txs) == 2 + # Only one HTTP call should have been made + assert mock_get.call_count == 1 + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_transactions_passes_club_id_header(self, mock_get) -> None: + """The `X-Spond-Clubid` header must carry the supplied `club_id`.""" + sc = SpondClub(MOCK_USERNAME, MOCK_PASSWORD) + sc.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + await sc.get_transactions(club_id="CLUB_ABC") + + _, kwargs = mock_get.call_args[0], mock_get.call_args[1] + assert kwargs["headers"]["X-Spond-Clubid"] == "CLUB_ABC" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_transactions_skip_param_on_second_page(self, mock_get) -> None: + """On the recursive call, `skip` must equal the number of records + already fetched.""" + sc = SpondClub(MOCK_USERNAME, MOCK_PASSWORD) + sc.token = MOCK_TOKEN + + # First page: 2 items; second page: empty + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[[_TX1, _TX2], []] + ) + + await sc.get_transactions(club_id="CLUB1", max_items=100) + + assert mock_get.call_count == 2 + second_call_params = mock_get.call_args_list[1][1].get("params") + assert second_call_params == {"skip": 2} diff --git a/tests/test_compat.py b/tests/test_compat.py index 0f7b05e..dfe0f48 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -194,3 +194,104 @@ def test_extra_allow_on_profile_and_group(self) -> None: g = Group.model_validate({"id": "G1", "name": "GG", "newGroupAttr": ["x"]}) assert "newGroupAttr" in g assert g.__pydantic_extra__["newGroupAttr"] == ["x"] + + def test_contains_non_string_key_returns_false(self) -> None: + """`__contains__` with a non-string key must return False without + raising — dict-compat callers may inadvertently pass ints.""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + assert 42 not in e + assert None not in e + + def test_keys_returns_api_shaped_list(self) -> None: + """`DictCompatModel.keys()` yields API-alias names for fields that + were populated from source data, matching `.keys()` on a raw dict.""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + k = e.keys() + assert isinstance(k, list) + assert "id" in k # alias, not "uid" + assert "heading" in k + assert "startTimestamp" in k # alias, not "start_time" + assert "description" not in k # absent from _MIN_EVENT_PAYLOAD + + def test_values_returns_values_for_set_fields(self) -> None: + """`DictCompatModel.values()` returns the values for fields present + in the source payload, in field-declaration order.""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + vals = e.values() + assert isinstance(vals, list) + # "ID1" must be among the values (it's `uid`, present in source) + assert "ID1" in vals + assert "Event One" in vals # heading + + def test_items_returns_key_value_pairs(self) -> None: + """`DictCompatModel.items()` returns `(api_key, value)` tuples for + every field present in the source data, mirroring dict.items().""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + items = dict(e.items()) + assert items["id"] == "ID1" + assert items["heading"] == "Event One" + assert "description" not in items # not in source + + def test_items_includes_extra_fields(self) -> None: + """Extra (unmodelled) fields surface in `.items()` with their + original key names.""" + payload = {**_MIN_EVENT_PAYLOAD, "customExtra": "hello"} + e = Event.model_validate(payload) + items = dict(e.items()) + assert items["customExtra"] == "hello" + + +class TestLenientDate: + """Unit tests for the `_parse_date_lenient` validator used by + `LenientDate` fields on `Member` and `Profile`.""" + + def test_none_passes_through(self) -> None: + from spond._compat import _parse_date_lenient + + assert _parse_date_lenient(None) is None + + def test_date_object_passes_through(self) -> None: + from datetime import date + + from spond._compat import _parse_date_lenient + + d = date(2000, 6, 15) + assert _parse_date_lenient(d) is d + + def test_valid_iso_string_parsed(self) -> None: + from datetime import date + + from spond._compat import _parse_date_lenient + + result = _parse_date_lenient("1995-03-22") + assert result == date(1995, 3, 22) + + def test_invalid_date_string_returns_none(self) -> None: + """Malformed dates (e.g. impossible day) must not raise — return None.""" + from spond._compat import _parse_date_lenient + + assert _parse_date_lenient("2012-03-99") is None + assert _parse_date_lenient("not-a-date") is None + + def test_non_string_non_date_returns_none(self) -> None: + """Non-string, non-date values (e.g. an int) must return None rather + than raising TypeError.""" + from spond._compat import _parse_date_lenient + + assert _parse_date_lenient(20001231) is None + assert _parse_date_lenient([]) is None + + def test_lenient_date_field_on_member(self) -> None: + """A malformed `dateOfBirth` in a Member payload must not crash + validation — the field silently becomes None.""" + from spond.person import Member + + m = Member.model_validate({"id": "M1", "dateOfBirth": "2012-03-99"}) + assert m.date_of_birth is None + + def test_lenient_date_field_on_profile(self) -> None: + """Same resilience on Profile.date_of_birth.""" + from spond.profile import Profile + + p = Profile.model_validate({"id": "P1", "dateOfBirth": "invalid"}) + assert p.date_of_birth is None diff --git a/tests/test_events.py b/tests/test_events.py index fd59055..5a53384 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -4,6 +4,7 @@ from __future__ import annotations +from datetime import UTC, datetime from unittest.mock import AsyncMock, patch import pytest @@ -11,7 +12,7 @@ from spond.event import Event from spond.spond import Spond -from .conftest import _MIN_EVENT_PAYLOAD, MOCK_PASSWORD, MOCK_USERNAME +from .conftest import _MIN_EVENT_PAYLOAD, MOCK_PASSWORD, MOCK_TOKEN, MOCK_USERNAME class TestEventMethods: @@ -162,6 +163,25 @@ async def test_change_response(self, mock_put, mock_payload, mock_token) -> None class TestEventOOMethods: """ActiveRecord methods on Event.""" + def test_event_str_with_start_time(self) -> None: + """`Event.__str__` includes uid, heading and ISO start_time.""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + s = str(e) + assert "ID1" in s + assert "Event One" in s + assert "2026-01-01" in s # from startTimestamp + + def test_event_str_without_start_time(self) -> None: + """`Event.__str__` uses '?' sentinel when start_time is None.""" + e = Event.model_validate({**_MIN_EVENT_PAYLOAD, "startTimestamp": None}) + s = str(e) + assert "?" in s + + def test_event_url_property(self) -> None: + """`Event.url` must return the canonical Spond web URL.""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + assert e.url == "https://spond.com/client/sponds/ID1/" + @pytest.mark.asyncio @patch("aiohttp.ClientSession.post") async def test_event_update_returns_new_event(self, mock_post, mock_token) -> None: @@ -370,3 +390,182 @@ async def test_match_update_preserves_match_type( # And the cache entry got swapped to the new Match instance, not a demoted Event. assert isinstance(s.events[0], Match) assert s.events[0] is result + + +class TestGetEventsHTTP: + """Tests for the `Spond.get_events()` HTTP-fetch path — query parameter + construction, error surfacing, and cache management.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_events_happy_path(self, mock_get) -> None: + """Two events come back from the API as typed Event objects and are + cached on `self.events`.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + raw = [ + {**_MIN_EVENT_PAYLOAD, "id": "E1", "heading": "First"}, + {**_MIN_EVENT_PAYLOAD, "id": "E2", "heading": "Second"}, + ] + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=raw) + + events = await s.get_events() + + assert events is not None + assert len(events) == 2 + assert all(isinstance(e, Event) for e in events) + assert events[0].uid == "E1" + assert events[1].heading == "Second" + assert s.events is events # cache identity + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_events_returns_none_when_api_returns_null( + self, mock_get + ) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=None) + + events = await s.get_events() + assert events is None + assert s.events is None + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_events_api_error_raises_valueerror(self, mock_get) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.ok = False + mock_get.return_value.__aenter__.return_value.status = 403 + mock_get.return_value.__aenter__.return_value.text = AsyncMock( + return_value="Forbidden" + ) + + with pytest.raises(ValueError, match="403"): + await s.get_events() + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_events_datetime_filters_in_params(self, mock_get) -> None: + """Datetime filter args must be serialised to the `_DT_FORMAT` string + and included as query parameters.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + min_start = datetime(2026, 1, 1, tzinfo=UTC) + max_start = datetime(2026, 6, 30, tzinfo=UTC) + await s.get_events(min_start=min_start, max_start=max_start) + + params = mock_get.call_args[1]["params"] + assert "minStartTimestamp" in params + assert "maxStartTimestamp" in params + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_events_include_hidden_param(self, mock_get) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + await s.get_events(include_hidden=True) + + params = mock_get.call_args[1]["params"] + assert params.get("includeHidden") == "true" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_events_group_and_subgroup_params(self, mock_get) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + await s.get_events(group_id="GRP1", subgroup_id="SUB1") + + params = mock_get.call_args[1]["params"] + assert params["groupId"] == "GRP1" + assert params["subGroupId"] == "SUB1" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_events_min_end_max_end_params(self, mock_get, mock_token) -> None: + """The `min_end` and `max_end` datetime args must also be serialised + and sent as `minEndTimestamp` / `maxEndTimestamp` query parameters.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + min_end = datetime(2026, 3, 1, tzinfo=UTC) + max_end = datetime(2026, 12, 31, tzinfo=UTC) + await s.get_events(min_end=min_end, max_end=max_end) + + params = mock_get.call_args[1]["params"] + assert "minEndTimestamp" in params + assert "maxEndTimestamp" in params + + @pytest.mark.asyncio + async def test_get_entity_unsupported_type_raises_not_implemented( + self, mock_token + ) -> None: + """Passing an unknown entity-type string to `_get_entity` must raise + `NotImplementedError` rather than silently returning None.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + with pytest.raises(NotImplementedError, match="not supported"): + await s._get_entity("banana", "UID1") + + +class TestGetProfile: + """Tests for `Spond.get_profile()` — HTTP fetch and caching.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_profile_happy_path(self, mock_get) -> None: + """Profile is returned as a typed Profile object and cached on + `self.profile`.""" + from spond.profile import Profile + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + raw = { + "id": "PROF1", + "firstName": "Ola", + "lastName": "Thoresen", + "primaryEmail": "ola@example.invalid", + } + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=raw) + + profile = await s.get_profile() + + assert isinstance(profile, Profile) + assert profile.uid == "PROF1" + assert profile.first_name == "Ola" + assert profile.full_name == "Ola Thoresen" + assert s.profile is profile # cache identity + + def test_profile_str(self) -> None: + """`Profile.__str__` includes uid and full name.""" + from spond.profile import Profile + + p = Profile.model_validate( + {"id": "P1", "firstName": "Jane", "lastName": "Doe"} + ) + s = str(p) + assert "P1" in s + assert "Jane Doe" in s diff --git a/tests/test_groups.py b/tests/test_groups.py index 89e4185..4c2874a 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -3,9 +3,10 @@ from __future__ import annotations -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest +import pytest_asyncio from spond.group import Group from spond.spond import Spond @@ -161,3 +162,220 @@ def test_member_custom_fields_alias_works_via_either_name(self) -> None: ) assert m1.custom_fields == {"height": "175"} assert m2.custom_fields == {"height": "180"} + + def test_group_str(self) -> None: + """`Group.__str__` includes uid, name, and member count.""" + raw = {"id": "GID1", "name": "My Team", "members": [ + {"id": "M1", "firstName": "A", "lastName": "B"}, + {"id": "M2", "firstName": "C", "lastName": "D"}, + ]} + g = Group.model_validate(raw) + s = str(g) + assert "GID1" in s + assert "My Team" in s + assert "2" in s # member count + + @pytest.mark.asyncio + async def test_group_from_api_wires_client_on_members_and_guardians(self) -> None: + """`Group.from_api()` must set `_client` on the group, each member, + and each nested guardian so per-instance HTTP methods work.""" + from spond.spond import Spond + + raw = { + "id": "GID", + "name": "G", + "members": [ + { + "id": "M1", + "firstName": "A", + "lastName": "B", + "guardians": [ + {"id": "G1", "firstName": "C", "lastName": "D"} + ], + } + ], + } + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + group = Group.from_api(raw, s) + + assert group._client is s + assert group.members[0]._client is s + assert group.members[0].guardians[0]._client is s + + def test_find_member_by_name(self) -> None: + """`find_member(name=...)` matches against `member.full_name`.""" + group = Group.model_validate({ + "id": "GID", + "name": "G", + "members": [ + {"id": "M1", "firstName": "Charlie", "lastName": "Brown"}, + {"id": "M2", "firstName": "Alice", "lastName": "Smith"}, + ], + }) + found = group.find_member(name="Alice Smith") + assert found is not None + assert found.uid == "M2" + + def test_find_member_by_uid(self) -> None: + """`find_member(uid=...)` returns the member with the matching id.""" + group = Group.model_validate({ + "id": "GID", + "name": "G", + "members": [ + {"id": "M1", "firstName": "A", "lastName": "B"}, + {"id": "M2", "firstName": "C", "lastName": "D"}, + ], + }) + found = group.find_member(uid="M1") + assert found is not None + assert found.uid == "M1" + + def test_person_str(self) -> None: + """`Person.__str__` includes class name, uid, and full_name.""" + from spond.person import Member + + m = Member.model_validate({"id": "M99", "firstName": "Ola", "lastName": "N"}) + s = str(m) + assert "Member" in s + assert "M99" in s + assert "Ola N" in s + + def test_role_str(self) -> None: + """`Role.__str__` includes uid and name.""" + from spond.role import Role + + r = Role.model_validate({"id": "R1", "name": "Coach"}) + s = str(r) + assert "R1" in s + assert "Coach" in s + + def test_subgroup_str(self) -> None: + """`Subgroup.__str__` includes uid and name.""" + from spond.subgroup import Subgroup + + sg = Subgroup.model_validate({"id": "SG1", "name": "Team A"}) + s = str(sg) + assert "SG1" in s + assert "Team A" in s + + +class TestGetPersonMethod: + """Tests for `Spond.get_person()` — member/guardian lookup by various + identifiers.""" + + _MEMBER_WITH_GUARDIAN = { + "id": "M1", + "firstName": "Alice", + "lastName": "Smith", + "email": "alice@example.invalid", + "profile": {"id": "PROF1"}, + "guardians": [ + { + "id": "G1", + "firstName": "Bob", + "lastName": "Smith", + "profile": {"id": "PROF_G1"}, + } + ], + } + _GROUP_PAYLOAD = { + "id": "GID1", + "name": "Test Group", + "members": [_MEMBER_WITH_GUARDIAN], + } + + @pytest_asyncio.fixture + async def spond_with_groups(self, mock_token): + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.groups = [Group.model_validate(self._GROUP_PAYLOAD)] + return s + + @pytest.mark.asyncio + async def test_get_person_by_uid(self, spond_with_groups) -> None: + person = await spond_with_groups.get_person("M1") + assert person.uid == "M1" + + @pytest.mark.asyncio + async def test_get_person_by_email(self, spond_with_groups) -> None: + person = await spond_with_groups.get_person("alice@example.invalid") + assert person.uid == "M1" + + @pytest.mark.asyncio + async def test_get_person_by_full_name(self, spond_with_groups) -> None: + person = await spond_with_groups.get_person("Alice Smith") + assert person.uid == "M1" + + @pytest.mark.asyncio + async def test_get_person_by_profile_id(self, spond_with_groups) -> None: + person = await spond_with_groups.get_person("PROF1") + assert person.uid == "M1" + + @pytest.mark.asyncio + async def test_get_person_returns_guardian(self, spond_with_groups) -> None: + """When the uid matches a guardian (not a member), that guardian is + returned.""" + from spond.person import Guardian + + person = await spond_with_groups.get_person("G1") + assert isinstance(person, Guardian) + assert person.uid == "G1" + + @pytest.mark.asyncio + async def test_get_person_no_match_raises_keyerror(self, spond_with_groups) -> None: + with pytest.raises(KeyError, match="scanned"): + await spond_with_groups.get_person("NOBODY") + + @pytest.mark.asyncio + async def test_get_person_no_groups_raises_keyerror(self, mock_token) -> None: + """When the account has no groups, a distinct KeyError message is raised.""" + from unittest.mock import AsyncMock + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.groups = None + # Mock get_groups to leave self.groups = None (simulates no groups) + s.get_groups = AsyncMock(return_value=None) + + with pytest.raises(KeyError, match="no groups"): + await s.get_person("ANYONE") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_groups_http_path(self, mock_get, mock_token) -> None: + """The HTTP-fetch path of `get_groups()` returns typed Group objects + and caches them on `self.groups`.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + raw_groups = [ + {"id": "G1", "name": "Alpha"}, + {"id": "G2", "name": "Beta"}, + ] + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=raw_groups + ) + + groups = await s.get_groups() + + assert groups is not None + assert len(groups) == 2 + assert all(isinstance(g, Group) for g in groups) + assert groups[0].uid == "G1" + assert groups[1].name == "Beta" + assert s.groups is groups # cache identity + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_groups_returns_none_when_api_returns_null( + self, mock_get, mock_token + ) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=None) + + groups = await s.get_groups() + + assert groups is None + assert s.groups is None diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 3109713..a30220b 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -9,7 +9,7 @@ from spond.spond import Spond -from .conftest import MOCK_PASSWORD, MOCK_USERNAME +from .conftest import MOCK_PASSWORD, MOCK_TOKEN, MOCK_USERNAME class TestSendMessage: @@ -99,6 +99,16 @@ def test_chat_parses_with_typed_message(self) -> None: assert c.message.text == "hello" assert c.message.user == "P1" + def test_chat_str(self) -> None: + """`Chat.__str__` includes uid, name, and type.""" + from spond.chat import Chat + + c = Chat.from_api(self._CHAT_PAYLOAD, None) + s = str(c) + assert "CHAT1" in s + assert "Demo Group" in s + assert "GROUP" in s + def test_chat_message_optional_for_resilience(self) -> None: """A chat with no embedded message (rare but possible) must not crash — the only required field on Chat is `uid`.""" @@ -157,3 +167,456 @@ def test_chat_send_refuses_without_client(self) -> None: c = Chat.from_api(self._CHAT_PAYLOAD, None) with pytest.raises(RuntimeError, match="no client attached"): asyncio.run(c.send("hello")) + + +class TestGetMessages: + """Tests for `Spond.get_messages()` — chat-server handshake and list + returned as typed Chat instances.""" + + _CHAT_PAYLOAD = { + "id": "CHAT1", + "name": "Demo", + "type": "GROUP", + "participants": ["P1"], + "newestTimestamp": "2026-05-14T12:00:00Z", + "unread": False, + "muted": False, + } + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_messages_happy_path(self, mock_get, mock_token) -> None: + """get_messages() returns a list of Chat objects and caches on + `self.messages`. The chat-server token is pre-set so `_login_chat` + is not triggered.""" + from spond.chat import Chat + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=[self._CHAT_PAYLOAD] + ) + + messages = await s.get_messages() + + assert messages is not None + assert len(messages) == 1 + assert isinstance(messages[0], Chat) + assert messages[0].uid == "CHAT1" + assert s.messages is messages + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_messages_returns_none_when_api_returns_null( + self, mock_get, mock_token + ) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=None) + + messages = await s.get_messages() + assert messages is None + assert s.messages is None + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_messages_max_chats_param(self, mock_get, mock_token) -> None: + """The `max` query parameter must reflect the `max_chats` argument.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + await s.get_messages(max_chats=50) + + params = mock_get.call_args[1]["params"] + assert params["max"] == "50" + + +class TestSendMessageNewChat: + """Tests for `Spond.send_message()` when starting a *new* chat + (user + group_uid path, lines 489-506 in spond.py).""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_send_message_new_chat_happy_path( + self, mock_post, mock_token + ) -> None: + """With `user` and `group_uid`, `send_message()` looks up the member + by `get_person()`, extracts `profile.id`, and POSTs to the chat server.""" + from spond.group import Group + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + # Pre-populate groups so get_person() resolves locally + s.groups = [ + Group.model_validate({ + "id": "GID1", + "name": "G", + "members": [{ + "id": "M1", + "firstName": "Alice", + "lastName": "Smith", + "profile": {"id": "PROF1"}, + }], + }) + ] + + api_response = {"ok": True, "messageId": "MSG1"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + result = await s.send_message( + text="Hello", user="M1", group_uid="GID1" + ) + + assert result == api_response + kwargs = mock_post.call_args[1] + assert kwargs["json"]["recipient"] == "PROF1" + assert kwargs["json"]["groupId"] == "GID1" + assert kwargs["json"]["text"] == "Hello" + assert kwargs["json"]["type"] == "TEXT" + + @pytest.mark.asyncio + async def test_send_message_user_without_profile_raises( + self, mock_token + ) -> None: + """If the located member has no `profile` dict with an `id`, a clear + `ValueError` is raised rather than crashing inside the POST.""" + from spond.group import Group + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + s.groups = [ + Group.model_validate({ + "id": "GID1", + "name": "G", + "members": [{ + "id": "M2", + "firstName": "Bob", + "lastName": "Jones", + # no profile → profile is None + }], + }) + ] + + with pytest.raises(ValueError, match="profile id"): + await s.send_message(text="hi", user="M2", group_uid="GID1") + + +class TestMemberSendMessage: + """Tests for `Member.send_message()` and `Guardian.send_message()` — + the per-instance HTTP helpers on `person.py`.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_member_send_message_routes_to_chat_server( + self, mock_post, mock_token + ) -> None: + """Calling `member.send_message()` POSTs the correct payload to the + chat-server host with the chat-server auth header.""" + from spond.group import Group + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + group = Group.from_api( + { + "id": "GID", + "name": "G", + "members": [{ + "id": "M1", + "firstName": "A", + "lastName": "B", + "profile": {"id": "PROF1"}, + }], + }, + s, + ) + member = group.members[0] + + api_response = {"ok": True} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + result = await member.send_message("test msg", "GID") + + assert result == api_response + kwargs = mock_post.call_args[1] + assert kwargs["json"]["recipient"] == "PROF1" + assert kwargs["json"]["groupId"] == "GID" + assert kwargs["json"]["text"] == "test msg" + assert kwargs["headers"] == {"auth": "CHAT_AUTH"} + + def test_member_send_message_without_client_raises(self) -> None: + """A Member instantiated without a client (e.g. test fixture) must + raise `RuntimeError` rather than crashing with AttributeError.""" + import asyncio + + from spond.person import Member + + m = Member.model_validate( + {"id": "M1", "firstName": "A", "lastName": "B", "profile": {"id": "P1"}} + ) + # _client is None — no Group.from_api() was called + with pytest.raises(RuntimeError, match="no client"): + asyncio.run(m.send_message("hello", "GID")) + + @pytest.mark.asyncio + async def test_member_send_message_without_profile_id_raises(self) -> None: + """If the member has no `profile.id`, a clear `ValueError` is raised + (not an AttributeError inside the send path).""" + from spond.person import Member + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s._auth = "CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + m = Member.model_validate({"id": "M1", "firstName": "A", "lastName": "B"}) + m._client = s # wire client, but no profile + + with pytest.raises(ValueError, match="profile id"): + await m.send_message("hello", "GID") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_guardian_send_message_routes_to_chat_server( + self, mock_post, mock_token + ) -> None: + """Guardian.send_message() uses the same `_send_message_to_person` + helper as Member.send_message() — verify it also works.""" + from spond.group import Group + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + group = Group.from_api( + { + "id": "GID", + "name": "G", + "members": [{ + "id": "M1", + "firstName": "Child", + "lastName": "A", + "guardians": [{ + "id": "G1", + "firstName": "Parent", + "lastName": "A", + "profile": {"id": "PROF_G1"}, + }], + }], + }, + s, + ) + guardian = group.members[0].guardians[0] + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={"ok": True} + ) + + result = await guardian.send_message("guardian msg", "GID") + + assert result == {"ok": True} + kwargs = mock_post.call_args[1] + assert kwargs["json"]["recipient"] == "PROF_G1" + + +class TestLazyChatLogin: + """Tests for the lazy `_login_chat()` handshake path — verifies that + `get_messages()`, `_continue_chat()`, `send_message()`, `chat.send()`, + and `member.send_message()` all trigger `_login_chat()` when `_auth` is + None, and that `_login_chat()` itself correctly stores the chat-server + URL and token from the API response.""" + + _CHAT_HANDSHAKE = { + "url": "https://chat.example.invalid", + "auth": "FRESH_CHAT_AUTH", + } + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + @patch("aiohttp.ClientSession.post") + async def test_login_chat_sets_url_and_auth( + self, mock_post, mock_get + ) -> None: + """`_login_chat()` must POST to `{api_url}chat`, then store the + returned `url` and `auth` on the client instance.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + # _auth and _chat_url are None — trigger lazy login + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self._CHAT_HANDSHAKE + ) + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=[] + ) + + await s.get_messages() + + assert s._chat_url == "https://chat.example.invalid" + assert s._auth == "FRESH_CHAT_AUTH" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + @patch("aiohttp.ClientSession.post") + async def test_get_messages_triggers_lazy_login( + self, mock_post, mock_get + ) -> None: + """`get_messages()` must call `_login_chat()` when `_auth` is None.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self._CHAT_HANDSHAKE + ) + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=[] + ) + + messages = await s.get_messages() + + # One POST for _login_chat, zero POSTs otherwise; one GET for chats + mock_post.assert_called_once() + assert messages == [] + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_continue_chat_triggers_lazy_login(self, mock_post) -> None: + """`_continue_chat()` independently calls `_login_chat()` when its + own `_auth` guard fires — covers line 420 in spond.py.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + # _auth=None and _chat_url=None: _continue_chat must bootstrap both + + # Side-effect: first call is _login_chat POST, second is the message POST + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[ + self._CHAT_HANDSHAKE, # _login_chat response + {"ok": True}, # message send response + ] + ) + + result = await s._continue_chat("CHAT1", "hello") + + assert result == {"ok": True} + assert s._auth == "FRESH_CHAT_AUTH" + assert mock_post.call_count == 2 + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_send_message_chat_id_triggers_lazy_login( + self, mock_post + ) -> None: + """`send_message(chat_id=...)` calls `_login_chat()` when `_auth` is + None — covers line 479 in spond.py.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[ + self._CHAT_HANDSHAKE, + {"ok": True}, + ] + ) + + result = await s.send_message(text="hi", chat_id="CHAT1") + + assert result == {"ok": True} + assert s._auth == "FRESH_CHAT_AUTH" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_chat_send_triggers_lazy_login_on_client( + self, mock_post + ) -> None: + """`chat.send()` triggers `_client._login_chat()` when `_client._auth` + is None — covers chat.py:158.""" + from spond.chat import Chat + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + # _auth is None — the chat's send() must bootstrap it + + chat = Chat.from_api( + { + "id": "CHAT1", + "name": "G", + "type": "GROUP", + "participants": [], + "newestTimestamp": "2026-01-01T00:00:00Z", + "unread": False, + "muted": False, + }, + s, + ) + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[ + self._CHAT_HANDSHAKE, # _login_chat + {"ok": True}, # message send + ] + ) + + result = await chat.send("ack") + + assert result == {"ok": True} + assert s._auth == "FRESH_CHAT_AUTH" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_member_send_message_triggers_lazy_login( + self, mock_post + ) -> None: + """`member.send_message()` calls `_login_chat()` when the client's + `_auth` is None — covers person.py:174.""" + from spond.group import Group + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + # _auth is None; _send_message_to_person must bootstrap it + + group = Group.from_api( + { + "id": "GID", + "name": "G", + "members": [{ + "id": "M1", + "firstName": "A", + "lastName": "B", + "profile": {"id": "PROF1"}, + }], + }, + s, + ) + member = group.members[0] + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[ + self._CHAT_HANDSHAKE, + {"ok": True}, + ] + ) + + result = await member.send_message("hello", "GID") + + assert result == {"ok": True} + assert s._auth == "FRESH_CHAT_AUTH" diff --git a/tests/test_posts.py b/tests/test_posts.py index 754c3e3..cfb84a6 100644 --- a/tests/test_posts.py +++ b/tests/test_posts.py @@ -146,3 +146,20 @@ async def test_get_posts__api_error_raises(self, mock_get, mock_token) -> None: with pytest.raises(ValueError, match="401"): await s.get_posts() + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__returns_none_when_api_returns_null( + self, mock_get, mock_token + ) -> None: + """When the API returns null, `get_posts()` returns None and sets + `self.posts = None`.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=None) + + result = await s.get_posts() + assert result is None + assert s.posts is None From 7764a0f78a0f906ed56689bff0d216c762ef042b Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 21:45:44 +0200 Subject: [PATCH 23/42] fix(oo-rewrite): address round-12 Copilot review + reformat new tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-12 review on top of Copilot's coverage-improvement commit. 1. `spond/person.py` / `spond/profile.py` — `full_name` no longer returns the bare `" "` artefact when both `first_name` and `last_name` default to empty strings (which they now do, per the earlier resilience relaxation). Joins only non-empty parts; returns `""` when both are absent. 2. `spond/spond.py` — `_get_entity()` return type was stale `JSONDict`; updated annotation and docstring to `Event | Group` (subclasses such as `Match` are preserved). 3. `tests/*.py` — six files reformatted per `ruff format` (the CI format-check step in the previous commit was failing on Copilot's newly added test code). The third Copilot note (`_continue_chat` double-decoration with `require_authentication`) was flagged "Not a bug — low priority" by the reviewer itself; running the auth check twice is harmless since the second call short-circuits on `self.token` being set, so leaving this alone. Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/person.py | 6 +- spond/profile.py | 6 +- spond/spond.py | 8 ++- tests/test_auth.py | 8 +-- tests/test_compat.py | 2 +- tests/test_events.py | 12 ++-- tests/test_groups.py | 56 +++++++++------- tests/test_messaging.py | 144 ++++++++++++++++++++-------------------- tests/test_posts.py | 4 +- 9 files changed, 132 insertions(+), 114 deletions(-) diff --git a/spond/person.py b/spond/person.py index 800127b..fa12632 100644 --- a/spond/person.py +++ b/spond/person.py @@ -60,8 +60,10 @@ class Person(DictCompatModel): @property def full_name(self) -> str: - """Convenience: `first_name` + ` ` + `last_name`.""" - return f"{self.first_name} {self.last_name}" + """Convenience: non-empty `first_name` and `last_name` joined by a + single space. Returns `""` when both are missing (avoids the bare + `" "` artefact when name fields default to empty strings).""" + return " ".join(part for part in (self.first_name, self.last_name) if part) def __str__(self) -> str: return f"{self.__class__.__name__}(uid={self.uid!r}, name={self.full_name!r})" diff --git a/spond/profile.py b/spond/profile.py index dcc028d..ac731e1 100644 --- a/spond/profile.py +++ b/spond/profile.py @@ -65,8 +65,10 @@ class Profile(DictCompatModel): @property def full_name(self) -> str: - """Convenience: `first_name` + ` ` + `last_name`.""" - return f"{self.first_name} {self.last_name}" + """Convenience: non-empty `first_name` and `last_name` joined by a + single space. Returns `""` when both are missing (avoids the bare + `" "` artefact when name fields default to empty strings).""" + return " ".join(part for part in (self.first_name, self.last_name) if part) def __str__(self) -> str: return f"Profile(uid={self.uid!r}, name={self.full_name!r})" diff --git a/spond/spond.py b/spond/spond.py index 0f8d2cd..d328ca7 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -739,7 +739,7 @@ async def change_response(self, uid: str, user: str, payload: JSONDict) -> JSOND return await r.json() @_SpondBase.require_authentication - async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: + async def _get_entity(self, entity_type: str, uid: str) -> Event | Group: """Internal lookup helper shared by `get_event` and `get_group`. Routes to the relevant cache (`self.events` or `self.groups`), @@ -758,8 +758,10 @@ async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: Returns ------- - JSONDict - The matching entity dict. + Event | Group + The matching typed entity (`Event` for `entity_type="event"`, + `Group` for `entity_type="group"`). Subclasses (e.g. `Match`) + are preserved. Raises ------ diff --git a/tests/test_auth.py b/tests/test_auth.py index c159889..21ec76d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -123,9 +123,7 @@ def test_decorator_preserves_name(self) -> None: @pytest.mark.asyncio @patch("aiohttp.ClientSession.post") - async def test_require_auth_closes_session_on_auth_error( - self, mock_post - ) -> None: + async def test_require_auth_closes_session_on_auth_error(self, mock_post) -> None: """When `login()` raises `AuthenticationError`, the `require_authentication` decorator must close the aiohttp session before re-raising — prevents `Unclosed client session` warnings and resource leaks.""" @@ -149,4 +147,6 @@ async def spy_close(): with pytest.raises(AuthenticationError): await s.get_profile() - assert close_called, "clientsession.close() was not called on AuthenticationError" + assert close_called, ( + "clientsession.close() was not called on AuthenticationError" + ) diff --git a/tests/test_compat.py b/tests/test_compat.py index dfe0f48..85e4172 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -208,7 +208,7 @@ def test_keys_returns_api_shaped_list(self) -> None: e = Event.model_validate(_MIN_EVENT_PAYLOAD) k = e.keys() assert isinstance(k, list) - assert "id" in k # alias, not "uid" + assert "id" in k # alias, not "uid" assert "heading" in k assert "startTimestamp" in k # alias, not "start_time" assert "description" not in k # absent from _MIN_EVENT_PAYLOAD diff --git a/tests/test_events.py b/tests/test_events.py index 5a53384..6cb3662 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -429,7 +429,9 @@ async def test_get_events_returns_none_when_api_returns_null( s.token = MOCK_TOKEN mock_get.return_value.__aenter__.return_value.ok = True - mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=None) + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=None + ) events = await s.get_events() assert events is None @@ -500,7 +502,9 @@ async def test_get_events_group_and_subgroup_params(self, mock_get) -> None: @pytest.mark.asyncio @patch("aiohttp.ClientSession.get") - async def test_get_events_min_end_max_end_params(self, mock_get, mock_token) -> None: + async def test_get_events_min_end_max_end_params( + self, mock_get, mock_token + ) -> None: """The `min_end` and `max_end` datetime args must also be serialised and sent as `minEndTimestamp` / `maxEndTimestamp` query parameters.""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) @@ -563,9 +567,7 @@ def test_profile_str(self) -> None: """`Profile.__str__` includes uid and full name.""" from spond.profile import Profile - p = Profile.model_validate( - {"id": "P1", "firstName": "Jane", "lastName": "Doe"} - ) + p = Profile.model_validate({"id": "P1", "firstName": "Jane", "lastName": "Doe"}) s = str(p) assert "P1" in s assert "Jane Doe" in s diff --git a/tests/test_groups.py b/tests/test_groups.py index 4c2874a..626527f 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -165,10 +165,14 @@ def test_member_custom_fields_alias_works_via_either_name(self) -> None: def test_group_str(self) -> None: """`Group.__str__` includes uid, name, and member count.""" - raw = {"id": "GID1", "name": "My Team", "members": [ - {"id": "M1", "firstName": "A", "lastName": "B"}, - {"id": "M2", "firstName": "C", "lastName": "D"}, - ]} + raw = { + "id": "GID1", + "name": "My Team", + "members": [ + {"id": "M1", "firstName": "A", "lastName": "B"}, + {"id": "M2", "firstName": "C", "lastName": "D"}, + ], + } g = Group.model_validate(raw) s = str(g) assert "GID1" in s @@ -189,9 +193,7 @@ async def test_group_from_api_wires_client_on_members_and_guardians(self) -> Non "id": "M1", "firstName": "A", "lastName": "B", - "guardians": [ - {"id": "G1", "firstName": "C", "lastName": "D"} - ], + "guardians": [{"id": "G1", "firstName": "C", "lastName": "D"}], } ], } @@ -204,28 +206,32 @@ async def test_group_from_api_wires_client_on_members_and_guardians(self) -> Non def test_find_member_by_name(self) -> None: """`find_member(name=...)` matches against `member.full_name`.""" - group = Group.model_validate({ - "id": "GID", - "name": "G", - "members": [ - {"id": "M1", "firstName": "Charlie", "lastName": "Brown"}, - {"id": "M2", "firstName": "Alice", "lastName": "Smith"}, - ], - }) + group = Group.model_validate( + { + "id": "GID", + "name": "G", + "members": [ + {"id": "M1", "firstName": "Charlie", "lastName": "Brown"}, + {"id": "M2", "firstName": "Alice", "lastName": "Smith"}, + ], + } + ) found = group.find_member(name="Alice Smith") assert found is not None assert found.uid == "M2" def test_find_member_by_uid(self) -> None: """`find_member(uid=...)` returns the member with the matching id.""" - group = Group.model_validate({ - "id": "GID", - "name": "G", - "members": [ - {"id": "M1", "firstName": "A", "lastName": "B"}, - {"id": "M2", "firstName": "C", "lastName": "D"}, - ], - }) + group = Group.model_validate( + { + "id": "GID", + "name": "G", + "members": [ + {"id": "M1", "firstName": "A", "lastName": "B"}, + {"id": "M2", "firstName": "C", "lastName": "D"}, + ], + } + ) found = group.find_member(uid="M1") assert found is not None assert found.uid == "M1" @@ -373,7 +379,9 @@ async def test_get_groups_returns_none_when_api_returns_null( s = Spond(MOCK_USERNAME, MOCK_PASSWORD) s.token = mock_token - mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=None) + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=None + ) groups = await s.get_groups() diff --git a/tests/test_messaging.py b/tests/test_messaging.py index a30220b..76f3722 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -218,7 +218,9 @@ async def test_get_messages_returns_none_when_api_returns_null( s._auth = "MOCK_CHAT_AUTH" s._chat_url = "https://chat.example.invalid" - mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=None) + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=None + ) messages = await s.get_messages() assert messages is None @@ -260,16 +262,20 @@ async def test_send_message_new_chat_happy_path( s._chat_url = "https://chat.example.invalid" # Pre-populate groups so get_person() resolves locally s.groups = [ - Group.model_validate({ - "id": "GID1", - "name": "G", - "members": [{ - "id": "M1", - "firstName": "Alice", - "lastName": "Smith", - "profile": {"id": "PROF1"}, - }], - }) + Group.model_validate( + { + "id": "GID1", + "name": "G", + "members": [ + { + "id": "M1", + "firstName": "Alice", + "lastName": "Smith", + "profile": {"id": "PROF1"}, + } + ], + } + ) ] api_response = {"ok": True, "messageId": "MSG1"} @@ -277,9 +283,7 @@ async def test_send_message_new_chat_happy_path( return_value=api_response ) - result = await s.send_message( - text="Hello", user="M1", group_uid="GID1" - ) + result = await s.send_message(text="Hello", user="M1", group_uid="GID1") assert result == api_response kwargs = mock_post.call_args[1] @@ -289,9 +293,7 @@ async def test_send_message_new_chat_happy_path( assert kwargs["json"]["type"] == "TEXT" @pytest.mark.asyncio - async def test_send_message_user_without_profile_raises( - self, mock_token - ) -> None: + async def test_send_message_user_without_profile_raises(self, mock_token) -> None: """If the located member has no `profile` dict with an `id`, a clear `ValueError` is raised rather than crashing inside the POST.""" from spond.group import Group @@ -301,16 +303,20 @@ async def test_send_message_user_without_profile_raises( s._auth = "MOCK_CHAT_AUTH" s._chat_url = "https://chat.example.invalid" s.groups = [ - Group.model_validate({ - "id": "GID1", - "name": "G", - "members": [{ - "id": "M2", - "firstName": "Bob", - "lastName": "Jones", - # no profile → profile is None - }], - }) + Group.model_validate( + { + "id": "GID1", + "name": "G", + "members": [ + { + "id": "M2", + "firstName": "Bob", + "lastName": "Jones", + # no profile → profile is None + } + ], + } + ) ] with pytest.raises(ValueError, match="profile id"): @@ -339,12 +345,14 @@ async def test_member_send_message_routes_to_chat_server( { "id": "GID", "name": "G", - "members": [{ - "id": "M1", - "firstName": "A", - "lastName": "B", - "profile": {"id": "PROF1"}, - }], + "members": [ + { + "id": "M1", + "firstName": "A", + "lastName": "B", + "profile": {"id": "PROF1"}, + } + ], }, s, ) @@ -412,17 +420,21 @@ async def test_guardian_send_message_routes_to_chat_server( { "id": "GID", "name": "G", - "members": [{ - "id": "M1", - "firstName": "Child", - "lastName": "A", - "guardians": [{ - "id": "G1", - "firstName": "Parent", + "members": [ + { + "id": "M1", + "firstName": "Child", "lastName": "A", - "profile": {"id": "PROF_G1"}, - }], - }], + "guardians": [ + { + "id": "G1", + "firstName": "Parent", + "lastName": "A", + "profile": {"id": "PROF_G1"}, + } + ], + } + ], }, s, ) @@ -454,9 +466,7 @@ class TestLazyChatLogin: @pytest.mark.asyncio @patch("aiohttp.ClientSession.get") @patch("aiohttp.ClientSession.post") - async def test_login_chat_sets_url_and_auth( - self, mock_post, mock_get - ) -> None: + async def test_login_chat_sets_url_and_auth(self, mock_post, mock_get) -> None: """`_login_chat()` must POST to `{api_url}chat`, then store the returned `url` and `auth` on the client instance.""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) @@ -466,9 +476,7 @@ async def test_login_chat_sets_url_and_auth( mock_post.return_value.__aenter__.return_value.json = AsyncMock( return_value=self._CHAT_HANDSHAKE ) - mock_get.return_value.__aenter__.return_value.json = AsyncMock( - return_value=[] - ) + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) await s.get_messages() @@ -478,9 +486,7 @@ async def test_login_chat_sets_url_and_auth( @pytest.mark.asyncio @patch("aiohttp.ClientSession.get") @patch("aiohttp.ClientSession.post") - async def test_get_messages_triggers_lazy_login( - self, mock_post, mock_get - ) -> None: + async def test_get_messages_triggers_lazy_login(self, mock_post, mock_get) -> None: """`get_messages()` must call `_login_chat()` when `_auth` is None.""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) s.token = MOCK_TOKEN @@ -488,9 +494,7 @@ async def test_get_messages_triggers_lazy_login( mock_post.return_value.__aenter__.return_value.json = AsyncMock( return_value=self._CHAT_HANDSHAKE ) - mock_get.return_value.__aenter__.return_value.json = AsyncMock( - return_value=[] - ) + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) messages = await s.get_messages() @@ -511,7 +515,7 @@ async def test_continue_chat_triggers_lazy_login(self, mock_post) -> None: mock_post.return_value.__aenter__.return_value.json = AsyncMock( side_effect=[ self._CHAT_HANDSHAKE, # _login_chat response - {"ok": True}, # message send response + {"ok": True}, # message send response ] ) @@ -523,9 +527,7 @@ async def test_continue_chat_triggers_lazy_login(self, mock_post) -> None: @pytest.mark.asyncio @patch("aiohttp.ClientSession.post") - async def test_send_message_chat_id_triggers_lazy_login( - self, mock_post - ) -> None: + async def test_send_message_chat_id_triggers_lazy_login(self, mock_post) -> None: """`send_message(chat_id=...)` calls `_login_chat()` when `_auth` is None — covers line 479 in spond.py.""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) @@ -545,9 +547,7 @@ async def test_send_message_chat_id_triggers_lazy_login( @pytest.mark.asyncio @patch("aiohttp.ClientSession.post") - async def test_chat_send_triggers_lazy_login_on_client( - self, mock_post - ) -> None: + async def test_chat_send_triggers_lazy_login_on_client(self, mock_post) -> None: """`chat.send()` triggers `_client._login_chat()` when `_client._auth` is None — covers chat.py:158.""" from spond.chat import Chat @@ -572,7 +572,7 @@ async def test_chat_send_triggers_lazy_login_on_client( mock_post.return_value.__aenter__.return_value.json = AsyncMock( side_effect=[ self._CHAT_HANDSHAKE, # _login_chat - {"ok": True}, # message send + {"ok": True}, # message send ] ) @@ -583,9 +583,7 @@ async def test_chat_send_triggers_lazy_login_on_client( @pytest.mark.asyncio @patch("aiohttp.ClientSession.post") - async def test_member_send_message_triggers_lazy_login( - self, mock_post - ) -> None: + async def test_member_send_message_triggers_lazy_login(self, mock_post) -> None: """`member.send_message()` calls `_login_chat()` when the client's `_auth` is None — covers person.py:174.""" from spond.group import Group @@ -598,12 +596,14 @@ async def test_member_send_message_triggers_lazy_login( { "id": "GID", "name": "G", - "members": [{ - "id": "M1", - "firstName": "A", - "lastName": "B", - "profile": {"id": "PROF1"}, - }], + "members": [ + { + "id": "M1", + "firstName": "A", + "lastName": "B", + "profile": {"id": "PROF1"}, + } + ], }, s, ) diff --git a/tests/test_posts.py b/tests/test_posts.py index cfb84a6..13e22f8 100644 --- a/tests/test_posts.py +++ b/tests/test_posts.py @@ -158,7 +158,9 @@ async def test_get_posts__returns_none_when_api_returns_null( s.token = mock_token mock_get.return_value.__aenter__.return_value.ok = True - mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=None) + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=None + ) result = await s.get_posts() assert result is None From 7942ecbd70cfc5715fcbb637b792e22c8de788d8 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 21:52:22 +0200 Subject: [PATCH 24/42] fix(oo-rewrite): JSON-encode caller updates in Event.update() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Event.update()` dumped the current state with `mode="json"` (so datetime fields became ISO strings) but then overlaid the caller's `api_updates` verbatim. A caller passing a native `datetime` — `event.update(start_time=datetime.now())`, the most natural shape given the field is itself a `datetime` — produced a payload with a raw `datetime` object that aiohttp's `json.dumps` rejects with `TypeError: Object of type datetime is not JSON serializable`. Run `api_updates` values through `pydantic_core.to_jsonable_python` to apply the same conversion as `model_dump(mode="json")`. Handles datetime, date, UUID, Decimal, sets, and any other Pydantic-encodable native type uniformly. Adds a regression test guarding the datetime path specifically. Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/event.py | 9 ++++++++- tests/test_events.py | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/spond/event.py b/spond/event.py index 3c610e0..f6f220c 100644 --- a/spond/event.py +++ b/spond/event.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any from pydantic import ConfigDict, Field, PrivateAttr, ValidationError +from pydantic_core import to_jsonable_python from ._compat import DictCompatModel @@ -297,7 +298,13 @@ async def update( exclude_unset=True, exclude_none=True, ) - payload.update(api_updates) + # `model_dump(mode="json")` converts native Python types (datetime, + # UUID, Decimal, sets, …) to JSON-safe equivalents, but the + # caller's `api_updates` haven't been through that pass. Run them + # through the same encoder so callers can pass typed values + # naturally — `event.update(start_time=datetime.now())` is the + # obvious shape, given the field is itself a `datetime`. + payload.update({k: to_jsonable_python(v) for k, v in api_updates.items()}) url = f"{self._client.api_url}sponds/{self.uid}" async with self._client.clientsession.post( diff --git a/tests/test_events.py b/tests/test_events.py index 6cb3662..2e4e587 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -222,6 +222,32 @@ async def test_event_update_accepts_positional_dict( posted = mock_post.call_args[1]["json"] assert posted["selfish"] == "any" + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_converts_native_python_values( + self, mock_post, mock_token + ) -> None: + """Caller-supplied native Python values (datetime, date, …) must be + JSON-serialised before they hit aiohttp's `json.dumps`. Without + this, `event.update(start_time=datetime(...))` crashes with + `TypeError: Object of type datetime is not JSON serializable`.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_MIN_EVENT_PAYLOAD + ) + + new_start = datetime(2027, 3, 15, 10, 0, tzinfo=UTC) + await event.update(start_time=new_start) + + posted = mock_post.call_args[1]["json"] + # The datetime must have been converted to its ISO string form + # (no naked `datetime` instance leaks into the JSON payload). + assert isinstance(posted["startTimestamp"], str) + assert posted["startTimestamp"].startswith("2027-03-15T10:00") + @pytest.mark.asyncio @patch("aiohttp.ClientSession.post") async def test_event_update_refreshes_client_cache( From 220abf0816b65be31cbbf9ae34e6c1cbb6d739ad Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 21:59:57 +0200 Subject: [PATCH 25/42] =?UTF-8?q?fix(oo-rewrite):=20round-13=20review=20?= =?UTF-8?q?=E2=80=94=20empty-name=20match=20+=20fail-fast=20in=20send=5Fme?= =?UTF-8?q?ssage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review items. 1. `spond/spond.py::_match_person` — after the `full_name` resilience fix, a record with no first/last names yields `full_name == ""`. The old `first + " " + last` form produced at least `" "`, so an empty `match_str` could never match. Guard the full-name branch with `if person.full_name and …` to preserve the prior "empty match string matches nothing" semantics. 2. `spond/spond.py::send_message` — moved the `chat_id`/`user`+`group_uid` validation above the lazy `_login_chat()` handshake so a pure client-side argument error no longer triggers a network round-trip before the `ValueError`. 3. `spond/group.py::Group.find_member` — added a note on the `name=` parameter that `full_name` now joins only non-empty parts, so callers searching for a single-name member must pass the trimmed form rather than the historic `f"{first} {last}"` shape. Adds a regression test guarding the empty-string match path against nameless records. The remaining review item (using a structured `.. deprecated::` reST/pdoc directive instead of the prose "Deprecated — use X" lead on the three wrapper docstrings) is a styling preference — keeping the existing prose lead, which pdoc renders cleanly and which matches the convention already established on every other deprecated entry point in this file. Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/group.py | 5 ++++- spond/spond.py | 19 +++++++++++++------ tests/test_groups.py | 21 +++++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/spond/group.py b/spond/group.py index a8e366f..8355357 100644 --- a/spond/group.py +++ b/spond/group.py @@ -164,7 +164,10 @@ def find_member( email : str, optional Match against `member.email` (exact). name : str, optional - Match against `member.full_name` (exact). + Match against `member.full_name` (exact). Note: `full_name` + joins only non-empty parts, so a member whose record carries + only `firstName` (no last name) has `full_name == "Alice"`, + not `"Alice "`. Pass the trimmed form. Returns ------- diff --git a/spond/spond.py b/spond/spond.py index d328ca7..722b4cd 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -281,7 +281,11 @@ def _match_person(person: Person, match_str: str) -> bool: """ if person.uid == match_str: return True - if person.full_name == match_str: + # `full_name` returns "" when both first/last default to empty; + # without this guard, an empty `match_str` would erroneously match + # every nameless record. Mirrors the `bool(person.email)` guard + # below. + if person.full_name and person.full_name == match_str: return True # `person.profile` is typed `dict | None`, but `extra="allow"` and # API drift mean we shouldn't trust the shape at runtime — guard @@ -475,16 +479,19 @@ async def send_message( of the authenticated user's groups (propagated from `get_person`). """ + # Validate caller args BEFORE the chat-server handshake so a pure + # client-side argument error doesn't trigger a network round-trip. + if chat_id is None and (group_uid is None or user is None): + raise ValueError( + "send_message requires either chat_id (to continue an existing " + "chat) or both user and group_uid (to start a new one)." + ) + if self._auth is None: await self._login_chat() if chat_id is not None: return await self._continue_chat(chat_id, text) - if group_uid is None or user is None: - raise ValueError( - "send_message requires either chat_id (to continue an existing " - "chat) or both user and group_uid (to start a new one)." - ) user_obj = await self.get_person(user) if not isinstance(user_obj.profile, dict) or "id" not in user_obj.profile: diff --git a/tests/test_groups.py b/tests/test_groups.py index 626527f..3986b08 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -332,6 +332,27 @@ async def test_get_person_no_match_raises_keyerror(self, spond_with_groups) -> N with pytest.raises(KeyError, match="scanned"): await spond_with_groups.get_person("NOBODY") + @pytest.mark.asyncio + async def test_get_person_empty_string_does_not_match_nameless_member( + self, mock_token + ) -> None: + """`_match_person` must NOT treat `match_str=""` as a hit on records + whose first/last name both default to `""` (so `full_name == ""`). + Regression for the `full_name`-resilience interaction.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.groups = [ + Group.model_validate( + { + "id": "GID", + "name": "G", + "members": [{"id": "M1"}], # no firstName/lastName → full_name="" + } + ) + ] + with pytest.raises(KeyError): + await s.get_person("") + @pytest.mark.asyncio async def test_get_person_no_groups_raises_keyerror(self, mock_token) -> None: """When the account has no groups, a distinct KeyError message is raised.""" From 8cc4683c61aa2d3d0f3552f236b735fa8aade852 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 22:37:57 +0200 Subject: [PATCH 26/42] feat(oo-rewrite): exception hierarchy + entity-identity equality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of the v1.4 incentive surface for the OO API. ## Custom exception hierarchy (spond/exceptions.py) `SpondError` base; everything the SDK raises descends from it. Lookup failures (`EventNotFoundError`, `GroupNotFoundError`, `PersonNotFoundError`, `ChatNotFoundError`) multi-inherit from `KeyError` so pre-OO `except KeyError:` callers keep working. `SpondAPIError` multi-inherits from `ValueError` for the same reason on HTTP failures, and carries `.status` / `.body` / `.url` for new callers that want structured access. `AuthenticationError` moved from `spond/__init__.py` into the new module and re-exported, so `from spond import AuthenticationError` keeps working. Wired into the three raise sites in `spond/spond.py`: `_get_entity()` now raises `EventNotFoundError` or `GroupNotFoundError` depending on the kind; `get_person()` raises `PersonNotFoundError`; HTTP failures in `get_posts()` and `get_events()` raise `SpondAPIError`. ## Entity-identity equality (DictCompatModel) New `_natural_key()` hook on `DictCompatModel`. `__eq__` and `__hash__` compare the natural keys when both sides have one; otherwise fall back to Pydantic's full-field equality between same-class instances. Default natural key: `(entity_kind, uid)` where `entity_kind` walks the MRO to find the closest user-defined ancestor before `DictCompatModel`. So `Match(uid="X") == Event(uid="X")` (both resolve to kind `"Event"`); same for `Member`/`Guardian` → `"Person"`. Each typed model overrides `_natural_key()` to provide a fallback for unsaved instances (no uid yet): | Entity | Fallback key | |-------------|--------------------------------------| | Event | heading + start_time | | Group | name | | Person/Member/Guardian | full_name + email | | Profile | full_name | | Post | title + timestamp | | Chat | name + type | | Transaction | paid_at + payment_name + paid_by_name| | Subgroup | name | | Role | name | | Message | (chat_id, msg_num) — no uid at all | This makes typed instances usable as set members and dict keys following the ORM "same uid → same entity" convention. Tests in `tests/test_identity.py` lock in the semantics across all model types, including the Match/Event cross-kind equality and the collection-key use case. Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/__init__.py | 46 +++++---- spond/_compat.py | 100 +++++++++++++++++++ spond/chat.py | 15 +++ spond/club.py | 15 +++ spond/event.py | 12 +++ spond/exceptions.py | 109 ++++++++++++++++++++ spond/group.py | 9 ++ spond/person.py | 14 +++ spond/post.py | 8 ++ spond/profile.py | 9 ++ spond/role.py | 7 ++ spond/spond.py | 28 ++++-- spond/subgroup.py | 7 ++ tests/test_exceptions.py | 175 ++++++++++++++++++++++++++++++++ tests/test_identity.py | 208 +++++++++++++++++++++++++++++++++++++++ 15 files changed, 732 insertions(+), 30 deletions(-) create mode 100644 spond/exceptions.py create mode 100644 tests/test_exceptions.py create mode 100644 tests/test_identity.py diff --git a/spond/__init__.py b/spond/__init__.py index 70bd3ef..048b73d 100644 --- a/spond/__init__.py +++ b/spond/__init__.py @@ -6,27 +6,33 @@ from typing import Any, TypeAlias +# Re-export the public exception surface from the dedicated module so +# `from spond import AuthenticationError` keeps working for pre-OO callers, +# and `from spond import SpondError, EventNotFoundError, ...` works for +# new callers using the typed-exception hierarchy. +from .exceptions import ( + AuthenticationError, + ChatNotFoundError, + EventNotFoundError, + GroupNotFoundError, + PersonNotFoundError, + SpondAPIError, + SpondError, + SpondNotFoundError, +) + JSONDict: TypeAlias = dict[str, Any] """Simple alias for type hinting `dict`s that can be passed to/from JSON-handling functions.""" -class AuthenticationError(Exception): - """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). - - 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 any of the response's whitelisted - diagnostic fields (`error`, `errorKey`, `errorCode`, `message`) so - most error cases are self-explanatory. Other response fields — such - as 2FA challenge tokens and (masked) `phoneNumber` — are intentionally - dropped from the message to avoid leaking them into application logs. - """ - - pass +__all__ = [ + "AuthenticationError", + "ChatNotFoundError", + "EventNotFoundError", + "GroupNotFoundError", + "JSONDict", + "PersonNotFoundError", + "SpondAPIError", + "SpondError", + "SpondNotFoundError", +] diff --git a/spond/_compat.py b/spond/_compat.py index 2d356a1..561c9a6 100644 --- a/spond/_compat.py +++ b/spond/_compat.py @@ -179,3 +179,103 @@ def items(self) -> list[tuple[str, Any]]: for extra_key, extra_value in self._pydantic_extras().items(): result.append((extra_key, extra_value)) return result + + # ----------------------------------------------------------------- + # Identity / equality / hashing + # + # Pydantic's default `__eq__` compares every field; two `Event` + # instances with the same `uid` but slightly different (e.g. + # `updated`) state are considered different. That's the wrong + # semantics for entity types served by a remote API — most callers + # want "same uid → same entity", and want to use Event instances as + # set members or dict keys. + # + # `_natural_key()` is the override hook. Subclasses return a tuple + # of (entity_kind, *identifying_fields). The default uses + # `(class-tree-root.__name__, uid)` when uid is set so `Match("X")` + # and `Event("X")` are equal (Match's MRO walks back to Event). + # When uid is absent (a freshly-constructed instance not yet + # persisted), subclasses provide a fallback natural key derived + # from user-visible fields (e.g. heading + start_time for Event). + # Returning `None` falls back to Pydantic's full-field equality. + # ----------------------------------------------------------------- + + def _natural_key(self) -> tuple | None: + """Return a tuple uniquely identifying this entity, or `None` to + fall back to Pydantic's full-field equality. + + Default implementation: if the instance has a non-empty `uid`, + the key is `(top-level entity class name, uid)`. This makes + `Match(uid="X") == Event(uid="X")` evaluate True — a sensible + outcome since they refer to the same Spond record. + + Subclasses override to provide a natural key for instances + without a uid yet (e.g. an `Event` about to be created): + + ```python + def _natural_key(self) -> tuple | None: + if self.uid: + return ("Event", self.uid) + if self.heading or self.start_time: + return ("Event", None, self.heading, self.start_time) + return None + ``` + """ + uid = getattr(self, "uid", None) + if uid: + return (_entity_kind_of(type(self)), uid) + return None + + def __eq__(self, other: object) -> bool: + # Falls outside the typed-model graph — let Python try the + # other operand's __eq__ via NotImplemented. + if not isinstance(other, DictCompatModel): + return NotImplemented + a = self._natural_key() + b = other._natural_key() + if a is not None and b is not None: + return a == b + # One or both lack a natural key — fall back to Pydantic's + # full-field equality, but only between same-class instances + # (cross-class full-field equality is rarely meaningful). + if type(self) is not type(other): + return False + return BaseModel.__eq__(self, other) + + def __hash__(self) -> int: + key = self._natural_key() + if key is not None: + return hash(key) + # No natural key (e.g. partially-constructed instance) — fall + # back to identity-based hash so the object is at least + # hashable. Two such instances are unequal under __eq__'s + # full-field-fallback path anyway, so this preserves the + # equality/hash invariant. + return object.__hash__(self) + + +def _entity_kind_of(cls: type) -> str: + """Walk the MRO to find the nearest non-`DictCompatModel`, + non-`BaseModel` ancestor — that's the "entity kind." + + For `Match` (which inherits from `Event`), this returns `"Event"` + so `Match` and `Event` instances with the same uid compare equal. + For `Member`/`Guardian` (both inheriting from `Person`), this + returns `"Person"` for the same reason. + """ + for ancestor in cls.__mro__: + if ancestor is DictCompatModel or ancestor is BaseModel: + break + # The most-derived "non-base" class with a name is the entity + # kind. We walk further up to find the most general one. + # Find the top-most user-defined class in the MRO before DictCompatModel. + user_classes = [ + c + for c in cls.__mro__ + if c not in (DictCompatModel, BaseModel, object) + and c.__module__.startswith("spond") + ] + if not user_classes: + return cls.__name__ + # The last one in MRO before DictCompatModel is the root entity kind. + return user_classes[-1].__name__ diff --git a/spond/chat.py b/spond/chat.py index 43f22ce..418328e 100644 --- a/spond/chat.py +++ b/spond/chat.py @@ -75,6 +75,13 @@ class Message(DictCompatModel): spond: dict[str, Any] | None = None """Set when `type=="SPOND"` — embedded event share.""" + def _natural_key(self) -> tuple | None: + """Messages have no `uid` — identity is `(chat_id, msg_num)`, the + composite key Spond uses to address an individual message.""" + if self.chat_id is not None and self.msg_num is not None: + return ("Message", self.chat_id, self.msg_num) + return None + class Chat(DictCompatModel): """A chat thread — group, direct message, system channel, or campaign. @@ -123,6 +130,14 @@ class Chat(DictCompatModel): def __str__(self) -> str: return f"Chat(uid={self.uid!r}, name={self.name!r}, type={self.type!r})" + def _natural_key(self) -> tuple | None: + """uid when set; otherwise (name, type) for unsaved threads.""" + if self.uid: + return ("Chat", self.uid) + if self.name or self.type: + return ("Chat", None, self.name, self.type) + return None + @classmethod def from_api(cls, data: dict[str, Any], client: Spond | None) -> Chat: """Construct a `Chat` from raw API data and bind the client. diff --git a/spond/club.py b/spond/club.py index c9402ea..7e02ac8 100644 --- a/spond/club.py +++ b/spond/club.py @@ -44,6 +44,21 @@ def __str__(self) -> str: f"paid_by={self.paid_by_name!r})" ) + def _natural_key(self) -> tuple | None: + """uid when set; otherwise (paid_at, payment_name, paid_by_name) + as the natural composite key Spond's UI exposes.""" + if self.uid: + return ("Transaction", self.uid) + if self.paid_at or self.payment_name or self.paid_by_name: + return ( + "Transaction", + None, + self.paid_at, + self.payment_name, + self.paid_by_name, + ) + return None + class SpondClub(_SpondBase): """Async client for the Spond Club finance API. diff --git a/spond/event.py b/spond/event.py index f6f220c..e93b9aa 100644 --- a/spond/event.py +++ b/spond/event.py @@ -198,6 +198,18 @@ def __str__(self) -> str: start = self.start_time.isoformat() if self.start_time else "?" return f"Event(uid={self.uid!r}, heading={self.heading!r}, start_time={start})" + def _natural_key(self) -> tuple | None: + """Entity-identity tuple. uid-based when set; otherwise the + `(heading, start_time)` pair lets a freshly-constructed event + compare equal to itself across copies (useful when staging an + event before `Spond.create_event()` is called). + """ + if self.uid: + return ("Event", self.uid) + if self.heading or self.start_time: + return ("Event", None, self.heading, self.start_time) + return None + @property def url(self) -> str: """Web URL of the event (for opening in a browser).""" diff --git a/spond/exceptions.py b/spond/exceptions.py new file mode 100644 index 0000000..96b2fe5 --- /dev/null +++ b/spond/exceptions.py @@ -0,0 +1,109 @@ +"""Exception hierarchy for the Spond SDK. + +All SDK-raised exceptions descend from `SpondError`, so callers can +`except SpondError:` to catch anything the SDK might raise without +having to enumerate the specific subclasses. + +Lookup failures (`EventNotFoundError`, `GroupNotFoundError`, +`PersonNotFoundError`, `ChatNotFoundError`) additionally inherit from +the stdlib `KeyError` so pre-OO callers that wrote `except KeyError:` +keep working through the v1.x deprecation cycle without modification. + +`AuthenticationError` is re-exported from `spond.__init__` for +backward compatibility — older code imports it as `from spond import +AuthenticationError`. +""" + +from __future__ import annotations + + +class SpondError(Exception): + """Base class for every exception raised by this SDK. + + Catch this to handle any SDK-originated failure without naming the + specific subclass. + """ + + +class AuthenticationError(SpondError): + """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). + - 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 any of the response's whitelisted + diagnostic fields (`error`, `errorKey`, `errorCode`, `message`) so + most error cases are self-explanatory. Other response fields — such + as 2FA challenge tokens and (masked) `phoneNumber` — are intentionally + dropped from the message to avoid leaking them into application logs. + """ + + +class SpondAPIError(SpondError, ValueError): + """Raised when the Spond API returns a non-success HTTP status. + + Carries the HTTP status code and the response body (truncated) so + callers can branch on either. + + Multi-inherits from `ValueError` so pre-OO callers that wrote + `except ValueError:` against the previous `raise ValueError(...)` + on HTTP failure keep working through the v1.x deprecation cycle. + New callers should use `except SpondAPIError:` and read + `.status` / `.body` / `.url` directly. + """ + + def __init__(self, status: int, body: str = "", url: str | None = None) -> None: + self.status = status + self.body = body + self.url = url + # Truncate body in the message to keep log noise bounded + # (the full body is still available on `self.body`). + trimmed = body[:500] + ("…" if len(body) > 500 else "") + # Preserve the pre-OO `ValueError` message shape so callers + # matching on substring (e.g. `match="401"`) still work. + if body: + msg = f"Request failed with status {status}: {trimmed}" + else: + msg = f"Spond API returned HTTP {status}" + if url and not body: + msg += f" for {url}" + super().__init__(msg) + + +class SpondNotFoundError(SpondError, KeyError): + """Base for "lookup-by-id failed" errors. + + Multi-inherits from `KeyError` so existing `except KeyError:` callers + written against the pre-OO dict-shaped API keep working unchanged. + Catch `SpondNotFoundError` for the typed form, or `KeyError` for the + permissive form. + """ + + +class EventNotFoundError(SpondNotFoundError): + """Raised by `Spond.get_event(uid)` when no event with the given uid + exists in the cache (and a refresh didn't surface one either).""" + + +class GroupNotFoundError(SpondNotFoundError): + """Raised by `Spond.get_group(uid)` when no group with the given uid + exists in the cache.""" + + +class PersonNotFoundError(SpondNotFoundError): + """Raised by `Spond.get_person(identifier)` when no member or guardian + matches the identifier across any of the authenticated user's groups.""" + + +class ChatNotFoundError(SpondNotFoundError): + """Raised by chat-list lookups when no chat with the given uid exists. + + Reserved for the future `Spond.get_chat(uid)` shape; the current + `get_messages()` returns a list and doesn't look up by uid. + """ diff --git a/spond/group.py b/spond/group.py index 8355357..afbede2 100644 --- a/spond/group.py +++ b/spond/group.py @@ -124,6 +124,15 @@ def __str__(self) -> str: f"Group(uid={self.uid!r}, name={self.name!r}, members={len(self.members)})" ) + def _natural_key(self) -> tuple | None: + """uid when set; otherwise the group `name` distinguishes + unsaved groups.""" + if self.uid: + return ("Group", self.uid) + if self.name: + return ("Group", None, self.name) + return None + @classmethod def from_api(cls, data: dict[str, Any], client: Spond) -> Group: """Construct a `Group` from raw API data and wire `_client` through. diff --git a/spond/person.py b/spond/person.py index fa12632..c5d7b54 100644 --- a/spond/person.py +++ b/spond/person.py @@ -68,6 +68,20 @@ def full_name(self) -> str: def __str__(self) -> str: return f"{self.__class__.__name__}(uid={self.uid!r}, name={self.full_name!r})" + def _natural_key(self) -> tuple | None: + """uid when set; otherwise full_name + email. Returns the same + kind tag (`"Person"`) for Member and Guardian, so two records + for the same human (one a member, one a guardian elsewhere) + compare unequal only if their identifiers differ — matching + Spond's data model where the same uid never appears in both + roles for the same account.""" + if self.uid: + return ("Person", self.uid) + email = getattr(self, "email", None) + if self.full_name or email: + return ("Person", None, self.full_name, email) + return None + class Guardian(Person): """A guardian attached to a `Member`. diff --git a/spond/post.py b/spond/post.py index 3783ccf..2ff132a 100644 --- a/spond/post.py +++ b/spond/post.py @@ -50,3 +50,11 @@ def __str__(self) -> str: # `start_time`. Avoids `AttributeError` when Spond omits the field. ts = self.timestamp.isoformat() if self.timestamp else "?" return f"Post(uid={self.uid!r}, title={self.title!r}, timestamp={ts})" + + def _natural_key(self) -> tuple | None: + """uid when set; otherwise (title, timestamp) for unsaved posts.""" + if self.uid: + return ("Post", self.uid) + if self.title or self.timestamp: + return ("Post", None, self.title, self.timestamp) + return None diff --git a/spond/profile.py b/spond/profile.py index ac731e1..5c5cec9 100644 --- a/spond/profile.py +++ b/spond/profile.py @@ -72,3 +72,12 @@ def full_name(self) -> str: def __str__(self) -> str: return f"Profile(uid={self.uid!r}, name={self.full_name!r})" + + def _natural_key(self) -> tuple | None: + """uid when set; otherwise full_name distinguishes + freshly-constructed profile instances.""" + if self.uid: + return ("Profile", self.uid) + if self.full_name: + return ("Profile", None, self.full_name) + return None diff --git a/spond/role.py b/spond/role.py index 614d236..b0af30e 100644 --- a/spond/role.py +++ b/spond/role.py @@ -24,3 +24,10 @@ class Role(DictCompatModel): def __str__(self) -> str: return f"Role(uid={self.uid!r}, name={self.name!r})" + + def _natural_key(self) -> tuple | None: + if self.uid: + return ("Role", self.uid) + if self.name: + return ("Role", None, self.name) + return None diff --git a/spond/spond.py b/spond/spond.py index 722b4cd..e082752 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -16,6 +16,12 @@ from .base import _SpondBase from .chat import Chat from .event import Event +from .exceptions import ( + EventNotFoundError, + GroupNotFoundError, + PersonNotFoundError, + SpondAPIError, +) from .group import Group from .match import Match from .person import Member, Person @@ -242,7 +248,7 @@ async def get_person(self, user: str) -> Person: # type so existing `except KeyError:` callers keep working, but # the message tells the caller which situation they're in. if not self.groups: - raise KeyError( + raise PersonNotFoundError( f"No person matched with identifier {user!r}: account has " f"no groups, so there are no members to search." ) @@ -253,7 +259,7 @@ async def get_person(self, user: str) -> Person: for guardian in member.guardians: if self._match_person(guardian, user): return guardian - raise KeyError( + raise PersonNotFoundError( f"No person matched with identifier {user!r}: scanned " f"{sum(len(g.members) for g in self.groups)} member(s) across " f"{len(self.groups)} group(s)." @@ -352,9 +358,7 @@ async def get_posts( ) as r: if not r.ok: error_details = await r.text() - raise ValueError( - f"Request failed with status {r.status}: {error_details}" - ) + raise SpondAPIError(r.status, error_details, url) raw = await r.json() if raw is None: self.posts = None @@ -612,9 +616,7 @@ async def get_events( ) as r: if not r.ok: error_details = await r.text() - raise ValueError( - f"Request failed with status {r.status}: {error_details}" - ) + raise SpondAPIError(r.status, error_details, url) raw = await r.json() if raw is None: self.events = None @@ -791,10 +793,16 @@ async def _get_entity(self, entity_type: str, uid: str) -> Event | Group: raise NotImplementedError(errmsg) errmsg = f"No {entity_type} with id='{uid}'." + # Pick the typed subclass so callers can `except EventNotFoundError` + # (or `except GroupNotFoundError`) specifically. Both still inherit + # from KeyError for pre-OO compat. + exc_cls = ( + EventNotFoundError if entity_type == self._EVENT else GroupNotFoundError + ) if not entities: - raise KeyError(errmsg) + raise exc_cls(errmsg) for entity in entities: if entity.uid == uid: return entity - raise KeyError(errmsg) + raise exc_cls(errmsg) diff --git a/spond/subgroup.py b/spond/subgroup.py index d1d68fe..ca04c5c 100644 --- a/spond/subgroup.py +++ b/spond/subgroup.py @@ -26,3 +26,10 @@ class Subgroup(DictCompatModel): def __str__(self) -> str: return f"Subgroup(uid={self.uid!r}, name={self.name!r})" + + def _natural_key(self) -> tuple | None: + if self.uid: + return ("Subgroup", self.uid) + if self.name: + return ("Subgroup", None, self.name) + return None diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..e340dc6 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,175 @@ +"""Tests for the typed exception hierarchy. + +Covers: +- Inheritance graph (every typed exception descends from `SpondError`). +- Backward compatibility: `*NotFoundError` is still a `KeyError`, + `SpondAPIError` is still a `ValueError`, and `AuthenticationError` + is still importable from `spond` top-level. +- Raise sites use the typed forms (`get_event` raises `EventNotFoundError`, + `get_person` raises `PersonNotFoundError`, `get_posts` HTTP failure + raises `SpondAPIError`). +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from spond import ( + AuthenticationError, + ChatNotFoundError, + EventNotFoundError, + GroupNotFoundError, + PersonNotFoundError, + SpondAPIError, + SpondError, + SpondNotFoundError, +) +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_USERNAME + + +class TestExceptionHierarchy: + """The hierarchy must be coherent: catching the base catches everything.""" + + def test_all_descend_from_spond_error(self) -> None: + for cls in ( + AuthenticationError, + SpondAPIError, + SpondNotFoundError, + EventNotFoundError, + GroupNotFoundError, + PersonNotFoundError, + ChatNotFoundError, + ): + assert issubclass(cls, SpondError), ( + f"{cls.__name__} must descend from SpondError" + ) + + def test_not_found_exceptions_are_keyerror(self) -> None: + """Pre-OO callers wrote `except KeyError:` — the typed forms must + remain compatible with that pattern.""" + for cls in ( + EventNotFoundError, + GroupNotFoundError, + PersonNotFoundError, + ChatNotFoundError, + SpondNotFoundError, + ): + assert issubclass(cls, KeyError), ( + f"{cls.__name__} must inherit from KeyError" + ) + + def test_api_error_is_valueerror(self) -> None: + """Pre-OO callers wrote `except ValueError:` for HTTP failures — + `SpondAPIError` must remain compatible.""" + assert issubclass(SpondAPIError, ValueError) + + def test_api_error_carries_status_body_url(self) -> None: + exc = SpondAPIError(401, "Unauthorized", "https://api.spond.com/test") + assert exc.status == 401 + assert exc.body == "Unauthorized" + assert exc.url == "https://api.spond.com/test" + # And the legacy message shape is preserved for substring-matchers + assert "401" in str(exc) + assert "Unauthorized" in str(exc) + + def test_api_error_truncates_long_body(self) -> None: + long_body = "x" * 10000 + exc = SpondAPIError(500, long_body) + # body attr keeps the full string, but the str() form is bounded + assert exc.body == long_body + assert len(str(exc)) < 1500 + + def test_authentication_error_still_top_level_importable(self) -> None: + """Pre-OO callers do `from spond import AuthenticationError` — that + import path must keep working through the v1.x deprecation cycle.""" + # Already imported at module level; just assert it's the same class + from spond.exceptions import AuthenticationError as AE2 + + assert AuthenticationError is AE2 + + +class TestRaiseSitesUseTypedExceptions: + """Verify the production raise sites use the typed forms, not bare + stdlib classes.""" + + @pytest.mark.asyncio + async def test_get_event_raises_event_not_found(self, mock_token) -> None: + from spond.event import Event + + from .conftest import _MIN_EVENT_PAYLOAD + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + # Non-empty cache so the re-fetch doesn't fire; just no match. + s.events = [Event.model_validate(_MIN_EVENT_PAYLOAD)] + + with pytest.raises(EventNotFoundError): + await s.get_event("NOSUCHID") + + @pytest.mark.asyncio + async def test_get_event_caught_by_keyerror(self, mock_token) -> None: + """Backward compat: `except KeyError:` still works for `get_event`.""" + from spond.event import Event + + from .conftest import _MIN_EVENT_PAYLOAD + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.events = [Event.model_validate(_MIN_EVENT_PAYLOAD)] + + with pytest.raises(KeyError): + await s.get_event("NOSUCHID") + + @pytest.mark.asyncio + async def test_get_group_raises_group_not_found(self, mock_token) -> None: + from spond.group import Group + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.groups = [Group.model_validate({"id": "EXISTS"})] + + with pytest.raises(GroupNotFoundError): + await s.get_group("NOSUCHID") + + @pytest.mark.asyncio + async def test_get_person_raises_person_not_found(self, mock_token) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.groups = [] + s.get_groups = AsyncMock(return_value=None) + + with pytest.raises(PersonNotFoundError): + await s.get_person("anyone") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_http_failure_raises_spond_api_error( + self, mock_get, mock_token + ) -> None: + """`get_posts` HTTP failure path now raises `SpondAPIError`. + Verify both the typed form and the legacy `ValueError` catch path.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = False + mock_get.return_value.__aenter__.return_value.status = 503 + mock_get.return_value.__aenter__.return_value.text = AsyncMock( + return_value="Service Unavailable" + ) + + with pytest.raises(SpondAPIError) as exc_info: + await s.get_posts() + + assert exc_info.value.status == 503 + assert "Service Unavailable" in exc_info.value.body + + # And the legacy `except ValueError:` shape still works + mock_get.return_value.__aenter__.return_value.text = AsyncMock( + return_value="Service Unavailable" + ) + with pytest.raises(ValueError): # noqa: PT011 — testing inheritance + await s.get_posts() diff --git a/tests/test_identity.py b/tests/test_identity.py new file mode 100644 index 0000000..ffd625b --- /dev/null +++ b/tests/test_identity.py @@ -0,0 +1,208 @@ +"""Tests for entity-identity equality (`__eq__` / `__hash__`) on typed models. + +The `_natural_key()` hook on `DictCompatModel` drives equality and hashing. +Two typed instances compare equal when their natural keys match; the +natural key uses `uid` when set, and falls back to user-visible fields +(heading + start_time for Event, name for Group, etc.) for instances +that haven't been saved to Spond yet. + +Match/Event share an entity kind so `Match("X") == Event("X")` evaluates +True — they refer to the same Spond record. Member/Guardian share the +`"Person"` kind for the same reason. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from spond.chat import Chat, Message +from spond.club import Transaction +from spond.event import Event +from spond.group import Group +from spond.match import Match +from spond.person import Guardian, Member +from spond.post import Post +from spond.profile import Profile +from spond.role import Role +from spond.subgroup import Subgroup + +from .conftest import _MIN_EVENT_PAYLOAD + + +class TestUIDBasedEquality: + """When uid is set, instances of the same entity-kind compare equal + iff their uids match — regardless of other field values.""" + + def test_two_events_with_same_uid_are_equal(self) -> None: + a = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1"}) + # Different heading, same uid → still equal + b = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1", "heading": "X"}) + assert a == b + + def test_two_events_with_different_uid_are_unequal(self) -> None: + a = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1"}) + b = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV2"}) + assert a != b + + def test_event_hash_matches_uid_identity(self) -> None: + a = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1"}) + b = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1", "heading": "X"}) + assert hash(a) == hash(b) + assert {a, b} == {a} # set dedups on hash + eq + + def test_match_and_event_with_same_uid_are_equal(self) -> None: + """Match is a subclass of Event; they share the `"Event"` entity + kind so the same Spond record returned as `Event` or `Match` + compares equal.""" + e = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1"}) + m = Match.model_validate( + {**_MIN_EVENT_PAYLOAD, "id": "EV1", "matchEvent": True} + ) + assert e == m + assert hash(e) == hash(m) + + def test_member_and_guardian_with_same_uid_are_equal(self) -> None: + """Both inherit from Person → share the `"Person"` entity kind. + In practice the same Spond uid never appears in both roles, but + the equality semantics are consistent if it did.""" + mem = Member.model_validate({"id": "P1", "firstName": "A"}) + grd = Guardian.model_validate({"id": "P1", "firstName": "A"}) + assert mem == grd + + def test_group_uid_equality(self) -> None: + a = Group.model_validate({"id": "G1", "name": "Alpha"}) + b = Group.model_validate({"id": "G1", "name": "Beta"}) + assert a == b + + def test_post_uid_equality(self) -> None: + a = Post.model_validate({"id": "P1", "title": "Hi"}) + b = Post.model_validate({"id": "P1", "title": "Different"}) + assert a == b + + def test_chat_uid_equality(self) -> None: + a = Chat.model_validate({"id": "C1", "name": "x"}) + b = Chat.model_validate({"id": "C1", "name": "y"}) + assert a == b + + def test_transaction_uid_equality(self) -> None: + a = Transaction.model_validate({"id": "T1", "paymentName": "Fee"}) + b = Transaction.model_validate({"id": "T1", "paymentName": "Other"}) + assert a == b + + def test_subgroup_uid_equality(self) -> None: + assert Subgroup.model_validate( + {"id": "S1", "name": "x"} + ) == Subgroup.model_validate({"id": "S1", "name": "y"}) + + def test_role_uid_equality(self) -> None: + assert Role.model_validate({"id": "R1", "name": "x"}) == Role.model_validate( + {"id": "R1", "name": "y"} + ) + + +class TestNaturalKeyFallback: + """When uid is absent (a freshly-constructed unsaved instance), the + natural-key fallback distinguishes by user-visible fields.""" + + def test_two_unsaved_events_with_same_heading_and_start_are_equal(self) -> None: + start = datetime(2026, 6, 1, 10, 0, tzinfo=UTC) + # Construct via model_validate without `id` — Event requires it + # but model_construct skips validation. Use the canonical form: + # set id="" so the natural_key falls through to the heading path. + a = Event(uid="", heading="Demo", start_time=start, end_time=start) + b = Event(uid="", heading="Demo", start_time=start, end_time=start) + assert a == b + assert hash(a) == hash(b) + + def test_two_unsaved_events_with_different_heading_are_unequal(self) -> None: + start = datetime(2026, 6, 1, 10, 0, tzinfo=UTC) + a = Event(uid="", heading="A", start_time=start, end_time=start) + b = Event(uid="", heading="B", start_time=start, end_time=start) + assert a != b + + def test_unsaved_event_unequal_to_saved_with_same_fields(self) -> None: + """A saved event (with uid) and an unsaved event (without uid) + but matching heading+start_time are NOT equal — the natural key + includes a sentinel `None` slot for unsaved entities.""" + start = datetime(2026, 6, 1, 10, 0, tzinfo=UTC) + saved = Event(uid="EV1", heading="Demo", start_time=start, end_time=start) + unsaved = Event(uid="", heading="Demo", start_time=start, end_time=start) + assert saved != unsaved + + def test_unsaved_group_by_name(self) -> None: + a = Group(uid="", name="Cool Group") + b = Group(uid="", name="Cool Group") + assert a == b + assert a != Group(uid="", name="Other") + + def test_unsaved_person_by_name_and_email(self) -> None: + a = Member(uid="", first_name="Alice", last_name="Smith", email="a@b.invalid") + b = Member(uid="", first_name="Alice", last_name="Smith", email="a@b.invalid") + assert a == b + # Differ on email — not equal + c = Member( + uid="", first_name="Alice", last_name="Smith", email="other@b.invalid" + ) + assert a != c + + def test_unsaved_profile_by_name(self) -> None: + a = Profile(uid="", first_name="Ola", last_name="N") + b = Profile(uid="", first_name="Ola", last_name="N") + assert a == b + + def test_unsaved_post_by_title_and_timestamp(self) -> None: + ts = datetime(2026, 6, 1, 10, 0, tzinfo=UTC) + a = Post(uid="", title="Hi", timestamp=ts) + b = Post(uid="", title="Hi", timestamp=ts) + assert a == b + + +class TestMessageNaturalKey: + """Message has no uid — identity is `(chat_id, msg_num)`.""" + + def test_messages_with_same_chat_id_and_num_are_equal(self) -> None: + a = Message.model_validate({"chatId": "C1", "msgNum": 7, "text": "hi"}) + b = Message.model_validate({"chatId": "C1", "msgNum": 7, "text": "different"}) + assert a == b + assert hash(a) == hash(b) + + def test_messages_with_different_msg_num_are_unequal(self) -> None: + a = Message.model_validate({"chatId": "C1", "msgNum": 7}) + b = Message.model_validate({"chatId": "C1", "msgNum": 8}) + assert a != b + + +class TestUseAsCollectionKey: + """The motivating use case for natural-key equality: typed models as + set members and dict keys.""" + + def test_event_dedup_in_set(self) -> None: + a = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1"}) + b = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1", "heading": "X"}) + c = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV2"}) + assert len({a, b, c}) == 2 + + def test_member_as_dict_key(self) -> None: + m1 = Member.model_validate({"id": "P1", "firstName": "A", "lastName": "B"}) + m2 = Member.model_validate({"id": "P1", "firstName": "A", "lastName": "B"}) + d = {m1: "first"} + # Same uid → same key, overwrites + d[m2] = "second" + assert len(d) == 1 + assert d[m1] == "second" + + +class TestCrossTypeEquality: + """An entity of one kind should never equal an entity of a different + kind — even if their uids happen to collide.""" + + def test_event_unequal_to_group_with_same_uid(self) -> None: + e = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "X"}) + g = Group.model_validate({"id": "X"}) + assert e != g + + def test_event_unequal_to_non_typed_object(self) -> None: + e = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "X"}) + assert e != "X" + assert e != {"id": "X"} + assert e != 42 From b37d25f6e0c3730c543999d1877505fbe9273ba0 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 22:44:03 +0200 Subject: [PATCH 27/42] feat(oo-rewrite): Event convenience properties + member-resolution helpers + model_equals escape hatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Event convenience properties Synchronous, pure-Python, no HTTP: - `event.is_past` — True if `end_time` (or `start_time` if no end) is past `datetime.now(UTC)`. Returns False when both are None. - `event.is_upcoming` — True if `start_time` is future. Not strictly the negation of `is_past`: an event with no `start_time` returns False for both. - `event.duration` — `end_time - start_time` as `timedelta`, or None when either is missing. - `event.response_for(uid)` — bucket name (`"accepted"`/`"declined"`/ `"unanswered"`/`"waiting_list"`/`"unconfirmed"`) or None if the uid isn't invited. - `event.has_responded(uid)` — True for any concrete response other than `unanswered`. ## Member-resolution helpers Resolve `responses.*_uids` to typed `Member`/`Guardian` objects via the client's group cache, lazy-fetching groups if needed. UIDs that no longer match any current group member are silently dropped (left members aren't an error). - `await event.accepted_members()` - `await event.declined_members()` - `await event.unanswered_members()` - `await event.waiting_list_members()` - `await event.unconfirmed_members()` Requires `_client` (set automatically by `Spond.get_event()` / `get_events()`); raises `RuntimeError` if absent. ## Backward-compat escape hatch: model_equals() The new natural-key `__eq__` (uid-based when set, field-based as fallback) is a semantic change for any caller that depended on Pydantic's pre-OO full-field equality (e.g. "has the server-side state changed?"). `model_equals(other)` gives back the old behaviour for those callers: ```python if not new_event.model_equals(old_event): # state actually changed, not just same uid ... ``` `tests/test_backward_compat.py` locks in the pre-OO surface explicitly: dict-style access, KeyError/ValueError catch patterns, top-level `from spond import AuthenticationError`, deprecated wrappers still emit `DeprecationWarning`. If any of these regress, the suite fails before release. Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/_compat.py | 24 ++++ spond/event.py | 137 ++++++++++++++++++++++- tests/test_backward_compat.py | 161 ++++++++++++++++++++++++++ tests/test_event_convenience.py | 192 ++++++++++++++++++++++++++++++++ tests/test_event_members.py | 184 ++++++++++++++++++++++++++++++ 5 files changed, 697 insertions(+), 1 deletion(-) create mode 100644 tests/test_backward_compat.py create mode 100644 tests/test_event_convenience.py create mode 100644 tests/test_event_members.py diff --git a/spond/_compat.py b/spond/_compat.py index 561c9a6..e9b1118 100644 --- a/spond/_compat.py +++ b/spond/_compat.py @@ -253,6 +253,30 @@ def __hash__(self) -> int: # equality/hash invariant. return object.__hash__(self) + def model_equals(self, other: object) -> bool: + """Pre-OO escape hatch: full-field equality, ignoring natural keys. + + The default `==` operator on typed models compares natural keys + (uid when set, or user-visible fields), so two `Event` instances + with the same uid but different field state compare equal. That's + usually the right semantics for entity types, but a pre-OO caller + that depended on Pydantic's field-by-field equality (e.g. to + detect "has this event been mutated server-side?") can use this + method instead: + + ```python + if not new_event.model_equals(old_event): + # state actually changed, not just the same record + ... + ``` + + Returns True iff `other` is the same class and every declared + field (plus `__pydantic_extra__`) matches. + """ + if type(self) is not type(other): + return False + return BaseModel.__eq__(self, other) + def _entity_kind_of(cls: type) -> str: """Walk the MRO to find the nearest non-`DictCompatModel`, diff --git a/spond/event.py b/spond/event.py index e93b9aa..0a5b278 100644 --- a/spond/event.py +++ b/spond/event.py @@ -12,7 +12,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime, timedelta from enum import StrEnum from typing import TYPE_CHECKING, Any @@ -215,6 +215,141 @@ def url(self) -> str: """Web URL of the event (for opening in a browser).""" return f"https://spond.com/client/sponds/{self.uid}/" + @property + def is_past(self) -> bool: + """True when the event has finished (or started, if no `end_time`). + + An event with no `start_time` is never "past" (the API hasn't + committed it to a calendar slot yet) — returns False. + """ + # Prefer end_time; fall back to start_time when end isn't set. + reference = self.end_time or self.start_time + if reference is None: + return False + return reference < datetime.now(UTC) + + @property + def is_upcoming(self) -> bool: + """True when the event hasn't started yet. Opposite face of + `is_past`, but **not** strictly its negation — an event with no + `start_time` returns False for both.""" + if self.start_time is None: + return False + return self.start_time > datetime.now(UTC) + + @property + def duration(self) -> timedelta | None: + """`end_time - start_time` when both are present; otherwise `None`. + + Returns a `datetime.timedelta`. Useful for calendar sync, slot + comparison, and reporting. + """ + if self.start_time is None or self.end_time is None: + return None + return self.end_time - self.start_time + + def response_for(self, member_uid: str) -> str | None: + """Return the response status of `member_uid` on this event. + + Returns one of `"accepted"`, `"declined"`, `"unanswered"`, + `"waiting_list"`, `"unconfirmed"` — or `None` if the uid doesn't + appear in any of the response lists (i.e. not invited). + + Synchronous; reads from the already-populated `responses` field. + Doesn't issue HTTP. Pair with `await event.accepted_members()` + and siblings to resolve uids → typed members. + """ + if not self.responses: + return None + for status, uids in ( + ("accepted", self.responses.accepted_uids), + ("declined", self.responses.declined_uids), + ("unanswered", self.responses.unanswered_uids), + ("waiting_list", self.responses.waiting_list_uids), + ("unconfirmed", self.responses.unconfirmed_uids), + ): + if member_uid in uids: + return status + return None + + def has_responded(self, member_uid: str) -> bool: + """True when `member_uid` has given any concrete response + (`accepted`, `declined`, `waiting_list`, or `unconfirmed`) — i.e. + their uid is in any list other than `unanswered_uids`.""" + status = self.response_for(member_uid) + return status is not None and status != "unanswered" + + async def _resolve_uids_to_persons(self, uids: list[str]) -> list[Any]: + """Resolve member UIDs to typed `Member`/`Guardian` objects. + + Walks the client's `groups` cache (fetching via `get_groups()` if + empty) and returns one typed `Person` per uid. UIDs that don't + match any current group member are silently skipped — Spond + sometimes retains response records for members who've since left. + + Used by `accepted_members()` and siblings. Requires `_client`. + """ + if self._client is None: + raise RuntimeError( + "Event has no client attached; member-resolution helpers " + "require an instance constructed via Spond.get_event() or " + "Spond.get_events()." + ) + if not self._client.groups: + await self._client.get_groups() + if not self._client.groups: + return [] + # Build a single uid → person lookup across all groups, so the + # per-uid scan is O(uids) not O(uids × groups × members). + index: dict[str, Any] = {} + for group in self._client.groups: + for member in group.members: + index.setdefault(member.uid, member) + for guardian in member.guardians: + index.setdefault(guardian.uid, guardian) + return [index[uid] for uid in uids if uid in index] + + async def accepted_members(self) -> list[Any]: + """Resolve `responses.accepted_uids` to typed `Member`/`Guardian` + objects via the client's group cache. Lazy — fetches groups if the + cache is empty. + + Returns + ------- + list[Member | Guardian] + One typed `Person` per uid that still resolves to a current + group member. UIDs that no longer correspond to a member of + any group are silently omitted from the result. + + Raises + ------ + RuntimeError + The Event was constructed without a client (e.g. via + `Event.model_validate(raw)` directly rather than + `Spond.get_event()`). Helpers need a client to fetch groups. + """ + return await self._resolve_uids_to_persons(self.responses.accepted_uids) + + async def declined_members(self) -> list[Any]: + """Resolve `responses.declined_uids` to typed `Member`/`Guardian` + objects. See `accepted_members` for semantics.""" + return await self._resolve_uids_to_persons(self.responses.declined_uids) + + async def unanswered_members(self) -> list[Any]: + """Resolve `responses.unanswered_uids` to typed `Member`/`Guardian` + objects. See `accepted_members` for semantics.""" + return await self._resolve_uids_to_persons(self.responses.unanswered_uids) + + async def waiting_list_members(self) -> list[Any]: + """Resolve `responses.waiting_list_uids` to typed `Member`/`Guardian` + objects. See `accepted_members` for semantics.""" + return await self._resolve_uids_to_persons(self.responses.waiting_list_uids) + + async def unconfirmed_members(self) -> list[Any]: + """Resolve `responses.unconfirmed_uids` to typed `Member`/`Guardian` + objects. See `accepted_members` for semantics.""" + return await self._resolve_uids_to_persons(self.responses.unconfirmed_uids) + @classmethod def from_api(cls, data: dict[str, Any], client: Spond | None) -> Event: """Construct an `Event` from a raw API response and bind the client. diff --git a/tests/test_backward_compat.py b/tests/test_backward_compat.py new file mode 100644 index 0000000..81fed6b --- /dev/null +++ b/tests/test_backward_compat.py @@ -0,0 +1,161 @@ +"""Backward-compatibility regression guards for pre-OO callers. + +These tests don't probe new behaviour — they exist to catch the day a +refactor accidentally drops one of the pre-OO patterns that callers in +the wild depend on. If anything here fails, it's a release-blocker for +the next v1.x. + +Covered: +- `event["heading"]` and `event.get("heading")` still work +- `"heading" in event`, `len(event)`, `list(event)` still work +- `from spond import AuthenticationError` still works +- `except KeyError:` still catches `get_event`/`get_group`/`get_person` + lookup failures +- `except ValueError:` still catches HTTP failures +- `event.model_equals(other)` escape hatch returns full-field equality + for callers who depended on Pydantic's pre-natural-key default +- Deprecated wrappers `Spond.update_event`/`change_response`/ + `get_event_attendance_xlsx` still exist and emit `DeprecationWarning` +""" + +from __future__ import annotations + +import warnings + +import pytest + +from spond import ( + AuthenticationError, + EventNotFoundError, + GroupNotFoundError, + PersonNotFoundError, + SpondAPIError, +) +from spond.event import Event +from spond.spond import Spond + +from .conftest import _MIN_EVENT_PAYLOAD, MOCK_PASSWORD, MOCK_USERNAME + + +class TestDictStyleAccessStillWorks: + """The DictCompatModel shim is the central backward-compat surface.""" + + def test_subscript_returns_value_with_deprecation_warning(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + value = e["heading"] + assert value == "Event One" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + def test_alias_subscript_also_works(self) -> None: + """Pre-OO callers used the camelCase API name (`startTimestamp`), + not the Python name (`start_time`).""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + assert e["startTimestamp"] is not None + assert e["id"] == "ID1" + + def test_get_with_default_returns_default(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + sentinel = object() + assert e.get("nope", sentinel) is sentinel + + def test_contains_works(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + assert "heading" in e + assert "startTimestamp" in e + assert "missing" not in e + + def test_iter_and_len_consistent(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + keys = list(e) + assert len(e) == len(keys) + for k in keys: + assert k in e + + +class TestExceptionsBackwardCompat: + """Existing `except KeyError:` / `except ValueError:` / `from spond + import AuthenticationError` patterns must keep working.""" + + def test_authentication_error_top_level_import(self) -> None: + """`from spond import AuthenticationError` is the pre-OO shape; + the new exceptions module just re-exports it.""" + from spond.exceptions import AuthenticationError as AE2 + + assert AuthenticationError is AE2 + + def test_event_not_found_caught_as_keyerror(self) -> None: + """Pre-OO code: `try: e = await s.get_event(uid) ... except KeyError:`""" + assert issubclass(EventNotFoundError, KeyError) + + def test_group_not_found_caught_as_keyerror(self) -> None: + assert issubclass(GroupNotFoundError, KeyError) + + def test_person_not_found_caught_as_keyerror(self) -> None: + assert issubclass(PersonNotFoundError, KeyError) + + def test_api_error_caught_as_valueerror(self) -> None: + """Pre-OO code did `except ValueError:` for HTTP failures.""" + assert issubclass(SpondAPIError, ValueError) + + +class TestModelEqualsEscapeHatch: + """`model_equals` provides full-field equality for callers who + depended on Pydantic's pre-natural-key default.""" + + def test_same_uid_different_state_unequal_under_model_equals(self) -> None: + """Two events with the same uid but different heading are equal + under `==` (natural-key match) but unequal under `model_equals` + (full field comparison).""" + a = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "X"}) + b = Event.model_validate( + {**_MIN_EVENT_PAYLOAD, "id": "X", "heading": "Different"} + ) + + assert a == b # natural-key equality + assert not a.model_equals(b) # field-by-field disagreement + + def test_identical_state_equal_under_model_equals(self) -> None: + a = Event.model_validate(_MIN_EVENT_PAYLOAD) + b = Event.model_validate(_MIN_EVENT_PAYLOAD) + assert a.model_equals(b) + + def test_different_class_unequal_under_model_equals(self) -> None: + from spond.group import Group + + e = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "X"}) + g = Group.model_validate({"id": "X"}) + assert not e.model_equals(g) + + +class TestDeprecatedWrappersStillExist: + """The pre-OO write methods (`update_event`, `change_response`, + `get_event_attendance_xlsx`) must still be present on `Spond` and + must emit `DeprecationWarning`.""" + + @pytest.mark.asyncio + async def test_update_event_emits_deprecation(self, mock_token) -> None: + from unittest.mock import AsyncMock, patch + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.events = [Event.from_api(_MIN_EVENT_PAYLOAD, s)] + + with patch("aiohttp.ClientSession.post") as mock_post: + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_MIN_EVENT_PAYLOAD + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await s.update_event(uid="ID1", updates={"heading": "X"}) + + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + def test_change_response_method_present(self) -> None: + assert callable(Spond.change_response) + + def test_get_event_attendance_xlsx_method_present(self) -> None: + assert callable(Spond.get_event_attendance_xlsx) diff --git a/tests/test_event_convenience.py b/tests/test_event_convenience.py new file mode 100644 index 0000000..6bbf2fb --- /dev/null +++ b/tests/test_event_convenience.py @@ -0,0 +1,192 @@ +"""Tests for the synchronous convenience properties on `Event`: +`is_past`, `is_upcoming`, `duration`, `has_responded(uid)`, +`response_for(uid)`. + +All five are pure-Python: no HTTP, no client required. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from spond.event import Event + +from .conftest import _MIN_EVENT_PAYLOAD + + +def _event_at(start: datetime, end: datetime | None = None, **overrides) -> Event: + """Build an Event with the given start/end and arbitrary overrides.""" + payload = { + **_MIN_EVENT_PAYLOAD, + "startTimestamp": start.isoformat(), + "endTimestamp": (end or start + timedelta(hours=1)).isoformat(), + **overrides, + } + return Event.model_validate(payload) + + +class TestIsPast: + def test_event_ending_in_past_is_past(self) -> None: + past_start = datetime.now(UTC) - timedelta(days=2) + past_end = past_start + timedelta(hours=1) + assert _event_at(past_start, past_end).is_past + + def test_event_ending_in_future_is_not_past(self) -> None: + future_start = datetime.now(UTC) + timedelta(days=2) + future_end = future_start + timedelta(hours=1) + assert not _event_at(future_start, future_end).is_past + + def test_event_without_start_or_end_is_not_past(self) -> None: + """A half-populated record (no start/end) is never "past" — we don't + know when it happens, so we don't claim it's over.""" + e = Event.model_validate( + {**_MIN_EVENT_PAYLOAD, "startTimestamp": None, "endTimestamp": None} + ) + assert not e.is_past + + def test_event_with_only_start_time_uses_start(self) -> None: + """If `end_time` is unset, `is_past` falls back to `start_time`.""" + past_start = datetime.now(UTC) - timedelta(days=2) + e = Event.model_validate( + { + **_MIN_EVENT_PAYLOAD, + "startTimestamp": past_start.isoformat(), + "endTimestamp": None, + } + ) + assert e.is_past + + +class TestIsUpcoming: + def test_future_event_is_upcoming(self) -> None: + future = datetime.now(UTC) + timedelta(days=2) + assert _event_at(future).is_upcoming + + def test_past_event_is_not_upcoming(self) -> None: + past = datetime.now(UTC) - timedelta(days=2) + assert not _event_at(past).is_upcoming + + def test_event_without_start_is_not_upcoming(self) -> None: + """Symmetric with `is_past=False` for the no-time case — neither + property fires when both start_time and end_time are None.""" + e = Event.model_validate( + {**_MIN_EVENT_PAYLOAD, "startTimestamp": None, "endTimestamp": None} + ) + assert not e.is_upcoming + assert not e.is_past + + +class TestDuration: + def test_duration_returns_timedelta(self) -> None: + start = datetime(2026, 6, 1, 10, 0, tzinfo=UTC) + end = datetime(2026, 6, 1, 11, 30, tzinfo=UTC) + e = _event_at(start, end) + assert e.duration == timedelta(hours=1, minutes=30) + + def test_duration_is_none_when_end_time_missing(self) -> None: + start = datetime(2026, 6, 1, 10, 0, tzinfo=UTC) + e = Event.model_validate( + { + **_MIN_EVENT_PAYLOAD, + "startTimestamp": start.isoformat(), + "endTimestamp": None, + } + ) + assert e.duration is None + + def test_duration_is_none_when_start_time_missing(self) -> None: + end = datetime(2026, 6, 1, 11, 0, tzinfo=UTC) + e = Event.model_validate( + { + **_MIN_EVENT_PAYLOAD, + "startTimestamp": None, + "endTimestamp": end.isoformat(), + } + ) + assert e.duration is None + + +class TestResponseFor: + """`response_for(uid)` returns the bucket the uid lives in.""" + + def _event_with_responses(self) -> Event: + return Event.model_validate( + { + **_MIN_EVENT_PAYLOAD, + "responses": { + "acceptedIds": ["A1", "A2"], + "declinedIds": ["D1"], + "unansweredIds": ["U1"], + "waitinglistIds": ["W1"], + "unconfirmedIds": ["X1"], + }, + } + ) + + def test_accepted_uid_returns_accepted(self) -> None: + e = self._event_with_responses() + assert e.response_for("A1") == "accepted" + assert e.response_for("A2") == "accepted" + + def test_declined_uid_returns_declined(self) -> None: + assert self._event_with_responses().response_for("D1") == "declined" + + def test_unanswered_uid_returns_unanswered(self) -> None: + assert self._event_with_responses().response_for("U1") == "unanswered" + + def test_waiting_list_uid_returns_waiting_list(self) -> None: + assert self._event_with_responses().response_for("W1") == "waiting_list" + + def test_unconfirmed_uid_returns_unconfirmed(self) -> None: + assert self._event_with_responses().response_for("X1") == "unconfirmed" + + def test_unknown_uid_returns_none(self) -> None: + assert self._event_with_responses().response_for("NOPE") is None + + def test_no_responses_returns_none(self) -> None: + """An event with default-empty responses returns None for any uid.""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + assert e.response_for("ANYONE") is None + + +class TestHasResponded: + """`has_responded(uid)` is True for any concrete response other than + `unanswered`.""" + + def _event_with_responses(self) -> Event: + return Event.model_validate( + { + **_MIN_EVENT_PAYLOAD, + "responses": { + "acceptedIds": ["A1"], + "declinedIds": ["D1"], + "unansweredIds": ["U1"], + "waitinglistIds": ["W1"], + "unconfirmedIds": ["X1"], + }, + } + ) + + def test_accepted_has_responded(self) -> None: + assert self._event_with_responses().has_responded("A1") + + def test_declined_has_responded(self) -> None: + assert self._event_with_responses().has_responded("D1") + + def test_waiting_list_has_responded(self) -> None: + assert self._event_with_responses().has_responded("W1") + + def test_unconfirmed_has_responded(self) -> None: + """`unconfirmed` is still a concrete response — only `unanswered` + means "no response given yet".""" + assert self._event_with_responses().has_responded("X1") + + def test_unanswered_has_not_responded(self) -> None: + """The whole point of the `unanswered` bucket: these uids haven't + responded yet.""" + assert not self._event_with_responses().has_responded("U1") + + def test_unknown_uid_has_not_responded(self) -> None: + """An uid not invited to the event returns False — not a typed + record of non-response, but also not a positive response.""" + assert not self._event_with_responses().has_responded("NOPE") diff --git a/tests/test_event_members.py b/tests/test_event_members.py new file mode 100644 index 0000000..82e3ecf --- /dev/null +++ b/tests/test_event_members.py @@ -0,0 +1,184 @@ +"""Tests for the async member-resolution helpers on `Event`: +`accepted_members()`, `declined_members()`, `unanswered_members()`, +`waiting_list_members()`, `unconfirmed_members()`. + +Each resolves the corresponding `responses.*_uids` list to typed +`Member`/`Guardian` objects via the client's group cache, fetching it +lazily if empty. UIDs that no longer correspond to a current group +member are silently dropped (left members aren't an error). +""" + +from __future__ import annotations + +import pytest + +from spond.event import Event +from spond.group import Group +from spond.person import Guardian, Member +from spond.spond import Spond + +from .conftest import _MIN_EVENT_PAYLOAD, MOCK_PASSWORD, MOCK_USERNAME + + +def _spond_with_group() -> Spond: + """Build a Spond client with one group containing three members and + one guardian, all pre-cached so no HTTP fires.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK_TOKEN" + s.groups = [ + Group.from_api( + { + "id": "GID1", + "name": "G", + "members": [ + { + "id": "M1", + "firstName": "Alice", + "lastName": "A", + "guardians": [ + {"id": "G1", "firstName": "Pat", "lastName": "A"} + ], + }, + {"id": "M2", "firstName": "Bob", "lastName": "B"}, + {"id": "M3", "firstName": "Charlie", "lastName": "C"}, + ], + }, + s, + ) + ] + return s + + +def _event_with_responses( + accepted: list[str] | None = None, + declined: list[str] | None = None, + unanswered: list[str] | None = None, + waiting_list: list[str] | None = None, + unconfirmed: list[str] | None = None, +) -> Event: + return Event.model_validate( + { + **_MIN_EVENT_PAYLOAD, + "responses": { + "acceptedIds": accepted or [], + "declinedIds": declined or [], + "unansweredIds": unanswered or [], + "waitinglistIds": waiting_list or [], + "unconfirmedIds": unconfirmed or [], + }, + } + ) + + +class TestAcceptedMembers: + @pytest.mark.asyncio + async def test_resolves_uids_to_typed_members(self) -> None: + s = _spond_with_group() + e = _event_with_responses(accepted=["M1", "M3"]) + e._client = s + + members = await e.accepted_members() + + assert len(members) == 2 + assert all(isinstance(m, Member) for m in members) + uids = [m.uid for m in members] + assert uids == ["M1", "M3"] # preserves order from response list + + @pytest.mark.asyncio + async def test_resolves_guardian_uids(self) -> None: + """A uid that matches a guardian (not a member) returns the + Guardian — same lookup index covers both.""" + s = _spond_with_group() + e = _event_with_responses(accepted=["G1"]) + e._client = s + + members = await e.accepted_members() + assert len(members) == 1 + assert isinstance(members[0], Guardian) + assert members[0].uid == "G1" + + @pytest.mark.asyncio + async def test_unknown_uids_are_silently_dropped(self) -> None: + """A uid in the response list that no longer matches any group + member (e.g. they left the group) is silently omitted — the rest + still resolve.""" + s = _spond_with_group() + e = _event_with_responses(accepted=["M1", "EXMEMBER", "M2"]) + e._client = s + + members = await e.accepted_members() + assert [m.uid for m in members] == ["M1", "M2"] + + @pytest.mark.asyncio + async def test_empty_accepted_list_returns_empty(self) -> None: + s = _spond_with_group() + e = _event_with_responses(accepted=[]) + e._client = s + + assert await e.accepted_members() == [] + + @pytest.mark.asyncio + async def test_no_client_raises_runtime_error(self) -> None: + """An Event constructed without a client (e.g. via + `model_validate(raw)`) can't fetch groups.""" + e = _event_with_responses(accepted=["M1"]) + # _client is None + with pytest.raises(RuntimeError, match="no client attached"): + await e.accepted_members() + + @pytest.mark.asyncio + async def test_empty_groups_cache_returns_empty(self) -> None: + """When the client has no groups at all (or `get_groups()` returns + None), all helpers return an empty list rather than raising.""" + from unittest.mock import AsyncMock + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK_TOKEN" + s.groups = None + s.get_groups = AsyncMock(return_value=None) + + e = _event_with_responses(accepted=["ANY"]) + e._client = s + + assert await e.accepted_members() == [] + + +class TestSiblingHelpers: + """Verify the other four siblings route to their respective uid lists.""" + + @pytest.mark.asyncio + async def test_declined_members(self) -> None: + s = _spond_with_group() + e = _event_with_responses(declined=["M2"]) + e._client = s + + members = await e.declined_members() + assert [m.uid for m in members] == ["M2"] + + @pytest.mark.asyncio + async def test_unanswered_members(self) -> None: + s = _spond_with_group() + e = _event_with_responses(unanswered=["M3", "M1"]) + e._client = s + + members = await e.unanswered_members() + assert [m.uid for m in members] == ["M3", "M1"] + + @pytest.mark.asyncio + async def test_waiting_list_members(self) -> None: + s = _spond_with_group() + e = _event_with_responses(waiting_list=["M2"]) + e._client = s + + members = await e.waiting_list_members() + assert [m.uid for m in members] == ["M2"] + + @pytest.mark.asyncio + async def test_unconfirmed_members(self) -> None: + s = _spond_with_group() + e = _event_with_responses(unconfirmed=["G1"]) + e._client = s + + members = await e.unconfirmed_members() + assert [m.uid for m in members] == ["G1"] + assert isinstance(members[0], Guardian) From 78128ddcf3b0514e5da7d8252ad00b0cbca879cb Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 22:45:48 +0200 Subject: [PATCH 28/42] feat(oo-rewrite): Spond and SpondClub as async context managers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `async with Spond(...) as s:` now closes the underlying aiohttp ClientSession automatically — eliminating the explicit `await s.clientsession.close()` ritual every example used to require and the "Unclosed client session" warnings that resulted when callers forgot. `__aenter__` returns self; `__aexit__` calls `clientsession.close()` inside `contextlib.suppress(RuntimeError)` so a caller who closed the session manually before exiting the `with` block doesn't trigger a second close that masks the original control flow. Both `Spond` and `SpondClub` inherit the shape via `_SpondBase`. README/docstring examples updated to the new idiomatic form; the older explicit-cleanup shape still works for long-lived clients. Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/base.py | 30 +++++++++++++++++++ spond/club.py | 10 +++---- spond/spond.py | 30 ++++++++++++------- tests/test_context_manager.py | 55 +++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 tests/test_context_manager.py diff --git a/spond/base.py b/spond/base.py index 16bc1e3..c7c2e90 100644 --- a/spond/base.py +++ b/spond/base.py @@ -8,6 +8,7 @@ Not intended to be instantiated directly — use a subclass. """ +import contextlib import functools from abc import ABC from collections.abc import Callable @@ -51,6 +52,35 @@ def __init__(self, username: str, password: str, api_url: str) -> None: self.clientsession = aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar()) self.token = None + async def __aenter__(self): + """Async context-manager entry — returns self. + + Enables the idiomatic `async with Spond(...) as s:` shape so the + underlying aiohttp session is closed cleanly on exit, even if the + body raises: + + ```python + async with Spond(username, password) as s: + events = await s.get_events() + # session closed automatically here + ``` + + Replaces the older `await s.clientsession.close()` cleanup that + every example used to require. + """ + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + """Async context-manager exit — close the aiohttp client session. + + Silently swallows any `RuntimeError` raised by `close()` (e.g. + "session is already closed" if the caller closed it manually + before exiting the `with` block) — defensive cleanup shouldn't + mask the original exception that triggered the exit. + """ + with contextlib.suppress(RuntimeError): + await self.clientsession.close() + @property def auth_headers(self) -> dict: """Headers required for authenticated requests: JSON content-type plus diff --git a/spond/club.py b/spond/club.py index 7e02ac8..6b2f0ce 100644 --- a/spond/club.py +++ b/spond/club.py @@ -76,11 +76,11 @@ class SpondClub(_SpondBase): 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() + async with club.SpondClub(username="me@example.invalid", + password="secret") as sc: + txs = await sc.get_transactions(club_id="ABCD1234...", max_items=50) + for t in txs: + print(t.paid_at, t.payment_name, t.paid_by_name) asyncio.run(main()) ``` diff --git a/spond/spond.py b/spond/spond.py index e082752..1483830 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -61,13 +61,23 @@ class Spond(_SpondBase): 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: + `Spond` is an async context manager — the idiomatic shape closes the + underlying aiohttp session automatically: + + ```python + async with Spond(username="...", password="...") as s: + groups = await s.get_groups() + ... + # session closed automatically on exit, even if the body raises + ``` + + The older explicit-cleanup shape still works for callers that need + a long-lived client outside a `with` block: ```python s = Spond(username="...", password="...") try: groups = await s.get_groups() - ... finally: await s.clientsession.close() ``` @@ -79,11 +89,11 @@ class Spond(_SpondBase): 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() + async with spond.Spond(username="me@example.invalid", + password="secret") as s: + groups = await s.get_groups() or [] + for g in groups: + print(g.name) asyncio.run(main()) ``` @@ -99,9 +109,9 @@ def __init__(self, username: str, password: str) -> None: 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. + opened immediately; use `Spond` as an `async with` context manager to + close it automatically, or call `await s.clientsession.close()` when + finished to avoid `Unclosed client session` warnings. Parameters ---------- diff --git a/tests/test_context_manager.py b/tests/test_context_manager.py new file mode 100644 index 0000000..d0a7ac2 --- /dev/null +++ b/tests/test_context_manager.py @@ -0,0 +1,55 @@ +"""Tests for the `async with Spond(...)` context-manager shape. + +`__aenter__` returns self; `__aexit__` closes the aiohttp ClientSession. +""" + +from __future__ import annotations + +import pytest + +from spond.club import SpondClub +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_USERNAME + + +class TestSpondAsContextManager: + @pytest.mark.asyncio + async def test_enter_returns_self(self) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + async with s as ctx: + assert ctx is s + + @pytest.mark.asyncio + async def test_session_closed_on_exit(self) -> None: + """The clientsession must be closed when the `with` block exits.""" + async with Spond(MOCK_USERNAME, MOCK_PASSWORD) as s: + assert not s.clientsession.closed + assert s.clientsession.closed + + @pytest.mark.asyncio + async def test_session_closed_even_on_exception(self) -> None: + """Cleanup must fire even when the body raises — that's the + whole point of `async with`.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + with pytest.raises(RuntimeError, match="intentional"): + async with s: + raise RuntimeError("intentional") + assert s.clientsession.closed + + @pytest.mark.asyncio + async def test_double_close_does_not_raise(self) -> None: + """If the caller manually closed the session inside the block, + the context-manager exit shouldn't blow up on top of it — + defensive cleanup must not mask the original control flow.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + async with s: + await s.clientsession.close() + # No exception escaped; both close paths were tolerated. + + @pytest.mark.asyncio + async def test_spondclub_also_supports_context_manager(self) -> None: + """Both subclasses of `_SpondBase` inherit the shape.""" + async with SpondClub(MOCK_USERNAME, MOCK_PASSWORD) as sc: + assert not sc.clientsession.closed + assert sc.clientsession.closed From 13f43fbc8a1c546ace0eec872abbe9047eaa17db Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 22:55:02 +0200 Subject: [PATCH 29/42] =?UTF-8?q?feat(oo-rewrite):=20Event.save()=20+=20Ev?= =?UTF-8?q?ent.delete()=20=E2=80=94=20ActiveRecord=20write=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The symmetric counterpart to the existing read surface. Replaces what was originally planned as `Spond.create_event(...)` + `event.delete()` with the universal `event.save()` shape per user feedback (the asymmetric "create on client / delete on instance" felt wrong). ## Event.save() Universal persist: - `self.uid` empty → POST `/sponds/` (collection endpoint) — creates a new event, mutates self in place with the result (uid populated, server-managed fields copied in), and appends to `client.events`. - `self.uid` set → POST `/sponds/{uid}` via the existing `update()` machinery, mutating self with the refreshed state. Client binding: `await event.save(client=spond)` on the first save of a freshly-constructed instance; subsequent saves use the bound client. ```python # Create event = Event(heading="...", start_time=..., end_time=..., type="EVENT", owners=[{"id": my_pid, "response": "accepted"}], recipients={"group": {"id": "GRP"}}) await event.save(client=spond) assert event.uid # populated # Mutate and persist event.heading = "Renamed" await event.save() ``` `save()` returns `self` (mutated in place) for chaining — the ActiveRecord contract. `update(**fields)` still returns a new instance; both shapes coexist so callers can pick whichever matches their style. ## Event.delete() Issues DELETE `/sponds/{uid}` and prunes the event from `client.events` so subsequent `get_event(uid)` raises `EventNotFoundError`. Refuses to delete unsaved instances (no uid) or unbound instances (no client). ## Endpoint verification Both endpoints were confirmed against the live Spond API in the designated test group `13A7054422134033B61925C9347CE549`: - POST `/sponds/` (trailing slash) with full payload — returns 200 with the created event including its new uid. - DELETE `/sponds/{uid}` — returns 200, event no longer appears in subsequent get_events(). - `recipients` field is **required** for create; it was previously in `_EVENT_READ_ONLY_FIELDS` (correct for update — don't round-trip stale recipients — but wrong for create where the caller has to specify the target group). save() now applies the read-only filter only on the update path. Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/event.py | 143 ++++++++++++++ tests/test_event_save_delete.py | 337 ++++++++++++++++++++++++++++++++ 2 files changed, 480 insertions(+) create mode 100644 tests/test_event_save_delete.py diff --git a/spond/event.py b/spond/event.py index 0a5b278..33275f5 100644 --- a/spond/event.py +++ b/spond/event.py @@ -20,6 +20,7 @@ from pydantic_core import to_jsonable_python from ._compat import DictCompatModel +from .exceptions import SpondAPIError if TYPE_CHECKING: from .spond import Spond @@ -563,3 +564,145 @@ async def attendance_xlsx(self) -> bytes: url, headers=self._client.auth_headers ) as r: return await r.read() + + async def save(self, client: Spond | None = None) -> Event: + """Persist this event to Spond — universal create-or-update. + + - When `self.uid` is empty (a freshly-constructed instance): + POSTs to `/sponds/` to create. Spond returns the new event + with `uid` populated; the result is **applied to self in + place** so the same instance can be used for subsequent + calls. + - When `self.uid` is set: POSTs to `/sponds/{uid}` (the same + path `update()` uses) to persist whatever local state the + caller has mutated. + + On first save of an unbound instance, pass `client=spond` to + bind a Spond client. Subsequent saves use the bound client. + + Example + ------- + ```python + # Create + event = Event(heading="Match vs Rivals", + start_time=..., end_time=..., + recipients={"group": {"id": "GROUPUID"}}, + owners=[{"id": my_profile_uid, "response": "accepted"}]) + await event.save(client=spond) + assert event.uid # populated by Spond + + # Mutate + save + event.heading = "Renamed" + await event.save() + ``` + + Returns `self` (mutated in place) for chaining. Compare with + `update(**fields)` which returns a *new* instance — both shapes + are supported; pick whichever matches your code style. + + Raises + ------ + RuntimeError + No client is bound and `client` was not supplied. + SpondAPIError + Spond rejected the create or update. + """ + if client is not None: + self._client = client + if self._client is None: + raise RuntimeError( + "Event has no client bound. Pass `client=spond` to " + "`event.save(client=...)` on first save." + ) + + if self.uid: + # Update path — round-trip through `update()` so all the + # payload-discipline machinery (exclude_unset, read-only + # filter, JSON-encoding of caller values) applies. Then + # mutate self with the refreshed state. + refreshed = await self.update() + else: + # Create path — POST to /sponds/ (collection endpoint). + # We do NOT apply `_EVENT_READ_ONLY_FIELDS` here: that + # filter exists to prevent stale-state round-tripping on + # update, but on create the caller's explicit state is + # all we have to work with — `recipients` in particular + # is required by Spond's create endpoint. + # Server-managed fields (creator_uid, created_time, + # updated, expired, registered, …) are still excluded + # because they default to None and exclude_none=True + # drops them, OR they're in model_fields_set as None and + # exclude_none=True drops them. + payload = self.model_dump( + by_alias=True, + mode="json", + exclude_unset=True, + exclude_none=True, + ) + # Drop the empty `id` if it slipped through — Spond mints + # a fresh uid on create. + payload.pop("id", None) + url = f"{self._client.api_url}sponds/" + async with self._client.clientsession.post( + url, json=payload, headers=self._client.auth_headers + ) as r: + if not r.ok: + raise SpondAPIError(r.status, await r.text(), url) + new_data = await r.json() + refreshed = type(self).from_api(new_data, self._client) + # Append to the client cache so subsequent `get_event(uid)` + # resolves the new event without a re-fetch. + if self._client.events is None: + self._client.events = [refreshed] + else: + self._client.events.insert(0, refreshed) + + # Apply the refreshed state to self IN PLACE — this is the + # ActiveRecord contract: after `save()`, `self` is the + # authoritative live record. + for field_name in type(self).model_fields: + object.__setattr__(self, field_name, getattr(refreshed, field_name)) + # Capture any extras Spond added that we don't model. + extras = refreshed._pydantic_extras() + if extras and self.__pydantic_extra__ is not None: + self.__pydantic_extra__.update(extras) + # Sync `model_fields_set` so subsequent `exclude_unset=True` + # dumps reflect what Spond actually emitted (not our pre-save + # snapshot). + self.__pydantic_fields_set__ = set(refreshed.__pydantic_fields_set__) + return self + + async def delete(self) -> None: + """Delete this event from Spond. + + Issues `DELETE /sponds/{uid}` and removes the event from the + client's `events` cache. After this call, `self.uid` is left + in place (so callers can still reference what was deleted), + but any subsequent `save()` would attempt to update a no-longer- + existing event and fail. + + Raises + ------ + RuntimeError + The event has no client bound or no `uid` (i.e. it was + never persisted to begin with). + SpondAPIError + Spond rejected the delete. + """ + if self._client is None: + raise RuntimeError("Event has no client bound; cannot delete.") + if not self.uid: + raise RuntimeError( + "Cannot delete an unsaved Event (no uid). Call save() first " + "or construct the instance via Spond.get_event()." + ) + url = f"{self._client.api_url}sponds/{self.uid}" + async with self._client.clientsession.delete( + url, headers=self._client.auth_headers + ) as r: + if not r.ok: + raise SpondAPIError(r.status, await r.text(), url) + # Remove from cache so subsequent get_event(uid) raises rather + # than serving a stale entry. + if self._client.events is not None: + self._client.events = [e for e in self._client.events if e.uid != self.uid] diff --git a/tests/test_event_save_delete.py b/tests/test_event_save_delete.py new file mode 100644 index 0000000..f0c62ad --- /dev/null +++ b/tests/test_event_save_delete.py @@ -0,0 +1,337 @@ +"""Tests for the ActiveRecord write surface: `Event.save()` and +`Event.delete()`. + +`save()` is the universal create-or-update operation: dispatches on +`self.uid` presence (empty → POST `/sponds/` to create; set → POST +`/sponds/{uid}` via the existing `update()` machinery). On create it +mutates `self` in place with the persisted state from Spond and binds +the client. + +`delete()` issues DELETE `/sponds/{uid}` and prunes the event from the +client's `events` cache so subsequent `get_event(uid)` lookups raise +`EventNotFoundError`. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +import pytest + +from spond import EventNotFoundError, SpondAPIError +from spond.event import Event +from spond.spond import Spond + +from .conftest import _MIN_EVENT_PAYLOAD, MOCK_PASSWORD, MOCK_USERNAME + + +def _fresh_event() -> Event: + """Build an unsaved Event (no uid) suitable for the create path.""" + return Event( + uid="", + heading="New Event", + start_time=datetime(2026, 6, 1, 10, 0, tzinfo=UTC), + end_time=datetime(2026, 6, 1, 11, 0, tzinfo=UTC), + type="EVENT", + owners=[{"id": "PROFILE1", "response": "accepted"}], + recipients={"group": {"id": "GROUP1"}}, + ) + + +class TestSaveCreate: + """Create path: `event.save()` on an instance with no uid.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_populates_uid_in_place(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = _fresh_event() + + # Spond returns the created event with a fresh uid + server-managed + # fields populated. + api_response = { + **_MIN_EVENT_PAYLOAD, + "id": "NEWUID", + "heading": "New Event", + "creatorId": "PROFILE1", + "createdTime": "2026-05-14T20:00:00Z", + } + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + result = await event.save(client=s) + + # save() returns self (mutated in place), not a fresh instance. + assert result is event + assert event.uid == "NEWUID" + # Server-managed fields applied to self + assert event.creator_uid == "PROFILE1" + # Client bound for subsequent operations + assert event._client is s + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_posts_to_collection_url(self, mock_post) -> None: + """The create path POSTs to `/sponds/` (no uid in path).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = _fresh_event() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "id": "NEWUID"} + ) + + await event.save(client=s) + + called_url = mock_post.call_args[0][0] + assert called_url.endswith("/sponds/"), ( + f"create should POST to /sponds/ (collection), got {called_url}" + ) + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_includes_recipients_in_payload(self, mock_post) -> None: + """`recipients` is required for create (and was previously being + stripped by `_EVENT_READ_ONLY_FIELDS`). Locks in the fix.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = _fresh_event() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "id": "NEWUID"} + ) + + await event.save(client=s) + + posted = mock_post.call_args[1]["json"] + assert "recipients" in posted, ( + "create payload must include recipients — Spond requires it" + ) + assert posted["recipients"] == {"group": {"id": "GROUP1"}} + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_appends_to_client_cache(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.events = [] # empty list, not None — so we observe the append + event = _fresh_event() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "id": "NEWUID"} + ) + + await event.save(client=s) + + assert len(s.events) == 1 + assert s.events[0].uid == "NEWUID" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_initialises_empty_cache(self, mock_post) -> None: + """When `s.events is None` (never fetched), create initialises the + cache rather than no-oping.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.events = None + event = _fresh_event() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "id": "NEWUID"} + ) + + await event.save(client=s) + + assert s.events is not None + assert len(s.events) == 1 + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_raises_on_http_error(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = _fresh_event() + + mock_post.return_value.__aenter__.return_value.ok = False + mock_post.return_value.__aenter__.return_value.status = 500 + mock_post.return_value.__aenter__.return_value.text = AsyncMock( + return_value="Server Error" + ) + + with pytest.raises(SpondAPIError) as exc_info: + await event.save(client=s) + assert exc_info.value.status == 500 + + @pytest.mark.asyncio + async def test_save_without_client_raises(self) -> None: + """Calling `save()` on an unbound new instance without passing + `client=` must raise — there's no way to send the POST.""" + event = _fresh_event() + with pytest.raises(RuntimeError, match="no client bound"): + await event.save() + + +class TestSaveUpdate: + """Update path: `event.save()` on an instance with an existing uid.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_existing_posts_to_uid_url(self, mock_post) -> None: + """A saved Event saves to `/sponds/{uid}` (delegates to `update()`).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + api_response = {**_MIN_EVENT_PAYLOAD, "heading": "Renamed"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + event.heading = "Renamed" + await event.save() + + called_url = mock_post.call_args[0][0] + assert called_url.endswith(f"/sponds/{event.uid}"), ( + f"update should POST to /sponds/{event.uid}, got {called_url}" + ) + assert event.heading == "Renamed" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_existing_uses_bound_client(self, mock_post) -> None: + """Subsequent saves don't need a `client` argument.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_MIN_EVENT_PAYLOAD + ) + + # No client kwarg — should use event._client which from_api wired. + await event.save() + mock_post.assert_called_once() + + +class TestDelete: + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + async def test_delete_issues_delete_request(self, mock_delete) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + s.events = [event] + + mock_delete.return_value.__aenter__.return_value.ok = True + mock_delete.return_value.__aenter__.return_value.status = 200 + + await event.delete() + + called_url = mock_delete.call_args[0][0] + assert called_url.endswith(f"/sponds/{event.uid}"), ( + f"delete should DELETE /sponds/{event.uid}, got {called_url}" + ) + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + async def test_delete_prunes_from_client_cache(self, mock_delete) -> None: + """After delete, the event must no longer be in `client.events` so + a subsequent `get_event(uid)` raises `EventNotFoundError`.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + # Cache has two events — one we'll delete, one we won't + e1 = Event.from_api({**_MIN_EVENT_PAYLOAD, "id": "TODELETE"}, s) + e2 = Event.from_api({**_MIN_EVENT_PAYLOAD, "id": "KEEP"}, s) + s.events = [e1, e2] + + mock_delete.return_value.__aenter__.return_value.ok = True + + await e1.delete() + + # e1 gone from cache; e2 stays + assert len(s.events) == 1 + assert s.events[0].uid == "KEEP" + + # And get_event(deleted_uid) raises + with pytest.raises(EventNotFoundError): + await s.get_event("TODELETE") + + @pytest.mark.asyncio + async def test_delete_without_client_raises(self) -> None: + event = Event.model_validate(_MIN_EVENT_PAYLOAD) + # No client wired (didn't go through from_api) + with pytest.raises(RuntimeError, match="no client"): + await event.delete() + + @pytest.mark.asyncio + async def test_delete_without_uid_raises(self) -> None: + """Cannot delete an event that was never persisted (no uid).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = _fresh_event() + event._client = s # bound but unsaved + + with pytest.raises(RuntimeError, match="unsaved"): + await event.delete() + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + async def test_delete_raises_on_http_error(self, mock_delete) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + mock_delete.return_value.__aenter__.return_value.ok = False + mock_delete.return_value.__aenter__.return_value.status = 403 + mock_delete.return_value.__aenter__.return_value.text = AsyncMock( + return_value="Forbidden" + ) + + with pytest.raises(SpondAPIError) as exc_info: + await event.delete() + assert exc_info.value.status == 403 + + +class TestSaveRoundtrip: + """Combined save/delete roundtrip — the canonical ActiveRecord flow.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + async def test_create_then_save_then_delete(self, mock_post, mock_delete) -> None: + """Construct → save (create) → mutate → save (update) → delete. + The canonical ActiveRecord lifecycle, end to end.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.events = [] + event = _fresh_event() + + # Create: returns with uid + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "id": "C1", "heading": "New Event"} + ) + + await event.save(client=s) + assert event.uid == "C1" + + # Update: mutate + save + event.heading = "Renamed" + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "id": "C1", "heading": "Renamed"} + ) + await event.save() + assert event.heading == "Renamed" + + # Delete + mock_delete.return_value.__aenter__.return_value.ok = True + await event.delete() + assert event.uid not in {e.uid for e in s.events} From 96ffc1cc18f2be1f5062f2fb5c9434fb475da45a Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 22:58:29 +0200 Subject: [PATCH 30/42] docs(oo-rewrite): update README and DESIGN doc for v2.0 surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflect the Phase 1 + Phase 2 work (exception hierarchy, natural-key equality, convenience properties, member-resolution helpers, async context manager, ActiveRecord write surface) and re-version the release as v2.0 — the new equality semantics and substantial new surface warrant a major bump even with the backward-compat shim in place. ## README - Example uses `async with Spond(...)` for automatic session cleanup - New section showing the typed-object workflow end-to-end (read, convenience properties, member resolution, save/update/delete) - Identity / equality section explains natural-key semantics and the `model_equals()` escape hatch - Exception hierarchy section showing both the typed forms and the preserved `except KeyError:` / `except ValueError:` patterns - "Typed objects from v2.0 onwards" callout (was "from v1.3 onwards") ## DESIGN-oo-rewrite.md - Scope line acknowledges the v2.0 deliverables beyond the original read-side rewrite - Type inventory for Event lists the full method set (save, delete, update, change_response, attendance_xlsx, accepted_members and siblings, convenience properties) - New top-level sections: Identity / equality, Exception hierarchy, Async context manager - Versioning rewritten: v2.0 → v2.x → v3.0 deprecation path. Three reasons for the major bump documented (new __eq__ semantics, substantial new surface, return-type change) - "Open questions / follow-up" trimmed to what's actually still deferred (Post write surface, Group invite, typed Comment, Guardian back-link, full chat history) - New "What shipped in v2.0 (previously open)" section captures the items that were "open questions" in earlier revisions of this doc - Files / Tests sections updated to list the new modules (exceptions.py) and new test files (test_backward_compat.py, test_context_manager.py, test_event_convenience.py, test_event_members.py, test_event_save_delete.py, test_exceptions.py, test_identity.py, test_club.py) Co-Authored-By: Claude Opus 4.7 (1M context) --- DESIGN-oo-rewrite.md | 167 ++++++++++++++++++++++++++++++++++++++----- README.md | 99 +++++++++++++++++++++---- 2 files changed, 233 insertions(+), 33 deletions(-) diff --git a/DESIGN-oo-rewrite.md b/DESIGN-oo-rewrite.md index 1cc7cc3..34dd756 100644 --- a/DESIGN-oo-rewrite.md +++ b/DESIGN-oo-rewrite.md @@ -2,7 +2,7 @@ **Status:** implementation complete on branch `feat/oo-rewrite`; under review **Last updated:** 2026-05-14 -**Scope:** First-class typed objects with ActiveRecord behaviour, replacing the dict-based return surface across `spond.spond.Spond` and `spond.club.SpondClub`. +**Scope:** First-class typed objects with ActiveRecord behaviour, replacing the dict-based return surface across `spond.spond.Spond` and `spond.club.SpondClub`. v2.0 also delivers the symmetric write surface (`Event.save()`/`delete()`), an exception hierarchy, natural-key equality, convenience properties, and async context-manager support. ## Feedback welcome @@ -55,8 +55,29 @@ Person (base, DictCompatModel) Event(DictCompatModel) ├─ uid, heading, start_time, end_time, type: str (compared against EventType), │ owners, recipients, responses, comments, behalf_of_uids, ... - ├─ methods: update(_updates=None, /, **fields), change_response(member_uid, *, - │ accepted, decline_message=None), attendance_xlsx() -> bytes + │ + ├─ ActiveRecord methods: + │ ├─ save(client=None) — POST `/sponds/` (create) when uid empty, + │ │ POST `/sponds/{uid}` (update) when uid set. + │ │ Mutates self in place with the persisted state. + │ ├─ delete() — DELETE `/sponds/{uid}`, prunes from cache. + │ ├─ update(_updates=None, /, **fields) — returns a new instance + │ │ with the updates applied. + │ ├─ change_response(member_uid, *, accepted, decline_message=None) + │ └─ attendance_xlsx() -> bytes + │ + ├─ Async helpers (resolve uid lists to typed Member/Guardian via the + │ client's group cache; lazy): + │ ├─ accepted_members() + │ ├─ declined_members() + │ ├─ unanswered_members() + │ ├─ waiting_list_members() + │ └─ unconfirmed_members() + │ + ├─ Synchronous convenience properties / methods (pure-Python, no HTTP): + │ ├─ is_past, is_upcoming, duration + │ ├─ has_responded(uid), response_for(uid) + │ └─ url (canonical Spond web URL) │ └─ Match(Event) — sports fixtures ├─ match_info: MatchInfo | None — team/opponent names, scores, HOME/AWAY @@ -124,6 +145,56 @@ _client: Any = PrivateAttr(default=None) Construction sites set this via a `from_api(data, client)` classmethod. `PrivateAttr` keeps it out of `model_dump()` and pdoc. Passive types (Subgroup, Role, Profile, Responses, MatchInfo) omit the client since they don't issue HTTP themselves. +## Identity / equality + +`DictCompatModel.__eq__` and `__hash__` are driven by a `_natural_key()` hook each typed model overrides. The default uses `(entity_kind, uid)` where `entity_kind` walks the MRO to find the closest user-defined ancestor before `DictCompatModel` — so `Match("X") == Event("X")` (both resolve to kind `"Event"`), and same for `Member`/`Guardian` → `"Person"`. + +When `uid` is absent (a freshly-constructed instance not yet persisted), each model provides a fallback derived from user-visible fields: + +| Entity | Fallback key | +|-------------|-------------------------------------------| +| Event | heading + start_time | +| Group | name | +| Person/Member/Guardian | full_name + email | +| Profile | full_name | +| Post | title + timestamp | +| Chat | name + type | +| Transaction | paid_at + payment_name + paid_by_name | +| Subgroup | name | +| Role | name | +| Message | (chat_id, msg_num) — no uid at all | + +This makes typed instances usable as set members and dict keys following the ORM "same uid → same entity" convention. For callers who need the pre-OO field-by-field equality (e.g. "has the server-side state changed?"), `model_equals(other)` returns the Pydantic default. + +## Exception hierarchy + +``` +SpondError (base) +├── AuthenticationError (login failures) +├── SpondAPIError, ValueError (HTTP failures; carries status/body/url) +└── SpondNotFoundError, KeyError (lookup-by-id failures) + ├── EventNotFoundError + ├── GroupNotFoundError + ├── PersonNotFoundError + └── ChatNotFoundError +``` + +`SpondAPIError` multi-inherits from `ValueError`; `*NotFoundError` types multi-inherit from `KeyError`. Pre-OO `except KeyError:` and `except ValueError:` patterns keep working. + +`AuthenticationError` lives in `spond.exceptions` but is re-exported from `spond.__init__`, so `from spond import AuthenticationError` keeps working. + +## Async context manager + +`Spond` and `SpondClub` are async context managers via `_SpondBase.__aenter__` / `__aexit__`. The idiomatic shape: + +```python +async with Spond(username, password) as s: + events = await s.get_events() +# clientsession closed automatically on exit, even if the body raised +``` + +`__aexit__` wraps the close in `contextlib.suppress(RuntimeError)` so a caller that manually closed the session inside the `with` block doesn't trigger a second-close that masks the original control flow. + ## Backward compatibility ### Dict-subscript shim @@ -173,7 +244,7 @@ async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: return await event.update(**updates) ``` -The three deprecated wrappers are removed in v2.0. +The three deprecated wrappers are removed in v3.0 (or a later v2.x), after callers have had a grace period to migrate. ## Spond.get_* return-type changes @@ -195,21 +266,33 @@ Dict-style consumers still work through `DictCompatModel` (with warning). ## Open questions / follow-up -These were deferred from this PR; they're tracked here as roadmap items. +Items deferred from this PR, tracked here as roadmap candidates. -1. **Member ↔ UID resolution in Responses.** `Event.responses.accepted_uids` is still `list[str]`, not `list[Member]`. Resolving requires Group context, which Events only have via `recipients` / `groupId`. A future helper `await event.accepted_members(spond)` that fetches the group and walks members — lazy, opt-in, no surprise HTTP from attribute reads — is the planned shape. -2. **Guardian.managed_member back-link.** Not yet exposed. Guardians are currently constructed inside `Member.guardians`; a post-hoc parent reference can be added if a downstream caller asks for it. -3. **Post.add_comment.** Not modelled. `Post.comments` is read-only `list[dict]`. Adding the write side depends on probing the API for the right endpoint. -4. **Typed `Comment`.** Modelling comments themselves as a typed class (rather than `list[dict]`) is a natural next step once `Post.add_comment` is in. +1. **`Post.save()` / `Post.delete()` / `Post.add_comment()`.** Posts are still read-only via the OO surface. The Event write surface is shipped (`Event.save()`/`delete()`); the same shape on Post needs endpoint probing — `POST /posts/`, `DELETE /posts/{uid}`, `POST /posts/{uid}/comments` are best guesses but not yet verified. +2. **`Group.invite_member()` / `Group.save()`.** Group management (inviting members, editing group settings) is read-only. The invitation flow in particular goes through `/invites` rather than `/groups/{uid}/members` and needs careful probing. +3. **Typed `Comment`.** `Post.comments` and `Event.comments` are still `list[dict]`. A typed `Comment` class is the natural next step once `Post.add_comment()` lands. +4. **Guardian.managed_member back-link.** Not yet exposed. Guardians are currently constructed inside `Member.guardians`; a post-hoc parent reference can be added if a downstream caller asks for it. 5. **Full chat history.** `Chat.message` only carries the most-recent message; the chat API has additional endpoints for older messages that aren't modelled yet. All five remain answerable with live API probing using the credentials at `/home/olen/prog/spond-kalender/config.py`. +## What shipped in v2.0 (previously open) + +These items were "open questions" in earlier revisions of this document and have since landed: + +- **`Event.accepted_members()` and siblings** — async helpers resolving each `responses.*_uids` list to typed `Member`/`Guardian` objects via the client's group cache (lazy fetch when empty). Five variants: `accepted_members`, `declined_members`, `unanswered_members`, `waiting_list_members`, `unconfirmed_members`. +- **`Event.save()` and `Event.delete()`** — symmetric ActiveRecord write surface. `save()` dispatches on `self.uid` (create vs update) and mutates self in place; `delete()` issues DELETE and prunes the cache. Endpoints verified live: POST `/sponds/` for create, DELETE `/sponds/{uid}` for delete. The `recipients` field (which is in `_EVENT_READ_ONLY_FIELDS` for the update path) is allowed through on create since Spond requires it. +- **Custom exception hierarchy** — `SpondError` base; `EventNotFoundError`/`GroupNotFoundError`/`PersonNotFoundError`/`ChatNotFoundError` (all also `KeyError`); `SpondAPIError` (also `ValueError`). Pre-OO `except KeyError:` / `except ValueError:` patterns preserved. +- **Natural-key equality** — `__eq__`/`__hash__` driven by `_natural_key()` on each typed model. uid-based when set, else user-visible-field fallback (heading+start_time for Event, etc.). `model_equals()` provides the old field-by-field shape for callers that need it. +- **Convenience properties on Event** — `is_past`, `is_upcoming`, `duration`, `has_responded(uid)`, `response_for(uid)`. +- **Async context manager on `Spond` / `SpondClub`** — `async with Spond(...) as s:` closes the aiohttp session automatically. + ## Files **New:** -- `spond/_compat.py` — `DictCompatModel`, `LenientDate` -- `spond/event.py` — `Event`, `Responses`, `EventType`, `_EVENT_READ_ONLY_FIELDS` +- `spond/_compat.py` — `DictCompatModel`, `LenientDate`, natural-key equality machinery +- `spond/event.py` — `Event`, `Responses`, `EventType`, `_EVENT_READ_ONLY_FIELDS`, `save()`/`delete()` write surface, member-resolution helpers, convenience properties +- `spond/exceptions.py` — `SpondError` and the typed-exception hierarchy - `spond/match.py` — `Match` (Event subclass), `MatchInfo` - `spond/person.py` — `Person`, `Member`, `Guardian` - `spond/group.py` — `Group` @@ -220,19 +303,29 @@ All five remain answerable with live API probing using the credentials at `/home - `spond/chat.py` — `Chat`, `Message` **Changed:** -- `spond/spond.py` — `get_*` methods return typed objects; legacy write methods get deprecation wrappers; `_typed_event` dispatches Event vs. Match +- `spond/__init__.py` — re-exports the typed exception hierarchy alongside `AuthenticationError` +- `spond/base.py` — `_SpondBase.__aenter__` / `__aexit__` for async context manager support +- `spond/spond.py` — `get_*` methods return typed objects; legacy write methods get deprecation wrappers; `_typed_event` dispatches Event vs. Match; raise sites use typed exceptions - `spond/club.py` — `Transaction` model added; `get_transactions` returns `list[Transaction]` - `pyproject.toml` — `pydantic = ">=2.0"` added to runtime deps -- `README.md` — examples updated to OO style +- `README.md` — examples updated to OO style + async-with shape **Tests:** -The previous monolithic `tests/test_spond.py` has been split by domain. The new layout: -- `tests/conftest.py` — shared fixtures, constants, the `_SpondBase.require_authentication` monkey-patch +The previous monolithic `tests/test_spond.py` has been split by domain. The current layout: +- `tests/conftest.py` — shared fixtures and constants - `tests/test_auth.py` — login flow + `require_authentication` decorator metadata -- `tests/test_compat.py` — `DictCompatModel` shim + Event-update payload regression guards -- `tests/test_events.py` — `Event.get_event`, deprecated wrappers, OO `Event` methods, `Match` subclass +- `tests/test_backward_compat.py` — regression guards for pre-OO patterns (dict access, `except KeyError:`/`ValueError:`, top-level `AuthenticationError` import, deprecated wrappers) +- `tests/test_club.py` — `Transaction` and `SpondClub.get_transactions()` +- `tests/test_compat.py` — `DictCompatModel` shim + `LenientDate` +- `tests/test_context_manager.py` — `async with Spond(...)` shape +- `tests/test_event_convenience.py` — `is_past`/`is_upcoming`/`duration`/`response_for`/`has_responded` +- `tests/test_event_members.py` — `accepted_members()` and siblings +- `tests/test_event_save_delete.py` — ActiveRecord write surface (`save()` create/update, `delete()`) +- `tests/test_events.py` — `get_event` / `get_events` HTTP path, deprecated wrappers, OO `Event` methods, `Match` subclass +- `tests/test_exceptions.py` — exception hierarchy + raise-site coverage - `tests/test_export.py` — deprecated `get_event_attendance_xlsx` wrapper -- `tests/test_groups.py` — `get_group` + Group → Member → Guardian navigation +- `tests/test_groups.py` — `get_group` + Group → Member → Guardian navigation + `get_person` +- `tests/test_identity.py` — natural-key equality / hashing across all models - `tests/test_messaging.py` — `Spond.send_message` + `Chat`/`Message` - `tests/test_posts.py` — `get_posts` query construction, caching, error surfacing @@ -254,7 +347,43 @@ The previous monolithic `tests/test_spond.py` has been split by domain. The new ## Versioning -Land as v1.3 — minor bump (return-type change is technically breaking, but `DictCompatModel` makes it soft). Legacy `Spond.*_event*` methods removed in v2.0 after a grace period. +This work lands as **v2.0** — a major bump even though backward +compatibility is preserved through the deprecation cycle. The +rationale: + +- **New equality semantics.** Two `Event(uid="X")` instances now + compare equal regardless of field state — pre-v2.0 they did not. + The `model_equals()` escape hatch preserves the old shape for + callers who need it, but the default operator changed, and that's + a behavioural break. +- **Substantial new surface.** Typed write methods (`save()`/ + `delete()`), exception hierarchy, member-resolution helpers, + convenience properties, and async context-manager support are all + net-new — calling this a "minor" feels wrong. +- **Return-type changes.** Every `Spond.get_*` method now returns + typed objects instead of `dict[str, Any]` — softened by the + dict-compat shim, but technically a breaking type change for any + caller using static type checking. + +The plan: + +- **v2.0 (this release)** — ship everything described in this + document, with full backward compatibility: `DictCompatModel` shim + emits `DeprecationWarning` on dict-style access; `*NotFoundError` + multi-inherits from `KeyError`; `SpondAPIError` multi-inherits + from `ValueError`; deprecated wrappers (`Spond.update_event`/ + `change_response`/`get_event_attendance_xlsx`) still present and + delegating with `DeprecationWarning`. +- **v2.x (later)** — start removing the compat shims behind feature + flags or env vars, so callers can opt into the v3.0 surface early + and find any code that still relies on the dict path. +- **v3.0** — remove the deprecation cycle: drop the dict-compat + shim entirely, drop the multi-inheritance from `KeyError`/ + `ValueError` on the typed exceptions, remove the three deprecated + wrapper methods. + +Callers running v2.x with no deprecation warnings will have a +zero-change upgrade to v3.0. ## Implementation notes for maintainers diff --git a/README.md b/README.md index e9a908a..cd6378d 100644 --- a/README.md +++ b/README.md @@ -22,27 +22,98 @@ password = 'Pa55worD' group_id = 'C9DC791FFE63D7914D6952BE10D97B46' # fake async def main(): - s = spond.Spond(username=username, password=password) - group = await s.get_group(group_id) - print(group.name) - for member in group.members: - print(f" {member.full_name}") - for guardian in member.guardians: - print(f" guardian: {guardian.full_name}") - await s.clientsession.close() + async with spond.Spond(username=username, password=password) as s: + group = await s.get_group(group_id) + print(group.name) + for member in group.members: + print(f" {member.full_name}") + for guardian in member.guardians: + print(f" guardian: {guardian.full_name}") asyncio.run(main()) ``` -> **Typed objects from the next minor release onwards.** `get_groups()`, -> `get_event()`, `get_posts()`, etc. now return typed `Group` / `Event` / -> `Post` objects with attribute access and per-instance methods -> (`event.update(...)`, `event.change_response(...)`, -> `member.send_message(...)`). Existing dict-style access (`group["name"]`) -> still works for one major version with a `DeprecationWarning`. See +> **Typed objects from v2.0 onwards.** `get_groups()`, `get_event()`, +> `get_posts()`, etc. now return typed `Group` / `Event` / `Post` objects +> with attribute access and per-instance methods. Existing dict-style +> access (`group["name"]`) still works with a `DeprecationWarning` +> through the v2.x line; the shim is removed in v3.0. See > [`DESIGN-oo-rewrite.md`](DESIGN-oo-rewrite.md) for the full design and > migration story. +### Working with the typed objects + +```python +async with spond.Spond(username, password) as s: + # Read: typed instances with attribute access + event = await s.get_event(uid) + print(event.heading, event.start_time, event.duration) + + # Convenience properties — synchronous, no HTTP + if event.is_upcoming and not event.has_responded(my_uid): + print("you haven't responded yet") + + # Resolve response uids to typed Member/Guardian objects + for member in await event.accepted_members(): + print(f" ✓ {member.full_name}") + + # Update via kwargs (returns a new instance) + new_event = await event.update(heading="Renamed") + + # ActiveRecord-style write surface + new = Event(heading="My new event", + start_time=start, end_time=end, type="EVENT", + owners=[{"id": my_pid, "response": "accepted"}], + recipients={"group": {"id": group_id}}) + await new.save(client=s) # POST → uid populated; cache updated + assert new.uid + + new.description = "Some details" + await new.save() # PATCH (mutate-in-place, then save) + + await new.delete() # DELETE → pruned from cache +``` + +### Identity / equality + +Typed instances use natural-key equality so they behave correctly in +sets and as dict keys: + +```python +a = await s.get_event(uid) +b = await s.get_event(uid) +assert a == b # same uid → equal, even if state differs +assert {a, b} == {a} # dedups via __hash__ + +# Match is a subclass of Event; same uid → same entity +assert isinstance(match, Event) +assert match == event_with_same_uid +``` + +For callers who need the old field-by-field comparison (e.g. "has the +server state changed?"), use `model_equals(other)`. + +### Exception hierarchy + +```python +from spond import ( + SpondError, # base — catch this for any SDK error + AuthenticationError, # login failures + EventNotFoundError, # also a KeyError, for backward compat + GroupNotFoundError, # also a KeyError + PersonNotFoundError, # also a KeyError + SpondAPIError, # HTTP failures; also a ValueError +) + +try: + event = await s.get_event(uid) +except EventNotFoundError: + ... +``` + +Pre-OO `except KeyError:` / `except ValueError:` patterns continue to +work — the typed exceptions multi-inherit from the stdlib classes. + ## Key methods ### get_groups() From 00fbeb77bb31216fdbeee981e231f6c9ece207f0 Mon Sep 17 00:00:00 2001 From: olen Date: Fri, 15 May 2026 11:08:15 +0200 Subject: [PATCH 31/42] feat(oo-rewrite): typed Comment + Post.save/delete/add_comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the symmetric ActiveRecord surface for Post (mirroring Event's `save()`/`delete()`) and replaces the raw `list[dict]` on `Post.comments` and `Event.comments` with typed `Comment` instances. ## Typed Comment (spond/comment.py) Field shape from live API: - uid (alias `id`) - from_profile_uid (alias `fromProfileId`) - timestamp - text - reactions Only `uid` strictly required; all other fields have defaults so API drift can't crash the whole `get_posts()` payload. `extra="allow"` preserves anything Spond adds. Natural-key equality: uid-based, else `(from_profile_uid, timestamp, text)` for unsaved comments. `Post.comments: list[Comment]` and `Event.comments: list[Comment]` — both materialize as typed instances automatically. Backward compat preserved: callers still iterating `post.comments` see the same sequence shape; attribute access on each element works (and the dict-compat shim covers `comment["text"]` with a DeprecationWarning). ## Post.save() / delete() / add_comment() Mirrors Event.save()/delete() exactly: - `await post.save(client=spond)` — POST `/posts/` (create) or POST `/posts/{uid}` (update), mutates self in place - `await post.delete()` — DELETE `/posts/{uid}`, prunes from cache - `await post.add_comment(text)` — POST `/posts/{uid}/comments`, returns the new typed `Comment` and appends to `self.comments` All three endpoints verified live in the test group: - `POST /posts/` → 200, returns created post with new uid - `POST /posts/{uid}/comments` → 200, returns Comment shape - `DELETE /posts/{uid}` → 200 ## Auth-ensure helper (spond/base.py) Both `Event.save/delete` and `Post.save/delete/add_comment` need to trigger the lazy login when called on a fresh (un-authenticated) client — they aren't decorated with `@require_authentication` because they're instance methods on typed models, not on Spond itself. Extracted `_SpondBase._ensure_authenticated()` from the decorator so both code paths share the same auth-trigger logic. The decorator now delegates to this helper; the model methods call it directly before their HTTP requests. ## Resolver fix (spond/base.py) Switched from aiohttp's c-ares-backed `AsyncResolver` (the default) to `ThreadedResolver`. Test suite was hitting `pycares.AresError: Failed to initialize c-ares channel` once the new test files pushed the live ClientSession count past the per-process channel limit. ThreadedResolver uses the stdlib synchronous resolver in a thread — slightly higher per-lookup overhead, no kernel-resource limit. ## Tests - `tests/test_comment.py` — 12 tests covering parsing, alias mapping, resilience defaults, natural-key equality, and the Post.comments/Event.comments materialization paths. - `tests/test_post_save_delete.py` — 32 tests covering the create path, update path, delete path, add_comment path, error handling, and a full end-to-end lifecycle (construct → save → mutate → save → add_comment → delete). Test count: 238 → 270. CI clean. ## Open Questions updated `Post.save()`/`delete()`/`add_comment()` and typed `Comment` removed from the deferred list. New item added: comment edit/delete endpoints (not yet probed). Co-Authored-By: Claude Opus 4.7 (1M context) --- DESIGN-oo-rewrite.md | 35 ++- README.md | 33 ++- spond/base.py | 40 +++- spond/comment.py | 71 ++++++ spond/event.py | 9 +- spond/post.py | 244 ++++++++++++++++++++- spond/spond.py | 2 +- tests/test_comment.py | 122 +++++++++++ tests/test_post_save_delete.py | 389 +++++++++++++++++++++++++++++++++ 9 files changed, 903 insertions(+), 42 deletions(-) create mode 100644 spond/comment.py create mode 100644 tests/test_comment.py create mode 100644 tests/test_post_save_delete.py diff --git a/DESIGN-oo-rewrite.md b/DESIGN-oo-rewrite.md index 34dd756..936342d 100644 --- a/DESIGN-oo-rewrite.md +++ b/DESIGN-oo-rewrite.md @@ -113,8 +113,21 @@ Profile(DictCompatModel) └─ uid, first_name, last_name, plus the live-audited extras (passive) Post(DictCompatModel) - ├─ uid, title, body, timestamp, comments: list[dict] - └─ (no methods yet; add_comment(...) deferred — see Open Questions) + ├─ uid, title, body, timestamp, group_uid, owner_uid, + │ subgroup_uids, visibility, comments: list[Comment], reactions, … + └─ ActiveRecord methods: + ├─ save(client=None) — POST `/posts/` (create) when uid empty, + │ POST `/posts/{uid}` (update) when uid set. + │ Mutates self in place with the persisted state. + ├─ delete() — DELETE `/posts/{uid}`, prunes from cache. + └─ add_comment(text) — POST `/posts/{uid}/comments`, returns the + new `Comment` and appends to self.comments. + +Comment(DictCompatModel) — typed sub-object of Post and Event + ├─ uid, from_profile_uid, timestamp, text, reactions + └─ (no methods; comment edit/delete endpoints aren't exposed by the + consumer API. Comment lifecycle goes through the parent — + `post.add_comment(text)`.) Chat(DictCompatModel) ├─ uid, name, type, participants, newest_timestamp, unread, muted, @@ -268,13 +281,12 @@ Dict-style consumers still work through `DictCompatModel` (with warning). Items deferred from this PR, tracked here as roadmap candidates. -1. **`Post.save()` / `Post.delete()` / `Post.add_comment()`.** Posts are still read-only via the OO surface. The Event write surface is shipped (`Event.save()`/`delete()`); the same shape on Post needs endpoint probing — `POST /posts/`, `DELETE /posts/{uid}`, `POST /posts/{uid}/comments` are best guesses but not yet verified. -2. **`Group.invite_member()` / `Group.save()`.** Group management (inviting members, editing group settings) is read-only. The invitation flow in particular goes through `/invites` rather than `/groups/{uid}/members` and needs careful probing. -3. **Typed `Comment`.** `Post.comments` and `Event.comments` are still `list[dict]`. A typed `Comment` class is the natural next step once `Post.add_comment()` lands. -4. **Guardian.managed_member back-link.** Not yet exposed. Guardians are currently constructed inside `Member.guardians`; a post-hoc parent reference can be added if a downstream caller asks for it. -5. **Full chat history.** `Chat.message` only carries the most-recent message; the chat API has additional endpoints for older messages that aren't modelled yet. +1. **`Group.invite_member()` / `Group.save()`.** Group management (inviting members, editing group settings) is read-only. The invitation flow in particular goes through `/invites` rather than `/groups/{uid}/members` and needs careful probing. +2. **Guardian.managed_member back-link.** Not yet exposed. Guardians are currently constructed inside `Member.guardians`; a post-hoc parent reference can be added if a downstream caller asks for it. +3. **Full chat history.** `Chat.message` only carries the most-recent message; the chat API has additional endpoints for older messages that aren't modelled yet. +4. **Comment edit / delete.** Spond's app supports editing and deleting your own comments, but the corresponding endpoints aren't yet probed/modelled. `post.add_comment(text)` ships in v2.0; the lifecycle endpoints (`PUT /posts/{uid}/comments/{cuid}`, `DELETE /posts/{uid}/comments/{cuid}` or similar) need verification. -All five remain answerable with live API probing using the credentials at `/home/olen/prog/spond-kalender/config.py`. +All four remain answerable with live API probing using the credentials at `/home/olen/prog/spond-kalender/config.py`. ## What shipped in v2.0 (previously open) @@ -286,6 +298,8 @@ These items were "open questions" in earlier revisions of this document and have - **Natural-key equality** — `__eq__`/`__hash__` driven by `_natural_key()` on each typed model. uid-based when set, else user-visible-field fallback (heading+start_time for Event, etc.). `model_equals()` provides the old field-by-field shape for callers that need it. - **Convenience properties on Event** — `is_past`, `is_upcoming`, `duration`, `has_responded(uid)`, `response_for(uid)`. - **Async context manager on `Spond` / `SpondClub`** — `async with Spond(...) as s:` closes the aiohttp session automatically. +- **`Post.save()` / `Post.delete()` / `Post.add_comment()`** — symmetric write surface on Post, mirroring Event's. Endpoints verified live: POST `/posts/` for create, POST `/posts/{uid}` for update, DELETE `/posts/{uid}`, POST `/posts/{uid}/comments` for add_comment. `add_comment` returns a typed `Comment` and appends it to `post.comments` in place. +- **Typed `Comment` model** — `Post.comments` and `Event.comments` now contain `list[Comment]` instead of `list[dict]`. Same forward-compat (`extra="allow"`) and resilience-default discipline as the other types. ## Files @@ -299,7 +313,8 @@ These items were "open questions" in earlier revisions of this document and have - `spond/subgroup.py` — `Subgroup` - `spond/role.py` — `Role` - `spond/profile.py` — `Profile` -- `spond/post.py` — `Post` (typed `Comment` deferred — see Open Questions) +- `spond/post.py` — `Post` with `save()`/`delete()`/`add_comment()` ActiveRecord surface +- `spond/comment.py` — typed `Comment` model (used by `Post.comments` and `Event.comments`) - `spond/chat.py` — `Chat`, `Message` **Changed:** @@ -316,6 +331,7 @@ The previous monolithic `tests/test_spond.py` has been split by domain. The curr - `tests/test_auth.py` — login flow + `require_authentication` decorator metadata - `tests/test_backward_compat.py` — regression guards for pre-OO patterns (dict access, `except KeyError:`/`ValueError:`, top-level `AuthenticationError` import, deprecated wrappers) - `tests/test_club.py` — `Transaction` and `SpondClub.get_transactions()` +- `tests/test_comment.py` — typed `Comment` model, materialisation on Post/Event - `tests/test_compat.py` — `DictCompatModel` shim + `LenientDate` - `tests/test_context_manager.py` — `async with Spond(...)` shape - `tests/test_event_convenience.py` — `is_past`/`is_upcoming`/`duration`/`response_for`/`has_responded` @@ -327,6 +343,7 @@ The previous monolithic `tests/test_spond.py` has been split by domain. The curr - `tests/test_groups.py` — `get_group` + Group → Member → Guardian navigation + `get_person` - `tests/test_identity.py` — natural-key equality / hashing across all models - `tests/test_messaging.py` — `Spond.send_message` + `Chat`/`Message` +- `tests/test_post_save_delete.py` — ActiveRecord write surface on Post (`save()`, `delete()`, `add_comment()`) - `tests/test_posts.py` — `get_posts` query construction, caching, error surfacing ## Out of scope diff --git a/README.md b/README.md index cd6378d..df9c0c7 100644 --- a/README.md +++ b/README.md @@ -60,18 +60,27 @@ async with spond.Spond(username, password) as s: # Update via kwargs (returns a new instance) new_event = await event.update(heading="Renamed") - # ActiveRecord-style write surface - new = Event(heading="My new event", - start_time=start, end_time=end, type="EVENT", - owners=[{"id": my_pid, "response": "accepted"}], - recipients={"group": {"id": group_id}}) - await new.save(client=s) # POST → uid populated; cache updated - assert new.uid - - new.description = "Some details" - await new.save() # PATCH (mutate-in-place, then save) - - await new.delete() # DELETE → pruned from cache + # ActiveRecord-style write surface — same shape for Event and Post + # (requires: from spond.event import Event; from spond.post import Post) + new_event = Event(heading="My new event", + start_time=start, end_time=end, type="EVENT", + owners=[{"id": my_pid, "response": "accepted"}], + recipients={"group": {"id": group_id}}) + await new_event.save(client=s) # POST → uid populated; cache updated + assert new_event.uid + + new_event.description = "Some details" + await new_event.save() # mutate-in-place, then re-save + + await new_event.delete() # DELETE → pruned from cache + + # Posts work the same way, with `add_comment` as a bonus: + post = Post(uid="", type="PLAIN", group_uid=group_id, + title="Hello", body="Welcome.") + await post.save(client=s) + comment = await post.add_comment("First!") + assert comment.uid and comment.text == "First!" + await post.delete() ``` ### Identity / equality diff --git a/spond/base.py b/spond/base.py index c7c2e90..3180531 100644 --- a/spond/base.py +++ b/spond/base.py @@ -49,7 +49,18 @@ def __init__(self, username: str, password: str, api_url: str) -> None: self.username = username self.password = password self.api_url = api_url - self.clientsession = aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar()) + # Use ThreadedResolver explicitly instead of aiohttp's c-ares default: + # c-ares allocates a kernel resource (an "AresChannel") per resolver + # instance, and the OS has a hard limit on those. A long-running + # process or a wide test matrix that constructs many short-lived + # `Spond` instances would otherwise hit + # `pycares.AresError: Failed to initialize c-ares channel`. + # ThreadedResolver uses the stdlib synchronous resolver in a thread + # — slightly higher per-lookup overhead, no channel limit. + self.clientsession = aiohttp.ClientSession( + cookie_jar=aiohttp.CookieJar(), + connector=aiohttp.TCPConnector(resolver=aiohttp.ThreadedResolver()), + ) self.token = None async def __aenter__(self): @@ -98,16 +109,31 @@ def require_authentication(func: Callable): @functools.wraps(func) async def wrapper(self, *args, **kwargs): - if not self.token: - try: - await self.login() - except AuthenticationError as e: - await self.clientsession.close() - raise e + await self._ensure_authenticated() return await func(self, *args, **kwargs) return wrapper + async def _ensure_authenticated(self) -> None: + """Trigger `login()` if not yet authenticated. + + Internal helper shared between the `@require_authentication` + decorator (which wraps `Spond.*` methods) and the per-instance + ActiveRecord methods on typed models (`event.save()`, + `post.save()`, etc.) — those route HTTP through `self._client` + but aren't themselves decorated, so they need to trigger the + lazy login themselves. + + On `AuthenticationError`, closes the underlying aiohttp session + before re-raising — same shape as the wrapper. + """ + if not self.token: + try: + await self.login() + except AuthenticationError: + await self.clientsession.close() + raise + async def login(self) -> None: """Authenticate against the Spond API and store the access token on `self.token`. Called automatically by the `require_authentication` diff --git a/spond/comment.py b/spond/comment.py new file mode 100644 index 0000000..f15d26f --- /dev/null +++ b/spond/comment.py @@ -0,0 +1,71 @@ +"""Typed `Comment` model — replaces the raw `list[dict]` previously +exposed by `Post.comments` and `Event.comments`. + +Comments are nested children of Posts and Events. They're typed for +attribute access and forward-compat (`extra="allow"`), but carry no +ActiveRecord operations of their own — the write surface lives on +the parent (`post.add_comment(text)`). Spond doesn't expose +comment-edit or comment-delete endpoints in the consumer API. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import ConfigDict, Field + +from ._compat import DictCompatModel + + +class Comment(DictCompatModel): + """A single comment attached to a `Post` or `Event`. + + Spond emits comments with this stable shape: + + ```json + { + "id": "", + "fromProfileId": "", + "timestamp": "", + "text": "", + "reactions": {} + } + ``` + + All fields except `uid` are optional in the SDK so a future API + drift (e.g. system-generated comments without a `fromProfileId`) + doesn't crash the whole `get_posts()` payload. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + uid: str = Field(alias="id") + from_profile_uid: str | None = Field(default=None, alias="fromProfileId") + """Profile UID of the comment author. Absent on system-generated + comments (if Spond ever emits any).""" + timestamp: datetime | None = None + text: str = "" + reactions: dict[str, Any] = Field(default_factory=dict) + """Emoji-reactions map keyed by reaction kind. Empty for unreacted + comments. Unmodelled — values vary by Spond release.""" + + def __str__(self) -> str: + ts = self.timestamp.isoformat() if self.timestamp else "?" + snippet = self.text[:40] + ("…" if len(self.text) > 40 else "") + return f"Comment(uid={self.uid!r}, from={self.from_profile_uid!r}, ts={ts}, text={snippet!r})" + + def _natural_key(self) -> tuple | None: + """uid when set; otherwise (from_profile_uid, timestamp, text) + for the rare case of a freshly-constructed comment with no uid yet.""" + if self.uid: + return ("Comment", self.uid) + if self.from_profile_uid or self.timestamp or self.text: + return ( + "Comment", + None, + self.from_profile_uid, + self.timestamp, + self.text, + ) + return None diff --git a/spond/event.py b/spond/event.py index 33275f5..bbb18fe 100644 --- a/spond/event.py +++ b/spond/event.py @@ -20,6 +20,7 @@ from pydantic_core import to_jsonable_python from ._compat import DictCompatModel +from .comment import Comment from .exceptions import SpondAPIError if TYPE_CHECKING: @@ -189,8 +190,10 @@ class Event(DictCompatModel): """Tasks dict with `openTasks`, `assignedTasks`. Unmodelled for now.""" attachments: list[Any] = Field(default_factory=list) """Attachment objects. Unmodelled for now.""" - comments: list[Any] = Field(default_factory=list) - """Comment objects. Only populated when fetched with `?includeComments=true`.""" + comments: list[Comment] = Field(default_factory=list) + """Typed `Comment` instances. Only populated when fetched with + `?includeComments=true` (which `Spond.get_event(uid)` sets by default + for the single-event endpoint).""" # Non-serialised reference back to the Spond client for HTTP calls. _client: Any = PrivateAttr(default=None) @@ -614,6 +617,7 @@ async def save(self, client: Spond | None = None) -> Event: "Event has no client bound. Pass `client=spond` to " "`event.save(client=...)` on first save." ) + await self._client._ensure_authenticated() if self.uid: # Update path — round-trip through `update()` so all the @@ -696,6 +700,7 @@ async def delete(self) -> None: "Cannot delete an unsaved Event (no uid). Call save() first " "or construct the instance via Spond.get_event()." ) + await self._client._ensure_authenticated() url = f"{self._client.api_url}sponds/{self.uid}" async with self._client.clientsession.delete( url, headers=self._client.auth_headers diff --git a/spond/post.py b/spond/post.py index 2ff132a..6bccb32 100644 --- a/spond/post.py +++ b/spond/post.py @@ -1,25 +1,61 @@ -"""Typed `Post` model — group-wall announcements and their comments. +"""Typed `Post` model — group-wall announcements with ActiveRecord behaviour. `Post`s are the announcement-style messages posted to a Group's wall (as opposed to chat messages or events). Returned by `Spond.get_posts()`. -Comments on posts are not yet modelled as a separate class — they're -exposed as a `list[dict]`. Modelling them is a follow-up (the comment -shape is small but varies by Spond version). +The write surface mirrors `Event`'s shape: + +- `post.save(client=spond)` — create (no uid) or update (uid present) +- `post.delete()` — DELETE `/posts/{uid}` and prune from cache +- `post.add_comment(text)` — POST `/posts/{uid}/comments`, returns the + new `Comment` and appends it to `post.comments` """ from __future__ import annotations from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, PrivateAttr from ._compat import DictCompatModel +from .comment import Comment +from .exceptions import SpondAPIError + +if TYPE_CHECKING: + from .spond import Spond class Post(DictCompatModel): - """A post on a Group's wall (announcement, not a chat message).""" + """A post on a Group's wall (announcement, not a chat message). + + Construct via `Spond.get_posts()` — which wires `_client` for you. + For a freshly-created post, build with `Post(...)` and call + `await post.save(client=spond)`; subsequent `save()` calls (and + `delete()` / `add_comment()`) use the bound client automatically. + + Example + ------- + ```python + async with Spond(username, password) as s: + # Read + posts = await s.get_posts(group_id="GRP") + for p in posts: + print(p.title, [c.text for c in p.comments]) + + # Create + post = Post(uid="", type="PLAIN", group_uid="GRP", + title="Hello", body="Welcome to the group.") + await post.save(client=s) + assert post.uid + + # Comment + await post.add_comment("First!") + + # Delete + await post.delete() + ``` + """ model_config = ConfigDict(populate_by_name=True, extra="allow") @@ -39,10 +75,20 @@ class Post(DictCompatModel): select_member_poll: bool = Field(default=False, alias="selectMemberPoll") media: list[Any] = Field(default_factory=list) attachments: list[Any] = Field(default_factory=list) - comments: list[dict[str, Any]] = Field(default_factory=list) - """Comment dicts. Only populated when fetched with - `include_comments=True` (the default for `Spond.get_posts()`). Currently - typed as raw `dict` — a `Comment` class is a possible future refinement.""" + reactions: dict[str, Any] = Field(default_factory=dict) + """Emoji-reactions map. Empty for unreacted posts.""" + comments: list[Comment] = Field(default_factory=list) + """Typed `Comment` instances. Only populated when fetched with + `include_comments=True` (the default for `Spond.get_posts()`).""" + + # Non-serialised reference back to the Spond client for HTTP calls. + _client: Any = PrivateAttr(default=None) + + # Fields that Spond manages server-side and shouldn't be round-tripped + # on update. Mirrors `_EVENT_READ_ONLY_FIELDS` on Event; see that + # docstring for the same reasoning (timestamps, derived state, + # nested sub-resources with their own endpoints). + _READ_ONLY_FIELDS: Any = PrivateAttr(default=None) def __str__(self) -> str: # `timestamp` is optional after the resilience relaxation, so guard @@ -58,3 +104,179 @@ def _natural_key(self) -> tuple | None: if self.title or self.timestamp: return ("Post", None, self.title, self.timestamp) return None + + @classmethod + def from_api(cls, data: dict[str, Any], client: Spond | None) -> Post: + """Construct a `Post` from raw API data and bind the client.""" + instance = cls.model_validate(data) + instance._client = client + return instance + + async def save(self, client: Spond | None = None) -> Post: + """Persist this post to Spond — universal create-or-update. + + - `self.uid` empty → POST `/posts/` to create. Mutates self in + place with the persisted state (uid populated, server-managed + fields copied in) and appends to `client.posts`. + - `self.uid` set → POST `/posts/{uid}` to update. Mutates self + with the refreshed state. + + On first save of a freshly-constructed instance, pass + `client=spond` to bind a client; subsequent saves use the + bound client. + + Raises + ------ + RuntimeError + No client is bound and `client` was not supplied. + SpondAPIError + Spond rejected the create or update. + """ + if client is not None: + self._client = client + if self._client is None: + raise RuntimeError( + "Post has no client bound. Pass `client=spond` to " + "`post.save(client=...)` on first save." + ) + await self._client._ensure_authenticated() + + # Server-managed fields — excluded from the update payload to + # avoid round-tripping stale local state. + _POST_READ_ONLY_FIELDS = frozenset( + { + "owner_uid", + "timestamp", # set by Spond on create; immutable + "unread", # per-user view state + "muted", # per-user view state + "reactions", # has its own endpoint + "comments", # has its own endpoint (add_comment) + } + ) + + if self.uid: + url = f"{self._client.api_url}posts/{self.uid}" + payload = self.model_dump( + by_alias=True, + mode="json", + exclude=_POST_READ_ONLY_FIELDS, + exclude_unset=True, + exclude_none=True, + ) + payload.pop("id", None) # don't echo uid in the body + else: + # Create path — POST to /posts/ (collection endpoint). + # Don't filter read-only fields on create: the caller is + # explicitly setting state. + payload = self.model_dump( + by_alias=True, + mode="json", + exclude_unset=True, + exclude_none=True, + ) + payload.pop("id", None) + url = f"{self._client.api_url}posts/" + + async with self._client.clientsession.post( + url, json=payload, headers=self._client.auth_headers + ) as r: + if not r.ok: + raise SpondAPIError(r.status, await r.text(), url) + new_data = await r.json() + + refreshed = type(self).from_api(new_data, self._client) + is_create = not self.uid + + # Apply refreshed state to self IN PLACE (ActiveRecord contract). + for field_name in type(self).model_fields: + object.__setattr__(self, field_name, getattr(refreshed, field_name)) + extras = refreshed._pydantic_extras() + if extras and self.__pydantic_extra__ is not None: + self.__pydantic_extra__.update(extras) + self.__pydantic_fields_set__ = set(refreshed.__pydantic_fields_set__) + + # Cache management. On create, prepend so subsequent + # `get_posts(...)` filter scans find it without a re-fetch. + if is_create: + if self._client.posts is None: + self._client.posts = [self] + else: + self._client.posts.insert(0, self) + + return self + + async def delete(self) -> None: + """Delete this post from Spond. + + Issues `DELETE /posts/{uid}` and prunes the post from the + client's `posts` cache. + + Raises + ------ + RuntimeError + The post has no client bound or no `uid` (was never + persisted). + SpondAPIError + Spond rejected the delete. + """ + if self._client is None: + raise RuntimeError("Post has no client bound; cannot delete.") + if not self.uid: + raise RuntimeError( + "Cannot delete an unsaved Post (no uid). Call save() first." + ) + await self._client._ensure_authenticated() + url = f"{self._client.api_url}posts/{self.uid}" + async with self._client.clientsession.delete( + url, headers=self._client.auth_headers + ) as r: + if not r.ok: + raise SpondAPIError(r.status, await r.text(), url) + if self._client.posts is not None: + self._client.posts = [p for p in self._client.posts if p.uid != self.uid] + + async def add_comment(self, text: str) -> Comment: + """Post a comment on this Post. + + Issues `POST /posts/{uid}/comments` with `{"text": text}` and + appends the resulting `Comment` to `self.comments` in place + so the parent post stays consistent without an explicit refresh. + + Parameters + ---------- + text : str + Comment body. + + Returns + ------- + Comment + The newly-created comment (also appended to `self.comments`). + + Raises + ------ + RuntimeError + No client bound, or `self.uid` is empty (the post hasn't + been saved to Spond yet — comments need a parent uid). + SpondAPIError + Spond rejected the comment (e.g. `commentsDisabled=True` on + the post). + """ + if self._client is None: + raise RuntimeError("Post has no client bound; cannot add comment.") + if not self.uid: + raise RuntimeError( + "Cannot add a comment to an unsaved Post. Call save() first." + ) + await self._client._ensure_authenticated() + url = f"{self._client.api_url}posts/{self.uid}/comments" + async with self._client.clientsession.post( + url, json={"text": text}, headers=self._client.auth_headers + ) as r: + if not r.ok: + raise SpondAPIError(r.status, await r.text(), url) + data = await r.json() + comment = Comment.model_validate(data) + # Keep the parent in sync — callers reading `post.comments` + # immediately after this call should see the new comment. + self.comments.append(comment) + return comment diff --git a/spond/spond.py b/spond/spond.py index 1483830..d92e77e 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -373,7 +373,7 @@ async def get_posts( if raw is None: self.posts = None return None - self.posts = [Post.model_validate(p) for p in raw] + self.posts = [Post.from_api(p, self) for p in raw] return self.posts @_SpondBase.require_authentication diff --git a/tests/test_comment.py b/tests/test_comment.py new file mode 100644 index 0000000..27f29fc --- /dev/null +++ b/tests/test_comment.py @@ -0,0 +1,122 @@ +"""Tests for the typed `Comment` model. + +Covers field mapping (alias resolution), natural-key equality with +Post/Event coverage of typed comments, and the resilience defaults +(only `uid` is strictly required).""" + +from __future__ import annotations + +from datetime import datetime + +from spond.comment import Comment +from spond.event import Event +from spond.post import Post + +from .conftest import _MIN_EVENT_PAYLOAD + +_RAW_COMMENT = { + "id": "CMT1", + "fromProfileId": "PROF1", + "timestamp": "2026-05-15T10:00:00Z", + "text": "Looks great!", + "reactions": {}, +} + + +class TestCommentParsing: + def test_parses_all_fields(self) -> None: + c = Comment.model_validate(_RAW_COMMENT) + assert c.uid == "CMT1" + assert c.from_profile_uid == "PROF1" + assert c.text == "Looks great!" + assert c.reactions == {} + assert c.timestamp is not None + assert c.timestamp.year == 2026 + + def test_minimal_only_uid_required(self) -> None: + """All non-uid fields have defaults; only `id` is strictly required. + Locks in the resilience guarantee against API drift.""" + c = Comment.model_validate({"id": "CMT_MIN"}) + assert c.uid == "CMT_MIN" + assert c.from_profile_uid is None + assert c.text == "" + assert c.timestamp is None + assert c.reactions == {} + + def test_str_includes_uid_and_snippet(self) -> None: + c = Comment.model_validate(_RAW_COMMENT) + s = str(c) + assert "CMT1" in s + assert "Looks great!" in s + + def test_str_handles_missing_timestamp(self) -> None: + """`__str__` must not crash when timestamp is None (same defensive + guard as `Post.__str__` / `Event.__str__`).""" + c = Comment.model_validate({"id": "X", "text": "no ts"}) + assert "?" in str(c) + assert "no ts" in str(c) + + def test_long_text_is_truncated_in_str(self) -> None: + c = Comment.model_validate({"id": "X", "text": "x" * 200}) + s = str(c) + assert "…" in s + assert len(s) < 200 + + def test_extra_fields_preserved(self) -> None: + """`extra="allow"` lets Spond add fields without crashing + validation. The extras are reachable via the dict-compat shim.""" + c = Comment.model_validate({**_RAW_COMMENT, "futureField": 42}) + assert "futureField" in c + assert c.__pydantic_extra__["futureField"] == 42 + + +class TestCommentNaturalKey: + """Comment uses uid when set, else (from_profile_uid, timestamp, text).""" + + def test_same_uid_equal_regardless_of_state(self) -> None: + a = Comment.model_validate({"id": "CMT1", "text": "hi"}) + b = Comment.model_validate({"id": "CMT1", "text": "different"}) + assert a == b + assert hash(a) == hash(b) + + def test_unsaved_equal_by_natural_key(self) -> None: + ts = datetime.fromisoformat("2026-05-15T10:00:00+00:00") + a = Comment(uid="", from_profile_uid="P1", timestamp=ts, text="hi") + b = Comment(uid="", from_profile_uid="P1", timestamp=ts, text="hi") + assert a == b + + +class TestCommentsOnPost: + """`Post.comments` must materialize as typed `Comment` instances.""" + + def test_post_comments_are_typed(self) -> None: + raw = { + "id": "POST1", + "type": "PLAIN", + "comments": [_RAW_COMMENT, {"id": "CMT2", "text": "second"}], + } + p = Post.model_validate(raw) + assert len(p.comments) == 2 + assert all(isinstance(c, Comment) for c in p.comments) + assert p.comments[0].text == "Looks great!" + assert p.comments[1].uid == "CMT2" + + def test_post_with_no_comments_defaults_to_empty_list(self) -> None: + p = Post.model_validate({"id": "P1"}) + assert p.comments == [] + + +class TestCommentsOnEvent: + """`Event.comments` must materialize as typed `Comment` instances too — + same shape across the two parent kinds.""" + + def test_event_comments_are_typed(self) -> None: + raw = {**_MIN_EVENT_PAYLOAD, "comments": [_RAW_COMMENT]} + e = Event.model_validate(raw) + assert len(e.comments) == 1 + assert isinstance(e.comments[0], Comment) + assert e.comments[0].text == "Looks great!" + + def test_event_with_no_comments_defaults_to_empty_list(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + assert e.comments == [] diff --git a/tests/test_post_save_delete.py b/tests/test_post_save_delete.py new file mode 100644 index 0000000..49e6bb7 --- /dev/null +++ b/tests/test_post_save_delete.py @@ -0,0 +1,389 @@ +"""Tests for the ActiveRecord write surface on Post: `save()`, +`delete()`, and `add_comment()`. + +Mirrors `tests/test_event_save_delete.py` — same dispatch shape, same +backward-compat guards. Endpoints (`POST /posts/`, +`DELETE /posts/{uid}`, `POST /posts/{uid}/comments`) verified live +against the test group before these tests were written; this file +locks in the wire shape so future refactors can't drift.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from spond import SpondAPIError +from spond.comment import Comment +from spond.post import Post +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_USERNAME + + +def _fresh_post() -> Post: + """Build an unsaved Post (no uid).""" + return Post( + uid="", + type="PLAIN", + group_uid="GROUP1", + title="New Post", + body="Some content.", + ) + + +_API_POST = { + "id": "NEWUID", + "type": "PLAIN", + "groupId": "GROUP1", + "title": "New Post", + "body": "Some content.", + "ownerId": "PROFILE1", + "timestamp": "2026-05-15T10:00:00Z", + "comments": [], +} + + +class TestPostSaveCreate: + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_populates_uid_in_place(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_API_POST + ) + + result = await post.save(client=s) + + # save() returns self (mutated in place) + assert result is post + assert post.uid == "NEWUID" + assert post.owner_uid == "PROFILE1" + assert post.timestamp is not None + assert post._client is s + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_posts_to_collection_url(self, mock_post) -> None: + """Create POSTs to `/posts/` (no uid in path).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_API_POST + ) + + await post.save(client=s) + + called_url = mock_post.call_args[0][0] + assert called_url.endswith("/posts/") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_payload_excludes_server_managed(self, mock_post) -> None: + """The create payload includes user-set fields but NOT `id` + (Spond mints the uid).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_API_POST + ) + + await post.save(client=s) + + posted = mock_post.call_args[1]["json"] + assert "id" not in posted + assert posted["title"] == "New Post" + assert posted["groupId"] == "GROUP1" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_appends_to_cache(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.posts = [] + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_API_POST + ) + + await post.save(client=s) + assert s.posts == [post] + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_initialises_empty_cache(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.posts = None + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_API_POST + ) + + await post.save(client=s) + assert s.posts == [post] + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_raises_on_http_error(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = False + mock_post.return_value.__aenter__.return_value.status = 500 + mock_post.return_value.__aenter__.return_value.text = AsyncMock( + return_value="boom" + ) + + with pytest.raises(SpondAPIError) as exc_info: + await post.save(client=s) + assert exc_info.value.status == 500 + + @pytest.mark.asyncio + async def test_save_without_client_raises(self) -> None: + post = _fresh_post() + with pytest.raises(RuntimeError, match="no client bound"): + await post.save() + + +class TestPostSaveUpdate: + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_existing_posts_to_uid_url(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_API_POST, "title": "Renamed"} + ) + + post.title = "Renamed" + await post.save() + + called_url = mock_post.call_args[0][0] + assert called_url.endswith("/posts/NEWUID") + assert post.title == "Renamed" + + +class TestPostDelete: + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + async def test_delete_issues_delete(self, mock_delete) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + s.posts = [post] + + mock_delete.return_value.__aenter__.return_value.ok = True + + await post.delete() + + called_url = mock_delete.call_args[0][0] + assert called_url.endswith("/posts/NEWUID") + assert s.posts == [] + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + async def test_delete_prunes_only_target_from_cache(self, mock_delete) -> None: + """Other posts in the cache must survive an unrelated delete.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + p1 = Post.from_api({**_API_POST, "id": "DELME"}, s) + p2 = Post.from_api({**_API_POST, "id": "KEEP"}, s) + s.posts = [p1, p2] + + mock_delete.return_value.__aenter__.return_value.ok = True + + await p1.delete() + assert [p.uid for p in s.posts] == ["KEEP"] + + @pytest.mark.asyncio + async def test_delete_without_client_raises(self) -> None: + post = Post.model_validate(_API_POST) + with pytest.raises(RuntimeError, match="no client"): + await post.delete() + + @pytest.mark.asyncio + async def test_delete_without_uid_raises(self) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = _fresh_post() + post._client = s + with pytest.raises(RuntimeError, match="unsaved"): + await post.delete() + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + async def test_delete_raises_on_http_error(self, mock_delete) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + + mock_delete.return_value.__aenter__.return_value.ok = False + mock_delete.return_value.__aenter__.return_value.status = 403 + mock_delete.return_value.__aenter__.return_value.text = AsyncMock( + return_value="nope" + ) + + with pytest.raises(SpondAPIError): + await post.delete() + + +class TestAddComment: + _API_COMMENT = { + "id": "CMT1", + "fromProfileId": "PROF1", + "timestamp": "2026-05-15T11:00:00Z", + "text": "Hello there", + "reactions": {}, + } + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_add_comment_returns_typed_comment(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self._API_COMMENT + ) + + result = await post.add_comment("Hello there") + assert isinstance(result, Comment) + assert result.uid == "CMT1" + assert result.text == "Hello there" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_add_comment_posts_to_comments_endpoint(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self._API_COMMENT + ) + + await post.add_comment("Hello there") + + called_url = mock_post.call_args[0][0] + assert called_url.endswith("/posts/NEWUID/comments") + # Body shape: just {"text": ...} + body = mock_post.call_args[1]["json"] + assert body == {"text": "Hello there"} + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_add_comment_appends_to_post_comments(self, mock_post) -> None: + """After `add_comment()`, `post.comments` contains the new comment + without an explicit refresh.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + assert len(post.comments) == 0 + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self._API_COMMENT + ) + + c = await post.add_comment("Hello there") + assert len(post.comments) == 1 + assert post.comments[0] is c + + @pytest.mark.asyncio + async def test_add_comment_without_client_raises(self) -> None: + post = Post.model_validate(_API_POST) + with pytest.raises(RuntimeError, match="no client"): + await post.add_comment("hi") + + @pytest.mark.asyncio + async def test_add_comment_without_uid_raises(self) -> None: + """Can't comment on an unsaved Post — no parent uid for the URL.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = _fresh_post() + post._client = s + with pytest.raises(RuntimeError, match="unsaved"): + await post.add_comment("hi") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_add_comment_raises_on_http_error(self, mock_post) -> None: + """E.g. when `commentsDisabled=True` on the post Spond rejects.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + + mock_post.return_value.__aenter__.return_value.ok = False + mock_post.return_value.__aenter__.return_value.status = 403 + mock_post.return_value.__aenter__.return_value.text = AsyncMock( + return_value="comments disabled" + ) + + with pytest.raises(SpondAPIError): + await post.add_comment("hi") + + +class TestPostRoundtrip: + """End-to-end ActiveRecord lifecycle: construct → save (create) → + mutate → save (update) → add_comment → delete.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + async def test_full_lifecycle(self, mock_post, mock_delete) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.posts = [] + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = True + # Sequence: create response, update response, comment response + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[ + _API_POST, + {**_API_POST, "title": "Renamed"}, + { + "id": "CMT", + "text": "hi", + "fromProfileId": "P", + "timestamp": "2026-05-15T12:00:00Z", + }, + ] + ) + + await post.save(client=s) + assert post.uid == "NEWUID" + + post.title = "Renamed" + await post.save() + assert post.title == "Renamed" + + c = await post.add_comment("hi") + assert c.text == "hi" + assert post.comments[-1] is c + + mock_delete.return_value.__aenter__.return_value.ok = True + await post.delete() + assert post.uid not in {p.uid for p in s.posts} From 87dd43c13990bf2aa928be5782c66efbf7ebc89b Mon Sep 17 00:00:00 2001 From: olen Date: Fri, 15 May 2026 11:15:17 +0200 Subject: [PATCH 32/42] fix(oo-rewrite): address pending Copilot review on Phase 1 + 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three items from an earlier Copilot review that had been left open across session boundaries. 1. `spond/_compat.py::_entity_kind_of` — removed a dead `for ancestor in cls.__mro__` loop whose body was only a comment and contributed nothing to the function's behaviour. The actual entity-kind resolution lives entirely in the list comprehension and the `user_classes[-1].__name__` return. Updated the surrounding doc- string to describe what the function actually does (filter MRO, return last user-defined entry) rather than the misleading "walk" language. 2. `spond/person.py::_send_message_to_person` — moved the `profile.id` validation above the lazy `_login_chat()` handshake so a pure client-side argument error (member without a profile id) no longer triggers a chat-server network round-trip before the `ValueError`. Mirrors the same fail-fast ordering already applied to `Spond.send_message`. 3. `tests/test_messaging.py` — simplified an awkward `url, _ = mock_post.call_args[0], mock_post.call_args[1]` line into a direct `assert mock_post.call_args[0][0] == ...`. The redundant kwargs capture was already re-read two lines below. All 270 tests pass; no behavioural change beyond fail-fast ordering. Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/_compat.py | 14 ++++++-------- spond/person.py | 11 +++++++---- tests/test_messaging.py | 3 +-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/spond/_compat.py b/spond/_compat.py index e9b1118..7752785 100644 --- a/spond/_compat.py +++ b/spond/_compat.py @@ -279,20 +279,18 @@ def model_equals(self, other: object) -> bool: def _entity_kind_of(cls: type) -> str: - """Walk the MRO to find the nearest non-`DictCompatModel`, - non-`BaseModel` ancestor — that's the "entity kind." + """Return the name of the top-most user-defined ancestor in `cls`'s + MRO before `DictCompatModel` — that's the "entity kind." For `Match` (which inherits from `Event`), this returns `"Event"` so `Match` and `Event` instances with the same uid compare equal. For `Member`/`Guardian` (both inheriting from `Person`), this returns `"Person"` for the same reason. """ - for ancestor in cls.__mro__: - if ancestor is DictCompatModel or ancestor is BaseModel: - break - # The most-derived "non-base" class with a name is the entity - # kind. We walk further up to find the most general one. - # Find the top-most user-defined class in the MRO before DictCompatModel. + # Filter MRO to user-defined spond.* classes, dropping the shared + # DictCompatModel / BaseModel / object roots. The result is ordered + # from most-derived (cls itself) to least-derived — the LAST entry + # is the entity kind (e.g. for Match: [Match, Event] → "Event"). user_classes = [ c for c in cls.__mro__ diff --git a/spond/person.py b/spond/person.py index c5d7b54..6c6abdf 100644 --- a/spond/person.py +++ b/spond/person.py @@ -185,16 +185,19 @@ async def _send_message_to_person( f"Spond.get_person() or walk Spond.get_groups()." ) - # Lazy chat handshake (Spond's chat API uses a separate host + token). - if client._auth is None: - await client._login_chat() - + # Validate caller args BEFORE the chat-server handshake so a pure + # client-side argument error doesn't trigger a network round-trip. + # Mirrors the same fail-fast ordering in `Spond.send_message`. if not isinstance(person.profile, dict) or "id" not in person.profile: raise ValueError( f"{type(person).__name__} {person.uid} has no profile id; " f"Spond cannot route a message without one." ) + # Lazy chat handshake (Spond's chat API uses a separate host + token). + if client._auth is None: + await client._login_chat() + payload = { "text": text, "type": "TEXT", diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 76f3722..7069c1a 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -150,8 +150,7 @@ async def test_chat_send_routes_through_chat_server( ) result = await chat.send("ack") assert result == {"ok": True} - url, _ = mock_post.call_args[0], mock_post.call_args[1] - assert url[0] == "https://chat.example.invalid/messages" + assert mock_post.call_args[0][0] == "https://chat.example.invalid/messages" kwargs = mock_post.call_args[1] assert kwargs["json"] == {"chatId": "CHAT1", "text": "ack", "type": "TEXT"} assert kwargs["headers"] == {"auth": "MOCK_CHAT_AUTH"} From 343086a10e7f72dd8f2e6c807ba142cbcc90b6d0 Mon Sep 17 00:00:00 2001 From: olen Date: Fri, 15 May 2026 12:33:29 +0200 Subject: [PATCH 33/42] docs: remove local credential path reference from design doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line "All four remain answerable with live API probing using the credentials at \`/home/olen/prog/spond-kalender/config.py\`" leaked a maintainer-specific filesystem path into a public doc. The path isn't a credential by itself (the file doesn't exist on anyone else's machine), but it shouldn't appear in published docs. Replaced with a generic note about "a real Spond account." Note: earlier commits on this branch still contain the path in their diffs (it was introduced in 1386 and persisted through the design-doc revisions). When merging this PR, prefer "Squash and merge" so the branch's commit history doesn't propagate into \`main\` — the squashed single commit only reflects the current (clean) state of the files. Co-Authored-By: Claude Opus 4.7 (1M context) --- DESIGN-oo-rewrite.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DESIGN-oo-rewrite.md b/DESIGN-oo-rewrite.md index 936342d..0c01891 100644 --- a/DESIGN-oo-rewrite.md +++ b/DESIGN-oo-rewrite.md @@ -286,7 +286,7 @@ Items deferred from this PR, tracked here as roadmap candidates. 3. **Full chat history.** `Chat.message` only carries the most-recent message; the chat API has additional endpoints for older messages that aren't modelled yet. 4. **Comment edit / delete.** Spond's app supports editing and deleting your own comments, but the corresponding endpoints aren't yet probed/modelled. `post.add_comment(text)` ships in v2.0; the lifecycle endpoints (`PUT /posts/{uid}/comments/{cuid}`, `DELETE /posts/{uid}/comments/{cuid}` or similar) need verification. -All four remain answerable with live API probing using the credentials at `/home/olen/prog/spond-kalender/config.py`. +All four remain answerable with live API probing against a real Spond account. ## What shipped in v2.0 (previously open) From c00f0457ea90b131ba3300bf25122283773ecadc Mon Sep 17 00:00:00 2001 From: olen Date: Fri, 15 May 2026 12:36:23 +0200 Subject: [PATCH 34/42] fix(oo-rewrite): address round-15 Copilot review on Post.save() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three items, all in spond/post.py with one matching cleanup in event.py. 1. Hoisted `_POST_READ_ONLY_FIELDS` from a `save()`-local frozenset to a module-level constant (now mirrors `_EVENT_READ_ONLY_FIELDS` in event.py). Removed the dead `_READ_ONLY_FIELDS: Any = PrivateAttr(default=None)` class attribute that was never read — a future maintainer could have mistaken it for the active filter. 2. Apply `_POST_READ_ONLY_FIELDS` on the create path too, not just update. Previously a `Post` instance built from another post's response payload (so `comments`/`reactions` populated) would have POSTed those nested lists to the create endpoint. Spond ignores most of them (server-managed `owner_uid`/`timestamp`, per-user `unread`/`muted`), but `comments`/`reactions` have dedicated endpoints — sending them at create-time is wrong on principle. The two paths now share one payload-construction block; the only difference is the URL. 3. Documented the `object.__setattr__(self, name, value)` bypass intent in both `Post.save()` and `Event.save()`. The bypass is deliberate: `refreshed` has already passed full Pydantic validation via `from_api`, so per-field re-validation here would be redundant AND would re-trigger any validators with side effects (e.g. mutation timestamps). Without the comment a future maintainer adding `validate_assignment=True` could be surprised. Live API re-verified end-to-end on the test group (create + delete both still 200 with the updated create payload). Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/event.py | 8 +++++ spond/post.py | 81 ++++++++++++++++++++++++++++---------------------- 2 files changed, 53 insertions(+), 36 deletions(-) diff --git a/spond/event.py b/spond/event.py index bbb18fe..ee17a3b 100644 --- a/spond/event.py +++ b/spond/event.py @@ -664,6 +664,14 @@ async def save(self, client: Spond | None = None) -> Event: # Apply the refreshed state to self IN PLACE — this is the # ActiveRecord contract: after `save()`, `self` is the # authoritative live record. + # + # `object.__setattr__` is used deliberately to bypass any + # `validate_assignment=True` or custom `__setattr__` a future + # subclass might add — the values in `refreshed` have already + # passed full Pydantic validation via `from_api`, so re-running + # validation per-field here would be redundant work AND would + # incorrectly re-trigger any validators with side effects (e.g. + # mutation timestamps). for field_name in type(self).model_fields: object.__setattr__(self, field_name, getattr(refreshed, field_name)) # Capture any extras Spond added that we don't model. diff --git a/spond/post.py b/spond/post.py index 6bccb32..9e637ec 100644 --- a/spond/post.py +++ b/spond/post.py @@ -26,6 +26,26 @@ from .spond import Spond +# Python field names that `Post.save()` strips from the POST payload +# on both the create and update paths. Mirrors `_EVENT_READ_ONLY_FIELDS` +# in event.py — same reasoning: server-managed timestamps and identifiers, +# per-user view state, and nested sub-resources that have their own +# endpoints (`comments` → `post.add_comment(...)`; reactions are managed +# elsewhere). Sending these back risks Spond treating stale local state +# as authoritative or, on create, having client-supplied values +# silently overridden by server-managed ones. +_POST_READ_ONLY_FIELDS = frozenset( + { + "owner_uid", # set by Spond from the authenticated user + "timestamp", # set by Spond on create; immutable + "unread", # per-user view state + "muted", # per-user view state + "reactions", # has its own dedicated endpoint + "comments", # has its own endpoint (post.add_comment) + } +) + + class Post(DictCompatModel): """A post on a Group's wall (announcement, not a chat message). @@ -84,12 +104,6 @@ class Post(DictCompatModel): # Non-serialised reference back to the Spond client for HTTP calls. _client: Any = PrivateAttr(default=None) - # Fields that Spond manages server-side and shouldn't be round-tripped - # on update. Mirrors `_EVENT_READ_ONLY_FIELDS` on Event; see that - # docstring for the same reasoning (timestamps, derived state, - # nested sub-resources with their own endpoints). - _READ_ONLY_FIELDS: Any = PrivateAttr(default=None) - def __str__(self) -> str: # `timestamp` is optional after the resilience relaxation, so guard # the .isoformat() call the same way `Event.__str__` guards @@ -141,40 +155,26 @@ async def save(self, client: Spond | None = None) -> Post: ) await self._client._ensure_authenticated() - # Server-managed fields — excluded from the update payload to - # avoid round-tripping stale local state. - _POST_READ_ONLY_FIELDS = frozenset( - { - "owner_uid", - "timestamp", # set by Spond on create; immutable - "unread", # per-user view state - "muted", # per-user view state - "reactions", # has its own endpoint - "comments", # has its own endpoint (add_comment) - } + # Apply `_POST_READ_ONLY_FIELDS` on BOTH paths. On update it + # prevents round-tripping stale local state of fields with + # dedicated endpoints. On create the same set is excluded + # because Spond sets `owner_uid`/`timestamp` itself, ignores + # client-supplied `unread`/`muted` (per-user state), and the + # `comments`/`reactions` lists are populated through their own + # endpoints — sending them at create time risks the caller + # accidentally seeding stale data from a Post built off another + # post's response payload. + payload = self.model_dump( + by_alias=True, + mode="json", + exclude=_POST_READ_ONLY_FIELDS, + exclude_unset=True, + exclude_none=True, ) - + payload.pop("id", None) # never echo uid in the body if self.uid: url = f"{self._client.api_url}posts/{self.uid}" - payload = self.model_dump( - by_alias=True, - mode="json", - exclude=_POST_READ_ONLY_FIELDS, - exclude_unset=True, - exclude_none=True, - ) - payload.pop("id", None) # don't echo uid in the body else: - # Create path — POST to /posts/ (collection endpoint). - # Don't filter read-only fields on create: the caller is - # explicitly setting state. - payload = self.model_dump( - by_alias=True, - mode="json", - exclude_unset=True, - exclude_none=True, - ) - payload.pop("id", None) url = f"{self._client.api_url}posts/" async with self._client.clientsession.post( @@ -188,6 +188,15 @@ async def save(self, client: Spond | None = None) -> Post: is_create = not self.uid # Apply refreshed state to self IN PLACE (ActiveRecord contract). + # `object.__setattr__` is used deliberately to bypass any + # `validate_assignment=True` or custom `__setattr__` a future + # subclass might add — the values in `refreshed` have already + # passed full Pydantic validation via `from_api`, so re-running + # validation per-field here would be redundant work AND would + # incorrectly re-trigger any validators that have side effects + # (e.g. mutation timestamps). The wholesale + # `__pydantic_fields_set__` replacement on the line below keeps + # `exclude_unset=True` dumps consistent with what Spond emitted. for field_name in type(self).model_fields: object.__setattr__(self, field_name, getattr(refreshed, field_name)) extras = refreshed._pydantic_extras() From 305cb2869b958bb18abbef60abdd8051ea184cab Mon Sep 17 00:00:00 2001 From: olen Date: Fri, 15 May 2026 15:56:16 +0200 Subject: [PATCH 35/42] fix(oo-rewrite): address round-16 Copilot review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four items — three real bugs and one false flag. ## __aexit__: narrow the RuntimeError suppression (#3247599418) `__aexit__` was wrapping `clientsession.close()` in `contextlib.suppress(RuntimeError)` — way too broad. A future aiohttp release raising RuntimeError for a real reason (connector failure, resource leak) would be silently swallowed. Replaced with `if not self.clientsession.closed: await close()`. The defensive double-close case is handled cleanly; genuine errors now propagate. ## validate_assignment=True on Event and Post (#3247599396) `save()` uses `model_dump(exclude_unset=True)` to build the POST payload. With Pydantic v2's default `validate_assignment=False`, direct attribute assignment after construction (`post.title = "X"`) does NOT update `__pydantic_fields_set__`. So mutating a previously- unset field and then calling `save()` silently dropped the change. Enabling `validate_assignment=True` on Event and Post fixes this cleanly — assignments now route through Pydantic's validator chain which both validates the new value AND records the field as set. Regression test added in `test_event_save_delete.py::TestSaveUpdate ::test_save_persists_mutation_of_unset_field`. ## __eq__/__hash__ invariant (#3247599307) The previous fallback was `BaseModel.__eq__` (full-field equality) for `__eq__` paired with `object.__hash__` (identity-based) for `__hash__`. Two distinct instances with identical state were equal but had different hashes — violating Python's `a == b ⟹ hash(a) == hash(b)` invariant. Adopted a two-tier model: - **Entity types** (have a natural key): equal iff keys match; hashable via the same key. Match/Event and Member/Guardian still compare equal across classes via their shared entity-kind tag in the key tuple. - **Value types / sub-objects** (no natural key — Responses, MatchInfo): equal iff every declared field matches (still useful for `model_equals()` propagating through nested fields), but `__hash__` raises `TypeError("unhashable type")` — same convention as `dict` / `list` / `set`. Their declared fields contain mutable lists and dicts; making them hashable would require hashing those too, which doesn't work. ## Test renames — false flag (#3247599368) Copilot saw `test_save_create_appends_to_client_cache` (Event) and `test_save_create_appends_to_cache` (Post) and inferred from the NAMES that the two methods used different ordering. Both actually use `insert(0, ...)` (prepend) — the test names were misleading. Renamed both to `test_save_create_prepends_to_cache` and added an explicit "new at position 0, existing slid down" assertion using a pre-populated cache so the prepend behavior is now self-evident from the test body. Live API re-verified end-to-end: event create + mutate-then-save + delete + post create/delete all still 200 in the test group. Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/_compat.py | 62 ++++++++++++++++++++++++--------- spond/base.py | 12 +++---- spond/event.py | 7 ++++ spond/post.py | 9 ++++- tests/test_event_save_delete.py | 44 +++++++++++++++++++++-- tests/test_post_save_delete.py | 14 ++++++-- 6 files changed, 119 insertions(+), 29 deletions(-) diff --git a/spond/_compat.py b/spond/_compat.py index 7752785..a0a993e 100644 --- a/spond/_compat.py +++ b/spond/_compat.py @@ -227,31 +227,61 @@ def _natural_key(self) -> tuple | None: return None def __eq__(self, other: object) -> bool: - # Falls outside the typed-model graph — let Python try the - # other operand's __eq__ via NotImplemented. + """Two-tier equality: + + - **Entity types** (have a natural key — uid, or a user-visible + fallback like heading+start_time): equal iff their natural + keys match. Hashable via the same key. + - **Value types / sub-objects** (no natural key — e.g. + `Responses`, `MatchInfo`): equal iff every declared field + plus extras match. Unhashable (like `dict`/`list`), so they + can't live in sets or dict keys — see `__hash__`. + + Mixed comparisons (entity vs value) return False; cross-class + comparisons return False. + """ if not isinstance(other, DictCompatModel): return NotImplemented a = self._natural_key() b = other._natural_key() if a is not None and b is not None: + # Natural-key path — types may differ legitimately (Match + # and Event share entity kind `"Event"`; Member and + # Guardian share `"Person"`). The kind tag is the FIRST + # element of the natural-key tuple, so equality across + # those pairs is preserved while two genuinely different + # entity kinds with the same uid (a hypothetical + # `Event("X")` and `Group("X")`) still compare unequal. return a == b - # One or both lack a natural key — fall back to Pydantic's - # full-field equality, but only between same-class instances - # (cross-class full-field equality is rarely meaningful). - if type(self) is not type(other): - return False - return BaseModel.__eq__(self, other) + if a is None and b is None: + # Value-type comparison: full-field equality, but only + # between same-class instances. Required so parent + # equality (an Event comparing its `responses` sub-object) + # gets the right "are these the same state?" answer. + if type(self) is not type(other): + return False + return BaseModel.__eq__(self, other) + # One side has a natural key and the other doesn't — they're + # different kinds of thing. + return False def __hash__(self) -> int: + """Hash via the natural key when set. + + Value-type sub-objects (no natural key — `Responses`, + `MatchInfo`, partially-constructed entities with all + identifying fields empty) raise `TypeError` — same convention + as the Python stdlib (`dict`, `list`, `set` are all unhashable + for the same reason: their content is mutable and they may + carry unhashable values internally). The `__eq__` path above + uses full-field comparison for these, so making them hashable + would require hashing every declared field, and the contained + lists/dicts aren't hashable themselves. + """ key = self._natural_key() - if key is not None: - return hash(key) - # No natural key (e.g. partially-constructed instance) — fall - # back to identity-based hash so the object is at least - # hashable. Two such instances are unequal under __eq__'s - # full-field-fallback path anyway, so this preserves the - # equality/hash invariant. - return object.__hash__(self) + if key is None: + raise TypeError(f"unhashable type: {type(self).__name__!r}") + return hash(key) def model_equals(self, other: object) -> bool: """Pre-OO escape hatch: full-field equality, ignoring natural keys. diff --git a/spond/base.py b/spond/base.py index 3180531..d96a36e 100644 --- a/spond/base.py +++ b/spond/base.py @@ -8,7 +8,6 @@ Not intended to be instantiated directly — use a subclass. """ -import contextlib import functools from abc import ABC from collections.abc import Callable @@ -84,12 +83,13 @@ async def __aenter__(self): async def __aexit__(self, exc_type, exc, tb) -> None: """Async context-manager exit — close the aiohttp client session. - Silently swallows any `RuntimeError` raised by `close()` (e.g. - "session is already closed" if the caller closed it manually - before exiting the `with` block) — defensive cleanup shouldn't - mask the original exception that triggered the exit. + Checks `clientsession.closed` first so a caller who manually + closed the session inside the `with` block doesn't trigger a + second close. Any genuine `RuntimeError` from `close()` (resource + leak, connector failure, etc.) is allowed to propagate rather + than being silently swallowed — that surface signals a real bug. """ - with contextlib.suppress(RuntimeError): + if not self.clientsession.closed: await self.clientsession.close() @property diff --git a/spond/event.py b/spond/event.py index ee17a3b..5771569 100644 --- a/spond/event.py +++ b/spond/event.py @@ -134,6 +134,13 @@ class Event(DictCompatModel): populate_by_name=True, extra="allow", arbitrary_types_allowed=True, + # `validate_assignment=True` makes direct attribute assignment + # (e.g. `event.heading = "Renamed"`) run through Pydantic's + # validation pipeline AND update `__pydantic_fields_set__`. + # Without this, mutate-then-save() would silently drop changes + # to fields that weren't in the source payload, since the + # `exclude_unset=True` dump filter wouldn't include them. + validate_assignment=True, ) # Core fields. Only `uid` is truly required for the SDK to be useful diff --git a/spond/post.py b/spond/post.py index 9e637ec..68c2c77 100644 --- a/spond/post.py +++ b/spond/post.py @@ -77,7 +77,14 @@ class Post(DictCompatModel): ``` """ - model_config = ConfigDict(populate_by_name=True, extra="allow") + model_config = ConfigDict( + populate_by_name=True, + extra="allow", + # See Event.model_config — same rationale: ensures + # mutate-then-save() picks up direct attribute assignments + # to fields that weren't in the source payload. + validate_assignment=True, + ) uid: str = Field(alias="id") type: str = "PLAIN" diff --git a/tests/test_event_save_delete.py b/tests/test_event_save_delete.py index f0c62ad..434a9af 100644 --- a/tests/test_event_save_delete.py +++ b/tests/test_event_save_delete.py @@ -117,10 +117,16 @@ async def test_save_create_includes_recipients_in_payload(self, mock_post) -> No @pytest.mark.asyncio @patch("aiohttp.ClientSession.post") - async def test_save_create_appends_to_client_cache(self, mock_post) -> None: + async def test_save_create_prepends_to_client_cache(self, mock_post) -> None: + """A newly-saved event is prepended to `events` (position 0) so it + appears first in subsequent `get_event(uid)` scans — matches Spond's + own newest-first ordering on `get_events()`.""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) s.token = "MOCK" - s.events = [] # empty list, not None — so we observe the append + # Pre-existing event in the cache, so we can observe the position + # the new one is inserted at. + existing = Event.from_api({**_MIN_EVENT_PAYLOAD, "id": "EXISTING"}, s) + s.events = [existing] event = _fresh_event() mock_post.return_value.__aenter__.return_value.ok = True @@ -130,8 +136,10 @@ async def test_save_create_appends_to_client_cache(self, mock_post) -> None: await event.save(client=s) - assert len(s.events) == 1 + # New event at position 0; existing event slid down. + assert len(s.events) == 2 assert s.events[0].uid == "NEWUID" + assert s.events[1].uid == "EXISTING" @pytest.mark.asyncio @patch("aiohttp.ClientSession.post") @@ -204,6 +212,36 @@ async def test_save_existing_posts_to_uid_url(self, mock_post) -> None: ) assert event.heading == "Renamed" + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_persists_mutation_of_unset_field(self, mock_post) -> None: + """Regression guard for the `validate_assignment=True` config: + mutating a field that wasn't in the source payload must still + reach the POST body. Without `validate_assignment=True`, direct + attribute assignment doesn't update `__pydantic_fields_set__`, + and `exclude_unset=True` would silently drop the change.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + # `_MIN_EVENT_PAYLOAD` deliberately omits `description`. + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + assert event.description is None + assert "description" not in event.__pydantic_fields_set__ + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_MIN_EVENT_PAYLOAD + ) + + # Direct attribute assignment to a previously-unset field. + event.description = "Now described" + await event.save() + + # `description` must reach Spond — the silent-drop footgun. + posted = mock_post.call_args[1]["json"] + assert posted.get("description") == "Now described", ( + "mutate-then-save dropped the change; validate_assignment " + "is not enabled or model_fields_set is not being tracked" + ) + @pytest.mark.asyncio @patch("aiohttp.ClientSession.post") async def test_save_existing_uses_bound_client(self, mock_post) -> None: diff --git a/tests/test_post_save_delete.py b/tests/test_post_save_delete.py index 49e6bb7..78dfe5c 100644 --- a/tests/test_post_save_delete.py +++ b/tests/test_post_save_delete.py @@ -107,10 +107,14 @@ async def test_save_create_payload_excludes_server_managed(self, mock_post) -> N @pytest.mark.asyncio @patch("aiohttp.ClientSession.post") - async def test_save_create_appends_to_cache(self, mock_post) -> None: + async def test_save_create_prepends_to_cache(self, mock_post) -> None: + """A newly-saved post is prepended to `posts` (position 0) — same + ordering convention as `Event.save()`, matching Spond's + newest-first ordering on `get_posts()`.""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) s.token = "MOCK" - s.posts = [] + existing = Post.from_api({**_API_POST, "id": "EXISTING"}, s) + s.posts = [existing] post = _fresh_post() mock_post.return_value.__aenter__.return_value.ok = True @@ -119,7 +123,11 @@ async def test_save_create_appends_to_cache(self, mock_post) -> None: ) await post.save(client=s) - assert s.posts == [post] + + # New post at position 0; existing post slid down. + assert len(s.posts) == 2 + assert s.posts[0].uid == "NEWUID" + assert s.posts[1].uid == "EXISTING" @pytest.mark.asyncio @patch("aiohttp.ClientSession.post") From 68d350f0aa260976066554a22889a8b1812e89d8 Mon Sep 17 00:00:00 2001 From: olen Date: Fri, 15 May 2026 16:55:23 +0200 Subject: [PATCH 36/42] fix(oo-rewrite): Event.save() caches self, not a refreshed copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inconsistency between Event.save() and Post.save() spotted in review: Post cached `self` after the in-place mutation, so callers could rely on `post is spond.posts[0]` after a successful save. Event, however, cached `refreshed` (the new instance built from the API response) *before* mutating self, so `event is not spond.events[0]` even though their field state matched. The two ActiveRecord types should agree. Aligned Event.save() to Post's contract: - Create path: insert `self` at position 0, not `refreshed`. The in-place mutation of self has already brought it up to date with the persisted state. - Update path: after `await self.update()` (which writes its own returned instance into the cache), overwrite that slot with `self` so the identity guarantee holds on this path too. The choice between caching self vs caching refreshed is mostly philosophical, but ActiveRecord's premise is "self is the live record" — caching anything else creates two objects with identical state and no way for the caller to tell them apart, which breaks set/dict-key semantics (and makes future "did anyone modify this since save?" introspection awkward). Tests added on both Post and Event: - `test_save_create_caches_self_not_refreshed_copy` (both files) - `test_save_update_caches_self` (Event only, since this is the path that delegates through update() and required extra fix-up) Live API re-verified: after `event.save()` (create AND mutate-then-save), `event is s.events[0]` evaluates True in both cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/event.py | 33 +++++++++++++++++------ tests/test_event_save_delete.py | 48 +++++++++++++++++++++++++++++++++ tests/test_post_save_delete.py | 18 +++++++++++++ 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/spond/event.py b/spond/event.py index 5771569..445e409 100644 --- a/spond/event.py +++ b/spond/event.py @@ -629,9 +629,13 @@ async def save(self, client: Spond | None = None) -> Event: if self.uid: # Update path — round-trip through `update()` so all the # payload-discipline machinery (exclude_unset, read-only - # filter, JSON-encoding of caller values) applies. Then - # mutate self with the refreshed state. + # filter, JSON-encoding of caller values) applies. Note + # that `update()` *also* writes its returned instance into + # `self._client.events`; we overwrite that slot with self + # below to preserve the ActiveRecord identity guarantee + # (`event is spond.events[i]` after `save()`). refreshed = await self.update() + is_create = False else: # Create path — POST to /sponds/ (collection endpoint). # We do NOT apply `_EVENT_READ_ONLY_FIELDS` here: that @@ -661,12 +665,7 @@ async def save(self, client: Spond | None = None) -> Event: raise SpondAPIError(r.status, await r.text(), url) new_data = await r.json() refreshed = type(self).from_api(new_data, self._client) - # Append to the client cache so subsequent `get_event(uid)` - # resolves the new event without a re-fetch. - if self._client.events is None: - self._client.events = [refreshed] - else: - self._client.events.insert(0, refreshed) + is_create = True # Apply the refreshed state to self IN PLACE — this is the # ActiveRecord contract: after `save()`, `self` is the @@ -689,6 +688,24 @@ async def save(self, client: Spond | None = None) -> Event: # dumps reflect what Spond actually emitted (not our pre-save # snapshot). self.__pydantic_fields_set__ = set(refreshed.__pydantic_fields_set__) + + # Cache management — match Post.save()'s identity guarantee: + # `event is spond.events[i]` after a successful save(). On + # create, prepend self. On update, replace whatever the + # delegated update() call wrote (which was the now-discardable + # `refreshed` instance, NOT self). + if is_create: + if self._client.events is None: + self._client.events = [self] + else: + self._client.events.insert(0, self) + else: + if self._client.events is not None: + for i, cached in enumerate(self._client.events): + if cached.uid == self.uid: + self._client.events[i] = self + break + return self async def delete(self) -> None: diff --git a/tests/test_event_save_delete.py b/tests/test_event_save_delete.py index 434a9af..2c387d5 100644 --- a/tests/test_event_save_delete.py +++ b/tests/test_event_save_delete.py @@ -141,6 +141,54 @@ async def test_save_create_prepends_to_client_cache(self, mock_post) -> None: assert s.events[0].uid == "NEWUID" assert s.events[1].uid == "EXISTING" + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_caches_self_not_refreshed_copy(self, mock_post) -> None: + """Identity guarantee: after `event.save()`, `event is + s.events[0]` — not a distinct same-state copy. Matches the + identity contract `Post.save()` already enforces; consistency + across ActiveRecord types lets callers rely on a single rule.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.events = [] + event = _fresh_event() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "id": "NEWUID"} + ) + + await event.save(client=s) + + assert s.events[0] is event, ( + "cache should hold the saved instance itself, not a copy" + ) + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_update_caches_self(self, mock_post) -> None: + """Same identity guarantee on the update path: even though + `save()` delegates to `update()` (which writes a fresh instance + to the cache), `save()` overwrites that slot with self + afterwards.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + s.events = [event] + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "heading": "Renamed"} + ) + + event.heading = "Renamed" + await event.save() + + assert s.events[0] is event, ( + "after save() the cache must still hold self, not the " + "intermediate instance delegated-update() wrote" + ) + @pytest.mark.asyncio @patch("aiohttp.ClientSession.post") async def test_save_create_initialises_empty_cache(self, mock_post) -> None: diff --git a/tests/test_post_save_delete.py b/tests/test_post_save_delete.py index 78dfe5c..2dd3c78 100644 --- a/tests/test_post_save_delete.py +++ b/tests/test_post_save_delete.py @@ -129,6 +129,24 @@ async def test_save_create_prepends_to_cache(self, mock_post) -> None: assert s.posts[0].uid == "NEWUID" assert s.posts[1].uid == "EXISTING" + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_caches_self_not_refreshed_copy(self, mock_post) -> None: + """Identity guarantee: after `post.save()`, `post is + s.posts[0]` — matches `Event.save()`'s identity contract.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.posts = [] + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_API_POST + ) + + await post.save(client=s) + assert s.posts[0] is post + @pytest.mark.asyncio @patch("aiohttp.ClientSession.post") async def test_save_create_initialises_empty_cache(self, mock_post) -> None: From 8895a2842d791a58ce9c237f715c72b262b1fac8 Mon Sep 17 00:00:00 2001 From: olen Date: Fri, 15 May 2026 17:15:20 +0200 Subject: [PATCH 37/42] feat(oo-rewrite): Group navigation helpers + typed FieldDef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ideas borrowed from elliot-100/Spond-classes — the read-only typed-wrapper library that informed parts of this rewrite. Two additions that materially improve OO ergonomics: ## Navigation helpers on Group Five synchronous, no-HTTP helpers that encode common membership-graph queries every caller would otherwise write inline: ```python member = group.member_by_uid(uid) # shorthand for find_member(uid=...) role = group.role_by_uid(uid) subgroup = group.subgroup_by_uid(uid) coaches = group.members_by_role(role) # or by uid string team_a = group.members_by_subgroup(sg) # or by uid string ``` `members_by_*` accept either a typed Subgroup/Role instance OR its uid string — typed for callers walking `group.subgroups`, string-accepting for callers holding only a uid (e.g. from `member.role_uids`). ## Typed FieldDef Spond Groups expose `fieldDefs` — definitions for the custom-data slots members can fill (shirt size, emergency contact, etc.). Previously `Group.field_defs: list[Any]` (raw); now `list[FieldDef]` with `uid` + `name`. This makes the previously-awkward "join group's field labels with each member's `custom_fields` values" pattern trivial: ```python for fd in group.field_defs: print(f"{fd.name}: {member.custom_fields.get(fd.uid)}") ``` Only `uid` is strictly required on `FieldDef`; `extra="allow"` preserves anything Spond adds in future releases (type, ordering, required flags) without breaking validation. ## What was considered but not adopted Several other ideas in Spond-classes were evaluated and skipped for specific reasons documented in the PR thread: - `Member.email: EmailStr` — adds `email-validator` dep, rejects malformed emails; we prefer permissive pass-through - `Event.type: Literal[...]` — constrains at type level; we intentionally use `str` + a separate `EventType` enum for forward-compat against new Spond variants - `Event.is_cancelled` / `is_hidden` properties — trivial wrappers around the bool fields; bikeshed - `list_from_data` classmethods — cleaner but adds API surface for marginal benefit - `validate_list_of_data_dicts` shape check (feat/check-responses branch) — Pydantic already does this at model_validate time with clearer errors Co-Authored-By: Claude Opus 4.7 (1M context) --- DESIGN-oo-rewrite.md | 30 +++- spond/field_def.py | 47 ++++++ spond/group.py | 52 +++++- tests/test_group_navigation_helpers.py | 222 +++++++++++++++++++++++++ 4 files changed, 340 insertions(+), 11 deletions(-) create mode 100644 spond/field_def.py create mode 100644 tests/test_group_navigation_helpers.py diff --git a/DESIGN-oo-rewrite.md b/DESIGN-oo-rewrite.md index 0c01891..82f6640 100644 --- a/DESIGN-oo-rewrite.md +++ b/DESIGN-oo-rewrite.md @@ -99,15 +99,25 @@ EventType (StrEnum, canonical reference) for callers writing comparisons. Group(DictCompatModel) - ├─ uid, name, members: list[Member], subgroups: list[Subgroup], roles: list[Role], - │ plus the full set of fields surfaced by the live API audit - │ (created_time, member_permissions, guardian_permissions, chat_age_limit, + ├─ uid, name, members: list[Member], subgroups: list[Subgroup], + │ roles: list[Role], field_defs: list[FieldDef], plus the full set + │ of admin fields surfaced by the live API audit (created_time, + │ member_permissions, guardian_permissions, chat_age_limit, │ share_contact_info, address_format, ...) - └─ methods: find_member(*, email=None, name=None, uid=None) -> Member | None - (`from_api` wires `_client` through nested Members/Guardians) - -Subgroup, Role (DictCompatModel) - └─ uid, name (passive data, no methods) + └─ Navigation helpers (synchronous, no HTTP): + ├─ find_member(*, email=None, name=None, uid=None) -> Member | None + ├─ member_by_uid(uid) / role_by_uid(uid) / subgroup_by_uid(uid) + │ — return the typed object by uid, or None + └─ members_by_subgroup(subgroup_or_uid) / + members_by_role(role_or_uid) + — return list[Member] filtered by membership/role + (accept either the typed object or its uid string). + (`from_api` wires `_client` through nested Members/Guardians) + +Subgroup, Role, FieldDef (DictCompatModel) + └─ uid, name (passive data, no methods). + FieldDef.uid pairs with Member.custom_fields keys so callers can + render human-readable label/value pairs for custom data. Profile(DictCompatModel) └─ uid, first_name, last_name, plus the live-audited extras (passive) @@ -309,9 +319,10 @@ These items were "open questions" in earlier revisions of this document and have - `spond/exceptions.py` — `SpondError` and the typed-exception hierarchy - `spond/match.py` — `Match` (Event subclass), `MatchInfo` - `spond/person.py` — `Person`, `Member`, `Guardian` -- `spond/group.py` — `Group` +- `spond/group.py` — `Group` with `find_member()`, `member_by_uid()`, `role_by_uid()`, `subgroup_by_uid()`, `members_by_subgroup()`, `members_by_role()` - `spond/subgroup.py` — `Subgroup` - `spond/role.py` — `Role` +- `spond/field_def.py` — typed `FieldDef` (replaces the raw extras dict on `Group.field_defs`) - `spond/profile.py` — `Profile` - `spond/post.py` — `Post` with `save()`/`delete()`/`add_comment()` ActiveRecord surface - `spond/comment.py` — typed `Comment` model (used by `Post.comments` and `Event.comments`) @@ -341,6 +352,7 @@ The previous monolithic `tests/test_spond.py` has been split by domain. The curr - `tests/test_exceptions.py` — exception hierarchy + raise-site coverage - `tests/test_export.py` — deprecated `get_event_attendance_xlsx` wrapper - `tests/test_groups.py` — `get_group` + Group → Member → Guardian navigation + `get_person` +- `tests/test_group_navigation_helpers.py` — `member_by_uid`, `role_by_uid`, `subgroup_by_uid`, `members_by_subgroup`, `members_by_role`, typed `FieldDef` - `tests/test_identity.py` — natural-key equality / hashing across all models - `tests/test_messaging.py` — `Spond.send_message` + `Chat`/`Message` - `tests/test_post_save_delete.py` — ActiveRecord write surface on Post (`save()`, `delete()`, `add_comment()`) diff --git a/spond/field_def.py b/spond/field_def.py new file mode 100644 index 0000000..4bdc7c1 --- /dev/null +++ b/spond/field_def.py @@ -0,0 +1,47 @@ +"""Typed `FieldDef` model — definitions for the custom fields a Group exposes +on its members. + +Spond's `Group.fieldDefs` is a list of definitions describing the +custom-data slots each member can fill (e.g. `"shirt size"`, +`"emergency contact"`). The per-member values live on +`Member.custom_fields` as a `dict` keyed by the field-def uid; this +class gives those keys human-readable context: + +```python +for fd in group.field_defs: + value = member.custom_fields.get(fd.uid) + print(f"{fd.name}: {value}") +``` + +The model is intentionally minimal — Spond's API may carry additional +fields per definition (type, required-flag, ordering hints), but those +shapes vary by Spond release and aren't part of the SDK contract. +`extra="allow"` preserves them on the instance for callers who need +to reach in directly. +""" + +from __future__ import annotations + +from pydantic import ConfigDict, Field + +from ._compat import DictCompatModel + + +class FieldDef(DictCompatModel): + """A custom-field definition attached to a `Group`.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + uid: str = Field(alias="id") + name: str = "" + """Human-readable label for the field, as shown in the Spond UI.""" + + def __str__(self) -> str: + return f"FieldDef(uid={self.uid!r}, name={self.name!r})" + + def _natural_key(self) -> tuple | None: + if self.uid: + return ("FieldDef", self.uid) + if self.name: + return ("FieldDef", None, self.name) + return None diff --git a/spond/group.py b/spond/group.py index afbede2..b0c5140 100644 --- a/spond/group.py +++ b/spond/group.py @@ -14,6 +14,7 @@ from pydantic import ConfigDict, Field, PrivateAttr from ._compat import DictCompatModel +from .field_def import FieldDef from .person import Member from .role import Role from .subgroup import Subgroup @@ -105,8 +106,10 @@ class Group(DictCompatModel): # Less user-facing — admin/finance internals. Kept as raw containers # because their nested shapes vary by Spond Club configuration and # we don't want to over-promise a structure. - field_defs: list[Any] = Field(default_factory=list, alias="fieldDefs") - """Custom-field definitions configured on the group. Unmodelled.""" + field_defs: list[FieldDef] = Field(default_factory=list, alias="fieldDefs") + """Custom-field definitions configured on the group. Pair with + `Member.custom_fields` (keyed by `FieldDef.uid`) to render + human-readable label/value pairs in callers' UIs.""" default_fields: dict[str, Any] = Field(default_factory=dict, alias="defaultFields") """Default per-member field metadata. Unmodelled.""" payout_accounts: list[Any] = Field(default_factory=list, alias="payoutAccounts") @@ -202,3 +205,48 @@ def find_member( if name is not None and member.full_name == name: return member return None + + def member_by_uid(self, uid: str) -> Member | None: + """Find a member by `uid`. Returns `None` if not found. + + Shorthand for `find_member(uid=uid)` — matches the + `role_by_uid` / `subgroup_by_uid` sibling shape so callers can + write `group.member_by_uid(...)` / `group.role_by_uid(...)` / + `group.subgroup_by_uid(...)` uniformly without remembering which + ones live on `find_member` vs as standalone helpers. + """ + return self.find_member(uid=uid) + + def role_by_uid(self, uid: str) -> Role | None: + """Find a role by `uid`. Returns `None` if not found.""" + return next((r for r in self.roles if r.uid == uid), None) + + def subgroup_by_uid(self, uid: str) -> Subgroup | None: + """Find a subgroup by `uid`. Returns `None` if not found.""" + return next((sg for sg in self.subgroups if sg.uid == uid), None) + + def members_by_subgroup(self, subgroup: Subgroup | str) -> list[Member]: + """Return members belonging to the given subgroup. + + Accepts either a `Subgroup` instance or its `uid` string — + callers walking `group.subgroups` already have the object, + callers holding only a uid (e.g. from `member.subgroup_uids`) + avoid an extra lookup. + + Returns an empty list if no members reference the subgroup + (including the case where the subgroup doesn't exist at all + — there's no need to distinguish "subgroup empty" from + "subgroup not in this group" for the navigation use case). + """ + target_uid = subgroup if isinstance(subgroup, str) else subgroup.uid + return [m for m in self.members if target_uid in m.subgroup_uids] + + def members_by_role(self, role: Role | str) -> list[Member]: + """Return members holding the given role. + + Accepts either a `Role` instance or its `uid` string. Returns + an empty list if no members reference the role. See + `members_by_subgroup` for the same conventions. + """ + target_uid = role if isinstance(role, str) else role.uid + return [m for m in self.members if target_uid in m.role_uids] diff --git a/tests/test_group_navigation_helpers.py b/tests/test_group_navigation_helpers.py new file mode 100644 index 0000000..4fd362e --- /dev/null +++ b/tests/test_group_navigation_helpers.py @@ -0,0 +1,222 @@ +"""Tests for the new Group navigation helpers and typed `FieldDef`. + +Adapted from the patterns in elliot-100/Spond-classes +(`member_by_uid`, `role_by_uid`, `subgroup_by_uid`, +`members_by_subgroup`, `members_by_role`) — they encode common +"membership graph navigation" queries that every caller would +otherwise write inline. +""" + +from __future__ import annotations + +from spond.field_def import FieldDef +from spond.group import Group +from spond.role import Role +from spond.subgroup import Subgroup + +_GROUP_PAYLOAD = { + "id": "GID", + "name": "Test Group", + "members": [ + { + "id": "M1", + "firstName": "Alice", + "lastName": "A", + "roles": ["R1"], + "subGroups": ["S1", "S2"], + }, + { + "id": "M2", + "firstName": "Bob", + "lastName": "B", + "roles": ["R2"], + "subGroups": ["S1"], + }, + { + "id": "M3", + "firstName": "Carol", + "lastName": "C", + "roles": [], + "subGroups": ["S2"], + }, + ], + "roles": [ + {"id": "R1", "name": "Coach"}, + {"id": "R2", "name": "Treasurer"}, + ], + "subGroups": [ + {"id": "S1", "name": "Team A"}, + {"id": "S2", "name": "Team B"}, + ], + "fieldDefs": [ + {"id": "F1", "name": "Shirt size"}, + {"id": "F2", "name": "Emergency contact"}, + ], +} + + +class TestRoleByUid: + def test_returns_role_when_present(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + r = g.role_by_uid("R1") + assert isinstance(r, Role) + assert r.name == "Coach" + + def test_returns_none_when_missing(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + assert g.role_by_uid("NOSUCH") is None + + def test_returns_none_on_empty_group(self) -> None: + g = Group.model_validate({"id": "X", "name": "Empty"}) + assert g.role_by_uid("ANY") is None + + +class TestSubgroupByUid: + def test_returns_subgroup_when_present(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + sg = g.subgroup_by_uid("S2") + assert isinstance(sg, Subgroup) + assert sg.name == "Team B" + + def test_returns_none_when_missing(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + assert g.subgroup_by_uid("NOSUCH") is None + + +class TestMemberByUid: + def test_returns_member_when_present(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + m = g.member_by_uid("M2") + assert m is not None + assert m.full_name == "Bob B" + + def test_returns_none_when_missing(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + assert g.member_by_uid("NOSUCH") is None + + def test_matches_find_member_uid_shape(self) -> None: + """`member_by_uid(x)` is documented as a shorthand for + `find_member(uid=x)` — verify they return the same result.""" + g = Group.model_validate(_GROUP_PAYLOAD) + assert g.member_by_uid("M1") is g.find_member(uid="M1") + + +class TestMembersBySubgroup: + def test_filters_by_subgroup_object(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + s1 = g.subgroup_by_uid("S1") + members = g.members_by_subgroup(s1) + assert [m.uid for m in members] == ["M1", "M2"] + + def test_filters_by_subgroup_uid_string(self) -> None: + """Either a Subgroup instance OR its uid string works — for + callers who only have a uid (e.g. from `member.subgroup_uids`).""" + g = Group.model_validate(_GROUP_PAYLOAD) + members = g.members_by_subgroup("S2") + assert [m.uid for m in members] == ["M1", "M3"] + + def test_empty_when_no_members_match(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + assert g.members_by_subgroup("UNKNOWN") == [] + + def test_empty_when_no_subgroups_at_all(self) -> None: + g = Group.model_validate({"id": "X", "name": "Empty"}) + assert g.members_by_subgroup("ANY") == [] + + +class TestMembersByRole: + def test_filters_by_role_object(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + coach = g.role_by_uid("R1") + coaches = g.members_by_role(coach) + assert [m.uid for m in coaches] == ["M1"] + + def test_filters_by_role_uid_string(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + members = g.members_by_role("R2") + assert [m.uid for m in members] == ["M2"] + + def test_empty_when_no_members_match(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + assert g.members_by_role("UNKNOWN") == [] + + +class TestFieldDef: + def test_field_defs_materialise_as_typed(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + assert len(g.field_defs) == 2 + assert all(isinstance(fd, FieldDef) for fd in g.field_defs) + assert g.field_defs[0].uid == "F1" + assert g.field_defs[0].name == "Shirt size" + assert g.field_defs[1].name == "Emergency contact" + + def test_empty_when_no_field_defs(self) -> None: + g = Group.model_validate({"id": "X", "name": "No FDs"}) + assert g.field_defs == [] + + def test_field_def_str_contains_uid_and_name(self) -> None: + fd = FieldDef.model_validate({"id": "F1", "name": "Shirt size"}) + s = str(fd) + assert "F1" in s + assert "Shirt size" in s + + def test_field_def_natural_key_uid_based(self) -> None: + a = FieldDef.model_validate({"id": "F1", "name": "Different"}) + b = FieldDef.model_validate({"id": "F1", "name": "Names"}) + assert a == b # same uid → equal + assert hash(a) == hash(b) + + def test_field_def_minimal_only_uid_required(self) -> None: + """Resilience: only `id` required; `name` defaults to empty.""" + fd = FieldDef.model_validate({"id": "F1"}) + assert fd.uid == "F1" + assert fd.name == "" + + def test_field_defs_pair_with_member_custom_fields(self) -> None: + """The motivating use case: render label/value pairs by joining + `group.field_defs` (label) with `member.custom_fields` (value).""" + g = Group.model_validate( + { + **_GROUP_PAYLOAD, + "members": [ + { + "id": "M1", + "firstName": "Alice", + "lastName": "A", + "fields": {"F1": "Medium", "F2": "555-1234"}, + } + ], + } + ) + member = g.member_by_uid("M1") + rendered = {fd.name: member.custom_fields.get(fd.uid) for fd in g.field_defs} + assert rendered == {"Shirt size": "Medium", "Emergency contact": "555-1234"} + + +class TestNavigationHelpersCompositeFlow: + """End-to-end: walk subgroups → list members → check their roles. + The motivating use case for the helpers landing together.""" + + def test_walk_subgroups_with_role_lookup(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + results: dict[str, list[str]] = {} + for sg in g.subgroups: + for m in g.members_by_subgroup(sg): + # For each member of this subgroup, what role names do they hold? + role_names = [ + r.name + for r_uid in m.role_uids + if (r := g.role_by_uid(r_uid)) is not None + ] + results.setdefault(sg.name, []).append(f"{m.full_name}: {role_names}") + # Team A (S1) has Alice (Coach) and Bob (Treasurer) + assert "Team A" in results + assert any( + "Alice A" in entry and "Coach" in entry for entry in results["Team A"] + ) + assert any( + "Bob B" in entry and "Treasurer" in entry for entry in results["Team A"] + ) + # Team B (S2) has Alice (Coach) and Carol (no roles) + assert "Team B" in results + assert any("Carol C" in entry and "[]" in entry for entry in results["Team B"]) From fe09fd4d93814f9f815628636499e8ea429af106 Mon Sep 17 00:00:00 2001 From: olen Date: Fri, 15 May 2026 18:26:55 +0200 Subject: [PATCH 38/42] fix(oo-rewrite): preserve local comments across save(); use PUT for Post update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six review items resolved, including two real bugs found during live re-verification. ## Real bugs ### 1. Wrong HTTP verb for Post update Post.save() update path used POST /posts/{uid}. Spond returns 405 Method Not Allowed; the right verb is PUT. Verified live in the test group. Update path now uses `clientsession.put`; create still uses POST `/posts/`. Inconsistent with Event (which uses POST for both verbs) but matches Spond's API contract. The previous tests passed because they mocked `aiohttp.ClientSession.post` for both code paths — the bug was invisible to mocks. Tests now mock `put` for the update path. The TestPostRoundtrip lifecycle test splits its mocks across post/put explicitly. ### 2. save() wiped locally-appended comments (and reactions/responses) `save()`'s in-place state copy iterated every model field and overwrote self with refreshed. On UPDATE, Spond's response often omits `comments` (and `reactions`) — those have their own dedicated endpoints. A caller doing: await post.add_comment("hi") # post.comments = [Comment("hi")] post.title = "Renamed" await post.save() # would wipe post.comments would silently lose the just-added comment. Fix: skip `comments`/`reactions` (Post) and `comments`/`responses` (Event) on the UPDATE path of the in-place state copy. The CREATE path still applies them — Spond's create response IS the canonical fresh state for a brand-new entity. Regression tests in test_post_save_delete.py::TestSaveDoesNotWipeLocallyAddedComments cover both the "response omits the key entirely" and "response returns an empty list" cases, plus a positive control that create still applies the response's comments. ## Other items ### 3. tests/test_groups.py — misleading AsyncMock comment `s.get_groups = AsyncMock() # leaves self.groups as None` was incorrect — AsyncMock returns a MagicMock instance when awaited, not None. Replaced with `AsyncMock(return_value=None)` and an accurate inline comment. ### 4. spond/post.py — document deliberate no-_client on Comment Added a comment at the `Comment.model_validate(data)` site in `add_comment` explaining that comments carry no ActiveRecord ops of their own (Spond exposes no edit/delete endpoints for individual comments via the consumer API), so the new instance doesn't need client wiring. Flag for future expansion. ### 5. spond/comment.py — line length Split the long `Comment.__str__` return into a multi-line f-string. ### 6. tests/test_context_manager.py — explicit spy on double-close Strengthened `test_double_close_does_not_raise` to install an `AsyncMock` spy on `clientsession.close` after the manual close inside the `with` block, then assert `spy.await_count == 0` to verify __aexit__'s closed-check actually short-circuits rather than just tolerating a second call. Live API re-verified end-to-end: create + add_comment + mutate + save (PUT) + delete; comments survive the save. Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/comment.py | 5 +- spond/event.py | 12 +++ spond/post.py | 33 +++++++- tests/test_context_manager.py | 22 ++++- tests/test_groups.py | 6 +- tests/test_post_save_delete.py | 146 ++++++++++++++++++++++++++++++--- 6 files changed, 204 insertions(+), 20 deletions(-) diff --git a/spond/comment.py b/spond/comment.py index f15d26f..13270ce 100644 --- a/spond/comment.py +++ b/spond/comment.py @@ -53,7 +53,10 @@ class Comment(DictCompatModel): def __str__(self) -> str: ts = self.timestamp.isoformat() if self.timestamp else "?" snippet = self.text[:40] + ("…" if len(self.text) > 40 else "") - return f"Comment(uid={self.uid!r}, from={self.from_profile_uid!r}, ts={ts}, text={snippet!r})" + return ( + f"Comment(uid={self.uid!r}, from={self.from_profile_uid!r}, " + f"ts={ts}, text={snippet!r})" + ) def _natural_key(self) -> tuple | None: """uid when set; otherwise (from_profile_uid, timestamp, text) diff --git a/spond/event.py b/spond/event.py index 445e409..7e6ff8e 100644 --- a/spond/event.py +++ b/spond/event.py @@ -678,7 +678,19 @@ async def save(self, client: Spond | None = None) -> Event: # validation per-field here would be redundant work AND would # incorrectly re-trigger any validators with side effects (e.g. # mutation timestamps). + # + # `comments` and `responses` are skipped on the UPDATE path: + # both have dedicated endpoints (post-style comments, and + # `change_response()` for attendance) and Spond's POST + # /sponds/{uid} response doesn't always include them. + # Overwriting from the response would silently wipe local + # state that's been updated through those side-channel + # endpoints. On the CREATE path the response IS the canonical + # fresh state, so we let the overwrite proceed. + skip_on_update = set() if is_create else {"comments", "responses"} for field_name in type(self).model_fields: + if field_name in skip_on_update: + continue object.__setattr__(self, field_name, getattr(refreshed, field_name)) # Capture any extras Spond added that we don't model. extras = refreshed._pydantic_extras() diff --git a/spond/post.py b/spond/post.py index 68c2c77..503d613 100644 --- a/spond/post.py +++ b/spond/post.py @@ -179,14 +179,22 @@ async def save(self, client: Spond | None = None) -> Post: exclude_none=True, ) payload.pop("id", None) # never echo uid in the body + + # Spond uses different verbs for create vs update on posts: + # POST `/posts/` to create, PUT `/posts/{uid}` to update. + # Verified live against the test group (POST `/posts/{uid}` + # returns 405 Method Not Allowed; PUT returns 200 with the + # updated post). This is inconsistent with Spond's event API + # (which uses POST for both), but matches what the SDK has to + # do to round-trip changes. if self.uid: url = f"{self._client.api_url}posts/{self.uid}" + http = self._client.clientsession.put else: url = f"{self._client.api_url}posts/" + http = self._client.clientsession.post - async with self._client.clientsession.post( - url, json=payload, headers=self._client.auth_headers - ) as r: + async with http(url, json=payload, headers=self._client.auth_headers) as r: if not r.ok: raise SpondAPIError(r.status, await r.text(), url) new_data = await r.json() @@ -204,7 +212,20 @@ async def save(self, client: Spond | None = None) -> Post: # (e.g. mutation timestamps). The wholesale # `__pydantic_fields_set__` replacement on the line below keeps # `exclude_unset=True` dumps consistent with what Spond emitted. + # + # `comments` and `reactions` are deliberately skipped on the + # UPDATE path: those fields have dedicated endpoints + # (`add_comment()`, reactions API) and Spond's POST /posts/{uid} + # update response doesn't always include them. Overwriting from + # the response would silently wipe a comment that + # `await add_comment()` had just appended locally. On the CREATE + # path Spond's response is the canonical fresh state (an empty + # list / dict for a brand-new post), so we let the overwrite + # proceed and treat it as the authoritative initial value. + skip_on_update = set() if is_create else {"comments", "reactions"} for field_name in type(self).model_fields: + if field_name in skip_on_update: + continue object.__setattr__(self, field_name, getattr(refreshed, field_name)) extras = refreshed._pydantic_extras() if extras and self.__pydantic_extra__ is not None: @@ -291,6 +312,12 @@ async def add_comment(self, text: str) -> Comment: if not r.ok: raise SpondAPIError(r.status, await r.text(), url) data = await r.json() + # Comments carry no ActiveRecord operations of their own + # (Spond exposes no edit/delete endpoints for individual + # comments via the consumer API), so the new instance doesn't + # need `_client` wiring. If Comment grows behaviour later + # (e.g. `comment.react()`), revisit this construction site to + # thread the client through. comment = Comment.model_validate(data) # Keep the parent in sync — callers reading `post.comments` # immediately after this call should see the new comment. diff --git a/tests/test_context_manager.py b/tests/test_context_manager.py index d0a7ac2..37e95ca 100644 --- a/tests/test_context_manager.py +++ b/tests/test_context_manager.py @@ -38,14 +38,28 @@ async def test_session_closed_even_on_exception(self) -> None: assert s.clientsession.closed @pytest.mark.asyncio - async def test_double_close_does_not_raise(self) -> None: + async def test_double_close_does_not_raise_and_skips_second_close(self) -> None: """If the caller manually closed the session inside the block, - the context-manager exit shouldn't blow up on top of it — - defensive cleanup must not mask the original control flow.""" + `__aexit__` must (a) not blow up on top of it, and (b) actually + skip the redundant close — exercised via a spy on `close()` + that counts how many times it's invoked.""" + from unittest.mock import AsyncMock + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) async with s: await s.clientsession.close() - # No exception escaped; both close paths were tolerated. + # Install a spy on close() AFTER the manual close, so the + # counter starts at zero and we can verify __aexit__'s + # closed-check skips the call. + spy = AsyncMock() + s.clientsession.close = spy + # No exception escaped, AND __aexit__ saw closed=True and + # never called the real close() a second time. + assert spy.await_count == 0, ( + "__aexit__ should have skipped close() because clientsession " + "was already closed; instead it called close() " + f"{spy.await_count} times" + ) @pytest.mark.asyncio async def test_spondclub_also_supports_context_manager(self) -> None: diff --git a/tests/test_groups.py b/tests/test_groups.py index 3986b08..8fdf8d6 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -74,7 +74,11 @@ async def test_get_group__no_groups_available_raises_keyerror( s = Spond(MOCK_USERNAME, MOCK_PASSWORD) s.token = mock_token s.groups = None - s.get_groups = AsyncMock() # leaves self.groups as None + # Production code consults `self.groups` (not the return value + # of `get_groups()`), so this mock just needs to be a no-op that + # doesn't trigger a real HTTP call. `s.groups` is already set + # to None on line 76; we never assert on the mock's return. + s.get_groups = AsyncMock(return_value=None) with pytest.raises(KeyError): await s.get_group("ID1") diff --git a/tests/test_post_save_delete.py b/tests/test_post_save_delete.py index 2dd3c78..5fb7348 100644 --- a/tests/test_post_save_delete.py +++ b/tests/test_post_save_delete.py @@ -189,23 +189,29 @@ async def test_save_without_client_raises(self) -> None: class TestPostSaveUpdate: @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post") - async def test_save_existing_posts_to_uid_url(self, mock_post) -> None: + @patch("aiohttp.ClientSession.put") + async def test_save_existing_puts_to_uid_url(self, mock_put) -> None: + """Post update uses **PUT** `/posts/{uid}` — verified live; the + create path uses POST `/posts/`. Different from Event (which + uses POST for both verbs).""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) s.token = "MOCK" post = Post.from_api(_API_POST, s) - mock_post.return_value.__aenter__.return_value.ok = True - mock_post.return_value.__aenter__.return_value.json = AsyncMock( + mock_put.return_value.__aenter__.return_value.ok = True + mock_put.return_value.__aenter__.return_value.json = AsyncMock( return_value={**_API_POST, "title": "Renamed"} ) post.title = "Renamed" await post.save() - called_url = mock_post.call_args[0][0] - assert called_url.endswith("/posts/NEWUID") + called_url = mock_put.call_args[0][0] + assert called_url.endswith("/posts/NEWUID"), ( + f"update should PUT to /posts/NEWUID, got {called_url}" + ) assert post.title == "Renamed" + mock_put.assert_called_once() class TestPostDelete: @@ -371,26 +377,139 @@ async def test_add_comment_raises_on_http_error(self, mock_post) -> None: await post.add_comment("hi") +class TestSaveDoesNotWipeLocallyAddedComments: + """Regression guard: a Post that has a comment added via + `add_comment()` and is then `save()`-d must NOT lose that comment, + even if Spond's update response omits the `comments` array.""" + + _API_COMMENT = { + "id": "CMT_X", + "text": "locally added", + "fromProfileId": "P", + "timestamp": "2026-05-15T12:00:00Z", + } + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + @patch("aiohttp.ClientSession.post") + async def test_save_preserves_comments_when_response_omits_them( + self, mock_post, mock_put + ) -> None: + """The realistic case: Spond's PUT /posts/{uid} update response + doesn't include the comments array. `save()` must preserve + whatever's in `self.comments` rather than overwriting with + the (empty / missing) `refreshed.comments`. add_comment uses + POST `/posts/{uid}/comments`; save() update uses PUT — both + verbs are mocked here so the test exercises both paths.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + assert post.comments == [] + + # 1) add_comment (POST /posts/{uid}/comments) populates self.comments. + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self._API_COMMENT + ) + await post.add_comment("locally added") + assert len(post.comments) == 1 + assert post.comments[0].text == "locally added" + + # 2) Mock save's update (PUT) response WITHOUT a `comments` key. + update_response = {k: v for k, v in _API_POST.items() if k != "comments"} + update_response["title"] = "Renamed" + mock_put.return_value.__aenter__.return_value.ok = True + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value=update_response + ) + post.title = "Renamed" + await post.save() + + # 3) The locally-added comment must survive. + assert len(post.comments) == 1, ( + "save() wiped the locally-added comment — `comments` should be " + "skipped during the in-place state copy on update" + ) + assert post.comments[0].text == "locally added" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + @patch("aiohttp.ClientSession.post") + async def test_save_preserves_comments_when_response_returns_empty_list( + self, mock_post, mock_put + ) -> None: + """Even if Spond's update response explicitly returns + `comments: []` (stale because they were fetched separately), + the local state must not be wiped — the server isn't the + authoritative view of comments through the update endpoint.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**self._API_COMMENT, "text": "must survive"} + ) + await post.add_comment("must survive") + assert len(post.comments) == 1 + + # Spond's update response includes `comments: []` (also realistic). + mock_put.return_value.__aenter__.return_value.ok = True + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_API_POST, "comments": [], "title": "Renamed"} + ) + post.title = "Renamed" + await post.save() + + assert len(post.comments) == 1 + assert post.comments[0].text == "must survive" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_DOES_apply_response_comments(self, mock_post) -> None: + """The skip-on-update behaviour must NOT apply on the create + path: a brand-new post has no local comments to preserve, and + Spond's create response IS the canonical fresh state.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.posts = [] + post = _fresh_post() + + # Hypothetical create response that somehow includes a comment + # (unusual but verifies the create path doesn't short-circuit). + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_API_POST, "comments": [self._API_COMMENT]} + ) + await post.save(client=s) + + assert len(post.comments) == 1, ( + "create path should accept the response's comments as canonical" + ) + + class TestPostRoundtrip: """End-to-end ActiveRecord lifecycle: construct → save (create) → mutate → save (update) → add_comment → delete.""" @pytest.mark.asyncio @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.put") @patch("aiohttp.ClientSession.post") - async def test_full_lifecycle(self, mock_post, mock_delete) -> None: + async def test_full_lifecycle(self, mock_post, mock_put, mock_delete) -> None: + """Mocks split by verb: POST is used for create + add_comment; + PUT is used for the update path; DELETE for delete.""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) s.token = "MOCK" s.posts = [] post = _fresh_post() mock_post.return_value.__aenter__.return_value.ok = True - # Sequence: create response, update response, comment response + # POST is used for create then add_comment. Order matters. mock_post.return_value.__aenter__.return_value.json = AsyncMock( side_effect=[ - _API_POST, - {**_API_POST, "title": "Renamed"}, - { + _API_POST, # 1) create response + { # 2) add_comment response "id": "CMT", "text": "hi", "fromProfileId": "P", @@ -398,6 +517,11 @@ async def test_full_lifecycle(self, mock_post, mock_delete) -> None: }, ] ) + # PUT is only used for the update path. + mock_put.return_value.__aenter__.return_value.ok = True + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_API_POST, "title": "Renamed"} + ) await post.save(client=s) assert post.uid == "NEWUID" From 2a4f02e2b0ab906af939a2b01f0859fbf95fee76 Mon Sep 17 00:00:00 2001 From: olen Date: Fri, 15 May 2026 18:39:39 +0200 Subject: [PATCH 39/42] docs: update examples to v2.x typed surface; README v2.0 upgrade notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## examples/ Every example now uses the typed-object surface — attribute access instead of dict subscripts, `async with Spond(...)` for automatic session cleanup, and per-type model methods (e.g. `event.attendance_xlsx()` instead of the deprecated `Spond.get_event_attendance_xlsx(uid)` wrapper). - `ical.py` — typed `event.heading` / `event.meetup_time` etc.; added `Event.meetup_time` field so the canonical "use meetup time if present, else kickoff" pattern reads cleanly without dict fallback. Migrated to `async with`. - `groups.py` — uses `group.model_dump_json(by_alias=True, indent=4)` for the per-group JSON dump. The pre-OO version did `json.dumps(group)` which now fails because a typed Group isn't JSON-serialisable by default; the Pydantic serialiser does the right thing. - `attendance.py` — typed `event.responses.accepted_uids` etc. instead of `event["responses"]["acceptedIds"]`; uses `person.full_name` from the typed Member/Guardian return of `get_person()`. - `transactions.py` — typed `Transaction` instances dumped via `model_dump(by_alias=True, mode="json")` for the CSV rows. - `manual_test_functions.py` — every `_*_summary()` helper now reads typed attributes; switched to `async with`; uses `events[0].attendance_xlsx()` (the OO method) for the export demo so users see the recommended shape. ## README.md New section "⚠️ Upgrading to v2.0 — read this first" near the top covers: 1. The semantic changes callers need to audit before upgrading (equality, return types, exception classes, deprecated wrappers). 2. How to **pin to `< 2.0.0`** to defer the upgrade (pip, pyproject.toml, requirements.txt syntaxes). 3. How to find every dict-style access site in their code by running with `-W error::DeprecationWarning`. ## spond/event.py Added `Event.meetup_time` (alias `meetupTimestamp`) — Spond's "oppmøtetid" field used by match events for arrival time. Previously this lived in `__pydantic_extra__` and was only reachable via the dict-shim. The ical example demonstrates the canonical `event.meetup_time or event.start_time` fallback shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 55 +++++++++ examples/attendance.py | 131 ++++++++------------ examples/groups.py | 24 ++-- examples/ical.py | 43 ++++--- examples/manual_test_functions.py | 194 +++++++++++++++--------------- examples/transactions.py | 34 ++++-- spond/event.py | 4 + 7 files changed, 270 insertions(+), 215 deletions(-) diff --git a/README.md b/README.md index df9c0c7..70d5453 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,61 @@ Simple, unofficial library with some example scripts to access data from the [Sp `pip install spond` +### ⚠️ Upgrading to v2.0 — read this first + +v2.0 is the OO-rewrite release. The `get_*` methods now return typed +Pydantic models (`Event`, `Group`, `Member`, `Post`, `Chat`, …) instead +of raw `dict`s. **Existing code that uses dict-style access keeps +working** through a `DeprecationWarning` shim — but a few things did +change. Before upgrading from 1.x: + +- **Equality semantics changed.** `Event(uid="X") == Event(uid="X")` + now compares natural keys (uid-based when present) rather than every + field. Two instances with the same uid but different field state are + now considered equal. Callers depending on the old "are these + field-identical?" behaviour can use the new `obj.model_equals(other)` + escape hatch. +- **Return types of every `Spond.get_*` method changed** from + `JSONDict` / `list[JSONDict]` to typed objects. Static type checkers + flag this; the runtime dict shim covers most code at runtime. +- **HTTP error class changed** from bare `ValueError` to `SpondAPIError` + — which still inherits from `ValueError`, so `except ValueError:` is + unaffected. Same for the `*NotFoundError` family (still `KeyError`). +- **Some deprecated wrappers will be removed in v3.x.** + `Spond.update_event()`, `Spond.change_response()`, and + `Spond.get_event_attendance_xlsx()` emit `DeprecationWarning` in v2.x; + use `Event.update()`, `Event.change_response()`, and + `Event.attendance_xlsx()` instead. + +**Pin to `< 2.0.0` if you aren't ready to upgrade yet:** + +```shell +pip install "spond<2.0.0" +``` + +Or in `pyproject.toml`: +```toml +[tool.poetry.dependencies] +spond = "<2.0.0" +``` + +Or `requirements.txt`: +``` +spond<2.0.0 +``` + +**Audit your code before upgrading** by running with deprecation +warnings promoted to errors — every dict-style access site lights up +so you can migrate it: + +```shell +python -W error::DeprecationWarning your_script.py +``` + +The full migration story (semantics, write surface, exception +hierarchy, async context manager, etc.) is in +[`DESIGN-oo-rewrite.md`](DESIGN-oo-rewrite.md). + ## Usage You need a username and password from Spond diff --git a/examples/attendance.py b/examples/attendance.py index 08340cd..3054282 100644 --- a/examples/attendance.py +++ b/examples/attendance.py @@ -1,3 +1,10 @@ +"""Per-event attendance CSVs for organisers. + +Uses the v2.x typed-object surface throughout — attribute access, +typed `event.responses`, the `Event.response_for(uid)` convenience +property, and the `_resolve_uids_to_persons()`-based member helpers. +""" + import argparse import asyncio import csv @@ -35,91 +42,55 @@ async def main() -> None: - session = spond.Spond(username=username, password=password) - events = await session.get_events(min_start=args.f, max_start=args.t) - EXPORT_DIRPATH.mkdir(exist_ok=True) - - for e in events: - base_filename = _sanitise_filename(f"{e['startTimestamp']}-{e['heading']}") - filepath = EXPORT_DIRPATH / f"{base_filename}.csv" - with filepath.open("w", newline="") as csvfile: - spamwriter = csv.writer( - csvfile, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL - ) - - spamwriter.writerow( - ["Start", "End", "Description", "Name", "Answer", "Organizer"] - ) - for o in e["owners"]: - name = await _derive_member_name(session, o["id"]) - spamwriter.writerow( - [ - e["startTimestamp"], - e["endTimestamp"], - e["heading"], - name, - o["response"], - "X", - ] + async with spond.Spond(username=username, password=password) as session: + events = await session.get_events(min_start=args.f, max_start=args.t) + EXPORT_DIRPATH.mkdir(exist_ok=True) + + for e in events: + base_filename = _sanitise_filename(f"{e.start_time}-{e.heading}") + filepath = EXPORT_DIRPATH / f"{base_filename}.csv" + with filepath.open("w", newline="") as csvfile: + writer = csv.writer( + csvfile, + delimiter=",", + quotechar='"', + quoting=csv.QUOTE_MINIMAL, ) - if args.a is True: - for r in e["responses"]["acceptedIds"]: - name = await _derive_member_name(session, r) - spamwriter.writerow( - [ - e["startTimestamp"], - e["endTimestamp"], - e["heading"], - name, - "accepted", - ] - ) - for r in e["responses"]["declinedIds"]: - name = await _derive_member_name(session, r) - spamwriter.writerow( - [ - e["startTimestamp"], - e["endTimestamp"], - e["heading"], - name, - "declined", - ] - ) - for r in e["responses"]["unansweredIds"]: - name = await _derive_member_name(session, r) - spamwriter.writerow( - [ - e["startTimestamp"], - e["endTimestamp"], - e["heading"], - name, - "unanswered", - ] - ) - for r in e["responses"]["unconfirmedIds"]: - name = await _derive_member_name(session, r) - spamwriter.writerow( - [ - e["startTimestamp"], - e["endTimestamp"], - e["heading"], - name, - "unconfirmed", - ] - ) - for r in e["responses"]["waitinglistIds"]: - name = await _derive_member_name(session, r) - spamwriter.writerow( + writer.writerow( + ["Start", "End", "Description", "Name", "Answer", "Organizer"] + ) + + # Organisers first (event.owners is list[dict] — + # individual owner shape isn't a typed model in v2.x). + for o in e.owners: + name = await _derive_member_name(session, o.get("id", "")) + writer.writerow( [ - e["startTimestamp"], - e["endTimestamp"], - e["heading"], + e.start_time, + e.end_time, + e.heading, name, - "waitinglist", + o.get("response", ""), + "X", ] ) - await session.clientsession.close() + if args.a: + # Each response bucket gets its own pass — using the + # typed `event.responses` instead of dict subscripts. + buckets = ( + ("accepted", e.responses.accepted_uids), + ("declined", e.responses.declined_uids), + ("unanswered", e.responses.unanswered_uids), + ("unconfirmed", e.responses.unconfirmed_uids), + ("waitinglist", e.responses.waiting_list_uids), + ) + for status, uids in buckets: + for uid in uids: + name = await _derive_member_name(session, uid) + writer.writerow( + [e.start_time, e.end_time, e.heading, name, status] + ) async def _derive_member_name(spond_session, member_id: str) -> str: @@ -128,7 +99,7 @@ async def _derive_member_name(spond_session, member_id: str) -> str: person = await spond_session.get_person(member_id) except KeyError: return member_id - return f"{person['firstName']} {person['lastName']}" + return person.full_name def _sanitise_filename(input_str: str) -> str: diff --git a/examples/groups.py b/examples/groups.py index 909273c..3611ed4 100644 --- a/examples/groups.py +++ b/examples/groups.py @@ -1,5 +1,11 @@ +"""Dump each group's full JSON representation to a per-group file. + +Uses the v2.x typed-object surface. `group.model_dump_json(by_alias=True)` +serialises a typed `Group` instance back to JSON in Spond's wire shape — +the equivalent of the raw dict the pre-OO API returned. +""" + import asyncio -import json from pathlib import Path from config import password, username @@ -10,23 +16,19 @@ async def main() -> None: - s = spond.Spond(username=username, password=password) - groups = await s.get_groups() + async with spond.Spond(username=username, password=password) as s: + groups = await s.get_groups() or [] + EXPORT_DIRPATH.mkdir(exist_ok=True) + keepcharacters = (" ", ".", "_") for group in groups: - name = group["name"] - data = json.dumps(group, indent=4, sort_keys=True) - keepcharacters = (" ", ".", "_") base_filename = "".join( - c for c in name if c.isalnum() or c in keepcharacters + c for c in group.name if c.isalnum() or c in keepcharacters ).rstrip() json_filepath = EXPORT_DIRPATH / f"{base_filename}.json" print(json_filepath) - with json_filepath.open("w") as out_file: - out_file.write(data) - - await s.clientsession.close() + json_filepath.write_text(group.model_dump_json(by_alias=True, indent=4)) loop = asyncio.new_event_loop() diff --git a/examples/ical.py b/examples/ical.py index 7c85f48..23ab2e4 100644 --- a/examples/ical.py +++ b/examples/ical.py @@ -1,4 +1,9 @@ #!/usr/bin/env python3 +"""Generate an .ics calendar file from your Spond events. + +Demonstrates the v2.x typed-object surface — attribute access throughout, +and `async with Spond(...)` for automatic session cleanup. +""" import asyncio from pathlib import Path @@ -12,37 +17,39 @@ async def main() -> None: - s = spond.Spond(username=username, password=password) - c = Calendar() - c.method = "PUBLISH" - events = await s.get_events() + async with spond.Spond(username=username, password=password) as s: + events = await s.get_events() + EXPORT_DIRPATH.mkdir(exist_ok=True) ics_filepath = EXPORT_DIRPATH / "spond.ics" + c = Calendar() + c.method = "PUBLISH" + for event in events: e = Event() - e.uid = event["id"] - e.name = event["heading"] - # Match events expose two start times: `startTimestamp` is the - # kickoff, while `meetupTimestamp` is when participants are expected + e.uid = event.uid + e.name = event.heading + # Match events expose two start times: `start_time` is the + # kickoff, while `meetup_time` is when participants are expected # to arrive (Norwegian: "oppmøtetid"). Training events only have - # `startTimestamp`. We prefer the meet-up time so calendar + # `start_time`. We prefer the meet-up time so calendar # subscribers see when to show up, and fall back to kickoff. - e.begin = event.get("meetupTimestamp", event["startTimestamp"]) - e.end = event["endTimestamp"] - e.sequence = event["updated"] - e.description = event.get("description") - if "cancelled" in event and event["cancelled"]: + e.begin = event.meetup_time or event.start_time + e.end = event.end_time + e.sequence = event.updated + e.description = event.description + if event.cancelled: e.status = "Cancelled" - if "location" in event: - e.location = f"{event['location'].get('feature')}, {event['location'].get('address')}" + if event.location: + e.location = ( + f"{event.location.get('feature')}, {event.location.get('address')}" + ) c.events.add(e) with ics_filepath.open("w") as out_file: out_file.writelines(c) - await s.clientsession.close() - loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) diff --git a/examples/manual_test_functions.py b/examples/manual_test_functions.py index 962c7de..3d0885c 100644 --- a/examples/manual_test_functions.py +++ b/examples/manual_test_functions.py @@ -1,18 +1,25 @@ """Use Spond 'get' functions to summarise available data. -Intended as a simple end-to-end test for assurance when making changes, where there are -gaps in test suite coverage. +Intended as a simple end-to-end test for assurance when making changes, +where there are gaps in test suite coverage. -Doesn't yet use `get_person(user)` or any `send_`, `update_` methods. +Uses the v2.x typed-object surface throughout. Doesn't yet exercise +`get_person(user)` or any `send_` / save / delete methods — those +have dedicated test coverage in the suite. """ import asyncio import tempfile from config import club_id, password, username -from spond import club, spond, JSONDict -DUMMY_ID = "DUMMY_ID" +from spond import club, spond +from spond.chat import Chat +from spond.club import Transaction +from spond.event import Event +from spond.group import Group +from spond.post import Post +from spond.profile import Profile MAX_EVENTS = 10 MAX_CHATS = 10 @@ -21,114 +28,108 @@ async def main() -> None: - - # SPOND - s = spond.Spond(username=username, password=password) - - # Profile - print("\nGetting profile...") - profile = await s.get_profile() - print(_profile_summary(profile)) - - # Groups - print("\nGetting all groups...") - groups = await s.get_groups() - print(f"{len(groups)} groups:") - for i, group in enumerate(groups): - print(f"[{i}] {_group_summary(group)}") - - # Events - print(f"\nGetting up to {MAX_EVENTS} events...") - events = await s.get_events(max_events=MAX_EVENTS) - print(f"{len(events)} events:") - for i, event in enumerate(events): - print(f"[{i}] {_event_summary(event)}") - - # Chats (messages) - print(f"\nGetting up to {MAX_CHATS} chats...") - chats = await s.get_messages(max_chats=MAX_CHATS) - print(f"{len(chats)} chats:") - for i, chat in enumerate(chats): - print(f"[{i}] {_chat_summary(chat)}") - - # Posts - print(f"\nGetting up to {MAX_POSTS} posts...") - posts = await s.get_posts(max_posts=MAX_POSTS) - print(f"{len(posts)} posts:") - for i, post in enumerate(posts): - print(f"[{i}] {_post_summary(post)}") - - # Attendance export - print("\nGetting attendance report for the first event...") - e = events[0] - data = await s.get_event_attendance_xlsx(e["id"]) - with tempfile.NamedTemporaryFile( - mode="wb", - suffix=".xlsx", - delete=False, - ) as temp_file: - temp_file.write(data) - print(f"Check out {temp_file.name}") - - await s.clientsession.close() - - # SPOND CLUB - sc = club.SpondClub(username=username, password=password) - print(f"\nGetting up to {MAX_TRANSACTIONS} transactions...") - - # Transactions - transactions = await sc.get_transactions( - club_id=club_id, - max_items=MAX_TRANSACTIONS, - ) - print(f"{len(transactions)} transactions:") - for i, t in enumerate(transactions): - print(f"[{i}] {_transaction_summary(t)}") - - await sc.clientsession.close() - - -def _profile_summary(profile: JSONDict) -> str: + # ---------------------------------------------------------------- + # Consumer Spond API + # ---------------------------------------------------------------- + async with spond.Spond(username=username, password=password) as s: + # Profile + print("\nGetting profile...") + profile = await s.get_profile() + print(_profile_summary(profile)) + + # Groups + print("\nGetting all groups...") + groups = await s.get_groups() or [] + print(f"{len(groups)} groups:") + for i, group in enumerate(groups): + print(f"[{i}] {_group_summary(group)}") + + # Events + print(f"\nGetting up to {MAX_EVENTS} events...") + events = await s.get_events(max_events=MAX_EVENTS) or [] + print(f"{len(events)} events:") + for i, event in enumerate(events): + print(f"[{i}] {_event_summary(event)}") + + # Chats (messages) + print(f"\nGetting up to {MAX_CHATS} chats...") + chats = await s.get_messages(max_chats=MAX_CHATS) or [] + print(f"{len(chats)} chats:") + for i, chat in enumerate(chats): + print(f"[{i}] {_chat_summary(chat)}") + + # Posts + print(f"\nGetting up to {MAX_POSTS} posts...") + posts = await s.get_posts(max_posts=MAX_POSTS) or [] + print(f"{len(posts)} posts:") + for i, post in enumerate(posts): + print(f"[{i}] {_post_summary(post)}") + + # Attendance export — exercise the v2.x ActiveRecord method + # `event.attendance_xlsx()` (the deprecated `Spond.get_event_ + # attendance_xlsx()` wrapper still works but emits a + # DeprecationWarning). + if events: + print("\nGetting attendance report for the first event...") + data = await events[0].attendance_xlsx() + with tempfile.NamedTemporaryFile( + mode="wb", + suffix=".xlsx", + delete=False, + ) as temp_file: + temp_file.write(data) + print(f"Check out {temp_file.name}") + + # ---------------------------------------------------------------- + # Spond Club finance API + # ---------------------------------------------------------------- + async with club.SpondClub(username=username, password=password) as sc: + print(f"\nGetting up to {MAX_TRANSACTIONS} transactions...") + transactions = await sc.get_transactions( + club_id=club_id, + max_items=MAX_TRANSACTIONS, + ) + print(f"{len(transactions)} transactions:") + for i, t in enumerate(transactions): + print(f"[{i}] {_transaction_summary(t)}") + + +def _profile_summary(profile: Profile) -> str: return ( - f"id='{profile['id']}', " - f"firstName='{profile['firstName']}, " - f"lastName='{profile['lastName']}'" + f"uid={profile.uid!r}, " + f"first_name={profile.first_name!r}, " + f"last_name={profile.last_name!r}" ) -def _group_summary(group: JSONDict) -> str: - return f"id='{group['id']}', name='{group['name']}'" +def _group_summary(group: Group) -> str: + return f"uid={group.uid!r}, name={group.name!r}, members={len(group.members)}" -def _event_summary(event: JSONDict) -> str: +def _event_summary(event: Event) -> str: return ( - f"id='{event['id']}', " - f"heading='{event['heading']}', " - f"startTimestamp='{event['startTimestamp']}'" + f"uid={event.uid!r}, heading={event.heading!r}, start_time={event.start_time}" ) -def _chat_summary(chat: JSONDict) -> str: - msg_text = chat["message"].get("text", "") +def _chat_summary(chat: Chat) -> str: + msg_text = chat.message.text if chat.message and chat.message.text else "" + msg_ts = chat.message.timestamp if chat.message else None return ( - f"id='{chat['id']}', " - f"timestamp='{chat['message']['timestamp']}', " - f"text={_abbreviate(msg_text, length=64)}" + f"uid={chat.uid!r}, timestamp={msg_ts}, text={_abbreviate(msg_text, length=64)}" ) -def _post_summary(post: JSONDict) -> str: - return ( - f"id='{post['id']}', timestamp='{post['timestamp']}', title='{post['title']}'" - ) +def _post_summary(post: Post) -> str: + return f"uid={post.uid!r}, timestamp={post.timestamp}, title={post.title!r}" -def _transaction_summary(transaction: JSONDict) -> str: +def _transaction_summary(transaction: Transaction) -> str: return ( - f"id='{transaction['id']}', " - f"timestamp='{transaction['paidAt']}', " - f"payment_name='{transaction['paymentName']}', " - f"name={transaction['paidByName']}" + f"uid={transaction.uid!r}, " + f"paid_at={transaction.paid_at}, " + f"payment_name={transaction.payment_name!r}, " + f"paid_by={transaction.paid_by_name!r}" ) @@ -137,7 +138,6 @@ def _abbreviate(text: str, length: int) -> str: escaped_text = repr(text) if len(text) > length: return f"{escaped_text[:length]}[…]" - return escaped_text diff --git a/examples/transactions.py b/examples/transactions.py index 1f47c34..46d2baf 100644 --- a/examples/transactions.py +++ b/examples/transactions.py @@ -1,3 +1,9 @@ +"""Spond Club transactions CSV export. + +Uses the v2.x typed `Transaction` instances; `model_dump(by_alias=True)` +gives the same row shape the pre-OO dict export produced. +""" + import argparse import asyncio import csv @@ -10,7 +16,9 @@ EXPORT_DIRPATH = Path("./exports") parser = argparse.ArgumentParser( - description="Creates an transactions.csv to keep track of payments accessible on Spond Club" + description=( + "Creates an transactions.csv to keep track of payments accessible on Spond Club" + ) ) parser.add_argument( "-m", @@ -20,30 +28,38 @@ dest="max", default=1000, ) - args = parser.parse_args() async def main() -> None: - s = SpondClub(username=username, password=password) - transactions = await s.get_transactions(club_id=club_id, max_items=args.max) + async with SpondClub(username=username, password=password) as s: + transactions = await s.get_transactions(club_id=club_id, max_items=args.max) + if not transactions: print("No transactions found.") - await s.clientsession.close() return EXPORT_DIRPATH.mkdir(exist_ok=True) csv_filepath = EXPORT_DIRPATH / "transactions.csv" - header = transactions[0].keys() + + # Each Transaction is a Pydantic model — dump with `by_alias=True` + # to get camelCase column names matching Spond's wire shape (the + # same shape the pre-OO dict export used). `model_dump(mode="json")` + # converts datetime fields to ISO strings so csv.DictWriter can + # write them without further conversion. + rows = [ + t.model_dump(by_alias=True, mode="json", exclude_none=True) + for t in transactions + ] + header = sorted({k for row in rows for k in row}) with csv_filepath.open("w", newline="") as file: writer = csv.DictWriter(file, fieldnames=header) writer.writeheader() - for t in transactions: - writer.writerow(t) + for row in rows: + writer.writerow(row) print(f"Collected {len(transactions)} transactions. Written to {csv_filepath}") - await s.clientsession.close() loop = asyncio.new_event_loop() diff --git a/spond/event.py b/spond/event.py index 7e6ff8e..104d155 100644 --- a/spond/event.py +++ b/spond/event.py @@ -152,6 +152,10 @@ class Event(DictCompatModel): heading: str = "" start_time: datetime | None = Field(default=None, alias="startTimestamp") end_time: datetime | None = Field(default=None, alias="endTimestamp") + meetup_time: datetime | None = Field(default=None, alias="meetupTimestamp") + """When participants are expected to arrive (Norwegian: \"oppmøtetid\"). + Present on match events and some training events. Falls back to + `start_time` for events that only have a kickoff.""" created_time: datetime | None = Field(default=None, alias="createdTime") type: str = "" """Spond's event-kind string. Common values are listed on `EventType`, From 8dc48b8da6941b73ab1e8aa1d2e4f9e0c9f666a2 Mon Sep 17 00:00:00 2001 From: olen Date: Fri, 15 May 2026 21:31:07 +0200 Subject: [PATCH 40/42] =?UTF-8?q?fix(oo-rewrite):=20round-19=20review=20?= =?UTF-8?q?=E2=80=94=20three=20consistency=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## SpondAPIError.__str__ always includes URL when present Previously the URL was only appended on the no-body path (`if url and not body`). For the more common diagnostic case (`SpondAPIError(503, "Service Unavailable", "https://...")`), the URL was silently dropped from the message — the worst of both worlds, since the body+url combination is the most useful one to a developer reading a traceback. Now the URL is always appended when set, regardless of whether there's a body. ## Missing `.ok = True` in three Event save tests Three tests in `tests/test_event_save_delete.py` mocked `aiohttp.ClientSession.post`'s `.json` but not `.ok`. They passed because `MagicMock().ok` evaluates truthy by default — the `if not r.ok:` check in production happened to pass by accident. If the production code is ever tightened to `if r.ok is not True:`, the tests would silently break. Set `.ok = True` explicitly to match the convention used in the rest of the file. ## `Person.__str__` / `Profile.__str__` show `` for empty names `full_name` returns `""` when both first/last name default to empty strings (per the resilience relaxation). `Person.__str__` then rendered as `Member(uid='M1', name='')` — the empty quotes look like a bug to a reader. Now shows `name=''`, mirroring the `"?"` sentinel pattern already used by Event/Post/Comment for missing timestamps. Pure cosmetic change to debug output. ## Boilerplate-fixture suggestion (declined, optional) The fourth comment proposed extracting a helper fixture in conftest.py to centralise the duplicated `mock_post.return_value .__aenter__.return_value.{ok,json}` pattern across ~20 tests. Genuine maintainability improvement, but a separate change — touching every test file at once would dwarf this round's fixes and obscure the substantive ones. Leaving as a follow-up if anyone wants to take it on. Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/exceptions.py | 6 +++++- spond/person.py | 6 +++++- spond/profile.py | 4 +++- tests/test_event_save_delete.py | 3 +++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/spond/exceptions.py b/spond/exceptions.py index 96b2fe5..0afc2eb 100644 --- a/spond/exceptions.py +++ b/spond/exceptions.py @@ -71,7 +71,11 @@ def __init__(self, status: int, body: str = "", url: str | None = None) -> None: msg = f"Request failed with status {status}: {trimmed}" else: msg = f"Spond API returned HTTP {status}" - if url and not body: + # URL is always appended when present — it's the most useful + # diagnostic field after the status code and is omitting it + # silently on the body-present path made the body+url case + # less debuggable than the body-only case. + if url: msg += f" for {url}" super().__init__(msg) diff --git a/spond/person.py b/spond/person.py index 6c6abdf..2276f46 100644 --- a/spond/person.py +++ b/spond/person.py @@ -66,7 +66,11 @@ def full_name(self) -> str: return " ".join(part for part in (self.first_name, self.last_name) if part) def __str__(self) -> str: - return f"{self.__class__.__name__}(uid={self.uid!r}, name={self.full_name!r})" + # `` mirrors the `"?"` sentinel used by Event/Post/Comment + # for missing timestamps — better debug output than an empty + # `name=''` when both first_name and last_name default to empty. + name = self.full_name or "" + return f"{self.__class__.__name__}(uid={self.uid!r}, name={name!r})" def _natural_key(self) -> tuple | None: """uid when set; otherwise full_name + email. Returns the same diff --git a/spond/profile.py b/spond/profile.py index 5c5cec9..58e85bd 100644 --- a/spond/profile.py +++ b/spond/profile.py @@ -71,7 +71,9 @@ def full_name(self) -> str: return " ".join(part for part in (self.first_name, self.last_name) if part) def __str__(self) -> str: - return f"Profile(uid={self.uid!r}, name={self.full_name!r})" + # Same `` sentinel as `Person.__str__` for consistency. + name = self.full_name or "" + return f"Profile(uid={self.uid!r}, name={name!r})" def _natural_key(self) -> tuple | None: """uid when set; otherwise full_name distinguishes diff --git a/tests/test_event_save_delete.py b/tests/test_event_save_delete.py index 2c387d5..3dccd8a 100644 --- a/tests/test_event_save_delete.py +++ b/tests/test_event_save_delete.py @@ -247,6 +247,7 @@ async def test_save_existing_posts_to_uid_url(self, mock_post) -> None: event = Event.from_api(_MIN_EVENT_PAYLOAD, s) api_response = {**_MIN_EVENT_PAYLOAD, "heading": "Renamed"} + mock_post.return_value.__aenter__.return_value.ok = True mock_post.return_value.__aenter__.return_value.json = AsyncMock( return_value=api_response ) @@ -275,6 +276,7 @@ async def test_save_persists_mutation_of_unset_field(self, mock_post) -> None: assert event.description is None assert "description" not in event.__pydantic_fields_set__ + mock_post.return_value.__aenter__.return_value.ok = True mock_post.return_value.__aenter__.return_value.json = AsyncMock( return_value=_MIN_EVENT_PAYLOAD ) @@ -298,6 +300,7 @@ async def test_save_existing_uses_bound_client(self, mock_post) -> None: s.token = "MOCK" event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + mock_post.return_value.__aenter__.return_value.ok = True mock_post.return_value.__aenter__.return_value.json = AsyncMock( return_value=_MIN_EVENT_PAYLOAD ) From 010a84b9258a037d0a75c07763145f4b23c9f35f Mon Sep 17 00:00:00 2001 From: olen Date: Fri, 15 May 2026 22:07:22 +0200 Subject: [PATCH 41/42] fix(oo-rewrite): replace extras on save(); aenter return type; grammar nit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Replace __pydantic_extra__ wholesale on save() (real bug) `save()` was merging refreshed extras into self via `.update()` while replacing `__pydantic_fields_set__` wholesale on the next line — producing an inconsistent picture where dict-compat iteration (`list(post)`, `post.keys()`) would report stale extras that the refreshed response didn't include. Now `__pydantic_extra__` is `.clear()`-then-`.update()`-d to match the wholesale replacement semantics used for declared fields. Same fix in both `Event.save()` and `Post.save()`. Regression test in `test_post_save_delete.py::TestSaveExtrasReplaceNotMerge` covers both directions: - A pre-existing extra absent from the response is gone after save - An extra in the response is present after save Live-verified on the test group: seeded a stale extra on a post before save, mutated title, called save() — the stale extra is no longer on the post after the round-trip. ## `__aenter__` return-type annotation Added `-> Self` (and the `from typing import Self`, `from __future__ import annotations`) so `async with Spond(...) as s:` type-checkers see `s: Spond` directly without falling back to inference. Consistent with the rest of the file. ## Grammar nit in examples/transactions.py "Creates an transactions.csv" → "Creates a transactions.csv". Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/transactions.py | 2 +- spond/base.py | 5 +++- spond/event.py | 12 +++++--- spond/post.py | 13 ++++++-- tests/test_post_save_delete.py | 54 ++++++++++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 9 deletions(-) diff --git a/examples/transactions.py b/examples/transactions.py index 46d2baf..dc9b69a 100644 --- a/examples/transactions.py +++ b/examples/transactions.py @@ -17,7 +17,7 @@ parser = argparse.ArgumentParser( description=( - "Creates an transactions.csv to keep track of payments accessible on Spond Club" + "Creates a transactions.csv to keep track of payments accessible on Spond Club" ) ) parser.add_argument( diff --git a/spond/base.py b/spond/base.py index d96a36e..db3dc89 100644 --- a/spond/base.py +++ b/spond/base.py @@ -8,9 +8,12 @@ Not intended to be instantiated directly — use a subclass. """ +from __future__ import annotations + import functools from abc import ABC from collections.abc import Callable +from typing import Self import aiohttp @@ -62,7 +65,7 @@ def __init__(self, username: str, password: str, api_url: str) -> None: ) self.token = None - async def __aenter__(self): + async def __aenter__(self) -> Self: """Async context-manager entry — returns self. Enables the idiomatic `async with Spond(...) as s:` shape so the diff --git a/spond/event.py b/spond/event.py index 104d155..97bad40 100644 --- a/spond/event.py +++ b/spond/event.py @@ -696,10 +696,14 @@ async def save(self, client: Spond | None = None) -> Event: if field_name in skip_on_update: continue object.__setattr__(self, field_name, getattr(refreshed, field_name)) - # Capture any extras Spond added that we don't model. - extras = refreshed._pydantic_extras() - if extras and self.__pydantic_extra__ is not None: - self.__pydantic_extra__.update(extras) + # Replace extras wholesale rather than merging — `model_fields_set` + # below is replaced wholesale too, and merging extras would leave + # stale entries on self that were present before the refresh but + # absent from the response. Dict-compat iteration (`list(event)`) + # would then report keys that aren't really in the model anymore. + if self.__pydantic_extra__ is not None: + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(refreshed._pydantic_extras()) # Sync `model_fields_set` so subsequent `exclude_unset=True` # dumps reflect what Spond actually emitted (not our pre-save # snapshot). diff --git a/spond/post.py b/spond/post.py index 503d613..b6ca2c7 100644 --- a/spond/post.py +++ b/spond/post.py @@ -227,9 +227,16 @@ async def save(self, client: Spond | None = None) -> Post: if field_name in skip_on_update: continue object.__setattr__(self, field_name, getattr(refreshed, field_name)) - extras = refreshed._pydantic_extras() - if extras and self.__pydantic_extra__ is not None: - self.__pydantic_extra__.update(extras) + # Replace, don't merge: `model_fields_set` is replaced wholesale + # on the line below, so the extras dict must follow the same + # "this is the post-refresh canonical state" semantics. Merging + # (.update()) would leave stale extras that were on self before + # the refresh but absent from the response, producing an + # inconsistent dict-compat view where `list(post)` reports + # fields that aren't actually in the model anymore. + if self.__pydantic_extra__ is not None: + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(refreshed._pydantic_extras()) self.__pydantic_fields_set__ = set(refreshed.__pydantic_fields_set__) # Cache management. On create, prepend so subsequent diff --git a/tests/test_post_save_delete.py b/tests/test_post_save_delete.py index 5fb7348..0c18b4a 100644 --- a/tests/test_post_save_delete.py +++ b/tests/test_post_save_delete.py @@ -377,6 +377,60 @@ async def test_add_comment_raises_on_http_error(self, mock_post) -> None: await post.add_comment("hi") +class TestSaveExtrasReplaceNotMerge: + """`save()` replaces `__pydantic_extra__` from the refreshed + response rather than merging it in. The merge approach left stale + extras visible via the dict-compat iter/keys/items surface even + after Spond's response said they were gone.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + async def test_save_drops_stale_extras_not_in_response(self, mock_put) -> None: + """A Post with extra fields present pre-save, but absent from + the update response, must not show those extras on `list(post)` + after the save. The previous merge approach silently kept them.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + # Construct with a stale extra Spond used to include but no + # longer does. + post = Post.from_api({**_API_POST, "stalePreviewField": "old-value"}, s) + assert "stalePreviewField" in post + assert post.__pydantic_extra__["stalePreviewField"] == "old-value" + + # Update response omits the stale extra (Spond stopped sending it). + mock_put.return_value.__aenter__.return_value.ok = True + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_API_POST, "title": "Renamed"} + ) + post.title = "Renamed" + await post.save() + + # The stale extra is gone from the model. + assert "stalePreviewField" not in post, ( + "stale extra survived save() — extras should be REPLACED " + "from the response, not merged into the old set" + ) + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + async def test_save_keeps_extras_present_in_response(self, mock_put) -> None: + """Conversely: an extra in the refreshed response IS reflected + on self after save — replace, not just clear.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + + mock_put.return_value.__aenter__.return_value.ok = True + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_API_POST, "newExtraField": 42, "title": "Renamed"} + ) + post.title = "Renamed" + await post.save() + + assert "newExtraField" in post + assert post.__pydantic_extra__["newExtraField"] == 42 + + class TestSaveDoesNotWipeLocallyAddedComments: """Regression guard: a Post that has a comment added via `add_comment()` and is then `save()`-d must NOT lose that comment, From 3e8b39203c331739bb80b9453df9cbcfec904b71 Mon Sep 17 00:00:00 2001 From: olen Date: Sat, 16 May 2026 15:29:26 +0200 Subject: [PATCH 42/42] =?UTF-8?q?fix(oo-rewrite):=20round-21=20review=20?= =?UTF-8?q?=E2=80=94=20add=20`.ok=20=3D=20True`=20to=20three=20more=20test?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pattern as the previous round's #3249591939 fix: three tests mocked `aiohttp.ClientSession.post`'s `.json` but not `.ok`, passing only because `MagicMock().ok` evaluates truthy by default. Set `.ok = True` explicitly to match the convention used in the rest of the suite and make these tests robust against any future tightening of the production `if not r.ok:` check. Tests fixed: - `tests/test_backward_compat.py::test_update_event_emits_deprecation` - `tests/test_compat.py::test_event_update_excludes_unset_default_collections` - `tests/test_compat.py::test_event_update_excludes_none_fields` The third review item (ical.py field name sanity check) verified clean: `Event.meetup_time`, `Event.updated`, and `Event.cancelled` are all declared fields with the correct aliases. No code change. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_backward_compat.py | 1 + tests/test_compat.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/tests/test_backward_compat.py b/tests/test_backward_compat.py index 81fed6b..94d9b4b 100644 --- a/tests/test_backward_compat.py +++ b/tests/test_backward_compat.py @@ -145,6 +145,7 @@ async def test_update_event_emits_deprecation(self, mock_token) -> None: s.events = [Event.from_api(_MIN_EVENT_PAYLOAD, s)] with patch("aiohttp.ClientSession.post") as mock_post: + mock_post.return_value.__aenter__.return_value.ok = True mock_post.return_value.__aenter__.return_value.json = AsyncMock( return_value=_MIN_EVENT_PAYLOAD ) diff --git a/tests/test_compat.py b/tests/test_compat.py index 85e4172..3c9f21d 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -138,6 +138,7 @@ async def test_event_update_excludes_unset_default_collections( assert event.owners == [] assert event.attachments == [] + mock_post.return_value.__aenter__.return_value.ok = True mock_post.return_value.__aenter__.return_value.json = AsyncMock( return_value=_MIN_EVENT_PAYLOAD ) @@ -164,6 +165,7 @@ async def test_event_update_excludes_none_fields( event = Event.from_api(_MIN_EVENT_PAYLOAD, s) assert event.description is None + mock_post.return_value.__aenter__.return_value.ok = True mock_post.return_value.__aenter__.return_value.json = AsyncMock( return_value=_MIN_EVENT_PAYLOAD )