wsocket-io 0.1.0__py3-none-any.whl
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.
wsocket/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""wSocket SDK for Python — Realtime Pub/Sub client."""
|
|
2
|
+
|
|
3
|
+
from wsocket.client import WSocket, Channel, Presence, PubSubNamespace, PushClient
|
|
4
|
+
from wsocket.client import create_client
|
|
5
|
+
|
|
6
|
+
__all__ = ["WSocket", "Channel", "Presence", "PubSubNamespace", "PushClient", "create_client"]
|
|
7
|
+
__version__ = "0.1.0"
|
wsocket/client.py
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wSocket Python SDK — Async WebSocket Pub/Sub client.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
import asyncio
|
|
6
|
+
from wsocket import create_client
|
|
7
|
+
|
|
8
|
+
async def main():
|
|
9
|
+
client = create_client("ws://localhost:9001", "your-api-key")
|
|
10
|
+
await client.connect()
|
|
11
|
+
|
|
12
|
+
chat = client.channel("chat")
|
|
13
|
+
|
|
14
|
+
@chat.on_message
|
|
15
|
+
def handle(data, meta):
|
|
16
|
+
print(f"Received: {data} at {meta['timestamp']}")
|
|
17
|
+
|
|
18
|
+
chat.subscribe()
|
|
19
|
+
chat.publish({"text": "Hello from Python!"})
|
|
20
|
+
|
|
21
|
+
await asyncio.sleep(5)
|
|
22
|
+
await client.disconnect()
|
|
23
|
+
|
|
24
|
+
asyncio.run(main())
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import time
|
|
33
|
+
import uuid
|
|
34
|
+
import base64
|
|
35
|
+
from typing import Any, Callable, Optional
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
|
|
38
|
+
import websockets
|
|
39
|
+
from websockets.asyncio.client import connect as ws_connect
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger("wsocket")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ─── Types ──────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class MessageMeta:
|
|
48
|
+
id: str
|
|
49
|
+
channel: str
|
|
50
|
+
timestamp: float
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class PresenceMember:
|
|
55
|
+
client_id: str
|
|
56
|
+
data: dict[str, Any] | None = None
|
|
57
|
+
joined_at: float = 0.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class HistoryMessage:
|
|
62
|
+
id: str
|
|
63
|
+
channel: str
|
|
64
|
+
data: Any
|
|
65
|
+
publisher_id: str
|
|
66
|
+
timestamp: float
|
|
67
|
+
sequence: int = 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class HistoryResult:
|
|
72
|
+
channel: str
|
|
73
|
+
messages: list[HistoryMessage]
|
|
74
|
+
has_more: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class WSocketOptions:
|
|
79
|
+
"""Configuration options for the wSocket client."""
|
|
80
|
+
auto_reconnect: bool = True
|
|
81
|
+
max_reconnect_attempts: int = 10
|
|
82
|
+
reconnect_delay: float = 1.0
|
|
83
|
+
token: str | None = None
|
|
84
|
+
recover: bool = True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ─── Presence ────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
class Presence:
|
|
90
|
+
"""Presence API for a channel."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, channel_name: str, send_fn: Callable):
|
|
93
|
+
self._channel = channel_name
|
|
94
|
+
self._send = send_fn
|
|
95
|
+
self._enter_cbs: list[Callable] = []
|
|
96
|
+
self._leave_cbs: list[Callable] = []
|
|
97
|
+
self._update_cbs: list[Callable] = []
|
|
98
|
+
self._members_cbs: list[Callable] = []
|
|
99
|
+
|
|
100
|
+
def enter(self, data: dict[str, Any] | None = None) -> "Presence":
|
|
101
|
+
"""Enter the presence set with optional data."""
|
|
102
|
+
self._send({"action": "presence.enter", "channel": self._channel, "data": data})
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def leave(self) -> "Presence":
|
|
106
|
+
"""Leave the presence set."""
|
|
107
|
+
self._send({"action": "presence.leave", "channel": self._channel})
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def update(self, data: dict[str, Any]) -> "Presence":
|
|
111
|
+
"""Update presence data."""
|
|
112
|
+
self._send({"action": "presence.update", "channel": self._channel, "data": data})
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def get(self) -> "Presence":
|
|
116
|
+
"""Request current members list."""
|
|
117
|
+
self._send({"action": "presence.get", "channel": self._channel})
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
def on_enter(self, cb: Callable) -> "Presence":
|
|
121
|
+
self._enter_cbs.append(cb)
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def on_leave(self, cb: Callable) -> "Presence":
|
|
125
|
+
self._leave_cbs.append(cb)
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
def on_update(self, cb: Callable) -> "Presence":
|
|
129
|
+
self._update_cbs.append(cb)
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
def on_members(self, cb: Callable) -> "Presence":
|
|
133
|
+
self._members_cbs.append(cb)
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
def _emit_enter(self, member: PresenceMember) -> None:
|
|
137
|
+
for cb in self._enter_cbs:
|
|
138
|
+
try:
|
|
139
|
+
cb(member)
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
def _emit_leave(self, member: PresenceMember) -> None:
|
|
144
|
+
for cb in self._leave_cbs:
|
|
145
|
+
try:
|
|
146
|
+
cb(member)
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
def _emit_update(self, member: PresenceMember) -> None:
|
|
151
|
+
for cb in self._update_cbs:
|
|
152
|
+
try:
|
|
153
|
+
cb(member)
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
def _emit_members(self, members: list[PresenceMember]) -> None:
|
|
158
|
+
for cb in self._members_cbs:
|
|
159
|
+
try:
|
|
160
|
+
cb(members)
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ─── Channel ────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
class Channel:
|
|
168
|
+
"""Represents a pub/sub channel."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, name: str, send_fn: Callable):
|
|
171
|
+
self.name = name
|
|
172
|
+
self._send = send_fn
|
|
173
|
+
self._subscribed = False
|
|
174
|
+
self._message_cbs: list[Callable] = []
|
|
175
|
+
self._history_cbs: list[Callable] = []
|
|
176
|
+
self.presence = Presence(name, send_fn)
|
|
177
|
+
|
|
178
|
+
def subscribe(self, rewind: int | None = None) -> "Channel":
|
|
179
|
+
"""Subscribe to messages on this channel."""
|
|
180
|
+
if not self._subscribed:
|
|
181
|
+
msg: dict = {"action": "subscribe", "channel": self.name}
|
|
182
|
+
if rewind:
|
|
183
|
+
msg["rewind"] = rewind
|
|
184
|
+
self._send(msg)
|
|
185
|
+
self._subscribed = True
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
def unsubscribe(self) -> None:
|
|
189
|
+
"""Unsubscribe from this channel."""
|
|
190
|
+
self._send({"action": "unsubscribe", "channel": self.name})
|
|
191
|
+
self._subscribed = False
|
|
192
|
+
self._message_cbs.clear()
|
|
193
|
+
|
|
194
|
+
def publish(self, data: Any, persist: bool = True) -> "Channel":
|
|
195
|
+
"""Publish data to this channel."""
|
|
196
|
+
msg: dict = {
|
|
197
|
+
"action": "publish",
|
|
198
|
+
"channel": self.name,
|
|
199
|
+
"data": data,
|
|
200
|
+
"id": str(uuid.uuid4()),
|
|
201
|
+
}
|
|
202
|
+
if not persist:
|
|
203
|
+
msg["persist"] = False
|
|
204
|
+
self._send(msg)
|
|
205
|
+
return self
|
|
206
|
+
|
|
207
|
+
def history(
|
|
208
|
+
self,
|
|
209
|
+
limit: int | None = None,
|
|
210
|
+
before: float | None = None,
|
|
211
|
+
after: float | None = None,
|
|
212
|
+
direction: str | None = None,
|
|
213
|
+
) -> "Channel":
|
|
214
|
+
"""Query message history for this channel."""
|
|
215
|
+
msg: dict = {"action": "history", "channel": self.name}
|
|
216
|
+
if limit:
|
|
217
|
+
msg["limit"] = limit
|
|
218
|
+
if before:
|
|
219
|
+
msg["before"] = before
|
|
220
|
+
if after:
|
|
221
|
+
msg["after"] = after
|
|
222
|
+
if direction:
|
|
223
|
+
msg["direction"] = direction
|
|
224
|
+
self._send(msg)
|
|
225
|
+
return self
|
|
226
|
+
|
|
227
|
+
def on_message(self, cb: Callable) -> Callable:
|
|
228
|
+
"""Register a message callback. Can be used as a decorator."""
|
|
229
|
+
self._message_cbs.append(cb)
|
|
230
|
+
return cb
|
|
231
|
+
|
|
232
|
+
def on_history(self, cb: Callable) -> Callable:
|
|
233
|
+
"""Register a history result callback. Can be used as a decorator."""
|
|
234
|
+
self._history_cbs.append(cb)
|
|
235
|
+
return cb
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def is_subscribed(self) -> bool:
|
|
239
|
+
return self._subscribed
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def has_listeners(self) -> bool:
|
|
243
|
+
return len(self._message_cbs) > 0
|
|
244
|
+
|
|
245
|
+
def _emit(self, data: Any, meta: MessageMeta) -> None:
|
|
246
|
+
for cb in self._message_cbs:
|
|
247
|
+
try:
|
|
248
|
+
cb(data, meta)
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
def _emit_history(self, result: HistoryResult) -> None:
|
|
253
|
+
for cb in self._history_cbs:
|
|
254
|
+
try:
|
|
255
|
+
cb(result)
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
def _mark_for_resubscribe(self) -> None:
|
|
260
|
+
self._subscribed = False
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ─── PubSub Namespace ───────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
class PubSubNamespace:
|
|
266
|
+
"""Scoped API for pub/sub operations.
|
|
267
|
+
|
|
268
|
+
Usage: client.pubsub.channel('chat').subscribe()
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
def __init__(self, channel_fn: Callable[[str], Channel]):
|
|
272
|
+
self._channel_fn = channel_fn
|
|
273
|
+
|
|
274
|
+
def channel(self, name: str) -> Channel:
|
|
275
|
+
"""Get or create a channel (same as client.channel())."""
|
|
276
|
+
return self._channel_fn(name)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ─── wSocket Client ─────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
class WSocket:
|
|
282
|
+
"""Async WebSocket Pub/Sub client for wSocket."""
|
|
283
|
+
|
|
284
|
+
def __init__(self, url: str, api_key: str, options: WSocketOptions | None = None):
|
|
285
|
+
self.url = url
|
|
286
|
+
self.api_key = api_key
|
|
287
|
+
self.options = options or WSocketOptions()
|
|
288
|
+
self._ws: Any = None
|
|
289
|
+
self._state = "disconnected"
|
|
290
|
+
self._channels: dict[str, Channel] = {}
|
|
291
|
+
self._event_cbs: dict[str, list[Callable]] = {}
|
|
292
|
+
self._reconnect_attempts = 0
|
|
293
|
+
self._reconnect_task: asyncio.Task | None = None
|
|
294
|
+
self._recv_task: asyncio.Task | None = None
|
|
295
|
+
self._ping_task: asyncio.Task | None = None
|
|
296
|
+
self._last_message_ts: float = 0
|
|
297
|
+
self._resume_token: str | None = None
|
|
298
|
+
|
|
299
|
+
# Namespaces
|
|
300
|
+
self.pubsub = PubSubNamespace(self.channel)
|
|
301
|
+
self.push: PushClient | None = None
|
|
302
|
+
|
|
303
|
+
# ─── Connection ─────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
async def connect(self) -> None:
|
|
306
|
+
"""Connect to the wSocket server."""
|
|
307
|
+
if self._state == "connected":
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
self._set_state("connecting")
|
|
311
|
+
|
|
312
|
+
if self.options.token:
|
|
313
|
+
ws_url = f"{self.url}?token={self.options.token}"
|
|
314
|
+
else:
|
|
315
|
+
ws_url = f"{self.url}?key={self.api_key}"
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
self._ws = await ws_connect(ws_url)
|
|
319
|
+
self._set_state("connected")
|
|
320
|
+
self._reconnect_attempts = 0
|
|
321
|
+
self._recv_task = asyncio.create_task(self._receive_loop())
|
|
322
|
+
self._ping_task = asyncio.create_task(self._ping_loop())
|
|
323
|
+
self._resubscribe_all()
|
|
324
|
+
except Exception as e:
|
|
325
|
+
self._set_state("disconnected")
|
|
326
|
+
raise e
|
|
327
|
+
|
|
328
|
+
async def disconnect(self) -> None:
|
|
329
|
+
"""Disconnect from the server."""
|
|
330
|
+
self._set_state("disconnected")
|
|
331
|
+
if self._ping_task:
|
|
332
|
+
self._ping_task.cancel()
|
|
333
|
+
self._ping_task = None
|
|
334
|
+
if self._recv_task:
|
|
335
|
+
self._recv_task.cancel()
|
|
336
|
+
self._recv_task = None
|
|
337
|
+
if self._reconnect_task:
|
|
338
|
+
self._reconnect_task.cancel()
|
|
339
|
+
self._reconnect_task = None
|
|
340
|
+
if self._ws:
|
|
341
|
+
await self._ws.close()
|
|
342
|
+
self._ws = None
|
|
343
|
+
|
|
344
|
+
# ─── Channels ───────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
def channel(self, name: str) -> Channel:
|
|
347
|
+
"""Get or create a channel.
|
|
348
|
+
|
|
349
|
+
Deprecated: Use client.pubsub.channel(name) for new code.
|
|
350
|
+
"""
|
|
351
|
+
if name not in self._channels:
|
|
352
|
+
self._channels[name] = Channel(name, self._send)
|
|
353
|
+
return self._channels[name]
|
|
354
|
+
|
|
355
|
+
def configure_push(self, base_url: str, token: str, app_id: str) -> "PushClient":
|
|
356
|
+
"""Configure push notification access.
|
|
357
|
+
|
|
358
|
+
Example:
|
|
359
|
+
push = client.configure_push("http://localhost:9001", "api-key", "app-id")
|
|
360
|
+
await push.send_to_member("user-1", title="Hello", body="World")
|
|
361
|
+
"""
|
|
362
|
+
self.push = PushClient(base_url, token, app_id)
|
|
363
|
+
return self.push
|
|
364
|
+
|
|
365
|
+
# ─── Events ─────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
def on(self, event: str, callback: Callable) -> "WSocket":
|
|
368
|
+
"""Listen for client events: 'connected', 'disconnected', 'error', 'state'."""
|
|
369
|
+
if event not in self._event_cbs:
|
|
370
|
+
self._event_cbs[event] = []
|
|
371
|
+
self._event_cbs[event].append(callback)
|
|
372
|
+
return self
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def connection_state(self) -> str:
|
|
376
|
+
return self._state
|
|
377
|
+
|
|
378
|
+
# ─── Internal ───────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
def _send(self, msg: dict) -> None:
|
|
381
|
+
if self._ws:
|
|
382
|
+
try:
|
|
383
|
+
asyncio.get_event_loop().create_task(self._ws.send(json.dumps(msg)))
|
|
384
|
+
except RuntimeError:
|
|
385
|
+
pass
|
|
386
|
+
|
|
387
|
+
async def _receive_loop(self) -> None:
|
|
388
|
+
try:
|
|
389
|
+
async for raw in self._ws:
|
|
390
|
+
self._handle_message(raw)
|
|
391
|
+
except websockets.ConnectionClosed:
|
|
392
|
+
self._emit("disconnected")
|
|
393
|
+
if self._state != "disconnected" and self.options.auto_reconnect:
|
|
394
|
+
self._schedule_reconnect()
|
|
395
|
+
except asyncio.CancelledError:
|
|
396
|
+
pass
|
|
397
|
+
except Exception as e:
|
|
398
|
+
self._emit("error", e)
|
|
399
|
+
|
|
400
|
+
def _handle_message(self, raw: str) -> None:
|
|
401
|
+
try:
|
|
402
|
+
msg = json.loads(raw)
|
|
403
|
+
except json.JSONDecodeError:
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
action = msg.get("action")
|
|
407
|
+
|
|
408
|
+
if action == "message":
|
|
409
|
+
ch_name = msg.get("channel")
|
|
410
|
+
if ch_name and ch_name in self._channels:
|
|
411
|
+
ts = msg.get("timestamp", time.time() * 1000)
|
|
412
|
+
if ts > self._last_message_ts:
|
|
413
|
+
self._last_message_ts = ts
|
|
414
|
+
meta = MessageMeta(
|
|
415
|
+
id=msg.get("id", ""),
|
|
416
|
+
channel=ch_name,
|
|
417
|
+
timestamp=ts,
|
|
418
|
+
)
|
|
419
|
+
self._channels[ch_name]._emit(msg.get("data"), meta)
|
|
420
|
+
|
|
421
|
+
elif action == "subscribed":
|
|
422
|
+
self._emit("subscribed", msg.get("channel"))
|
|
423
|
+
|
|
424
|
+
elif action == "unsubscribed":
|
|
425
|
+
self._emit("unsubscribed", msg.get("channel"))
|
|
426
|
+
|
|
427
|
+
elif action == "ack":
|
|
428
|
+
if msg.get("id") == "resume" and isinstance(msg.get("data"), dict):
|
|
429
|
+
self._resume_token = msg["data"].get("resumeToken")
|
|
430
|
+
self._emit("ack", msg.get("id"), msg.get("channel"))
|
|
431
|
+
|
|
432
|
+
elif action == "error":
|
|
433
|
+
self._emit("error", Exception(msg.get("error", "Unknown error")))
|
|
434
|
+
|
|
435
|
+
elif action == "pong":
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
elif action == "presence.enter":
|
|
439
|
+
ch = self._channels.get(msg.get("channel", ""))
|
|
440
|
+
if ch:
|
|
441
|
+
member = self._parse_member(msg.get("data", {}))
|
|
442
|
+
ch.presence._emit_enter(member)
|
|
443
|
+
|
|
444
|
+
elif action == "presence.leave":
|
|
445
|
+
ch = self._channels.get(msg.get("channel", ""))
|
|
446
|
+
if ch:
|
|
447
|
+
member = self._parse_member(msg.get("data", {}))
|
|
448
|
+
ch.presence._emit_leave(member)
|
|
449
|
+
|
|
450
|
+
elif action == "presence.update":
|
|
451
|
+
ch = self._channels.get(msg.get("channel", ""))
|
|
452
|
+
if ch:
|
|
453
|
+
member = self._parse_member(msg.get("data", {}))
|
|
454
|
+
ch.presence._emit_update(member)
|
|
455
|
+
|
|
456
|
+
elif action == "presence.members":
|
|
457
|
+
ch = self._channels.get(msg.get("channel", ""))
|
|
458
|
+
if ch and isinstance(msg.get("data"), list):
|
|
459
|
+
members = [self._parse_member(m) for m in msg["data"]]
|
|
460
|
+
ch.presence._emit_members(members)
|
|
461
|
+
|
|
462
|
+
elif action == "history":
|
|
463
|
+
ch = self._channels.get(msg.get("channel", ""))
|
|
464
|
+
if ch and isinstance(msg.get("data"), dict):
|
|
465
|
+
result = self._parse_history(msg["data"])
|
|
466
|
+
ch._emit_history(result)
|
|
467
|
+
|
|
468
|
+
@staticmethod
|
|
469
|
+
def _parse_member(data: dict) -> PresenceMember:
|
|
470
|
+
return PresenceMember(
|
|
471
|
+
client_id=data.get("clientId", ""),
|
|
472
|
+
data=data.get("data"),
|
|
473
|
+
joined_at=data.get("joinedAt", 0),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
@staticmethod
|
|
477
|
+
def _parse_history(data: dict) -> HistoryResult:
|
|
478
|
+
messages = []
|
|
479
|
+
for m in data.get("messages", []):
|
|
480
|
+
messages.append(HistoryMessage(
|
|
481
|
+
id=m.get("id", ""),
|
|
482
|
+
channel=m.get("channel", ""),
|
|
483
|
+
data=m.get("data"),
|
|
484
|
+
publisher_id=m.get("publisherId", ""),
|
|
485
|
+
timestamp=m.get("timestamp", 0),
|
|
486
|
+
sequence=m.get("sequence", 0),
|
|
487
|
+
))
|
|
488
|
+
return HistoryResult(
|
|
489
|
+
channel=data.get("channel", ""),
|
|
490
|
+
messages=messages,
|
|
491
|
+
has_more=data.get("hasMore", False),
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def _set_state(self, state: str) -> None:
|
|
495
|
+
prev = self._state
|
|
496
|
+
self._state = state
|
|
497
|
+
if prev != state:
|
|
498
|
+
self._emit("state", state, prev)
|
|
499
|
+
if state == "connected":
|
|
500
|
+
self._emit("connected")
|
|
501
|
+
|
|
502
|
+
def _emit(self, event: str, *args: Any) -> None:
|
|
503
|
+
cbs = self._event_cbs.get(event, [])
|
|
504
|
+
for cb in cbs:
|
|
505
|
+
try:
|
|
506
|
+
cb(*args)
|
|
507
|
+
except Exception:
|
|
508
|
+
pass
|
|
509
|
+
|
|
510
|
+
def _resubscribe_all(self) -> None:
|
|
511
|
+
# Connection state recovery: use resume
|
|
512
|
+
if self.options.recover and self._last_message_ts > 0:
|
|
513
|
+
channel_names = [
|
|
514
|
+
ch.name for ch in self._channels.values() if ch.has_listeners
|
|
515
|
+
]
|
|
516
|
+
if channel_names:
|
|
517
|
+
token_data = json.dumps({
|
|
518
|
+
"channels": channel_names,
|
|
519
|
+
"lastTs": self._last_message_ts,
|
|
520
|
+
})
|
|
521
|
+
token = self._resume_token or base64.urlsafe_b64encode(
|
|
522
|
+
token_data.encode()
|
|
523
|
+
).decode().rstrip("=")
|
|
524
|
+
self._send({"action": "resume", "resumeToken": token})
|
|
525
|
+
for ch in self._channels.values():
|
|
526
|
+
if ch.has_listeners:
|
|
527
|
+
ch._mark_for_resubscribe()
|
|
528
|
+
return
|
|
529
|
+
|
|
530
|
+
# Fallback: simple resubscribe
|
|
531
|
+
for ch in self._channels.values():
|
|
532
|
+
if ch.has_listeners:
|
|
533
|
+
ch._mark_for_resubscribe()
|
|
534
|
+
ch.subscribe()
|
|
535
|
+
|
|
536
|
+
def _schedule_reconnect(self) -> None:
|
|
537
|
+
if self._reconnect_attempts >= self.options.max_reconnect_attempts:
|
|
538
|
+
self._set_state("disconnected")
|
|
539
|
+
self._emit("error", Exception("Max reconnect attempts reached"))
|
|
540
|
+
return
|
|
541
|
+
|
|
542
|
+
self._set_state("reconnecting")
|
|
543
|
+
delay = self.options.reconnect_delay * (2 ** self._reconnect_attempts)
|
|
544
|
+
self._reconnect_attempts += 1
|
|
545
|
+
|
|
546
|
+
async def _reconnect():
|
|
547
|
+
await asyncio.sleep(delay)
|
|
548
|
+
try:
|
|
549
|
+
await self.connect()
|
|
550
|
+
except Exception:
|
|
551
|
+
pass # will retry via close handler
|
|
552
|
+
|
|
553
|
+
self._reconnect_task = asyncio.create_task(_reconnect())
|
|
554
|
+
|
|
555
|
+
async def _ping_loop(self) -> None:
|
|
556
|
+
try:
|
|
557
|
+
while self._state == "connected":
|
|
558
|
+
await asyncio.sleep(30)
|
|
559
|
+
self._send({"action": "ping"})
|
|
560
|
+
except asyncio.CancelledError:
|
|
561
|
+
pass
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# ─── Factory ────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
def create_client(
|
|
567
|
+
url: str,
|
|
568
|
+
api_key: str,
|
|
569
|
+
options: WSocketOptions | None = None,
|
|
570
|
+
) -> WSocket:
|
|
571
|
+
"""
|
|
572
|
+
Create a new wSocket client.
|
|
573
|
+
|
|
574
|
+
Example:
|
|
575
|
+
client = create_client("ws://localhost:9001", "your-api-key")
|
|
576
|
+
await client.connect()
|
|
577
|
+
|
|
578
|
+
chat = client.channel("chat")
|
|
579
|
+
|
|
580
|
+
@chat.on_message
|
|
581
|
+
def handle(data, meta):
|
|
582
|
+
print(f"Received: {data}")
|
|
583
|
+
|
|
584
|
+
chat.subscribe()
|
|
585
|
+
chat.publish({"text": "Hello, world!"})
|
|
586
|
+
"""
|
|
587
|
+
return WSocket(url, api_key, options)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# ─── Push Notifications ─────────────────────────────────────
|
|
591
|
+
|
|
592
|
+
class PushClient:
|
|
593
|
+
"""REST-based push notification client for wSocket.
|
|
594
|
+
|
|
595
|
+
Supports registering device tokens (FCM/APNs) and sending push notifications.
|
|
596
|
+
|
|
597
|
+
Example:
|
|
598
|
+
push = PushClient("http://localhost:9001", "admin-jwt-token", "app-id")
|
|
599
|
+
await push.register_fcm("device-token-123", member_id="user1")
|
|
600
|
+
await push.send_to_member("user1", title="Hello", body="World")
|
|
601
|
+
"""
|
|
602
|
+
|
|
603
|
+
def __init__(self, base_url: str, token: str, app_id: str):
|
|
604
|
+
self._base_url = base_url.rstrip("/")
|
|
605
|
+
self._token = token
|
|
606
|
+
self._app_id = app_id
|
|
607
|
+
|
|
608
|
+
async def _api(self, method: str, path: str, body: dict | None = None) -> dict:
|
|
609
|
+
import aiohttp
|
|
610
|
+
|
|
611
|
+
url = f"{self._base_url}{path}"
|
|
612
|
+
headers = {
|
|
613
|
+
"Content-Type": "application/json",
|
|
614
|
+
"Authorization": f"Bearer {self._token}",
|
|
615
|
+
}
|
|
616
|
+
async with aiohttp.ClientSession() as session:
|
|
617
|
+
async with session.request(method, url, headers=headers, json=body) as resp:
|
|
618
|
+
if resp.status >= 400:
|
|
619
|
+
text = await resp.text()
|
|
620
|
+
raise Exception(f"Push API error {resp.status}: {text}")
|
|
621
|
+
return await resp.json()
|
|
622
|
+
|
|
623
|
+
async def get_vapid_key(self) -> str | None:
|
|
624
|
+
"""Get the VAPID public key for Web Push subscription."""
|
|
625
|
+
res = await self._api("GET", f"/api/admin/apps/{self._app_id}/push/config")
|
|
626
|
+
return res.get("vapidPublicKey")
|
|
627
|
+
|
|
628
|
+
async def register_fcm(self, device_token: str, member_id: str | None = None) -> str:
|
|
629
|
+
"""Register an FCM device token (Android)."""
|
|
630
|
+
res = await self._api("POST", f"/api/admin/apps/{self._app_id}/push/register", {
|
|
631
|
+
"platform": "fcm",
|
|
632
|
+
"memberId": member_id,
|
|
633
|
+
"deviceToken": device_token,
|
|
634
|
+
})
|
|
635
|
+
return res["subscriptionId"]
|
|
636
|
+
|
|
637
|
+
async def register_apns(self, device_token: str, member_id: str | None = None) -> str:
|
|
638
|
+
"""Register an APNs device token (iOS)."""
|
|
639
|
+
res = await self._api("POST", f"/api/admin/apps/{self._app_id}/push/register", {
|
|
640
|
+
"platform": "apns",
|
|
641
|
+
"memberId": member_id,
|
|
642
|
+
"deviceToken": device_token,
|
|
643
|
+
})
|
|
644
|
+
return res["subscriptionId"]
|
|
645
|
+
|
|
646
|
+
async def register_web_push(
|
|
647
|
+
self,
|
|
648
|
+
endpoint: str,
|
|
649
|
+
keys: dict,
|
|
650
|
+
member_id: str | None = None,
|
|
651
|
+
) -> str:
|
|
652
|
+
"""Register a Web Push subscription."""
|
|
653
|
+
res = await self._api("POST", f"/api/admin/apps/{self._app_id}/push/register", {
|
|
654
|
+
"platform": "web",
|
|
655
|
+
"memberId": member_id,
|
|
656
|
+
"webPush": {"endpoint": endpoint, "keys": keys},
|
|
657
|
+
})
|
|
658
|
+
return res["subscriptionId"]
|
|
659
|
+
|
|
660
|
+
async def unregister(self, member_id: str, platform: str | None = None) -> int:
|
|
661
|
+
"""Unregister push subscriptions for a member."""
|
|
662
|
+
res = await self._api("DELETE", f"/api/admin/apps/{self._app_id}/push/unregister", {
|
|
663
|
+
"memberId": member_id,
|
|
664
|
+
"platform": platform,
|
|
665
|
+
})
|
|
666
|
+
return res["removed"]
|
|
667
|
+
|
|
668
|
+
async def send_to_member(self, member_id: str, **payload) -> dict:
|
|
669
|
+
"""Send a push notification to a specific member."""
|
|
670
|
+
return await self._api("POST", f"/api/admin/apps/{self._app_id}/push/send", {
|
|
671
|
+
"memberId": member_id,
|
|
672
|
+
**payload,
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
async def send_to_members(self, member_ids: list[str], **payload) -> dict:
|
|
676
|
+
"""Send a push notification to multiple members."""
|
|
677
|
+
return await self._api("POST", f"/api/admin/apps/{self._app_id}/push/send", {
|
|
678
|
+
"memberIds": member_ids,
|
|
679
|
+
**payload,
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
async def broadcast(self, **payload) -> dict:
|
|
683
|
+
"""Broadcast a push notification to all subscribers."""
|
|
684
|
+
return await self._api("POST", f"/api/admin/apps/{self._app_id}/push/send", {
|
|
685
|
+
"broadcast": True,
|
|
686
|
+
**payload,
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
async def get_stats(self) -> dict:
|
|
690
|
+
"""Get push notification statistics."""
|
|
691
|
+
res = await self._api("GET", f"/api/admin/apps/{self._app_id}/push/stats")
|
|
692
|
+
return res["stats"]
|
|
693
|
+
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wsocket-io
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: wSocket Realtime Pub/Sub SDK for Python
|
|
5
|
+
Author-email: wSocket <dev@wsocket.io>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://wsocket.io
|
|
8
|
+
Project-URL: Repository, https://github.com/wsocket-io/sdk-python
|
|
9
|
+
Project-URL: Documentation, https://docs.wsocket.io
|
|
10
|
+
Keywords: websocket,pubsub,realtime,wsocket
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: websockets>=12.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# wSocket Python SDK
|
|
32
|
+
|
|
33
|
+
Official Python SDK for [wSocket](https://wsocket.io) — Realtime Pub/Sub over WebSockets.
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/wsocket-io/)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install wsocket-io
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
from wsocket import create_client
|
|
49
|
+
|
|
50
|
+
async def main():
|
|
51
|
+
client = create_client("wss://your-server.com", "your-api-key")
|
|
52
|
+
await client.connect()
|
|
53
|
+
|
|
54
|
+
chat = client.channel("chat:general")
|
|
55
|
+
|
|
56
|
+
@chat.on_message
|
|
57
|
+
def handle(data, meta):
|
|
58
|
+
print(f"[{meta.channel}] {data}")
|
|
59
|
+
|
|
60
|
+
chat.subscribe()
|
|
61
|
+
chat.publish({"text": "Hello from Python!"})
|
|
62
|
+
|
|
63
|
+
await asyncio.sleep(5)
|
|
64
|
+
await client.disconnect()
|
|
65
|
+
|
|
66
|
+
asyncio.run(main())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Features
|
|
70
|
+
|
|
71
|
+
- **Pub/Sub** — Subscribe and publish to channels in real-time
|
|
72
|
+
- **Presence** — Track who is online in a channel
|
|
73
|
+
- **History** — Retrieve past messages
|
|
74
|
+
- **Connection Recovery** — Automatic reconnection with message replay
|
|
75
|
+
- **Async/Await** — Built on `asyncio` and `websockets`
|
|
76
|
+
|
|
77
|
+
## Presence
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
chat = client.channel("chat:general")
|
|
81
|
+
|
|
82
|
+
@chat.presence.on_enter
|
|
83
|
+
def user_joined(member):
|
|
84
|
+
print(f"Joined: {member.client_id}")
|
|
85
|
+
|
|
86
|
+
@chat.presence.on_leave
|
|
87
|
+
def user_left(member):
|
|
88
|
+
print(f"Left: {member.client_id}")
|
|
89
|
+
|
|
90
|
+
chat.presence.enter({"name": "Alice"})
|
|
91
|
+
members = chat.presence.get()
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## History
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
@chat.on_history
|
|
98
|
+
def handle_history(result):
|
|
99
|
+
for msg in result.messages:
|
|
100
|
+
print(f"[{msg['timestamp']}] {msg['data']}")
|
|
101
|
+
|
|
102
|
+
chat.history(limit=50)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Requirements
|
|
106
|
+
|
|
107
|
+
- Python >= 3.9
|
|
108
|
+
- `websockets >= 12.0`
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pip install -e ".[dev]"
|
|
114
|
+
pytest
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
wsocket/__init__.py,sha256=qXBm6uLUFKNvYG5Uc0cP-clne2TWGqJ2YLKDK3S8Ckc,301
|
|
2
|
+
wsocket/client.py,sha256=NnSxOn1Lyi539emGQsjnMKBfwREewzg_nRwkAxdfp2U,23323
|
|
3
|
+
wsocket_io-0.1.0.dist-info/licenses/LICENSE,sha256=R30Ok0N77NElbcRoaeocSjSTcNOb6D4plxhBdPes6KU,1064
|
|
4
|
+
wsocket_io-0.1.0.dist-info/METADATA,sha256=zDydvS3TxJ6yv9yCcI_N1JsetGGB2N3KqtTK46mRs0o,2898
|
|
5
|
+
wsocket_io-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
6
|
+
wsocket_io-0.1.0.dist-info/top_level.txt,sha256=GErjUc43fGDkIncmDbHQZKyo4Y5zv2XtuiQgrdQeZYk,8
|
|
7
|
+
wsocket_io-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wSocket
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wsocket
|