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.
- phoenix_channels_python_client/__init__.py +1 -1
- phoenix_channels_python_client/client.py +52 -36
- phoenix_channels_python_client/shutdown_signals.py +119 -0
- phoenix_channels_python_client/supervisor.py +66 -68
- phoenix_channels_python_client/topic_runtime.py +4 -11
- phoenix_channels_python_client/utils.py +19 -0
- {phoenix_channels_python_client-0.2.4.dist-info → phoenix_channels_python_client-0.2.5.dist-info}/METADATA +121 -23
- phoenix_channels_python_client-0.2.5.dist-info/RECORD +18 -0
- phoenix_channels_python_client-0.2.4.dist-info/RECORD +0 -17
- {phoenix_channels_python_client-0.2.4.dist-info → phoenix_channels_python_client-0.2.5.dist-info}/WHEEL +0 -0
- {phoenix_channels_python_client-0.2.4.dist-info → phoenix_channels_python_client-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {phoenix_channels_python_client-0.2.4.dist-info → phoenix_channels_python_client-0.2.5.dist-info}/top_level.txt +0 -0
|
@@ -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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
self.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
)
|
|
204
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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",
|
|
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
|
|
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`).
|
|
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
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|