phoenix-channels-python-client 0.5.0__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 (65) hide show
  1. {phoenix_channels_python_client-0.5.0/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.5.1}/PKG-INFO +76 -74
  2. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/README.md +75 -73
  3. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/__init__.py +1 -1
  4. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1/phoenix_channels_python_client.egg-info}/PKG-INFO +76 -74
  5. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/pyproject.toml +1 -1
  6. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/LICENSE +0 -0
  7. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/MANIFEST.in +0 -0
  8. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/authentication.py +0 -0
  9. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/channel.py +0 -0
  10. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/client.py +0 -0
  11. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/client_state_machine.py +0 -0
  12. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/client_types.py +0 -0
  13. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/codec.py +0 -0
  14. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/default_decider.py +0 -0
  15. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/diagnostics.py +0 -0
  16. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/exceptions.py +0 -0
  17. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/messages.py +0 -0
  18. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/protocol.py +0 -0
  19. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/protocol_handler.py +0 -0
  20. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/py.typed +0 -0
  21. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/reconnect_controller.py +0 -0
  22. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/replies.py +0 -0
  23. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/send_session.py +0 -0
  24. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/shutdown_signals.py +0 -0
  25. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/supervisor.py +0 -0
  26. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/topic_runtime.py +0 -0
  27. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/topic_subscription.py +0 -0
  28. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client/utils.py +0 -0
  29. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client.egg-info/SOURCES.txt +0 -0
  30. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
  31. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client.egg-info/requires.txt +0 -0
  32. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
  33. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/setup.cfg +0 -0
  34. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_binary.py +0 -0
  35. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_cancel_and_wait.py +0 -0
  36. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_channel_api.py +0 -0
  37. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_channel_recovery.py +0 -0
  38. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_cleanup_ownership.py +0 -0
  39. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_client_state.py +0 -0
  40. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_codec.py +0 -0
  41. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_connection.py +0 -0
  42. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_heartbeat.py +0 -0
  43. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_heartbeat_timeout.py +0 -0
  44. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_join_installation.py +0 -0
  45. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_join_lifecycle.py +0 -0
  46. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_join_params.py +0 -0
  47. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_join_params_serialization.py +0 -0
  48. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_join_responses.py +0 -0
  49. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_optional_lifecycle.py +0 -0
  50. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_optional_reconnect.py +0 -0
  51. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_package_typing.py +0 -0
  52. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_protocol_handler.py +0 -0
  53. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_public_api.py +0 -0
  54. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_push.py +0 -0
  55. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_reconnect.py +0 -0
  56. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_reconnect_decider.py +0 -0
  57. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_reconnect_policy_invariants.py +0 -0
  58. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_reconnect_stress.py +0 -0
  59. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_run_forever_signals.py +0 -0
  60. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_security.py +0 -0
  61. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_send_session.py +0 -0
  62. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_shutdown_races.py +0 -0
  63. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_subscriptions.py +0 -0
  64. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_supervisor_internals.py +0 -0
  65. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.1}/tests/test_topic_runtime_internals.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phoenix-channels-python-client
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: A Python client library for connecting to Phoenix Channels
5
5
  Author: Phoenix Channels Python Client
6
6
  License-Expression: MIT
@@ -181,10 +181,12 @@ async with asyncio.TaskGroup() as tasks:
181
181
  loop.remove_signal_handler(signal.SIGTERM)
182
182
  ```
183
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
+
184
186
  From a plain `signal.signal` handler or another thread, use
185
187
  `loop.call_soon_threadsafe(...)` instead: a bare `asyncio.create_task` there
186
188
  doesn't wake a loop blocked on I/O, so the shutdown can stall until the next
187
- network activity. See [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py).
189
+ network activity.
188
190
 
189
191
  With the default, handlers are installed only when `run_forever()` runs on the
190
192
  main thread; elsewhere it just waits. A handler you registered with
@@ -237,7 +239,7 @@ string values `"1.0"` and `"2.0"`. Other values raise `ValueError`. It adds
237
239
 
238
240
  ## Usage Examples
239
241
 
240
- ### Basic Topic Subscription
242
+ ### Joining a channel
241
243
 
242
244
  Use `on_any` when you need the complete `Message`:
243
245
 
@@ -254,7 +256,7 @@ async def main() -> None:
254
256
  async def message_handler(message: Message) -> None:
255
257
  print(f"Topic: {message.topic}")
256
258
  print(f"Event: {message.event}")
257
- print(f"Payload: {message.payload}")
259
+ print(f"Payload: {message.payload!r}")
258
260
 
259
261
  async with room:
260
262
  await client.run_forever()
@@ -262,7 +264,7 @@ async def main() -> None:
262
264
  asyncio.run(main())
263
265
  ```
264
266
 
265
- ## Message Handlers vs Event-Specific Handlers
267
+ ## Catch-all vs event handlers
266
268
 
267
269
  A channel supports one catch-all handler and one handler per event:
268
270
 
@@ -315,7 +317,7 @@ async def main() -> None:
315
317
  asyncio.run(main())
316
318
  ```
317
319
 
318
- ## Unsubscribing from Topics
320
+ ## Leaving a channel
319
321
 
320
322
  Leaving a channel stops delivery and cleans up its membership. Prefer a context
321
323
  manager so exceptions and cancellation also trigger cleanup:
@@ -331,7 +333,7 @@ async def main() -> None:
331
333
 
332
334
  @room.on_any
333
335
  async def on_message(message: Message) -> None:
334
- print(f"Message: {message.payload}")
336
+ print(f"Message: {message.payload!r}")
335
337
 
336
338
  async with room:
337
339
  await asyncio.sleep(5)
@@ -376,6 +378,47 @@ frame. Providers must be nonblocking and return a JSON-serializable dict;
376
378
  provider/encoding failure raises `ChannelError` with its cause. A provider failure
377
379
  during recovery permanently loses only that channel instead of retrying forever.
378
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
+
379
422
  ## Pushing Events
380
423
 
381
424
  Use `await channel.push(event, payload, timeout_s=10.0)` to send an application
@@ -396,7 +439,7 @@ async def main() -> None:
396
439
  if reply.status == "ok":
397
440
  print(reply.response)
398
441
  else:
399
- print(f"Server replied {reply.status}: {reply.response}")
442
+ print(f"Server replied {reply.status}: {reply.response!r}")
400
443
 
401
444
  asyncio.run(main())
402
445
  ```
@@ -506,7 +549,7 @@ Only `websocket_url` may be positional; all other constructor options are keywor
506
549
  | `join_timeout_s` | `10.0` | Finite positive join-operation deadline |
507
550
  | `leave_timeout_s` | `5.0` | Finite positive leave-operation deadline |
508
551
  | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
509
- | `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 |
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 |
510
553
  | `on_reconnect` | `None` | `async () -> None`, called after required first-attempt reconciliation |
511
554
  | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
512
555
  | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
@@ -597,6 +640,26 @@ or rejoining. `await room.leave()` also works during recovery and stops future
597
640
  rejoin attempts. Client lifecycle callbacks stay constructor options; `shutdown()`
598
641
  cancels them, including a callback that itself called `shutdown()`.
599
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
+
600
663
  ### Heartbeats
601
664
 
602
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.
@@ -629,8 +692,8 @@ logging.getLogger("phoenix_channels_python_client").setLevel(logging.DEBUG)
629
692
 
630
693
  ## Examples
631
694
 
632
- - [`examples/minimal_phx_events_client.py`](examples/minimal_phx_events_client.py): subscribe to a topic and log messages until Ctrl+C or SIGTERM.
633
- - [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py): run the client in a host that owns its process signals.
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.
634
697
 
635
698
  ---
636
699
 
@@ -659,10 +722,6 @@ async with Client(
659
722
  ...
660
723
  ```
661
724
 
662
- ---
663
-
664
- **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.
665
-
666
725
  ### Authentication and diagnostic safety
667
726
 
668
727
  A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
@@ -683,63 +742,6 @@ Logs retain static lifecycle descriptions, native connection states,
683
742
  and exception types. Application data and caller-visible errors are not changed;
684
743
  applications remain responsible for logs outside this connection-owned boundary.
685
744
 
686
- ### Ordered join responses
687
-
688
- Pass `on_join_response` when creating a channel to install a snapshot from every
689
- initial join, socket rejoin and same-socket recovery:
690
-
691
- ```python
692
- state = None
693
-
694
- def install_snapshot(response):
695
- global state
696
- state = validate_snapshot(response)
697
-
698
- room = client.channel("room:123", on_join_response=install_snapshot)
699
- room.on_any(on_message)
700
- async with room:
701
- await client.run_forever()
702
- ```
703
-
704
- `JoinResponseCallback` is exported from the package. The hook receives decoded
705
- successful `payload.response`; a missing response is `None`. It runs synchronously
706
- before the channel becomes joined, the join completes, or later events are
707
- delivered. The client adds no application schema or empty snapshot. Join and leave
708
- replies are handled separately from the bounded application-event queue.
709
-
710
- Hooks must be short, nonblocking and return `None`. Async callables, awaitables
711
- and other non-`None` results fail installation with `ChannelError`, as do hook
712
- exceptions (including a directly raised `CancelledError`); the original cause is
713
- preserved. Arbitrary prior hook effects are not rolled back. Do not wait on locks,
714
- perform I/O, defer installation or change client lifecycle inside the hook. An
715
- asyncio deadline cannot preempt synchronous code that blocks the loop.
716
-
717
- Initial failure retires the admitted handle. Installation failure on an established
718
- channel is reported once through `on_topic_lost`. Queue overflow and recovery can
719
- still discard application events, so applications needing durable state must
720
- resynchronize rather than assume replay.
721
-
722
- Join replies match request refs on both protocols. V2 additionally checks a
723
- present `join_ref`; a missing join_ref is accepted, including for broadcasts.
724
- Membership controls use their available refs for correlation. V1 cannot provide
725
- V2 isolation for arbitrary unsolicited events from old memberships.
726
-
727
- ### Optional reconnect participation
728
-
729
- `client.channel(topic, *, params=None, on_join_response=None,
730
- blocks_reconnect=True)` creates a channel that blocks the reconnect barrier by
731
- default. Required channels reconcile their first socket-rejoin attempt sequentially
732
- before `on_reconnect`; transient failures continue through recovery/backoff. The
733
- barrier does not wait for eventual success of every required channel.
734
-
735
- Set `blocks_reconnect=False` when creating an optional channel. Its owned recovery
736
- task attempts an immediate join after required first attempts and retries transient
737
- failures with the same backoff. Required delivery and `on_reconnect` do not wait for
738
- optional replies, callback drains or retries. Explicit `await room.join()` still
739
- waits for that channel's reply and installation.
745
+ ---
740
746
 
741
- `on_reconnect` does not promise optional readiness. Check each handle's `joined`
742
- property and use `on_topic_lost` to replace permanent losses. Do not blindly create
743
- replacement channels on every reconnect. Leave, shutdown and another socket
744
- disconnect stop or hand over recovery; obsolete attempts cannot revive a left
745
- handle.
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.
@@ -148,10 +148,12 @@ async with asyncio.TaskGroup() as tasks:
148
148
  loop.remove_signal_handler(signal.SIGTERM)
149
149
  ```
150
150
 
151
+ 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.
152
+
151
153
  From a plain `signal.signal` handler or another thread, use
152
154
  `loop.call_soon_threadsafe(...)` instead: a bare `asyncio.create_task` there
153
155
  doesn't wake a loop blocked on I/O, so the shutdown can stall until the next
154
- network activity. See [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py).
156
+ network activity.
155
157
 
156
158
  With the default, handlers are installed only when `run_forever()` runs on the
157
159
  main thread; elsewhere it just waits. A handler you registered with
@@ -204,7 +206,7 @@ string values `"1.0"` and `"2.0"`. Other values raise `ValueError`. It adds
204
206
 
205
207
  ## Usage Examples
206
208
 
207
- ### Basic Topic Subscription
209
+ ### Joining a channel
208
210
 
209
211
  Use `on_any` when you need the complete `Message`:
210
212
 
@@ -221,7 +223,7 @@ async def main() -> None:
221
223
  async def message_handler(message: Message) -> None:
222
224
  print(f"Topic: {message.topic}")
223
225
  print(f"Event: {message.event}")
224
- print(f"Payload: {message.payload}")
226
+ print(f"Payload: {message.payload!r}")
225
227
 
226
228
  async with room:
227
229
  await client.run_forever()
@@ -229,7 +231,7 @@ async def main() -> None:
229
231
  asyncio.run(main())
230
232
  ```
231
233
 
232
- ## Message Handlers vs Event-Specific Handlers
234
+ ## Catch-all vs event handlers
233
235
 
234
236
  A channel supports one catch-all handler and one handler per event:
235
237
 
@@ -282,7 +284,7 @@ async def main() -> None:
282
284
  asyncio.run(main())
283
285
  ```
284
286
 
285
- ## Unsubscribing from Topics
287
+ ## Leaving a channel
286
288
 
287
289
  Leaving a channel stops delivery and cleans up its membership. Prefer a context
288
290
  manager so exceptions and cancellation also trigger cleanup:
@@ -298,7 +300,7 @@ async def main() -> None:
298
300
 
299
301
  @room.on_any
300
302
  async def on_message(message: Message) -> None:
301
- print(f"Message: {message.payload}")
303
+ print(f"Message: {message.payload!r}")
302
304
 
303
305
  async with room:
304
306
  await asyncio.sleep(5)
@@ -343,6 +345,47 @@ frame. Providers must be nonblocking and return a JSON-serializable dict;
343
345
  provider/encoding failure raises `ChannelError` with its cause. A provider failure
344
346
  during recovery permanently loses only that channel instead of retrying forever.
345
347
 
348
+ ### Ordered join responses
349
+
350
+ Pass `on_join_response` when creating a channel to install a snapshot from every
351
+ initial join, socket rejoin and same-socket recovery:
352
+
353
+ ```python
354
+ state = None
355
+
356
+ def install_snapshot(response):
357
+ global state
358
+ state = validate_snapshot(response)
359
+
360
+ room = client.channel("room:123", on_join_response=install_snapshot)
361
+ room.on_any(on_message)
362
+ async with room:
363
+ await client.run_forever()
364
+ ```
365
+
366
+ `JoinResponseCallback` is exported from the package. The hook receives decoded
367
+ successful `payload.response`; a missing response is `None`. It runs synchronously
368
+ before the channel becomes joined, the join completes, or later events are
369
+ delivered. The client adds no application schema or empty snapshot. Join and leave
370
+ replies are handled separately from the bounded application-event queue.
371
+
372
+ Hooks must be short, nonblocking and return `None`. Async callables, awaitables
373
+ and other non-`None` results fail installation with `ChannelError`, as do hook
374
+ exceptions (including a directly raised `CancelledError`); the original cause is
375
+ preserved. Arbitrary prior hook effects are not rolled back. Do not wait on locks,
376
+ perform I/O, defer installation or change client lifecycle inside the hook. An
377
+ asyncio deadline cannot preempt synchronous code that blocks the loop.
378
+
379
+ Initial failure retires the admitted handle. Installation failure on an established
380
+ channel is reported once through `on_topic_lost`. Queue overflow and recovery can
381
+ still discard application events, so applications needing durable state must
382
+ resynchronize rather than assume replay.
383
+
384
+ Join replies match request refs on both protocols. V2 additionally checks a
385
+ present `join_ref`; a missing join_ref is accepted, including for broadcasts.
386
+ Membership controls use their available refs for correlation. V1 cannot provide
387
+ V2 isolation for arbitrary unsolicited events from old memberships.
388
+
346
389
  ## Pushing Events
347
390
 
348
391
  Use `await channel.push(event, payload, timeout_s=10.0)` to send an application
@@ -363,7 +406,7 @@ async def main() -> None:
363
406
  if reply.status == "ok":
364
407
  print(reply.response)
365
408
  else:
366
- print(f"Server replied {reply.status}: {reply.response}")
409
+ print(f"Server replied {reply.status}: {reply.response!r}")
367
410
 
368
411
  asyncio.run(main())
369
412
  ```
@@ -473,7 +516,7 @@ Only `websocket_url` may be positional; all other constructor options are keywor
473
516
  | `join_timeout_s` | `10.0` | Finite positive join-operation deadline |
474
517
  | `leave_timeout_s` | `5.0` | Finite positive leave-operation deadline |
475
518
  | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
476
- | `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 |
519
+ | `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 |
477
520
  | `on_reconnect` | `None` | `async () -> None`, called after required first-attempt reconciliation |
478
521
  | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
479
522
  | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
@@ -564,6 +607,26 @@ or rejoining. `await room.leave()` also works during recovery and stops future
564
607
  rejoin attempts. Client lifecycle callbacks stay constructor options; `shutdown()`
565
608
  cancels them, including a callback that itself called `shutdown()`.
566
609
 
610
+ ### Optional reconnect participation
611
+
612
+ `client.channel(topic, *, params=None, on_join_response=None,
613
+ blocks_reconnect=True)` creates a channel that blocks the reconnect barrier by
614
+ default. Required channels reconcile their first socket-rejoin attempt sequentially
615
+ before `on_reconnect`; transient failures continue through recovery/backoff. The
616
+ barrier does not wait for eventual success of every required channel.
617
+
618
+ Set `blocks_reconnect=False` when creating an optional channel. Its owned recovery
619
+ task attempts an immediate join after required first attempts and retries transient
620
+ failures with the same backoff. Required delivery and `on_reconnect` do not wait for
621
+ optional replies, callback drains or retries. Explicit `await room.join()` still
622
+ waits for that channel's reply and installation.
623
+
624
+ `on_reconnect` does not promise optional readiness. Check each handle's `joined`
625
+ property and use `on_topic_lost` to replace permanent losses. Do not blindly create
626
+ replacement channels on every reconnect. Leave, shutdown and another socket
627
+ disconnect stop or hand over recovery; obsolete attempts cannot revive a left
628
+ handle.
629
+
567
630
  ### Heartbeats
568
631
 
569
632
  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.
@@ -596,8 +659,8 @@ logging.getLogger("phoenix_channels_python_client").setLevel(logging.DEBUG)
596
659
 
597
660
  ## Examples
598
661
 
599
- - [`examples/minimal_phx_events_client.py`](examples/minimal_phx_events_client.py): subscribe to a topic and log messages until Ctrl+C or SIGTERM.
600
- - [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py): run the client in a host that owns its process signals.
662
+ - [`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.
663
+ - [`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.
601
664
 
602
665
  ---
603
666
 
@@ -626,10 +689,6 @@ async with Client(
626
689
  ...
627
690
  ```
628
691
 
629
- ---
630
-
631
- **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.
632
-
633
692
  ### Authentication and diagnostic safety
634
693
 
635
694
  A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
@@ -650,63 +709,6 @@ Logs retain static lifecycle descriptions, native connection states,
650
709
  and exception types. Application data and caller-visible errors are not changed;
651
710
  applications remain responsible for logs outside this connection-owned boundary.
652
711
 
653
- ### Ordered join responses
654
-
655
- Pass `on_join_response` when creating a channel to install a snapshot from every
656
- initial join, socket rejoin and same-socket recovery:
657
-
658
- ```python
659
- state = None
660
-
661
- def install_snapshot(response):
662
- global state
663
- state = validate_snapshot(response)
664
-
665
- room = client.channel("room:123", on_join_response=install_snapshot)
666
- room.on_any(on_message)
667
- async with room:
668
- await client.run_forever()
669
- ```
670
-
671
- `JoinResponseCallback` is exported from the package. The hook receives decoded
672
- successful `payload.response`; a missing response is `None`. It runs synchronously
673
- before the channel becomes joined, the join completes, or later events are
674
- delivered. The client adds no application schema or empty snapshot. Join and leave
675
- replies are handled separately from the bounded application-event queue.
676
-
677
- Hooks must be short, nonblocking and return `None`. Async callables, awaitables
678
- and other non-`None` results fail installation with `ChannelError`, as do hook
679
- exceptions (including a directly raised `CancelledError`); the original cause is
680
- preserved. Arbitrary prior hook effects are not rolled back. Do not wait on locks,
681
- perform I/O, defer installation or change client lifecycle inside the hook. An
682
- asyncio deadline cannot preempt synchronous code that blocks the loop.
683
-
684
- Initial failure retires the admitted handle. Installation failure on an established
685
- channel is reported once through `on_topic_lost`. Queue overflow and recovery can
686
- still discard application events, so applications needing durable state must
687
- resynchronize rather than assume replay.
688
-
689
- Join replies match request refs on both protocols. V2 additionally checks a
690
- present `join_ref`; a missing join_ref is accepted, including for broadcasts.
691
- Membership controls use their available refs for correlation. V1 cannot provide
692
- V2 isolation for arbitrary unsolicited events from old memberships.
693
-
694
- ### Optional reconnect participation
695
-
696
- `client.channel(topic, *, params=None, on_join_response=None,
697
- blocks_reconnect=True)` creates a channel that blocks the reconnect barrier by
698
- default. Required channels reconcile their first socket-rejoin attempt sequentially
699
- before `on_reconnect`; transient failures continue through recovery/backoff. The
700
- barrier does not wait for eventual success of every required channel.
701
-
702
- Set `blocks_reconnect=False` when creating an optional channel. Its owned recovery
703
- task attempts an immediate join after required first attempts and retries transient
704
- failures with the same backoff. Required delivery and `on_reconnect` do not wait for
705
- optional replies, callback drains or retries. Explicit `await room.join()` still
706
- waits for that channel's reply and installation.
712
+ ---
707
713
 
708
- `on_reconnect` does not promise optional readiness. Check each handle's `joined`
709
- property and use `on_topic_lost` to replace permanent losses. Do not blindly create
710
- replacement channels on every reconnect. Leave, shutdown and another socket
711
- disconnect stop or hand over recovery; obsolete attempts cannot revive a left
712
- handle.
714
+ **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.
@@ -27,7 +27,7 @@ from phoenix_channels_python_client.messages import Message, Payload, SystemEven
27
27
  from phoenix_channels_python_client.protocol import ProtocolVersion
28
28
  from phoenix_channels_python_client.replies import Reply
29
29
 
30
- __version__ = "0.5.0"
30
+ __version__ = "0.5.1"
31
31
 
32
32
  __all__ = [
33
33
  "Channel",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phoenix-channels-python-client
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: A Python client library for connecting to Phoenix Channels
5
5
  Author: Phoenix Channels Python Client
6
6
  License-Expression: MIT
@@ -181,10 +181,12 @@ async with asyncio.TaskGroup() as tasks:
181
181
  loop.remove_signal_handler(signal.SIGTERM)
182
182
  ```
183
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
+
184
186
  From a plain `signal.signal` handler or another thread, use
185
187
  `loop.call_soon_threadsafe(...)` instead: a bare `asyncio.create_task` there
186
188
  doesn't wake a loop blocked on I/O, so the shutdown can stall until the next
187
- network activity. See [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py).
189
+ network activity.
188
190
 
189
191
  With the default, handlers are installed only when `run_forever()` runs on the
190
192
  main thread; elsewhere it just waits. A handler you registered with
@@ -237,7 +239,7 @@ string values `"1.0"` and `"2.0"`. Other values raise `ValueError`. It adds
237
239
 
238
240
  ## Usage Examples
239
241
 
240
- ### Basic Topic Subscription
242
+ ### Joining a channel
241
243
 
242
244
  Use `on_any` when you need the complete `Message`:
243
245
 
@@ -254,7 +256,7 @@ async def main() -> None:
254
256
  async def message_handler(message: Message) -> None:
255
257
  print(f"Topic: {message.topic}")
256
258
  print(f"Event: {message.event}")
257
- print(f"Payload: {message.payload}")
259
+ print(f"Payload: {message.payload!r}")
258
260
 
259
261
  async with room:
260
262
  await client.run_forever()
@@ -262,7 +264,7 @@ async def main() -> None:
262
264
  asyncio.run(main())
263
265
  ```
264
266
 
265
- ## Message Handlers vs Event-Specific Handlers
267
+ ## Catch-all vs event handlers
266
268
 
267
269
  A channel supports one catch-all handler and one handler per event:
268
270
 
@@ -315,7 +317,7 @@ async def main() -> None:
315
317
  asyncio.run(main())
316
318
  ```
317
319
 
318
- ## Unsubscribing from Topics
320
+ ## Leaving a channel
319
321
 
320
322
  Leaving a channel stops delivery and cleans up its membership. Prefer a context
321
323
  manager so exceptions and cancellation also trigger cleanup:
@@ -331,7 +333,7 @@ async def main() -> None:
331
333
 
332
334
  @room.on_any
333
335
  async def on_message(message: Message) -> None:
334
- print(f"Message: {message.payload}")
336
+ print(f"Message: {message.payload!r}")
335
337
 
336
338
  async with room:
337
339
  await asyncio.sleep(5)
@@ -376,6 +378,47 @@ frame. Providers must be nonblocking and return a JSON-serializable dict;
376
378
  provider/encoding failure raises `ChannelError` with its cause. A provider failure
377
379
  during recovery permanently loses only that channel instead of retrying forever.
378
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
+
379
422
  ## Pushing Events
380
423
 
381
424
  Use `await channel.push(event, payload, timeout_s=10.0)` to send an application
@@ -396,7 +439,7 @@ async def main() -> None:
396
439
  if reply.status == "ok":
397
440
  print(reply.response)
398
441
  else:
399
- print(f"Server replied {reply.status}: {reply.response}")
442
+ print(f"Server replied {reply.status}: {reply.response!r}")
400
443
 
401
444
  asyncio.run(main())
402
445
  ```
@@ -506,7 +549,7 @@ Only `websocket_url` may be positional; all other constructor options are keywor
506
549
  | `join_timeout_s` | `10.0` | Finite positive join-operation deadline |
507
550
  | `leave_timeout_s` | `5.0` | Finite positive leave-operation deadline |
508
551
  | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
509
- | `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 |
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 |
510
553
  | `on_reconnect` | `None` | `async () -> None`, called after required first-attempt reconciliation |
511
554
  | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
512
555
  | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
@@ -597,6 +640,26 @@ or rejoining. `await room.leave()` also works during recovery and stops future
597
640
  rejoin attempts. Client lifecycle callbacks stay constructor options; `shutdown()`
598
641
  cancels them, including a callback that itself called `shutdown()`.
599
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
+
600
663
  ### Heartbeats
601
664
 
602
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.
@@ -629,8 +692,8 @@ logging.getLogger("phoenix_channels_python_client").setLevel(logging.DEBUG)
629
692
 
630
693
  ## Examples
631
694
 
632
- - [`examples/minimal_phx_events_client.py`](examples/minimal_phx_events_client.py): subscribe to a topic and log messages until Ctrl+C or SIGTERM.
633
- - [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py): run the client in a host that owns its process signals.
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.
634
697
 
635
698
  ---
636
699
 
@@ -659,10 +722,6 @@ async with Client(
659
722
  ...
660
723
  ```
661
724
 
662
- ---
663
-
664
- **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.
665
-
666
725
  ### Authentication and diagnostic safety
667
726
 
668
727
  A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
@@ -683,63 +742,6 @@ Logs retain static lifecycle descriptions, native connection states,
683
742
  and exception types. Application data and caller-visible errors are not changed;
684
743
  applications remain responsible for logs outside this connection-owned boundary.
685
744
 
686
- ### Ordered join responses
687
-
688
- Pass `on_join_response` when creating a channel to install a snapshot from every
689
- initial join, socket rejoin and same-socket recovery:
690
-
691
- ```python
692
- state = None
693
-
694
- def install_snapshot(response):
695
- global state
696
- state = validate_snapshot(response)
697
-
698
- room = client.channel("room:123", on_join_response=install_snapshot)
699
- room.on_any(on_message)
700
- async with room:
701
- await client.run_forever()
702
- ```
703
-
704
- `JoinResponseCallback` is exported from the package. The hook receives decoded
705
- successful `payload.response`; a missing response is `None`. It runs synchronously
706
- before the channel becomes joined, the join completes, or later events are
707
- delivered. The client adds no application schema or empty snapshot. Join and leave
708
- replies are handled separately from the bounded application-event queue.
709
-
710
- Hooks must be short, nonblocking and return `None`. Async callables, awaitables
711
- and other non-`None` results fail installation with `ChannelError`, as do hook
712
- exceptions (including a directly raised `CancelledError`); the original cause is
713
- preserved. Arbitrary prior hook effects are not rolled back. Do not wait on locks,
714
- perform I/O, defer installation or change client lifecycle inside the hook. An
715
- asyncio deadline cannot preempt synchronous code that blocks the loop.
716
-
717
- Initial failure retires the admitted handle. Installation failure on an established
718
- channel is reported once through `on_topic_lost`. Queue overflow and recovery can
719
- still discard application events, so applications needing durable state must
720
- resynchronize rather than assume replay.
721
-
722
- Join replies match request refs on both protocols. V2 additionally checks a
723
- present `join_ref`; a missing join_ref is accepted, including for broadcasts.
724
- Membership controls use their available refs for correlation. V1 cannot provide
725
- V2 isolation for arbitrary unsolicited events from old memberships.
726
-
727
- ### Optional reconnect participation
728
-
729
- `client.channel(topic, *, params=None, on_join_response=None,
730
- blocks_reconnect=True)` creates a channel that blocks the reconnect barrier by
731
- default. Required channels reconcile their first socket-rejoin attempt sequentially
732
- before `on_reconnect`; transient failures continue through recovery/backoff. The
733
- barrier does not wait for eventual success of every required channel.
734
-
735
- Set `blocks_reconnect=False` when creating an optional channel. Its owned recovery
736
- task attempts an immediate join after required first attempts and retries transient
737
- failures with the same backoff. Required delivery and `on_reconnect` do not wait for
738
- optional replies, callback drains or retries. Explicit `await room.join()` still
739
- waits for that channel's reply and installation.
745
+ ---
740
746
 
741
- `on_reconnect` does not promise optional readiness. Check each handle's `joined`
742
- property and use `on_topic_lost` to replace permanent losses. Do not blindly create
743
- replacement channels on every reconnect. Leave, shutdown and another socket
744
- disconnect stop or hand over recovery; obsolete attempts cannot revive a left
745
- handle.
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "phoenix-channels-python-client"
7
- version = "0.5.0"
7
+ version = "0.5.1"
8
8
  description = "A Python client library for connecting to Phoenix Channels"
9
9
  readme = "README.md"
10
10
  license = "MIT"