wsocket-io 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,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,3 @@
1
+ include LICENSE
2
+ include README.md
3
+ recursive-include wsocket *.py
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/wsocket-io)](https://pypi.org/project/wsocket-io/)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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,89 @@
1
+ # wSocket Python SDK
2
+
3
+ Official Python SDK for [wSocket](https://wsocket.io) — Realtime Pub/Sub over WebSockets.
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/wsocket-io)](https://pypi.org/project/wsocket-io/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pip install wsocket-io
12
+ ```
13
+
14
+ ## Quick Start
15
+
16
+ ```python
17
+ import asyncio
18
+ from wsocket import create_client
19
+
20
+ async def main():
21
+ client = create_client("wss://your-server.com", "your-api-key")
22
+ await client.connect()
23
+
24
+ chat = client.channel("chat:general")
25
+
26
+ @chat.on_message
27
+ def handle(data, meta):
28
+ print(f"[{meta.channel}] {data}")
29
+
30
+ chat.subscribe()
31
+ chat.publish({"text": "Hello from Python!"})
32
+
33
+ await asyncio.sleep(5)
34
+ await client.disconnect()
35
+
36
+ asyncio.run(main())
37
+ ```
38
+
39
+ ## Features
40
+
41
+ - **Pub/Sub** — Subscribe and publish to channels in real-time
42
+ - **Presence** — Track who is online in a channel
43
+ - **History** — Retrieve past messages
44
+ - **Connection Recovery** — Automatic reconnection with message replay
45
+ - **Async/Await** — Built on `asyncio` and `websockets`
46
+
47
+ ## Presence
48
+
49
+ ```python
50
+ chat = client.channel("chat:general")
51
+
52
+ @chat.presence.on_enter
53
+ def user_joined(member):
54
+ print(f"Joined: {member.client_id}")
55
+
56
+ @chat.presence.on_leave
57
+ def user_left(member):
58
+ print(f"Left: {member.client_id}")
59
+
60
+ chat.presence.enter({"name": "Alice"})
61
+ members = chat.presence.get()
62
+ ```
63
+
64
+ ## History
65
+
66
+ ```python
67
+ @chat.on_history
68
+ def handle_history(result):
69
+ for msg in result.messages:
70
+ print(f"[{msg['timestamp']}] {msg['data']}")
71
+
72
+ chat.history(limit=50)
73
+ ```
74
+
75
+ ## Requirements
76
+
77
+ - Python >= 3.9
78
+ - `websockets >= 12.0`
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ pip install -e ".[dev]"
84
+ pytest
85
+ ```
86
+
87
+ ## License
88
+
89
+ MIT
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "wsocket-io"
7
+ version = "0.1.0"
8
+ description = "wSocket Realtime Pub/Sub SDK for Python"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ {name = "wSocket", email = "dev@wsocket.io"},
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Internet :: WWW/HTTP",
26
+ "Topic :: Software Development :: Libraries",
27
+ ]
28
+ keywords = ["websocket", "pubsub", "realtime", "wsocket"]
29
+ dependencies = [
30
+ "websockets>=12.0",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://wsocket.io"
35
+ Repository = "https://github.com/wsocket-io/sdk-python"
36
+ Documentation = "https://docs.wsocket.io"
37
+
38
+ [project.optional-dependencies]
39
+ dev = [
40
+ "pytest>=7.0",
41
+ "pytest-asyncio>=0.21",
42
+ ]
43
+
44
+ [tool.setuptools.packages.find]
45
+ include = ["wsocket*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,253 @@
1
+ """Tests for the wSocket Python SDK."""
2
+
3
+ import json
4
+ import base64
5
+ from unittest.mock import MagicMock, AsyncMock, patch
6
+
7
+ import pytest
8
+
9
+ from wsocket.client import (
10
+ WSocket,
11
+ WSocketOptions,
12
+ Channel,
13
+ Presence,
14
+ MessageMeta,
15
+ PresenceMember,
16
+ HistoryMessage,
17
+ HistoryResult,
18
+ create_client,
19
+ )
20
+
21
+
22
+ class TestCreateClient:
23
+ def test_returns_wsocket_instance(self):
24
+ client = create_client("ws://localhost:9001", "my-key")
25
+ assert isinstance(client, WSocket)
26
+ assert client.url == "ws://localhost:9001"
27
+ assert client.api_key == "my-key"
28
+
29
+ def test_default_options(self):
30
+ client = create_client("ws://localhost:9001", "k")
31
+ assert client.options.auto_reconnect is True
32
+ assert client.options.max_reconnect_attempts == 10
33
+ assert client.options.recover is True
34
+
35
+ def test_custom_options(self):
36
+ opts = WSocketOptions(auto_reconnect=False, recover=False, max_reconnect_attempts=3)
37
+ client = create_client("ws://localhost:9001", "k", opts)
38
+ assert client.options.auto_reconnect is False
39
+ assert client.options.recover is False
40
+ assert client.options.max_reconnect_attempts == 3
41
+
42
+
43
+ class TestChannel:
44
+ def setup_method(self):
45
+ self.sent: list = []
46
+ self.channel = Channel("chat", lambda msg: self.sent.append(msg))
47
+
48
+ def test_subscribe_sends_action(self):
49
+ self.channel.subscribe()
50
+ assert len(self.sent) == 1
51
+ assert self.sent[0]["action"] == "subscribe"
52
+ assert self.sent[0]["channel"] == "chat"
53
+
54
+ def test_subscribe_idempotent(self):
55
+ self.channel.subscribe()
56
+ self.channel.subscribe()
57
+ assert len(self.sent) == 1
58
+
59
+ def test_subscribe_with_rewind(self):
60
+ self.channel.subscribe(rewind=10)
61
+ assert self.sent[0]["rewind"] == 10
62
+
63
+ def test_unsubscribe(self):
64
+ self.channel.subscribe()
65
+ self.channel.unsubscribe()
66
+ assert self.sent[-1]["action"] == "unsubscribe"
67
+ assert not self.channel.is_subscribed
68
+
69
+ def test_publish(self):
70
+ self.channel.publish({"text": "hello"})
71
+ msg = self.sent[0]
72
+ assert msg["action"] == "publish"
73
+ assert msg["channel"] == "chat"
74
+ assert msg["data"] == {"text": "hello"}
75
+ assert "id" in msg
76
+
77
+ def test_publish_ephemeral(self):
78
+ self.channel.publish({"text": "hi"}, persist=False)
79
+ assert self.sent[0]["persist"] is False
80
+
81
+ def test_on_message_decorator(self):
82
+ received = []
83
+
84
+ @self.channel.on_message
85
+ def handler(data, meta):
86
+ received.append(data)
87
+
88
+ assert self.channel.has_listeners is True
89
+ self.channel._emit("test-data", MessageMeta(id="1", channel="chat", timestamp=0))
90
+ assert received == ["test-data"]
91
+
92
+ def test_history_query(self):
93
+ self.channel.history(limit=50, direction="backward")
94
+ msg = self.sent[0]
95
+ assert msg["action"] == "history"
96
+ assert msg["limit"] == 50
97
+ assert msg["direction"] == "backward"
98
+
99
+
100
+ class TestPresence:
101
+ def setup_method(self):
102
+ self.sent: list = []
103
+ self.presence = Presence("room", lambda msg: self.sent.append(msg))
104
+
105
+ def test_enter(self):
106
+ self.presence.enter({"name": "Alice"})
107
+ assert self.sent[0]["action"] == "presence.enter"
108
+ assert self.sent[0]["data"] == {"name": "Alice"}
109
+
110
+ def test_leave(self):
111
+ self.presence.leave()
112
+ assert self.sent[0]["action"] == "presence.leave"
113
+
114
+ def test_update(self):
115
+ self.presence.update({"status": "away"})
116
+ assert self.sent[0]["action"] == "presence.update"
117
+
118
+ def test_get(self):
119
+ self.presence.get()
120
+ assert self.sent[0]["action"] == "presence.get"
121
+
122
+ def test_callbacks(self):
123
+ entered = []
124
+ left = []
125
+
126
+ self.presence.on_enter(lambda m: entered.append(m))
127
+ self.presence.on_leave(lambda m: left.append(m))
128
+
129
+ member = PresenceMember(client_id="c1", data={"name": "Bob"})
130
+ self.presence._emit_enter(member)
131
+ self.presence._emit_leave(member)
132
+
133
+ assert len(entered) == 1
134
+ assert entered[0].client_id == "c1"
135
+ assert len(left) == 1
136
+
137
+
138
+ class TestWSocketMessageHandling:
139
+ def setup_method(self):
140
+ self.client = create_client("ws://localhost:9001", "key")
141
+
142
+ def test_handle_message_dispatches_to_channel(self):
143
+ ch = self.client.channel("chat")
144
+ received = []
145
+
146
+ @ch.on_message
147
+ def handler(data, meta):
148
+ received.append(data)
149
+
150
+ self.client._handle_message(json.dumps({
151
+ "action": "message",
152
+ "channel": "chat",
153
+ "data": {"text": "hello"},
154
+ "id": "msg-1",
155
+ "timestamp": 1000,
156
+ }))
157
+
158
+ assert received == [{"text": "hello"}]
159
+
160
+ def test_handle_message_unknown_channel_ignored(self):
161
+ self.client._handle_message(json.dumps({
162
+ "action": "message",
163
+ "channel": "unknown",
164
+ "data": "test",
165
+ }))
166
+ # No exception
167
+
168
+ def test_handle_presence_enter(self):
169
+ ch = self.client.channel("room")
170
+ entered = []
171
+ ch.presence.on_enter(lambda m: entered.append(m))
172
+
173
+ self.client._handle_message(json.dumps({
174
+ "action": "presence.enter",
175
+ "channel": "room",
176
+ "data": {"clientId": "c1", "data": {"name": "Alice"}},
177
+ }))
178
+
179
+ assert len(entered) == 1
180
+ assert entered[0].client_id == "c1"
181
+
182
+ def test_handle_ack_with_resume_token(self):
183
+ self.client._handle_message(json.dumps({
184
+ "action": "ack",
185
+ "id": "resume",
186
+ "data": {"resumeToken": "new-token-123"},
187
+ }))
188
+ assert self.client._resume_token == "new-token-123"
189
+
190
+ def test_handle_error(self):
191
+ errors = []
192
+ self.client.on("error", lambda e: errors.append(str(e)))
193
+
194
+ self.client._handle_message(json.dumps({
195
+ "action": "error",
196
+ "error": "Rate limit exceeded",
197
+ }))
198
+
199
+ assert len(errors) == 1
200
+ assert "Rate limit" in errors[0]
201
+
202
+ def test_invalid_json_ignored(self):
203
+ self.client._handle_message("not-json{{{")
204
+ # No exception
205
+
206
+ def test_connection_state(self):
207
+ assert self.client.connection_state == "disconnected"
208
+
209
+ def test_state_events(self):
210
+ states = []
211
+ self.client.on("state", lambda new, old: states.append((new, old)))
212
+ self.client._set_state("connecting")
213
+ self.client._set_state("connected")
214
+ assert states == [("connecting", "disconnected"), ("connected", "connecting")]
215
+
216
+
217
+ class TestDataClasses:
218
+ def test_message_meta(self):
219
+ m = MessageMeta(id="1", channel="ch", timestamp=1234.5)
220
+ assert m.id == "1"
221
+ assert m.channel == "ch"
222
+ assert m.timestamp == 1234.5
223
+
224
+ def test_presence_member(self):
225
+ m = PresenceMember(client_id="c1")
226
+ assert m.data is None
227
+ assert m.joined_at == 0.0
228
+
229
+ def test_history_message(self):
230
+ h = HistoryMessage(id="h1", channel="ch", data="test", publisher_id="p1", timestamp=100)
231
+ assert h.sequence == 0
232
+
233
+ def test_history_result(self):
234
+ r = HistoryResult(channel="ch", messages=[], has_more=True)
235
+ assert r.has_more is True
236
+
237
+ def test_parse_member(self):
238
+ m = WSocket._parse_member({"clientId": "c1", "data": {"name": "Bob"}, "joinedAt": 100})
239
+ assert m.client_id == "c1"
240
+ assert m.data == {"name": "Bob"}
241
+ assert m.joined_at == 100
242
+
243
+ def test_parse_history(self):
244
+ r = WSocket._parse_history({
245
+ "channel": "chat",
246
+ "messages": [
247
+ {"id": "m1", "channel": "chat", "data": "hello", "publisherId": "p1", "timestamp": 1000, "sequence": 1},
248
+ ],
249
+ "hasMore": False,
250
+ })
251
+ assert r.channel == "chat"
252
+ assert len(r.messages) == 1
253
+ assert r.messages[0].publisher_id == "p1"
@@ -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"
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/wsocket-io)](https://pypi.org/project/wsocket-io/)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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,12 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ tests/test_client.py
6
+ wsocket/__init__.py
7
+ wsocket/client.py
8
+ wsocket_io.egg-info/PKG-INFO
9
+ wsocket_io.egg-info/SOURCES.txt
10
+ wsocket_io.egg-info/dependency_links.txt
11
+ wsocket_io.egg-info/requires.txt
12
+ wsocket_io.egg-info/top_level.txt
@@ -0,0 +1,5 @@
1
+ websockets>=12.0
2
+
3
+ [dev]
4
+ pytest>=7.0
5
+ pytest-asyncio>=0.21
@@ -0,0 +1 @@
1
+ wsocket