sodp-client 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sodp_client-0.2.1/.gitignore +30 -0
- sodp_client-0.2.1/PKG-INFO +298 -0
- sodp_client-0.2.1/README.md +281 -0
- sodp_client-0.2.1/integration.py +455 -0
- sodp_client-0.2.1/pyproject.toml +31 -0
- sodp_client-0.2.1/src/sodp/__init__.py +4 -0
- sodp_client-0.2.1/src/sodp/client.py +598 -0
- sodp_client-0.2.1/src/sodp/delta.py +140 -0
- sodp_client-0.2.1/tests/__init__.py +0 -0
- sodp_client-0.2.1/tests/test_client.py +93 -0
- sodp_client-0.2.1/tests/test_delta.py +204 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Rust
|
|
2
|
+
/target/
|
|
3
|
+
|
|
4
|
+
# TypeScript
|
|
5
|
+
client-ts/node_modules/
|
|
6
|
+
client-ts/dist/
|
|
7
|
+
react-sodp/node_modules/
|
|
8
|
+
react-sodp/dist/
|
|
9
|
+
|
|
10
|
+
# Python
|
|
11
|
+
sodp-py/.venv/
|
|
12
|
+
sodp-py/dist/
|
|
13
|
+
sodp-py/src/*.egg-info/
|
|
14
|
+
sodp-py/__pycache__/
|
|
15
|
+
**/__pycache__/
|
|
16
|
+
**/*.pyc
|
|
17
|
+
|
|
18
|
+
# Java
|
|
19
|
+
sodp-java/target/
|
|
20
|
+
|
|
21
|
+
## Demo app (not published)
|
|
22
|
+
#/demo-collab/
|
|
23
|
+
|
|
24
|
+
# Local environment (never commit — copy from .env.example)
|
|
25
|
+
.env
|
|
26
|
+
|
|
27
|
+
# IDE & tooling
|
|
28
|
+
.idea/
|
|
29
|
+
*.iml
|
|
30
|
+
.claude/
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sodp-client
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Python client for the State-Oriented Data Protocol
|
|
5
|
+
Project-URL: Homepage, https://github.com/orkestri/SODP
|
|
6
|
+
Project-URL: Repository, https://github.com/orkestri/SODP
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/orkestri/SODP/issues
|
|
8
|
+
Author-email: Orkestri <hello@orkestri.io>
|
|
9
|
+
License: MIT
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: msgpack>=1.0
|
|
12
|
+
Requires-Dist: websockets>=12
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# sodp
|
|
19
|
+
|
|
20
|
+
[](https://pypi.org/project/sodp/)
|
|
21
|
+
[](https://pypi.org/project/sodp/)
|
|
22
|
+
[](https://github.com/orkestri/SODP/blob/main/LICENSE)
|
|
23
|
+
|
|
24
|
+
Python asyncio client for the **State-Oriented Data Protocol (SODP)** — a WebSocket-based protocol for continuous state synchronization.
|
|
25
|
+
|
|
26
|
+
Instead of polling or request/response, SODP streams every change as a minimal delta to all connected subscribers. One mutation to a 100-field object sends exactly the changed fields.
|
|
27
|
+
|
|
28
|
+
→ [Protocol spec & server](https://github.com/orkestri/SODP)
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install sodp-client
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires **Python 3.11+** and a running asyncio event loop.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import asyncio
|
|
46
|
+
from sodp import SodpClient
|
|
47
|
+
|
|
48
|
+
async def main():
|
|
49
|
+
client = SodpClient("ws://localhost:7777")
|
|
50
|
+
await client.ready
|
|
51
|
+
|
|
52
|
+
# Subscribe
|
|
53
|
+
def on_player(value, meta):
|
|
54
|
+
print(f"player: {value} version={meta.version}")
|
|
55
|
+
|
|
56
|
+
unsub = client.watch("game.player", on_player)
|
|
57
|
+
|
|
58
|
+
# Mutate
|
|
59
|
+
await client.set("game.player", {"name": "Alice", "health": 100})
|
|
60
|
+
await client.patch("game.player", {"health": 80}) # only health changes
|
|
61
|
+
|
|
62
|
+
await asyncio.sleep(1)
|
|
63
|
+
|
|
64
|
+
unsub() # remove this callback
|
|
65
|
+
client.close() # close the connection
|
|
66
|
+
|
|
67
|
+
asyncio.run(main())
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Authentication
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
# Static token
|
|
76
|
+
client = SodpClient("wss://sodp.example.com", token="eyJhbG...")
|
|
77
|
+
|
|
78
|
+
# Dynamic token provider — called on every connect/reconnect
|
|
79
|
+
async def get_token() -> str:
|
|
80
|
+
async with aiohttp.ClientSession() as s:
|
|
81
|
+
return await (await s.get("/api/sodp-token")).text()
|
|
82
|
+
|
|
83
|
+
client = SodpClient("wss://sodp.example.com", token_provider=get_token)
|
|
84
|
+
|
|
85
|
+
# Sync provider is also accepted
|
|
86
|
+
client = SodpClient(url, token_provider=lambda: os.environ["SODP_TOKEN"])
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## API reference
|
|
92
|
+
|
|
93
|
+
### `SodpClient(url, *, ...)`
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
client = SodpClient(
|
|
97
|
+
url, # WebSocket URL, e.g. "ws://localhost:7777"
|
|
98
|
+
token=None, # static JWT string
|
|
99
|
+
token_provider=None, # callable → str | Awaitable[str]; supersedes token
|
|
100
|
+
reconnect=True, # auto-reconnect on disconnect
|
|
101
|
+
reconnect_delay=1.0, # base reconnect delay in seconds (doubles per attempt)
|
|
102
|
+
max_reconnect_delay=30.0, # maximum reconnect delay in seconds
|
|
103
|
+
on_connect=None, # called each time the connection is established
|
|
104
|
+
on_disconnect=None, # called each time the connection drops
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The client connects immediately in the background. Use `await client.ready` (or `await client`) to wait for the first successful authentication before sending commands.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
### `await client.ready`
|
|
113
|
+
|
|
114
|
+
Awaitable that resolves once the client is connected and authenticated. You can also `await client` directly:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
await client.ready # explicit
|
|
118
|
+
await client # same thing
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
### `client.watch(key, callback) → unsub`
|
|
124
|
+
|
|
125
|
+
Subscribe to a state key. `callback(value, meta)` fires on every update and immediately with the cached value if the key is already known.
|
|
126
|
+
|
|
127
|
+
- `value` — current state (any JSON-compatible type), or `None` if the key has no value yet
|
|
128
|
+
- `meta.version` — monotonically increasing version number (`int`)
|
|
129
|
+
- `meta.initialized` — `False` when the key has never been written to the server
|
|
130
|
+
- `meta.source` — origin of this callback invocation:
|
|
131
|
+
- `"cache"` — fired synchronously from `watch()` with an already-cached value
|
|
132
|
+
- `"init"` — the server's `STATE_INIT` baseline (initial load or post-reconnect)
|
|
133
|
+
- `"delta"` — an incremental mutation (`DELTA` frame)
|
|
134
|
+
|
|
135
|
+
Use `meta.source` — not `meta.initialized` — to distinguish the initial baseline from subsequent changes. `initialized` only tells you whether the key has ever been written on the server.
|
|
136
|
+
|
|
137
|
+
`callback` may be a plain function or an `async` function.
|
|
138
|
+
|
|
139
|
+
Returns an **unsubscribe callable**. Multiple `watch()` calls for the same key share a single server subscription.
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
def on_player(value, meta):
|
|
143
|
+
if not meta.initialized:
|
|
144
|
+
return
|
|
145
|
+
print(value["name"], value["health"])
|
|
146
|
+
|
|
147
|
+
unsub = client.watch("game.player", on_player)
|
|
148
|
+
|
|
149
|
+
# Async callback also works:
|
|
150
|
+
async def on_score(value, meta):
|
|
151
|
+
await db.update_score(value)
|
|
152
|
+
|
|
153
|
+
unsub2 = client.watch("game.score", on_score)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
### `client.state(key) → StateRef`
|
|
159
|
+
|
|
160
|
+
Returns a key-scoped handle for cleaner per-key code:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
player = client.state("game.player")
|
|
164
|
+
|
|
165
|
+
unsub = player.watch(lambda v, m: print(v))
|
|
166
|
+
|
|
167
|
+
await player.set({"name": "Alice", "health": 100, "position": {"x": 0, "y": 0}})
|
|
168
|
+
await player.patch({"health": 80}) # only health changes
|
|
169
|
+
await player.set_in("/position/x", 5) # atomic nested field update
|
|
170
|
+
await player.delete() # remove the key entirely
|
|
171
|
+
await player.presence("/alice", {"line": 1}) # session-lifetime path
|
|
172
|
+
|
|
173
|
+
current = player.get() # cached snapshot
|
|
174
|
+
player.unwatch() # cancel subscription
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
### `await client.call(method, args) → data`
|
|
180
|
+
|
|
181
|
+
Invoke a built-in server method:
|
|
182
|
+
|
|
183
|
+
| Method | Args | Effect |
|
|
184
|
+
|---|---|---|
|
|
185
|
+
| `"state.set"` | `{"state": key, "value": v}` | Replace full value |
|
|
186
|
+
| `"state.patch"` | `{"state": key, "patch": {...}}` | Deep-merge partial update |
|
|
187
|
+
| `"state.set_in"` | `{"state": key, "path": "/a/b", "value": v}` | Set nested field by JSON Pointer |
|
|
188
|
+
| `"state.delete"` | `{"state": key}` | Remove key entirely |
|
|
189
|
+
| `"state.presence"` | `{"state": key, "path": "/p", "value": v}` | Session-lifetime path |
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
await client.call("state.set", {"state": "game.score", "value": {"value": 0}})
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
### Convenience methods
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
await client.set("game.score", {"value": 42})
|
|
201
|
+
await client.patch("game.player", {"health": 80})
|
|
202
|
+
await client.presence("collab.cursors", "/alice", {"name": "Alice", "line": 3})
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
### `client.unwatch(key)`
|
|
208
|
+
|
|
209
|
+
Cancel the server subscription and clear all local state for a key.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
### `client.get(key) → Any`
|
|
214
|
+
|
|
215
|
+
Synchronously read the cached value without subscribing. Returns `None` if the key is not being watched or has no value.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
### `client.is_watching(key) → bool`
|
|
220
|
+
|
|
221
|
+
Returns `True` if this client has an active subscription for `key`.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
### `client.close()`
|
|
226
|
+
|
|
227
|
+
Gracefully close the connection and stop reconnecting.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Presence
|
|
232
|
+
|
|
233
|
+
Presence binds a nested path to the session lifetime. The server automatically removes it and notifies all watchers when the client disconnects for any reason — no ghost cursors or stale "online" flags:
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
# Bind cursor to this session — auto-removed if the process crashes or disconnects
|
|
237
|
+
await client.presence("collab.cursors", "/alice", {"name": "Alice", "line": 1})
|
|
238
|
+
|
|
239
|
+
# Or via StateRef:
|
|
240
|
+
cursors = client.state("collab.cursors")
|
|
241
|
+
await cursors.presence("/alice", {"name": "Alice", "line": 1})
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Auto-reconnect & RESUME
|
|
247
|
+
|
|
248
|
+
The client reconnects with exponential backoff (1 s → 2 s → 4 s → … → 30 s). After reconnecting:
|
|
249
|
+
|
|
250
|
+
- Keys with a known version send `RESUME` — the server replays missed deltas, then resumes live streaming
|
|
251
|
+
- Keys never seen yet send `WATCH` — you receive the current snapshot
|
|
252
|
+
|
|
253
|
+
No data is lost during short disconnections as long as the server's delta log is not full (1 000 deltas per key).
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## StateRef API summary
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
ref = client.state("my.key")
|
|
261
|
+
|
|
262
|
+
ref.watch(callback) # subscribe; returns unsub callable
|
|
263
|
+
ref.get() # cached value
|
|
264
|
+
ref.is_watching() # True if subscribed
|
|
265
|
+
ref.unwatch() # cancel subscription + clear local state
|
|
266
|
+
await ref.set(value) # replace full value
|
|
267
|
+
await ref.patch(partial) # deep-merge partial
|
|
268
|
+
await ref.set_in(path, val) # set nested field by JSON Pointer
|
|
269
|
+
await ref.delete() # remove key from server
|
|
270
|
+
await ref.presence(path, v) # session-lifetime path binding
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## FastAPI example
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
from contextlib import asynccontextmanager
|
|
279
|
+
from fastapi import FastAPI
|
|
280
|
+
from sodp import SodpClient
|
|
281
|
+
|
|
282
|
+
client: SodpClient
|
|
283
|
+
|
|
284
|
+
@asynccontextmanager
|
|
285
|
+
async def lifespan(app: FastAPI):
|
|
286
|
+
global client
|
|
287
|
+
client = SodpClient("ws://sodp-server:7777", token=os.environ["SODP_TOKEN"])
|
|
288
|
+
await client.ready
|
|
289
|
+
yield
|
|
290
|
+
client.close()
|
|
291
|
+
|
|
292
|
+
app = FastAPI(lifespan=lifespan)
|
|
293
|
+
|
|
294
|
+
@app.post("/score/{value}")
|
|
295
|
+
async def set_score(value: int):
|
|
296
|
+
await client.set("game.score", {"value": value})
|
|
297
|
+
return {"ok": True}
|
|
298
|
+
```
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# sodp
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/sodp/)
|
|
4
|
+
[](https://pypi.org/project/sodp/)
|
|
5
|
+
[](https://github.com/orkestri/SODP/blob/main/LICENSE)
|
|
6
|
+
|
|
7
|
+
Python asyncio client for the **State-Oriented Data Protocol (SODP)** — a WebSocket-based protocol for continuous state synchronization.
|
|
8
|
+
|
|
9
|
+
Instead of polling or request/response, SODP streams every change as a minimal delta to all connected subscribers. One mutation to a 100-field object sends exactly the changed fields.
|
|
10
|
+
|
|
11
|
+
→ [Protocol spec & server](https://github.com/orkestri/SODP)
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install sodp-client
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires **Python 3.11+** and a running asyncio event loop.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import asyncio
|
|
29
|
+
from sodp import SodpClient
|
|
30
|
+
|
|
31
|
+
async def main():
|
|
32
|
+
client = SodpClient("ws://localhost:7777")
|
|
33
|
+
await client.ready
|
|
34
|
+
|
|
35
|
+
# Subscribe
|
|
36
|
+
def on_player(value, meta):
|
|
37
|
+
print(f"player: {value} version={meta.version}")
|
|
38
|
+
|
|
39
|
+
unsub = client.watch("game.player", on_player)
|
|
40
|
+
|
|
41
|
+
# Mutate
|
|
42
|
+
await client.set("game.player", {"name": "Alice", "health": 100})
|
|
43
|
+
await client.patch("game.player", {"health": 80}) # only health changes
|
|
44
|
+
|
|
45
|
+
await asyncio.sleep(1)
|
|
46
|
+
|
|
47
|
+
unsub() # remove this callback
|
|
48
|
+
client.close() # close the connection
|
|
49
|
+
|
|
50
|
+
asyncio.run(main())
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Authentication
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
# Static token
|
|
59
|
+
client = SodpClient("wss://sodp.example.com", token="eyJhbG...")
|
|
60
|
+
|
|
61
|
+
# Dynamic token provider — called on every connect/reconnect
|
|
62
|
+
async def get_token() -> str:
|
|
63
|
+
async with aiohttp.ClientSession() as s:
|
|
64
|
+
return await (await s.get("/api/sodp-token")).text()
|
|
65
|
+
|
|
66
|
+
client = SodpClient("wss://sodp.example.com", token_provider=get_token)
|
|
67
|
+
|
|
68
|
+
# Sync provider is also accepted
|
|
69
|
+
client = SodpClient(url, token_provider=lambda: os.environ["SODP_TOKEN"])
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## API reference
|
|
75
|
+
|
|
76
|
+
### `SodpClient(url, *, ...)`
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
client = SodpClient(
|
|
80
|
+
url, # WebSocket URL, e.g. "ws://localhost:7777"
|
|
81
|
+
token=None, # static JWT string
|
|
82
|
+
token_provider=None, # callable → str | Awaitable[str]; supersedes token
|
|
83
|
+
reconnect=True, # auto-reconnect on disconnect
|
|
84
|
+
reconnect_delay=1.0, # base reconnect delay in seconds (doubles per attempt)
|
|
85
|
+
max_reconnect_delay=30.0, # maximum reconnect delay in seconds
|
|
86
|
+
on_connect=None, # called each time the connection is established
|
|
87
|
+
on_disconnect=None, # called each time the connection drops
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The client connects immediately in the background. Use `await client.ready` (or `await client`) to wait for the first successful authentication before sending commands.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
### `await client.ready`
|
|
96
|
+
|
|
97
|
+
Awaitable that resolves once the client is connected and authenticated. You can also `await client` directly:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
await client.ready # explicit
|
|
101
|
+
await client # same thing
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
### `client.watch(key, callback) → unsub`
|
|
107
|
+
|
|
108
|
+
Subscribe to a state key. `callback(value, meta)` fires on every update and immediately with the cached value if the key is already known.
|
|
109
|
+
|
|
110
|
+
- `value` — current state (any JSON-compatible type), or `None` if the key has no value yet
|
|
111
|
+
- `meta.version` — monotonically increasing version number (`int`)
|
|
112
|
+
- `meta.initialized` — `False` when the key has never been written to the server
|
|
113
|
+
- `meta.source` — origin of this callback invocation:
|
|
114
|
+
- `"cache"` — fired synchronously from `watch()` with an already-cached value
|
|
115
|
+
- `"init"` — the server's `STATE_INIT` baseline (initial load or post-reconnect)
|
|
116
|
+
- `"delta"` — an incremental mutation (`DELTA` frame)
|
|
117
|
+
|
|
118
|
+
Use `meta.source` — not `meta.initialized` — to distinguish the initial baseline from subsequent changes. `initialized` only tells you whether the key has ever been written on the server.
|
|
119
|
+
|
|
120
|
+
`callback` may be a plain function or an `async` function.
|
|
121
|
+
|
|
122
|
+
Returns an **unsubscribe callable**. Multiple `watch()` calls for the same key share a single server subscription.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
def on_player(value, meta):
|
|
126
|
+
if not meta.initialized:
|
|
127
|
+
return
|
|
128
|
+
print(value["name"], value["health"])
|
|
129
|
+
|
|
130
|
+
unsub = client.watch("game.player", on_player)
|
|
131
|
+
|
|
132
|
+
# Async callback also works:
|
|
133
|
+
async def on_score(value, meta):
|
|
134
|
+
await db.update_score(value)
|
|
135
|
+
|
|
136
|
+
unsub2 = client.watch("game.score", on_score)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
### `client.state(key) → StateRef`
|
|
142
|
+
|
|
143
|
+
Returns a key-scoped handle for cleaner per-key code:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
player = client.state("game.player")
|
|
147
|
+
|
|
148
|
+
unsub = player.watch(lambda v, m: print(v))
|
|
149
|
+
|
|
150
|
+
await player.set({"name": "Alice", "health": 100, "position": {"x": 0, "y": 0}})
|
|
151
|
+
await player.patch({"health": 80}) # only health changes
|
|
152
|
+
await player.set_in("/position/x", 5) # atomic nested field update
|
|
153
|
+
await player.delete() # remove the key entirely
|
|
154
|
+
await player.presence("/alice", {"line": 1}) # session-lifetime path
|
|
155
|
+
|
|
156
|
+
current = player.get() # cached snapshot
|
|
157
|
+
player.unwatch() # cancel subscription
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
### `await client.call(method, args) → data`
|
|
163
|
+
|
|
164
|
+
Invoke a built-in server method:
|
|
165
|
+
|
|
166
|
+
| Method | Args | Effect |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| `"state.set"` | `{"state": key, "value": v}` | Replace full value |
|
|
169
|
+
| `"state.patch"` | `{"state": key, "patch": {...}}` | Deep-merge partial update |
|
|
170
|
+
| `"state.set_in"` | `{"state": key, "path": "/a/b", "value": v}` | Set nested field by JSON Pointer |
|
|
171
|
+
| `"state.delete"` | `{"state": key}` | Remove key entirely |
|
|
172
|
+
| `"state.presence"` | `{"state": key, "path": "/p", "value": v}` | Session-lifetime path |
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
await client.call("state.set", {"state": "game.score", "value": {"value": 0}})
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
### Convenience methods
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
await client.set("game.score", {"value": 42})
|
|
184
|
+
await client.patch("game.player", {"health": 80})
|
|
185
|
+
await client.presence("collab.cursors", "/alice", {"name": "Alice", "line": 3})
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
### `client.unwatch(key)`
|
|
191
|
+
|
|
192
|
+
Cancel the server subscription and clear all local state for a key.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
### `client.get(key) → Any`
|
|
197
|
+
|
|
198
|
+
Synchronously read the cached value without subscribing. Returns `None` if the key is not being watched or has no value.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
### `client.is_watching(key) → bool`
|
|
203
|
+
|
|
204
|
+
Returns `True` if this client has an active subscription for `key`.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
### `client.close()`
|
|
209
|
+
|
|
210
|
+
Gracefully close the connection and stop reconnecting.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Presence
|
|
215
|
+
|
|
216
|
+
Presence binds a nested path to the session lifetime. The server automatically removes it and notifies all watchers when the client disconnects for any reason — no ghost cursors or stale "online" flags:
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
# Bind cursor to this session — auto-removed if the process crashes or disconnects
|
|
220
|
+
await client.presence("collab.cursors", "/alice", {"name": "Alice", "line": 1})
|
|
221
|
+
|
|
222
|
+
# Or via StateRef:
|
|
223
|
+
cursors = client.state("collab.cursors")
|
|
224
|
+
await cursors.presence("/alice", {"name": "Alice", "line": 1})
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Auto-reconnect & RESUME
|
|
230
|
+
|
|
231
|
+
The client reconnects with exponential backoff (1 s → 2 s → 4 s → … → 30 s). After reconnecting:
|
|
232
|
+
|
|
233
|
+
- Keys with a known version send `RESUME` — the server replays missed deltas, then resumes live streaming
|
|
234
|
+
- Keys never seen yet send `WATCH` — you receive the current snapshot
|
|
235
|
+
|
|
236
|
+
No data is lost during short disconnections as long as the server's delta log is not full (1 000 deltas per key).
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## StateRef API summary
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
ref = client.state("my.key")
|
|
244
|
+
|
|
245
|
+
ref.watch(callback) # subscribe; returns unsub callable
|
|
246
|
+
ref.get() # cached value
|
|
247
|
+
ref.is_watching() # True if subscribed
|
|
248
|
+
ref.unwatch() # cancel subscription + clear local state
|
|
249
|
+
await ref.set(value) # replace full value
|
|
250
|
+
await ref.patch(partial) # deep-merge partial
|
|
251
|
+
await ref.set_in(path, val) # set nested field by JSON Pointer
|
|
252
|
+
await ref.delete() # remove key from server
|
|
253
|
+
await ref.presence(path, v) # session-lifetime path binding
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## FastAPI example
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
from contextlib import asynccontextmanager
|
|
262
|
+
from fastapi import FastAPI
|
|
263
|
+
from sodp import SodpClient
|
|
264
|
+
|
|
265
|
+
client: SodpClient
|
|
266
|
+
|
|
267
|
+
@asynccontextmanager
|
|
268
|
+
async def lifespan(app: FastAPI):
|
|
269
|
+
global client
|
|
270
|
+
client = SodpClient("ws://sodp-server:7777", token=os.environ["SODP_TOKEN"])
|
|
271
|
+
await client.ready
|
|
272
|
+
yield
|
|
273
|
+
client.close()
|
|
274
|
+
|
|
275
|
+
app = FastAPI(lifespan=lifespan)
|
|
276
|
+
|
|
277
|
+
@app.post("/score/{value}")
|
|
278
|
+
async def set_score(value: int):
|
|
279
|
+
await client.set("game.score", {"value": value})
|
|
280
|
+
return {"ok": True}
|
|
281
|
+
```
|