roomer-client 0.1.0__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,62 @@
1
+ # Rust build artifacts
2
+ target/
3
+ **/target/
4
+ **/*.rs.bk
5
+ *.rlib
6
+
7
+ # Go build artifacts & binaries
8
+ server_bin
9
+ /app/server_bin
10
+ bin/
11
+ **/bin/
12
+ *.exe
13
+ *.exe~
14
+ *.dll
15
+ *.so
16
+ *.dylib
17
+ *.test
18
+ *.out
19
+ *.prof
20
+
21
+ # Node / Web / Package managers (if any tooling is added)
22
+ node_modules/
23
+ dist/
24
+ .npm
25
+ npm-debug.log*
26
+ yarn-debug.log*
27
+ yarn-error.log*
28
+ .pnpm-debug.log*
29
+
30
+ # IDEs and Text Editors
31
+ .vscode/
32
+ !.vscode/settings.json
33
+ !.vscode/tasks.json
34
+ !.vscode/launch.json
35
+ !.vscode/extensions.json
36
+ .idea/
37
+ *.iml
38
+ *.sublime-workspace
39
+ *.sublime-project
40
+ *.swp
41
+ *.swo
42
+ *~
43
+
44
+ # Operating System Files
45
+ .DS_Store
46
+ .DS_Store?
47
+ ._*
48
+ .Spotlight-V100
49
+ .Trashes
50
+ ehthumbs.db
51
+ Thumbs.db
52
+
53
+ # Environment variables & local overrides
54
+ .env
55
+ .env.local
56
+ .env.*.local
57
+ docker-compose.override.yml
58
+
59
+ # TLA+ Model Checker (TLC) generated artifacts
60
+ spec/states/
61
+ MC.*
62
+ *.tla.dump
@@ -0,0 +1,297 @@
1
+ Metadata-Version: 2.5
2
+ Name: roomer-client
3
+ Version: 0.1.0
4
+ Summary: High-performance, room-based WebSocket client for Roomer with binary framing and presence synchronization
5
+ Author: Jon Cody
6
+ License-Expression: MIT
7
+ Keywords: asyncio,binary,clustering,realtime,rooms,websocket
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: websockets>=12.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
12
+ Requires-Dist: pytest>=8.0; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # `roomer-client` – Python Client SDK
16
+
17
+ [![PyPI Version](https://img.shields.io/pypi/v/roomer-client.svg?color=3776AB&logo=pypi&logoColor=white)](https://pypi.org/project/roomer-client/)
18
+ [![Python Version](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat&logo=python&logoColor=white)](https://www.python.org/)
19
+ [![AsyncIO](https://img.shields.io/badge/AsyncIO-Native-00599C?style=flat&logo=python&logoColor=white)](https://docs.python.org/3/library/asyncio.html)
20
+ [![Typing: Typed](https://img.shields.io/badge/Typing-PEP%20484%20%2F%20561-blue?style=flat)](https://peps.python.org/pep-0561/)
21
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE)
22
+
23
+ High-performance, asynchronous Python client for the Roomer WebSocket framework with zero-copy binary framing, automatic exponential reconnection with jitter, cluster-wide presence synchronization, and 100% wire protocol parity across Go, Rust, and Node.js servers.
24
+
25
+ > 📖 **For Wire Protocol specifications and Server documentation, see the [Root README](../../README.md).**
26
+
27
+ ---
28
+
29
+ ## 📦 Scope & Architecture
30
+
31
+ The `roomer-client` library provides an asynchronous, non-blocking interface for Python applications (FastAPI backends, AI/LLM streaming pipelines, data processing workers, CLI tools) to communicate over Roomer clusters.
32
+
33
+ ```text
34
+ +---------------------------------------------------+
35
+ | Python Application |
36
+ | (FastAPI / LangChain / PyTorch Worker) |
37
+ +-------------------------+-------------------------+
38
+ |
39
+ +-------------------------v-------------------------+
40
+ | Roomer Client SDK |
41
+ | - asyncio / websockets async connection manager |
42
+ | - Async / Sync Dual-Mode Event Emitter |
43
+ +-------------------+-------------------+-----------+
44
+ | |
45
+ +-------------v----+ +-----v-------------+
46
+ | Room Multiplexer | | Binary Wire Frame |
47
+ | (Presence & Acks)| | (struct.pack >I) |
48
+ +------------------+ +-------------------+
49
+ | |
50
+ +-------------------v-------------------v-----------+
51
+ | WebSocket Connection |
52
+ | (Auto-Reconnect with Exponential Jitter) |
53
+ +---------------------------------------------------+
54
+ ```
55
+
56
+ ---
57
+
58
+ ## ⚡ Key Features
59
+
60
+ - **High-Performance Binary Wire Framing**: Serializes and unpacks 5 big-endian length-prefixed fields via native `struct.pack(">I", ...)` with zero-copy `memoryview` slicing.
61
+ - **Dual-Mode Event Emitter**: Register event listeners as either standard synchronous functions (`def handler(...)`) or native coroutines (`async def handler(...)`).
62
+ - **Async Context Manager**: Native `async with roomer("ws://...") as root:` pattern for deterministic lifecycle management and cleanup.
63
+ - **Automatic Exponential Reconnection**: Recovers from abrupt socket disconnects with randomized jitter backoff while preserving room subscriptions across reconnects.
64
+ - **Cluster Presence Tracking**: Automatic handling of `join_ack` snapshots, `new_member` notifications, and `member_left` presence events.
65
+ - **Direct 1-to-1 Point-to-Point Unicast**: Route messages directly to specific client UUIDs across cluster nodes with $O(1)$ efficiency.
66
+
67
+ ---
68
+
69
+ ## 🚀 Installation
70
+
71
+ Install from PyPI:
72
+
73
+ ```bash
74
+ pip install roomer-client
75
+ ```
76
+
77
+ Or install in editable mode for local development:
78
+
79
+ ```bash
80
+ cd client/python
81
+ pip install -e ".[dev]"
82
+ ```
83
+
84
+ ---
85
+
86
+ ## 🧠 Quick Start
87
+
88
+ ```python
89
+ import asyncio
90
+ from roomer import roomer
91
+
92
+ async def main():
93
+ # Connect and auto-join the root room
94
+ async with roomer("ws://localhost:8080/ws") as root:
95
+ print(f"Connected to Roomer cluster! Client ID: {root.id}")
96
+
97
+ # Join a named room channel
98
+ lobby = root.join("lobby")
99
+
100
+ @lobby.on("open")
101
+ def on_open():
102
+ print(f"Joined lobby! Active members: {lobby.members()}")
103
+ lobby.send("chat", "Hello from Python!")
104
+
105
+ @lobby.on("chat")
106
+ def on_chat(payload: bytes, sender_id: str):
107
+ print(f"[{sender_id}]: {payload.decode('utf-8')}")
108
+
109
+ @lobby.on("new_member")
110
+ def on_new_member(member_id: str):
111
+ print(f"User joined lobby: {member_id}")
112
+
113
+ @lobby.on("member_left")
114
+ def on_member_left(member_id: str):
115
+ print(f"User left lobby: {member_id}")
116
+
117
+ # Keep running
118
+ await asyncio.Event().wait()
119
+
120
+ if __name__ == "__main__":
121
+ asyncio.run(main())
122
+ ```
123
+
124
+ ---
125
+
126
+ ## 🛠️ Real-World Recipes & Patterns
127
+
128
+ ### 1. Streaming AI / LLM Tokens into a Room
129
+ Stream token completions from OpenAI, Anthropic, or local HuggingFace/vLLM models in real-time to all clients subscribed to a room:
130
+
131
+ ```python
132
+ import asyncio
133
+ from roomer import roomer
134
+
135
+ async def stream_ai_response(prompt: str, room_name: str):
136
+ async with roomer("ws://localhost:8080/ws") as root:
137
+ ai_room = root.join(room_name)
138
+
139
+ # Simulated token generator (e.g. from vLLM or Ollama)
140
+ tokens = ["The", " future", " of", " real-time", " messaging", " is", " binary."]
141
+
142
+ for token in tokens:
143
+ ai_room.send("ai_token", token)
144
+ await asyncio.sleep(0.040) # 40ms token interval
145
+
146
+ ai_room.send("ai_complete", {"prompt": prompt, "status": "done"})
147
+
148
+ asyncio.run(stream_ai_response("Explain binary framing", "generation-101"))
149
+ ```
150
+
151
+ ---
152
+
153
+ ### 2. FastAPI Background Task Bridge
154
+ Publish real-time telemetry, job notifications, or database changes from a FastAPI backend to connected browser clients:
155
+
156
+ ```python
157
+ from contextlib import asynccontextmanager
158
+ from fastapi import FastAPI, BackgroundTasks
159
+ from roomer import RoomerClient
160
+
161
+ client = RoomerClient("ws://localhost:8080/ws")
162
+
163
+ @asynccontextmanager
164
+ async def lifespan(app: FastAPI):
165
+ # Connect Roomer client on FastAPI startup
166
+ await client.connect()
167
+ yield
168
+ # Gracefully close on shutdown
169
+ await client.close()
170
+
171
+ app = FastAPI(lifespan=lifespan)
172
+
173
+ @app.post("/notifications/broadcast")
174
+ async def notify_users(message: str, background_tasks: BackgroundTasks):
175
+ def send_broadcast():
176
+ alerts_room = client.get_room("system-alerts")
177
+ alerts_room.send("alert", {"message": message, "severity": "info"})
178
+
179
+ background_tasks.add_task(send_broadcast)
180
+ return {"status": "broadcast scheduled"}
181
+ ```
182
+
183
+ ---
184
+
185
+ ### 3. Targeted 1-to-1 Direct Unicast
186
+ Send direct private messages targeted at a specific client ID without broadcasting to the entire room:
187
+
188
+ ```python
189
+ import asyncio
190
+ from roomer import roomer
191
+
192
+ async def main():
193
+ async with roomer("ws://localhost:8080/ws") as root:
194
+ target_client_id = "038edeb7-7823-4537-92c0-ba479cc2329c"
195
+
196
+ @root.on("direct_message")
197
+ def on_dm(payload: bytes, sender_id: str):
198
+ print(f"[Private DM from {sender_id}]: {payload.decode('utf-8')}")
199
+
200
+ # Send direct point-to-point packet (dst=target_client_id)
201
+ root.send("direct_message", "Secret private message", dst=target_client_id)
202
+
203
+ await asyncio.sleep(2)
204
+
205
+ asyncio.run(main())
206
+ ```
207
+
208
+ ---
209
+
210
+ ### 4. Custom Reconnection Backoff Configuration
211
+ Fine-tune initial delay, backoff multiplier, and max backoff ceiling:
212
+
213
+ ```python
214
+ from roomer import roomer
215
+
216
+ # Configured for high-resilience environments
217
+ root_context = roomer(
218
+ "ws://localhost:8080/ws",
219
+ reconnect=True,
220
+ initial_delay=0.250, # Start at 250ms backoff
221
+ max_delay=10.0, # Max backoff ceiling of 10s
222
+ backoff_factor=2.0 # Double backoff duration on consecutive drops
223
+ )
224
+ ```
225
+
226
+ ---
227
+
228
+ ## 📚 API Reference
229
+
230
+ ### `roomer(url, **kwargs) -> RoomerContext`
231
+ Factory function creating an asynchronous context manager.
232
+
233
+ | Argument | Type | Default | Description |
234
+ |---|---|---|---|
235
+ | `url` | `str` | *Required* | WebSocket endpoint URL (e.g. `ws://localhost:8080/ws`). |
236
+ | `reconnect` | `bool` | `True` | Automatically reconnect on connection drop. |
237
+ | `initial_delay` | `float` | `0.5` | Initial backoff delay in seconds. |
238
+ | `max_delay` | `float` | `5.0` | Maximum reconnection delay ceiling in seconds. |
239
+ | `backoff_factor` | `float` | `1.5` | Backoff multiplier applied on consecutive failures. |
240
+
241
+ ---
242
+
243
+ ### `Room` Instance Properties & Methods
244
+
245
+ #### Properties
246
+ - **`room.name -> str`**: Channel name for this room instance.
247
+ - **`room.id -> str`**: Assigned connection UUID string.
248
+ - **`room.is_open -> bool`**: Returns `True` if room subscription is active.
249
+
250
+ #### Methods
251
+ | Method | Returns | Description |
252
+ |---|---|---|
253
+ | `room.members()` | `list[str]` | Shallow copy array of active member connection IDs. |
254
+ | `room.join(room_name)` | `Room` | Subscribes to another room channel over the active connection. |
255
+ | `room.leave()` | `Room` | Unsubscribes from the room and notifies the cluster. |
256
+ | `room.send(event, payload=None, dst="")` | `Room` | Sends a message packet to the room or directly to `dst`. |
257
+ | `room.on(event, listener)` | `Callable` | Subscribes a synchronous or asynchronous callback. Supports `@room.on(event)`. |
258
+ | `room.once(event, listener)` | `Callable` | Subscribes a one-time event callback. |
259
+ | `room.off(event, listener)` | `None` | Unsubscribes a registered listener callback. |
260
+ | `room.clear_listeners(exceptions=None)` | `Room` | Clears custom listeners except those listed in `exceptions`. |
261
+ | `room.force_close(is_disconnect=False)` | `Room` | Clears local member state and emits `"close"`. |
262
+ | `root.purge()` *(root only)* | `Room` | Unsubscribes from all non-root rooms simultaneously. |
263
+ | `root.rooms()` *(root only)* | `dict[str, Room]` | Dictionary mapping of all active room handles. |
264
+
265
+ ---
266
+
267
+ ### `Packet` Data Attributes & Helpers
268
+
269
+ Decoded binary packet object passed into event handlers:
270
+
271
+ | Attribute / Helper | Type | Description |
272
+ |---|---|---|
273
+ | `packet.room` | `str` | Target channel / room name. |
274
+ | `packet.event` | `str` | Event descriptor string. |
275
+ | `packet.dst` | `str` | Destination member ID (empty string if room broadcast). |
276
+ | `packet.src` | `str` | Sender client ID. |
277
+ | `packet.payload` | `bytes` | Raw binary payload bytes. |
278
+ | `packet.payload_text()` | `str` | Decodes payload as UTF-8 string. |
279
+ | `packet.payload_json()` | `Any` | Unmarshals binary payload as JSON. |
280
+
281
+ ---
282
+
283
+ ## 🧪 Testing & Verification
284
+
285
+ Run the test suite using `pytest`:
286
+
287
+ ```bash
288
+ cd client/python
289
+ pip install -e ".[dev]"
290
+ pytest -v
291
+ ```
292
+
293
+ ---
294
+
295
+ ## 📄 License
296
+
297
+ Roomer is open-source software licensed under the [MIT License](../../LICENSE).
@@ -0,0 +1,283 @@
1
+ # `roomer-client` – Python Client SDK
2
+
3
+ [![PyPI Version](https://img.shields.io/pypi/v/roomer-client.svg?color=3776AB&logo=pypi&logoColor=white)](https://pypi.org/project/roomer-client/)
4
+ [![Python Version](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat&logo=python&logoColor=white)](https://www.python.org/)
5
+ [![AsyncIO](https://img.shields.io/badge/AsyncIO-Native-00599C?style=flat&logo=python&logoColor=white)](https://docs.python.org/3/library/asyncio.html)
6
+ [![Typing: Typed](https://img.shields.io/badge/Typing-PEP%20484%20%2F%20561-blue?style=flat)](https://peps.python.org/pep-0561/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE)
8
+
9
+ High-performance, asynchronous Python client for the Roomer WebSocket framework with zero-copy binary framing, automatic exponential reconnection with jitter, cluster-wide presence synchronization, and 100% wire protocol parity across Go, Rust, and Node.js servers.
10
+
11
+ > 📖 **For Wire Protocol specifications and Server documentation, see the [Root README](../../README.md).**
12
+
13
+ ---
14
+
15
+ ## 📦 Scope & Architecture
16
+
17
+ The `roomer-client` library provides an asynchronous, non-blocking interface for Python applications (FastAPI backends, AI/LLM streaming pipelines, data processing workers, CLI tools) to communicate over Roomer clusters.
18
+
19
+ ```text
20
+ +---------------------------------------------------+
21
+ | Python Application |
22
+ | (FastAPI / LangChain / PyTorch Worker) |
23
+ +-------------------------+-------------------------+
24
+ |
25
+ +-------------------------v-------------------------+
26
+ | Roomer Client SDK |
27
+ | - asyncio / websockets async connection manager |
28
+ | - Async / Sync Dual-Mode Event Emitter |
29
+ +-------------------+-------------------+-----------+
30
+ | |
31
+ +-------------v----+ +-----v-------------+
32
+ | Room Multiplexer | | Binary Wire Frame |
33
+ | (Presence & Acks)| | (struct.pack >I) |
34
+ +------------------+ +-------------------+
35
+ | |
36
+ +-------------------v-------------------v-----------+
37
+ | WebSocket Connection |
38
+ | (Auto-Reconnect with Exponential Jitter) |
39
+ +---------------------------------------------------+
40
+ ```
41
+
42
+ ---
43
+
44
+ ## ⚡ Key Features
45
+
46
+ - **High-Performance Binary Wire Framing**: Serializes and unpacks 5 big-endian length-prefixed fields via native `struct.pack(">I", ...)` with zero-copy `memoryview` slicing.
47
+ - **Dual-Mode Event Emitter**: Register event listeners as either standard synchronous functions (`def handler(...)`) or native coroutines (`async def handler(...)`).
48
+ - **Async Context Manager**: Native `async with roomer("ws://...") as root:` pattern for deterministic lifecycle management and cleanup.
49
+ - **Automatic Exponential Reconnection**: Recovers from abrupt socket disconnects with randomized jitter backoff while preserving room subscriptions across reconnects.
50
+ - **Cluster Presence Tracking**: Automatic handling of `join_ack` snapshots, `new_member` notifications, and `member_left` presence events.
51
+ - **Direct 1-to-1 Point-to-Point Unicast**: Route messages directly to specific client UUIDs across cluster nodes with $O(1)$ efficiency.
52
+
53
+ ---
54
+
55
+ ## 🚀 Installation
56
+
57
+ Install from PyPI:
58
+
59
+ ```bash
60
+ pip install roomer-client
61
+ ```
62
+
63
+ Or install in editable mode for local development:
64
+
65
+ ```bash
66
+ cd client/python
67
+ pip install -e ".[dev]"
68
+ ```
69
+
70
+ ---
71
+
72
+ ## 🧠 Quick Start
73
+
74
+ ```python
75
+ import asyncio
76
+ from roomer import roomer
77
+
78
+ async def main():
79
+ # Connect and auto-join the root room
80
+ async with roomer("ws://localhost:8080/ws") as root:
81
+ print(f"Connected to Roomer cluster! Client ID: {root.id}")
82
+
83
+ # Join a named room channel
84
+ lobby = root.join("lobby")
85
+
86
+ @lobby.on("open")
87
+ def on_open():
88
+ print(f"Joined lobby! Active members: {lobby.members()}")
89
+ lobby.send("chat", "Hello from Python!")
90
+
91
+ @lobby.on("chat")
92
+ def on_chat(payload: bytes, sender_id: str):
93
+ print(f"[{sender_id}]: {payload.decode('utf-8')}")
94
+
95
+ @lobby.on("new_member")
96
+ def on_new_member(member_id: str):
97
+ print(f"User joined lobby: {member_id}")
98
+
99
+ @lobby.on("member_left")
100
+ def on_member_left(member_id: str):
101
+ print(f"User left lobby: {member_id}")
102
+
103
+ # Keep running
104
+ await asyncio.Event().wait()
105
+
106
+ if __name__ == "__main__":
107
+ asyncio.run(main())
108
+ ```
109
+
110
+ ---
111
+
112
+ ## 🛠️ Real-World Recipes & Patterns
113
+
114
+ ### 1. Streaming AI / LLM Tokens into a Room
115
+ Stream token completions from OpenAI, Anthropic, or local HuggingFace/vLLM models in real-time to all clients subscribed to a room:
116
+
117
+ ```python
118
+ import asyncio
119
+ from roomer import roomer
120
+
121
+ async def stream_ai_response(prompt: str, room_name: str):
122
+ async with roomer("ws://localhost:8080/ws") as root:
123
+ ai_room = root.join(room_name)
124
+
125
+ # Simulated token generator (e.g. from vLLM or Ollama)
126
+ tokens = ["The", " future", " of", " real-time", " messaging", " is", " binary."]
127
+
128
+ for token in tokens:
129
+ ai_room.send("ai_token", token)
130
+ await asyncio.sleep(0.040) # 40ms token interval
131
+
132
+ ai_room.send("ai_complete", {"prompt": prompt, "status": "done"})
133
+
134
+ asyncio.run(stream_ai_response("Explain binary framing", "generation-101"))
135
+ ```
136
+
137
+ ---
138
+
139
+ ### 2. FastAPI Background Task Bridge
140
+ Publish real-time telemetry, job notifications, or database changes from a FastAPI backend to connected browser clients:
141
+
142
+ ```python
143
+ from contextlib import asynccontextmanager
144
+ from fastapi import FastAPI, BackgroundTasks
145
+ from roomer import RoomerClient
146
+
147
+ client = RoomerClient("ws://localhost:8080/ws")
148
+
149
+ @asynccontextmanager
150
+ async def lifespan(app: FastAPI):
151
+ # Connect Roomer client on FastAPI startup
152
+ await client.connect()
153
+ yield
154
+ # Gracefully close on shutdown
155
+ await client.close()
156
+
157
+ app = FastAPI(lifespan=lifespan)
158
+
159
+ @app.post("/notifications/broadcast")
160
+ async def notify_users(message: str, background_tasks: BackgroundTasks):
161
+ def send_broadcast():
162
+ alerts_room = client.get_room("system-alerts")
163
+ alerts_room.send("alert", {"message": message, "severity": "info"})
164
+
165
+ background_tasks.add_task(send_broadcast)
166
+ return {"status": "broadcast scheduled"}
167
+ ```
168
+
169
+ ---
170
+
171
+ ### 3. Targeted 1-to-1 Direct Unicast
172
+ Send direct private messages targeted at a specific client ID without broadcasting to the entire room:
173
+
174
+ ```python
175
+ import asyncio
176
+ from roomer import roomer
177
+
178
+ async def main():
179
+ async with roomer("ws://localhost:8080/ws") as root:
180
+ target_client_id = "038edeb7-7823-4537-92c0-ba479cc2329c"
181
+
182
+ @root.on("direct_message")
183
+ def on_dm(payload: bytes, sender_id: str):
184
+ print(f"[Private DM from {sender_id}]: {payload.decode('utf-8')}")
185
+
186
+ # Send direct point-to-point packet (dst=target_client_id)
187
+ root.send("direct_message", "Secret private message", dst=target_client_id)
188
+
189
+ await asyncio.sleep(2)
190
+
191
+ asyncio.run(main())
192
+ ```
193
+
194
+ ---
195
+
196
+ ### 4. Custom Reconnection Backoff Configuration
197
+ Fine-tune initial delay, backoff multiplier, and max backoff ceiling:
198
+
199
+ ```python
200
+ from roomer import roomer
201
+
202
+ # Configured for high-resilience environments
203
+ root_context = roomer(
204
+ "ws://localhost:8080/ws",
205
+ reconnect=True,
206
+ initial_delay=0.250, # Start at 250ms backoff
207
+ max_delay=10.0, # Max backoff ceiling of 10s
208
+ backoff_factor=2.0 # Double backoff duration on consecutive drops
209
+ )
210
+ ```
211
+
212
+ ---
213
+
214
+ ## 📚 API Reference
215
+
216
+ ### `roomer(url, **kwargs) -> RoomerContext`
217
+ Factory function creating an asynchronous context manager.
218
+
219
+ | Argument | Type | Default | Description |
220
+ |---|---|---|---|
221
+ | `url` | `str` | *Required* | WebSocket endpoint URL (e.g. `ws://localhost:8080/ws`). |
222
+ | `reconnect` | `bool` | `True` | Automatically reconnect on connection drop. |
223
+ | `initial_delay` | `float` | `0.5` | Initial backoff delay in seconds. |
224
+ | `max_delay` | `float` | `5.0` | Maximum reconnection delay ceiling in seconds. |
225
+ | `backoff_factor` | `float` | `1.5` | Backoff multiplier applied on consecutive failures. |
226
+
227
+ ---
228
+
229
+ ### `Room` Instance Properties & Methods
230
+
231
+ #### Properties
232
+ - **`room.name -> str`**: Channel name for this room instance.
233
+ - **`room.id -> str`**: Assigned connection UUID string.
234
+ - **`room.is_open -> bool`**: Returns `True` if room subscription is active.
235
+
236
+ #### Methods
237
+ | Method | Returns | Description |
238
+ |---|---|---|
239
+ | `room.members()` | `list[str]` | Shallow copy array of active member connection IDs. |
240
+ | `room.join(room_name)` | `Room` | Subscribes to another room channel over the active connection. |
241
+ | `room.leave()` | `Room` | Unsubscribes from the room and notifies the cluster. |
242
+ | `room.send(event, payload=None, dst="")` | `Room` | Sends a message packet to the room or directly to `dst`. |
243
+ | `room.on(event, listener)` | `Callable` | Subscribes a synchronous or asynchronous callback. Supports `@room.on(event)`. |
244
+ | `room.once(event, listener)` | `Callable` | Subscribes a one-time event callback. |
245
+ | `room.off(event, listener)` | `None` | Unsubscribes a registered listener callback. |
246
+ | `room.clear_listeners(exceptions=None)` | `Room` | Clears custom listeners except those listed in `exceptions`. |
247
+ | `room.force_close(is_disconnect=False)` | `Room` | Clears local member state and emits `"close"`. |
248
+ | `root.purge()` *(root only)* | `Room` | Unsubscribes from all non-root rooms simultaneously. |
249
+ | `root.rooms()` *(root only)* | `dict[str, Room]` | Dictionary mapping of all active room handles. |
250
+
251
+ ---
252
+
253
+ ### `Packet` Data Attributes & Helpers
254
+
255
+ Decoded binary packet object passed into event handlers:
256
+
257
+ | Attribute / Helper | Type | Description |
258
+ |---|---|---|
259
+ | `packet.room` | `str` | Target channel / room name. |
260
+ | `packet.event` | `str` | Event descriptor string. |
261
+ | `packet.dst` | `str` | Destination member ID (empty string if room broadcast). |
262
+ | `packet.src` | `str` | Sender client ID. |
263
+ | `packet.payload` | `bytes` | Raw binary payload bytes. |
264
+ | `packet.payload_text()` | `str` | Decodes payload as UTF-8 string. |
265
+ | `packet.payload_json()` | `Any` | Unmarshals binary payload as JSON. |
266
+
267
+ ---
268
+
269
+ ## 🧪 Testing & Verification
270
+
271
+ Run the test suite using `pytest`:
272
+
273
+ ```bash
274
+ cd client/python
275
+ pip install -e ".[dev]"
276
+ pytest -v
277
+ ```
278
+
279
+ ---
280
+
281
+ ## 📄 License
282
+
283
+ Roomer is open-source software licensed under the [MIT License](../../LICENSE).
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "roomer-client"
7
+ version = "0.1.0"
8
+ description = "High-performance, room-based WebSocket client for Roomer with binary framing and presence synchronization"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [
12
+ { name = "Jon Cody" }
13
+ ]
14
+ requires-python = ">=3.10"
15
+ keywords = [
16
+ "websocket",
17
+ "realtime",
18
+ "rooms",
19
+ "binary",
20
+ "asyncio",
21
+ "clustering"
22
+ ]
23
+ dependencies = [
24
+ "websockets>=12.0"
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=8.0",
30
+ "pytest-asyncio>=0.23"
31
+ ]
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ include = [
35
+ "roomer.py",
36
+ ]
37
+
38
+ [tool.hatch.build.targets.sdist]
39
+ include = [
40
+ "roomer.py",
41
+ ]
42
+
43
+ [tool.pytest.ini_options]
44
+ asyncio_mode = "auto"
45
+ testpaths = ["tests"]
@@ -0,0 +1,565 @@
1
+ """
2
+ Roomer Python Client SDK.
3
+
4
+ High-performance, room-based WebSocket client with zero-copy binary framing,
5
+ exponential reconnection with jitter, presence synchronization, and full parity
6
+ with Go, Rust, and Node.js servers.
7
+
8
+ License: MIT
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import inspect
15
+ import json
16
+ import random
17
+ import struct
18
+ from collections import defaultdict
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass
21
+ from typing import Any
22
+
23
+ import websockets
24
+
25
+ HEADER_OVERHEAD: int = 20
26
+
27
+ RESERVED_EVENTS: frozenset[str] = frozenset({
28
+ "close",
29
+ "join",
30
+ "join_ack",
31
+ "leave",
32
+ "leave_ack",
33
+ "member_left",
34
+ "new_member",
35
+ "open"
36
+ })
37
+
38
+ Listener = Callable[..., Any]
39
+
40
+
41
+ # ==============================================================================
42
+ # 1. Wire Protocol & Binary Framing
43
+ # ==============================================================================
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Packet:
47
+ """Represents an immutable decoded Roomer binary wire frame."""
48
+ room: str
49
+ event: str
50
+ dst: str
51
+ src: str
52
+ payload: bytes
53
+
54
+ def payload_text(self, encoding: str = "utf-8") -> str:
55
+ """Decodes the binary payload as a text string."""
56
+ return self.payload.decode(encoding)
57
+
58
+ def payload_json(self) -> Any:
59
+ """Parses the binary payload as JSON."""
60
+ if not self.payload:
61
+ raise ValueError("Payload is empty")
62
+ return json.loads(self.payload.decode("utf-8"))
63
+
64
+
65
+ def encode_message(
66
+ room: str = "",
67
+ event: str = "",
68
+ dst: str = "",
69
+ src: str = "",
70
+ payload: bytes | bytearray | memoryview | str | dict | list | int | float | bool | None = None
71
+ ) -> bytes:
72
+ """
73
+ Serializes message parameters into a contiguous big-endian length-prefixed binary packet.
74
+ Format: [4B room_len][room][4B event_len][event][4B dst_len][dst][4B src_len][src][4B payload_len][payload]
75
+ """
76
+ room_bytes = room.encode("utf-8") if isinstance(room, str) else b""
77
+ event_bytes = event.encode("utf-8") if isinstance(event, str) else b""
78
+ dst_bytes = dst.encode("utf-8") if isinstance(dst, str) else b""
79
+ src_bytes = src.encode("utf-8") if isinstance(src, str) else b""
80
+
81
+ if payload is None:
82
+ payload_bytes = b""
83
+ elif isinstance(payload, bytes):
84
+ payload_bytes = payload
85
+ elif isinstance(payload, (bytearray, memoryview)):
86
+ payload_bytes = bytes(payload)
87
+ elif isinstance(payload, str):
88
+ payload_bytes = payload.encode("utf-8")
89
+ elif isinstance(payload, (dict, list)):
90
+ payload_bytes = json.dumps(payload).encode("utf-8")
91
+ elif isinstance(payload, (int, float, bool)):
92
+ payload_bytes = str(payload).encode("utf-8")
93
+ else:
94
+ payload_bytes = b""
95
+
96
+ fmt = f">I{len(room_bytes)}sI{len(event_bytes)}sI{len(dst_bytes)}sI{len(src_bytes)}sI{len(payload_bytes)}s"
97
+ return struct.pack(
98
+ fmt,
99
+ len(room_bytes),
100
+ room_bytes,
101
+ len(event_bytes),
102
+ event_bytes,
103
+ len(dst_bytes),
104
+ dst_bytes,
105
+ len(src_bytes),
106
+ src_bytes,
107
+ len(payload_bytes),
108
+ payload_bytes
109
+ )
110
+
111
+
112
+ def decode_message(data: bytes | bytearray | memoryview) -> Packet | None:
113
+ """
114
+ Decodes raw binary bytes into a Packet instance. Returns None on malformed input.
115
+ """
116
+ buf = memoryview(data)
117
+ if len(buf) < HEADER_OVERHEAD:
118
+ return None
119
+
120
+ offset = 0
121
+
122
+ try:
123
+ # 1. Room
124
+ (room_len,) = struct.unpack_from(">I", buf, offset)
125
+ offset += 4
126
+ if offset + room_len > len(buf):
127
+ return None
128
+ room = bytes(buf[offset : offset + room_len]).decode("utf-8")
129
+ offset += room_len
130
+
131
+ # 2. Event
132
+ if offset + 4 > len(buf):
133
+ return None
134
+ (event_len,) = struct.unpack_from(">I", buf, offset)
135
+ offset += 4
136
+ if offset + event_len > len(buf):
137
+ return None
138
+ event = bytes(buf[offset : offset + event_len]).decode("utf-8")
139
+ offset += event_len
140
+
141
+ # 3. Dst
142
+ if offset + 4 > len(buf):
143
+ return None
144
+ (dst_len,) = struct.unpack_from(">I", buf, offset)
145
+ offset += 4
146
+ if offset + dst_len > len(buf):
147
+ return None
148
+ dst = bytes(buf[offset : offset + dst_len]).decode("utf-8")
149
+ offset += dst_len
150
+
151
+ # 4. Src
152
+ if offset + 4 > len(buf):
153
+ return None
154
+ (src_len,) = struct.unpack_from(">I", buf, offset)
155
+ offset += 4
156
+ if offset + src_len > len(buf):
157
+ return None
158
+ src = bytes(buf[offset : offset + src_len]).decode("utf-8")
159
+ offset += src_len
160
+
161
+ # 5. Payload
162
+ if offset + 4 > len(buf):
163
+ return None
164
+ (payload_len,) = struct.unpack_from(">I", buf, offset)
165
+ offset += 4
166
+ if offset + payload_len != len(buf):
167
+ return None
168
+ payload = bytes(buf[offset : offset + payload_len])
169
+
170
+ return Packet(room=room, event=event, dst=dst, src=src, payload=payload)
171
+ except (struct.error, UnicodeDecodeError):
172
+ return None
173
+
174
+
175
+ # ==============================================================================
176
+ # 2. Async-Compatible Event Emitter
177
+ # ==============================================================================
178
+
179
+ class EventEmitter:
180
+ """Lightweight event emitter supporting both sync functions and async coroutines."""
181
+
182
+ def __init__(self) -> None:
183
+ self._events: dict[str, list[Listener]] = defaultdict(list)
184
+
185
+ def on(self, event: str, listener: Listener | None = None) -> Listener | Callable[[Listener], Listener]:
186
+ """Registers an event listener callback or decorator."""
187
+ def decorator(fn: Listener) -> Listener:
188
+ self._events[event].append(fn)
189
+ return fn
190
+
191
+ if listener is not None:
192
+ return decorator(listener)
193
+ return decorator
194
+
195
+ def once(self, event: str, listener: Listener | None = None) -> Listener | Callable[[Listener], Listener]:
196
+ """Registers a one-time event listener callback."""
197
+ def decorator(fn: Listener) -> Listener:
198
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
199
+ self.off(event, wrapper)
200
+ return fn(*args, **kwargs)
201
+
202
+ setattr(wrapper, "_original_fn", fn)
203
+ self._events[event].append(wrapper)
204
+ return fn
205
+
206
+ if listener is not None:
207
+ return decorator(listener)
208
+ return decorator
209
+
210
+ def off(self, event: str, listener: Listener) -> None:
211
+ """Removes a registered event listener callback."""
212
+ if event in self._events:
213
+ self._events[event] = [
214
+ fn for fn in self._events[event]
215
+ if fn != listener and getattr(fn, "_original_fn", None) != listener
216
+ ]
217
+ if not self._events[event]:
218
+ del self._events[event]
219
+
220
+ def emit(self, event: str, *args: Any, **kwargs: Any) -> bool:
221
+ """Synchronously invokes listeners, dispatching coroutines to the event loop."""
222
+ listeners = list(self._events.get(event, []))
223
+ if not listeners:
224
+ return False
225
+
226
+ for fn in listeners:
227
+ try:
228
+ res = fn(*args, **kwargs)
229
+ if inspect.isawaitable(res):
230
+ try:
231
+ loop = asyncio.get_running_loop()
232
+ loop.create_task(res)
233
+ except RuntimeError:
234
+ pass
235
+ except Exception as err:
236
+ print(f"[Roomer] Unhandled exception in listener for '{event}': {err}")
237
+
238
+ return True
239
+
240
+ def remove_all_listeners(self, event: str | None = None) -> None:
241
+ """Removes all registered listeners, or those for a specific event."""
242
+ if event is None:
243
+ self._events.clear()
244
+ elif event in self._events:
245
+ del self._events[event]
246
+
247
+ def listeners(self, event: str) -> list[Listener]:
248
+ """Returns a copy of registered listener callbacks for an event."""
249
+ return list(self._events.get(event, []))
250
+
251
+
252
+ # ==============================================================================
253
+ # 3. Room Channel Handle
254
+ # ==============================================================================
255
+
256
+ class Room(EventEmitter):
257
+ """Represents a room channel subscription over the WebSocket connection."""
258
+
259
+ def __init__(
260
+ self,
261
+ name: str,
262
+ send_packet_fn: Callable[[str, str, str, str, Any], None],
263
+ get_room_fn: Callable[[str], Room],
264
+ is_socket_open_fn: Callable[[], bool],
265
+ ) -> None:
266
+ super().__init__()
267
+ self.name = name
268
+ self._send_packet = send_packet_fn
269
+ self._get_room = get_room_fn
270
+ self._is_socket_open = is_socket_open_fn
271
+
272
+ self._member_id: str = ""
273
+ self._is_open: bool = False
274
+ self._members: list[str] = []
275
+ self._custom_events: set[str] = set()
276
+
277
+ @property
278
+ def id(self) -> str:
279
+ """Connection UUID string assigned by the server."""
280
+ return self._member_id
281
+
282
+ @property
283
+ def is_open(self) -> bool:
284
+ """Whether room membership is currently active."""
285
+ return self._is_open
286
+
287
+ def open(self) -> bool:
288
+ """Method alias for is_open."""
289
+ return self._is_open
290
+
291
+ def members(self) -> list[str]:
292
+ """Returns a shallow copy of active member IDs in this room."""
293
+ return list(self._members)
294
+
295
+ def join(self, room_name: str) -> Room:
296
+ """Subscribes to a new room channel over the existing connection."""
297
+ if not self._is_open:
298
+ raise RuntimeError("Cannot join: current room is closed.")
299
+ if not isinstance(room_name, str):
300
+ raise TypeError("Room name must be a string.")
301
+ return self._get_room(room_name)
302
+
303
+ def leave(self) -> Room:
304
+ """Leaves the current room and notifies the cluster."""
305
+ if not self._is_open:
306
+ raise RuntimeError("Cannot leave: room is closed.")
307
+ if self._is_socket_open():
308
+ self._send_packet(self.name, "leave", "", "", b"")
309
+ return self
310
+
311
+ def send(self, event: str, payload: Any = None, dst: str = "") -> Room:
312
+ """Sends an event message to the room or directly to a member ID."""
313
+ if not self._is_open:
314
+ raise RuntimeError("Cannot send: socket is closed.")
315
+ if not isinstance(event, str):
316
+ raise TypeError("Event name must be a string.")
317
+ if event in RESERVED_EVENTS:
318
+ raise ValueError(f"Cannot send reserved event: '{event}'")
319
+
320
+ if self._is_socket_open():
321
+ self._send_packet(self.name, event, dst, self._member_id, payload)
322
+ return self
323
+
324
+ def clear_listeners(self, exceptions: list[str] | set[str] | None = None) -> Room:
325
+ """Clears registered event listeners except those listed in exceptions."""
326
+ exc = set(exceptions or [])
327
+ for event_name in list(self._custom_events):
328
+ if event_name not in exc:
329
+ self.remove_all_listeners(event_name)
330
+ self._custom_events.discard(event_name)
331
+ return self
332
+
333
+ def force_close(self, is_disconnect: bool = False) -> Room:
334
+ """Closes the room locally and clears tracked state."""
335
+ if self._is_open:
336
+ self._is_open = False
337
+ self._members.clear()
338
+ self.emit("close")
339
+ if not is_disconnect:
340
+ self._member_id = ""
341
+ return self
342
+
343
+ def parse(self, packet: Packet) -> None:
344
+ """Dispatches an incoming parsed packet to room event listeners."""
345
+ match packet.event:
346
+ case "join_ack":
347
+ self._member_id = packet.src
348
+ self._members.clear()
349
+ try:
350
+ parsed = json.loads(packet.payload_text())
351
+ if isinstance(parsed, list):
352
+ self._members.extend(str(m) for m in parsed)
353
+ except Exception:
354
+ pass
355
+ self._is_open = True
356
+ self.emit("open")
357
+
358
+ case "new_member":
359
+ member_id = packet.payload_text()
360
+ if member_id and member_id not in self._members:
361
+ self._members.append(member_id)
362
+ self.emit("new_member", member_id)
363
+
364
+ case "leave_ack":
365
+ self.emit("close")
366
+ self._is_open = False
367
+ self._members.clear()
368
+ self._member_id = ""
369
+
370
+ case "member_left":
371
+ member_id = packet.payload_text()
372
+ if member_id in self._members:
373
+ self._members.remove(member_id)
374
+ self.emit("member_left", member_id)
375
+
376
+ case _:
377
+ self.emit(packet.event, packet.payload, packet.src)
378
+
379
+ def on(self, event: str, listener: Listener | None = None) -> Any:
380
+ if event not in RESERVED_EVENTS:
381
+ self._custom_events.add(event)
382
+ return super().on(event, listener)
383
+
384
+
385
+ # ==============================================================================
386
+ # 4. Connection Lifecycle & Context Manager
387
+ # ==============================================================================
388
+
389
+ class RoomerClient:
390
+ """Manages the underlying WebSocket connection and room multiplexing."""
391
+
392
+ def __init__(
393
+ self,
394
+ url: str,
395
+ reconnect: bool = True,
396
+ initial_delay: float = 0.5,
397
+ max_delay: float = 5.0,
398
+ backoff_factor: float = 1.5,
399
+ ) -> None:
400
+ if not isinstance(url, str):
401
+ raise TypeError("WebSocket URL must be a string.")
402
+
403
+ self.url = url
404
+ self.reconnect = reconnect
405
+ self.initial_delay = initial_delay
406
+ self.max_delay = max_delay
407
+ self.backoff_factor = backoff_factor
408
+
409
+ self._rooms: dict[str, Room] = {}
410
+ self._ws: Any = None
411
+ self._running: bool = False
412
+ self._manual_close: bool = False
413
+ self._task: asyncio.Task[None] | None = None
414
+ self._reconnect_delay: float = initial_delay
415
+
416
+ self._root = self.get_room("root")
417
+
418
+ @property
419
+ def root(self) -> Room:
420
+ """Returns the default 'root' room instance."""
421
+ return self._root
422
+
423
+ def get_room(self, name: str) -> Room:
424
+ """Retrieves or instantiates a room client interface by name."""
425
+ if not isinstance(name, str):
426
+ raise TypeError("Room name must be a string.")
427
+ if name in self._rooms:
428
+ return self._rooms[name]
429
+
430
+ room = Room(
431
+ name=name,
432
+ send_packet_fn=self._send_packet,
433
+ get_room_fn=self.get_room,
434
+ is_socket_open_fn=self.is_connected,
435
+ )
436
+
437
+ if name == "root":
438
+ def purge() -> Room:
439
+ for r_name in list(self._rooms.keys()):
440
+ if r_name != "root":
441
+ self._rooms[r_name].leave()
442
+ return room
443
+
444
+ def rooms_map() -> dict[str, Room]:
445
+ return dict(self._rooms)
446
+
447
+ setattr(room, "purge", purge)
448
+ setattr(room, "rooms", rooms_map)
449
+
450
+ self._rooms[name] = room
451
+
452
+ if name != "root" and self.is_connected():
453
+ self._send_packet(name, "join", "", "", b"")
454
+
455
+ return room
456
+
457
+ def is_connected(self) -> bool:
458
+ """Returns True if the underlying WebSocket connection is active."""
459
+ return self._ws is not None and not getattr(self._ws, "closed", False)
460
+
461
+ def _send_packet(
462
+ self,
463
+ room: str,
464
+ event: str,
465
+ dst: str,
466
+ src: str,
467
+ payload: Any
468
+ ) -> None:
469
+ """Serializes and transmits a binary frame over the WebSocket."""
470
+ if self.is_connected() and self._ws is not None:
471
+ raw = encode_message(room, event, dst, src, payload)
472
+ try:
473
+ loop = asyncio.get_running_loop()
474
+ loop.create_task(self._ws.send(raw))
475
+ except RuntimeError:
476
+ pass
477
+
478
+ async def connect(self) -> Room:
479
+ """Starts the background event loop and waits for root join_ack."""
480
+ if self._running:
481
+ return self._root
482
+
483
+ self._running = True
484
+ self._manual_close = False
485
+ self._task = asyncio.create_task(self._run_loop())
486
+
487
+ while self._running and not self._root.is_open:
488
+ await asyncio.sleep(0.010)
489
+
490
+ return self._root
491
+
492
+ async def _run_loop(self) -> None:
493
+ """Background connection supervisor with exponential backoff and jitter."""
494
+ while self._running:
495
+ try:
496
+ async with websockets.connect(self.url) as ws:
497
+ self._ws = ws
498
+ self._reconnect_delay = self.initial_delay
499
+
500
+ # Re-join all non-root active rooms upon reconnection
501
+ for r_name in list(self._rooms.keys()):
502
+ if r_name != "root":
503
+ raw = encode_message(r_name, "join", "", "", b"")
504
+ await ws.send(raw)
505
+
506
+ # Reader loop
507
+ async for raw_message in ws:
508
+ if isinstance(raw_message, (bytes, bytearray, memoryview)):
509
+ packet = decode_message(raw_message)
510
+ if packet is not None and packet.room in self._rooms:
511
+ self._rooms[packet.room].parse(packet)
512
+
513
+ except (websockets.exceptions.WebSocketException, OSError, asyncio.CancelledError):
514
+ pass
515
+ finally:
516
+ self._ws = None
517
+ is_reconnecting = self.reconnect and not self._manual_close and self._running
518
+ for r in list(self._rooms.values()):
519
+ r.force_close(is_disconnect=is_reconnecting)
520
+
521
+ if not self.reconnect or self._manual_close or not self._running:
522
+ break
523
+
524
+ jitter = random.uniform(0, 0.200)
525
+ await asyncio.sleep(self._reconnect_delay + jitter)
526
+ self._reconnect_delay = min(self._reconnect_delay * self.backoff_factor, self.max_delay)
527
+
528
+ async def close(self) -> None:
529
+ """Gracefully closes all rooms and the WebSocket connection."""
530
+ self._manual_close = True
531
+ self._running = False
532
+ if self._ws is not None:
533
+ await self._ws.close()
534
+ if self._task is not None:
535
+ self._task.cancel()
536
+ try:
537
+ await self._task
538
+ except asyncio.CancelledError:
539
+ pass
540
+ for r in list(self._rooms.values()):
541
+ r.force_close(is_disconnect=False)
542
+ self._rooms.clear()
543
+
544
+
545
+ class RoomerContext:
546
+ """Async context manager wrapper for Roomer."""
547
+
548
+ def __init__(self, url: str, **kwargs: Any) -> None:
549
+ self.client = RoomerClient(url, **kwargs)
550
+
551
+ async def __aenter__(self) -> Room:
552
+ await self.client.connect()
553
+ return self.client.root
554
+
555
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
556
+ await self.client.close()
557
+
558
+
559
+ def roomer(url: str, **kwargs: Any) -> RoomerContext:
560
+ """
561
+ Initializes a Roomer client instance. Supports async context manager:
562
+ async with roomer("ws://localhost:8080/ws") as root:
563
+ lobby = root.join("lobby")
564
+ """
565
+ return RoomerContext(url, **kwargs)