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.
- wsocket_io-0.1.0/LICENSE +21 -0
- wsocket_io-0.1.0/MANIFEST.in +3 -0
- wsocket_io-0.1.0/PKG-INFO +119 -0
- wsocket_io-0.1.0/README.md +89 -0
- wsocket_io-0.1.0/pyproject.toml +45 -0
- wsocket_io-0.1.0/setup.cfg +4 -0
- wsocket_io-0.1.0/tests/test_client.py +253 -0
- wsocket_io-0.1.0/wsocket/__init__.py +7 -0
- wsocket_io-0.1.0/wsocket/client.py +693 -0
- wsocket_io-0.1.0/wsocket_io.egg-info/PKG-INFO +119 -0
- wsocket_io-0.1.0/wsocket_io.egg-info/SOURCES.txt +12 -0
- wsocket_io-0.1.0/wsocket_io.egg-info/dependency_links.txt +1 -0
- wsocket_io-0.1.0/wsocket_io.egg-info/requires.txt +5 -0
- wsocket_io-0.1.0/wsocket_io.egg-info/top_level.txt +1 -0
wsocket_io-0.1.0/LICENSE
ADDED
|
@@ -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,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,89 @@
|
|
|
1
|
+
# wSocket Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python SDK for [wSocket](https://wsocket.io) — Realtime Pub/Sub over WebSockets.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/wsocket-io/)
|
|
6
|
+
[](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,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
|
+
[](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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wsocket
|