pulse-framework 0.1.50__py3-none-any.whl → 0.1.52__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 (85) hide show
  1. pulse/__init__.py +542 -562
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +0 -14
  4. pulse/cli/cmd.py +96 -80
  5. pulse/cli/dependencies.py +10 -41
  6. pulse/cli/folder_lock.py +3 -3
  7. pulse/cli/helpers.py +40 -67
  8. pulse/cli/logging.py +102 -0
  9. pulse/cli/packages.py +16 -0
  10. pulse/cli/processes.py +40 -23
  11. pulse/codegen/codegen.py +70 -35
  12. pulse/codegen/js.py +2 -4
  13. pulse/codegen/templates/route.py +94 -146
  14. pulse/component.py +115 -0
  15. pulse/components/for_.py +1 -1
  16. pulse/components/if_.py +1 -1
  17. pulse/components/react_router.py +16 -22
  18. pulse/{html → dom}/events.py +1 -1
  19. pulse/{html → dom}/props.py +6 -6
  20. pulse/{html → dom}/tags.py +11 -11
  21. pulse/dom/tags.pyi +480 -0
  22. pulse/form.py +7 -6
  23. pulse/hooks/init.py +1 -13
  24. pulse/js/__init__.py +37 -41
  25. pulse/js/__init__.pyi +22 -2
  26. pulse/js/_types.py +5 -3
  27. pulse/js/array.py +121 -38
  28. pulse/js/console.py +9 -9
  29. pulse/js/date.py +22 -19
  30. pulse/js/document.py +8 -4
  31. pulse/js/error.py +12 -14
  32. pulse/js/json.py +4 -3
  33. pulse/js/map.py +17 -7
  34. pulse/js/math.py +2 -2
  35. pulse/js/navigator.py +4 -4
  36. pulse/js/number.py +8 -8
  37. pulse/js/object.py +9 -13
  38. pulse/js/promise.py +25 -9
  39. pulse/js/regexp.py +6 -6
  40. pulse/js/set.py +20 -8
  41. pulse/js/string.py +7 -7
  42. pulse/js/weakmap.py +6 -6
  43. pulse/js/weakset.py +6 -6
  44. pulse/js/window.py +17 -14
  45. pulse/messages.py +1 -4
  46. pulse/react_component.py +3 -999
  47. pulse/render_session.py +74 -66
  48. pulse/renderer.py +311 -238
  49. pulse/routing.py +1 -10
  50. pulse/serializer.py +11 -1
  51. pulse/transpiler/__init__.py +84 -114
  52. pulse/transpiler/builtins.py +661 -343
  53. pulse/transpiler/errors.py +78 -2
  54. pulse/transpiler/function.py +463 -133
  55. pulse/transpiler/id.py +18 -0
  56. pulse/transpiler/imports.py +230 -325
  57. pulse/transpiler/js_module.py +218 -209
  58. pulse/transpiler/modules/__init__.py +16 -13
  59. pulse/transpiler/modules/asyncio.py +45 -26
  60. pulse/transpiler/modules/json.py +12 -8
  61. pulse/transpiler/modules/math.py +161 -216
  62. pulse/transpiler/modules/pulse/__init__.py +5 -0
  63. pulse/transpiler/modules/pulse/tags.py +231 -0
  64. pulse/transpiler/modules/typing.py +33 -28
  65. pulse/transpiler/nodes.py +1607 -923
  66. pulse/transpiler/py_module.py +118 -95
  67. pulse/transpiler/react_component.py +51 -0
  68. pulse/transpiler/transpiler.py +593 -437
  69. pulse/transpiler/vdom.py +255 -0
  70. {pulse_framework-0.1.50.dist-info → pulse_framework-0.1.52.dist-info}/METADATA +1 -1
  71. pulse_framework-0.1.52.dist-info/RECORD +120 -0
  72. pulse/html/tags.pyi +0 -470
  73. pulse/transpiler/constants.py +0 -110
  74. pulse/transpiler/context.py +0 -26
  75. pulse/transpiler/ids.py +0 -16
  76. pulse/transpiler/modules/re.py +0 -466
  77. pulse/transpiler/modules/tags.py +0 -268
  78. pulse/transpiler/utils.py +0 -4
  79. pulse/vdom.py +0 -667
  80. pulse_framework-0.1.50.dist-info/RECORD +0 -119
  81. /pulse/{html → dom}/__init__.py +0 -0
  82. /pulse/{html → dom}/elements.py +0 -0
  83. /pulse/{html → dom}/svg.py +0 -0
  84. {pulse_framework-0.1.50.dist-info → pulse_framework-0.1.52.dist-info}/WHEEL +0 -0
  85. {pulse_framework-0.1.50.dist-info → pulse_framework-0.1.52.dist-info}/entry_points.txt +0 -0
pulse/render_session.py CHANGED
@@ -11,7 +11,6 @@ from pulse.helpers import create_future_on_loop, create_task
11
11
  from pulse.hooks.runtime import NotFoundInterrupt, RedirectInterrupt
12
12
  from pulse.messages import (
13
13
  ServerApiCallMessage,
14
- ServerErrorMessage,
15
14
  ServerErrorPhase,
16
15
  ServerInitMessage,
17
16
  ServerJsExecMessage,
@@ -31,10 +30,8 @@ from pulse.routing import (
31
30
  ensure_absolute_path,
32
31
  )
33
32
  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
37
- from pulse.vdom import Element
33
+ from pulse.transpiler.id import next_id
34
+ from pulse.transpiler.nodes import Expr, Node, emit
38
35
 
39
36
  if TYPE_CHECKING:
40
37
  from pulse.channel import ChannelsManager
@@ -49,14 +46,14 @@ class JsExecError(Exception):
49
46
 
50
47
  # Module-level convenience wrapper
51
48
  @overload
52
- def run_js(expr: JSExpr | str, *, result: Literal[True]) -> asyncio.Future[Any]: ...
49
+ def run_js(expr: Expr | str, *, result: Literal[True]) -> asyncio.Future[Any]: ...
53
50
 
54
51
 
55
52
  @overload
56
- def run_js(expr: JSExpr | str, *, result: Literal[False] = ...) -> None: ...
53
+ def run_js(expr: Expr | str, *, result: Literal[False] = ...) -> None: ...
57
54
 
58
55
 
59
- def run_js(expr: JSExpr | str, *, result: bool = False) -> asyncio.Future[Any] | None:
56
+ def run_js(expr: Expr | str, *, result: bool = False) -> asyncio.Future[Any] | None:
60
57
  """Execute JavaScript on the client. Convenience wrapper for RenderSession.run_js()."""
61
58
  ctx = PulseContext.get()
62
59
  if ctx.render is None:
@@ -70,7 +67,7 @@ class RouteMount:
70
67
  tree: RenderTree
71
68
  effect: Effect | None
72
69
  _pulse_ctx: PulseContext | None
73
- element: Element
70
+ element: Node
74
71
  rendered: bool
75
72
 
76
73
  def __init__(
@@ -92,12 +89,13 @@ class RenderSession:
92
89
  forms: "FormRegistry"
93
90
  query_store: QueryStore
94
91
  route_mounts: dict[str, RouteMount]
92
+ connected: bool
95
93
  _server_address: str | None
96
94
  _client_address: str | None
97
95
  _send_message: Callable[[ServerMessage], Any] | None
98
96
  _pending_api: dict[str, asyncio.Future[dict[str, Any]]]
97
+ _pending_js_results: dict[str, asyncio.Future[Any]]
99
98
  _global_states: dict[str, State]
100
- connected: bool
101
99
 
102
100
  def __init__(
103
101
  self,
@@ -118,7 +116,6 @@ class RenderSession:
118
116
  # Best-effort client address, captured at prerender or socket connect time
119
117
  self._client_address = client_address
120
118
  self._send_message = None
121
- self._pending_api = {}
122
119
  # Registry of per-session global singletons (created via ps.global_state without id)
123
120
  self._global_states = {}
124
121
  self.query_store = QueryStore()
@@ -126,8 +123,9 @@ class RenderSession:
126
123
  self.connected = False
127
124
  self.channels = ChannelsManager(self)
128
125
  self.forms = FormRegistry(self)
126
+ self._pending_api = {}
129
127
  # Pending JS execution results (for awaiting run_js().result())
130
- self._pending_js_results: dict[str, asyncio.Future[Any]] = {}
128
+ self._pending_js_results = {}
131
129
 
132
130
  @property
133
131
  def server_address(self) -> str:
@@ -177,17 +175,18 @@ class RenderSession:
177
175
  exc: BaseException,
178
176
  details: dict[str, Any] | None = None,
179
177
  ):
180
- error_msg: ServerErrorMessage = {
181
- "type": "server_error",
182
- "path": path,
183
- "error": {
184
- "message": str(exc),
185
- "stack": traceback.format_exc(),
186
- "phase": phase,
187
- "details": details or {},
188
- },
189
- }
190
- self.send(error_msg)
178
+ self.send(
179
+ {
180
+ "type": "server_error",
181
+ "path": path,
182
+ "error": {
183
+ "message": str(exc),
184
+ "stack": traceback.format_exc(),
185
+ "phase": phase,
186
+ "details": details or {},
187
+ },
188
+ }
189
+ )
191
190
  logger.error(
192
191
  "Error reported for path %r during %s: %s\n%s",
193
192
  path,
@@ -211,6 +210,16 @@ class RenderSession:
211
210
  if channel:
212
211
  channel.closed = True
213
212
  self.channels.dispose_channel(channel, reason="render.close")
213
+ # Cancel pending API calls
214
+ for fut in self._pending_api.values():
215
+ if not fut.done():
216
+ fut.cancel()
217
+ self._pending_api.clear()
218
+ # Cancel pending JS execution results
219
+ for fut in self._pending_js_results.values():
220
+ if not fut.done():
221
+ fut.cancel()
222
+ self._pending_js_results.clear()
214
223
  # The effect will be garbage collected, and with it the dependencies
215
224
  self._send_message = None
216
225
  self.connected = False
@@ -240,12 +249,17 @@ class RenderSession:
240
249
  headers: dict[str, str] | None = None,
241
250
  body: Any | None = None,
242
251
  credentials: str = "include",
252
+ timeout: float = 30.0,
243
253
  ) -> dict[str, Any]:
244
254
  """Request the client to perform a fetch and await the result.
245
255
 
246
256
  Accepts either an absolute URL (http/https) or a relative path. When a
247
257
  relative path is provided, it is resolved against this session's
248
258
  server_address.
259
+
260
+ Args:
261
+ timeout: Maximum seconds to wait for response (default 30s).
262
+ Raises asyncio.TimeoutError if exceeded.
249
263
  """
250
264
  # Resolve to absolute URL if a relative path is passed
251
265
  if url_or_path.startswith("http://") or url_or_path.startswith("https://"):
@@ -274,7 +288,11 @@ class RenderSession:
274
288
  credentials="include" if credentials == "include" else "omit",
275
289
  )
276
290
  )
277
- result = await fut
291
+ try:
292
+ result = await asyncio.wait_for(fut, timeout=timeout)
293
+ except asyncio.TimeoutError:
294
+ self._pending_api.pop(corr_id, None)
295
+ raise
278
296
  return result
279
297
 
280
298
  def handle_api_result(self, data: dict[str, Any]):
@@ -296,21 +314,29 @@ class RenderSession:
296
314
  # ---- JS Execution ----
297
315
  @overload
298
316
  def run_js(
299
- self, expr: JSExpr | str, *, result: Literal[True]
317
+ self, expr: Expr | str, *, result: Literal[True], timeout: float = ...
300
318
  ) -> asyncio.Future[object]: ...
301
319
 
302
320
  @overload
303
- def run_js(self, expr: JSExpr | str, *, result: Literal[False] = ...) -> None: ...
321
+ def run_js(
322
+ self,
323
+ expr: Expr | str,
324
+ *,
325
+ result: Literal[False] = ...,
326
+ timeout: float = ...,
327
+ ) -> None: ...
304
328
 
305
329
  def run_js(
306
- self, expr: JSExpr | str, *, result: bool = False
330
+ self, expr: Expr | str, *, result: bool = False, timeout: float = 10.0
307
331
  ) -> asyncio.Future[object] | None:
308
332
  """Execute JavaScript on the client.
309
333
 
310
334
  Args:
311
- expr: A JSExpr (e.g. from calling a @javascript function) or raw JS string.
335
+ expr: A Expr (e.g. from calling a @javascript function) or raw JS string.
312
336
  result: If True, returns a Future that resolves with the JS return value.
313
337
  If False (default), returns None (fire-and-forget).
338
+ timeout: Maximum seconds to wait for result (default 10s, only applies when
339
+ result=True). Future raises asyncio.TimeoutError if exceeded.
314
340
 
315
341
  Returns:
316
342
  None if result=False, otherwise a Future resolving to the JS result.
@@ -338,16 +364,16 @@ class RenderSession:
338
364
  run_js("console.log('Hello from Python!')")
339
365
  """
340
366
  ctx = PulseContext.get()
341
- exec_id = generate_id()
367
+ exec_id = next_id()
342
368
 
343
369
  if isinstance(expr, str):
344
370
  code = expr
345
371
  else:
346
- with interpreted_mode():
347
- code = expr.emit()
372
+ code = emit(expr)
348
373
 
349
- # Get path from route context, fallback to "/"
350
- path = ctx.route.pathname if ctx.route else "/"
374
+ # Get route pattern path (e.g., "/users/:id") not pathname (e.g., "/users/123")
375
+ # This must match the path used to key views on the client side
376
+ path = ctx.route.pulse_route.unique_path() if ctx.route else "/"
351
377
 
352
378
  self.send(
353
379
  ServerJsExecMessage(
@@ -362,6 +388,15 @@ class RenderSession:
362
388
  loop = asyncio.get_running_loop()
363
389
  future: asyncio.Future[object] = loop.create_future()
364
390
  self._pending_js_results[exec_id] = future
391
+
392
+ # Schedule auto-timeout
393
+ def _on_timeout() -> None:
394
+ self._pending_js_results.pop(exec_id, None)
395
+ if not future.done():
396
+ future.set_exception(asyncio.TimeoutError())
397
+
398
+ loop.call_later(timeout, _on_timeout)
399
+
365
400
  return future
366
401
 
367
402
  return None
@@ -395,7 +430,7 @@ class RenderSession:
395
430
  initial message instead of sending over a socket.
396
431
 
397
432
  Returns a dict:
398
- { "type": "vdom_init", "vdom": VDOM, "callbacks": list[str] } or
433
+ { "type": "vdom_init", "vdom": VDOM } or
399
434
  { "type": "navigate_to", "path": str, "replace": bool }
400
435
  """
401
436
  # If already mounted (e.g., repeated prerender), do nothing special.
@@ -409,15 +444,7 @@ class RenderSession:
409
444
  if normalized_root is not None:
410
445
  mount.element = normalized_root
411
446
  mount.rendered = True
412
- msg = ServerInitMessage(
413
- type="vdom_init",
414
- path=path,
415
- vdom=vdom,
416
- callbacks=sorted(mount.tree.callbacks.keys()),
417
- render_props=sorted(mount.tree.render_props),
418
- jsexpr_paths=sorted(mount.tree.jsexpr_paths),
419
- )
420
- return msg
447
+ return ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
421
448
 
422
449
  captured: ServerInitMessage | ServerNavigateToMessage | None = None
423
450
 
@@ -428,12 +455,7 @@ class RenderSession:
428
455
  return
429
456
  if msg["type"] == "vdom_init" and msg["path"] == path:
430
457
  captured = ServerInitMessage(
431
- type="vdom_init",
432
- path=path,
433
- vdom=msg.get("vdom"),
434
- callbacks=msg.get("callbacks", []),
435
- render_props=msg.get("render_props", []),
436
- jsexpr_paths=msg.get("jsexpr_paths", []),
458
+ type="vdom_init", path=path, vdom=msg.get("vdom")
437
459
  )
438
460
  elif msg["type"] == "navigate_to":
439
461
  captured = ServerNavigateToMessage(
@@ -462,15 +484,7 @@ class RenderSession:
462
484
  if normalized_root is not None:
463
485
  mount.element = normalized_root
464
486
  mount.rendered = True
465
- msg = ServerInitMessage(
466
- type="vdom_init",
467
- path=path,
468
- vdom=vdom,
469
- callbacks=sorted(mount.tree.callbacks.keys()),
470
- render_props=sorted(mount.tree.render_props),
471
- jsexpr_paths=sorted(mount.tree.jsexpr_paths),
472
- )
473
- return msg
487
+ return ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
474
488
 
475
489
  return captured
476
490
 
@@ -540,15 +554,9 @@ class RenderSession:
540
554
  if normalized_root is not None:
541
555
  mount.element = normalized_root
542
556
  mount.rendered = True
543
- msg = ServerInitMessage(
544
- type="vdom_init",
545
- path=path,
546
- vdom=vdom,
547
- callbacks=sorted(mount.tree.callbacks.keys()),
548
- render_props=sorted(mount.tree.render_props),
549
- jsexpr_paths=sorted(mount.tree.jsexpr_paths),
557
+ self.send(
558
+ ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
550
559
  )
551
- self.send(msg)
552
560
  else:
553
561
  ops = mount.tree.diff(mount.element)
554
562
  normalized_root = getattr(mount.tree, "_normalized", None)