Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 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
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
441 changes: 441 additions & 0 deletions DESIGN-oo-rewrite.md

Large diffs are not rendered by default.

159 changes: 153 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,177 @@ 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

### 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'])
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 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 — 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

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()
Expand Down
131 changes: 51 additions & 80 deletions examples/attendance.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
24 changes: 13 additions & 11 deletions examples/groups.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
Expand Down
Loading