phoenix-channels-python-client 0.4.1__tar.gz → 0.4.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 (48) hide show
  1. {phoenix_channels_python_client-0.4.1/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.4.2}/PKG-INFO +72 -5
  2. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/README.md +69 -2
  3. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/__init__.py +3 -1
  4. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/client.py +4 -0
  5. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/client_types.py +4 -1
  6. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/topic_runtime.py +181 -45
  7. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/topic_subscription.py +6 -0
  8. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2/phoenix_channels_python_client.egg-info}/PKG-INFO +72 -5
  9. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client.egg-info/SOURCES.txt +6 -0
  10. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client.egg-info/requires.txt +2 -2
  11. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/pyproject.toml +3 -3
  12. phoenix_channels_python_client-0.4.2/tests/test_cleanup_ownership.py +95 -0
  13. phoenix_channels_python_client-0.4.2/tests/test_join_installation.py +428 -0
  14. phoenix_channels_python_client-0.4.2/tests/test_join_lifecycle.py +733 -0
  15. phoenix_channels_python_client-0.4.2/tests/test_join_responses.py +203 -0
  16. phoenix_channels_python_client-0.4.2/tests/test_optional_lifecycle.py +582 -0
  17. phoenix_channels_python_client-0.4.2/tests/test_optional_reconnect.py +249 -0
  18. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_topic_runtime_internals.py +11 -7
  19. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/LICENSE +0 -0
  20. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/MANIFEST.in +0 -0
  21. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/authentication.py +0 -0
  22. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/client_state_machine.py +0 -0
  23. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/diagnostics.py +0 -0
  24. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/exceptions.py +0 -0
  25. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/phx_messages.py +0 -0
  26. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/protocol_handler.py +0 -0
  27. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/reconnect_controller.py +0 -0
  28. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/shutdown_signals.py +0 -0
  29. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/supervisor.py +0 -0
  30. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/utils.py +0 -0
  31. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
  32. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
  33. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/setup.cfg +0 -0
  34. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_cancel_and_wait.py +0 -0
  35. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_channel_recovery.py +0 -0
  36. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_client_state.py +0 -0
  37. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_connection.py +0 -0
  38. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_heartbeat.py +0 -0
  39. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_heartbeat_timeout.py +0 -0
  40. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_protocol_handler.py +0 -0
  41. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_reconnect.py +0 -0
  42. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_reconnect_policy_invariants.py +0 -0
  43. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_reconnect_stress.py +0 -0
  44. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_run_forever_signals.py +0 -0
  45. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_security.py +0 -0
  46. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_shutdown_races.py +0 -0
  47. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_subscriptions.py +0 -0
  48. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.2}/tests/test_supervisor_internals.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phoenix-channels-python-client
3
- Version: 0.4.1
3
+ Version: 0.4.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
@@ -19,7 +19,7 @@ Requires-Dist: websockets>=14.2
19
19
  Provides-Extra: dev
20
20
  Requires-Dist: pytest; extra == "dev"
21
21
  Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
22
- Requires-Dist: looptime<0.8,>=0.7; extra == "dev"
22
+ Requires-Dist: looptime<0.10,>=0.7; extra == "dev"
23
23
  Requires-Dist: pytest-cov; extra == "dev"
24
24
  Requires-Dist: pre-commit>=3.0.0; extra == "dev"
25
25
  Requires-Dist: commitizen>=3.13.0; extra == "dev"
@@ -28,7 +28,7 @@ Requires-Dist: ruff; extra == "dev"
28
28
  Provides-Extra: test
29
29
  Requires-Dist: pytest; extra == "test"
30
30
  Requires-Dist: pytest-asyncio>=1.4; extra == "test"
31
- Requires-Dist: looptime<0.8,>=0.7; extra == "test"
31
+ Requires-Dist: looptime<0.10,>=0.7; extra == "test"
32
32
  Dynamic: license-file
33
33
 
34
34
  # Phoenix Channels Python Client
@@ -303,13 +303,14 @@ Everything after `api_key` is keyword-only:
303
303
  | `leave_timeout_s` | `5.0` | Wait for a leave reply |
304
304
  | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
305
305
  | `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 |
306
- | `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
306
+ | `on_reconnect` | `None` | `async () -> None`, called after required first-attempt reconciliation |
307
307
  | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
308
308
  | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
309
309
  | `on_topic_lost` | `None` | `async (topic: str, error: Exception) -> None`, called when the client drops a topic you didn't unsubscribe |
310
310
  | `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
311
311
 
312
- Exceptions raised in callbacks are logged, not raised.
312
+ Exceptions raised in ordinary message and lifecycle callbacks are logged, not raised.
313
+ Join-response installation failures instead fail the join (see below).
313
314
 
314
315
  ## Connection Lifecycle and Errors
315
316
 
@@ -419,3 +420,69 @@ headers, frame bodies, user/topic identities, and untrusted server error details
419
420
  Logs retain static lifecycle descriptions, native connection states,
420
421
  and exception types. Application data and caller-visible errors are not changed;
421
422
  applications remain responsible for logs outside this connection-owned boundary.
423
+
424
+ ### Ordered join responses
425
+
426
+ Use the optional keyword-only `on_join_response` hook to install a snapshot from
427
+ initial joins, socket rejoins and same-socket channel recovery:
428
+
429
+ ```python
430
+ state = None
431
+
432
+ def install_snapshot(response):
433
+ global state
434
+ state = validate_snapshot(response)
435
+
436
+ await client.subscribe_to_topic(
437
+ "room:123", on_message, on_join_response=install_snapshot
438
+ )
439
+ ```
440
+
441
+ `JoinResponseCallback` is exported from the package. The hook receives the decoded
442
+ successful `payload.response`; a missing response is `None`. The transport adds no
443
+ application schema or empty snapshot. Installation runs synchronously before
444
+ `is_topic_joined()` becomes true, subscribe completes, or later deltas deliver.
445
+ Subscribe returns `None`. Success admits the subscription; a later disconnect or
446
+ channel failure can already have changed its current readiness when the caller
447
+ resumes.
448
+
449
+ Hooks must be short, nonblocking and return `None`. Do not await, perform I/O,
450
+ wait on locks, defer installation, or change task/client lifecycle inside them.
451
+ Async callables, awaitable results and other non-`None` results fail admission.
452
+ The join timeout bounds reply waiting and cannot preempt synchronous code. Hook
453
+ exceptions (including directly raised `CancelledError`) fail with `PHXTopicError`
454
+ and preserve their cause; arbitrary prior hook effects are not rolled back.
455
+ Initial failure belongs to the caller. An established subscription whose installer
456
+ fails is removed and reported once through `on_topic_lost`.
457
+
458
+ Initial cancellation, timeout and leave remove the exact subscription. Physical
459
+ cleanup remains owned if its waiter is canceled again, and shutdown awaits it.
460
+ Never-established subscriptions are not adopted by automatic recovery. Recorded
461
+ queue loss before a hook-enabled installation fails the attempt transiently;
462
+ steady-state queues still drop their oldest message at capacity. There is no
463
+ replay guarantee. A fresh client must install a fresh snapshot after process loss.
464
+
465
+ Join replies match request refs on both protocols. V2 also filters stale channel
466
+ generations by `join_ref`. V1 cannot reliably fence unsolicited events/lifecycle
467
+ frames from older channels; it does not provide V2 event-generation isolation.
468
+
469
+ ### Optional reconnect participation
470
+
471
+ `subscribe_to_topic(topic, async_callback=None, *, on_join_response=None,
472
+ blocks_reconnect=True) -> None` preserves required recovery by default. Required
473
+ topics reconcile their first socket-rejoin attempt sequentially before
474
+ `on_reconnect`; transient failures continue through existing recovery/backoff.
475
+ The barrier does not wait for eventual success of every required topic.
476
+
477
+ Set `blocks_reconnect=False` for optional subscriptions. After required first
478
+ attempts, their existing per-topic recovery tasks perform an immediate join and
479
+ retry transient failures with the existing backoff. Required event delivery and
480
+ `on_reconnect` do not wait for optional replies, old callback drains or retries.
481
+ This option cannot isolate synchronous code that blocks the event loop. Explicit
482
+ initial subscribe always waits for its own join response and installation.
483
+
484
+ `on_reconnect` conveys no optional readiness or registration-absence guarantee.
485
+ Check `is_topic_joined()` for current readiness and use `on_topic_lost` to replace
486
+ permanently rejected optional subscriptions; do not blindly resubscribe them
487
+ from `on_reconnect`. Leave, shutdown and another socket disconnect cancel/await
488
+ the owned recovery; obsolete generations cannot install or revive a replacement.
@@ -270,13 +270,14 @@ Everything after `api_key` is keyword-only:
270
270
  | `leave_timeout_s` | `5.0` | Wait for a leave reply |
271
271
  | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
272
272
  | `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 |
273
- | `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
273
+ | `on_reconnect` | `None` | `async () -> None`, called after required first-attempt reconciliation |
274
274
  | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
275
275
  | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
276
276
  | `on_topic_lost` | `None` | `async (topic: str, error: Exception) -> None`, called when the client drops a topic you didn't unsubscribe |
277
277
  | `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
278
278
 
279
- Exceptions raised in callbacks are logged, not raised.
279
+ Exceptions raised in ordinary message and lifecycle callbacks are logged, not raised.
280
+ Join-response installation failures instead fail the join (see below).
280
281
 
281
282
  ## Connection Lifecycle and Errors
282
283
 
@@ -386,3 +387,69 @@ headers, frame bodies, user/topic identities, and untrusted server error details
386
387
  Logs retain static lifecycle descriptions, native connection states,
387
388
  and exception types. Application data and caller-visible errors are not changed;
388
389
  applications remain responsible for logs outside this connection-owned boundary.
390
+
391
+ ### Ordered join responses
392
+
393
+ Use the optional keyword-only `on_join_response` hook to install a snapshot from
394
+ initial joins, socket rejoins and same-socket channel recovery:
395
+
396
+ ```python
397
+ state = None
398
+
399
+ def install_snapshot(response):
400
+ global state
401
+ state = validate_snapshot(response)
402
+
403
+ await client.subscribe_to_topic(
404
+ "room:123", on_message, on_join_response=install_snapshot
405
+ )
406
+ ```
407
+
408
+ `JoinResponseCallback` is exported from the package. The hook receives the decoded
409
+ successful `payload.response`; a missing response is `None`. The transport adds no
410
+ application schema or empty snapshot. Installation runs synchronously before
411
+ `is_topic_joined()` becomes true, subscribe completes, or later deltas deliver.
412
+ Subscribe returns `None`. Success admits the subscription; a later disconnect or
413
+ channel failure can already have changed its current readiness when the caller
414
+ resumes.
415
+
416
+ Hooks must be short, nonblocking and return `None`. Do not await, perform I/O,
417
+ wait on locks, defer installation, or change task/client lifecycle inside them.
418
+ Async callables, awaitable results and other non-`None` results fail admission.
419
+ The join timeout bounds reply waiting and cannot preempt synchronous code. Hook
420
+ exceptions (including directly raised `CancelledError`) fail with `PHXTopicError`
421
+ and preserve their cause; arbitrary prior hook effects are not rolled back.
422
+ Initial failure belongs to the caller. An established subscription whose installer
423
+ fails is removed and reported once through `on_topic_lost`.
424
+
425
+ Initial cancellation, timeout and leave remove the exact subscription. Physical
426
+ cleanup remains owned if its waiter is canceled again, and shutdown awaits it.
427
+ Never-established subscriptions are not adopted by automatic recovery. Recorded
428
+ queue loss before a hook-enabled installation fails the attempt transiently;
429
+ steady-state queues still drop their oldest message at capacity. There is no
430
+ replay guarantee. A fresh client must install a fresh snapshot after process loss.
431
+
432
+ Join replies match request refs on both protocols. V2 also filters stale channel
433
+ generations by `join_ref`. V1 cannot reliably fence unsolicited events/lifecycle
434
+ frames from older channels; it does not provide V2 event-generation isolation.
435
+
436
+ ### Optional reconnect participation
437
+
438
+ `subscribe_to_topic(topic, async_callback=None, *, on_join_response=None,
439
+ blocks_reconnect=True) -> None` preserves required recovery by default. Required
440
+ topics reconcile their first socket-rejoin attempt sequentially before
441
+ `on_reconnect`; transient failures continue through existing recovery/backoff.
442
+ The barrier does not wait for eventual success of every required topic.
443
+
444
+ Set `blocks_reconnect=False` for optional subscriptions. After required first
445
+ attempts, their existing per-topic recovery tasks perform an immediate join and
446
+ retry transient failures with the existing backoff. Required event delivery and
447
+ `on_reconnect` do not wait for optional replies, old callback drains or retries.
448
+ This option cannot isolate synchronous code that blocks the event loop. Explicit
449
+ initial subscribe always waits for its own join response and installation.
450
+
451
+ `on_reconnect` conveys no optional readiness or registration-absence guarantee.
452
+ Check `is_topic_joined()` for current readiness and use `on_topic_lost` to replace
453
+ permanently rejected optional subscriptions; do not blindly resubscribe them
454
+ from `on_reconnect`. Leave, shutdown and another socket disconnect cancel/await
455
+ the owned recovery; obsolete generations cannot install or revive a replacement.
@@ -13,18 +13,20 @@ from phoenix_channels_python_client.client import (
13
13
  ReconnectPolicy,
14
14
  TopicLostCallback,
15
15
  )
16
+ from phoenix_channels_python_client.client_types import JoinResponseCallback
16
17
  from phoenix_channels_python_client.protocol_handler import (
17
18
  PhoenixChannelsProtocolVersion,
18
19
  PHXProtocolHandler,
19
20
  )
20
21
  from phoenix_channels_python_client.utils import setup_logging
21
22
 
22
- __version__ = "0.4.1"
23
+ __version__ = "0.4.2"
23
24
  __author__ = "Phoenix Channels Python Client"
24
25
 
25
26
  __all__ = [
26
27
  "DisconnectCallback",
27
28
  "HeartbeatAckCallback",
29
+ "JoinResponseCallback",
28
30
  "PHXChannelsClient",
29
31
  "PHXProtocolHandler",
30
32
  "PhoenixChannelsProtocolVersion",
@@ -198,6 +198,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
198
198
  self._on_heartbeat_ack = on_heartbeat_ack
199
199
  self._on_topic_lost = on_topic_lost
200
200
  self._lifecycle_tasks: set[asyncio.Task[None]] = set()
201
+ self._cleanup_tasks: set[asyncio.Task[None]] = set()
201
202
  self._forced_close_pending = False
202
203
 
203
204
  def redact_diagnostic(self, text: str) -> str:
@@ -215,6 +216,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
215
216
  self._state == ClientState.CLOSED
216
217
  and not self._topic_subscriptions
217
218
  and self.connection is None
219
+ and not self._cleanup_tasks
218
220
  )
219
221
 
220
222
  async def __aenter__(self) -> Self:
@@ -273,6 +275,8 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
273
275
  await self._unsubscribe_all()
274
276
  # Includes on_topic_lost callbacks still running for topics already gone.
275
277
  await cancel_and_wait(*self._lifecycle_tasks)
278
+ if self._cleanup_tasks:
279
+ await asyncio.gather(*self._cleanup_tasks)
276
280
  await self._stop_supervisor()
277
281
  await self._cleanup_connection()
278
282
  self._connected_event.clear()
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Callable
3
4
  from dataclasses import dataclass
4
5
  from enum import StrEnum
5
- from typing import Self
6
+ from typing import Any, Self
6
7
 
7
8
  from pydantic import (
8
9
  BaseModel,
@@ -15,6 +16,8 @@ from pydantic import (
15
16
 
16
17
  from phoenix_channels_python_client.exceptions import PHXConnectionError
17
18
 
19
+ JoinResponseCallback = Callable[[Any], None]
20
+
18
21
 
19
22
  class ClientState(StrEnum):
20
23
  CONNECTING = "connecting"