phoenix-channels-python-client 0.2.4__py3-none-any.whl → 0.2.5__py3-none-any.whl

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.
@@ -19,7 +19,7 @@ from phoenix_channels_python_client.protocol_handler import (
19
19
  )
20
20
  from phoenix_channels_python_client.utils import setup_logging
21
21
 
22
- __version__ = "0.2.4"
22
+ __version__ = "0.2.5"
23
23
  __author__ = "Phoenix Channels Python Client"
24
24
 
25
25
  __all__ = [
@@ -25,6 +25,7 @@ from phoenix_channels_python_client.reconnect_controller import ReconnectControl
25
25
  from phoenix_channels_python_client.supervisor import SupervisorMixin
26
26
  from phoenix_channels_python_client.topic_runtime import TopicRuntimeMixin
27
27
  from phoenix_channels_python_client.topic_subscription import TopicSubscription
28
+ from phoenix_channels_python_client.utils import cancel_and_wait
28
29
 
29
30
  logger = logging.getLogger(__name__)
30
31
 
@@ -145,6 +146,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
145
146
  self._connected_event = asyncio.Event()
146
147
  self._conn_generation = 0
147
148
  self._supervisor_task: asyncio.Task[None] | None = None
149
+ self._shutdown_task: asyncio.Task[None] | None = None
148
150
  self._message_routing_task: asyncio.Task[None] | None = None
149
151
  self._initial_connection_future: asyncio.Future[None] | None = None
150
152
  self._rapid_disconnects: deque[float] = deque()
@@ -157,13 +159,26 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
157
159
  self._on_heartbeat_ack = on_heartbeat_ack
158
160
  self._forced_close_pending = False
159
161
 
162
+ @property
163
+ def _active_shutdown(self) -> asyncio.Task[None] | None:
164
+ task = self._shutdown_task
165
+ return task if task is not None and not task.done() else None
166
+
167
+ @property
168
+ def _is_fully_closed(self) -> bool:
169
+ return (
170
+ self._state == ClientState.CLOSED
171
+ and not self._topic_subscriptions
172
+ and self.connection is None
173
+ )
174
+
160
175
  @staticmethod
161
176
  def reconnect_policy_is_invalid(policy: ReconnectPolicy) -> bool:
162
177
  return reconnect_policy_is_invalid(policy)
163
178
 
164
179
  async def __aenter__(self) -> PHXChannelsClient:
165
180
  self.logger.debug("Entering PHXChannelsClient context")
166
- if self._state != ClientState.CLOSED:
181
+ if self._state != ClientState.CLOSED or self._active_shutdown is not None:
167
182
  raise PHXConnectionError("Client is already running")
168
183
 
169
184
  self._shutdown_event.clear()
@@ -192,51 +207,52 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
192
207
  self.logger.debug("Leaving PHXChannelsClient context")
193
208
  await self.shutdown("Leaving PHXChannelsClient context")
194
209
 
195
- async def shutdown(
196
- self,
197
- reason: str,
198
- ) -> None:
199
- if (
200
- self._state == ClientState.CLOSED
201
- and not self._topic_subscriptions
202
- and self.connection is None
203
- ):
204
- return
205
-
210
+ async def shutdown(self, reason: str) -> None:
211
+ task = self._active_shutdown
212
+ if task is None:
213
+ if self._is_fully_closed:
214
+ return
215
+ task = self._shutdown_task = asyncio.create_task(self._shutdown(reason))
216
+ # Every caller waits for the same shutdown, and one caller's
217
+ # cancellation never aborts it.
218
+ await asyncio.shield(task)
219
+
220
+ async def _shutdown(self, reason: str) -> None:
206
221
  self.logger.info("Event loop shutting down! reason=%s", reason)
207
222
 
208
223
  if self._state not in (ClientState.SHUTTING_DOWN, ClientState.CLOSED):
209
224
  self._transition_state(ClientState.SHUTTING_DOWN)
210
-
211
225
  self._shutdown_event.set()
212
226
 
213
- topics_to_unsubscribe = list(self._topic_subscriptions.keys())
214
- if topics_to_unsubscribe:
215
- unsubscribe_tasks = [
216
- self.unsubscribe_from_topic(topic, _allow_disconnected=True)
217
- for topic in topics_to_unsubscribe
218
- ]
219
- results = await asyncio.gather(*unsubscribe_tasks, return_exceptions=True)
220
-
221
- for topic, result in zip(topics_to_unsubscribe, results, strict=False):
222
- if isinstance(result, Exception):
223
- self.logger.warning(
224
- "Failed to unsubscribe from topic %s during shutdown: %s",
225
- topic,
226
- result,
227
- )
228
-
229
- if self._supervisor_task and not self._supervisor_task.done():
230
- self._supervisor_task.cancel()
231
- try:
232
- await self._supervisor_task
233
- except asyncio.CancelledError:
234
- self.logger.debug("Supervisor task cancelled during shutdown")
235
-
227
+ await self._unsubscribe_all()
228
+ await self._stop_supervisor()
236
229
  await self._cleanup_connection()
237
230
  self._connected_event.clear()
238
231
  self._transition_state(ClientState.CLOSED)
239
232
 
233
+ async def _unsubscribe_all(self) -> None:
234
+ topics = list(self._topic_subscriptions)
235
+ results = await asyncio.gather(
236
+ *(
237
+ self.unsubscribe_from_topic(topic, _allow_disconnected=True)
238
+ for topic in topics
239
+ ),
240
+ return_exceptions=True,
241
+ )
242
+ for topic, result in zip(topics, results, strict=True):
243
+ if isinstance(result, Exception):
244
+ self.logger.warning(
245
+ "Failed to unsubscribe from topic %s during shutdown: %s",
246
+ topic,
247
+ result,
248
+ )
249
+
250
+ async def _stop_supervisor(self) -> None:
251
+ # Cleared, so run_forever() after the stop reports the client isn't running.
252
+ supervisor, self._supervisor_task = self._supervisor_task, None
253
+ if supervisor is not None and not supervisor.done():
254
+ await cancel_and_wait(supervisor)
255
+
240
256
  def _transition_state(self, new_state: ClientState) -> None:
241
257
  if self._state == new_state:
242
258
  return
@@ -0,0 +1,119 @@
1
+ """Process-wide SIGTERM/SIGINT handling shared by every waiting event loop.
2
+
3
+ Handlers are installed with ``signal.signal`` and the previous handler is put
4
+ back exactly, as ``asyncio.Runner`` does. Signal handlers interrupt main-thread
5
+ code between bytecodes, so nothing here takes a lock: the OS handler table
6
+ decides ownership, the handler never iterates a live container or removes
7
+ entries, and ``_previous`` entries are only ever overwritten.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ import signal
15
+ import threading
16
+ from collections.abc import Callable, Generator
17
+ from contextlib import contextmanager
18
+ from types import FrameType
19
+ from typing import Any, NamedTuple
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ SHUTDOWN_SIGNALS = (signal.SIGTERM, signal.SIGINT)
24
+
25
+ # typeshed's signal._HANDLER is private.
26
+ _Handler = Callable[[int, FrameType | None], Any] | int | None
27
+
28
+
29
+ class _Waiter(NamedTuple):
30
+ loop: asyncio.AbstractEventLoop
31
+ event: asyncio.Event
32
+
33
+ @property
34
+ def live(self) -> bool:
35
+ return not self.loop.is_closed()
36
+
37
+
38
+ # Each waiter maps to the last signal it took, if any.
39
+ _waiters: dict[_Waiter, int | None] = {}
40
+ _previous: dict[int, _Handler] = {}
41
+
42
+
43
+ def _on_main_thread() -> bool:
44
+ return threading.current_thread() is threading.main_thread()
45
+
46
+
47
+ def _owned(sig: int) -> bool:
48
+ return signal.getsignal(sig) is _on_shutdown_signal
49
+
50
+
51
+ def _on_shutdown_signal(signum: int, frame: FrameType | None) -> None:
52
+ live = [waiter for waiter in tuple(_waiters) if waiter.live]
53
+ for waiter in live:
54
+ _waiters[waiter] = signum
55
+ waiter.loop.call_soon_threadsafe(waiter.event.set)
56
+ # Every waiting loop closed without unwinding: hand the signal back. If a
57
+ # newer handler chained to this one instead, that handler already has it.
58
+ if not live and _owned(signum):
59
+ _restore()
60
+ signal.raise_signal(signum)
61
+
62
+
63
+ def _install() -> bool:
64
+ try:
65
+ for sig in SHUTDOWN_SIGNALS:
66
+ # None means a handler set outside Python, which can't be restored.
67
+ if not _owned(sig) and signal.getsignal(sig) is not None:
68
+ _previous[sig] = signal.signal(sig, _on_shutdown_signal)
69
+ except ValueError:
70
+ # The main thread can't take signals (embedded interpreter, gh-91880).
71
+ return False
72
+ return True
73
+
74
+
75
+ def _restore() -> None:
76
+ for sig in SHUTDOWN_SIGNALS:
77
+ if _owned(sig): # never overwrite a handler installed after ours
78
+ signal.signal(sig, _previous[sig])
79
+
80
+
81
+ def _unregister(waiter: _Waiter) -> int | None:
82
+ """Drop ``waiter``, restoring the handlers if it was the last one.
83
+
84
+ Returns the last signal it took, if any.
85
+ """
86
+ received = _waiters.pop(waiter, None)
87
+ for orphan in [other for other in tuple(_waiters) if not other.live]:
88
+ _waiters.pop(orphan, None) # a closed loop never blocks a restore
89
+ # GC may finalize an orphaned body off the main thread, where
90
+ # signal.signal raises.
91
+ if not _waiters and _on_main_thread():
92
+ _restore()
93
+ return received
94
+
95
+
96
+ @contextmanager
97
+ def handle_shutdown_signals(event: asyncio.Event) -> Generator[None, None, None]:
98
+ """Set ``event`` on SIGTERM/SIGINT while inside.
99
+
100
+ A signal is consumed only if the body completes with ``event`` set, and the
101
+ caller must then act on it. Any other signal taken is handed back on exit.
102
+ """
103
+ if not _on_main_thread():
104
+ logger.debug("Signal handlers not available off the main thread")
105
+ yield
106
+ return
107
+ waiter = _Waiter(asyncio.get_running_loop(), event)
108
+ consumed = False
109
+ try:
110
+ # Register before installing so a signal mid-install is never dropped.
111
+ _waiters[waiter] = None
112
+ if not _install():
113
+ logger.debug("Signal handlers not available in this interpreter")
114
+ yield
115
+ consumed = event.is_set()
116
+ finally:
117
+ received = _unregister(waiter)
118
+ if received is not None and not consumed and not _owned(received):
119
+ signal.raise_signal(received) # nobody acted on it: hand it back
@@ -3,10 +3,9 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import inspect
5
5
  import logging
6
- import signal
7
6
  from collections import deque
8
7
  from collections.abc import Awaitable, Callable
9
- from contextlib import suppress
8
+ from contextlib import nullcontext
10
9
  from typing import Protocol, cast
11
10
 
12
11
  from websockets import ClientConnection, connect
@@ -24,8 +23,9 @@ from phoenix_channels_python_client.phx_messages import (
24
23
  Event,
25
24
  )
26
25
  from phoenix_channels_python_client.protocol_handler import PHXProtocolHandler
26
+ from phoenix_channels_python_client.shutdown_signals import handle_shutdown_signals
27
27
  from phoenix_channels_python_client.topic_subscription import TopicSubscription
28
- from phoenix_channels_python_client.utils import make_message
28
+ from phoenix_channels_python_client.utils import cancel_and_wait, make_message
29
29
 
30
30
  # RFC 6455 §7.4.2 private-use range (4000-4999, unregistrable); outside
31
31
  # _classify_disconnect's specially-handled codes, so a forced close
@@ -235,6 +235,10 @@ class SupervisorMixin:
235
235
  continue
236
236
 
237
237
  self.connection = connection
238
+ if self._shutdown_event.is_set():
239
+ # shutdown() began during the handshake; its own cleanup
240
+ # closes this socket.
241
+ break
238
242
  self._conn_generation += 1
239
243
  generation = self._conn_generation
240
244
  self._connected_event.set()
@@ -288,7 +292,6 @@ class SupervisorMixin:
288
292
  routing_error=routing_error,
289
293
  )
290
294
  forced_close = self._forced_close_pending
291
- self._forced_close_pending = False
292
295
 
293
296
  await self._cleanup_connection()
294
297
 
@@ -351,6 +354,7 @@ class SupervisorMixin:
351
354
  delay = runtime_deps._apply_disconnect_delay_override(delay, decision)
352
355
  await self._wait_for_shutdown_or_timeout(delay)
353
356
 
357
+ finally:
354
358
  if (
355
359
  self._initial_connection_future
356
360
  and not self._initial_connection_future.done()
@@ -360,8 +364,6 @@ class SupervisorMixin:
360
364
  "Connection supervisor stopped before connecting"
361
365
  )
362
366
  )
363
-
364
- finally:
365
367
  self._connected_event.clear()
366
368
  if self._state != ClientState.SHUTTING_DOWN:
367
369
  runtime_deps._transition_state(ClientState.CLOSED)
@@ -374,75 +376,71 @@ class SupervisorMixin:
374
376
 
375
377
  async def _cleanup_connection(self) -> None:
376
378
  connection = self.connection
379
+ running = [
380
+ task
381
+ for task in (self._heartbeat_task, self._message_routing_task)
382
+ if task is not None and not task.done()
383
+ ]
377
384
  self.connection = None
378
- self._connected_event.clear()
379
-
380
- if self._heartbeat_task and not self._heartbeat_task.done():
381
- self._heartbeat_task.cancel()
382
- with suppress(asyncio.CancelledError):
383
- await self._heartbeat_task
384
385
  self._heartbeat_task = None
385
- self._pending_heartbeat_ref = None
386
-
387
- if self._message_routing_task and not self._message_routing_task.done():
388
- self._message_routing_task.cancel()
389
- with suppress(asyncio.CancelledError):
390
- await self._message_routing_task
391
-
392
386
  self._message_routing_task = None
387
+ self._pending_heartbeat_ref = None
388
+ self._connected_event.clear()
389
+ self._forced_close_pending = False
393
390
 
394
- if connection is not None:
395
- try:
396
- await connection.close()
397
- except Exception:
398
- self.logger.exception("Failed while closing websocket connection")
391
+ try:
392
+ await cancel_and_wait(*running)
393
+ finally:
394
+ # Close the socket even if the caller is cancelled meanwhile.
395
+ if connection is not None:
396
+ await self._close_websocket(connection)
399
397
 
400
- async def run_forever(self) -> None:
398
+ async def _close_websocket(self, connection: ClientConnection) -> None:
399
+ try:
400
+ await connection.close()
401
+ except Exception:
402
+ self.logger.exception("Failed while closing websocket connection")
403
+
404
+ async def run_forever(self, *, install_signal_handlers: bool = True) -> None:
405
+ """Wait until the client stops, then raise why if it failed.
406
+
407
+ With ``install_signal_handlers`` (the default), SIGTERM and SIGINT shut
408
+ the client down. The handlers in place before the call are restored once
409
+ the last waiting ``run_forever()`` stops waiting, so a second signal
410
+ during the shutdown reaches them. A host handler registered with
411
+ ``loop.add_signal_handler`` before the call also fires; one registered
412
+ during it replaces ours. Hosts that own their process signals pass
413
+ ``False`` and schedule ``shutdown()`` on the client's loop from their
414
+ own handler.
415
+ """
401
416
  runtime_deps = cast(_SupervisorRuntimeDeps, self)
402
- if self._supervisor_task is None:
417
+ supervisor = self._supervisor_task
418
+ if supervisor is None:
403
419
  raise PHXConnectionError(
404
420
  "Client is not connected. Use 'async with' context manager."
405
421
  )
406
422
 
407
- shutdown_signal = asyncio.Event()
408
-
409
- def signal_handler(*_: object) -> None:
410
- shutdown_signal.set()
411
-
412
- loop = asyncio.get_running_loop()
413
- signal_handlers_registered = False
414
- try:
415
- for sig in (signal.SIGTERM, signal.SIGINT):
416
- loop.add_signal_handler(sig, signal_handler)
417
- signal_handlers_registered = True
418
- except (RuntimeError, NotImplementedError):
419
- self.logger.debug(
420
- "Signal handlers not available (not running on main thread)"
421
- )
422
-
423
- shutdown_task = asyncio.create_task(shutdown_signal.wait())
424
-
425
- try:
426
- done, pending = await asyncio.wait(
427
- [shutdown_task, self._supervisor_task],
428
- return_when=asyncio.FIRST_COMPLETED,
429
- )
430
-
431
- for task in pending:
432
- task.cancel()
433
- with suppress(asyncio.CancelledError):
434
- await task
435
-
436
- if shutdown_task in done:
437
- await runtime_deps.shutdown("Signal received")
438
- return
439
-
440
- # Supervisor task ended unexpectedly: propagate if it failed.
441
- if self._terminal_error is not None:
442
- raise self._terminal_error
443
- self._supervisor_task.result()
444
-
445
- finally:
446
- if signal_handlers_registered:
447
- for sig in (signal.SIGTERM, signal.SIGINT):
448
- loop.remove_signal_handler(sig)
423
+ signaled = asyncio.Event()
424
+ signals = (
425
+ handle_shutdown_signals(signaled)
426
+ if install_signal_handlers
427
+ else nullcontext()
428
+ )
429
+ with signals:
430
+ signal_wait = asyncio.create_task(signaled.wait())
431
+ try:
432
+ await asyncio.wait(
433
+ {supervisor, signal_wait}, return_when=asyncio.FIRST_COMPLETED
434
+ )
435
+ finally:
436
+ signal_wait.cancel()
437
+
438
+ # No await since leaving the `with`, so this sees `signaled` exactly as
439
+ # handle_shutdown_signals judged it consumed.
440
+ if signaled.is_set():
441
+ await runtime_deps.shutdown("Signal received")
442
+ if self._terminal_error is not None:
443
+ raise self._terminal_error
444
+ if supervisor.cancelled() and self._shutdown_event.is_set():
445
+ return # a requested stop
446
+ supervisor.result()
@@ -4,7 +4,6 @@ import asyncio
4
4
  import logging
5
5
  from asyncio import Queue
6
6
  from collections.abc import Awaitable, Callable
7
- from contextlib import suppress
8
7
  from typing import Any
9
8
 
10
9
  from websockets import ClientConnection
@@ -24,7 +23,7 @@ from phoenix_channels_python_client.topic_subscription import (
24
23
  TopicProcessingState,
25
24
  TopicSubscription,
26
25
  )
27
- from phoenix_channels_python_client.utils import make_message
26
+ from phoenix_channels_python_client.utils import cancel_and_wait, make_message
28
27
 
29
28
 
30
29
  class TopicRuntimeMixin:
@@ -274,9 +273,7 @@ class TopicRuntimeMixin:
274
273
 
275
274
  task = topic_subscription.process_topic_messages_task
276
275
  if task and not task.done() and asyncio.current_task() is not task:
277
- task.cancel()
278
- with suppress(asyncio.CancelledError):
279
- await task
276
+ await cancel_and_wait(task)
280
277
 
281
278
  self.logger.info("Unregistered topic %s", topic_name)
282
279
 
@@ -501,14 +498,10 @@ class TopicRuntimeMixin:
501
498
  "Callback for topic %s did not finish before reconnect; cancelling",
502
499
  topic_name,
503
500
  )
504
- callback_task.cancel()
505
- with suppress(asyncio.CancelledError):
506
- await callback_task
501
+ await cancel_and_wait(callback_task)
507
502
 
508
503
  if previous_task and not previous_task.done():
509
- previous_task.cancel()
510
- with suppress(asyncio.CancelledError):
511
- await previous_task
504
+ await cancel_and_wait(previous_task)
512
505
 
513
506
  topic_subscription.conn_generation = generation
514
507
  topic_subscription.join_ref = self._generate_ref()
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import logging
2
3
  from typing import Any, Optional
3
4
 
@@ -9,6 +10,8 @@ from phoenix_channels_python_client.phx_messages import (
9
10
  PHXMessage,
10
11
  )
11
12
 
13
+ logger = logging.getLogger(__name__)
14
+
12
15
 
13
16
  def parse_event(event: ChannelEvent) -> ChannelEvent:
14
17
  try:
@@ -46,6 +49,22 @@ def make_message(
46
49
  )
47
50
 
48
51
 
52
+ async def cancel_and_wait(*futures: asyncio.Future[Any]) -> None:
53
+ """Cancel ``futures`` and wait until they have all finished.
54
+
55
+ Unlike ``suppress(CancelledError)``, the caller's own cancellation still
56
+ propagates. A failure raised while a future unwinds is logged, not raised.
57
+ """
58
+ if not futures:
59
+ return
60
+ for future in futures:
61
+ future.cancel()
62
+ await asyncio.wait(futures)
63
+ for future in futures:
64
+ if not future.cancelled() and (error := future.exception()):
65
+ logger.error("Failed while being cancelled", exc_info=error)
66
+
67
+
49
68
  def setup_logging(level: int = logging.INFO) -> None:
50
69
  """
51
70
  Configure clean logging with timestamps for Phoenix Channels Python Client.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phoenix-channels-python-client
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: A Python client library for connecting to Phoenix Channels
5
5
  Author: Phoenix Channels Python Client
6
6
  License-Expression: MIT
@@ -17,14 +17,14 @@ License-File: LICENSE
17
17
  Requires-Dist: websockets>=14.2
18
18
  Provides-Extra: dev
19
19
  Requires-Dist: pytest; extra == "dev"
20
- Requires-Dist: pytest-asyncio; extra == "dev"
20
+ Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
21
21
  Requires-Dist: pre-commit>=3.0.0; extra == "dev"
22
22
  Requires-Dist: commitizen>=3.13.0; extra == "dev"
23
23
  Requires-Dist: pyrefly; extra == "dev"
24
24
  Requires-Dist: ruff; extra == "dev"
25
25
  Provides-Extra: test
26
26
  Requires-Dist: pytest; extra == "test"
27
- Requires-Dist: pytest-asyncio; extra == "test"
27
+ Requires-Dist: pytest-asyncio>=1.4; extra == "test"
28
28
  Dynamic: license-file
29
29
 
30
30
  # Phoenix Channels Python Client
@@ -39,7 +39,11 @@ A modern, async Python client library for connecting to [Phoenix Channels](https
39
39
 
40
40
  ### For Users
41
41
 
42
- Install from source:
42
+ ```bash
43
+ pip install phoenix-channels-python-client
44
+ ```
45
+
46
+ Or from source:
43
47
 
44
48
  ```bash
45
49
  git clone https://github.com/band-ai/phoenix-channels-python-client.git
@@ -65,7 +69,6 @@ pip install -e ".[dev]"
65
69
  Run tests:
66
70
 
67
71
  ```bash
68
- pip install -e ".[test]"
69
72
  pytest
70
73
  ```
71
74
 
@@ -96,26 +99,56 @@ async def main():
96
99
  # Subscribe to a topic
97
100
  await client.subscribe_to_topic("room:lobby", handle_message)
98
101
 
99
- # Use built-in convenience method to keep connection alive
100
- await client.run_forever() # Handles Ctrl+C automatically
102
+ # Ctrl+C or SIGTERM shuts the client down and run_forever() returns.
103
+ # The previous handlers are restored first, so a second signal reaches them.
104
+ await client.run_forever()
101
105
 
102
106
  if __name__ == "__main__":
103
107
  asyncio.run(main())
104
108
  ```
105
109
 
106
- ## Phoenix System Events
110
+ ### Owning process signals yourself
111
+
112
+ If your application owns its process signals (a service, desktop app or test
113
+ runner), call `client.run_forever(install_signal_handlers=False)`. It then
114
+ leaves signal handlers alone; stop the client by scheduling
115
+ `client.shutdown(reason)` on the client's event loop, and `run_forever()`
116
+ returns `None`:
117
+
118
+ ```python
119
+ loop = asyncio.get_running_loop()
120
+ async with asyncio.TaskGroup() as tasks:
121
+ loop.add_signal_handler(
122
+ signal.SIGTERM, lambda: tasks.create_task(client.shutdown("SIGTERM"))
123
+ )
124
+ try:
125
+ await client.run_forever(install_signal_handlers=False)
126
+ finally:
127
+ # Once the group exits, a later SIGTERM must not reach the closed group.
128
+ loop.remove_signal_handler(signal.SIGTERM)
129
+ ```
130
+
131
+ From a plain `signal.signal` handler or another thread, use
132
+ `loop.call_soon_threadsafe(...)` instead: a bare `asyncio.create_task` there
133
+ doesn't wake a loop blocked on I/O, so the shutdown can stall until the next
134
+ network activity. See [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py).
107
135
 
108
- Phoenix Channels uses several reserved event names for internal protocol communication. The client library automatically handles these system events to manage connections, subscriptions, and message routing:
136
+ With the default, handlers are installed only when `run_forever()` runs on the
137
+ main thread; elsewhere it just waits. A handler you registered with
138
+ `loop.add_signal_handler` before calling `run_forever()` still fires for the
139
+ same signal; one registered while it runs replaces the client's.
109
140
 
110
- - `phx_join` - Channel join requests
111
- - `phx_reply` - Server replies to client messages
112
- - `phx_leave` - Channel leave notifications
113
- - `phx_close` - Channel close events
114
- - `phx_error` - Channel error events
141
+ ## Phoenix System Events
115
142
 
116
- **Important:** You should avoid using these event names when handling custom events in your application, as they are reserved for Phoenix's internal protocol. The library uses these events to determine message routing and connection state management.
143
+ Phoenix reserves `phx_join`, `phx_reply`, `phx_leave`, `phx_close` and
144
+ `phx_error`. Don't use these names for your own events.
117
145
 
118
- If you have a specific use case that requires handling these system events directly, you can do so, but be aware that this may interfere with the library's automatic connection management.
146
+ - The client sends `phx_join` and `phx_leave` and consumes their `phx_reply`.
147
+ - A server `phx_close` or `phx_error` reaches your message handler like any
148
+ other message; the client does not rejoin or unsubscribe on its own.
149
+ - For an event-specific handler on these, register `PHXEvent.close` or
150
+ `PHXEvent.error` from `phoenix_channels_python_client.phx_messages`; the
151
+ plain strings don't match.
119
152
 
120
153
  ## Protocol Versions
121
154
 
@@ -130,7 +163,7 @@ async with PHXChannelsClient(
130
163
  api_key="your-api-key"
131
164
  # protocol_version defaults to v2.0
132
165
  ) as client:
133
- # Your code here
166
+ ... # your code here
134
167
  ```
135
168
 
136
169
  ### Protocol v1.0
@@ -142,9 +175,11 @@ async with PHXChannelsClient(
142
175
  api_key="your-api-key",
143
176
  protocol_version=PhoenixChannelsProtocolVersion.V1
144
177
  ) as client:
145
- # Your code here
178
+ ... # your code here
146
179
  ```
147
180
 
181
+ The client adds `vsn=2.0.0` (V2) or `vsn=1.0.0` (V1) to the socket URL.
182
+
148
183
  ## Usage Examples
149
184
 
150
185
  ### Basic Topic Subscription
@@ -186,7 +221,7 @@ The library provides two complementary ways to handle incoming messages:
186
221
  1. **Message handler runs first** (if set) - receives the full message
187
222
  2. **Event-specific handler runs second** (if matching event) - receives just the payload
188
223
 
189
- You can add, remove, or change either type of handler at any time during your connection.
224
+ All handlers must be `async def`. You can add, remove, or change either type of handler at any time during your connection.
190
225
 
191
226
  ### Event-Specific Handlers
192
227
 
@@ -225,10 +260,13 @@ You can unsubscribe from topics to stop receiving messages and clean up resource
225
260
  import asyncio
226
261
  from phoenix_channels_python_client import PHXChannelsClient
227
262
 
263
+ async def on_message(msg):
264
+ print(f"Message: {msg.payload}")
265
+
228
266
  async def main():
229
267
  async with PHXChannelsClient("ws://localhost:4000/socket/websocket", "api-key") as client:
230
268
  # Subscribe to a topic
231
- await client.subscribe_to_topic("room:lobby", lambda msg: print(f"Message: {msg.payload}"))
269
+ await client.subscribe_to_topic("room:lobby", on_message)
232
270
 
233
271
  # Do some work...
234
272
  await asyncio.sleep(5)
@@ -245,13 +283,73 @@ asyncio.run(main())
245
283
 
246
284
  **Notes on unsubscription:**
247
285
  - Removes all handlers (both message and event-specific) for that topic
248
- - Sends a leave message to the Phoenix server
286
+ - Sends `phx_leave` and waits for the reply (`leave_timeout_s`, default 5 s)
287
+ - Raises `PHXTopicError` if the topic isn't subscribed or the leave times out, and `PHXConnectionError` while disconnected
288
+
289
+ ## Configuration
290
+
291
+ Everything after `api_key` is keyword-only:
292
+
293
+ | Option | Default | Meaning |
294
+ |---|---|---|
295
+ | `protocol_version` | `V2` | Phoenix Channels protocol version |
296
+ | `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
297
+ | `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
298
+ | `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats |
299
+ | `join_timeout_s` | `10.0` | Wait for a join reply |
300
+ | `leave_timeout_s` | `5.0` | Wait for a leave reply |
301
+ | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
302
+ | `callback_drain_timeout_s` | `2.0` | How long a running callback may finish before a rejoin cancels it |
303
+ | `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
304
+ | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
305
+ | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
306
+ | `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
307
+
308
+ Exceptions raised in callbacks are logged, not raised.
309
+
310
+ ## Connection Lifecycle and Errors
311
+
312
+ - `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
313
+ - `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, after repeated rapid disconnects, or when the client has never entered `async with`.
314
+ - `subscribe_to_topic()` raises `PHXTopicError` on a rejected join, a join timeout or a duplicate subscription.
315
+ - `await client.close_connection(reason)` force-closes the current connection; the client then reconnects if `auto_reconnect` is on.
316
+ - Exceptions live in `phoenix_channels_python_client.exceptions`.
317
+
318
+ ### Reconnects
319
+
320
+ | Close code | Behaviour (defaults) |
321
+ |---|---|
322
+ | 1000, 1001 | Stop, unless `reconnect_on_normal_close=True` |
323
+ | 1008 | Stop with `PHXConnectionError` (`policy_violation_is_terminal=True`) |
324
+ | 1012 | Reconnect after at least a random 1–5 s |
325
+ | 1013 | Reconnect after at least a random 30–60 s |
326
+ | Anything else, or no close frame | Reconnect with jittered backoff: 0.5 s × 2ⁿ, capped at 30 s, reset after 60 s of uptime |
327
+
328
+ Disconnects within 5 s of connecting count as rapid and get longer minimum delays; ten within 60 s stop the client with `PHXConnectionError`. Tune all of this with `ReconnectPolicy`.
329
+
330
+ ### Heartbeats
331
+
332
+ The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. A missed reply only logs a warning; to act on it, combine `on_heartbeat_ack` with `close_connection()`.
333
+
334
+ ## Logging
335
+
336
+ ```python
337
+ import logging
338
+ from phoenix_channels_python_client import setup_logging
339
+
340
+ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_client"
341
+ ```
342
+
343
+ ## Examples
344
+
345
+ - [`examples/minimal_phx_events_client.py`](examples/minimal_phx_events_client.py): subscribe to a topic and log messages until Ctrl+C or SIGTERM.
346
+ - [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py): run the client in a host that owns its process signals.
249
347
 
250
348
  ---
251
349
 
252
350
  ## Security Considerations
253
351
 
254
- **API Key in URL**: This client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). While the connection uses WSS (encrypted in transit), the API key may be logged by:
352
+ **API Key in URL**: This client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). Even over WSS (encrypted in transit), the API key may be logged by:
255
353
 
256
354
  - Server access logs
257
355
  - Reverse proxies and load balancers
@@ -262,7 +360,7 @@ asyncio.run(main())
262
360
  - Use short-lived, rotatable tokens rather than long-lived API keys
263
361
  - Consider this limitation when evaluating this client for sensitive environments
264
362
 
265
- **Note:** The official Phoenix JS client (v1.8+) supports header-based authentication via the `authToken` option, which avoids URL logging. This Python client currently uses the older `params` style for broad compatibility.
363
+ **Note:** The official Phoenix JS client (v1.8+) supports header-based authentication via the `authToken` option, which avoids URL logging. This client always sends `api_key` in the URL (logged URLs show `api_key=***`). Pass `additional_headers={"x-api-key": key}` to also send it as a handshake header on every (re)connect, e.g. for a proxy that reads it from there.
266
364
 
267
365
  ---
268
366
 
@@ -0,0 +1,18 @@
1
+ phoenix_channels_python_client/__init__.py,sha256=zTn4s0MP1HYlkmyBOsW5PtwRn88lgTn6f-dsk5awbMU,791
2
+ phoenix_channels_python_client/client.py,sha256=e3omXmnJnB8rr8YCEyg86kskut0tHRUkMo-z1vitJ6M,10310
3
+ phoenix_channels_python_client/client_state_machine.py,sha256=Exr5wjYB0VXpCq1-tGHN4VraNman1M1vWN073rRFZFo,1124
4
+ phoenix_channels_python_client/client_types.py,sha256=Gyyf72zDpAZtGcnbXJMW6Xj6PksdOM6bh-BXjlN6U5Y,3889
5
+ phoenix_channels_python_client/exceptions.py,sha256=1cpnD_SO3En-yZKS47QSlLjLnK-29gOBcBVHoBQVMkg,227
6
+ phoenix_channels_python_client/phx_messages.py,sha256=lvtyECL_CwUlP_yom57f0U_OpGJsOqETy7KEtdu_yf4,1214
7
+ phoenix_channels_python_client/protocol_handler.py,sha256=ohnZN0Z3GmM0xiRAhLD5n4XtbL37A_HvEgs5b_uBmg8,8141
8
+ phoenix_channels_python_client/reconnect_controller.py,sha256=R924_f59fQN7jvQXFfQxKb6LDBYxheSPszOFETpZ5iI,11025
9
+ phoenix_channels_python_client/shutdown_signals.py,sha256=2BBHZZbIPA6CXX1CP6sRZ50nK2mA3WU0lJECk6FQgEs,4053
10
+ phoenix_channels_python_client/supervisor.py,sha256=UHYw19BEBIIEP9L2o5iAYHMFVd9Hotfft3TSvMB-sTw,18583
11
+ phoenix_channels_python_client/topic_runtime.py,sha256=GAkJQwpeXG-_3wgH3s5GWdTvlBJ7Xz2SX8AlrDNig6E,21580
12
+ phoenix_channels_python_client/topic_subscription.py,sha256=J0wcCsfP9laQ7DxT-6e7OdhQrK_t1zu3n3gcnuedvFI,1916
13
+ phoenix_channels_python_client/utils.py,sha256=h8AhBOM-o7gx0sXh507iRIcL-xka9rLmzvypLeCI--4,2254
14
+ phoenix_channels_python_client-0.2.5.dist-info/licenses/LICENSE,sha256=zwqkIClV_fMWOC0Auvd0DYqZMLL0-hIqeKd6tygE39g,1064
15
+ phoenix_channels_python_client-0.2.5.dist-info/METADATA,sha256=VZLjeABZ30_Cro_TNdsmFhINzpt6w7c7O_QyzjIUZq0,13955
16
+ phoenix_channels_python_client-0.2.5.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
17
+ phoenix_channels_python_client-0.2.5.dist-info/top_level.txt,sha256=ZYXMGCOeev4cyNU9XvNGHKt1wOgVkFHaS9TlwDa4eDU,31
18
+ phoenix_channels_python_client-0.2.5.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- phoenix_channels_python_client/__init__.py,sha256=EVdevvtteX6B9wQewjgbn_f_S3H-w4YkIRpeXK0oKZE,791
2
- phoenix_channels_python_client/client.py,sha256=EAy1vVC3JInfNk6RYvbE6d8ZC5YmPNho1q474IrptHQ,9546
3
- phoenix_channels_python_client/client_state_machine.py,sha256=Exr5wjYB0VXpCq1-tGHN4VraNman1M1vWN073rRFZFo,1124
4
- phoenix_channels_python_client/client_types.py,sha256=Gyyf72zDpAZtGcnbXJMW6Xj6PksdOM6bh-BXjlN6U5Y,3889
5
- phoenix_channels_python_client/exceptions.py,sha256=1cpnD_SO3En-yZKS47QSlLjLnK-29gOBcBVHoBQVMkg,227
6
- phoenix_channels_python_client/phx_messages.py,sha256=lvtyECL_CwUlP_yom57f0U_OpGJsOqETy7KEtdu_yf4,1214
7
- phoenix_channels_python_client/protocol_handler.py,sha256=ohnZN0Z3GmM0xiRAhLD5n4XtbL37A_HvEgs5b_uBmg8,8141
8
- phoenix_channels_python_client/reconnect_controller.py,sha256=R924_f59fQN7jvQXFfQxKb6LDBYxheSPszOFETpZ5iI,11025
9
- phoenix_channels_python_client/supervisor.py,sha256=OU1ywX1dAAvw1cNNtFaO6CqdI_G_J7oRevx-rzpQrUI,18133
10
- phoenix_channels_python_client/topic_runtime.py,sha256=IkZ6tXxRP5Svc8vkuAUo-ic5K_pfsHmYHmabRAHmYnQ,21829
11
- phoenix_channels_python_client/topic_subscription.py,sha256=J0wcCsfP9laQ7DxT-6e7OdhQrK_t1zu3n3gcnuedvFI,1916
12
- phoenix_channels_python_client/utils.py,sha256=MdxW2tq5MTCE7RQhhajwChMipSHXJyopBwq5ORo0Vao,1616
13
- phoenix_channels_python_client-0.2.4.dist-info/licenses/LICENSE,sha256=zwqkIClV_fMWOC0Auvd0DYqZMLL0-hIqeKd6tygE39g,1064
14
- phoenix_channels_python_client-0.2.4.dist-info/METADATA,sha256=gPYMjC6UbmF8CSHUh7U5VV6WZ7sJZe1GS_ZbsC5nUTM,9157
15
- phoenix_channels_python_client-0.2.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
- phoenix_channels_python_client-0.2.4.dist-info/top_level.txt,sha256=ZYXMGCOeev4cyNU9XvNGHKt1wOgVkFHaS9TlwDa4eDU,31
17
- phoenix_channels_python_client-0.2.4.dist-info/RECORD,,