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.
- pulse/__init__.py +10 -24
- pulse/app.py +3 -25
- pulse/codegen/codegen.py +43 -88
- pulse/codegen/js.py +35 -5
- pulse/codegen/templates/route.py +341 -254
- pulse/form.py +1 -1
- pulse/helpers.py +40 -8
- pulse/hooks/core.py +2 -2
- pulse/hooks/effects.py +1 -1
- pulse/hooks/init.py +2 -1
- pulse/hooks/setup.py +1 -1
- pulse/hooks/stable.py +2 -2
- pulse/hooks/states.py +2 -2
- pulse/html/props.py +3 -2
- pulse/html/tags.py +135 -0
- pulse/html/tags.pyi +4 -0
- pulse/js/__init__.py +110 -0
- pulse/js/__init__.pyi +95 -0
- pulse/js/_types.py +297 -0
- pulse/js/array.py +253 -0
- pulse/js/console.py +47 -0
- pulse/js/date.py +113 -0
- pulse/js/document.py +138 -0
- pulse/js/error.py +139 -0
- pulse/js/json.py +62 -0
- pulse/js/map.py +84 -0
- pulse/js/math.py +66 -0
- pulse/js/navigator.py +76 -0
- pulse/js/number.py +54 -0
- pulse/js/object.py +173 -0
- pulse/js/promise.py +150 -0
- pulse/js/regexp.py +54 -0
- pulse/js/set.py +109 -0
- pulse/js/string.py +35 -0
- pulse/js/weakmap.py +50 -0
- pulse/js/weakset.py +45 -0
- pulse/js/window.py +199 -0
- pulse/messages.py +22 -3
- pulse/queries/client.py +7 -7
- pulse/queries/effect.py +16 -0
- pulse/queries/infinite_query.py +138 -29
- pulse/queries/mutation.py +1 -15
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +610 -174
- pulse/queries/store.py +11 -14
- pulse/react_component.py +167 -14
- pulse/reactive.py +19 -1
- pulse/reactive_extensions.py +5 -5
- pulse/render_session.py +185 -59
- pulse/renderer.py +80 -158
- pulse/routing.py +1 -18
- pulse/transpiler/__init__.py +131 -0
- pulse/transpiler/builtins.py +731 -0
- pulse/transpiler/constants.py +110 -0
- pulse/transpiler/context.py +26 -0
- pulse/transpiler/errors.py +2 -0
- pulse/transpiler/function.py +250 -0
- pulse/transpiler/ids.py +16 -0
- pulse/transpiler/imports.py +409 -0
- pulse/transpiler/js_module.py +274 -0
- pulse/transpiler/modules/__init__.py +30 -0
- pulse/transpiler/modules/asyncio.py +38 -0
- pulse/transpiler/modules/json.py +20 -0
- pulse/transpiler/modules/math.py +320 -0
- pulse/transpiler/modules/re.py +466 -0
- pulse/transpiler/modules/tags.py +268 -0
- pulse/transpiler/modules/typing.py +59 -0
- pulse/transpiler/nodes.py +1216 -0
- pulse/transpiler/py_module.py +119 -0
- pulse/transpiler/transpiler.py +938 -0
- pulse/transpiler/utils.py +4 -0
- pulse/types/event_handler.py +3 -2
- pulse/vdom.py +212 -13
- {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/METADATA +1 -1
- pulse_framework-0.1.47.dist-info/RECORD +119 -0
- pulse/codegen/imports.py +0 -204
- pulse/css.py +0 -155
- pulse_framework-0.1.44.dist-info/RECORD +0 -79
- {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/WHEEL +0 -0
- {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
|
|
53
|
-
self._pulse_ctx
|
|
54
|
-
self.element
|
|
81
|
+
self.effect = None
|
|
82
|
+
self._pulse_ctx = None
|
|
83
|
+
self.element = route.render()
|
|
55
84
|
self.tree = RenderTree(self.element)
|
|
56
|
-
self.rendered
|
|
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
|
|
115
|
+
self.route_mounts = {}
|
|
80
116
|
# Base server address for building absolute API URLs (e.g., http://localhost:8000)
|
|
81
|
-
self._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
|
|
84
|
-
self._send_message
|
|
85
|
-
|
|
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
|
|
123
|
+
self._global_states = {}
|
|
90
124
|
self.query_store = QueryStore()
|
|
91
125
|
# Connection state
|
|
92
|
-
self.connected
|
|
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
|
-
#
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
396
|
-
#
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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)
|