pulse-framework 0.1.62__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.
- pulse/__init__.py +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/channel.py
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import uuid
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
8
|
+
|
|
9
|
+
from pulse.context import PulseContext
|
|
10
|
+
from pulse.helpers import create_future_on_loop
|
|
11
|
+
from pulse.messages import (
|
|
12
|
+
ClientChannelRequestMessage,
|
|
13
|
+
ClientChannelResponseMessage,
|
|
14
|
+
ServerChannelMessage,
|
|
15
|
+
ServerChannelRequestMessage,
|
|
16
|
+
ServerChannelResponseMessage,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from pulse.render_session import RenderSession
|
|
21
|
+
from pulse.user_session import UserSession
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ChannelHandler = Callable[[Any], Any | Awaitable[Any]]
|
|
27
|
+
"""Handler function for channel events. Can be sync or async.
|
|
28
|
+
|
|
29
|
+
Type alias for ``Callable[[Any], Any | Awaitable[Any]]``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ChannelClosed(RuntimeError):
|
|
34
|
+
"""Raised when interacting with a channel that has been closed.
|
|
35
|
+
|
|
36
|
+
This exception is raised when attempting to call ``on()``, ``emit()``,
|
|
37
|
+
or ``request()`` on a channel that has already been closed.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
ch = ps.channel("my-channel")
|
|
43
|
+
ch.close()
|
|
44
|
+
ch.emit("event") # Raises ChannelClosed
|
|
45
|
+
```
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ChannelTimeout(asyncio.TimeoutError):
|
|
50
|
+
"""Raised when a channel request times out waiting for a response.
|
|
51
|
+
|
|
52
|
+
This exception is raised by ``Channel.request()`` when the specified
|
|
53
|
+
timeout elapses before receiving a response from the client.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
result = await ch.request("get_value", timeout=5.0) # Raises if no response in 5s
|
|
59
|
+
```
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class PendingRequest:
|
|
65
|
+
future: asyncio.Future[Any]
|
|
66
|
+
channel_id: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ChannelsManager:
|
|
70
|
+
"""Coordinates creation, routing, and cleanup of Pulse channels."""
|
|
71
|
+
|
|
72
|
+
_render_session: "RenderSession"
|
|
73
|
+
_channels: dict[str, "Channel"]
|
|
74
|
+
_channels_by_route: dict[str, set[str]]
|
|
75
|
+
pending_requests: dict[str, PendingRequest]
|
|
76
|
+
|
|
77
|
+
def __init__(self, render_session: "RenderSession") -> None:
|
|
78
|
+
self._render_session = render_session
|
|
79
|
+
self._channels = {}
|
|
80
|
+
self._channels_by_route = defaultdict(set)
|
|
81
|
+
self.pending_requests = {}
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
def create(self, identifier: str | None = None) -> "Channel":
|
|
85
|
+
ctx = PulseContext.get()
|
|
86
|
+
render = ctx.render
|
|
87
|
+
session = ctx.session
|
|
88
|
+
if render is None or session is None:
|
|
89
|
+
raise RuntimeError("Channels require an active render and session")
|
|
90
|
+
|
|
91
|
+
channel_id = identifier or uuid.uuid4().hex
|
|
92
|
+
if channel_id in self._channels:
|
|
93
|
+
raise ValueError(f"Channel id '{channel_id}' is already in use")
|
|
94
|
+
|
|
95
|
+
route_path: str | None = None
|
|
96
|
+
if ctx.route is not None:
|
|
97
|
+
# unique_path() returns absolute path, use as-is for keys
|
|
98
|
+
route_path = ctx.route.pulse_route.unique_path()
|
|
99
|
+
|
|
100
|
+
channel = Channel(
|
|
101
|
+
self,
|
|
102
|
+
channel_id,
|
|
103
|
+
render_id=render.id,
|
|
104
|
+
session_id=session.sid,
|
|
105
|
+
route_path=route_path,
|
|
106
|
+
)
|
|
107
|
+
self._channels[channel_id] = channel
|
|
108
|
+
if route_path is not None:
|
|
109
|
+
self._channels_by_route[route_path].add(channel_id)
|
|
110
|
+
return channel
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
def remove_route(self, path: str) -> None:
|
|
114
|
+
# route_path is already an absolute path
|
|
115
|
+
route_channels = list(self._channels_by_route.get(path, set()))
|
|
116
|
+
# if route_channels:
|
|
117
|
+
# print(f"Disposing {len(route_channels)} channel(s) for route {route_path}")
|
|
118
|
+
for channel_id in route_channels:
|
|
119
|
+
channel = self._channels.get(channel_id)
|
|
120
|
+
if channel is None:
|
|
121
|
+
continue
|
|
122
|
+
channel.closed = True
|
|
123
|
+
self.dispose_channel(channel, reason="route.unmount")
|
|
124
|
+
self._channels_by_route.pop(path, None)
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
def handle_client_response(self, message: ClientChannelResponseMessage) -> None:
|
|
128
|
+
response_to = message.get("responseTo")
|
|
129
|
+
if not response_to:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
if error := message.get("error") is not None:
|
|
133
|
+
self.resolve_pending_error(response_to, error)
|
|
134
|
+
else:
|
|
135
|
+
self._resolve_pending_success(response_to, message.get("payload"))
|
|
136
|
+
|
|
137
|
+
def handle_client_event(
|
|
138
|
+
self,
|
|
139
|
+
*,
|
|
140
|
+
render: "RenderSession",
|
|
141
|
+
session: "UserSession",
|
|
142
|
+
message: ClientChannelRequestMessage,
|
|
143
|
+
) -> None:
|
|
144
|
+
channel_id = str(message.get("channel"))
|
|
145
|
+
channel = self._channels.get(channel_id)
|
|
146
|
+
if channel is None:
|
|
147
|
+
if request_id := message.get("requestId"):
|
|
148
|
+
self._send_error_response(channel_id, request_id, "Channel closed")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
if channel.render_id != render.id or channel.session_id != session.sid:
|
|
152
|
+
logger.warning(
|
|
153
|
+
"Ignoring channel message for mismatched context: %s", channel_id
|
|
154
|
+
)
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
event = message["event"]
|
|
158
|
+
payload = message.get("payload")
|
|
159
|
+
request_id = message.get("requestId")
|
|
160
|
+
|
|
161
|
+
if event == "__close__":
|
|
162
|
+
reason: str | None = None
|
|
163
|
+
if isinstance(payload, str):
|
|
164
|
+
reason = payload
|
|
165
|
+
elif isinstance(payload, dict):
|
|
166
|
+
raw_reason = cast(Any, payload.get("reason"))
|
|
167
|
+
if raw_reason is not None:
|
|
168
|
+
reason = str(raw_reason)
|
|
169
|
+
self.release_channel(channel.id, reason=reason)
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
route_ctx = None
|
|
173
|
+
if channel.route_path is not None:
|
|
174
|
+
try:
|
|
175
|
+
mount = render.get_route_mount(channel.route_path)
|
|
176
|
+
route_ctx = mount.route
|
|
177
|
+
except Exception:
|
|
178
|
+
route_ctx = None
|
|
179
|
+
|
|
180
|
+
async def _invoke() -> None:
|
|
181
|
+
try:
|
|
182
|
+
with PulseContext.update(
|
|
183
|
+
session=session, render=render, route=route_ctx
|
|
184
|
+
):
|
|
185
|
+
result = await channel.dispatch(event, payload, request_id)
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
if request_id:
|
|
188
|
+
self._send_error_response(channel.id, request_id, str(exc))
|
|
189
|
+
else:
|
|
190
|
+
logger.exception("Unhandled error in channel handler")
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
if request_id:
|
|
194
|
+
msg = ServerChannelResponseMessage(
|
|
195
|
+
type="channel_message",
|
|
196
|
+
channel=channel.id,
|
|
197
|
+
event=None,
|
|
198
|
+
responseTo=request_id,
|
|
199
|
+
payload=result,
|
|
200
|
+
)
|
|
201
|
+
self.send_to_client(
|
|
202
|
+
channel=channel,
|
|
203
|
+
msg=msg,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
asyncio.create_task(_invoke())
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
def register_pending(
|
|
210
|
+
self,
|
|
211
|
+
request_id: str,
|
|
212
|
+
future: asyncio.Future[Any],
|
|
213
|
+
channel_id: str,
|
|
214
|
+
) -> None:
|
|
215
|
+
self.pending_requests[request_id] = PendingRequest(
|
|
216
|
+
future=future, channel_id=channel_id
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _resolve_pending_success(self, request_id: str, payload: Any) -> None:
|
|
220
|
+
pending = self.pending_requests.pop(request_id, None)
|
|
221
|
+
if not pending:
|
|
222
|
+
return
|
|
223
|
+
if pending.future.done():
|
|
224
|
+
return
|
|
225
|
+
pending.future.set_result(payload)
|
|
226
|
+
|
|
227
|
+
def resolve_pending_error(self, request_id: str, error: Any) -> None:
|
|
228
|
+
pending = self.pending_requests.pop(request_id, None)
|
|
229
|
+
if not pending:
|
|
230
|
+
return
|
|
231
|
+
if pending.future.done():
|
|
232
|
+
return
|
|
233
|
+
if isinstance(error, Exception):
|
|
234
|
+
pending.future.set_exception(error)
|
|
235
|
+
else:
|
|
236
|
+
pending.future.set_exception(RuntimeError(str(error)))
|
|
237
|
+
|
|
238
|
+
def _send_error_response(
|
|
239
|
+
self, channel_id: str, request_id: str, message: str
|
|
240
|
+
) -> None:
|
|
241
|
+
channel = self._channels.get(channel_id)
|
|
242
|
+
if channel is None:
|
|
243
|
+
self.resolve_pending_error(request_id, ChannelClosed(message))
|
|
244
|
+
return
|
|
245
|
+
try:
|
|
246
|
+
msg = ServerChannelResponseMessage(
|
|
247
|
+
type="channel_message",
|
|
248
|
+
channel=channel.id,
|
|
249
|
+
event=None,
|
|
250
|
+
responseTo=request_id,
|
|
251
|
+
payload=None,
|
|
252
|
+
error=message,
|
|
253
|
+
)
|
|
254
|
+
self.send_to_client(
|
|
255
|
+
channel=channel,
|
|
256
|
+
msg=msg,
|
|
257
|
+
)
|
|
258
|
+
except ChannelClosed:
|
|
259
|
+
self.resolve_pending_error(request_id, ChannelClosed(message))
|
|
260
|
+
|
|
261
|
+
def send_error(self, channel_id: str, request_id: str, message: str) -> None:
|
|
262
|
+
self._send_error_response(channel_id, request_id, message)
|
|
263
|
+
|
|
264
|
+
def _cancel_pending_for_channel(self, channel_id: str) -> None:
|
|
265
|
+
for key, pending in list(self.pending_requests.items()):
|
|
266
|
+
if pending.channel_id != channel_id:
|
|
267
|
+
continue
|
|
268
|
+
if not pending.future.done():
|
|
269
|
+
pending.future.set_exception(ChannelClosed("Channel closed"))
|
|
270
|
+
self.pending_requests.pop(key, None)
|
|
271
|
+
|
|
272
|
+
# ------------------------------------------------------------------
|
|
273
|
+
def release_channel(
|
|
274
|
+
self,
|
|
275
|
+
channel_id: str,
|
|
276
|
+
*,
|
|
277
|
+
reason: str | None = None,
|
|
278
|
+
) -> bool:
|
|
279
|
+
channel = self._channels.get(channel_id)
|
|
280
|
+
if channel is None:
|
|
281
|
+
return False
|
|
282
|
+
if channel.closed:
|
|
283
|
+
# Already closed but still tracked; ensure cleanup completes.
|
|
284
|
+
self.dispose_channel(channel, reason=reason or "client.close")
|
|
285
|
+
return True
|
|
286
|
+
|
|
287
|
+
channel.closed = True
|
|
288
|
+
self.dispose_channel(channel, reason=reason or "client.close")
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
# ------------------------------------------------------------------
|
|
292
|
+
def _cleanup_channel_refs(self, channel: "Channel") -> None:
|
|
293
|
+
if channel.route_path is not None:
|
|
294
|
+
route_bucket = self._channels_by_route.get(channel.route_path)
|
|
295
|
+
if route_bucket is not None:
|
|
296
|
+
route_bucket.discard(channel.id)
|
|
297
|
+
if not route_bucket:
|
|
298
|
+
self._channels_by_route.pop(channel.route_path, None)
|
|
299
|
+
|
|
300
|
+
def dispose_channel(
|
|
301
|
+
self,
|
|
302
|
+
channel: "Channel",
|
|
303
|
+
*,
|
|
304
|
+
reason: str | None = None,
|
|
305
|
+
) -> None:
|
|
306
|
+
# pending = sum(
|
|
307
|
+
# 1
|
|
308
|
+
# for pending in self.pending_requests.values()
|
|
309
|
+
# if pending.channel_id == channel.id
|
|
310
|
+
# )
|
|
311
|
+
# print(f"Disposing channel id={channel.id} render={channel.render_id} session={channel.session_id} route={channel.route_path} reason={reason or 'unspecified'} pending={pending}")
|
|
312
|
+
self._cleanup_channel_refs(channel)
|
|
313
|
+
self._cancel_pending_for_channel(channel.id)
|
|
314
|
+
self._channels.pop(channel.id, None)
|
|
315
|
+
# Notify client that the channel has been closed
|
|
316
|
+
try:
|
|
317
|
+
msg = ServerChannelRequestMessage(
|
|
318
|
+
type="channel_message",
|
|
319
|
+
channel=channel.id,
|
|
320
|
+
event="__close__",
|
|
321
|
+
payload=None,
|
|
322
|
+
)
|
|
323
|
+
self.send_to_client(
|
|
324
|
+
channel=channel,
|
|
325
|
+
msg=msg,
|
|
326
|
+
)
|
|
327
|
+
except Exception:
|
|
328
|
+
print(f"Failed to send close notification for channel {channel.id}")
|
|
329
|
+
|
|
330
|
+
def send_to_client(
|
|
331
|
+
self,
|
|
332
|
+
*,
|
|
333
|
+
channel: "Channel",
|
|
334
|
+
msg: ServerChannelMessage,
|
|
335
|
+
) -> None:
|
|
336
|
+
self._render_session.send(msg)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class Channel:
|
|
340
|
+
"""Bidirectional communication channel bound to a render session.
|
|
341
|
+
|
|
342
|
+
Channels enable real-time messaging between server and client. Use
|
|
343
|
+
``ps.channel()`` to create a channel within a component.
|
|
344
|
+
|
|
345
|
+
Attributes:
|
|
346
|
+
id: Channel identifier (auto-generated UUID or user-provided).
|
|
347
|
+
render_id: Associated render session ID.
|
|
348
|
+
session_id: Associated user session ID.
|
|
349
|
+
route_path: Route path this channel is bound to, or None.
|
|
350
|
+
closed: Whether the channel has been closed.
|
|
351
|
+
|
|
352
|
+
Example:
|
|
353
|
+
|
|
354
|
+
```python
|
|
355
|
+
@ps.component
|
|
356
|
+
def ChatRoom():
|
|
357
|
+
ch = ps.channel("chat")
|
|
358
|
+
|
|
359
|
+
@ch.on("message")
|
|
360
|
+
def handle_message(payload):
|
|
361
|
+
ch.emit("broadcast", payload)
|
|
362
|
+
|
|
363
|
+
return ps.div("Chat room")
|
|
364
|
+
```
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
_manager: ChannelsManager
|
|
368
|
+
id: str
|
|
369
|
+
render_id: str
|
|
370
|
+
session_id: str
|
|
371
|
+
route_path: str | None
|
|
372
|
+
_handlers: dict[str, list[ChannelHandler]]
|
|
373
|
+
closed: bool
|
|
374
|
+
|
|
375
|
+
def __init__(
|
|
376
|
+
self,
|
|
377
|
+
manager: ChannelsManager,
|
|
378
|
+
identifier: str,
|
|
379
|
+
*,
|
|
380
|
+
render_id: str,
|
|
381
|
+
session_id: str,
|
|
382
|
+
route_path: str | None,
|
|
383
|
+
) -> None:
|
|
384
|
+
self._manager = manager
|
|
385
|
+
self.id = identifier
|
|
386
|
+
self.render_id = render_id
|
|
387
|
+
self.session_id = session_id
|
|
388
|
+
self.route_path = route_path
|
|
389
|
+
self._handlers = defaultdict(list)
|
|
390
|
+
self.closed = False
|
|
391
|
+
|
|
392
|
+
# ---------------------------------------------------------------------
|
|
393
|
+
# Registration
|
|
394
|
+
# ---------------------------------------------------------------------
|
|
395
|
+
def on(self, event: str, handler: ChannelHandler) -> Callable[[], None]:
|
|
396
|
+
"""Register a handler for an incoming event.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
event: Event name to listen for.
|
|
400
|
+
handler: Callback function ``(payload: Any) -> Any | Awaitable[Any]``.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
Callable that removes the handler when invoked.
|
|
404
|
+
|
|
405
|
+
Raises:
|
|
406
|
+
ChannelClosed: If the channel is closed.
|
|
407
|
+
|
|
408
|
+
Example:
|
|
409
|
+
|
|
410
|
+
```python
|
|
411
|
+
ch = ps.channel()
|
|
412
|
+
remove_handler = ch.on("data", lambda payload: print(payload))
|
|
413
|
+
# Later, to unregister:
|
|
414
|
+
remove_handler()
|
|
415
|
+
```
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
self._ensure_open()
|
|
419
|
+
bucket = self._handlers[event]
|
|
420
|
+
bucket.append(handler)
|
|
421
|
+
|
|
422
|
+
def _remove() -> None:
|
|
423
|
+
handlers = self._handlers.get(event)
|
|
424
|
+
if not handlers:
|
|
425
|
+
return
|
|
426
|
+
try:
|
|
427
|
+
handlers.remove(handler)
|
|
428
|
+
except ValueError:
|
|
429
|
+
return
|
|
430
|
+
if not handlers:
|
|
431
|
+
self._handlers.pop(event, None)
|
|
432
|
+
|
|
433
|
+
return _remove
|
|
434
|
+
|
|
435
|
+
# ---------------------------------------------------------------------
|
|
436
|
+
# Outgoing messages
|
|
437
|
+
# ---------------------------------------------------------------------
|
|
438
|
+
def emit(self, event: str, payload: Any = None) -> None:
|
|
439
|
+
"""Send a fire-and-forget event to the client.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
event: Event name.
|
|
443
|
+
payload: Data to send (optional).
|
|
444
|
+
|
|
445
|
+
Raises:
|
|
446
|
+
ChannelClosed: If the channel is closed.
|
|
447
|
+
|
|
448
|
+
Example:
|
|
449
|
+
|
|
450
|
+
```python
|
|
451
|
+
ch.emit("notification", {"message": "Hello"})
|
|
452
|
+
```
|
|
453
|
+
"""
|
|
454
|
+
|
|
455
|
+
self._ensure_open()
|
|
456
|
+
msg = ServerChannelRequestMessage(
|
|
457
|
+
type="channel_message",
|
|
458
|
+
channel=self.id,
|
|
459
|
+
event=event,
|
|
460
|
+
payload=payload,
|
|
461
|
+
)
|
|
462
|
+
self._manager.send_to_client(
|
|
463
|
+
channel=self,
|
|
464
|
+
msg=msg,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
async def request(
|
|
468
|
+
self,
|
|
469
|
+
event: str,
|
|
470
|
+
payload: Any = None,
|
|
471
|
+
*,
|
|
472
|
+
timeout: float | None = None,
|
|
473
|
+
) -> Any:
|
|
474
|
+
"""Send a request to the client and await the response.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
event: Event name.
|
|
478
|
+
payload: Data to send (optional).
|
|
479
|
+
timeout: Timeout in seconds (optional).
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
Response payload from client.
|
|
483
|
+
|
|
484
|
+
Raises:
|
|
485
|
+
ChannelClosed: If the channel is closed.
|
|
486
|
+
ChannelTimeout: If the request times out.
|
|
487
|
+
|
|
488
|
+
Example:
|
|
489
|
+
|
|
490
|
+
```python
|
|
491
|
+
result = await ch.request("get_value", timeout=5.0)
|
|
492
|
+
```
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
self._ensure_open()
|
|
496
|
+
request_id = uuid.uuid4().hex
|
|
497
|
+
fut = create_future_on_loop()
|
|
498
|
+
self._manager.register_pending(request_id, fut, self.id)
|
|
499
|
+
msg = ServerChannelRequestMessage(
|
|
500
|
+
type="channel_message",
|
|
501
|
+
channel=self.id,
|
|
502
|
+
event=event,
|
|
503
|
+
payload=payload,
|
|
504
|
+
requestId=request_id,
|
|
505
|
+
)
|
|
506
|
+
self._manager.send_to_client(
|
|
507
|
+
channel=self,
|
|
508
|
+
msg=msg,
|
|
509
|
+
)
|
|
510
|
+
try:
|
|
511
|
+
if timeout is None:
|
|
512
|
+
return await fut
|
|
513
|
+
return await asyncio.wait_for(fut, timeout=timeout)
|
|
514
|
+
except TimeoutError as exc:
|
|
515
|
+
self._manager.resolve_pending_error(
|
|
516
|
+
request_id,
|
|
517
|
+
ChannelTimeout("Channel request timed out"),
|
|
518
|
+
)
|
|
519
|
+
raise ChannelTimeout("Channel request timed out") from exc
|
|
520
|
+
finally:
|
|
521
|
+
self._manager.pending_requests.pop(request_id, None)
|
|
522
|
+
|
|
523
|
+
# ---------------------------------------------------------------------
|
|
524
|
+
def close(self) -> None:
|
|
525
|
+
"""Close the channel and clean up resources.
|
|
526
|
+
|
|
527
|
+
After closing, any further operations on the channel will raise
|
|
528
|
+
``ChannelClosed``. Pending requests will be cancelled.
|
|
529
|
+
"""
|
|
530
|
+
if self.closed:
|
|
531
|
+
return
|
|
532
|
+
self.closed = True
|
|
533
|
+
self._handlers.clear()
|
|
534
|
+
self._manager.dispose_channel(self, reason="channel.close")
|
|
535
|
+
|
|
536
|
+
# ---------------------------------------------------------------------
|
|
537
|
+
def _ensure_open(self) -> None:
|
|
538
|
+
if self.closed:
|
|
539
|
+
raise ChannelClosed(f"Channel '{self.id}' is closed")
|
|
540
|
+
|
|
541
|
+
async def dispatch(
|
|
542
|
+
self, event: str, payload: Any, request_id: str | None
|
|
543
|
+
) -> Any | None:
|
|
544
|
+
handlers = list(self._handlers.get(event, ()))
|
|
545
|
+
if not handlers:
|
|
546
|
+
return None
|
|
547
|
+
|
|
548
|
+
last_result: Any | None = None
|
|
549
|
+
for handler in handlers:
|
|
550
|
+
try:
|
|
551
|
+
result = handler(payload)
|
|
552
|
+
if asyncio.iscoroutine(result):
|
|
553
|
+
result = await result
|
|
554
|
+
except Exception as exc:
|
|
555
|
+
logger.exception(
|
|
556
|
+
"Error in channel handler '%s' for event '%s'", self.id, event
|
|
557
|
+
)
|
|
558
|
+
raise exc
|
|
559
|
+
if request_id is not None and result is not None:
|
|
560
|
+
return result
|
|
561
|
+
if result is not None:
|
|
562
|
+
last_result = result
|
|
563
|
+
return last_result
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def channel(identifier: str | None = None) -> Channel:
|
|
567
|
+
"""Create a channel bound to the current render session.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
identifier: Optional channel ID. Auto-generated UUID if not provided.
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
Channel instance.
|
|
574
|
+
|
|
575
|
+
Raises:
|
|
576
|
+
RuntimeError: If called outside an active render session.
|
|
577
|
+
|
|
578
|
+
Example:
|
|
579
|
+
|
|
580
|
+
```python
|
|
581
|
+
import pulse as ps
|
|
582
|
+
|
|
583
|
+
@ps.component
|
|
584
|
+
def ChatRoom():
|
|
585
|
+
ch = ps.channel("chat")
|
|
586
|
+
|
|
587
|
+
@ch.on("message")
|
|
588
|
+
def handle_message(payload):
|
|
589
|
+
ch.emit("broadcast", payload)
|
|
590
|
+
|
|
591
|
+
return ps.div("Chat room")
|
|
592
|
+
```
|
|
593
|
+
"""
|
|
594
|
+
|
|
595
|
+
ctx = PulseContext.get()
|
|
596
|
+
if ctx.render is None:
|
|
597
|
+
raise RuntimeError("Channels require an active render session")
|
|
598
|
+
return ctx.render.channels.create(identifier)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
__all__ = [
|
|
602
|
+
"ChannelsManager",
|
|
603
|
+
"Channel",
|
|
604
|
+
"ChannelClosed",
|
|
605
|
+
"ChannelTimeout",
|
|
606
|
+
"channel",
|
|
607
|
+
]
|
pulse/cli/__init__.py
ADDED
|
File without changes
|