phoenix-channels-python-client 0.2.2__py3-none-any.whl → 0.2.4__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 +3 -1
- phoenix_channels_python_client/client.py +7 -0
- phoenix_channels_python_client/phx_messages.py +3 -0
- phoenix_channels_python_client/protocol_handler.py +6 -2
- phoenix_channels_python_client/reconnect_controller.py +9 -5
- phoenix_channels_python_client/supervisor.py +75 -11
- {phoenix_channels_python_client-0.2.2.dist-info → phoenix_channels_python_client-0.2.4.dist-info}/METADATA +5 -5
- phoenix_channels_python_client-0.2.4.dist-info/RECORD +17 -0
- {phoenix_channels_python_client-0.2.2.dist-info → phoenix_channels_python_client-0.2.4.dist-info}/WHEEL +1 -1
- phoenix_channels_python_client-0.2.2.dist-info/RECORD +0 -17
- {phoenix_channels_python_client-0.2.2.dist-info → phoenix_channels_python_client-0.2.4.dist-info}/licenses/LICENSE +0 -0
- {phoenix_channels_python_client-0.2.2.dist-info → phoenix_channels_python_client-0.2.4.dist-info}/top_level.txt +0 -0
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from phoenix_channels_python_client.client import (
|
|
10
10
|
DisconnectCallback,
|
|
11
|
+
HeartbeatAckCallback,
|
|
11
12
|
PHXChannelsClient,
|
|
12
13
|
ReconnectCallback,
|
|
13
14
|
ReconnectPolicy,
|
|
@@ -18,11 +19,12 @@ from phoenix_channels_python_client.protocol_handler import (
|
|
|
18
19
|
)
|
|
19
20
|
from phoenix_channels_python_client.utils import setup_logging
|
|
20
21
|
|
|
21
|
-
__version__ = "0.2.
|
|
22
|
+
__version__ = "0.2.4"
|
|
22
23
|
__author__ = "Phoenix Channels Python Client"
|
|
23
24
|
|
|
24
25
|
__all__ = [
|
|
25
26
|
"DisconnectCallback",
|
|
27
|
+
"HeartbeatAckCallback",
|
|
26
28
|
"PHXChannelsClient",
|
|
27
29
|
"PHXProtocolHandler",
|
|
28
30
|
"PhoenixChannelsProtocolVersion",
|
|
@@ -30,6 +30,10 @@ logger = logging.getLogger(__name__)
|
|
|
30
30
|
|
|
31
31
|
ReconnectCallback = Callable[[], Awaitable[None]]
|
|
32
32
|
DisconnectCallback = Callable[[Exception | None], Awaitable[None]]
|
|
33
|
+
# Must be synchronous and fast: unlike the callbacks above, this runs inline
|
|
34
|
+
# on the message-routing hot path, so blocking work here stalls heartbeat
|
|
35
|
+
# sends and message routing for every connection.
|
|
36
|
+
HeartbeatAckCallback = Callable[[], None]
|
|
33
37
|
|
|
34
38
|
|
|
35
39
|
def _build_channel_socket_urls(
|
|
@@ -84,6 +88,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
84
88
|
heartbeat_interval_s: float | None = 30.0,
|
|
85
89
|
on_reconnect: ReconnectCallback | None = None,
|
|
86
90
|
on_disconnect: DisconnectCallback | None = None,
|
|
91
|
+
on_heartbeat_ack: HeartbeatAckCallback | None = None,
|
|
87
92
|
additional_headers: dict[str, str] | None = None,
|
|
88
93
|
):
|
|
89
94
|
self.logger = logger
|
|
@@ -149,6 +154,8 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
149
154
|
self._pending_heartbeat_ref: str | None = None
|
|
150
155
|
self._on_reconnect = on_reconnect
|
|
151
156
|
self._on_disconnect = on_disconnect
|
|
157
|
+
self._on_heartbeat_ack = on_heartbeat_ack
|
|
158
|
+
self._forced_close_pending = False
|
|
152
159
|
|
|
153
160
|
@staticmethod
|
|
154
161
|
def reconnect_policy_is_invalid(policy: ReconnectPolicy) -> bool:
|
|
@@ -8,7 +8,11 @@ from enum import Enum
|
|
|
8
8
|
|
|
9
9
|
from websockets import ClientConnection
|
|
10
10
|
|
|
11
|
-
from phoenix_channels_python_client.phx_messages import
|
|
11
|
+
from phoenix_channels_python_client.phx_messages import (
|
|
12
|
+
PHOENIX_TOPIC,
|
|
13
|
+
ChannelMessage,
|
|
14
|
+
Event,
|
|
15
|
+
)
|
|
12
16
|
from phoenix_channels_python_client.topic_subscription import TopicSubscription
|
|
13
17
|
from phoenix_channels_python_client.utils import make_message
|
|
14
18
|
|
|
@@ -155,7 +159,7 @@ class PHXProtocolHandler:
|
|
|
155
159
|
self.logger.debug("Processing message - %s", phx_message)
|
|
156
160
|
topic = phx_message.topic
|
|
157
161
|
|
|
158
|
-
if topic ==
|
|
162
|
+
if topic == PHOENIX_TOPIC:
|
|
159
163
|
if on_heartbeat_response is not None:
|
|
160
164
|
on_heartbeat_response(phx_message)
|
|
161
165
|
continue
|
|
@@ -7,6 +7,7 @@ from collections import deque
|
|
|
7
7
|
|
|
8
8
|
from websockets import ClientConnection
|
|
9
9
|
from websockets.exceptions import ConnectionClosed
|
|
10
|
+
from websockets.frames import CloseCode
|
|
10
11
|
|
|
11
12
|
from phoenix_channels_python_client.client_types import (
|
|
12
13
|
ReconnectDecision,
|
|
@@ -159,7 +160,7 @@ class ReconnectControllerMixin:
|
|
|
159
160
|
return decision
|
|
160
161
|
|
|
161
162
|
if (
|
|
162
|
-
close_code in {
|
|
163
|
+
close_code in {CloseCode.NORMAL_CLOSURE, CloseCode.GOING_AWAY}
|
|
163
164
|
and not self.reconnect_policy.reconnect_on_normal_close
|
|
164
165
|
):
|
|
165
166
|
decision = ReconnectDecision(should_reconnect=False)
|
|
@@ -176,12 +177,15 @@ class ReconnectControllerMixin:
|
|
|
176
177
|
)
|
|
177
178
|
return decision
|
|
178
179
|
|
|
179
|
-
if
|
|
180
|
+
if (
|
|
181
|
+
close_code == CloseCode.POLICY_VIOLATION
|
|
182
|
+
and self.reconnect_policy.policy_violation_is_terminal
|
|
183
|
+
):
|
|
180
184
|
reason = close_reason or "policy violation"
|
|
181
185
|
decision = ReconnectDecision(
|
|
182
186
|
should_reconnect=False,
|
|
183
187
|
terminal_error=PHXConnectionError(
|
|
184
|
-
f"Reconnect disabled due to terminal close code
|
|
188
|
+
f"Reconnect disabled due to terminal close code {CloseCode.POLICY_VIOLATION} ({reason})"
|
|
185
189
|
),
|
|
186
190
|
)
|
|
187
191
|
logger.debug(
|
|
@@ -199,7 +203,7 @@ class ReconnectControllerMixin:
|
|
|
199
203
|
)
|
|
200
204
|
return decision
|
|
201
205
|
|
|
202
|
-
if close_code ==
|
|
206
|
+
if close_code == CloseCode.SERVICE_RESTART:
|
|
203
207
|
decision = ReconnectDecision(
|
|
204
208
|
should_reconnect=True,
|
|
205
209
|
min_delay_s=self.reconnect_policy.service_restart_min_delay_s,
|
|
@@ -222,7 +226,7 @@ class ReconnectControllerMixin:
|
|
|
222
226
|
)
|
|
223
227
|
return decision
|
|
224
228
|
|
|
225
|
-
if close_code ==
|
|
229
|
+
if close_code == CloseCode.TRY_AGAIN_LATER:
|
|
226
230
|
decision = ReconnectDecision(
|
|
227
231
|
should_reconnect=True,
|
|
228
232
|
min_delay_s=self.reconnect_policy.try_again_later_min_delay_s,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import inspect
|
|
4
5
|
import logging
|
|
5
6
|
import signal
|
|
6
7
|
from collections import deque
|
|
@@ -17,11 +18,31 @@ from phoenix_channels_python_client.client_types import (
|
|
|
17
18
|
ReconnectPolicy,
|
|
18
19
|
)
|
|
19
20
|
from phoenix_channels_python_client.exceptions import PHXConnectionError
|
|
20
|
-
from phoenix_channels_python_client.phx_messages import
|
|
21
|
+
from phoenix_channels_python_client.phx_messages import (
|
|
22
|
+
PHOENIX_TOPIC,
|
|
23
|
+
ChannelMessage,
|
|
24
|
+
Event,
|
|
25
|
+
)
|
|
21
26
|
from phoenix_channels_python_client.protocol_handler import PHXProtocolHandler
|
|
22
27
|
from phoenix_channels_python_client.topic_subscription import TopicSubscription
|
|
23
28
|
from phoenix_channels_python_client.utils import make_message
|
|
24
29
|
|
|
30
|
+
# RFC 6455 §7.4.2 private-use range (4000-4999, unregistrable); outside
|
|
31
|
+
# _classify_disconnect's specially-handled codes, so a forced close
|
|
32
|
+
# reconnects like any other unclassified disconnect.
|
|
33
|
+
_FORCED_CLOSE_CODE = 4000
|
|
34
|
+
|
|
35
|
+
# RFC 6455 §5.5.1: control frames (including Close) cap at 125 bytes total;
|
|
36
|
+
# 2 of those bytes are the close code, leaving 123 for the reason.
|
|
37
|
+
_MAX_CLOSE_REASON_BYTES = 123
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _truncate_close_reason(reason: str) -> str:
|
|
41
|
+
encoded = reason.encode("utf-8")
|
|
42
|
+
if len(encoded) <= _MAX_CLOSE_REASON_BYTES:
|
|
43
|
+
return reason
|
|
44
|
+
return encoded[:_MAX_CLOSE_REASON_BYTES].decode("utf-8", errors="ignore")
|
|
45
|
+
|
|
25
46
|
|
|
26
47
|
class _SupervisorRuntimeDeps(Protocol):
|
|
27
48
|
async def _rejoin_topics(self, generation: int) -> None: ...
|
|
@@ -65,6 +86,8 @@ class SupervisorMixin:
|
|
|
65
86
|
_pending_heartbeat_ref: str | None
|
|
66
87
|
_on_reconnect: Callable[[], Awaitable[None]] | None
|
|
67
88
|
_on_disconnect: Callable[[Exception | None], Awaitable[None]] | None
|
|
89
|
+
_on_heartbeat_ack: Callable[[], None] | None
|
|
90
|
+
_forced_close_pending: bool
|
|
68
91
|
|
|
69
92
|
async def _start_processing(
|
|
70
93
|
self, connection: ClientConnection, conn_generation: int
|
|
@@ -80,6 +103,37 @@ class SupervisorMixin:
|
|
|
80
103
|
if message.ref is not None and message.ref == self._pending_heartbeat_ref:
|
|
81
104
|
self.logger.debug("Heartbeat acknowledged (ref=%s)", message.ref)
|
|
82
105
|
self._pending_heartbeat_ref = None
|
|
106
|
+
if self._on_heartbeat_ack is not None:
|
|
107
|
+
try:
|
|
108
|
+
result = self._on_heartbeat_ack()
|
|
109
|
+
if inspect.iscoroutine(result):
|
|
110
|
+
result.close()
|
|
111
|
+
raise TypeError(
|
|
112
|
+
"on_heartbeat_ack must be synchronous; an async "
|
|
113
|
+
"function was passed and its body never ran"
|
|
114
|
+
)
|
|
115
|
+
except Exception:
|
|
116
|
+
self.logger.exception("Error in on_heartbeat_ack callback")
|
|
117
|
+
|
|
118
|
+
async def _invoke_callback_safely(
|
|
119
|
+
self, label: str, callback: Callable[..., Awaitable[None]], *args: object
|
|
120
|
+
) -> None:
|
|
121
|
+
try:
|
|
122
|
+
await callback(*args)
|
|
123
|
+
except Exception:
|
|
124
|
+
self.logger.exception("Error in %s callback", label)
|
|
125
|
+
|
|
126
|
+
async def close_connection(self, reason: str) -> None:
|
|
127
|
+
"""Force-close the current connection so the supervisor's own
|
|
128
|
+
disconnect handling decides whether to reconnect. No-op if not
|
|
129
|
+
currently connected."""
|
|
130
|
+
connection = self.connection
|
|
131
|
+
if connection is None:
|
|
132
|
+
return
|
|
133
|
+
reason = _truncate_close_reason(reason)
|
|
134
|
+
self.logger.info("Forcing connection close: %s", reason)
|
|
135
|
+
self._forced_close_pending = True
|
|
136
|
+
await connection.close(code=_FORCED_CLOSE_CODE, reason=reason)
|
|
83
137
|
|
|
84
138
|
async def _heartbeat_loop(self, connection: ClientConnection) -> None:
|
|
85
139
|
if self._heartbeat_interval_s is None:
|
|
@@ -105,7 +159,7 @@ class SupervisorMixin:
|
|
|
105
159
|
self._pending_heartbeat_ref = ref
|
|
106
160
|
|
|
107
161
|
heartbeat_message = make_message(
|
|
108
|
-
topic=
|
|
162
|
+
topic=PHOENIX_TOPIC,
|
|
109
163
|
event=Event("heartbeat"),
|
|
110
164
|
payload={},
|
|
111
165
|
ref=ref,
|
|
@@ -208,10 +262,9 @@ class SupervisorMixin:
|
|
|
208
262
|
self._initial_connection_future.set_result(None)
|
|
209
263
|
|
|
210
264
|
if generation > 1 and self._on_reconnect is not None:
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
self.logger.exception("Error in on_reconnect callback")
|
|
265
|
+
await self._invoke_callback_safely(
|
|
266
|
+
"on_reconnect", self._on_reconnect
|
|
267
|
+
)
|
|
215
268
|
|
|
216
269
|
try:
|
|
217
270
|
await self._message_routing_task
|
|
@@ -234,14 +287,15 @@ class SupervisorMixin:
|
|
|
234
287
|
connection=connection,
|
|
235
288
|
routing_error=routing_error,
|
|
236
289
|
)
|
|
290
|
+
forced_close = self._forced_close_pending
|
|
291
|
+
self._forced_close_pending = False
|
|
237
292
|
|
|
238
293
|
await self._cleanup_connection()
|
|
239
294
|
|
|
240
295
|
if self._on_disconnect is not None:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
self.logger.exception("Error in on_disconnect callback")
|
|
296
|
+
await self._invoke_callback_safely(
|
|
297
|
+
"on_disconnect", self._on_disconnect, routing_error
|
|
298
|
+
)
|
|
245
299
|
|
|
246
300
|
if self._shutdown_event.is_set():
|
|
247
301
|
break
|
|
@@ -249,7 +303,17 @@ class SupervisorMixin:
|
|
|
249
303
|
if not self.auto_reconnect:
|
|
250
304
|
break
|
|
251
305
|
|
|
252
|
-
|
|
306
|
+
if forced_close:
|
|
307
|
+
# The remote's echoed close code isn't guaranteed to match
|
|
308
|
+
# what we sent (websockets' close_code reflects the code
|
|
309
|
+
# *received*, not sent), so classification would be
|
|
310
|
+
# unreliable here; a forced close always reconnects like
|
|
311
|
+
# any other unclassified disconnect.
|
|
312
|
+
decision = ReconnectDecision(should_reconnect=True)
|
|
313
|
+
else:
|
|
314
|
+
decision = runtime_deps._classify_disconnect(
|
|
315
|
+
close_code, close_reason
|
|
316
|
+
)
|
|
253
317
|
if decision.terminal_error is not None:
|
|
254
318
|
self._terminal_error = decision.terminal_error
|
|
255
319
|
self.logger.error("%s", self._terminal_error)
|
|
@@ -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.4
|
|
4
4
|
Summary: A Python client library for connecting to Phoenix Channels
|
|
5
5
|
Author: Phoenix Channels Python Client
|
|
6
6
|
License-Expression: MIT
|
|
@@ -42,8 +42,8 @@ A modern, async Python client library for connecting to [Phoenix Channels](https
|
|
|
42
42
|
Install from source:
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
git clone https://github.com/
|
|
46
|
-
cd
|
|
45
|
+
git clone https://github.com/band-ai/phoenix-channels-python-client.git
|
|
46
|
+
cd phoenix-channels-python-client
|
|
47
47
|
python3 -m venv venv
|
|
48
48
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
49
49
|
pip install .
|
|
@@ -54,8 +54,8 @@ pip install .
|
|
|
54
54
|
Set up development environment:
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
|
-
git clone https://github.com/
|
|
58
|
-
cd
|
|
57
|
+
git clone https://github.com/band-ai/phoenix-channels-python-client.git
|
|
58
|
+
cd phoenix-channels-python-client
|
|
59
59
|
python3 -m venv venv
|
|
60
60
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
61
61
|
python3 -m pip install -U pip
|
|
@@ -0,0 +1,17 @@
|
|
|
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,,
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
phoenix_channels_python_client/__init__.py,sha256=LCv6fVM36IZ2Vf60SlKFZnmcT8vkJS7wgvfTitS-sac,737
|
|
2
|
-
phoenix_channels_python_client/client.py,sha256=1h-34mci3HB7UtBI_3JmZVep_-9coXxCNS7NnWfOUZk,9148
|
|
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=EiJX0KbcpJBtNj0r3j4zdN9qCb7fzJB87TtjuFYGDwQ,1186
|
|
7
|
-
phoenix_channels_python_client/protocol_handler.py,sha256=6nuespMcWWhVQwea8W3eANr_Ea6BcZBqNj1ui1WvDuE,8105
|
|
8
|
-
phoenix_channels_python_client/reconnect_controller.py,sha256=v2YXx5qVlxxQw8jUy-5kvrkMDR_slRRVqhJr7GrZsUk,10825
|
|
9
|
-
phoenix_channels_python_client/supervisor.py,sha256=l4-awiJiSVU4qBhCJeDkKnNnAw_1dLWlrE0y_2e74Zw,15464
|
|
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.2.dist-info/licenses/LICENSE,sha256=zwqkIClV_fMWOC0Auvd0DYqZMLL0-hIqeKd6tygE39g,1064
|
|
14
|
-
phoenix_channels_python_client-0.2.2.dist-info/METADATA,sha256=uKqLijxsab8Y8Q962t4lxi8u6Rsv3HzXoz-6QG543xE,9159
|
|
15
|
-
phoenix_channels_python_client-0.2.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
16
|
-
phoenix_channels_python_client-0.2.2.dist-info/top_level.txt,sha256=ZYXMGCOeev4cyNU9XvNGHKt1wOgVkFHaS9TlwDa4eDU,31
|
|
17
|
-
phoenix_channels_python_client-0.2.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|