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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/sodp)](https://pypi.org/project/sodp/)
21
+ [![Python](https://img.shields.io/pypi/pyversions/sodp)](https://pypi.org/project/sodp/)
22
+ [![license](https://img.shields.io/github/license/orkestri/SODP)](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
+ [![PyPI](https://img.shields.io/pypi/v/sodp)](https://pypi.org/project/sodp/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/sodp)](https://pypi.org/project/sodp/)
5
+ [![license](https://img.shields.io/github/license/orkestri/SODP)](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
+ ```