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.
- {phoenix_channels_python_client-0.5.0/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.5.2}/PKG-INFO +85 -79
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/README.md +84 -78
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/__init__.py +1 -1
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/codec.py +3 -1
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/diagnostics.py +29 -9
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/messages.py +1 -1
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/replies.py +7 -9
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/supervisor.py +2 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/topic_runtime.py +3 -2
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2/phoenix_channels_python_client.egg-info}/PKG-INFO +85 -79
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/pyproject.toml +1 -1
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_channel_recovery.py +2 -2
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_security.py +43 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/LICENSE +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/MANIFEST.in +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/authentication.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/channel.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/client.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/client_state_machine.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/client_types.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/default_decider.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/exceptions.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/protocol.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/protocol_handler.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/py.typed +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/reconnect_controller.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/send_session.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/shutdown_signals.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/topic_subscription.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/utils.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client.egg-info/SOURCES.txt +0 -0
- {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
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client.egg-info/requires.txt +0 -0
- {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
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/setup.cfg +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_binary.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_cancel_and_wait.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_channel_api.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_cleanup_ownership.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_client_state.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_codec.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_connection.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_heartbeat.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_heartbeat_timeout.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_join_installation.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_join_lifecycle.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_join_params.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_join_params_serialization.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_join_responses.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_optional_lifecycle.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_optional_reconnect.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_package_typing.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_protocol_handler.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_public_api.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_push.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_reconnect.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_reconnect_decider.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_reconnect_policy_invariants.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_reconnect_stress.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_run_forever_signals.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_send_session.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_shutdown_races.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_subscriptions.py +0 -0
- {phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_supervisor_internals.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
###
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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
|
|
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):
|
|
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
|
|
681
|
-
headers, frame bodies, user/topic identities, and untrusted server error
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
###
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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
|
|
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):
|
|
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
|
|
648
|
-
headers, frame bodies, user/topic identities, and untrusted server error
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
842
|
-
|
|
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.
|
|
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.
|
|
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
|
-
###
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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
|
|
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):
|
|
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
|
|
681
|
-
headers, frame bodies, user/topic identities, and untrusted server error
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -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
|
|
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
|
|
181
|
+
assert "Lost topic <omitted>" in caplog.text
|
|
182
182
|
|
|
183
183
|
|
|
184
184
|
@each_protocol
|
{phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_security.py
RENAMED
|
@@ -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__)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_binary.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_codec.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{phoenix_channels_python_client-0.5.0 → phoenix_channels_python_client-0.5.2}/tests/test_push.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|