phoenix-channels-python-client 0.2.4__tar.gz → 0.3.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.
Files changed (48) hide show
  1. phoenix_channels_python_client-0.3.0/PKG-INFO +383 -0
  2. phoenix_channels_python_client-0.3.0/README.md +352 -0
  3. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/__init__.py +5 -4
  4. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/client.py +71 -65
  5. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/client_state_machine.py +1 -3
  6. phoenix_channels_python_client-0.3.0/phoenix_channels_python_client/client_types.py +73 -0
  7. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/exceptions.py +0 -2
  8. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/phx_messages.py +16 -9
  9. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/protocol_handler.py +107 -68
  10. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/reconnect_controller.py +58 -27
  11. phoenix_channels_python_client-0.3.0/phoenix_channels_python_client/shutdown_signals.py +120 -0
  12. phoenix_channels_python_client-0.3.0/phoenix_channels_python_client/supervisor.py +476 -0
  13. phoenix_channels_python_client-0.3.0/phoenix_channels_python_client/topic_runtime.py +783 -0
  14. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/topic_subscription.py +21 -6
  15. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/utils.py +33 -15
  16. phoenix_channels_python_client-0.3.0/phoenix_channels_python_client.egg-info/PKG-INFO +383 -0
  17. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client.egg-info/SOURCES.txt +14 -2
  18. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client.egg-info/requires.txt +4 -2
  19. phoenix_channels_python_client-0.3.0/pyproject.toml +87 -0
  20. phoenix_channels_python_client-0.3.0/tests/test_cancel_and_wait.py +53 -0
  21. phoenix_channels_python_client-0.3.0/tests/test_channel_recovery.py +350 -0
  22. phoenix_channels_python_client-0.3.0/tests/test_client_state.py +77 -0
  23. phoenix_channels_python_client-0.3.0/tests/test_connection.py +250 -0
  24. phoenix_channels_python_client-0.3.0/tests/test_heartbeat.py +77 -0
  25. phoenix_channels_python_client-0.3.0/tests/test_protocol_handler.py +228 -0
  26. phoenix_channels_python_client-0.3.0/tests/test_reconnect.py +627 -0
  27. phoenix_channels_python_client-0.3.0/tests/test_reconnect_policy_invariants.py +177 -0
  28. phoenix_channels_python_client-0.3.0/tests/test_reconnect_stress.py +185 -0
  29. phoenix_channels_python_client-0.3.0/tests/test_run_forever_signals.py +308 -0
  30. phoenix_channels_python_client-0.3.0/tests/test_shutdown_races.py +321 -0
  31. phoenix_channels_python_client-0.3.0/tests/test_subscriptions.py +445 -0
  32. phoenix_channels_python_client-0.3.0/tests/test_supervisor_internals.py +178 -0
  33. phoenix_channels_python_client-0.3.0/tests/test_topic_runtime_internals.py +303 -0
  34. phoenix_channels_python_client-0.2.4/PKG-INFO +0 -269
  35. phoenix_channels_python_client-0.2.4/README.md +0 -240
  36. phoenix_channels_python_client-0.2.4/phoenix_channels_python_client/client_types.py +0 -102
  37. phoenix_channels_python_client-0.2.4/phoenix_channels_python_client/supervisor.py +0 -448
  38. phoenix_channels_python_client-0.2.4/phoenix_channels_python_client/topic_runtime.py +0 -581
  39. phoenix_channels_python_client-0.2.4/phoenix_channels_python_client.egg-info/PKG-INFO +0 -269
  40. phoenix_channels_python_client-0.2.4/pyproject.toml +0 -52
  41. phoenix_channels_python_client-0.2.4/tests/test_internal_components.py +0 -1138
  42. phoenix_channels_python_client-0.2.4/tests/test_reconnect_policy_invariants.py +0 -124
  43. phoenix_channels_python_client-0.2.4/tests/test_reconnect_stress.py +0 -136
  44. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/LICENSE +0 -0
  45. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/MANIFEST.in +0 -0
  46. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
  47. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
  48. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.3.0}/setup.cfg +0 -0
@@ -0,0 +1,383 @@
1
+ Metadata-Version: 2.4
2
+ Name: phoenix-channels-python-client
3
+ Version: 0.3.0
4
+ Summary: A Python client library for connecting to Phoenix Channels
5
+ Author: Phoenix Channels Python Client
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: pydantic>=2.8
18
+ Requires-Dist: websockets>=14.2
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest; extra == "dev"
21
+ Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
22
+ Requires-Dist: pytest-cov; extra == "dev"
23
+ Requires-Dist: pre-commit>=3.0.0; extra == "dev"
24
+ Requires-Dist: commitizen>=3.13.0; extra == "dev"
25
+ Requires-Dist: pyrefly; extra == "dev"
26
+ Requires-Dist: ruff; extra == "dev"
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest; extra == "test"
29
+ Requires-Dist: pytest-asyncio>=1.4; extra == "test"
30
+ Dynamic: license-file
31
+
32
+ # Phoenix Channels Python Client
33
+
34
+ [![PyPI version](https://img.shields.io/pypi/v/phoenix-channels-python-client.svg)](https://pypi.org/project/phoenix-channels-python-client/)
35
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
37
+
38
+ A modern, async Python client library for connecting to [Phoenix Channels](https://hexdocs.pm/phoenix/channels.html) - the real-time WebSocket layer of the Phoenix Framework.
39
+
40
+ ## Installation
41
+
42
+ ### For Users
43
+
44
+ ```bash
45
+ pip install phoenix-channels-python-client
46
+ ```
47
+
48
+ Or from source:
49
+
50
+ ```bash
51
+ git clone https://github.com/band-ai/phoenix-channels-python-client.git
52
+ cd phoenix-channels-python-client
53
+ python3 -m venv venv
54
+ source venv/bin/activate # On Windows: venv\Scripts\activate
55
+ pip install .
56
+ ```
57
+
58
+ ### For Development
59
+
60
+ Set up development environment:
61
+
62
+ ```bash
63
+ git clone https://github.com/band-ai/phoenix-channels-python-client.git
64
+ cd phoenix-channels-python-client
65
+ uv sync --extra dev
66
+ ```
67
+
68
+ Run tests:
69
+
70
+ ```bash
71
+ uv run pytest
72
+ ```
73
+
74
+ ### Dependencies
75
+
76
+ - Python 3.11+
77
+ - `pydantic>=2.8`
78
+ - `websockets>=14.2`
79
+
80
+ ## Quick Start
81
+
82
+ Here's a minimal example to get you started:
83
+
84
+ ```python
85
+ import asyncio
86
+ from phoenix_channels_python_client import PHXChannelsClient
87
+
88
+ async def main():
89
+ # Connect to Phoenix WebSocket server
90
+ async with PHXChannelsClient(
91
+ websocket_url="ws://localhost:4000/socket/websocket",
92
+ api_key="your-api-key"
93
+ ) as client:
94
+
95
+ # Define message handler
96
+ async def handle_message(message):
97
+ print(f"Received: {message}")
98
+
99
+ # Subscribe to a topic
100
+ await client.subscribe_to_topic("room:lobby", handle_message)
101
+
102
+ # Ctrl+C or SIGTERM shuts the client down and run_forever() returns.
103
+ # The previous handlers are restored first, so a second signal reaches them.
104
+ await client.run_forever()
105
+
106
+ if __name__ == "__main__":
107
+ asyncio.run(main())
108
+ ```
109
+
110
+ ### Owning process signals yourself
111
+
112
+ If your application owns its process signals (a service, desktop app or test
113
+ runner), call `client.run_forever(install_signal_handlers=False)`. It then
114
+ leaves signal handlers alone; stop the client by scheduling
115
+ `client.shutdown(reason)` on the client's event loop, and `run_forever()`
116
+ returns `None`:
117
+
118
+ ```python
119
+ loop = asyncio.get_running_loop()
120
+ async with asyncio.TaskGroup() as tasks:
121
+ loop.add_signal_handler(
122
+ signal.SIGTERM, lambda: tasks.create_task(client.shutdown("SIGTERM"))
123
+ )
124
+ try:
125
+ await client.run_forever(install_signal_handlers=False)
126
+ finally:
127
+ # Once the group exits, a later SIGTERM must not reach the closed group.
128
+ loop.remove_signal_handler(signal.SIGTERM)
129
+ ```
130
+
131
+ From a plain `signal.signal` handler or another thread, use
132
+ `loop.call_soon_threadsafe(...)` instead: a bare `asyncio.create_task` there
133
+ doesn't wake a loop blocked on I/O, so the shutdown can stall until the next
134
+ network activity. See [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py).
135
+
136
+ With the default, handlers are installed only when `run_forever()` runs on the
137
+ main thread; elsewhere it just waits. A handler you registered with
138
+ `loop.add_signal_handler` before calling `run_forever()` still fires for the
139
+ same signal; one registered while it runs replaces the client's.
140
+
141
+ ## Phoenix System Events
142
+
143
+ Phoenix reserves `phx_join`, `phx_reply`, `phx_leave`, `phx_close` and
144
+ `phx_error`. Don't use these names for your own events.
145
+
146
+ - The client sends `phx_join` and `phx_leave` and consumes their `phx_reply`.
147
+ - The client handles `phx_error` and `phx_close` itself, and neither reaches
148
+ your handlers: a crashed channel is rejoined, and a channel the server
149
+ closes loses its topic (see [Channel recovery](#channel-recovery)).
150
+ - `add_event_handler` raises `ValueError` for `PHXEvent.error` and
151
+ `PHXEvent.close`. Use `on_topic_lost` and `is_topic_joined()` instead.
152
+
153
+ ## Protocol Versions
154
+
155
+ Phoenix Channels supports two protocol versions. Choose based on your Phoenix server version:
156
+
157
+ ### Protocol v2.0 (Default)
158
+ ```python
159
+ from phoenix_channels_python_client import PHXChannelsClient
160
+
161
+ async with PHXChannelsClient(
162
+ websocket_url="ws://localhost:4000/socket/websocket",
163
+ api_key="your-api-key"
164
+ # protocol_version defaults to v2.0
165
+ ) as client:
166
+ ... # your code here
167
+ ```
168
+
169
+ ### Protocol v1.0
170
+ ```python
171
+ from phoenix_channels_python_client import PHXChannelsClient, PhoenixChannelsProtocolVersion
172
+
173
+ async with PHXChannelsClient(
174
+ websocket_url="ws://localhost:4000/socket/websocket",
175
+ api_key="your-api-key",
176
+ protocol_version=PhoenixChannelsProtocolVersion.V1
177
+ ) as client:
178
+ ... # your code here
179
+ ```
180
+
181
+ The client adds `vsn=2.0.0` (V2) or `vsn=1.0.0` (V1) to the socket URL.
182
+
183
+ ## Usage Examples
184
+
185
+ ### Basic Topic Subscription
186
+
187
+ ```python
188
+ import asyncio
189
+ from phoenix_channels_python_client import PHXChannelsClient
190
+
191
+ async def message_handler(message):
192
+ print(f"Topic: {message.topic}")
193
+ print(f"Event: {message.event}")
194
+ print(f"Payload: {message.payload}")
195
+
196
+ async def main():
197
+ async with PHXChannelsClient("ws://localhost:4000/socket/websocket", "api-key") as client:
198
+ await client.subscribe_to_topic("chat:general", message_handler)
199
+
200
+ # Use built-in method to keep connection alive
201
+ await client.run_forever()
202
+
203
+ asyncio.run(main())
204
+ ```
205
+
206
+ ## Message Handlers vs Event-Specific Handlers
207
+
208
+ The library provides two complementary ways to handle incoming messages:
209
+
210
+ **Message Handler** - Receives ALL messages for a topic:
211
+ - Set when subscribing to a topic or separately with `set_message_handler()`
212
+ - Gets the complete message object with topic, event, payload, etc.
213
+ - Good for logging, debugging, or handling any message type
214
+
215
+ **Event-Specific Handlers** - Receive only messages with specific event names:
216
+ - Added with `add_event_handler()` for particular events like "user_join", "chat_message", etc.
217
+ - Only get the payload (data) part of the message
218
+ - Perfect for handling specific application events
219
+
220
+ **Execution order** when you have both types of handlers:
221
+ 1. **Message handler runs first** (if set) - receives the full message
222
+ 2. **Event-specific handler runs second** (if matching event) - receives just the payload
223
+
224
+ All handlers must be `async def`. You can add, remove, or change either type of handler at any time during your connection.
225
+
226
+ ### Event-Specific Handlers
227
+
228
+ You can register handlers for specific events on a topic:
229
+
230
+ ```python
231
+ import asyncio
232
+ from phoenix_channels_python_client import PHXChannelsClient
233
+
234
+ async def handle_user_join(payload):
235
+ print(f"User joined: {payload}")
236
+
237
+ async def handle_user_leave(payload):
238
+ print(f"User left: {payload}")
239
+
240
+ async def main():
241
+ async with PHXChannelsClient("ws://localhost:4000/socket/websocket", "api-key") as client:
242
+ # Subscribe to topic first
243
+ await client.subscribe_to_topic("room:lobby")
244
+
245
+ # Add event-specific handlers for custom events
246
+ client.add_event_handler("room:lobby", "user_join", handle_user_join)
247
+ client.add_event_handler("room:lobby", "user_leave", handle_user_leave)
248
+
249
+ # Use built-in method to keep connection alive
250
+ await client.run_forever()
251
+
252
+ asyncio.run(main())
253
+ ```
254
+
255
+ ## Unsubscribing from Topics
256
+
257
+ You can unsubscribe from topics to stop receiving messages and clean up resources:
258
+
259
+ ```python
260
+ import asyncio
261
+ from phoenix_channels_python_client import PHXChannelsClient
262
+
263
+ async def on_message(msg):
264
+ print(f"Message: {msg.payload}")
265
+
266
+ async def main():
267
+ async with PHXChannelsClient("ws://localhost:4000/socket/websocket", "api-key") as client:
268
+ # Subscribe to a topic
269
+ await client.subscribe_to_topic("room:lobby", on_message)
270
+
271
+ # Do some work...
272
+ await asyncio.sleep(5)
273
+
274
+ # Unsubscribe when no longer needed
275
+ await client.unsubscribe_from_topic("room:lobby")
276
+ print("Unsubscribed from room:lobby")
277
+
278
+ # Continue with other work or subscribe to different topics
279
+ await client.run_forever()
280
+
281
+ asyncio.run(main())
282
+ ```
283
+
284
+ **Notes on unsubscription:**
285
+ - Removes all handlers (both message and event-specific) for that topic
286
+ - Sends `phx_leave` and waits for the reply (`leave_timeout_s`, default 5 s)
287
+ - Raises `PHXTopicError` if the topic isn't subscribed or the leave times out, and `PHXConnectionError` while disconnected
288
+
289
+ ## Configuration
290
+
291
+ Everything after `api_key` is keyword-only:
292
+
293
+ | Option | Default | Meaning |
294
+ |---|---|---|
295
+ | `protocol_version` | `V2` | Phoenix Channels protocol version |
296
+ | `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
297
+ | `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
298
+ | `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats |
299
+ | `join_timeout_s` | `10.0` | Wait for a join reply |
300
+ | `leave_timeout_s` | `5.0` | Wait for a leave reply |
301
+ | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
302
+ | `callback_drain_timeout_s` | `2.0` | How long the message being handled (its message handler, then its event handler) may finish before a rejoin cancels it; queued messages are dropped |
303
+ | `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
304
+ | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
305
+ | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
306
+ | `on_topic_lost` | `None` | `async (topic: str, error: Exception) -> None`, called when the client drops a topic you didn't unsubscribe |
307
+ | `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
308
+
309
+ Exceptions raised in callbacks are logged, not raised.
310
+
311
+ ## Connection Lifecycle and Errors
312
+
313
+ - `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
314
+ - `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, after repeated rapid disconnects, or when the client has never entered `async with`.
315
+ - `subscribe_to_topic()` raises `PHXTopicError` on a rejected join, a join timeout or a duplicate subscription, and `PHXConnectionError` while disconnected or if the connection drops before the join completes.
316
+ - `await client.close_connection(reason)` force-closes the current connection; the client then reconnects if `auto_reconnect` is on. It does nothing if the client isn't connected or the connection is already closing.
317
+ - Exceptions live in `phoenix_channels_python_client.exceptions`.
318
+
319
+ ### Reconnects
320
+
321
+ | Close code | Behaviour (defaults) |
322
+ |---|---|
323
+ | 1000, 1001 | Stop, unless `reconnect_on_normal_close=True` |
324
+ | 1008 | Stop with `PHXConnectionError` (`policy_violation_is_terminal=True`) |
325
+ | 1012 | Reconnect after at least a random 1–5 s |
326
+ | 1013 | Reconnect after at least a random 30–60 s |
327
+ | Anything else, or no close frame | Reconnect with jittered backoff: 0.5 s × 2ⁿ, capped at 30 s, reset after 60 s of uptime |
328
+
329
+ Disconnects within 5 s of connecting count as rapid and get longer minimum delays; ten within 60 s stop the client with `PHXConnectionError`. Tune all of this with `ReconnectPolicy`.
330
+
331
+ A `ReconnectPolicy` is validated when it is built: an out-of-range or unknown field raises `pydantic.ValidationError`, which is a `ValueError`. To derive one policy from another, validate the merged fields:
332
+
333
+ ```python
334
+ base = ReconnectPolicy()
335
+ policy = ReconnectPolicy.model_validate(base.model_dump() | {"max_delay_s": 10.0})
336
+ ```
337
+
338
+ ### Channel recovery
339
+
340
+ A channel can fail while the socket stays up. When its process crashes, the server sends `phx_error`, and the client rejoins that topic on the same socket, waiting the `ReconnectPolicy` backoff (`base_delay_s`, `factor`, `max_delay_s`, with equal jitter) before each attempt. A rejoin that times out is retried the same way, including one after a socket reconnect. A drop of the socket hands the topic to the socket's own rejoin.
341
+
342
+ The client drops the topic and calls `on_topic_lost(topic, error)` once when the server rejects a rejoin, after a crash or a reconnect, closes the channel with `phx_close`, or its message processor fails. It never calls it for `unsubscribe_from_topic()`, `shutdown()` or a failed first `subscribe_to_topic()`, which raises instead. Subscribing to the topic again from the callback works. `shutdown()` cancels the callback, including when the callback itself called `shutdown()`, so code after that call does not run.
343
+
344
+ `get_current_subscriptions()` lists the subscribed topics. `is_topic_joined(topic)` tells whether a topic's channel is joined right now: it's false while the socket is down or a crashed channel waits to rejoin. Unsubscribing a topic whose channel is being recovered completes without waiting for a leave reply.
345
+
346
+ ### Heartbeats
347
+
348
+ The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. A missed reply only logs a warning; to act on it, combine `on_heartbeat_ack` with `close_connection()`.
349
+
350
+ ## Logging
351
+
352
+ ```python
353
+ import logging
354
+ from phoenix_channels_python_client import setup_logging
355
+
356
+ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_client"
357
+ ```
358
+
359
+ ## Examples
360
+
361
+ - [`examples/minimal_phx_events_client.py`](examples/minimal_phx_events_client.py): subscribe to a topic and log messages until Ctrl+C or SIGTERM.
362
+ - [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py): run the client in a host that owns its process signals.
363
+
364
+ ---
365
+
366
+ ## Security Considerations
367
+
368
+ **API Key in URL**: This client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). Even over WSS (encrypted in transit), the API key may be logged by:
369
+
370
+ - Server access logs
371
+ - Reverse proxies and load balancers
372
+ - Network monitoring tools
373
+
374
+ **Recommendations:**
375
+ - Ensure your infrastructure does not log full URLs in production
376
+ - Use short-lived, rotatable tokens rather than long-lived API keys
377
+ - Consider this limitation when evaluating this client for sensitive environments
378
+
379
+ **Note:** The official Phoenix JS client (v1.8+) supports header-based authentication via the `authToken` option, which avoids URL logging. This client always sends `api_key` in the URL (logged URLs show `api_key=***`). Pass `additional_headers={"x-api-key": key}` to also send it as a handshake header on every (re)connect, e.g. for a proxy that reads it from there.
380
+
381
+ ---
382
+
383
+ **Need help?** Open an issue on GitHub or check the [Phoenix Channels documentation](https://hexdocs.pm/phoenix/channels.html) for more information about the Phoenix Channels protocol.