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.
Files changed (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. 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