phoenix-channels-python-client 0.5.0__tar.gz → 0.5.2__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.2}/PKG-INFO +85 -79
  2. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/README.md +84 -78
  3. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/__init__.py +1 -1
  4. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/codec.py +3 -1
  5. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/diagnostics.py +29 -9
  6. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/messages.py +1 -1
  7. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/replies.py +7 -9
  8. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/supervisor.py +2 -0
  9. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/topic_runtime.py +3 -2
  10. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2/phoenix_channels_python_client.egg-info}/PKG-INFO +85 -79
  11. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/pyproject.toml +1 -1
  12. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_channel_recovery.py +2 -2
  13. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_security.py +43 -0
  14. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/LICENSE +0 -0
  15. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/MANIFEST.in +0 -0
  16. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/authentication.py +0 -0
  17. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/channel.py +0 -0
  18. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/client.py +0 -0
  19. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/client_state_machine.py +0 -0
  20. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/client_types.py +0 -0
  21. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/default_decider.py +0 -0
  22. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/exceptions.py +0 -0
  23. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/protocol.py +0 -0
  24. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/protocol_handler.py +0 -0
  25. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/py.typed +0 -0
  26. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/reconnect_controller.py +0 -0
  27. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/send_session.py +0 -0
  28. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/shutdown_signals.py +0 -0
  29. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/topic_subscription.py +0 -0
  30. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/utils.py +0 -0
  31. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client.egg-info/SOURCES.txt +0 -0
  32. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
  33. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client.egg-info/requires.txt +0 -0
  34. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
  35. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/setup.cfg +0 -0
  36. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_binary.py +0 -0
  37. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_cancel_and_wait.py +0 -0
  38. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_channel_api.py +0 -0
  39. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_cleanup_ownership.py +0 -0
  40. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_client_state.py +0 -0
  41. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_codec.py +0 -0
  42. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_connection.py +0 -0
  43. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_heartbeat.py +0 -0
  44. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_heartbeat_timeout.py +0 -0
  45. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_join_installation.py +0 -0
  46. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_join_lifecycle.py +0 -0
  47. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_join_params.py +0 -0
  48. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_join_params_serialization.py +0 -0
  49. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_join_responses.py +0 -0
  50. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_optional_lifecycle.py +0 -0
  51. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_optional_reconnect.py +0 -0
  52. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_package_typing.py +0 -0
  53. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_protocol_handler.py +0 -0
  54. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_public_api.py +0 -0
  55. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_push.py +0 -0
  56. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_reconnect.py +0 -0
  57. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_reconnect_decider.py +0 -0
  58. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_reconnect_policy_invariants.py +0 -0
  59. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_reconnect_stress.py +0 -0
  60. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_run_forever_signals.py +0 -0
  61. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_send_session.py +0 -0
  62. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_shutdown_races.py +0 -0
  63. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_subscriptions.py +0 -0
  64. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_supervisor_internals.py +0 -0
  65. {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/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.2
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.
@@ -618,7 +681,9 @@ Expiry or heartbeat send failure force-closes that socket; the supervisor handle
618
681
  ## Logging
619
682
 
620
683
  The library uses loggers under `phoenix_channels_python_client`. Configure
621
- logging in your application; importing the package does not configure it:
684
+ logging in your application; importing the package does not configure it.
685
+ Connection log lines show `<omitted>` in place of values that may carry personal
686
+ data (see [Authentication and diagnostic safety](#authentication-and-diagnostic-safety)):
622
687
 
623
688
  ```python
624
689
  import logging
@@ -629,8 +694,8 @@ logging.getLogger("phoenix_channels_python_client").setLevel(logging.DEBUG)
629
694
 
630
695
  ## Examples
631
696
 
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.
697
+ - [`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.
698
+ - [`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
699
 
635
700
  ---
636
701
 
@@ -659,10 +724,6 @@ async with Client(
659
724
  ...
660
725
  ```
661
726
 
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
727
  ### Authentication and diagnostic safety
667
728
 
668
729
  A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
@@ -677,69 +738,14 @@ exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
677
738
  the credential policy to transport errors a caller needs to retain. Caller-visible
678
739
  errors may still contain personal data and should not be logged outside this policy.
679
740
 
680
- Connection diagnostics omit dynamic strings and transport payloads, including URLs,
681
- headers, frame bodies, user/topic identities, and untrusted server error details.
682
- Logs retain static lifecycle descriptions, native connection states,
683
- and exception types. Application data and caller-visible errors are not changed;
741
+ Connection diagnostics omit dynamic values and transport payloads, including URLs,
742
+ headers, frame bodies, user/topic identities, numbers, and untrusted server error
743
+ details. Each omitted value appears as `<omitted>`, for example
744
+ `No handler found for event <omitted> on topic <omitted>`. Logs retain static
745
+ lifecycle descriptions, enum values such as connection states, booleans, and
746
+ exception types. Application data and caller-visible errors are not changed;
684
747
  applications remain responsible for logs outside this connection-owned boundary.
685
748
 
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.
749
+ ---
740
750
 
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.
751
+ **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.
@@ -585,7 +648,9 @@ Expiry or heartbeat send failure force-closes that socket; the supervisor handle
585
648
  ## Logging
586
649
 
587
650
  The library uses loggers under `phoenix_channels_python_client`. Configure
588
- logging in your application; importing the package does not configure it:
651
+ logging in your application; importing the package does not configure it.
652
+ Connection log lines show `<omitted>` in place of values that may carry personal
653
+ data (see [Authentication and diagnostic safety](#authentication-and-diagnostic-safety)):
589
654
 
590
655
  ```python
591
656
  import logging
@@ -596,8 +661,8 @@ logging.getLogger("phoenix_channels_python_client").setLevel(logging.DEBUG)
596
661
 
597
662
  ## Examples
598
663
 
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.
664
+ - [`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.
665
+ - [`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
666
 
602
667
  ---
603
668
 
@@ -626,10 +691,6 @@ async with Client(
626
691
  ...
627
692
  ```
628
693
 
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
694
  ### Authentication and diagnostic safety
634
695
 
635
696
  A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
@@ -644,69 +705,14 @@ exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
644
705
  the credential policy to transport errors a caller needs to retain. Caller-visible
645
706
  errors may still contain personal data and should not be logged outside this policy.
646
707
 
647
- Connection diagnostics omit dynamic strings and transport payloads, including URLs,
648
- headers, frame bodies, user/topic identities, and untrusted server error details.
649
- Logs retain static lifecycle descriptions, native connection states,
650
- and exception types. Application data and caller-visible errors are not changed;
708
+ Connection diagnostics omit dynamic values and transport payloads, including URLs,
709
+ headers, frame bodies, user/topic identities, numbers, and untrusted server error
710
+ details. Each omitted value appears as `<omitted>`, for example
711
+ `No handler found for event <omitted> on topic <omitted>`. Logs retain static
712
+ lifecycle descriptions, enum values such as connection states, booleans, and
713
+ exception types. Application data and caller-visible errors are not changed;
651
714
  applications remain responsible for logs outside this connection-owned boundary.
652
715
 
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.
716
+ ---
707
717
 
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.
718
+ **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.2"
31
31
 
32
32
  __all__ = [
33
33
  "Channel",
@@ -120,7 +120,9 @@ def _decode_binary(raw: bytes) -> DecodedMessage:
120
120
  raise ValueError("Empty binary frame")
121
121
  kind = BinaryKind(raw[0])
122
122
  fields, payload = _binary_fields(raw, SERVER_HEADER_FIELDS[kind])
123
- status = None
123
+ join_ref: str | None
124
+ ref: str | None
125
+ status: str | None = None
124
126
  match kind:
125
127
  case BinaryKind.PUSH:
126
128
  join_ref, topic, event = fields
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  import logging
5
5
  import re
6
+ from enum import Enum
6
7
  from urllib.parse import parse_qsl, quote, quote_plus, urlsplit
7
8
 
8
9
  from websockets.protocol import State
@@ -14,7 +15,34 @@ from phoenix_channels_python_client.authentication import (
14
15
  )
15
16
 
16
17
  REDACTED = "[REDACTED]"
18
+ OMITTED = "<omitted>"
17
19
  PERCENT_ESCAPE = re.compile(r"%[0-9a-fA-F]{2}")
20
+ # A printf-style conversion, as logging applies to a record's msg and args.
21
+ PLACEHOLDER = re.compile(
22
+ r"%(?:\([^)]*\))?[#0 +-]*(?:\*|\d+)?(?:\.(?:\*|\d+))?[hlL]?[diouxXeEfFgGcrsa%]"
23
+ )
24
+ WEBSOCKET_STATE_MESSAGE = "= connection is %s"
25
+
26
+
27
+ def render_value(value: object) -> str:
28
+ # Enum members and booleans are code-defined; any other value, numbers
29
+ # included, may carry personal data.
30
+ return str(value) if isinstance(value, Enum | bool) else OMITTED
31
+
32
+
33
+ def render_message(record: logging.LogRecord) -> str:
34
+ """Fill a record's placeholders without exposing its arguments."""
35
+ message = str(record.msg)
36
+ if not record.args:
37
+ return message
38
+ args = record.args if isinstance(record.args, tuple) else ()
39
+ if message == WEBSOCKET_STATE_MESSAGE and args and args[0] in State.__members__:
40
+ return message % args[0]
41
+ values = iter(args)
42
+ return PLACEHOLDER.sub(
43
+ lambda match: "%" if match[0] == "%%" else render_value(next(values, None)),
44
+ message,
45
+ )
18
46
 
19
47
 
20
48
  AUTHENTICATION_NAMES = frozenset(
@@ -86,15 +114,7 @@ class ConnectionLogger(logging.Logger):
86
114
  def handle(self, record: logging.LogRecord) -> None:
87
115
  if self.target.disabled:
88
116
  return
89
- message = str(record.msg)
90
- if (
91
- record.msg == "= connection is %s"
92
- and isinstance(record.args, tuple)
93
- and len(record.args) == 1
94
- and isinstance(record.args[0], str)
95
- and record.args[0] in State.__members__
96
- ):
97
- message = record.msg % record.args
117
+ message = render_message(record)
98
118
  if record.exc_info and record.exc_info[0]:
99
119
  message += " [" + record.exc_info[0].__name__ + "]"
100
120
  safe_record = logging.LogRecord(
@@ -14,7 +14,7 @@ class SystemEvent(StrEnum):
14
14
  close = "phx_close"
15
15
  error = "phx_error"
16
16
  # The protocol event intentionally shadows str.join.
17
- join = "phx_join" # type: ignore[bad-override]
17
+ join = "phx_join" # type: ignore[bad-override, assignment]
18
18
  reply = "phx_reply"
19
19
  leave = "phx_leave"
20
20
 
@@ -20,13 +20,11 @@ def reply_from_message(message: Message, *, binary_status: str | None = None) ->
20
20
  if binary_status is not None:
21
21
  if type(message.payload) is not bytes:
22
22
  raise TypeError("Binary reply requires a bytes payload")
23
- status, response = binary_status, message.payload
24
- else:
25
- payload = message.payload
26
- if not isinstance(payload, dict):
27
- raise TypeError("Text reply requires a response envelope")
28
- status = payload.get("status")
29
- response = payload.get("response")
30
- if not isinstance(status, str) or not isinstance(response, dict):
31
- raise TypeError("Reply must contain a string status and a response map")
23
+ return Reply(status=binary_status, response=message.payload)
24
+ payload = message.payload
25
+ if not isinstance(payload, dict):
26
+ raise TypeError("Text reply requires a response envelope")
27
+ status, response = payload.get("status"), payload.get("response")
28
+ if not isinstance(status, str) or not isinstance(response, dict):
29
+ raise TypeError("Reply must contain a string status and a response map")
32
30
  return Reply(status=status, response=response)
@@ -27,6 +27,7 @@ from phoenix_channels_python_client.messages import (
27
27
  Message,
28
28
  )
29
29
  from phoenix_channels_python_client.protocol_handler import PHXProtocolHandler
30
+ from phoenix_channels_python_client.send_session import SessionSends
30
31
  from phoenix_channels_python_client.shutdown_signals import handle_shutdown_signals
31
32
  from phoenix_channels_python_client.topic_subscription import (
32
33
  ChannelState,
@@ -102,6 +103,7 @@ class SupervisorMixin:
102
103
  heartbeat_interval_s: float | None
103
104
  heartbeat_task: asyncio.Task[None] | None
104
105
  pending_heartbeat_ref: str | None
106
+ send_session: SessionSends | None
105
107
  on_reconnect: Callable[[], Awaitable[None]] | None
106
108
  on_disconnect: Callable[[Exception | None], Awaitable[None]] | None
107
109
  on_heartbeat_ack: Callable[[], None] | None
@@ -838,8 +838,9 @@ class TopicRuntimeMixin:
838
838
 
839
839
  def _validate_join_hook(self, topic: TopicSubscription) -> None:
840
840
  hook = topic.on_join_response
841
- if inspect.iscoroutinefunction(hook) or inspect.iscoroutinefunction(
842
- hook.__call__ if callable(hook) else None
841
+ if hook is not None and (
842
+ inspect.iscoroutinefunction(hook)
843
+ or inspect.iscoroutinefunction(type(hook).__call__)
843
844
  ):
844
845
  raise ChannelError("on_join_response must be synchronous")
845
846
 
@@ -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.2
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.
@@ -618,7 +681,9 @@ Expiry or heartbeat send failure force-closes that socket; the supervisor handle
618
681
  ## Logging
619
682
 
620
683
  The library uses loggers under `phoenix_channels_python_client`. Configure
621
- logging in your application; importing the package does not configure it:
684
+ logging in your application; importing the package does not configure it.
685
+ Connection log lines show `<omitted>` in place of values that may carry personal
686
+ data (see [Authentication and diagnostic safety](#authentication-and-diagnostic-safety)):
622
687
 
623
688
  ```python
624
689
  import logging
@@ -629,8 +694,8 @@ logging.getLogger("phoenix_channels_python_client").setLevel(logging.DEBUG)
629
694
 
630
695
  ## Examples
631
696
 
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.
697
+ - [`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.
698
+ - [`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
699
 
635
700
  ---
636
701
 
@@ -659,10 +724,6 @@ async with Client(
659
724
  ...
660
725
  ```
661
726
 
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
727
  ### Authentication and diagnostic safety
667
728
 
668
729
  A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
@@ -677,69 +738,14 @@ exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
677
738
  the credential policy to transport errors a caller needs to retain. Caller-visible
678
739
  errors may still contain personal data and should not be logged outside this policy.
679
740
 
680
- Connection diagnostics omit dynamic strings and transport payloads, including URLs,
681
- headers, frame bodies, user/topic identities, and untrusted server error details.
682
- Logs retain static lifecycle descriptions, native connection states,
683
- and exception types. Application data and caller-visible errors are not changed;
741
+ Connection diagnostics omit dynamic values and transport payloads, including URLs,
742
+ headers, frame bodies, user/topic identities, numbers, and untrusted server error
743
+ details. Each omitted value appears as `<omitted>`, for example
744
+ `No handler found for event <omitted> on topic <omitted>`. Logs retain static
745
+ lifecycle descriptions, enum values such as connection states, booleans, and
746
+ exception types. Application data and caller-visible errors are not changed;
684
747
  applications remain responsible for logs outside this connection-owned boundary.
685
748
 
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.
749
+ ---
740
750
 
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.
751
+ **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.2"
8
8
  description = "A Python client library for connecting to Phoenix Channels"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -91,7 +91,7 @@ async def test_a_crashed_channel_rejoins_on_the_live_socket(
91
91
  assert (await expect_message(received)).event == EVENT
92
92
  assert reconnects.count == 0
93
93
 
94
- assert "Recovered channel for topic %s" in caplog.text
94
+ assert "Recovered channel for topic <omitted>" in caplog.text
95
95
 
96
96
 
97
97
  @each_protocol
@@ -178,7 +178,7 @@ async def test_a_rejected_channel_rejoin_loses_the_topic(
178
178
  lost.only_error()
179
179
  assert TOPIC not in client.topic_subscriptions.copy()
180
180
  assert joins(phoenix_server) == REJOINED_ONCE
181
- assert "Lost topic %s" in caplog.text
181
+ assert "Lost topic <omitted>" in caplog.text
182
182
 
183
183
 
184
184
  @each_protocol
@@ -15,6 +15,7 @@ from phoenix_channels_python_client import Client
15
15
  from phoenix_channels_python_client.client_types import ReconnectPolicy
16
16
  from phoenix_channels_python_client.diagnostics import ConnectionLogger
17
17
  from phoenix_channels_python_client.exceptions import SocketError
18
+ from phoenix_channels_python_client.messages import SystemEvent
18
19
  from tests.fake_server import FakePhoenixServer
19
20
 
20
21
  URL_CREDENTIAL = "url/private+ credential?x=1&z=#[]"
@@ -199,6 +200,45 @@ class TestConnectionSecurity:
199
200
  target.disabled = previous_disabled
200
201
  target.removeFilter(reject)
201
202
 
203
+ @pytest.mark.parametrize(
204
+ ("message", "args", "rendered"),
205
+ [
206
+ (
207
+ "No handler found for event %s on topic %s",
208
+ ("user_join", "room:42"),
209
+ "No handler found for event <omitted> on topic <omitted>",
210
+ ),
211
+ (
212
+ "Channel %s is %s after %d attempts",
213
+ (SystemEvent.join, True, 3),
214
+ "Channel phx_join is True after <omitted> attempts",
215
+ ),
216
+ ("100%% of %s", ("room:42",), "100% of <omitted>"),
217
+ ("%s and %s", ("only-one",), "<omitted> and <omitted>"),
218
+ ("%d with text", ("not-a-number",), "<omitted> with text"),
219
+ ("%(topic)s by name", ({"topic": "room:42"},), "<omitted> by name"),
220
+ ],
221
+ ids=[
222
+ "strings",
223
+ "enum-bool-number",
224
+ "escape",
225
+ "too-few",
226
+ "type-mismatch",
227
+ "mapping",
228
+ ],
229
+ )
230
+ def test_placeholders_render_without_exposing_arguments(
231
+ self, message: str, args: tuple[object, ...], rendered: str
232
+ ) -> None:
233
+ client = Client("ws://127.0.0.1:1/socket", api_key="synthetic-key")
234
+ collector = BufferingHandler(10)
235
+ client.logger.addHandler(collector)
236
+ client.logger.setLevel(logging.WARNING)
237
+
238
+ client.logger.warning(message, *args)
239
+
240
+ assert [record.getMessage() for record in collector.buffer] == [rendered]
241
+
202
242
  @pytest.mark.parametrize(
203
243
  "header_names",
204
244
  [
@@ -395,6 +435,9 @@ class TestConnectionSecurity:
395
435
  "Parsing raw message:" in record.getMessage() for record in collector.buffer
396
436
  )
397
437
  assert any("OPEN" in record.getMessage() for record in collector.buffer)
438
+ messages = [record.getMessage() for record in collector.buffer]
439
+ assert "Connection callback failed: <omitted> [RuntimeError]" in messages
440
+ assert "Untrusted numeric identity: <omitted> <omitted> <omitted>" in messages
398
441
  for record in collector.buffer:
399
442
  assert identity not in repr(record.__dict__)
400
443
  assert session not in repr(record.__dict__)