amarwave 2.0.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.
- amarwave-2.0.0/PKG-INFO +195 -0
- amarwave-2.0.0/README.md +174 -0
- amarwave-2.0.0/amarwave/__init__.py +37 -0
- amarwave-2.0.0/amarwave/channel.py +63 -0
- amarwave-2.0.0/amarwave/client.py +357 -0
- amarwave-2.0.0/amarwave/crypto.py +24 -0
- amarwave-2.0.0/amarwave/emitter.py +68 -0
- amarwave-2.0.0/amarwave/types.py +37 -0
- amarwave-2.0.0/amarwave.egg-info/PKG-INFO +195 -0
- amarwave-2.0.0/amarwave.egg-info/SOURCES.txt +13 -0
- amarwave-2.0.0/amarwave.egg-info/dependency_links.txt +1 -0
- amarwave-2.0.0/amarwave.egg-info/requires.txt +9 -0
- amarwave-2.0.0/amarwave.egg-info/top_level.txt +1 -0
- amarwave-2.0.0/pyproject.toml +49 -0
- amarwave-2.0.0/setup.cfg +4 -0
amarwave-2.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: amarwave
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Real-time WebSocket client for AmarWave servers
|
|
5
|
+
Author-email: AmarWave <mehedinaeem66@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://amarwave.com
|
|
8
|
+
Project-URL: Repository, https://github.com/amarwave/amarwave-python
|
|
9
|
+
Project-URL: Issues, https://github.com/amarwave/amarwave-python/issues
|
|
10
|
+
Keywords: websocket,realtime,pubsub,amarwave,async
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: websockets>=12.0
|
|
14
|
+
Requires-Dist: httpx>=0.27.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
18
|
+
Requires-Dist: black; extra == "dev"
|
|
19
|
+
Requires-Dist: mypy; extra == "dev"
|
|
20
|
+
Requires-Dist: ruff; extra == "dev"
|
|
21
|
+
|
|
22
|
+
# amarwave
|
|
23
|
+
|
|
24
|
+
Official Python client for [AmarWave](https://amarwave.com) real-time messaging — async, typed, zero boilerplate.
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/amarwave/)
|
|
27
|
+
[](https://pypi.org/project/amarwave/)
|
|
28
|
+
[](LICENSE)
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install amarwave
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
from amarwave import AmarWave
|
|
45
|
+
|
|
46
|
+
async def main():
|
|
47
|
+
aw = AmarWave(
|
|
48
|
+
app_key = "YOUR_APP_KEY",
|
|
49
|
+
app_secret = "YOUR_APP_SECRET",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
ch = await aw.subscribe("public-chat")
|
|
53
|
+
ch.bind("message", lambda data: print(data["user"], data["text"]))
|
|
54
|
+
|
|
55
|
+
await ch.publish("message", {"user": "Ali", "text": "Hello!"})
|
|
56
|
+
await aw.listen() # keep alive forever
|
|
57
|
+
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
| Parameter | Type | Default | Description |
|
|
66
|
+
|-----------------------|-------|------------------|------------------------------------------------|
|
|
67
|
+
| `app_key` | str | — | Your app key **(required)** |
|
|
68
|
+
| `app_secret` | str | `""` | App secret for HMAC channel auth |
|
|
69
|
+
| `cluster` | str | `"default"` | `"default"` \| `"eu"` \| `"us"` \| `"ap1"` \| `"ap2"` |
|
|
70
|
+
| `auth_endpoint` | str | `"/broadcasting/auth"` | Server auth URL for private/presence channels |
|
|
71
|
+
| `auth_headers` | dict | `{}` | Headers sent to the auth endpoint |
|
|
72
|
+
| `reconnect_delay` | float | `1.0` | Base reconnect delay in seconds |
|
|
73
|
+
| `max_reconnect_delay` | float | `30.0` | Max reconnect delay in seconds |
|
|
74
|
+
| `max_retries` | int | `5` | Max reconnect attempts (0 = infinite) |
|
|
75
|
+
| `activity_timeout` | float | `120.0` | Seconds between keepalive pings |
|
|
76
|
+
| `pong_timeout` | float | `30.0` | Seconds to wait for pong before reconnecting |
|
|
77
|
+
|
|
78
|
+
### Clusters
|
|
79
|
+
|
|
80
|
+
All clusters connect to `amarwave.com`. The `cluster` parameter is reserved for future regional routing.
|
|
81
|
+
|
|
82
|
+
| Cluster | WebSocket | API |
|
|
83
|
+
|-----------|----------------------------|----------------------------|
|
|
84
|
+
| `default` | `wss://amarwave.com` | `https://amarwave.com` |
|
|
85
|
+
| `eu` | `wss://amarwave.com` | `https://amarwave.com` |
|
|
86
|
+
| `us` | `wss://amarwave.com` | `https://amarwave.com` |
|
|
87
|
+
| `ap1` | `wss://amarwave.com` | `https://amarwave.com` |
|
|
88
|
+
| `ap2` | `wss://amarwave.com` | `https://amarwave.com` |
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
aw = AmarWave(app_key="KEY", app_secret="SECRET", cluster="eu")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Channel API
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
ch = await aw.subscribe("public-chat")
|
|
100
|
+
|
|
101
|
+
ch.bind("message", handler) # listen for event
|
|
102
|
+
ch.bind_global(lambda e, d: ...) # listen for all events on this channel
|
|
103
|
+
ch.unbind("message", handler) # remove listener
|
|
104
|
+
await ch.publish("message", data) # publish via HTTP API → bool
|
|
105
|
+
await aw.publish("ch", "ev", data) # top-level publish shortcut
|
|
106
|
+
|
|
107
|
+
ch.name # "public-chat"
|
|
108
|
+
ch.subscribed # True when server confirmed subscription
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Connection Events
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
aw.bind("connecting", lambda _: print("Connecting…"))
|
|
117
|
+
aw.bind("connected", lambda _: print(f"Connected: {aw.socket_id}"))
|
|
118
|
+
aw.bind("disconnected", lambda _: print("Disconnected"))
|
|
119
|
+
aw.bind("error", lambda e: print(f"Error: {e}"))
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Private & Presence Channels
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
# Client-side HMAC auth (app_secret required)
|
|
128
|
+
aw = AmarWave(app_key="KEY", app_secret="SECRET")
|
|
129
|
+
ch = await aw.subscribe("private-orders") # auto-signed
|
|
130
|
+
ch = await aw.subscribe("presence-room-1") # auto-signed
|
|
131
|
+
|
|
132
|
+
# Server-side auth (omit app_secret, provide auth_endpoint)
|
|
133
|
+
aw = AmarWave(
|
|
134
|
+
app_key = "KEY",
|
|
135
|
+
auth_endpoint = "https://yourapp.com/api/broadcasting/auth",
|
|
136
|
+
auth_headers = {"Authorization": f"Bearer {token}"},
|
|
137
|
+
)
|
|
138
|
+
ch = await aw.subscribe("private-orders")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Django Integration
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
import asyncio
|
|
147
|
+
from amarwave import AmarWave
|
|
148
|
+
|
|
149
|
+
# One-shot publish from a sync Django view
|
|
150
|
+
def notify_user(user_id: int, message: str) -> bool:
|
|
151
|
+
async def _publish() -> bool:
|
|
152
|
+
aw = AmarWave(app_key="KEY", app_secret="SECRET")
|
|
153
|
+
return await aw.publish(f"private-user-{user_id}", "notification", {"message": message})
|
|
154
|
+
return asyncio.run(_publish())
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## FastAPI Integration
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from contextlib import asynccontextmanager
|
|
163
|
+
from fastapi import FastAPI
|
|
164
|
+
from amarwave import AmarWave
|
|
165
|
+
|
|
166
|
+
aw = AmarWave(app_key="KEY", app_secret="SECRET")
|
|
167
|
+
|
|
168
|
+
@asynccontextmanager
|
|
169
|
+
async def lifespan(app: FastAPI):
|
|
170
|
+
ch = await aw.subscribe("public-updates")
|
|
171
|
+
ch.bind("message", lambda d: print(d))
|
|
172
|
+
yield
|
|
173
|
+
await aw.disconnect()
|
|
174
|
+
|
|
175
|
+
app = FastAPI(lifespan=lifespan)
|
|
176
|
+
|
|
177
|
+
@app.post("/notify")
|
|
178
|
+
async def notify(message: str):
|
|
179
|
+
await aw.publish("public-updates", "message", {"text": message})
|
|
180
|
+
return {"ok": True}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Requirements
|
|
186
|
+
|
|
187
|
+
- Python 3.10+
|
|
188
|
+
- `websockets >= 12.0`
|
|
189
|
+
- `httpx >= 0.27.0`
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## License
|
|
194
|
+
|
|
195
|
+
MIT © AmarWave
|
amarwave-2.0.0/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# amarwave
|
|
2
|
+
|
|
3
|
+
Official Python client for [AmarWave](https://amarwave.com) real-time messaging — async, typed, zero boilerplate.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/amarwave/)
|
|
6
|
+
[](https://pypi.org/project/amarwave/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install amarwave
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import asyncio
|
|
23
|
+
from amarwave import AmarWave
|
|
24
|
+
|
|
25
|
+
async def main():
|
|
26
|
+
aw = AmarWave(
|
|
27
|
+
app_key = "YOUR_APP_KEY",
|
|
28
|
+
app_secret = "YOUR_APP_SECRET",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
ch = await aw.subscribe("public-chat")
|
|
32
|
+
ch.bind("message", lambda data: print(data["user"], data["text"]))
|
|
33
|
+
|
|
34
|
+
await ch.publish("message", {"user": "Ali", "text": "Hello!"})
|
|
35
|
+
await aw.listen() # keep alive forever
|
|
36
|
+
|
|
37
|
+
asyncio.run(main())
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
| Parameter | Type | Default | Description |
|
|
45
|
+
|-----------------------|-------|------------------|------------------------------------------------|
|
|
46
|
+
| `app_key` | str | — | Your app key **(required)** |
|
|
47
|
+
| `app_secret` | str | `""` | App secret for HMAC channel auth |
|
|
48
|
+
| `cluster` | str | `"default"` | `"default"` \| `"eu"` \| `"us"` \| `"ap1"` \| `"ap2"` |
|
|
49
|
+
| `auth_endpoint` | str | `"/broadcasting/auth"` | Server auth URL for private/presence channels |
|
|
50
|
+
| `auth_headers` | dict | `{}` | Headers sent to the auth endpoint |
|
|
51
|
+
| `reconnect_delay` | float | `1.0` | Base reconnect delay in seconds |
|
|
52
|
+
| `max_reconnect_delay` | float | `30.0` | Max reconnect delay in seconds |
|
|
53
|
+
| `max_retries` | int | `5` | Max reconnect attempts (0 = infinite) |
|
|
54
|
+
| `activity_timeout` | float | `120.0` | Seconds between keepalive pings |
|
|
55
|
+
| `pong_timeout` | float | `30.0` | Seconds to wait for pong before reconnecting |
|
|
56
|
+
|
|
57
|
+
### Clusters
|
|
58
|
+
|
|
59
|
+
All clusters connect to `amarwave.com`. The `cluster` parameter is reserved for future regional routing.
|
|
60
|
+
|
|
61
|
+
| Cluster | WebSocket | API |
|
|
62
|
+
|-----------|----------------------------|----------------------------|
|
|
63
|
+
| `default` | `wss://amarwave.com` | `https://amarwave.com` |
|
|
64
|
+
| `eu` | `wss://amarwave.com` | `https://amarwave.com` |
|
|
65
|
+
| `us` | `wss://amarwave.com` | `https://amarwave.com` |
|
|
66
|
+
| `ap1` | `wss://amarwave.com` | `https://amarwave.com` |
|
|
67
|
+
| `ap2` | `wss://amarwave.com` | `https://amarwave.com` |
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
aw = AmarWave(app_key="KEY", app_secret="SECRET", cluster="eu")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Channel API
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
ch = await aw.subscribe("public-chat")
|
|
79
|
+
|
|
80
|
+
ch.bind("message", handler) # listen for event
|
|
81
|
+
ch.bind_global(lambda e, d: ...) # listen for all events on this channel
|
|
82
|
+
ch.unbind("message", handler) # remove listener
|
|
83
|
+
await ch.publish("message", data) # publish via HTTP API → bool
|
|
84
|
+
await aw.publish("ch", "ev", data) # top-level publish shortcut
|
|
85
|
+
|
|
86
|
+
ch.name # "public-chat"
|
|
87
|
+
ch.subscribed # True when server confirmed subscription
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Connection Events
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
aw.bind("connecting", lambda _: print("Connecting…"))
|
|
96
|
+
aw.bind("connected", lambda _: print(f"Connected: {aw.socket_id}"))
|
|
97
|
+
aw.bind("disconnected", lambda _: print("Disconnected"))
|
|
98
|
+
aw.bind("error", lambda e: print(f"Error: {e}"))
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Private & Presence Channels
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
# Client-side HMAC auth (app_secret required)
|
|
107
|
+
aw = AmarWave(app_key="KEY", app_secret="SECRET")
|
|
108
|
+
ch = await aw.subscribe("private-orders") # auto-signed
|
|
109
|
+
ch = await aw.subscribe("presence-room-1") # auto-signed
|
|
110
|
+
|
|
111
|
+
# Server-side auth (omit app_secret, provide auth_endpoint)
|
|
112
|
+
aw = AmarWave(
|
|
113
|
+
app_key = "KEY",
|
|
114
|
+
auth_endpoint = "https://yourapp.com/api/broadcasting/auth",
|
|
115
|
+
auth_headers = {"Authorization": f"Bearer {token}"},
|
|
116
|
+
)
|
|
117
|
+
ch = await aw.subscribe("private-orders")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Django Integration
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
import asyncio
|
|
126
|
+
from amarwave import AmarWave
|
|
127
|
+
|
|
128
|
+
# One-shot publish from a sync Django view
|
|
129
|
+
def notify_user(user_id: int, message: str) -> bool:
|
|
130
|
+
async def _publish() -> bool:
|
|
131
|
+
aw = AmarWave(app_key="KEY", app_secret="SECRET")
|
|
132
|
+
return await aw.publish(f"private-user-{user_id}", "notification", {"message": message})
|
|
133
|
+
return asyncio.run(_publish())
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## FastAPI Integration
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from contextlib import asynccontextmanager
|
|
142
|
+
from fastapi import FastAPI
|
|
143
|
+
from amarwave import AmarWave
|
|
144
|
+
|
|
145
|
+
aw = AmarWave(app_key="KEY", app_secret="SECRET")
|
|
146
|
+
|
|
147
|
+
@asynccontextmanager
|
|
148
|
+
async def lifespan(app: FastAPI):
|
|
149
|
+
ch = await aw.subscribe("public-updates")
|
|
150
|
+
ch.bind("message", lambda d: print(d))
|
|
151
|
+
yield
|
|
152
|
+
await aw.disconnect()
|
|
153
|
+
|
|
154
|
+
app = FastAPI(lifespan=lifespan)
|
|
155
|
+
|
|
156
|
+
@app.post("/notify")
|
|
157
|
+
async def notify(message: str):
|
|
158
|
+
await aw.publish("public-updates", "message", {"text": message})
|
|
159
|
+
return {"ok": True}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Requirements
|
|
165
|
+
|
|
166
|
+
- Python 3.10+
|
|
167
|
+
- `websockets >= 12.0`
|
|
168
|
+
- `httpx >= 0.27.0`
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT © AmarWave
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AmarWave Python Client v2.0.0
|
|
3
|
+
Real-time WebSocket client for AmarWave servers.
|
|
4
|
+
|
|
5
|
+
Example::
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from amarwave import AmarWave
|
|
9
|
+
|
|
10
|
+
async def main():
|
|
11
|
+
aw = AmarWave(app_key="YOUR_KEY", app_secret="YOUR_SECRET")
|
|
12
|
+
|
|
13
|
+
ch = await aw.subscribe("public-chat")
|
|
14
|
+
|
|
15
|
+
ch.bind("message", lambda data: print(data["user"], data["text"]))
|
|
16
|
+
|
|
17
|
+
await ch.publish("message", {"user": "Ali", "text": "Hello!"})
|
|
18
|
+
|
|
19
|
+
await aw.listen() # keep alive forever
|
|
20
|
+
|
|
21
|
+
asyncio.run(main())
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from .client import AmarWave
|
|
25
|
+
from .channel import Channel
|
|
26
|
+
from .emitter import EventEmitter
|
|
27
|
+
from .types import ConnectionState, CLUSTERS
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AmarWave",
|
|
31
|
+
"Channel",
|
|
32
|
+
"EventEmitter",
|
|
33
|
+
"ConnectionState",
|
|
34
|
+
"CLUSTERS",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
__version__ = "2.0.0"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AmarWave — Channel class.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from .emitter import EventEmitter
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .client import AmarWave
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Channel(EventEmitter):
|
|
16
|
+
"""
|
|
17
|
+
Represents a subscription to a named AmarWave channel.
|
|
18
|
+
|
|
19
|
+
Obtained via ``aw.subscribe("channel-name")`` — never constructed directly.
|
|
20
|
+
|
|
21
|
+
Example::
|
|
22
|
+
|
|
23
|
+
ch = aw.subscribe("public-chat")
|
|
24
|
+
ch.bind("message", lambda data: print(data["text"]))
|
|
25
|
+
await ch.publish("message", {"user": "Ali", "text": "Hello!"})
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, name: str, client: "AmarWave") -> None:
|
|
29
|
+
super().__init__()
|
|
30
|
+
self.name: str = name
|
|
31
|
+
self._aw: AmarWave = client
|
|
32
|
+
self.subscribed: bool = False
|
|
33
|
+
self._queue: list[dict[str, Any]] = []
|
|
34
|
+
|
|
35
|
+
# ── Publish ───────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
async def publish(self, event: str, data: Any = None) -> bool:
|
|
38
|
+
"""
|
|
39
|
+
Publish an event to this channel via HTTP API.
|
|
40
|
+
|
|
41
|
+
- Safe to call before subscribed — queued and flushed automatically.
|
|
42
|
+
- Returns ``True`` on success, ``False`` on failure.
|
|
43
|
+
|
|
44
|
+
Example::
|
|
45
|
+
|
|
46
|
+
await ch.publish("message", {"user": "Ali", "text": "Hello!"})
|
|
47
|
+
"""
|
|
48
|
+
if not self.subscribed:
|
|
49
|
+
# Queue until subscription is confirmed
|
|
50
|
+
future: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
|
|
51
|
+
self._queue.append({"event": event, "data": data, "future": future})
|
|
52
|
+
return await future
|
|
53
|
+
|
|
54
|
+
return await self._aw._http_publish(self.name, event, data)
|
|
55
|
+
|
|
56
|
+
async def _flush_queue(self) -> None:
|
|
57
|
+
"""Called internally when subscription_succeeded arrives."""
|
|
58
|
+
items, self._queue = self._queue[:], []
|
|
59
|
+
for item in items:
|
|
60
|
+
result = await self._aw._http_publish(self.name, item["event"], item["data"])
|
|
61
|
+
future: asyncio.Future[bool] = item["future"]
|
|
62
|
+
if not future.done():
|
|
63
|
+
future.set_result(result)
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AmarWave — Main async client.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import urlencode
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import websockets
|
|
14
|
+
from websockets.exceptions import ConnectionClosed
|
|
15
|
+
|
|
16
|
+
from .channel import Channel
|
|
17
|
+
from .crypto import generate_uid, hmac_sha256
|
|
18
|
+
from .emitter import EventEmitter
|
|
19
|
+
from .types import CLUSTERS, ConnectionState
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("amarwave")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AmarWave(EventEmitter):
|
|
25
|
+
"""
|
|
26
|
+
AmarWave async real-time client.
|
|
27
|
+
|
|
28
|
+
Example::
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
from amarwave import AmarWave
|
|
32
|
+
|
|
33
|
+
async def main():
|
|
34
|
+
aw = AmarWave(app_key="KEY", app_secret="SECRET")
|
|
35
|
+
|
|
36
|
+
ch = await aw.subscribe("public-chat")
|
|
37
|
+
ch.bind("message", lambda d: print(d["user"], d["text"]))
|
|
38
|
+
|
|
39
|
+
await ch.publish("message", {"user": "Ali", "text": "Hello!"})
|
|
40
|
+
await aw.listen() # keep alive forever
|
|
41
|
+
|
|
42
|
+
asyncio.run(main())
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
app_key: str,
|
|
49
|
+
app_secret: str = "",
|
|
50
|
+
cluster: str = "default",
|
|
51
|
+
auth_endpoint: str = "/broadcasting/auth",
|
|
52
|
+
auth_headers: dict[str, str] | None = None,
|
|
53
|
+
reconnect_delay: float = 1.0,
|
|
54
|
+
max_reconnect_delay: float = 30.0,
|
|
55
|
+
max_retries: int = 5,
|
|
56
|
+
activity_timeout: float = 120.0,
|
|
57
|
+
pong_timeout: float = 30.0,
|
|
58
|
+
) -> None:
|
|
59
|
+
super().__init__()
|
|
60
|
+
|
|
61
|
+
self.app_key = app_key
|
|
62
|
+
self.app_secret = app_secret
|
|
63
|
+
self.cluster = cluster
|
|
64
|
+
|
|
65
|
+
cluster_cfg = CLUSTERS.get(cluster.lower(), CLUSTERS["default"])
|
|
66
|
+
# Use plain ws:// for local, wss:// for all cloud clusters
|
|
67
|
+
self._ws_base = cluster_cfg["ws"] if cluster.lower() == "local" else cluster_cfg["wss"]
|
|
68
|
+
self._api_base = cluster_cfg["api"]
|
|
69
|
+
|
|
70
|
+
self.auth_endpoint = auth_endpoint
|
|
71
|
+
self.auth_headers = auth_headers or {}
|
|
72
|
+
|
|
73
|
+
self.reconnect_delay = reconnect_delay
|
|
74
|
+
self.max_reconnect_delay = max_reconnect_delay
|
|
75
|
+
self.max_retries = max_retries
|
|
76
|
+
self.activity_timeout = activity_timeout
|
|
77
|
+
self.pong_timeout = pong_timeout
|
|
78
|
+
|
|
79
|
+
# Public state
|
|
80
|
+
self.socket_id: str | None = None
|
|
81
|
+
self.state: ConnectionState = "initialized"
|
|
82
|
+
|
|
83
|
+
# Internal
|
|
84
|
+
self._ws: Any = None
|
|
85
|
+
self._channels: dict[str, Channel] = {}
|
|
86
|
+
self._retries: int = 0
|
|
87
|
+
self._stop: bool = False
|
|
88
|
+
self._connected: asyncio.Event = asyncio.Event()
|
|
89
|
+
self._recv_task: asyncio.Task | None = None
|
|
90
|
+
|
|
91
|
+
# ─── URLs ─────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def _ws_url(self) -> str:
|
|
94
|
+
params = urlencode({"app_key": self.app_key})
|
|
95
|
+
return f"{self._ws_base}/ws?{params}"
|
|
96
|
+
|
|
97
|
+
def _api_url(self) -> str:
|
|
98
|
+
return f"{self._api_base}/api/v1/trigger"
|
|
99
|
+
|
|
100
|
+
# ─── Connect ──────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
async def connect(self) -> None:
|
|
103
|
+
"""Open the WebSocket connection (called automatically by subscribe)."""
|
|
104
|
+
self._stop = False
|
|
105
|
+
await self._open()
|
|
106
|
+
|
|
107
|
+
async def _open(self) -> None:
|
|
108
|
+
"""Internal — open socket and start receive loop."""
|
|
109
|
+
self._set_state("connecting")
|
|
110
|
+
try:
|
|
111
|
+
self._ws = await websockets.connect(self._ws_url())
|
|
112
|
+
logger.info("[AmarWave] WebSocket opened → %s", self._ws_url())
|
|
113
|
+
self._recv_task = asyncio.create_task(self._recv_loop())
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.warning("[AmarWave] Connect failed: %s", e)
|
|
116
|
+
self._emit("error", e)
|
|
117
|
+
await self._schedule_reconnect()
|
|
118
|
+
|
|
119
|
+
async def _recv_loop(self) -> None:
|
|
120
|
+
"""Receive messages until the socket closes."""
|
|
121
|
+
try:
|
|
122
|
+
async for raw in self._ws:
|
|
123
|
+
await self._handle_raw(raw)
|
|
124
|
+
except ConnectionClosed as e:
|
|
125
|
+
logger.warning("[AmarWave] Connection closed: %s", e)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.warning("[AmarWave] Receive error: %s", e)
|
|
128
|
+
finally:
|
|
129
|
+
await self._on_close()
|
|
130
|
+
|
|
131
|
+
async def _handle_raw(self, raw: str) -> None:
|
|
132
|
+
try:
|
|
133
|
+
msg = json.loads(raw)
|
|
134
|
+
except json.JSONDecodeError:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
# data field may be a JSON string itself
|
|
138
|
+
if isinstance(msg.get("data"), str):
|
|
139
|
+
try:
|
|
140
|
+
msg["data"] = json.loads(msg["data"])
|
|
141
|
+
except json.JSONDecodeError:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
await self._handle_message(msg)
|
|
145
|
+
|
|
146
|
+
async def _handle_message(self, msg: dict) -> None:
|
|
147
|
+
event = msg.get("event", "")
|
|
148
|
+
channel = msg.get("channel", "")
|
|
149
|
+
data = msg.get("data")
|
|
150
|
+
|
|
151
|
+
if event == "amarwave:connection_established":
|
|
152
|
+
self.socket_id = data.get("socket_id") if isinstance(data, dict) else None
|
|
153
|
+
self._retries = 0
|
|
154
|
+
self._set_state("connected")
|
|
155
|
+
self._connected.set()
|
|
156
|
+
logger.info("[AmarWave] Connected — socket_id=%s", self.socket_id)
|
|
157
|
+
# Re-subscribe all channels (handles reconnect)
|
|
158
|
+
for ch in self._channels.values():
|
|
159
|
+
ch.subscribed = False
|
|
160
|
+
await self._do_subscribe(ch)
|
|
161
|
+
|
|
162
|
+
elif event == "amarwave:error":
|
|
163
|
+
msg_text = data.get("message", str(data)) if isinstance(data, dict) else str(data)
|
|
164
|
+
logger.error("[AmarWave] Server error: %s", msg_text)
|
|
165
|
+
self._emit("error", Exception(msg_text))
|
|
166
|
+
|
|
167
|
+
elif event == "amarwave:pong":
|
|
168
|
+
pass # keepalive acknowledged
|
|
169
|
+
|
|
170
|
+
elif event == "amarwave_internal:subscription_succeeded":
|
|
171
|
+
ch = self._channels.get(channel)
|
|
172
|
+
if ch:
|
|
173
|
+
ch.subscribed = True
|
|
174
|
+
ch._emit("subscribed", data)
|
|
175
|
+
await ch._flush_queue()
|
|
176
|
+
logger.info("[AmarWave] Subscribed → %s", channel)
|
|
177
|
+
|
|
178
|
+
elif event == "amarwave_internal:subscription_error":
|
|
179
|
+
ch = self._channels.get(channel)
|
|
180
|
+
if ch:
|
|
181
|
+
ch._emit("error", data)
|
|
182
|
+
logger.warning("[AmarWave] Subscription error on %s: %s", channel, data)
|
|
183
|
+
|
|
184
|
+
else:
|
|
185
|
+
# Dispatch to channel listeners
|
|
186
|
+
if channel and channel in self._channels:
|
|
187
|
+
self._channels[channel]._emit(event, data)
|
|
188
|
+
# Also bubble to instance listeners
|
|
189
|
+
self._emit(event, {"channel": channel, "data": data})
|
|
190
|
+
|
|
191
|
+
async def _on_close(self) -> None:
|
|
192
|
+
self.socket_id = None
|
|
193
|
+
self._connected.clear()
|
|
194
|
+
for ch in self._channels.values():
|
|
195
|
+
ch.subscribed = False
|
|
196
|
+
self._set_state("disconnected")
|
|
197
|
+
if not self._stop:
|
|
198
|
+
await self._schedule_reconnect()
|
|
199
|
+
|
|
200
|
+
async def _schedule_reconnect(self) -> None:
|
|
201
|
+
if self.max_retries > 0 and self._retries >= self.max_retries:
|
|
202
|
+
logger.warning("[AmarWave] Max retries reached — giving up.")
|
|
203
|
+
return
|
|
204
|
+
delay = min(self.reconnect_delay * (2 ** self._retries), self.max_reconnect_delay)
|
|
205
|
+
self._retries += 1
|
|
206
|
+
logger.info("[AmarWave] Reconnecting in %.1fs (attempt %d)…", delay, self._retries)
|
|
207
|
+
await asyncio.sleep(delay)
|
|
208
|
+
if not self._stop:
|
|
209
|
+
await self._open()
|
|
210
|
+
|
|
211
|
+
# ─── Disconnect ───────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
async def disconnect(self) -> None:
|
|
214
|
+
"""Close the connection. No auto-reconnect after this."""
|
|
215
|
+
self._stop = True
|
|
216
|
+
if self._recv_task:
|
|
217
|
+
self._recv_task.cancel()
|
|
218
|
+
if self._ws:
|
|
219
|
+
await self._ws.close()
|
|
220
|
+
self._set_state("disconnected")
|
|
221
|
+
|
|
222
|
+
# ─── Subscribe ────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
async def subscribe(self, channel_name: str) -> Channel:
|
|
225
|
+
"""
|
|
226
|
+
Subscribe to a channel. Auto-connects if needed.
|
|
227
|
+
Returns a Channel — safe to bind events and publish immediately.
|
|
228
|
+
|
|
229
|
+
Example::
|
|
230
|
+
|
|
231
|
+
ch = await aw.subscribe("public-chat")
|
|
232
|
+
ch.bind("message", lambda data: print(data))
|
|
233
|
+
"""
|
|
234
|
+
if channel_name in self._channels:
|
|
235
|
+
return self._channels[channel_name]
|
|
236
|
+
|
|
237
|
+
ch = Channel(channel_name, self)
|
|
238
|
+
self._channels[channel_name] = ch
|
|
239
|
+
|
|
240
|
+
if self.state != "connected":
|
|
241
|
+
if self.state == "initialized":
|
|
242
|
+
asyncio.create_task(self._open())
|
|
243
|
+
await self._connected.wait()
|
|
244
|
+
|
|
245
|
+
await self._do_subscribe(ch)
|
|
246
|
+
return ch
|
|
247
|
+
|
|
248
|
+
async def unsubscribe(self, channel_name: str) -> None:
|
|
249
|
+
"""Unsubscribe from a channel."""
|
|
250
|
+
if channel_name not in self._channels:
|
|
251
|
+
return
|
|
252
|
+
await self._raw_send({"event": "amarwave:unsubscribe", "data": {"channel": channel_name}})
|
|
253
|
+
del self._channels[channel_name]
|
|
254
|
+
|
|
255
|
+
def channel(self, channel_name: str) -> Channel | None:
|
|
256
|
+
"""Get an existing subscribed channel by name."""
|
|
257
|
+
return self._channels.get(channel_name)
|
|
258
|
+
|
|
259
|
+
# ─── Publish ──────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
async def publish(self, channel_name: str, event: str, data: Any = None) -> bool:
|
|
262
|
+
"""
|
|
263
|
+
Top-level publish shortcut — no need to hold a channel reference.
|
|
264
|
+
|
|
265
|
+
Example::
|
|
266
|
+
|
|
267
|
+
await aw.publish("public-chat", "message", {"user": "Ali", "text": "Hi"})
|
|
268
|
+
"""
|
|
269
|
+
return await self._http_publish(channel_name, event, data)
|
|
270
|
+
|
|
271
|
+
async def _http_publish(self, channel: str, event: str, data: Any) -> bool:
|
|
272
|
+
"""Internal HTTP POST to /api/v1/trigger."""
|
|
273
|
+
body = {
|
|
274
|
+
"app_key": self.app_key,
|
|
275
|
+
"app_secret": self.app_secret,
|
|
276
|
+
"channel": channel,
|
|
277
|
+
"event": event,
|
|
278
|
+
"data": data,
|
|
279
|
+
}
|
|
280
|
+
try:
|
|
281
|
+
async with httpx.AsyncClient() as client:
|
|
282
|
+
res = await client.post(self._api_url(), json=body, timeout=10.0)
|
|
283
|
+
if res.status_code >= 400:
|
|
284
|
+
logger.warning("[AmarWave] Publish failed %d: %s", res.status_code, res.text)
|
|
285
|
+
return False
|
|
286
|
+
return True
|
|
287
|
+
except Exception as e:
|
|
288
|
+
logger.warning("[AmarWave] Publish error: %s", e)
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
# ─── Subscribe helpers ────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
async def _do_subscribe(self, ch: Channel) -> None:
|
|
294
|
+
name = ch.name
|
|
295
|
+
payload: dict[str, Any] = {"event": "amarwave:subscribe", "data": {"channel": name}}
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
if name.startswith("presence-"):
|
|
299
|
+
if self.app_secret:
|
|
300
|
+
cd = json.dumps({"user_id": generate_uid(), "user_info": {}})
|
|
301
|
+
sig = hmac_sha256(self.app_secret, f"{self.socket_id}:{name}:{cd}")
|
|
302
|
+
payload["data"]["auth"] = f"{self.app_key}:{sig}"
|
|
303
|
+
payload["data"]["channel_data"] = cd
|
|
304
|
+
else:
|
|
305
|
+
await self._server_auth(ch, payload)
|
|
306
|
+
|
|
307
|
+
elif name.startswith("private-"):
|
|
308
|
+
if self.app_secret:
|
|
309
|
+
sig = hmac_sha256(self.app_secret, f"{self.socket_id}:{name}")
|
|
310
|
+
payload["data"]["auth"] = f"{self.app_key}:{sig}"
|
|
311
|
+
else:
|
|
312
|
+
await self._server_auth(ch, payload)
|
|
313
|
+
|
|
314
|
+
except Exception as e:
|
|
315
|
+
ch._emit("error", str(e))
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
await self._raw_send(payload)
|
|
319
|
+
|
|
320
|
+
async def _server_auth(self, ch: Channel, payload: dict) -> None:
|
|
321
|
+
"""Fetch auth token from server auth_endpoint."""
|
|
322
|
+
headers = {"Content-Type": "application/json", **self.auth_headers}
|
|
323
|
+
body = {"socket_id": self.socket_id, "channel_name": ch.name}
|
|
324
|
+
async with httpx.AsyncClient() as client:
|
|
325
|
+
res = await client.post(self.auth_endpoint, json=body, headers=headers, timeout=10.0)
|
|
326
|
+
if res.status_code >= 400:
|
|
327
|
+
raise Exception(f"Auth failed: {res.status_code}")
|
|
328
|
+
payload["data"].update(res.json())
|
|
329
|
+
|
|
330
|
+
# ─── Keepalive ────────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
async def listen(self) -> None:
|
|
333
|
+
"""
|
|
334
|
+
Block forever, keeping the connection alive with periodic pings.
|
|
335
|
+
Call this at the end of your main() to prevent the program from exiting.
|
|
336
|
+
|
|
337
|
+
Example::
|
|
338
|
+
|
|
339
|
+
await aw.listen()
|
|
340
|
+
"""
|
|
341
|
+
while not self._stop:
|
|
342
|
+
await asyncio.sleep(self.activity_timeout)
|
|
343
|
+
if self._ws and self.state == "connected":
|
|
344
|
+
await self._raw_send({"event": "amarwave:ping", "data": {}})
|
|
345
|
+
|
|
346
|
+
# ─── Utilities ────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
async def _raw_send(self, payload: dict) -> None:
|
|
349
|
+
if self._ws:
|
|
350
|
+
try:
|
|
351
|
+
await self._ws.send(json.dumps(payload))
|
|
352
|
+
except Exception as e:
|
|
353
|
+
logger.warning("[AmarWave] Send error: %s", e)
|
|
354
|
+
|
|
355
|
+
def _set_state(self, state: ConnectionState) -> None:
|
|
356
|
+
self.state = state
|
|
357
|
+
self._emit(state)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AmarWave — HMAC-SHA256 signing utility.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import secrets
|
|
9
|
+
import string
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def hmac_sha256(secret: str, message: str) -> str:
|
|
13
|
+
"""Return HMAC-SHA256 of `message` signed with `secret` as lowercase hex."""
|
|
14
|
+
return hmac.new(
|
|
15
|
+
secret.encode(),
|
|
16
|
+
message.encode(),
|
|
17
|
+
hashlib.sha256,
|
|
18
|
+
).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_uid(length: int = 16) -> str:
|
|
22
|
+
"""Generate a random alphanumeric ID."""
|
|
23
|
+
alphabet = string.ascii_lowercase + string.digits
|
|
24
|
+
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AmarWave — EventEmitter base class.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .types import EventCallback, GlobalCallback
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EventEmitter:
|
|
13
|
+
"""
|
|
14
|
+
Simple synchronous event emitter.
|
|
15
|
+
Used as the base class for both AmarWave and Channel.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._listeners: dict[str, list[EventCallback]] = defaultdict(list)
|
|
20
|
+
self._globals: list[GlobalCallback] = []
|
|
21
|
+
|
|
22
|
+
# ── Bind / unbind ─────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
def bind(self, event: str, fn: EventCallback) -> "EventEmitter":
|
|
25
|
+
"""Register a listener for `event`. Returns self for chaining."""
|
|
26
|
+
self._listeners[event].append(fn)
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
def on(self, event: str, fn: EventCallback) -> "EventEmitter":
|
|
30
|
+
"""Alias for bind()."""
|
|
31
|
+
return self.bind(event, fn)
|
|
32
|
+
|
|
33
|
+
def unbind(self, event: str, fn: EventCallback | None = None) -> "EventEmitter":
|
|
34
|
+
"""
|
|
35
|
+
Remove a listener.
|
|
36
|
+
If `fn` is omitted, all listeners for `event` are removed.
|
|
37
|
+
"""
|
|
38
|
+
if fn is None:
|
|
39
|
+
self._listeners.pop(event, None)
|
|
40
|
+
else:
|
|
41
|
+
self._listeners[event] = [f for f in self._listeners[event] if f is not fn]
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def off(self, event: str, fn: EventCallback | None = None) -> "EventEmitter":
|
|
45
|
+
"""Alias for unbind()."""
|
|
46
|
+
return self.unbind(event, fn)
|
|
47
|
+
|
|
48
|
+
def bind_global(self, fn: GlobalCallback) -> "EventEmitter":
|
|
49
|
+
"""Listen to every event emitted on this emitter."""
|
|
50
|
+
self._globals.append(fn)
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def unbind_global(self, fn: GlobalCallback | None = None) -> "EventEmitter":
|
|
54
|
+
"""Remove a global listener (or all if fn is omitted)."""
|
|
55
|
+
if fn is None:
|
|
56
|
+
self._globals.clear()
|
|
57
|
+
else:
|
|
58
|
+
self._globals = [f for f in self._globals if f is not fn]
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
# ── Emit ──────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def _emit(self, event: str, data: Any = None) -> None:
|
|
64
|
+
"""Fire all listeners for `event` with `data`."""
|
|
65
|
+
for fn in list(self._listeners.get(event, [])):
|
|
66
|
+
fn(data)
|
|
67
|
+
for fn in list(self._globals):
|
|
68
|
+
fn(event, data)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AmarWave — Type definitions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Callable, Literal
|
|
8
|
+
|
|
9
|
+
# ── Connection state ──────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
ConnectionState = Literal["initialized", "connecting", "connected", "disconnected"]
|
|
12
|
+
|
|
13
|
+
# ── Callback types ────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
EventCallback = Callable[[Any], None]
|
|
16
|
+
GlobalCallback = Callable[[str, Any], None]
|
|
17
|
+
|
|
18
|
+
# ── Cluster map ───────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
ClusterName = Literal["default", "local", "eu", "us", "ap1", "ap2"]
|
|
21
|
+
|
|
22
|
+
CLUSTERS: dict[str, dict[str, str]] = {
|
|
23
|
+
"default": {
|
|
24
|
+
"ws": "ws://amarwave.com",
|
|
25
|
+
"wss": "wss://amarwave.com",
|
|
26
|
+
"api": "https://amarwave.com",
|
|
27
|
+
},
|
|
28
|
+
"local": {
|
|
29
|
+
"ws": "ws://amarwave.com",
|
|
30
|
+
"wss": "wss://amarwave.com",
|
|
31
|
+
"api": "https://amarwave.com",
|
|
32
|
+
},
|
|
33
|
+
"eu": {"ws": "ws://amarwave.com", "wss": "wss://amarwave.com", "api": "https://amarwave.com"},
|
|
34
|
+
"us": {"ws": "ws://amarwave.com", "wss": "wss://amarwave.com", "api": "https://amarwave.com"},
|
|
35
|
+
"ap1": {"ws": "ws://amarwave.com", "wss": "wss://amarwave.com", "api": "https://amarwave.com"},
|
|
36
|
+
"ap2": {"ws": "ws://amarwave.com", "wss": "wss://amarwave.com", "api": "https://amarwave.com"},
|
|
37
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: amarwave
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Real-time WebSocket client for AmarWave servers
|
|
5
|
+
Author-email: AmarWave <mehedinaeem66@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://amarwave.com
|
|
8
|
+
Project-URL: Repository, https://github.com/amarwave/amarwave-python
|
|
9
|
+
Project-URL: Issues, https://github.com/amarwave/amarwave-python/issues
|
|
10
|
+
Keywords: websocket,realtime,pubsub,amarwave,async
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: websockets>=12.0
|
|
14
|
+
Requires-Dist: httpx>=0.27.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
18
|
+
Requires-Dist: black; extra == "dev"
|
|
19
|
+
Requires-Dist: mypy; extra == "dev"
|
|
20
|
+
Requires-Dist: ruff; extra == "dev"
|
|
21
|
+
|
|
22
|
+
# amarwave
|
|
23
|
+
|
|
24
|
+
Official Python client for [AmarWave](https://amarwave.com) real-time messaging — async, typed, zero boilerplate.
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/amarwave/)
|
|
27
|
+
[](https://pypi.org/project/amarwave/)
|
|
28
|
+
[](LICENSE)
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install amarwave
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
from amarwave import AmarWave
|
|
45
|
+
|
|
46
|
+
async def main():
|
|
47
|
+
aw = AmarWave(
|
|
48
|
+
app_key = "YOUR_APP_KEY",
|
|
49
|
+
app_secret = "YOUR_APP_SECRET",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
ch = await aw.subscribe("public-chat")
|
|
53
|
+
ch.bind("message", lambda data: print(data["user"], data["text"]))
|
|
54
|
+
|
|
55
|
+
await ch.publish("message", {"user": "Ali", "text": "Hello!"})
|
|
56
|
+
await aw.listen() # keep alive forever
|
|
57
|
+
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
| Parameter | Type | Default | Description |
|
|
66
|
+
|-----------------------|-------|------------------|------------------------------------------------|
|
|
67
|
+
| `app_key` | str | — | Your app key **(required)** |
|
|
68
|
+
| `app_secret` | str | `""` | App secret for HMAC channel auth |
|
|
69
|
+
| `cluster` | str | `"default"` | `"default"` \| `"eu"` \| `"us"` \| `"ap1"` \| `"ap2"` |
|
|
70
|
+
| `auth_endpoint` | str | `"/broadcasting/auth"` | Server auth URL for private/presence channels |
|
|
71
|
+
| `auth_headers` | dict | `{}` | Headers sent to the auth endpoint |
|
|
72
|
+
| `reconnect_delay` | float | `1.0` | Base reconnect delay in seconds |
|
|
73
|
+
| `max_reconnect_delay` | float | `30.0` | Max reconnect delay in seconds |
|
|
74
|
+
| `max_retries` | int | `5` | Max reconnect attempts (0 = infinite) |
|
|
75
|
+
| `activity_timeout` | float | `120.0` | Seconds between keepalive pings |
|
|
76
|
+
| `pong_timeout` | float | `30.0` | Seconds to wait for pong before reconnecting |
|
|
77
|
+
|
|
78
|
+
### Clusters
|
|
79
|
+
|
|
80
|
+
All clusters connect to `amarwave.com`. The `cluster` parameter is reserved for future regional routing.
|
|
81
|
+
|
|
82
|
+
| Cluster | WebSocket | API |
|
|
83
|
+
|-----------|----------------------------|----------------------------|
|
|
84
|
+
| `default` | `wss://amarwave.com` | `https://amarwave.com` |
|
|
85
|
+
| `eu` | `wss://amarwave.com` | `https://amarwave.com` |
|
|
86
|
+
| `us` | `wss://amarwave.com` | `https://amarwave.com` |
|
|
87
|
+
| `ap1` | `wss://amarwave.com` | `https://amarwave.com` |
|
|
88
|
+
| `ap2` | `wss://amarwave.com` | `https://amarwave.com` |
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
aw = AmarWave(app_key="KEY", app_secret="SECRET", cluster="eu")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Channel API
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
ch = await aw.subscribe("public-chat")
|
|
100
|
+
|
|
101
|
+
ch.bind("message", handler) # listen for event
|
|
102
|
+
ch.bind_global(lambda e, d: ...) # listen for all events on this channel
|
|
103
|
+
ch.unbind("message", handler) # remove listener
|
|
104
|
+
await ch.publish("message", data) # publish via HTTP API → bool
|
|
105
|
+
await aw.publish("ch", "ev", data) # top-level publish shortcut
|
|
106
|
+
|
|
107
|
+
ch.name # "public-chat"
|
|
108
|
+
ch.subscribed # True when server confirmed subscription
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Connection Events
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
aw.bind("connecting", lambda _: print("Connecting…"))
|
|
117
|
+
aw.bind("connected", lambda _: print(f"Connected: {aw.socket_id}"))
|
|
118
|
+
aw.bind("disconnected", lambda _: print("Disconnected"))
|
|
119
|
+
aw.bind("error", lambda e: print(f"Error: {e}"))
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Private & Presence Channels
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
# Client-side HMAC auth (app_secret required)
|
|
128
|
+
aw = AmarWave(app_key="KEY", app_secret="SECRET")
|
|
129
|
+
ch = await aw.subscribe("private-orders") # auto-signed
|
|
130
|
+
ch = await aw.subscribe("presence-room-1") # auto-signed
|
|
131
|
+
|
|
132
|
+
# Server-side auth (omit app_secret, provide auth_endpoint)
|
|
133
|
+
aw = AmarWave(
|
|
134
|
+
app_key = "KEY",
|
|
135
|
+
auth_endpoint = "https://yourapp.com/api/broadcasting/auth",
|
|
136
|
+
auth_headers = {"Authorization": f"Bearer {token}"},
|
|
137
|
+
)
|
|
138
|
+
ch = await aw.subscribe("private-orders")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Django Integration
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
import asyncio
|
|
147
|
+
from amarwave import AmarWave
|
|
148
|
+
|
|
149
|
+
# One-shot publish from a sync Django view
|
|
150
|
+
def notify_user(user_id: int, message: str) -> bool:
|
|
151
|
+
async def _publish() -> bool:
|
|
152
|
+
aw = AmarWave(app_key="KEY", app_secret="SECRET")
|
|
153
|
+
return await aw.publish(f"private-user-{user_id}", "notification", {"message": message})
|
|
154
|
+
return asyncio.run(_publish())
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## FastAPI Integration
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from contextlib import asynccontextmanager
|
|
163
|
+
from fastapi import FastAPI
|
|
164
|
+
from amarwave import AmarWave
|
|
165
|
+
|
|
166
|
+
aw = AmarWave(app_key="KEY", app_secret="SECRET")
|
|
167
|
+
|
|
168
|
+
@asynccontextmanager
|
|
169
|
+
async def lifespan(app: FastAPI):
|
|
170
|
+
ch = await aw.subscribe("public-updates")
|
|
171
|
+
ch.bind("message", lambda d: print(d))
|
|
172
|
+
yield
|
|
173
|
+
await aw.disconnect()
|
|
174
|
+
|
|
175
|
+
app = FastAPI(lifespan=lifespan)
|
|
176
|
+
|
|
177
|
+
@app.post("/notify")
|
|
178
|
+
async def notify(message: str):
|
|
179
|
+
await aw.publish("public-updates", "message", {"text": message})
|
|
180
|
+
return {"ok": True}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Requirements
|
|
186
|
+
|
|
187
|
+
- Python 3.10+
|
|
188
|
+
- `websockets >= 12.0`
|
|
189
|
+
- `httpx >= 0.27.0`
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## License
|
|
194
|
+
|
|
195
|
+
MIT © AmarWave
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
amarwave/__init__.py
|
|
4
|
+
amarwave/channel.py
|
|
5
|
+
amarwave/client.py
|
|
6
|
+
amarwave/crypto.py
|
|
7
|
+
amarwave/emitter.py
|
|
8
|
+
amarwave/types.py
|
|
9
|
+
amarwave.egg-info/PKG-INFO
|
|
10
|
+
amarwave.egg-info/SOURCES.txt
|
|
11
|
+
amarwave.egg-info/dependency_links.txt
|
|
12
|
+
amarwave.egg-info/requires.txt
|
|
13
|
+
amarwave.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
amarwave
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "amarwave"
|
|
7
|
+
version = "2.0.0"
|
|
8
|
+
description = "Real-time WebSocket client for AmarWave servers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "AmarWave", email = "mehedinaeem66@gmail.com" }]
|
|
12
|
+
keywords = ["websocket", "realtime", "pubsub", "amarwave", "async"]
|
|
13
|
+
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
|
|
16
|
+
dependencies = [
|
|
17
|
+
"websockets>=12.0",
|
|
18
|
+
"httpx>=0.27.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=8.0",
|
|
24
|
+
"pytest-asyncio>=0.23",
|
|
25
|
+
"black",
|
|
26
|
+
"mypy",
|
|
27
|
+
"ruff",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://amarwave.com"
|
|
32
|
+
Repository = "https://github.com/amarwave/amarwave-python"
|
|
33
|
+
Issues = "https://github.com/amarwave/amarwave-python/issues"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["."]
|
|
37
|
+
include = ["amarwave*"]
|
|
38
|
+
|
|
39
|
+
[tool.black]
|
|
40
|
+
line-length = 100
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 100
|
|
44
|
+
|
|
45
|
+
[tool.mypy]
|
|
46
|
+
strict = true
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
asyncio_mode = "auto"
|
amarwave-2.0.0/setup.cfg
ADDED