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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/amarwave)](https://pypi.org/project/amarwave/)
27
+ [![Python](https://img.shields.io/pypi/pyversions/amarwave)](https://pypi.org/project/amarwave/)
28
+ [![License](https://img.shields.io/pypi/l/amarwave)](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,174 @@
1
+ # amarwave
2
+
3
+ Official Python client for [AmarWave](https://amarwave.com) real-time messaging — async, typed, zero boilerplate.
4
+
5
+ [![PyPI version](https://img.shields.io/pypi/v/amarwave)](https://pypi.org/project/amarwave/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/amarwave)](https://pypi.org/project/amarwave/)
7
+ [![License](https://img.shields.io/pypi/l/amarwave)](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
+ [![PyPI version](https://img.shields.io/pypi/v/amarwave)](https://pypi.org/project/amarwave/)
27
+ [![Python](https://img.shields.io/pypi/pyversions/amarwave)](https://pypi.org/project/amarwave/)
28
+ [![License](https://img.shields.io/pypi/l/amarwave)](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,9 @@
1
+ websockets>=12.0
2
+ httpx>=0.27.0
3
+
4
+ [dev]
5
+ pytest>=8.0
6
+ pytest-asyncio>=0.23
7
+ black
8
+ mypy
9
+ ruff
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+