pulse-framework 0.1.44__py3-none-any.whl → 0.1.47__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 (80) hide show
  1. pulse/__init__.py +10 -24
  2. pulse/app.py +3 -25
  3. pulse/codegen/codegen.py +43 -88
  4. pulse/codegen/js.py +35 -5
  5. pulse/codegen/templates/route.py +341 -254
  6. pulse/form.py +1 -1
  7. pulse/helpers.py +40 -8
  8. pulse/hooks/core.py +2 -2
  9. pulse/hooks/effects.py +1 -1
  10. pulse/hooks/init.py +2 -1
  11. pulse/hooks/setup.py +1 -1
  12. pulse/hooks/stable.py +2 -2
  13. pulse/hooks/states.py +2 -2
  14. pulse/html/props.py +3 -2
  15. pulse/html/tags.py +135 -0
  16. pulse/html/tags.pyi +4 -0
  17. pulse/js/__init__.py +110 -0
  18. pulse/js/__init__.pyi +95 -0
  19. pulse/js/_types.py +297 -0
  20. pulse/js/array.py +253 -0
  21. pulse/js/console.py +47 -0
  22. pulse/js/date.py +113 -0
  23. pulse/js/document.py +138 -0
  24. pulse/js/error.py +139 -0
  25. pulse/js/json.py +62 -0
  26. pulse/js/map.py +84 -0
  27. pulse/js/math.py +66 -0
  28. pulse/js/navigator.py +76 -0
  29. pulse/js/number.py +54 -0
  30. pulse/js/object.py +173 -0
  31. pulse/js/promise.py +150 -0
  32. pulse/js/regexp.py +54 -0
  33. pulse/js/set.py +109 -0
  34. pulse/js/string.py +35 -0
  35. pulse/js/weakmap.py +50 -0
  36. pulse/js/weakset.py +45 -0
  37. pulse/js/window.py +199 -0
  38. pulse/messages.py +22 -3
  39. pulse/queries/client.py +7 -7
  40. pulse/queries/effect.py +16 -0
  41. pulse/queries/infinite_query.py +138 -29
  42. pulse/queries/mutation.py +1 -15
  43. pulse/queries/protocol.py +136 -0
  44. pulse/queries/query.py +610 -174
  45. pulse/queries/store.py +11 -14
  46. pulse/react_component.py +167 -14
  47. pulse/reactive.py +19 -1
  48. pulse/reactive_extensions.py +5 -5
  49. pulse/render_session.py +185 -59
  50. pulse/renderer.py +80 -158
  51. pulse/routing.py +1 -18
  52. pulse/transpiler/__init__.py +131 -0
  53. pulse/transpiler/builtins.py +731 -0
  54. pulse/transpiler/constants.py +110 -0
  55. pulse/transpiler/context.py +26 -0
  56. pulse/transpiler/errors.py +2 -0
  57. pulse/transpiler/function.py +250 -0
  58. pulse/transpiler/ids.py +16 -0
  59. pulse/transpiler/imports.py +409 -0
  60. pulse/transpiler/js_module.py +274 -0
  61. pulse/transpiler/modules/__init__.py +30 -0
  62. pulse/transpiler/modules/asyncio.py +38 -0
  63. pulse/transpiler/modules/json.py +20 -0
  64. pulse/transpiler/modules/math.py +320 -0
  65. pulse/transpiler/modules/re.py +466 -0
  66. pulse/transpiler/modules/tags.py +268 -0
  67. pulse/transpiler/modules/typing.py +59 -0
  68. pulse/transpiler/nodes.py +1216 -0
  69. pulse/transpiler/py_module.py +119 -0
  70. pulse/transpiler/transpiler.py +938 -0
  71. pulse/transpiler/utils.py +4 -0
  72. pulse/types/event_handler.py +3 -2
  73. pulse/vdom.py +212 -13
  74. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/METADATA +1 -1
  75. pulse_framework-0.1.47.dist-info/RECORD +119 -0
  76. pulse/codegen/imports.py +0 -204
  77. pulse/css.py +0 -155
  78. pulse_framework-0.1.44.dist-info/RECORD +0 -79
  79. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/WHEEL +0 -0
  80. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/entry_points.txt +0 -0
pulse/render_session.py CHANGED
@@ -4,7 +4,7 @@ import traceback
4
4
  import uuid
5
5
  from asyncio import iscoroutine
6
6
  from collections.abc import Callable
7
- from typing import TYPE_CHECKING, Any
7
+ from typing import TYPE_CHECKING, Any, Literal, overload
8
8
 
9
9
  from pulse.context import PulseContext
10
10
  from pulse.helpers import create_future_on_loop, create_task
@@ -14,6 +14,7 @@ from pulse.messages import (
14
14
  ServerErrorMessage,
15
15
  ServerErrorPhase,
16
16
  ServerInitMessage,
17
+ ServerJsExecMessage,
17
18
  ServerMessage,
18
19
  ServerNavigateToMessage,
19
20
  ServerUpdateMessage,
@@ -30,6 +31,9 @@ from pulse.routing import (
30
31
  ensure_absolute_path,
31
32
  )
32
33
  from pulse.state import State
34
+ from pulse.transpiler.context import interpreted_mode
35
+ from pulse.transpiler.ids import generate_id
36
+ from pulse.transpiler.nodes import JSExpr
33
37
  from pulse.vdom import Element
34
38
 
35
39
  if TYPE_CHECKING:
@@ -39,21 +43,46 @@ if TYPE_CHECKING:
39
43
  logger = logging.getLogger(__file__)
40
44
 
41
45
 
46
+ class JsExecError(Exception):
47
+ """Raised when client-side JS execution fails."""
48
+
49
+
50
+ # Module-level convenience wrapper
51
+ @overload
52
+ def run_js(expr: JSExpr | str, *, result: Literal[True]) -> asyncio.Future[Any]: ...
53
+
54
+
55
+ @overload
56
+ def run_js(expr: JSExpr | str, *, result: Literal[False] = ...) -> None: ...
57
+
58
+
59
+ def run_js(expr: JSExpr | str, *, result: bool = False) -> asyncio.Future[Any] | None:
60
+ """Execute JavaScript on the client. Convenience wrapper for RenderSession.run_js()."""
61
+ ctx = PulseContext.get()
62
+ if ctx.render is None:
63
+ raise RuntimeError("run_js() can only be called during callback execution")
64
+ return ctx.render.run_js(expr, result=result)
65
+
66
+
42
67
  class RouteMount:
43
68
  render: "RenderSession"
44
69
  route: RouteContext
45
70
  tree: RenderTree
71
+ effect: Effect | None
72
+ _pulse_ctx: PulseContext | None
73
+ element: Element
74
+ rendered: bool
46
75
 
47
76
  def __init__(
48
77
  self, render: "RenderSession", route: Route | Layout, route_info: RouteInfo
49
78
  ) -> None:
50
79
  self.render = render
51
80
  self.route = RouteContext(route_info, route)
52
- self.effect: Effect | None = None
53
- self._pulse_ctx: PulseContext | None = None
54
- self.element: Element = route.render()
81
+ self.effect = None
82
+ self._pulse_ctx = None
83
+ self.element = route.render()
55
84
  self.tree = RenderTree(self.element)
56
- self.rendered: bool = False
85
+ self.rendered = False
57
86
 
58
87
 
59
88
  class RenderSession:
@@ -62,6 +91,13 @@ class RenderSession:
62
91
  channels: "ChannelsManager"
63
92
  forms: "FormRegistry"
64
93
  query_store: QueryStore
94
+ route_mounts: dict[str, RouteMount]
95
+ _server_address: str | None
96
+ _client_address: str | None
97
+ _send_message: Callable[[ServerMessage], Any] | None
98
+ _pending_api: dict[str, asyncio.Future[dict[str, Any]]]
99
+ _global_states: dict[str, State]
100
+ connected: bool
65
101
 
66
102
  def __init__(
67
103
  self,
@@ -76,22 +112,22 @@ class RenderSession:
76
112
 
77
113
  self.id = id
78
114
  self.routes = routes
79
- self.route_mounts: dict[str, RouteMount] = {}
115
+ self.route_mounts = {}
80
116
  # Base server address for building absolute API URLs (e.g., http://localhost:8000)
81
- self._server_address: str | None = server_address
117
+ self._server_address = server_address
82
118
  # Best-effort client address, captured at prerender or socket connect time
83
- self._client_address: str | None = client_address
84
- self._send_message: Callable[[ServerMessage], Any] | None = None
85
- # Buffer messages emitted before a connection is established
86
- self._message_buffer: list[ServerMessage] = []
87
- self._pending_api: dict[str, asyncio.Future[dict[str, Any]]] = {}
119
+ self._client_address = client_address
120
+ self._send_message = None
121
+ self._pending_api = {}
88
122
  # Registry of per-session global singletons (created via ps.global_state without id)
89
- self._global_states: dict[str, State] = {}
123
+ self._global_states = {}
90
124
  self.query_store = QueryStore()
91
125
  # Connection state
92
- self.connected: bool = False
126
+ self.connected = False
93
127
  self.channels = ChannelsManager(self)
94
128
  self.forms = FormRegistry(self)
129
+ # Pending JS execution results (for awaiting run_js().result())
130
+ self._pending_js_results: dict[str, asyncio.Future[Any]] = {}
95
131
 
96
132
  @property
97
133
  def server_address(self) -> str:
@@ -117,25 +153,28 @@ class RenderSession:
117
153
  def connect(self, send_message: Callable[[ServerMessage], Any]):
118
154
  self._send_message = send_message
119
155
  self.connected = True
120
- # Flush any buffered messages now that we can send
121
- if self._message_buffer:
122
- for msg in self._message_buffer:
123
- self._send_message(msg)
124
- self._message_buffer.clear()
156
+ # Don't flush buffer or resume effects here - mount() handles reconnection
157
+ # by resetting mount.rendered and resuming effects to send fresh vdom_init
158
+
159
+ def disconnect(self):
160
+ """Called when client disconnects - pause render effects."""
161
+ self._send_message = None
162
+ self.connected = False
163
+ for mount in self.route_mounts.values():
164
+ if mount.effect:
165
+ mount.effect.pause()
125
166
 
126
167
  def send(self, message: ServerMessage):
127
168
  # If a sender is available (connected or during prerender capture), send immediately.
128
- # Otherwise, buffer until a connection is established.
169
+ # Otherwise, drop the message - we'll send full VDOM state on reconnection.
129
170
  if self._send_message:
130
171
  self._send_message(message)
131
- else:
132
- self._message_buffer.append(message)
133
172
 
134
173
  def report_error(
135
174
  self,
136
175
  path: str,
137
176
  phase: ServerErrorPhase,
138
- exc: Exception,
177
+ exc: BaseException,
139
178
  details: dict[str, Any] | None = None,
140
179
  ):
141
180
  error_msg: ServerErrorMessage = {
@@ -174,32 +213,24 @@ class RenderSession:
174
213
  self.channels.dispose_channel(channel, reason="render.close")
175
214
  # The effect will be garbage collected, and with it the dependencies
176
215
  self._send_message = None
177
- # Discard any buffered messages on close
178
- self._message_buffer.clear()
179
216
  self.connected = False
180
217
 
181
218
  def execute_callback(self, path: str, key: str, args: list[Any] | tuple[Any, ...]):
182
219
  mount = self.route_mounts[path]
183
- try:
184
- cb = mount.tree.callbacks[key]
185
- fn, n_params = cb.fn, cb.n_args
186
- res = fn(*args[:n_params])
187
- if iscoroutine(res):
188
-
189
- def _on_task_done(t: asyncio.Task[Any]):
190
- try:
191
- t.result()
192
- except Exception as e:
193
- self.report_error(
194
- path,
195
- "callback",
196
- e,
197
- {"callback": key, "async": True},
198
- )
220
+ cb = mount.tree.callbacks[key]
221
+
222
+ def report(e: BaseException, is_async: bool = False):
223
+ self.report_error(path, "callback", e, {"callback": key, "async": is_async})
199
224
 
200
- create_task(res, on_done=_on_task_done)
225
+ try:
226
+ with PulseContext.update(render=self, route=mount.route):
227
+ res = cb.fn(*args[: cb.n_args])
228
+ if iscoroutine(res):
229
+ create_task(
230
+ res, on_done=lambda t: (e := t.exception()) and report(e, True)
231
+ )
201
232
  except Exception as e:
202
- self.report_error(path, "callback", e, {"callback": key})
233
+ report(e)
203
234
 
204
235
  async def call_api(
205
236
  self,
@@ -262,6 +293,94 @@ class RenderSession:
262
293
  }
263
294
  )
264
295
 
296
+ # ---- JS Execution ----
297
+ @overload
298
+ def run_js(
299
+ self, expr: JSExpr | str, *, result: Literal[True]
300
+ ) -> asyncio.Future[object]: ...
301
+
302
+ @overload
303
+ def run_js(self, expr: JSExpr | str, *, result: Literal[False] = ...) -> None: ...
304
+
305
+ def run_js(
306
+ self, expr: JSExpr | str, *, result: bool = False
307
+ ) -> asyncio.Future[object] | None:
308
+ """Execute JavaScript on the client.
309
+
310
+ Args:
311
+ expr: A JSExpr (e.g. from calling a @javascript function) or raw JS string.
312
+ result: If True, returns a Future that resolves with the JS return value.
313
+ If False (default), returns None (fire-and-forget).
314
+
315
+ Returns:
316
+ None if result=False, otherwise a Future resolving to the JS result.
317
+
318
+ Example - Fire and forget:
319
+ @javascript
320
+ def focus_element(selector: str):
321
+ document.querySelector(selector).focus()
322
+
323
+ def on_save():
324
+ save_data()
325
+ run_js(focus_element("#next-input"))
326
+
327
+ Example - Await result:
328
+ @javascript
329
+ def get_scroll_position():
330
+ return {"x": window.scrollX, "y": window.scrollY}
331
+
332
+ async def on_click():
333
+ pos = await run_js(get_scroll_position(), result=True)
334
+ print(pos["x"], pos["y"])
335
+
336
+ Example - Raw JS string:
337
+ def on_click():
338
+ run_js("console.log('Hello from Python!')")
339
+ """
340
+ ctx = PulseContext.get()
341
+ exec_id = generate_id()
342
+
343
+ if isinstance(expr, str):
344
+ code = expr
345
+ else:
346
+ with interpreted_mode():
347
+ code = expr.emit()
348
+
349
+ # Get path from route context, fallback to "/"
350
+ path = ctx.route.pathname if ctx.route else "/"
351
+
352
+ self.send(
353
+ ServerJsExecMessage(
354
+ type="js_exec",
355
+ path=path,
356
+ id=exec_id,
357
+ code=code,
358
+ )
359
+ )
360
+
361
+ if result:
362
+ loop = asyncio.get_running_loop()
363
+ future: asyncio.Future[object] = loop.create_future()
364
+ self._pending_js_results[exec_id] = future
365
+ return future
366
+
367
+ return None
368
+
369
+ def handle_js_result(self, data: dict[str, Any]) -> None:
370
+ """Handle js_result message from client."""
371
+ exec_id = data.get("id")
372
+ if exec_id is None:
373
+ return
374
+ exec_id = str(exec_id)
375
+ fut = self._pending_js_results.pop(exec_id, None)
376
+ if fut is None or fut.done():
377
+ return
378
+ error = data.get("error")
379
+ if error is not None:
380
+ fut.set_exception(JsExecError(error))
381
+ else:
382
+ fut.set_result(data.get("result"))
383
+
265
384
  def create_route_mount(self, path: str, route_info: RouteInfo | None = None):
266
385
  route = self.routes.find(path)
267
386
  mount = RouteMount(self, route, route_info or route.default_route_info())
@@ -290,14 +409,15 @@ class RenderSession:
290
409
  if normalized_root is not None:
291
410
  mount.element = normalized_root
292
411
  mount.rendered = True
293
- return ServerInitMessage(
412
+ msg = ServerInitMessage(
294
413
  type="vdom_init",
295
414
  path=path,
296
415
  vdom=vdom,
297
416
  callbacks=sorted(mount.tree.callbacks.keys()),
298
417
  render_props=sorted(mount.tree.render_props),
299
- css_refs=sorted(mount.tree.css_refs),
418
+ jsexpr_paths=sorted(mount.tree.jsexpr_paths),
300
419
  )
420
+ return msg
301
421
 
302
422
  captured: ServerInitMessage | ServerNavigateToMessage | None = None
303
423
 
@@ -313,7 +433,7 @@ class RenderSession:
313
433
  vdom=msg.get("vdom"),
314
434
  callbacks=msg.get("callbacks", []),
315
435
  render_props=msg.get("render_props", []),
316
- css_refs=msg.get("css_refs", []),
436
+ jsexpr_paths=msg.get("jsexpr_paths", []),
317
437
  )
318
438
  elif msg["type"] == "navigate_to":
319
439
  captured = ServerNavigateToMessage(
@@ -341,14 +461,15 @@ class RenderSession:
341
461
  if normalized_root is not None:
342
462
  mount.element = normalized_root
343
463
  mount.rendered = True
344
- return ServerInitMessage(
464
+ msg = ServerInitMessage(
345
465
  type="vdom_init",
346
466
  path=path,
347
467
  vdom=vdom,
348
468
  callbacks=sorted(mount.tree.callbacks.keys()),
349
469
  render_props=sorted(mount.tree.render_props),
350
- css_refs=sorted(mount.tree.css_refs),
470
+ jsexpr_paths=sorted(mount.tree.jsexpr_paths),
351
471
  )
472
+ return msg
352
473
 
353
474
  return captured
354
475
 
@@ -392,8 +513,14 @@ class RenderSession:
392
513
 
393
514
  def mount(self, path: str, route_info: RouteInfo):
394
515
  if path in self.route_mounts:
395
- # No logging, this is bound to happen with React strict mode
396
- # logger.error(f"Route already mounted: '{path}'")
516
+ # Route already mounted - this is a reconnection case.
517
+ # Reset rendered flag so effect sends vdom_init, update route info,
518
+ # and resume the paused effect.
519
+ mount = self.route_mounts[path]
520
+ mount.rendered = False
521
+ mount.route.update(route_info)
522
+ if mount.effect and mount.effect.paused:
523
+ mount.effect.resume()
397
524
  return
398
525
 
399
526
  mount = self.create_route_mount(path, route_info)
@@ -412,16 +539,15 @@ class RenderSession:
412
539
  if normalized_root is not None:
413
540
  mount.element = normalized_root
414
541
  mount.rendered = True
415
- self.send(
416
- ServerInitMessage(
417
- type="vdom_init",
418
- path=path,
419
- vdom=vdom,
420
- callbacks=sorted(mount.tree.callbacks.keys()),
421
- render_props=sorted(mount.tree.render_props),
422
- css_refs=sorted(mount.tree.css_refs),
423
- )
542
+ msg = ServerInitMessage(
543
+ type="vdom_init",
544
+ path=path,
545
+ vdom=vdom,
546
+ callbacks=sorted(mount.tree.callbacks.keys()),
547
+ render_props=sorted(mount.tree.render_props),
548
+ jsexpr_paths=sorted(mount.tree.jsexpr_paths),
424
549
  )
550
+ self.send(msg)
425
551
  else:
426
552
  ops = mount.tree.diff(mount.element)
427
553
  normalized_root = getattr(mount.tree, "_normalized", None)