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
@@ -0,0 +1,768 @@
1
+ import asyncio
2
+ import logging
3
+ import traceback
4
+ import uuid
5
+ from asyncio import iscoroutine
6
+ from collections.abc import Callable
7
+ from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
8
+
9
+ from pulse.context import PulseContext
10
+ from pulse.helpers import create_future_on_loop, create_task, later
11
+ from pulse.hooks.runtime import NotFoundInterrupt, RedirectInterrupt
12
+ from pulse.messages import (
13
+ ServerApiCallMessage,
14
+ ServerErrorPhase,
15
+ ServerInitMessage,
16
+ ServerJsExecMessage,
17
+ ServerMessage,
18
+ ServerNavigateToMessage,
19
+ ServerUpdateMessage,
20
+ )
21
+ from pulse.queries.store import QueryStore
22
+ from pulse.reactive import REACTIVE_CONTEXT, Effect, flush_effects
23
+ from pulse.renderer import RenderTree
24
+ from pulse.routing import (
25
+ Layout,
26
+ Route,
27
+ RouteContext,
28
+ RouteInfo,
29
+ RouteTree,
30
+ ensure_absolute_path,
31
+ )
32
+ from pulse.state import State
33
+ from pulse.transpiler.id import next_id
34
+ from pulse.transpiler.nodes import Expr
35
+
36
+ if TYPE_CHECKING:
37
+ from pulse.channel import ChannelsManager
38
+ from pulse.form import FormRegistry
39
+
40
+ logger = logging.getLogger(__file__)
41
+
42
+
43
+ class JsExecError(Exception):
44
+ """Raised when client-side JS execution fails."""
45
+
46
+
47
+ class RenderLoopError(RuntimeError):
48
+ path: str
49
+ renders: int
50
+ batch_id: int
51
+
52
+ def __init__(self, path: str, renders: int, batch_id: int) -> None:
53
+ super().__init__(
54
+ "Detected an infinite render loop in Pulse. "
55
+ + f"Render path '{path}' exceeded {renders} renders in reactive batch {batch_id}. "
56
+ + "This usually happens when a render or effect mutates state without a guard."
57
+ )
58
+ self.path = path
59
+ self.renders = renders
60
+ self.batch_id = batch_id
61
+
62
+
63
+ # Module-level convenience wrapper
64
+ @overload
65
+ def run_js(expr: Any, *, result: Literal[True]) -> asyncio.Future[Any]: ...
66
+
67
+
68
+ @overload
69
+ def run_js(expr: Any, *, result: Literal[False] = ...) -> None: ...
70
+
71
+
72
+ def run_js(expr: Any, *, result: bool = False) -> asyncio.Future[Any] | None:
73
+ """Execute JavaScript on the client. Convenience wrapper for RenderSession.run_js()."""
74
+ ctx = PulseContext.get()
75
+ if ctx.render is None:
76
+ raise RuntimeError("run_js() can only be called during callback execution")
77
+ return ctx.render.run_js(expr, result=result)
78
+
79
+
80
+ MountState = Literal["pending", "active", "idle", "closed"]
81
+ PendingAction = Literal["idle", "dispose"]
82
+ T_Render = TypeVar("T_Render")
83
+
84
+
85
+ class RouteMount:
86
+ render: "RenderSession"
87
+ path: str
88
+ route: RouteContext
89
+ tree: RenderTree
90
+ effect: Effect | None
91
+ _pulse_ctx: PulseContext | None
92
+ initialized: bool
93
+ state: MountState
94
+ pending_action: PendingAction | None
95
+ queue: list[ServerMessage] | None
96
+ queue_timeout: asyncio.TimerHandle | None
97
+ render_batch_id: int
98
+ render_batch_renders: int
99
+
100
+ def __init__(
101
+ self,
102
+ render: "RenderSession",
103
+ path: str,
104
+ route: Route | Layout,
105
+ route_info: RouteInfo,
106
+ ) -> None:
107
+ self.render = render
108
+ self.path = ensure_absolute_path(path)
109
+ self.route = RouteContext(route_info, route)
110
+ self.effect = None
111
+ self._pulse_ctx = None
112
+ self.tree = RenderTree(route.render())
113
+ self.initialized = False
114
+ self.state = "pending"
115
+ self.pending_action = None
116
+ self.queue = []
117
+ self.queue_timeout = None
118
+ self.render_batch_id = -1
119
+ self.render_batch_renders = 0
120
+
121
+ def update_route(self, route_info: RouteInfo) -> None:
122
+ self.route.update(route_info)
123
+
124
+ def _cancel_pending_timeout(self) -> None:
125
+ if self.queue_timeout is not None:
126
+ self.queue_timeout.cancel()
127
+ self.queue_timeout = None
128
+ self.pending_action = None
129
+
130
+ def _on_pending_timeout(self) -> None:
131
+ if self.state != "pending":
132
+ return
133
+ action = self.pending_action
134
+ self.pending_action = None
135
+ if action == "dispose":
136
+ self.render.dispose_mount(self.path, self)
137
+ return
138
+ self.to_idle()
139
+
140
+ def start_pending(self, timeout: float, *, action: PendingAction = "idle") -> None:
141
+ if self.state == "pending":
142
+ prev_action = self.pending_action
143
+ next_action: PendingAction = (
144
+ "dispose" if prev_action == "dispose" or action == "dispose" else "idle"
145
+ )
146
+ self._cancel_pending_timeout()
147
+ self.pending_action = next_action
148
+ self.queue_timeout = later(timeout, self._on_pending_timeout)
149
+ return
150
+ self._cancel_pending_timeout()
151
+ if self.state == "idle" and self.effect:
152
+ self.effect.resume()
153
+ self.state = "pending"
154
+ self.queue = []
155
+ self.pending_action = action
156
+ self.queue_timeout = later(timeout, self._on_pending_timeout)
157
+
158
+ def activate(self, send_message: Callable[[ServerMessage], Any]) -> None:
159
+ if self.state != "pending":
160
+ return
161
+ self._cancel_pending_timeout()
162
+ if self.queue:
163
+ for msg in self.queue:
164
+ send_message(msg)
165
+ self.queue = None
166
+ self.state = "active"
167
+
168
+ def deliver(
169
+ self, message: ServerMessage, send_message: Callable[[ServerMessage], Any]
170
+ ):
171
+ if self.state == "pending":
172
+ if self.queue is None:
173
+ raise RuntimeError(f"Pending mount missing queue for {self.path!r}")
174
+ self.queue.append(message)
175
+ return
176
+ if self.state == "active":
177
+ send_message(message)
178
+ return
179
+ if self.state == "closed":
180
+ raise RuntimeError(f"Message sent to closed mount {self.path!r}")
181
+
182
+ def to_idle(self) -> None:
183
+ if self.state != "pending":
184
+ return
185
+ self.state = "idle"
186
+ self.queue = None
187
+ self._cancel_pending_timeout()
188
+ if self.effect:
189
+ self.effect.pause()
190
+
191
+ def ensure_effect(self, *, lazy: bool = False, flush: bool = True) -> None:
192
+ if self.effect is not None:
193
+ if flush:
194
+ self.effect.flush()
195
+ return
196
+
197
+ ctx = PulseContext.get()
198
+ session = ctx.session
199
+
200
+ def _render_effect():
201
+ message = self.render.rerender(self, self.path, session=session)
202
+ if message is not None:
203
+ self.render.send(message)
204
+
205
+ def _report_render_error(exc: Exception) -> None:
206
+ details: dict[str, Any] | None = None
207
+ if isinstance(exc, RenderLoopError):
208
+ details = {
209
+ "renders": exc.renders,
210
+ "batch_id": exc.batch_id,
211
+ }
212
+ self.render.report_error(self.path, "render", exc, details)
213
+
214
+ self.effect = Effect(
215
+ _render_effect,
216
+ immediate=False,
217
+ name=f"{self.path}:render",
218
+ on_error=_report_render_error,
219
+ lazy=lazy,
220
+ )
221
+ if flush:
222
+ self.effect.flush()
223
+
224
+ def dispose(self) -> None:
225
+ self._cancel_pending_timeout()
226
+ self.state = "closed"
227
+ self.queue = None
228
+ self.tree.unmount()
229
+ if self.effect:
230
+ self.effect.dispose()
231
+
232
+
233
+ class RenderSession:
234
+ id: str
235
+ routes: RouteTree
236
+ channels: "ChannelsManager"
237
+ forms: "FormRegistry"
238
+ query_store: QueryStore
239
+ route_mounts: dict[str, RouteMount]
240
+ connected: bool
241
+ prerender_queue_timeout: float
242
+ detach_queue_timeout: float
243
+ disconnect_queue_timeout: float
244
+ render_loop_limit: int
245
+ _server_address: str | None
246
+ _client_address: str | None
247
+ _send_message: Callable[[ServerMessage], Any] | None
248
+ _pending_api: dict[str, asyncio.Future[dict[str, Any]]]
249
+ _pending_js_results: dict[str, asyncio.Future[Any]]
250
+ _global_states: dict[str, State]
251
+
252
+ def __init__(
253
+ self,
254
+ id: str,
255
+ routes: RouteTree,
256
+ *,
257
+ server_address: str | None = None,
258
+ client_address: str | None = None,
259
+ prerender_queue_timeout: float = 5.0,
260
+ detach_queue_timeout: float = 15.0,
261
+ disconnect_queue_timeout: float = 300.0,
262
+ render_loop_limit: int = 50,
263
+ ) -> None:
264
+ from pulse.channel import ChannelsManager
265
+ from pulse.form import FormRegistry
266
+
267
+ self.id = id
268
+ self.routes = routes
269
+ self.route_mounts = {}
270
+ self._server_address = server_address
271
+ self._client_address = client_address
272
+ self._send_message = None
273
+ self._global_states = {}
274
+ self.query_store = QueryStore()
275
+ self.connected = False
276
+ self.channels = ChannelsManager(self)
277
+ self.forms = FormRegistry(self)
278
+ self._pending_api = {}
279
+ self._pending_js_results = {}
280
+ self.prerender_queue_timeout = prerender_queue_timeout
281
+ self.detach_queue_timeout = detach_queue_timeout
282
+ self.disconnect_queue_timeout = disconnect_queue_timeout
283
+ self.render_loop_limit = render_loop_limit
284
+
285
+ @property
286
+ def server_address(self) -> str:
287
+ if self._server_address is None:
288
+ raise RuntimeError("Server address not set")
289
+ return self._server_address
290
+
291
+ @property
292
+ def client_address(self) -> str:
293
+ if self._client_address is None:
294
+ raise RuntimeError("Client address not set")
295
+ return self._client_address
296
+
297
+ def _on_effect_error(self, effect: Effect, exc: Exception):
298
+ details = {"effect": effect.name or "<unnamed>"}
299
+ for path in list(self.route_mounts.keys()):
300
+ self.report_error(path, "effect", exc, details)
301
+
302
+ # ---- Connection lifecycle ----
303
+
304
+ def connect(self, send_message: Callable[[ServerMessage], Any]):
305
+ """WebSocket connected. Set sender, don't auto-flush (attach does that)."""
306
+ self._send_message = send_message
307
+ self.connected = True
308
+
309
+ def disconnect(self):
310
+ """WebSocket disconnected. Start queuing briefly before pausing."""
311
+ self._send_message = None
312
+ self.connected = False
313
+
314
+ for mount in self.route_mounts.values():
315
+ if mount.state == "active":
316
+ mount.start_pending(self.disconnect_queue_timeout)
317
+
318
+ # ---- Message routing ----
319
+
320
+ def send(self, message: ServerMessage):
321
+ """Route message based on mount state."""
322
+ # Global messages (not path-specific) go directly if connected
323
+ path = message.get("path")
324
+ if path is None:
325
+ if self._send_message:
326
+ self._send_message(message)
327
+ return
328
+
329
+ # Normalize path for lookup
330
+ path = ensure_absolute_path(path)
331
+ mount = self.route_mounts.get(path)
332
+ if not mount:
333
+ # Unknown path - send directly if connected (for js_exec, etc.)
334
+ if self._send_message:
335
+ self._send_message(message)
336
+ return
337
+
338
+ if self._send_message:
339
+ mount.deliver(message, self._send_message)
340
+ return
341
+ if mount.state == "pending":
342
+ mount.deliver(message, lambda _: None)
343
+ # idle: drop (effect should be paused anyway)
344
+
345
+ def report_error(
346
+ self,
347
+ path: str,
348
+ phase: ServerErrorPhase,
349
+ exc: BaseException,
350
+ details: dict[str, Any] | None = None,
351
+ ):
352
+ self.send(
353
+ {
354
+ "type": "server_error",
355
+ "path": path,
356
+ "error": {
357
+ "message": str(exc),
358
+ "stack": traceback.format_exc(),
359
+ "phase": phase,
360
+ "details": details or {},
361
+ },
362
+ }
363
+ )
364
+ logger.error(
365
+ "Error reported for path %r during %s: %s\n%s",
366
+ path,
367
+ phase,
368
+ exc,
369
+ traceback.format_exc(),
370
+ )
371
+
372
+ # ---- State transitions ----
373
+
374
+ # ---- Prerendering ----
375
+
376
+ def prerender(
377
+ self, paths: list[str], route_info: RouteInfo | None = None
378
+ ) -> dict[str, ServerInitMessage | ServerNavigateToMessage]:
379
+ """
380
+ Synchronous render for SSR. Returns per-path init or navigate_to messages.
381
+ - Creates mounts in PENDING state and starts queue
382
+ """
383
+ normalized = [ensure_absolute_path(path) for path in paths]
384
+
385
+ results: dict[str, ServerInitMessage | ServerNavigateToMessage] = {}
386
+
387
+ for path in normalized:
388
+ route = self.routes.find(path)
389
+ info = route_info or route.default_route_info()
390
+ mount = self.route_mounts.get(path)
391
+
392
+ if mount is None:
393
+ mount = RouteMount(self, path, route, info)
394
+ self.route_mounts[path] = mount
395
+ mount.ensure_effect(lazy=True, flush=False)
396
+ else:
397
+ mount.update_route(info)
398
+ if mount.effect is None:
399
+ mount.ensure_effect(lazy=True, flush=False)
400
+
401
+ if mount.state != "active" and mount.queue_timeout is None:
402
+ mount.start_pending(self.prerender_queue_timeout)
403
+ assert mount.effect is not None
404
+ with mount.effect.capture_deps(update_deps=True):
405
+ message = self.render(mount, path)
406
+
407
+ results[path] = message
408
+ if message["type"] == "navigate_to":
409
+ mount.dispose()
410
+ del self.route_mounts[path]
411
+ continue
412
+
413
+ return results
414
+
415
+ # ---- Client lifecycle ----
416
+
417
+ def attach(self, path: str, route_info: RouteInfo):
418
+ """
419
+ Client ready to receive updates for path.
420
+ - PENDING: flush queue, transition to ACTIVE
421
+ - IDLE: request reload
422
+ - ACTIVE: update route_info
423
+ - No mount: request reload
424
+ """
425
+ path = ensure_absolute_path(path)
426
+ mount = self.route_mounts.get(path)
427
+
428
+ if mount is None or mount.state == "idle":
429
+ # Initial render must come from prerender
430
+ self.send({"type": "reload"})
431
+ return
432
+
433
+ # Update route info for active and pending mounts
434
+ mount.update_route(route_info)
435
+ if mount.state == "pending" and self._send_message:
436
+ mount.activate(self._send_message)
437
+
438
+ def update_route(self, path: str, route_info: RouteInfo):
439
+ """Update routing state (query params, etc.) for attached path."""
440
+ path = ensure_absolute_path(path)
441
+ try:
442
+ mount = self.get_route_mount(path)
443
+ mount.update_route(route_info)
444
+ except Exception as e:
445
+ self.report_error(path, "navigate", e)
446
+
447
+ def dispose_mount(self, path: str, mount: RouteMount) -> None:
448
+ current = self.route_mounts.get(path)
449
+ if current is not mount:
450
+ return
451
+ try:
452
+ self.route_mounts.pop(path, None)
453
+ mount.dispose()
454
+ except Exception as e:
455
+ self.report_error(path, "unmount", e)
456
+
457
+ def detach(self, path: str, *, timeout: float | None = None):
458
+ """Client no longer wants updates. Queue briefly, then dispose."""
459
+ path = ensure_absolute_path(path)
460
+ mount = self.route_mounts.get(path)
461
+ if not mount:
462
+ return
463
+
464
+ if timeout is None:
465
+ timeout = self.detach_queue_timeout
466
+ if timeout <= 0:
467
+ self.dispose_mount(path, mount)
468
+ return
469
+ mount.start_pending(timeout, action="dispose")
470
+
471
+ # ---- Effect creation ----
472
+
473
+ def _check_render_loop(self, mount: RouteMount, path: str) -> None:
474
+ batch_id = REACTIVE_CONTEXT.get().batch.flush_id
475
+ if mount.render_batch_id == batch_id:
476
+ mount.render_batch_renders += 1
477
+ else:
478
+ mount.render_batch_id = batch_id
479
+ mount.render_batch_renders = 1
480
+ if mount.render_batch_renders > self.render_loop_limit:
481
+ if mount.effect:
482
+ mount.effect.pause()
483
+ raise RenderLoopError(path, mount.render_batch_renders, batch_id)
484
+
485
+ def _render_with_interrupts(
486
+ self,
487
+ mount: RouteMount,
488
+ path: str,
489
+ *,
490
+ session: Any | None = None,
491
+ render_fn: Callable[[], T_Render],
492
+ ) -> T_Render | ServerNavigateToMessage:
493
+ ctx = PulseContext.get()
494
+ render_session = ctx.session if session is None else session
495
+ with PulseContext.update(
496
+ session=render_session, render=self, route=mount.route
497
+ ):
498
+ try:
499
+ self._check_render_loop(mount, path)
500
+ return render_fn()
501
+ except RedirectInterrupt as r:
502
+ return ServerNavigateToMessage(
503
+ type="navigate_to",
504
+ path=r.path,
505
+ replace=r.replace,
506
+ hard=False,
507
+ )
508
+ except NotFoundInterrupt:
509
+ ctx = PulseContext.get()
510
+ return ServerNavigateToMessage(
511
+ type="navigate_to",
512
+ path=ctx.app.not_found,
513
+ replace=True,
514
+ hard=False,
515
+ )
516
+
517
+ def render(
518
+ self, mount: RouteMount, path: str, *, session: Any | None = None
519
+ ) -> ServerInitMessage | ServerNavigateToMessage:
520
+ def _render() -> ServerInitMessage:
521
+ vdom = mount.tree.render()
522
+ mount.initialized = True
523
+ return ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
524
+
525
+ message = self._render_with_interrupts(
526
+ mount, path, session=session, render_fn=_render
527
+ )
528
+ return message
529
+
530
+ def rerender(
531
+ self, mount: RouteMount, path: str, *, session: Any | None = None
532
+ ) -> ServerUpdateMessage | ServerNavigateToMessage | None:
533
+ def _rerender() -> ServerUpdateMessage | None:
534
+ if not mount.initialized:
535
+ raise RuntimeError(f"rerender called before init for {path!r}")
536
+ ops = mount.tree.rerender()
537
+ if ops:
538
+ return ServerUpdateMessage(type="vdom_update", path=path, ops=ops)
539
+ return None
540
+
541
+ return self._render_with_interrupts(
542
+ mount, path, session=session, render_fn=_rerender
543
+ )
544
+
545
+ # ---- Helpers ----
546
+
547
+ def close(self):
548
+ self.forms.dispose()
549
+ for path in list(self.route_mounts.keys()):
550
+ self.detach(path, timeout=0)
551
+ self.route_mounts.clear()
552
+ for value in self._global_states.values():
553
+ value.dispose()
554
+ self._global_states.clear()
555
+ for channel_id in list(self.channels._channels.keys()): # pyright: ignore[reportPrivateUsage]
556
+ channel = self.channels._channels.get(channel_id) # pyright: ignore[reportPrivateUsage]
557
+ if channel:
558
+ channel.closed = True
559
+ self.channels.dispose_channel(channel, reason="render.close")
560
+ for fut in self._pending_api.values():
561
+ if not fut.done():
562
+ fut.cancel()
563
+ self._pending_api.clear()
564
+ for fut in self._pending_js_results.values():
565
+ if not fut.done():
566
+ fut.cancel()
567
+ self._pending_js_results.clear()
568
+ self._send_message = None
569
+ self.connected = False
570
+
571
+ def get_route_mount(self, path: str) -> RouteMount:
572
+ path = ensure_absolute_path(path)
573
+ mount = self.route_mounts.get(path)
574
+ if not mount:
575
+ raise ValueError(f"No active route for '{path}'")
576
+ return mount
577
+
578
+ def get_global_state(self, key: str, factory: Callable[[], Any]) -> Any:
579
+ """Return a per-session singleton for the provided key."""
580
+ inst = self._global_states.get(key)
581
+ if inst is None:
582
+ inst = factory()
583
+ self._global_states[key] = inst
584
+ return inst
585
+
586
+ def flush(self):
587
+ with PulseContext.update(render=self):
588
+ flush_effects()
589
+
590
+ def execute_callback(self, path: str, key: str, args: list[Any] | tuple[Any, ...]):
591
+ mount = self.route_mounts[path]
592
+ cb = mount.tree.callbacks[key]
593
+
594
+ def report(e: BaseException, is_async: bool = False):
595
+ self.report_error(path, "callback", e, {"callback": key, "async": is_async})
596
+
597
+ try:
598
+ with PulseContext.update(render=self, route=mount.route):
599
+ res = cb.fn(*args[: cb.n_args])
600
+ if iscoroutine(res):
601
+ create_task(
602
+ res, on_done=lambda t: (e := t.exception()) and report(e, True)
603
+ )
604
+ except Exception as e:
605
+ report(e)
606
+
607
+ # ---- API calls ----
608
+
609
+ async def call_api(
610
+ self,
611
+ url_or_path: str,
612
+ *,
613
+ method: str = "POST",
614
+ headers: dict[str, str] | None = None,
615
+ body: Any | None = None,
616
+ credentials: str = "include",
617
+ timeout: float = 30.0,
618
+ ) -> dict[str, Any]:
619
+ """Request the client to perform a fetch and await the result."""
620
+ if url_or_path.startswith("http://") or url_or_path.startswith("https://"):
621
+ url = url_or_path
622
+ else:
623
+ base = self.server_address
624
+ if not base:
625
+ raise RuntimeError(
626
+ "Server address unavailable. Ensure App.run_codegen/asgi_factory set server_address."
627
+ )
628
+ api_path = url_or_path if url_or_path.startswith("/") else "/" + url_or_path
629
+ url = f"{base}{api_path}"
630
+ corr_id = uuid.uuid4().hex
631
+ fut = create_future_on_loop()
632
+ self._pending_api[corr_id] = fut
633
+ headers = headers or {}
634
+ headers["x-pulse-render-id"] = self.id
635
+ self.send(
636
+ ServerApiCallMessage(
637
+ type="api_call",
638
+ id=corr_id,
639
+ url=url,
640
+ method=method,
641
+ headers=headers,
642
+ body=body,
643
+ credentials="include" if credentials == "include" else "omit",
644
+ )
645
+ )
646
+ try:
647
+ result = await asyncio.wait_for(fut, timeout=timeout)
648
+ except asyncio.TimeoutError:
649
+ self._pending_api.pop(corr_id, None)
650
+ raise
651
+ return result
652
+
653
+ def handle_api_result(self, data: dict[str, Any]):
654
+ id_ = data.get("id")
655
+ if id_ is None:
656
+ return
657
+ id_ = str(id_)
658
+ fut = self._pending_api.pop(id_, None)
659
+ if fut and not fut.done():
660
+ fut.set_result(
661
+ {
662
+ "ok": data.get("ok", False),
663
+ "status": data.get("status", 0),
664
+ "headers": data.get("headers", {}),
665
+ "body": data.get("body"),
666
+ }
667
+ )
668
+
669
+ # ---- JS Execution ----
670
+
671
+ @overload
672
+ def run_js(
673
+ self, expr: Any, *, result: Literal[True], timeout: float = ...
674
+ ) -> asyncio.Future[object]: ...
675
+
676
+ @overload
677
+ def run_js(
678
+ self,
679
+ expr: Any,
680
+ *,
681
+ result: Literal[False] = ...,
682
+ timeout: float = ...,
683
+ ) -> None: ...
684
+
685
+ def run_js(
686
+ self, expr: Any, *, result: bool = False, timeout: float = 10.0
687
+ ) -> asyncio.Future[object] | None:
688
+ """Execute JavaScript on the client.
689
+
690
+ Args:
691
+ expr: An Expr from calling a @javascript function.
692
+ result: If True, returns a Future that resolves with the JS return value.
693
+ If False (default), returns None (fire-and-forget).
694
+ timeout: Maximum seconds to wait for result (default 10s, only applies when
695
+ result=True). Future raises asyncio.TimeoutError if exceeded.
696
+
697
+ Returns:
698
+ None if result=False, otherwise a Future resolving to the JS result.
699
+
700
+ Example - Fire and forget:
701
+ @javascript
702
+ def focus_element(selector: str):
703
+ document.querySelector(selector).focus()
704
+
705
+ def on_save():
706
+ save_data()
707
+ run_js(focus_element("#next-input"))
708
+
709
+ Example - Await result:
710
+ @javascript
711
+ def get_scroll_position():
712
+ return {"x": window.scrollX, "y": window.scrollY}
713
+
714
+ async def on_click():
715
+ pos = await run_js(get_scroll_position(), result=True)
716
+ print(pos["x"], pos["y"])
717
+ """
718
+ if not isinstance(expr, Expr):
719
+ raise TypeError(
720
+ f"run_js() requires an Expr (from @javascript function or pulse.js module), got {type(expr).__name__}"
721
+ )
722
+
723
+ ctx = PulseContext.get()
724
+ exec_id = next_id()
725
+
726
+ # Get route pattern path (e.g., "/users/:id") not pathname (e.g., "/users/123")
727
+ # This must match the path used to key views on the client side
728
+ path = ctx.route.pulse_route.unique_path() if ctx.route else "/"
729
+
730
+ self.send(
731
+ ServerJsExecMessage(
732
+ type="js_exec",
733
+ path=path,
734
+ id=exec_id,
735
+ expr=expr.render(),
736
+ )
737
+ )
738
+
739
+ if result:
740
+ loop = asyncio.get_running_loop()
741
+ future: asyncio.Future[object] = loop.create_future()
742
+ self._pending_js_results[exec_id] = future
743
+
744
+ def _on_timeout() -> None:
745
+ self._pending_js_results.pop(exec_id, None)
746
+ if not future.done():
747
+ future.set_exception(asyncio.TimeoutError())
748
+
749
+ loop.call_later(timeout, _on_timeout)
750
+
751
+ return future
752
+
753
+ return None
754
+
755
+ def handle_js_result(self, data: dict[str, Any]) -> None:
756
+ """Handle js_result message from client."""
757
+ exec_id = data.get("id")
758
+ if exec_id is None:
759
+ return
760
+ exec_id = str(exec_id)
761
+ fut = self._pending_js_results.pop(exec_id, None)
762
+ if fut is None or fut.done():
763
+ return
764
+ error = data.get("error")
765
+ if error is not None:
766
+ fut.set_exception(JsExecError(error))
767
+ else:
768
+ fut.set_result(data.get("result"))