Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
0fcb9f9
design: spec for object-oriented rewrite
Olen May 14, 2026
4af213d
feat(oo-rewrite): add typed model classes
Olen May 14, 2026
205df75
feat(oo-rewrite): wire Spond.get_* to typed objects + deprecate legac…
Olen May 14, 2026
5000d69
docs: README points at typed-object API + DESIGN-oo-rewrite.md
Olen May 14, 2026
4e9bb72
fix: don't use locals() inside a comprehension (broke Python 3.11)
Olen May 14, 2026
2490b30
fix(oo-rewrite): address Copilot review on #246
Olen May 14, 2026
7ad9f92
refactor: drop _EVENT_TEMPLATE — Event class is the canonical schema
Olen May 14, 2026
636dbc0
fix(oo-rewrite): address second-round Copilot review on #246
Olen May 14, 2026
f53853e
fix(oo-rewrite): address third-round Copilot review on #246
Olen May 14, 2026
6cfdba0
fix(oo-rewrite): address fourth-round Copilot review on #246
Olen May 14, 2026
b9ea50e
fix(oo-rewrite): address fifth-round Copilot review on #246
Olen May 14, 2026
0bb5245
fix(oo-rewrite): address sixth-round Copilot review on #246
Olen May 14, 2026
f69b4c7
fix(oo-rewrite): guard Post.__str__ against None timestamp
Olen May 14, 2026
477a97e
feat(oo-rewrite): add Match as an Event subclass
Olen May 14, 2026
ed93e23
fix(oo-rewrite): round-9 review + live API field-drift audit
Olen May 14, 2026
1ad187c
docs: add maintainer notes for periodic field-drift audit + subclass …
Olen May 14, 2026
3f59a34
fix(oo-rewrite): round-10 review — narrow update payload further
Olen May 14, 2026
daf565e
feat(oo-rewrite): typed Chat and Message models
Olen May 14, 2026
61a1af5
refactor(tests): split test_spond.py by domain + add Chat tests
Olen May 14, 2026
22fb02d
docs(oo-rewrite): align design doc with shipped implementation
Olen May 14, 2026
d692fab
fix(oo-rewrite): address round-11 Copilot review
Olen May 14, 2026
35b0374
test: improve coverage from 76% to 99% — 70 new tests across 6 files
Copilot May 14, 2026
7764a0f
fix(oo-rewrite): address round-12 Copilot review + reformat new tests
Olen May 14, 2026
7942ecb
fix(oo-rewrite): JSON-encode caller updates in Event.update()
Olen May 14, 2026
220abf0
fix(oo-rewrite): round-13 review — empty-name match + fail-fast in se…
Olen May 14, 2026
8cc4683
feat(oo-rewrite): exception hierarchy + entity-identity equality
Olen May 14, 2026
b37d25f
feat(oo-rewrite): Event convenience properties + member-resolution he…
Olen May 14, 2026
78128dd
feat(oo-rewrite): Spond and SpondClub as async context managers
Olen May 14, 2026
13f43fb
feat(oo-rewrite): Event.save() + Event.delete() — ActiveRecord write …
Olen May 14, 2026
96ffc1c
docs(oo-rewrite): update README and DESIGN doc for v2.0 surface
Olen May 14, 2026
00fbeb7
feat(oo-rewrite): typed Comment + Post.save/delete/add_comment
Olen May 15, 2026
87dd43c
fix(oo-rewrite): address pending Copilot review on Phase 1 + 2
Olen May 15, 2026
343086a
docs: remove local credential path reference from design doc
Olen May 15, 2026
c00f045
fix(oo-rewrite): address round-15 Copilot review on Post.save()
Olen May 15, 2026
305cb28
fix(oo-rewrite): address round-16 Copilot review
Olen May 15, 2026
68d350f
fix(oo-rewrite): Event.save() caches self, not a refreshed copy
Olen May 15, 2026
8895a28
feat(oo-rewrite): Group navigation helpers + typed FieldDef
Olen May 15, 2026
fe09fd4
fix(oo-rewrite): preserve local comments across save(); use PUT for P…
Olen May 15, 2026
2a4f02e
docs: update examples to v2.x typed surface; README v2.0 upgrade notes
Olen May 15, 2026
8dc48b8
fix(oo-rewrite): round-19 review — three consistency fixes
Olen May 15, 2026
010a84b
fix(oo-rewrite): replace extras on save(); aenter return type; gramma…
Olen May 15, 2026
3e8b392
fix(oo-rewrite): round-21 review — add `.ok = True` to three more tests
Olen May 16, 2026
9e504b7
Merge branch 'main' into feat/oo-rewrite
github-actions[bot] Aug 15, 2026
7b4d4fa
Merge branch 'main' into feat/oo-rewrite
github-actions[bot] Aug 15, 2026
6571a45
Merge branch 'main' into feat/oo-rewrite
Olen Aug 16, 2026
6b018be
style: format README code blocks for ruff 0.16
Olen Aug 16, 2026
03c2c54
Merge branch 'main' into feat/oo-rewrite
github-actions[bot] Aug 16, 2026
25737b1
Revert "style: format README code blocks for ruff 0.16"
Olen Aug 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions DESIGN-oo-rewrite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
# 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[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)
```

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 deferred — see Type Inventory above)

**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` (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.

## 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.
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,36 @@ 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 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
> migration story.

## Key methods

### get_groups()
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading