Skip to content

Commit e2f54ca

Browse files
committed
events are typed
1 parent 755602d commit e2f54ca

5 files changed

Lines changed: 158 additions & 47 deletions

File tree

examples/simple_cli.py

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -140,31 +140,26 @@ async def main():
140140
gateway = stackcoin.Gateway(ws_url=ws_url, token=token)
141141

142142
@gateway.on("transfer.completed")
143-
async def on_transfer(event: stackcoin.Event):
144-
d = event.data
145-
role = d.get("role", "?")
146-
if role == "sender":
147-
print(f"\n [event] Sent {d['amount']} STK to user #{d['to_id']}")
143+
async def on_transfer(event: stackcoin.TransferCompletedEvent):
144+
if event.data.role == "sender":
145+
print(f"\n [event] Sent {event.data.amount} STK to user #{event.data.to_id}")
148146
else:
149-
print(f"\n [event] Received {d['amount']} STK from user #{d['from_id']}")
147+
print(f"\n [event] Received {event.data.amount} STK from user #{event.data.from_id}")
150148
print("> ", end="", flush=True)
151149

152150
@gateway.on("request.created")
153-
async def on_request_created(event: stackcoin.Event):
154-
d = event.data
155-
print(f"\n [event] New request #{d['request_id']} for {d['amount']} STK")
151+
async def on_request_created(event: stackcoin.RequestCreatedEvent):
152+
print(f"\n [event] New request #{event.data.request_id} for {event.data.amount} STK")
156153
print("> ", end="", flush=True)
157154

158155
@gateway.on("request.accepted")
159-
async def on_request_accepted(event: stackcoin.Event):
160-
d = event.data
161-
print(f"\n [event] Request #{d['request_id']} accepted")
156+
async def on_request_accepted(event: stackcoin.RequestAcceptedEvent):
157+
print(f"\n [event] Request #{event.data.request_id} accepted")
162158
print("> ", end="", flush=True)
163159

164160
@gateway.on("request.denied")
165-
async def on_request_denied(event: stackcoin.Event):
166-
d = event.data
167-
print(f"\n [event] Request #{d['request_id']} denied")
161+
async def on_request_denied(event: stackcoin.RequestDeniedEvent):
162+
print(f"\n [event] Request #{event.data.request_id} denied")
168163
print("> ", end="", flush=True)
169164

170165
# Run gateway in background

stackcoin/stackcoin/__init__.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,32 @@
11
"""StackCoin Python SDK."""
22

3-
from .client import Client
3+
from .client import AnyEvent, Client
44
from .errors import StackCoinError
5-
from .gateway import Event, Gateway
5+
from .gateway import Gateway
6+
from .models import (
7+
Event,
8+
RequestAcceptedData,
9+
RequestAcceptedEvent,
10+
RequestCreatedData,
11+
RequestCreatedEvent,
12+
RequestDeniedData,
13+
RequestDeniedEvent,
14+
TransferCompletedData,
15+
TransferCompletedEvent,
16+
)
617

7-
__all__ = ["Client", "Event", "Gateway", "StackCoinError"]
18+
__all__ = [
19+
"AnyEvent",
20+
"Client",
21+
"Event",
22+
"Gateway",
23+
"RequestAcceptedData",
24+
"RequestAcceptedEvent",
25+
"RequestCreatedData",
26+
"RequestCreatedEvent",
27+
"RequestDeniedData",
28+
"RequestDeniedEvent",
29+
"StackCoinError",
30+
"TransferCompletedData",
31+
"TransferCompletedEvent",
32+
]

stackcoin/stackcoin/client.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,24 @@
1111
CreateRequestResponse,
1212
DiscordGuild,
1313
DiscordGuildsResponse,
14+
EventsResponse,
1415
Request,
16+
RequestAcceptedEvent,
1517
RequestActionResponse,
18+
RequestCreatedEvent,
19+
RequestDeniedEvent,
1620
RequestsResponse,
1721
SendStkResponse,
1822
Transaction,
1923
TransactionsResponse,
24+
TransferCompletedEvent,
2025
User,
2126
UsersResponse,
2227
)
2328

29+
# Union of all concrete event types (unwrapped from Event RootModel)
30+
AnyEvent = TransferCompletedEvent | RequestCreatedEvent | RequestAcceptedEvent | RequestDeniedEvent
31+
2432

2533
class Client:
2634
"""Async client for the StackCoin REST API.
@@ -198,18 +206,15 @@ async def get_transaction(self, transaction_id: int) -> Transaction:
198206

199207
# -- events ----------------------------------------------------------- #
200208

201-
async def get_events(self, *, since_id: int = 0) -> list[dict[str, Any]]:
202-
"""Return events since the given ID.
203-
204-
Events are not yet in the OpenAPI spec, so this returns raw dicts.
205-
"""
209+
async def get_events(self, *, since_id: int = 0) -> list[AnyEvent]:
210+
"""Return typed events since the given ID."""
206211
params: dict[str, Any] = {}
207212
if since_id:
208213
params["since_id"] = since_id
209214
resp = await self._http.get("/api/events", params=params)
210215
self._raise_for_error(resp)
211-
data = resp.json()
212-
return data.get("events", data) if isinstance(data, dict) else data
216+
wrapper = EventsResponse.model_validate(resp.json())
217+
return [e.root for e in wrapper.events]
213218

214219
# -- discord guilds --------------------------------------------------- #
215220

stackcoin/stackcoin/gateway.py

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,11 @@
44
import json
55
from typing import Any, Callable, Awaitable
66

7-
from pydantic import BaseModel
7+
from .client import AnyEvent
8+
from .models import Event
89

910

10-
EventHandler = Callable[["Event"], Awaitable[None]]
11-
12-
13-
class Event(BaseModel):
14-
"""A StackCoin event received via the gateway."""
15-
16-
id: int
17-
type: str
18-
data: dict[str, Any]
19-
inserted_at: str
11+
EventHandler = Callable[[AnyEvent], Awaitable[None]]
2012

2113

2214
class Gateway:
@@ -30,8 +22,8 @@ class Gateway:
3022
)
3123
3224
@gateway.on("request.accepted")
33-
async def handle_accepted(event: stackcoin.Event):
34-
print(event.data["request_id"])
25+
async def handle_accepted(event: stackcoin.RequestAcceptedEvent):
26+
print(event.data.request_id)
3527
3628
await gateway.connect()
3729
"""
@@ -133,20 +125,21 @@ async def _handle_message(self, msg: list[Any]) -> None:
133125
payload = msg[4]
134126

135127
if event_name == "event":
136-
event = Event.model_validate(payload)
128+
# Parse via discriminated union RootModel, then unwrap
129+
typed_event = Event.model_validate(payload).root
137130

138-
if event.id > self._last_event_id:
139-
self._last_event_id = event.id
131+
if typed_event.id > self._last_event_id:
132+
self._last_event_id = typed_event.id
140133

141-
for handler in self._handlers.get(event.type, []):
134+
for handler in self._handlers.get(typed_event.type, []):
142135
try:
143-
await handler(event)
136+
await handler(typed_event)
144137
except Exception:
145138
pass
146139

147-
if event.id > 0 and self._on_event_id:
140+
if typed_event.id > 0 and self._on_event_id:
148141
try:
149-
self._on_event_id(event.id)
142+
self._on_event_id(typed_event.id)
150143
except Exception:
151144
pass
152145

stackcoin/stackcoin/models.py

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
# generated by datamodel-codegen:
22
# filename: openapi.json
3-
# timestamp: 2026-03-05T04:04:56+00:00
3+
# timestamp: 2026-03-05T04:32:37+00:00
44

55
from __future__ import annotations
66

77
from datetime import datetime
8+
from enum import StrEnum
9+
from typing import Literal
810

9-
from pydantic import BaseModel, Field
11+
from pydantic import BaseModel, Field, RootModel
1012

1113

1214
class CreateRequestParams(BaseModel):
@@ -82,6 +84,24 @@ class Request(BaseModel):
8284
transaction_id: int | None = Field(None, description="Associated transaction ID")
8385

8486

87+
class RequestAcceptedData(BaseModel):
88+
amount: int = Field(..., description="Request amount")
89+
request_id: int = Field(..., description="Request ID")
90+
status: str = Field(..., description="New request status")
91+
transaction_id: int = Field(..., description="Created transaction ID")
92+
93+
94+
class Type(StrEnum):
95+
request_accepted = "request.accepted"
96+
97+
98+
class RequestAcceptedEvent(BaseModel):
99+
data: RequestAcceptedData
100+
id: int = Field(..., description="Event ID")
101+
inserted_at: datetime = Field(..., description="Event timestamp")
102+
type: Literal["request.accepted"] = Field(..., description="Event type")
103+
104+
85105
class RequestActionResponse(BaseModel):
86106
request_id: int = Field(..., description="Request ID")
87107
resolved_at: datetime = Field(..., description="Resolution timestamp")
@@ -90,6 +110,41 @@ class RequestActionResponse(BaseModel):
90110
transaction_id: int | None = Field(None, description="Associated transaction ID")
91111

92112

113+
class RequestCreatedData(BaseModel):
114+
amount: int = Field(..., description="Requested amount")
115+
label: str | None = Field(None, description="Request label")
116+
request_id: int = Field(..., description="Request ID")
117+
requester_id: int = Field(..., description="Requester user ID")
118+
responder_id: int = Field(..., description="Responder user ID")
119+
120+
121+
class Type1(StrEnum):
122+
request_created = "request.created"
123+
124+
125+
class RequestCreatedEvent(BaseModel):
126+
data: RequestCreatedData
127+
id: int = Field(..., description="Event ID")
128+
inserted_at: datetime = Field(..., description="Event timestamp")
129+
type: Literal["request.created"] = Field(..., description="Event type")
130+
131+
132+
class RequestDeniedData(BaseModel):
133+
request_id: int = Field(..., description="Request ID")
134+
status: str = Field(..., description="New request status")
135+
136+
137+
class Type2(StrEnum):
138+
request_denied = "request.denied"
139+
140+
141+
class RequestDeniedEvent(BaseModel):
142+
data: RequestDeniedData
143+
id: int = Field(..., description="Event ID")
144+
inserted_at: datetime = Field(..., description="Event timestamp")
145+
type: Literal["request.denied"] = Field(..., description="Event type")
146+
147+
93148
class RequestResponse(BaseModel):
94149
amount: int = Field(..., description="Requested amount")
95150
id: int = Field(..., description="Request ID")
@@ -153,6 +208,25 @@ class TransactionsResponse(BaseModel):
153208
transactions: list[Transaction] | None = Field(None, description="The transactions list")
154209

155210

211+
class TransferCompletedData(BaseModel):
212+
amount: int = Field(..., description="Amount transferred")
213+
from_id: int = Field(..., description="Sender user ID")
214+
role: str = Field(..., description="Role of the event recipient (sender or receiver)")
215+
to_id: int = Field(..., description="Recipient user ID")
216+
transaction_id: int = Field(..., description="Transaction ID")
217+
218+
219+
class Type3(StrEnum):
220+
transfer_completed = "transfer.completed"
221+
222+
223+
class TransferCompletedEvent(BaseModel):
224+
data: TransferCompletedData
225+
id: int = Field(..., description="Event ID")
226+
inserted_at: datetime = Field(..., description="Event timestamp")
227+
type: Literal["transfer.completed"] = Field(..., description="Event type")
228+
229+
156230
class User(BaseModel):
157231
admin: bool = Field(..., description="Whether user is an admin")
158232
balance: int = Field(..., description="User's STK balance")
@@ -176,3 +250,22 @@ class UserResponse(BaseModel):
176250
class UsersResponse(BaseModel):
177251
pagination: Pagination | None = None
178252
users: list[User] | None = Field(None, description="The users list")
253+
254+
255+
class Event(
256+
RootModel[
257+
TransferCompletedEvent | RequestCreatedEvent | RequestAcceptedEvent | RequestDeniedEvent
258+
]
259+
):
260+
root: (
261+
TransferCompletedEvent | RequestCreatedEvent | RequestAcceptedEvent | RequestDeniedEvent
262+
) = Field(
263+
...,
264+
description="A StackCoin event (discriminated by type)",
265+
discriminator="type",
266+
title="Event",
267+
)
268+
269+
270+
class EventsResponse(BaseModel):
271+
events: list[Event] = Field(..., description="The events list")

0 commit comments

Comments
 (0)