phoenix-channels-python-client 0.4.4__tar.gz → 0.5.1__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 (75) hide show
  1. phoenix_channels_python_client-0.5.1/PKG-INFO +747 -0
  2. phoenix_channels_python_client-0.5.1/README.md +714 -0
  3. phoenix_channels_python_client-0.5.1/phoenix_channels_python_client/__init__.py +57 -0
  4. phoenix_channels_python_client-0.5.1/phoenix_channels_python_client/channel.py +96 -0
  5. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/client.py +111 -101
  6. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/client_types.py +13 -5
  7. phoenix_channels_python_client-0.5.1/phoenix_channels_python_client/codec.py +208 -0
  8. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/default_decider.py +38 -40
  9. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/diagnostics.py +4 -2
  10. phoenix_channels_python_client-0.5.1/phoenix_channels_python_client/exceptions.py +14 -0
  11. phoenix_channels_python_client-0.5.1/phoenix_channels_python_client/messages.py +66 -0
  12. phoenix_channels_python_client-0.5.1/phoenix_channels_python_client/protocol.py +18 -0
  13. phoenix_channels_python_client-0.5.1/phoenix_channels_python_client/protocol_handler.py +127 -0
  14. phoenix_channels_python_client-0.5.1/phoenix_channels_python_client/py.typed +0 -0
  15. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/reconnect_controller.py +7 -7
  16. phoenix_channels_python_client-0.5.1/phoenix_channels_python_client/replies.py +32 -0
  17. phoenix_channels_python_client-0.5.1/phoenix_channels_python_client/send_session.py +180 -0
  18. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/shutdown_signals.py +16 -16
  19. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/supervisor.py +153 -119
  20. phoenix_channels_python_client-0.5.1/phoenix_channels_python_client/topic_runtime.py +1106 -0
  21. phoenix_channels_python_client-0.5.1/phoenix_channels_python_client/topic_subscription.py +122 -0
  22. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/utils.py +12 -30
  23. phoenix_channels_python_client-0.5.1/phoenix_channels_python_client.egg-info/PKG-INFO +747 -0
  24. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client.egg-info/SOURCES.txt +16 -1
  25. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/pyproject.toml +6 -1
  26. phoenix_channels_python_client-0.5.1/tests/test_binary.py +291 -0
  27. phoenix_channels_python_client-0.5.1/tests/test_channel_api.py +229 -0
  28. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_channel_recovery.py +90 -62
  29. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_cleanup_ownership.py +13 -7
  30. phoenix_channels_python_client-0.5.1/tests/test_codec.py +293 -0
  31. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_connection.py +178 -21
  32. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_heartbeat.py +4 -4
  33. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_heartbeat_timeout.py +53 -54
  34. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_join_installation.py +156 -158
  35. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_join_lifecycle.py +232 -92
  36. phoenix_channels_python_client-0.5.1/tests/test_join_params.py +386 -0
  37. phoenix_channels_python_client-0.5.1/tests/test_join_params_serialization.py +124 -0
  38. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_join_responses.py +29 -21
  39. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_optional_lifecycle.py +60 -41
  40. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_optional_reconnect.py +33 -25
  41. phoenix_channels_python_client-0.5.1/tests/test_package_typing.py +84 -0
  42. phoenix_channels_python_client-0.5.1/tests/test_protocol_handler.py +159 -0
  43. phoenix_channels_python_client-0.5.1/tests/test_public_api.py +104 -0
  44. phoenix_channels_python_client-0.5.1/tests/test_push.py +650 -0
  45. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_reconnect.py +133 -112
  46. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_reconnect_decider.py +18 -19
  47. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_reconnect_policy_invariants.py +7 -6
  48. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_reconnect_stress.py +7 -8
  49. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_run_forever_signals.py +5 -5
  50. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_security.py +106 -19
  51. phoenix_channels_python_client-0.5.1/tests/test_send_session.py +335 -0
  52. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_shutdown_races.py +52 -49
  53. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_subscriptions.py +185 -160
  54. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_supervisor_internals.py +34 -36
  55. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_topic_runtime_internals.py +85 -93
  56. phoenix_channels_python_client-0.4.4/PKG-INFO +0 -517
  57. phoenix_channels_python_client-0.4.4/README.md +0 -484
  58. phoenix_channels_python_client-0.4.4/phoenix_channels_python_client/__init__.py +0 -53
  59. phoenix_channels_python_client-0.4.4/phoenix_channels_python_client/exceptions.py +0 -10
  60. phoenix_channels_python_client-0.4.4/phoenix_channels_python_client/phx_messages.py +0 -65
  61. phoenix_channels_python_client-0.4.4/phoenix_channels_python_client/protocol_handler.py +0 -255
  62. phoenix_channels_python_client-0.4.4/phoenix_channels_python_client/topic_runtime.py +0 -919
  63. phoenix_channels_python_client-0.4.4/phoenix_channels_python_client/topic_subscription.py +0 -74
  64. phoenix_channels_python_client-0.4.4/phoenix_channels_python_client.egg-info/PKG-INFO +0 -517
  65. phoenix_channels_python_client-0.4.4/tests/test_protocol_handler.py +0 -228
  66. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/LICENSE +0 -0
  67. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/MANIFEST.in +0 -0
  68. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/authentication.py +0 -0
  69. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/client_state_machine.py +0 -0
  70. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
  71. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client.egg-info/requires.txt +0 -0
  72. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
  73. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/setup.cfg +0 -0
  74. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_cancel_and_wait.py +0 -0
  75. {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.1}/tests/test_client_state.py +0 -0
@@ -0,0 +1,747 @@
1
+ Metadata-Version: 2.4
2
+ Name: phoenix-channels-python-client
3
+ Version: 0.5.1
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: looptime<0.10,>=0.7; extra == "dev"
23
+ Requires-Dist: pytest-cov; extra == "dev"
24
+ Requires-Dist: pre-commit>=3.0.0; extra == "dev"
25
+ Requires-Dist: commitizen>=3.13.0; extra == "dev"
26
+ Requires-Dist: pyrefly; extra == "dev"
27
+ Requires-Dist: ruff; extra == "dev"
28
+ Provides-Extra: test
29
+ Requires-Dist: pytest; extra == "test"
30
+ Requires-Dist: pytest-asyncio>=1.4; extra == "test"
31
+ Requires-Dist: looptime<0.10,>=0.7; extra == "test"
32
+ Dynamic: license-file
33
+
34
+ # Phoenix Channels Python Client
35
+
36
+ [![PyPI version](https://img.shields.io/pypi/v/phoenix-channels-python-client.svg)](https://pypi.org/project/phoenix-channels-python-client/)
37
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
39
+
40
+ 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.
41
+
42
+ ## Installation
43
+
44
+ ### For Users
45
+
46
+ ```bash
47
+ pip install phoenix-channels-python-client
48
+ ```
49
+
50
+ Or from source:
51
+
52
+ ```bash
53
+ git clone https://github.com/band-ai/phoenix-channels-python-client.git
54
+ cd phoenix-channels-python-client
55
+ python3 -m venv venv
56
+ source venv/bin/activate # On Windows: venv\Scripts\activate
57
+ pip install .
58
+ ```
59
+
60
+ ### For Development
61
+
62
+ Set up development environment:
63
+
64
+ ```bash
65
+ git clone https://github.com/band-ai/phoenix-channels-python-client.git
66
+ cd phoenix-channels-python-client
67
+ uv sync --extra dev
68
+ ```
69
+
70
+ Run tests:
71
+
72
+ ```bash
73
+ uv run pytest
74
+ ```
75
+
76
+ ### Dependencies
77
+
78
+ - Python 3.11+
79
+ - `pydantic>=2.8`
80
+ - `websockets>=14.2`
81
+
82
+ ## Public API migration
83
+
84
+ The public names have changed without compatibility aliases:
85
+
86
+ | Previous name | Current name |
87
+ | --- | --- |
88
+ | `PHXChannelsClient` | `Client` |
89
+ | `PHXClientError` | `ClientError` |
90
+ | `PHXTopicError` | `ChannelError` |
91
+ | `PHXConnectionError` | `SocketError` |
92
+ | `PhoenixChannelsProtocolVersion` | `ProtocolVersion` |
93
+ | `PHXEvent` | `SystemEvent` |
94
+ | `ChannelMessage` | `Message` |
95
+ | `phx_messages` module | `messages` module |
96
+ | `Client(url, key)` | `Client(url, api_key=key)` |
97
+ | `setup_logging(level)` | Standard-library logging configuration (see [Logging](#logging)) |
98
+ | `PHXProtocolHandler` | Internal protocol implementation; no public replacement |
99
+ | `get_protocol_handler()` | Removed; protocol parsing and serialization are internal |
100
+ | `Event` | Use `str` or `SystemEvent` for event names |
101
+ | `__author__` | Removed; no public replacement |
102
+
103
+ Import public types from `phoenix_channels_python_client`. `Message` is an
104
+ annotation/result union, not a model constructor. The installed package ships
105
+ `py.typed` for type checkers.
106
+ `SystemEvent` is a `StrEnum`, so members compare directly with their wire strings.
107
+
108
+ Create a `Channel`, register handlers, then join it. Retain the handle for
109
+ readiness, handler changes and cleanup; the topic-string client methods below
110
+ have been removed without compatibility aliases.
111
+
112
+ | Previous method | Channel replacement |
113
+ | --- | --- |
114
+ | `subscribe_to_topic(topic, callback)` | `room = client.channel(topic)`; `room.on_any(callback)`; `await room.join()` or `async with room:` |
115
+ | `unsubscribe_from_topic(topic)` | `await room.leave()` |
116
+ | `is_topic_joined(topic)` | `room.joined` |
117
+ | `add_event_handler(topic, event, handler)` | `room.on(event, handler)` or `@room.on(event)` |
118
+ | `remove_event_handler(topic, event)` | `room.off(event)` |
119
+ | `set_message_handler(topic, handler)` | `room.on_any(handler)` or `@room.on_any` |
120
+ | `remove_message_handler(topic)` | `room.off_any()` |
121
+ | `get_current_subscriptions()` | Retain your channel handles; no replacement collection getter |
122
+ | `get_event_handler`, `has_event_handler`, `list_event_handlers` | Retain your handlers; no replacement introspection methods |
123
+ | `get_message_handler`, `has_message_handler` | Retain your handlers; no replacement introspection methods |
124
+
125
+ ## Quick Start
126
+
127
+ Create the channel, register its handlers, then join with `async with`:
128
+
129
+ ```python
130
+ import asyncio
131
+
132
+ from phoenix_channels_python_client import Client, Payload
133
+
134
+ async def main() -> None:
135
+ async with Client(
136
+ "ws://localhost:4000/socket/websocket", api_key="your-api-key"
137
+ ) as client:
138
+ room = client.channel("room:lobby")
139
+
140
+ @room.on("new_msg")
141
+ async def handle_message(payload: Payload) -> None:
142
+ if isinstance(payload, bytes):
143
+ print(f"Received binary: {payload.hex()}")
144
+ else:
145
+ print(f"Received: {payload}")
146
+
147
+ async with room:
148
+ await client.run_forever()
149
+
150
+ if __name__ == "__main__":
151
+ asyncio.run(main())
152
+ ```
153
+
154
+ `client.channel(...)` is synchronous, returns an unjoined `Channel`, and sends
155
+ nothing. Handlers registered before entry are ready for the first event after
156
+ the join reply. Application queues still drop their oldest event at capacity,
157
+ and recovery can discard undelivered events; this is not a lossless stream.
158
+
159
+ Entering `async with room:` joins; exiting leaves, including on an exception or
160
+ cancellation. With the default `run_forever()`, Ctrl+C or SIGTERM stops the client.
161
+ The previous signal handlers are restored before a second signal is handled.
162
+
163
+ ### Owning process signals yourself
164
+
165
+ If your application owns its process signals (a service, desktop app or test
166
+ runner), call `client.run_forever(install_signal_handlers=False)`. It then
167
+ leaves signal handlers alone; stop the client by scheduling
168
+ `client.shutdown(reason)` on the client's event loop, and `run_forever()`
169
+ returns `None`:
170
+
171
+ ```python
172
+ loop = asyncio.get_running_loop()
173
+ async with asyncio.TaskGroup() as tasks:
174
+ loop.add_signal_handler(
175
+ signal.SIGTERM, lambda: tasks.create_task(client.shutdown("SIGTERM"))
176
+ )
177
+ try:
178
+ await client.run_forever(install_signal_handlers=False)
179
+ finally:
180
+ # Once the group exits, a later SIGTERM must not reach the closed group.
181
+ loop.remove_signal_handler(signal.SIGTERM)
182
+ ```
183
+
184
+ See [`examples/embedded_host_signals.py`](https://github.com/band-ai/phoenix-channels-python-client/blob/main/examples/embedded_host_signals.py) for a complete program.
185
+
186
+ From a plain `signal.signal` handler or another thread, use
187
+ `loop.call_soon_threadsafe(...)` instead: a bare `asyncio.create_task` there
188
+ doesn't wake a loop blocked on I/O, so the shutdown can stall until the next
189
+ network activity.
190
+
191
+ With the default, handlers are installed only when `run_forever()` runs on the
192
+ main thread; elsewhere it just waits. A handler you registered with
193
+ `loop.add_signal_handler` before calling `run_forever()` still fires for the
194
+ same signal; one registered while it runs replaces the client's.
195
+
196
+ ## Phoenix System Events
197
+
198
+ Phoenix reserves `phx_join`, `phx_reply`, `phx_leave`, `phx_close` and
199
+ `phx_error`. Don't use these names for your own events.
200
+
201
+ - The client sends `phx_join` and `phx_leave` and consumes their `phx_reply`.
202
+ - The client handles `phx_error` and `phx_close` itself, and neither reaches
203
+ your handlers: a crashed channel is rejoined, and a channel the server
204
+ closes loses its topic (see [Channel recovery](#channel-recovery)).
205
+ - `channel.on(...)` raises `ValueError` for `SystemEvent.error` and
206
+ `SystemEvent.close`. Use `on_topic_lost` and `channel.joined` instead.
207
+
208
+ ## Protocol Versions
209
+
210
+ Phoenix Channels supports two protocol versions. Choose based on your Phoenix server version:
211
+
212
+ ### Protocol v2.0 (Default)
213
+ ```python
214
+ from phoenix_channels_python_client import Client
215
+
216
+ async with Client(
217
+ websocket_url="ws://localhost:4000/socket/websocket",
218
+ api_key="your-api-key"
219
+ # protocol_version defaults to v2.0
220
+ ) as client:
221
+ ... # your code here
222
+ ```
223
+
224
+ ### Protocol v1.0
225
+ ```python
226
+ from phoenix_channels_python_client import Client, ProtocolVersion
227
+
228
+ async with Client(
229
+ websocket_url="ws://localhost:4000/socket/websocket",
230
+ api_key="your-api-key",
231
+ protocol_version=ProtocolVersion.V1
232
+ ) as client:
233
+ ... # your code here
234
+ ```
235
+
236
+ The client accepts `ProtocolVersion.V1`, `ProtocolVersion.V2`, or their exact
237
+ string values `"1.0"` and `"2.0"`. Other values raise `ValueError`. It adds
238
+ `vsn=2.0.0` (V2) or `vsn=1.0.0` (V1) to the socket URL.
239
+
240
+ ## Usage Examples
241
+
242
+ ### Joining a channel
243
+
244
+ Use `on_any` when you need the complete `Message`:
245
+
246
+ ```python
247
+ import asyncio
248
+
249
+ from phoenix_channels_python_client import Client, Message
250
+
251
+ async def main() -> None:
252
+ async with Client("ws://localhost:4000/socket/websocket", api_key="api-key") as client:
253
+ room = client.channel("chat:general")
254
+
255
+ @room.on_any
256
+ async def message_handler(message: Message) -> None:
257
+ print(f"Topic: {message.topic}")
258
+ print(f"Event: {message.event}")
259
+ print(f"Payload: {message.payload!r}")
260
+
261
+ async with room:
262
+ await client.run_forever()
263
+
264
+ asyncio.run(main())
265
+ ```
266
+
267
+ ## Catch-all vs event handlers
268
+
269
+ A channel supports one catch-all handler and one handler per event:
270
+
271
+ | Registration | Handler receives | Removal |
272
+ | --- | --- | --- |
273
+ | `@room.on_any` or `room.on_any(handler)` | Full `Message` | `room.off_any()` |
274
+ | `@room.on("new_msg")` or `room.on("new_msg", handler)` | `Payload` (dict or bytes) | `room.off("new_msg")` |
275
+
276
+ Both registration forms return the handler unchanged. Registering again replaces
277
+ the previous handler for that slot. Handlers must be async functions. `Payload`
278
+ is the public alias `dict[str, Any] | bytes`; narrow it before accessing dict
279
+ fields or decoding application bytes.
280
+
281
+ For each dispatched event, the catch-all runs first, then its event-specific
282
+ handler. Handler selection is captured for that dispatch: changes apply to later
283
+ dispatches, while teardown or cancellation can interrupt the current one. You may
284
+ add or remove handlers before joining or while the channel is active. Removal is
285
+ idempotent; it does not leave the channel.
286
+
287
+ ### Event-Specific Handlers
288
+
289
+ Register event decorators before joining:
290
+
291
+ ```python
292
+ import asyncio
293
+
294
+ from phoenix_channels_python_client import Client, Payload
295
+
296
+ async def main() -> None:
297
+ async with Client("ws://localhost:4000/socket/websocket", api_key="api-key") as client:
298
+ room = client.channel("room:lobby")
299
+
300
+ @room.on("user_join")
301
+ async def handle_user_join(payload: Payload) -> None:
302
+ if isinstance(payload, bytes):
303
+ print(f"Binary join notice: {payload.hex()}")
304
+ else:
305
+ print(f"User joined: {payload}")
306
+
307
+ @room.on("user_leave")
308
+ async def handle_user_leave(payload: Payload) -> None:
309
+ if isinstance(payload, bytes):
310
+ print(f"Binary leave notice: {payload.hex()}")
311
+ else:
312
+ print(f"User left: {payload}")
313
+
314
+ async with room:
315
+ await client.run_forever()
316
+
317
+ asyncio.run(main())
318
+ ```
319
+
320
+ ## Leaving a channel
321
+
322
+ Leaving a channel stops delivery and cleans up its membership. Prefer a context
323
+ manager so exceptions and cancellation also trigger cleanup:
324
+
325
+ ```python
326
+ import asyncio
327
+
328
+ from phoenix_channels_python_client import Client, Message
329
+
330
+ async def main() -> None:
331
+ async with Client("ws://localhost:4000/socket/websocket", api_key="api-key") as client:
332
+ room = client.channel("room:lobby")
333
+
334
+ @room.on_any
335
+ async def on_message(message: Message) -> None:
336
+ print(f"Message: {message.payload!r}")
337
+
338
+ async with room:
339
+ await asyncio.sleep(5)
340
+
341
+ await room.leave() # Repeated leave is harmless.
342
+ print(f"Left {room.topic}; joined={room.joined}")
343
+
344
+ asyncio.run(main())
345
+ ```
346
+
347
+ For manual ownership, call `await room.join()` and ensure `await room.leave()`
348
+ runs in your cleanup path. A left handle cannot join again; create a new channel.
349
+ `room.joined` is false during leave or recovery, and `room.topic` remains available.
350
+ A successful join confirms its reply was installed; a later server failure can
351
+ already have changed readiness by the time your code resumes.
352
+
353
+ Join rejection and timeout raise `ChannelError`; joining without a connected
354
+ client raises `SocketError`. A second live channel for the same topic raises
355
+ `ChannelError`. Duplicate/disconnected attempts rejected before admission leave
356
+ the created handle usable. A failed admitted initial join retires that handle.
357
+
358
+ Leave is idempotent, including while joining, rejoining or disconnected. A live
359
+ membership sends a JSON leave and uses `leave_timeout_s`; failure can raise
360
+ `ChannelError` or `SocketError`. Cleanup remains owned if its waiter is cancelled.
361
+ If cleanup cannot confirm that the old membership ended, its topic can stay
362
+ reserved until a terminal control or connection end makes reuse safe. A new handle
363
+ for that topic then raises `ChannelError`; unrelated channels remain usable.
364
+
365
+ ### Join parameters
366
+
367
+ `params` follows Phoenix's name for the join payload. Pass a dict, `None` for `{}`,
368
+ or a synchronous callable returning a dict:
369
+
370
+ ```python
371
+ room = client.channel("room:lobby", params={"locale": "en"})
372
+ room = client.channel("room:private", params=lambda: {"token": fresh_token()})
373
+ ```
374
+
375
+ The callable is evaluated once for each actual initial join or rejoin, allowing
376
+ fresh credentials after reconnect. Creation alone does not call it or send a
377
+ frame. Providers must be nonblocking and return a JSON-serializable dict;
378
+ provider/encoding failure raises `ChannelError` with its cause. A provider failure
379
+ during recovery permanently loses only that channel instead of retrying forever.
380
+
381
+ ### Ordered join responses
382
+
383
+ Pass `on_join_response` when creating a channel to install a snapshot from every
384
+ initial join, socket rejoin and same-socket recovery:
385
+
386
+ ```python
387
+ state = None
388
+
389
+ def install_snapshot(response):
390
+ global state
391
+ state = validate_snapshot(response)
392
+
393
+ room = client.channel("room:123", on_join_response=install_snapshot)
394
+ room.on_any(on_message)
395
+ async with room:
396
+ await client.run_forever()
397
+ ```
398
+
399
+ `JoinResponseCallback` is exported from the package. The hook receives decoded
400
+ successful `payload.response`; a missing response is `None`. It runs synchronously
401
+ before the channel becomes joined, the join completes, or later events are
402
+ delivered. The client adds no application schema or empty snapshot. Join and leave
403
+ replies are handled separately from the bounded application-event queue.
404
+
405
+ Hooks must be short, nonblocking and return `None`. Async callables, awaitables
406
+ and other non-`None` results fail installation with `ChannelError`, as do hook
407
+ exceptions (including a directly raised `CancelledError`); the original cause is
408
+ preserved. Arbitrary prior hook effects are not rolled back. Do not wait on locks,
409
+ perform I/O, defer installation or change client lifecycle inside the hook. An
410
+ asyncio deadline cannot preempt synchronous code that blocks the loop.
411
+
412
+ Initial failure retires the admitted handle. Installation failure on an established
413
+ channel is reported once through `on_topic_lost`. Queue overflow and recovery can
414
+ still discard application events, so applications needing durable state must
415
+ resynchronize rather than assume replay.
416
+
417
+ Join replies match request refs on both protocols. V2 additionally checks a
418
+ present `join_ref`; a missing join_ref is accepted, including for broadcasts.
419
+ Membership controls use their available refs for correlation. V1 cannot provide
420
+ V2 isolation for arbitrary unsolicited events from old memberships.
421
+
422
+ ## Pushing Events
423
+
424
+ Use `await channel.push(event, payload, timeout_s=10.0)` to send an application
425
+ event and wait for its matching reply. Pass a JSON-serializable dict or, on V2,
426
+ `bytes` (see [Binary payloads](#binary-payloads)). `timeout_s` must be finite and
427
+ greater than zero. The default is 10 seconds.
428
+
429
+ ```python
430
+ import asyncio
431
+
432
+ from phoenix_channels_python_client import Client, Reply
433
+
434
+ async def main() -> None:
435
+ async with Client("ws://localhost:4000/socket/websocket", api_key="api-key") as client:
436
+ room = client.channel("room:lobby")
437
+ async with room:
438
+ reply: Reply = await room.push("new_msg", {"body": "Hello"}, timeout_s=5.0)
439
+ if reply.status == "ok":
440
+ print(reply.response)
441
+ else:
442
+ print(f"Server replied {reply.status}: {reply.response!r}")
443
+
444
+ asyncio.run(main())
445
+ ```
446
+
447
+ `Reply` is a frozen Pydantic model with `status: str` and
448
+ `response: Payload`. A text reply has a dict response; a binary reply has a bytes
449
+ response. Status values are server-defined: an `"error"` reply
450
+ is returned as a `Reply`, without raising `ChannelError`.
451
+
452
+ | Situation | Result |
453
+ | --- | --- |
454
+ | Channel is not joined, including while rejoining | `ChannelError` immediately, even if the socket is also unavailable |
455
+ | Connection is lost after joined admission, before or during send/wait | `SocketError` |
456
+ | Deadline expires without a reply | `PushTimeoutError` (a `ChannelError` subclass) |
457
+ | Channel crashes, closes, is explicitly left, or has a local processing failure | `ChannelError` |
458
+ | Client is explicitly shut down while a push is pending | `ChannelError` |
459
+ | Server returns an error status | `Reply` with that status and response |
460
+ | Caller is cancelled | `asyncio.CancelledError` |
461
+
462
+ A malformed matching reply raises `ChannelError` with the validation cause.
463
+ Invalid payload encoding raises its validation or encoding exception before
464
+ submission. A timeout after a completed send affects only that push. If a write
465
+ remains stalled at its deadline, the client closes that socket; other pending
466
+ pushes fail with `SocketError` and normal reconnect policy applies. An already
467
+ settled reply or failure keeps its outcome.
468
+
469
+ Pushes are never buffered or replayed after recovery. A timeout or cancellation
470
+ does not establish whether the server executed the request; choose retries at
471
+ the application level.
472
+
473
+ Replies are resolved independently of topic handlers. A handler may await a
474
+ push on its own channel. `push()` does not wait for earlier messages to finish
475
+ handling, so its completion does not imply that earlier callbacks completed.
476
+ Only a reply matching a pending push is consumed by that caller; an unmatched
477
+ or late reply can reach the normal handlers, subject to current membership
478
+ checks.
479
+
480
+ ### Binary payloads
481
+
482
+ V2 supports `bytes` for pushes, replies, server events and broadcasts. Dicts use
483
+ JSON text frames in both protocols. Passing bytes on V1 raises `TypeError` before
484
+ submission; the client does not encode, decode or select an application format
485
+ for you. Join parameters and lifecycle control frames remain JSON dicts.
486
+
487
+ For example, the [protobuf wire format](https://protobuf.dev/programming-guides/encoding/)
488
+ for `message Update { string body = 1; }` serializes `body="hello"` to the bytes below. With generated protobuf classes,
489
+ pass `update.SerializeToString()` and parse a bytes response with your generated
490
+ class. This example uses the wire bytes directly, so it needs no protobuf
491
+ dependency:
492
+
493
+ ```python
494
+ import asyncio
495
+
496
+ from phoenix_channels_python_client import Client, ProtocolVersion
497
+
498
+ async def main() -> None:
499
+ async with Client(
500
+ "ws://localhost:4000/socket/websocket",
501
+ api_key="api-key",
502
+ protocol_version=ProtocolVersion.V2,
503
+ ) as client:
504
+ room = client.channel("room:lobby")
505
+ async with room:
506
+ encoded_update = b"\x0a\x05hello"
507
+ reply = await room.push("update", encoded_update)
508
+ if isinstance(reply.response, bytes):
509
+ print(f"Protobuf response: {reply.response.hex()}")
510
+ else:
511
+ print(f"JSON response: {reply.response}")
512
+
513
+ asyncio.run(main())
514
+ ```
515
+
516
+ Event handlers and `Message.payload` also receive raw bytes for binary server
517
+ events and broadcasts. A broadcast can have no `join_ref` and is delivered to
518
+ its current topic. The application chooses the payload decoder; binary data
519
+ is never interpreted as JSON merely because it contains JSON-looking bytes.
520
+
521
+ ## Configuration
522
+
523
+ `api_key` is keyword-only and defaults to `None`. Without an
524
+ authentication header, a supplied key replaces any `api_key` in the URL; omitting
525
+ it (or passing `None`) keeps the URL credential, if present. An empty argument
526
+ (`api_key=""`) raises `ValueError`.
527
+
528
+ Connect without a credential, or keep one already in the URL:
529
+
530
+ ```python
531
+ async with Client("ws://localhost:4000/socket/websocket") as client:
532
+ ...
533
+
534
+ async with Client(
535
+ "wss://server.com/socket/websocket?api_key=your-api-key"
536
+ ) as client:
537
+ ...
538
+ ```
539
+
540
+ Only `websocket_url` may be positional; all other constructor options are keyword-only:
541
+
542
+ | Option | Default | Meaning |
543
+ |---|---|---|
544
+ | `protocol_version` | `V2` | Phoenix Channels protocol version |
545
+ | `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
546
+ | `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
547
+ | `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats and timeout enforcement |
548
+ | `heartbeat_dead_threshold_s` | `None` | Optional finite positive acknowledgement-silence limit, greater than the enabled finite interval |
549
+ | `join_timeout_s` | `10.0` | Finite positive join-operation deadline |
550
+ | `leave_timeout_s` | `5.0` | Finite positive leave-operation deadline |
551
+ | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
552
+ | `callback_drain_timeout_s` | `2.0` | How long the message being handled (its catch-all handler, then its event handler) may finish before a rejoin cancels it; queued messages are dropped |
553
+ | `on_reconnect` | `None` | `async () -> None`, called after required first-attempt reconciliation |
554
+ | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
555
+ | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
556
+ | `on_topic_lost` | `None` | `async (topic: str, error: Exception) -> None`, called when the client permanently loses a channel you did not leave |
557
+ | `reconnect_decider` | `None` | Synchronous `(event: ReconnectEvent) -> ReconnectDecision`; replaces the built-in reconnect policy (see [Owning the reconnect policy](#owning-the-reconnect-policy)) |
558
+ | `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
559
+
560
+ Exceptions raised in ordinary message and lifecycle callbacks are logged, not raised.
561
+ Join-response installation failures instead fail the join (see below).
562
+
563
+ ## Connection Lifecycle and Errors
564
+
565
+ - `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying, except after a refused handshake with HTTP 403, which raises `SocketError`; with `auto_reconnect=False` a failed first connection raises `SocketError`.
566
+ - `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `SocketError` on a terminal close, a refused handshake with HTTP 403, after repeated rapid disconnects, or when the client has never entered `async with`.
567
+ - `channel.join()` raises `ChannelError` on rejection, timeout or duplicate live membership, and `SocketError` when the client is disconnected or the connection fails before admission completes.
568
+ - `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.
569
+ - `await client.shutdown()` uses the default reason `"Client shutdown"`; pass a
570
+ reason to describe an explicit stop.
571
+ - `ClientError`, `ChannelError`, `SocketError` and `PushTimeoutError` are exported
572
+ from the package and defined in `phoenix_channels_python_client.exceptions`.
573
+ `ChannelError` and `SocketError` derive from `ClientError`; `PushTimeoutError`
574
+ derives from `ChannelError`.
575
+
576
+ ### Reconnects
577
+
578
+ | Close code or handshake | Behaviour (defaults) |
579
+ |---|---|
580
+ | 1000, 1001 | Reconnect (Phoenix ≥ 1.8.8 sends 1001 for "reconnect expected"); stop with `reconnect_on_normal_close=False` |
581
+ | 1008 | Stop with `SocketError` (`policy_violation_is_terminal=True`) |
582
+ | Handshake refused with HTTP 403 | Stop with `SocketError`; other statuses (401, 5xx, …) retry with the backoff below |
583
+ | 1012 | Reconnect after at least a random 1–5 s |
584
+ | 1013 | Reconnect after at least a random 30–60 s |
585
+ | Anything else, or no close frame | Reconnect with jittered backoff: 0.5 s × 2ⁿ, capped at 30 s, reset after 60 s of uptime |
586
+
587
+ Disconnects within 5 s of connecting count as rapid and get longer minimum delays; ten within 60 s stop the client with `SocketError`. Tune all of this with `ReconnectPolicy`.
588
+
589
+ 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:
590
+
591
+ ```python
592
+ base = ReconnectPolicy()
593
+ policy = ReconnectPolicy.model_validate(base.model_dump() | {"max_delay_s": 10.0})
594
+ ```
595
+
596
+ ### Owning the reconnect policy
597
+
598
+ Pass `reconnect_decider` to make every reconnect decision yourself. While `auto_reconnect` is on, the client calls it with:
599
+
600
+ - `Connecting()` before every connection attempt, the first included;
601
+ - `Connected(generation)` when a socket is up, before any topic rejoins on it;
602
+ - `Disconnected(close_code, close_reason)` after every close. A forced close (heartbeat expiry, `close_connection()`) has `close_code=None`, since the received code is unreliable then;
603
+ - `ConnectFailed(error)` after every failed attempt, with the raw exception (a refused handshake is a `websockets` `InvalidStatus` carrying the response).
604
+
605
+ It returns a `ReconnectDecision`:
606
+
607
+ - `ReconnectDecision(should_reconnect=True, delay_s=2.0)` after `Disconnected` or `ConnectFailed` waits exactly 2 s before the next attempt; `delay_s` defaults to `0`. `Connecting` and `Connected` only proceed or stop, and a delay there stops the client with `SocketError`.
608
+ - `ReconnectDecision(should_reconnect=False)` stops; `run_forever()` returns `None`, and before the first connection `async with` raises `SocketError`. A stop at `Connected` closes that socket before any rejoin.
609
+ - `ReconnectDecision(should_reconnect=False, terminal_error=SocketError(...))` stops; `run_forever()` (or `async with`, before the first connection) raises that error.
610
+
611
+ The decider replaces the whole built-in policy above, rapid-disconnect suppression included; the built-in policy is itself a decider over the same events. It runs inline on the client's supervisor task, so keep it fast and never block. The client calls the same decider on every `async with` entry, so reset any per-run state yourself; the built-in one is rebuilt each time. If it raises, the client stops with a `SocketError` whose `__cause__` is that exception; if it returns anything but a `ReconnectDecision` (an `async def` decider returns a coroutine), the client stops with a `SocketError` too. It is not asked once `shutdown()` has begun. To end a live connection, call `close_connection()` and stop at the next decision.
612
+
613
+ ```python
614
+ def decide(event: ReconnectEvent) -> ReconnectDecision:
615
+ match event:
616
+ case ConnectFailed(error=InvalidStatus(response=response)) if response.status_code == 403:
617
+ return ReconnectDecision(should_reconnect=False, terminal_error=SocketError("refused"))
618
+ case Disconnected() | ConnectFailed():
619
+ return ReconnectDecision(should_reconnect=True, delay_s=1.0)
620
+ case _:
621
+ return ReconnectDecision(should_reconnect=True)
622
+ ```
623
+
624
+ ### Channel recovery
625
+
626
+ A channel can crash while the socket stays up. On `phx_error`, the client marks it
627
+ not joined and retries its join on that socket using the `ReconnectPolicy`
628
+ backoff. A missed rejoin reply is transient and retried. A socket disconnect hands
629
+ recovery to the next connection. Only established channels recover automatically;
630
+ a failed initial join belongs to its caller.
631
+
632
+ Rejected rejoins, invalid join params, installation failures and server
633
+ `phx_close` permanently lose that channel. The client calls
634
+ `on_topic_lost(topic, error)` once for permanent loss. Explicit leave, shutdown
635
+ and failed initial join do not call it. Create a fresh handle to replace a lost
636
+ channel after its old cleanup releases the topic.
637
+
638
+ Retain your channel handles and check `room.joined`: it is false while disconnected
639
+ or rejoining. `await room.leave()` also works during recovery and stops future
640
+ rejoin attempts. Client lifecycle callbacks stay constructor options; `shutdown()`
641
+ cancels them, including a callback that itself called `shutdown()`.
642
+
643
+ ### Optional reconnect participation
644
+
645
+ `client.channel(topic, *, params=None, on_join_response=None,
646
+ blocks_reconnect=True)` creates a channel that blocks the reconnect barrier by
647
+ default. Required channels reconcile their first socket-rejoin attempt sequentially
648
+ before `on_reconnect`; transient failures continue through recovery/backoff. The
649
+ barrier does not wait for eventual success of every required channel.
650
+
651
+ Set `blocks_reconnect=False` when creating an optional channel. Its owned recovery
652
+ task attempts an immediate join after required first attempts and retries transient
653
+ failures with the same backoff. Required delivery and `on_reconnect` do not wait for
654
+ optional replies, callback drains or retries. Explicit `await room.join()` still
655
+ waits for that channel's reply and installation.
656
+
657
+ `on_reconnect` does not promise optional readiness. Check each handle's `joined`
658
+ property and use `on_topic_lost` to replace permanent losses. Do not blindly create
659
+ replacement channels on every reconnect. Leave, shutdown and another socket
660
+ disconnect stop or hand over recovery; obsolete attempts cannot revive a left
661
+ handle.
662
+
663
+ ### Heartbeats
664
+
665
+ The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. By default, missed replies log warnings and sending continues with a new reference each interval.
666
+
667
+ Set `heartbeat_dead_threshold_s` to enable closure after acknowledgement silence:
668
+
669
+ ```python
670
+ client = Client(
671
+ websocket_url, api_key=api_key,
672
+ heartbeat_interval_s=30.0,
673
+ heartbeat_dead_threshold_s=60.0,
674
+ )
675
+ ```
676
+
677
+ The deadline starts when each socket is adopted, including during rejoin and reconnect callbacks. Only a matching pending reply received strictly before expiry renews it, before `on_heartbeat_ack` runs. Enabled enforcement keeps one outstanding heartbeat, skipping sends until it is acknowledged or expires. WebSocket Pong and unrelated messages do not renew it. Rejoin completion grants no additional grace.
678
+
679
+ Expiry or heartbeat send failure force-closes that socket; the supervisor handles cleanup and reconnect/rejoin according to `auto_reconnect` and the existing policy. The deadline bounds close initiation; the transport has its own close-handshake timeout. `heartbeat_interval_s=None` disables heartbeat activity even with a threshold, but a supplied threshold must still be finite and positive. Long thresholds do not prevent the server from applying its own inactivity timeout.
680
+
681
+ ## Logging
682
+
683
+ The library uses loggers under `phoenix_channels_python_client`. Configure
684
+ logging in your application; importing the package does not configure it:
685
+
686
+ ```python
687
+ import logging
688
+
689
+ logging.basicConfig(level=logging.INFO)
690
+ logging.getLogger("phoenix_channels_python_client").setLevel(logging.DEBUG)
691
+ ```
692
+
693
+ ## Examples
694
+
695
+ - [`examples/minimal_phx_events_client.py`](https://github.com/band-ai/phoenix-channels-python-client/blob/main/examples/minimal_phx_events_client.py): join a channel and log messages until Ctrl+C or SIGTERM.
696
+ - [`examples/embedded_host_signals.py`](https://github.com/band-ai/phoenix-channels-python-client/blob/main/examples/embedded_host_signals.py): run the client in a host that owns its process signals.
697
+
698
+ ---
699
+
700
+ ## Security Considerations
701
+
702
+ **API Key in URL**: Without an authentication header, 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:
703
+
704
+ - Server access logs
705
+ - Reverse proxies and load balancers
706
+ - Network monitoring tools
707
+
708
+ **Recommendations:**
709
+ - Ensure your infrastructure does not log full URLs in production
710
+ - Use short-lived, rotatable tokens rather than long-lived API keys
711
+ - Consider this limitation when evaluating this client for sensitive environments
712
+
713
+ Pass `additional_headers={"x-api-key": key}` or `{"x-auth-token": jwt}` to use header-only authentication on every connection and reconnect.
714
+
715
+ No `api_key` argument is needed for header authentication:
716
+
717
+ ```python
718
+ async with Client(
719
+ "wss://server.com/socket/websocket",
720
+ additional_headers={"x-api-key": "your-api-key"},
721
+ ) as client:
722
+ ...
723
+ ```
724
+
725
+ ### Authentication and diagnostic safety
726
+
727
+ A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
728
+ every connection and reconnect. Embedded API-key parameters are removed; empty
729
+ or conflicting authentication headers and simultaneous token-query authentication
730
+ are rejected before connecting. A JWT header overrides an embedded token and takes
731
+ precedence over an API-key header, matching the platform. Without an authentication header, legacy query authentication
732
+ is preserved and remains visible to servers and proxies. No fallback is attempted.
733
+
734
+ `client.logger` forwards sanitized connection records without raw arguments,
735
+ exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
736
+ the credential policy to transport errors a caller needs to retain. Caller-visible
737
+ errors may still contain personal data and should not be logged outside this policy.
738
+
739
+ Connection diagnostics omit dynamic strings and transport payloads, including URLs,
740
+ headers, frame bodies, user/topic identities, and untrusted server error details.
741
+ Logs retain static lifecycle descriptions, native connection states,
742
+ and exception types. Application data and caller-visible errors are not changed;
743
+ applications remain responsible for logs outside this connection-owned boundary.
744
+
745
+ ---
746
+
747
+ **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.