pulse-framework 0.1.53__py3-none-any.whl → 0.1.55__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 (41) hide show
  1. pulse/__init__.py +3 -3
  2. pulse/app.py +34 -20
  3. pulse/code_analysis.py +38 -0
  4. pulse/codegen/codegen.py +18 -50
  5. pulse/codegen/templates/route.py +100 -56
  6. pulse/component.py +24 -6
  7. pulse/components/for_.py +17 -2
  8. pulse/cookies.py +38 -2
  9. pulse/env.py +4 -4
  10. pulse/hooks/init.py +174 -14
  11. pulse/hooks/state.py +105 -0
  12. pulse/js/__init__.py +12 -9
  13. pulse/js/obj.py +79 -0
  14. pulse/js/pulse.py +112 -0
  15. pulse/js/react.py +457 -0
  16. pulse/messages.py +13 -13
  17. pulse/proxy.py +18 -5
  18. pulse/render_session.py +282 -266
  19. pulse/renderer.py +36 -73
  20. pulse/serializer.py +5 -2
  21. pulse/transpiler/__init__.py +13 -0
  22. pulse/transpiler/assets.py +66 -0
  23. pulse/transpiler/builtins.py +0 -20
  24. pulse/transpiler/dynamic_import.py +131 -0
  25. pulse/transpiler/emit_context.py +49 -0
  26. pulse/transpiler/errors.py +29 -11
  27. pulse/transpiler/function.py +36 -5
  28. pulse/transpiler/imports.py +33 -27
  29. pulse/transpiler/js_module.py +73 -20
  30. pulse/transpiler/modules/pulse/tags.py +35 -15
  31. pulse/transpiler/nodes.py +121 -36
  32. pulse/transpiler/py_module.py +1 -1
  33. pulse/transpiler/react_component.py +4 -11
  34. pulse/transpiler/transpiler.py +32 -26
  35. pulse/user_session.py +10 -0
  36. pulse_framework-0.1.55.dist-info/METADATA +196 -0
  37. {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/RECORD +39 -32
  38. pulse/hooks/states.py +0 -285
  39. pulse_framework-0.1.53.dist-info/METADATA +0 -18
  40. {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/WHEEL +0 -0
  41. {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/entry_points.txt +0 -0
pulse/cookies.py CHANGED
@@ -5,6 +5,7 @@ from urllib.parse import urlparse
5
5
 
6
6
  from fastapi import Request, Response
7
7
 
8
+ from pulse.env import PulseEnv
8
9
  from pulse.hooks.runtime import set_cookie
9
10
 
10
11
  if TYPE_CHECKING:
@@ -16,7 +17,7 @@ class Cookie:
16
17
  name: str
17
18
  _: KW_ONLY
18
19
  domain: str | None = None
19
- secure: bool = True
20
+ secure: bool | None = None
20
21
  samesite: Literal["lax", "strict", "none"] = "lax"
21
22
  max_age_seconds: int = 7 * 24 * 3600
22
23
 
@@ -33,6 +34,10 @@ class Cookie:
33
34
  return cookies.get(self.name)
34
35
 
35
36
  async def set_through_api(self, value: str):
37
+ if self.secure is None:
38
+ raise RuntimeError(
39
+ "Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
40
+ )
36
41
  await set_cookie(
37
42
  name=self.name,
38
43
  value=value,
@@ -44,6 +49,10 @@ class Cookie:
44
49
 
45
50
  def set_on_fastapi(self, response: Response, value: str) -> None:
46
51
  """Set the session cookie on a FastAPI Response-like object."""
52
+ if self.secure is None:
53
+ raise RuntimeError(
54
+ "Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
55
+ )
47
56
  response.set_cookie(
48
57
  key=self.name,
49
58
  value=value,
@@ -62,6 +71,10 @@ class SetCookie(Cookie):
62
71
 
63
72
  @classmethod
64
73
  def from_cookie(cls, cookie: Cookie, value: str) -> "SetCookie":
74
+ if cookie.secure is None:
75
+ raise RuntimeError(
76
+ "Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
77
+ )
65
78
  return cls(
66
79
  name=cookie.name,
67
80
  value=value,
@@ -81,7 +94,7 @@ def session_cookie(
81
94
  return Cookie(
82
95
  name,
83
96
  domain=None,
84
- secure=False,
97
+ secure=None,
85
98
  samesite="lax",
86
99
  max_age_seconds=max_age_seconds,
87
100
  )
@@ -146,6 +159,29 @@ def compute_cookie_domain(mode: "PulseMode", server_address: str) -> str | None:
146
159
  return None
147
160
 
148
161
 
162
+ def compute_cookie_secure(env: PulseEnv, server_address: str | None) -> bool:
163
+ scheme = urlparse(server_address or "").scheme.lower()
164
+ if scheme in ("https", "wss"):
165
+ secure = True
166
+ elif scheme in ("http", "ws"):
167
+ secure = False
168
+ else:
169
+ secure = None
170
+ if secure is None:
171
+ if env in ("prod", "ci"):
172
+ raise RuntimeError(
173
+ "Could not determine cookie security from server_address. "
174
+ + "Use an explicit https:// server_address or set Cookie(secure=True/False)."
175
+ )
176
+ return False
177
+ if env in ("prod", "ci") and not secure:
178
+ raise RuntimeError(
179
+ "Refusing to use insecure cookies in prod/ci. "
180
+ + "Use an https server_address or set Cookie(secure=True) explicitly."
181
+ )
182
+ return secure
183
+
184
+
149
185
  def cors_options(mode: "PulseMode", server_address: str) -> CORSOptions:
150
186
  host = _parse_host(server_address) or "localhost"
151
187
  opts: CORSOptions = {
pulse/env.py CHANGED
@@ -4,7 +4,7 @@ Centralized environment variable definitions and typed accessors for Pulse.
4
4
  Preferred usage:
5
5
 
6
6
  from pulse.env import env
7
- env.pulse_mode = "prod"
7
+ env.pulse_env = "prod"
8
8
  if env.running_cli:
9
9
  ...
10
10
 
@@ -20,7 +20,7 @@ from typing import Literal
20
20
  PulseEnv = Literal["dev", "ci", "prod"]
21
21
 
22
22
  # Keys
23
- ENV_PULSE_MODE = "PULSE_MODE"
23
+ ENV_PULSE_ENV = "PULSE_ENV"
24
24
  ENV_PULSE_APP_FILE = "PULSE_APP_FILE"
25
25
  ENV_PULSE_APP_DIR = "PULSE_APP_DIR"
26
26
  ENV_PULSE_HOST = "PULSE_HOST"
@@ -42,14 +42,14 @@ class EnvVars:
42
42
 
43
43
  @property
44
44
  def pulse_env(self) -> PulseEnv:
45
- value = (self._get(ENV_PULSE_MODE) or "dev").lower()
45
+ value = (self._get(ENV_PULSE_ENV) or "dev").lower()
46
46
  if value not in ("dev", "ci", "prod"):
47
47
  value = "dev"
48
48
  return value
49
49
 
50
50
  @pulse_env.setter
51
51
  def pulse_env(self, value: PulseEnv) -> None:
52
- self._set(ENV_PULSE_MODE, value)
52
+ self._set(ENV_PULSE_ENV, value)
53
53
 
54
54
  # App file/dir
55
55
  @property
pulse/hooks/init.py CHANGED
@@ -11,6 +11,7 @@ from typing import Any, Literal, cast, override
11
11
 
12
12
  from pulse.helpers import getsourcecode
13
13
  from pulse.hooks.core import HookState, hooks
14
+ from pulse.transpiler.errors import TranspileError
14
15
 
15
16
  # Storage keyed by (code object, lineno) of the `with ps.init()` call site.
16
17
  _init_hook = hooks.create("init_storage", lambda: InitState())
@@ -312,6 +313,10 @@ def rewrite_init_blocks(func: Callable[..., Any]) -> Callable[..., Any]:
312
313
  """Rewrite `with ps.init()` blocks in the provided function, if present."""
313
314
 
314
315
  source = textwrap.dedent(getsourcecode(func)) # raises immediately if missing
316
+ try:
317
+ source_start_line = inspect.getsourcelines(func)[1]
318
+ except (OSError, TypeError):
319
+ source_start_line = None
315
320
 
316
321
  if "init" not in source: # quick prefilter, allow alias detection later
317
322
  return func
@@ -320,6 +325,7 @@ def rewrite_init_blocks(func: Callable[..., Any]) -> Callable[..., Any]:
320
325
 
321
326
  init_names, init_modules = _resolve_init_bindings(func)
322
327
 
328
+ target_def: ast.FunctionDef | ast.AsyncFunctionDef | None = None
323
329
  # Remove decorators so the re-exec'd function isn't double-wrapped.
324
330
  for node in ast.walk(tree):
325
331
  if (
@@ -327,14 +333,59 @@ def rewrite_init_blocks(func: Callable[..., Any]) -> Callable[..., Any]:
327
333
  and node.name == func.__name__
328
334
  ):
329
335
  node.decorator_list = []
336
+ target_def = node
337
+
338
+ if target_def is None:
339
+ return func
330
340
 
331
341
  if not _contains_ps_init(tree, init_names, init_modules):
332
342
  return func
333
343
 
334
- if _has_disallowed_control_flow(tree, init_names, init_modules):
335
- raise RuntimeError(
336
- "ps.init blocks cannot contain control flow (if/for/while/try/with/match)"
337
- )
344
+ init_items = _find_init_items(target_def.body, init_names, init_modules)
345
+ if len(init_items) > 1:
346
+ try:
347
+ filename = inspect.getsourcefile(func) or inspect.getfile(func)
348
+ except (TypeError, OSError):
349
+ filename = None
350
+ raise TranspileError(
351
+ "ps.init may only be used once per component render",
352
+ node=init_items[1].context_expr,
353
+ source=source,
354
+ filename=filename,
355
+ func_name=func.__name__,
356
+ source_start_line=source_start_line,
357
+ ) from None
358
+
359
+ if init_items and init_items[0].optional_vars is not None:
360
+ try:
361
+ filename = inspect.getsourcefile(func) or inspect.getfile(func)
362
+ except (TypeError, OSError):
363
+ filename = None
364
+ raise TranspileError(
365
+ "ps.init does not support 'as' bindings",
366
+ node=init_items[0].optional_vars,
367
+ source=source,
368
+ filename=filename,
369
+ func_name=func.__name__,
370
+ source_start_line=source_start_line,
371
+ ) from None
372
+
373
+ disallowed = _find_disallowed_control_flow(
374
+ target_def.body, init_names, init_modules
375
+ )
376
+ if disallowed is not None:
377
+ try:
378
+ filename = inspect.getsourcefile(func) or inspect.getfile(func)
379
+ except (TypeError, OSError):
380
+ filename = None
381
+ raise TranspileError(
382
+ "ps.init blocks cannot contain control flow (if/for/while/try/with/match)",
383
+ node=disallowed,
384
+ source=source,
385
+ filename=filename,
386
+ func_name=func.__name__,
387
+ source_start_line=source_start_line,
388
+ ) from None
338
389
 
339
390
  rewriter: ast.NodeTransformer
340
391
  if _CAN_USE_CPYTHON:
@@ -373,19 +424,128 @@ def _contains_ps_init(
373
424
  return checker.contains_init(tree)
374
425
 
375
426
 
376
- def _has_disallowed_control_flow(
377
- tree: ast.AST, init_names: set[str], init_modules: set[str]
378
- ) -> bool:
379
- disallowed = (ast.If, ast.For, ast.While, ast.Try, ast.With, ast.Match)
427
+ def _find_disallowed_control_flow(
428
+ body: Sequence[ast.stmt], init_names: set[str], init_modules: set[str]
429
+ ) -> ast.stmt | None:
430
+ disallowed: tuple[type[ast.AST], ...] = (
431
+ ast.If,
432
+ ast.For,
433
+ ast.AsyncFor,
434
+ ast.While,
435
+ ast.Try,
436
+ ast.With,
437
+ ast.AsyncWith,
438
+ ast.Match,
439
+ )
380
440
  checker = _InitCallChecker(init_names, init_modules)
381
- for node in ast.walk(tree):
382
- if isinstance(node, ast.With):
441
+
442
+ class _Finder(ast.NodeVisitor):
443
+ found: ast.stmt | None
444
+
445
+ def __init__(self) -> None:
446
+ self.found = None
447
+
448
+ @override
449
+ def visit(self, node: ast.AST) -> Any: # type: ignore[override]
450
+ if self.found is not None:
451
+ return None
452
+ if isinstance(node, disallowed):
453
+ self.found = cast(ast.stmt, node)
454
+ return None
455
+ return super().visit(node)
456
+
457
+ @override
458
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
459
+ return None
460
+
461
+ @override
462
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
463
+ return None
464
+
465
+ @override
466
+ def visit_ClassDef(self, node: ast.ClassDef) -> Any:
467
+ return None
468
+
469
+ finder = _Finder()
470
+
471
+ class _WithFinder(ast.NodeVisitor):
472
+ @override
473
+ def visit_With(self, node: ast.With) -> Any: # type: ignore[override]
383
474
  first = node.items[0] if node.items else None
384
475
  if first and checker.is_init_call(first.context_expr):
385
- continue
386
- if isinstance(node, disallowed):
387
- return True
388
- return False
476
+ for stmt in node.body:
477
+ finder.visit(stmt)
478
+ if finder.found is not None:
479
+ return None
480
+ self.generic_visit(node)
481
+
482
+ @override
483
+ def visit_AsyncWith(self, node: ast.AsyncWith) -> Any: # type: ignore[override]
484
+ first = node.items[0] if node.items else None
485
+ if first and checker.is_init_call(first.context_expr):
486
+ for stmt in node.body:
487
+ finder.visit(stmt)
488
+ if finder.found is not None:
489
+ return None
490
+ self.generic_visit(node)
491
+
492
+ @override
493
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
494
+ return None
495
+
496
+ @override
497
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
498
+ return None
499
+
500
+ @override
501
+ def visit_ClassDef(self, node: ast.ClassDef) -> Any:
502
+ return None
503
+
504
+ with_finder = _WithFinder()
505
+ for stmt in body:
506
+ with_finder.visit(stmt)
507
+ if finder.found is not None:
508
+ return finder.found
509
+ return None
510
+
511
+
512
+ def _find_init_items(
513
+ body: Sequence[ast.stmt], init_names: set[str], init_modules: set[str]
514
+ ) -> list[ast.withitem]:
515
+ checker = _InitCallChecker(init_names, init_modules)
516
+ items: list[ast.withitem] = []
517
+
518
+ class _Finder(ast.NodeVisitor):
519
+ @override
520
+ def visit_With(self, node: ast.With) -> Any: # type: ignore[override]
521
+ first = node.items[0] if node.items else None
522
+ if first and checker.is_init_call(first.context_expr):
523
+ items.append(first)
524
+ self.generic_visit(node)
525
+
526
+ @override
527
+ def visit_AsyncWith(self, node: ast.AsyncWith) -> Any: # type: ignore[override]
528
+ first = node.items[0] if node.items else None
529
+ if first and checker.is_init_call(first.context_expr):
530
+ items.append(first)
531
+ self.generic_visit(node)
532
+
533
+ @override
534
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
535
+ return None
536
+
537
+ @override
538
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
539
+ return None
540
+
541
+ @override
542
+ def visit_ClassDef(self, node: ast.ClassDef) -> Any:
543
+ return None
544
+
545
+ finder = _Finder()
546
+ for stmt in body:
547
+ finder.visit(stmt)
548
+ return items
389
549
 
390
550
 
391
551
  class _InitCallChecker:
pulse/hooks/state.py ADDED
@@ -0,0 +1,105 @@
1
+ from collections.abc import Callable
2
+ from typing import TypeVar, override
3
+
4
+ from pulse.hooks.core import HookMetadata, HookState, hooks
5
+ from pulse.state import State
6
+
7
+ S = TypeVar("S", bound=State)
8
+
9
+
10
+ class StateHookState(HookState):
11
+ __slots__ = ("instances", "called_keys") # pyright: ignore[reportUnannotatedClassAttribute]
12
+ instances: dict[str, State]
13
+ called_keys: set[str]
14
+
15
+ def __init__(self) -> None:
16
+ super().__init__()
17
+ self.instances = {}
18
+ self.called_keys = set()
19
+
20
+ @override
21
+ def on_render_start(self, render_cycle: int) -> None:
22
+ super().on_render_start(render_cycle)
23
+ self.called_keys.clear()
24
+
25
+ def get_or_create_state(self, key: str, arg: State | Callable[[], State]) -> State:
26
+ if key in self.called_keys:
27
+ raise RuntimeError(
28
+ f"`pulse.state` can only be called once per component render with key='{key}'"
29
+ )
30
+ self.called_keys.add(key)
31
+
32
+ existing = self.instances.get(key)
33
+ if existing is not None:
34
+ # Dispose any State instances passed directly as args that aren't being used
35
+ if isinstance(arg, State) and arg is not existing:
36
+ try:
37
+ if not arg.__disposed__:
38
+ arg.dispose()
39
+ except RuntimeError:
40
+ # Already disposed, ignore
41
+ pass
42
+ return existing
43
+
44
+ # Create new state
45
+ instance = _instantiate_state(arg)
46
+ self.instances[key] = instance
47
+ return instance
48
+
49
+ @override
50
+ def dispose(self) -> None:
51
+ for instance in self.instances.values():
52
+ try:
53
+ if not instance.__disposed__:
54
+ instance.dispose()
55
+ except RuntimeError:
56
+ # Already disposed, ignore
57
+ pass
58
+ self.instances.clear()
59
+
60
+
61
+ def _instantiate_state(arg: State | Callable[[], State]) -> State:
62
+ instance = arg() if callable(arg) else arg
63
+ if not isinstance(instance, State):
64
+ raise TypeError(
65
+ "`pulse.state` expects a State instance or a callable returning a State instance"
66
+ )
67
+ return instance
68
+
69
+
70
+ def _state_factory():
71
+ return StateHookState()
72
+
73
+
74
+ _state_hook = hooks.create(
75
+ "pulse:core.state",
76
+ _state_factory,
77
+ metadata=HookMetadata(
78
+ owner="pulse.core",
79
+ description="Internal storage for pulse.state hook",
80
+ ),
81
+ )
82
+
83
+
84
+ def state(key: str, arg: S | Callable[[], S]) -> S:
85
+ """Get or create a state instance associated with the given key.
86
+
87
+ Args:
88
+ key: A unique string key identifying this state within the component.
89
+ arg: A State instance or a callable that returns a State instance.
90
+
91
+ Returns:
92
+ The state instance (same instance on subsequent renders with the same key).
93
+
94
+ Raises:
95
+ ValueError: If key is empty.
96
+ RuntimeError: If called more than once per render with the same key.
97
+ TypeError: If arg is not a State or callable returning a State.
98
+ """
99
+ if not key:
100
+ raise ValueError("state() requires a non-empty string key")
101
+ hook_state = _state_hook()
102
+ return hook_state.get_or_create_state(key, arg) # pyright: ignore[reportReturnType]
103
+
104
+
105
+ __all__ = ["state", "StateHookState"]
pulse/js/__init__.py CHANGED
@@ -30,21 +30,22 @@ import importlib as _importlib
30
30
  from typing import Any as _Any
31
31
  from typing import NoReturn as _NoReturn
32
32
 
33
- from pulse.transpiler.builtins import obj as obj
33
+ from pulse.js.obj import obj as obj
34
+ from pulse.transpiler.nodes import EXPR_REGISTRY as _EXPR_REGISTRY
34
35
  from pulse.transpiler.nodes import UNDEFINED as _UNDEFINED
35
- from pulse.transpiler.nodes import Identifier as _Identifier
36
36
 
37
- # Namespace modules that resolve to Identifier
38
- _MODULE_EXPORTS_IDENTIFIER: dict[str, str] = {
37
+ # Namespace modules - return JsModule from registry (handles both builtins and external)
38
+ _MODULE_EXPORTS_NAMESPACE: dict[str, str] = {
39
39
  "JSON": "pulse.js.json",
40
40
  "Math": "pulse.js.math",
41
+ "React": "pulse.js.react",
41
42
  "console": "pulse.js.console",
42
43
  "window": "pulse.js.window",
43
44
  "document": "pulse.js.document",
44
45
  "navigator": "pulse.js.navigator",
45
46
  }
46
47
 
47
- # Regular modules that resolve via getattr
48
+ # Class modules - return via getattr to get Class wrapper (emits `new ...`)
48
49
  _MODULE_EXPORTS_ATTRIBUTE: dict[str, str] = {
49
50
  "Array": "pulse.js.array",
50
51
  "Date": "pulse.js.date",
@@ -52,6 +53,7 @@ _MODULE_EXPORTS_ATTRIBUTE: dict[str, str] = {
52
53
  "Map": "pulse.js.map",
53
54
  "Object": "pulse.js.object",
54
55
  "Promise": "pulse.js.promise",
56
+ "React": "pulse.js.react",
55
57
  "RegExp": "pulse.js.regexp",
56
58
  "Set": "pulse.js.set",
57
59
  "String": "pulse.js.string",
@@ -92,10 +94,11 @@ def __getattr__(name: str) -> _Any:
92
94
  if name in _export_cache:
93
95
  return _export_cache[name]
94
96
 
95
- # Check which dict contains the name
96
- if name in _MODULE_EXPORTS_IDENTIFIER:
97
- module = _importlib.import_module(_MODULE_EXPORTS_IDENTIFIER[name])
98
- export = _Identifier(name)
97
+ # Namespace modules: return JsModule (handles attribute access via transpile_getattr)
98
+ if name in _MODULE_EXPORTS_NAMESPACE:
99
+ module = _importlib.import_module(_MODULE_EXPORTS_NAMESPACE[name])
100
+ export = _EXPR_REGISTRY[id(module)]
101
+ # Class modules: return Class wrapper via getattr (emits `new ...()`)
99
102
  elif name in _MODULE_EXPORTS_ATTRIBUTE:
100
103
  module = _importlib.import_module(_MODULE_EXPORTS_ATTRIBUTE[name])
101
104
  export = getattr(module, name)
pulse/js/obj.py ADDED
@@ -0,0 +1,79 @@
1
+ """
2
+ JavaScript object literal creation.
3
+
4
+ Usage:
5
+ from pulse.js import obj
6
+
7
+ # Create plain JS objects (not Maps):
8
+ obj(a=1, b=2) # -> { a: 1, b: 2 }
9
+
10
+ # With spread syntax:
11
+ obj(**base, c=3) # -> { ...base, c: 3 }
12
+ obj(a=1, **base) # -> { a: 1, ...base }
13
+
14
+ # Empty object:
15
+ obj() # -> {}
16
+
17
+ Unlike dict() which transpiles to new Map(), obj() creates plain JavaScript
18
+ object literals. Use this for React props, style objects, and anywhere you
19
+ need a plain JS object.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import ast
25
+ from dataclasses import dataclass
26
+ from typing import TYPE_CHECKING, override
27
+
28
+ from pulse.transpiler.errors import TranspileError
29
+ from pulse.transpiler.nodes import Expr, Object, Spread, spread_dict
30
+ from pulse.transpiler.vdom import VDOMNode
31
+
32
+ # TYPE_CHECKING avoids import cycle: Transpiler -> nodes -> Expr -> obj -> Transpiler
33
+ if TYPE_CHECKING:
34
+ from pulse.transpiler.transpiler import Transpiler
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class ObjTransformer(Expr):
39
+ """Transformer for obj() with **spread support.
40
+
41
+ obj(key=value, ...) -> { key: value, ... }
42
+ obj(**base, key=value) -> { ...base, key: value }
43
+
44
+ Creates a plain JavaScript object literal.
45
+ Use this instead of dict() when you need a plain object (e.g., for React props).
46
+ """
47
+
48
+ @override
49
+ def emit(self, out: list[str]) -> None:
50
+ raise TypeError("obj cannot be emitted directly - must be called")
51
+
52
+ @override
53
+ def render(self) -> VDOMNode:
54
+ raise TypeError("obj cannot be rendered - must be called")
55
+
56
+ @override
57
+ def transpile_call(
58
+ self,
59
+ args: list[ast.expr],
60
+ keywords: list[ast.keyword],
61
+ ctx: Transpiler,
62
+ ) -> Expr:
63
+ if args:
64
+ raise TranspileError("obj() only accepts keyword arguments")
65
+
66
+ props: list[tuple[str, Expr] | Spread] = []
67
+ for kw in keywords:
68
+ if kw.arg is None:
69
+ # **spread syntax
70
+ props.append(spread_dict(ctx.emit_expr(kw.value)))
71
+ else:
72
+ # key=value
73
+ props.append((kw.arg, ctx.emit_expr(kw.value)))
74
+
75
+ return Object(props)
76
+
77
+
78
+ # Create singleton instance for use as a callable
79
+ obj = ObjTransformer()
pulse/js/pulse.py ADDED
@@ -0,0 +1,112 @@
1
+ """
2
+ Pulse UI client bindings for channel communication.
3
+
4
+ Usage:
5
+ from pulse.js.pulse import usePulseChannel, ChannelBridge, PulseChannelResetError
6
+
7
+ @ps.javascript(jsx=True)
8
+ def MyChannelComponent(*, channel_id: str):
9
+ bridge = usePulseChannel(channel_id)
10
+
11
+ # Subscribe to events
12
+ useEffect(
13
+ lambda: bridge.on("server:notify", lambda payload: console.log(payload)),
14
+ [bridge],
15
+ )
16
+
17
+ # Emit events to server
18
+ def send_ping():
19
+ bridge.emit("client:ping", {"message": "hello"})
20
+
21
+ # Make requests to server
22
+ async def send_request():
23
+ response = await bridge.request("client:request", {"data": 123})
24
+ console.log(response)
25
+
26
+ return ps.div()[
27
+ ps.button(onClick=send_ping)["Send Ping"],
28
+ ps.button(onClick=send_request)["Send Request"],
29
+ ]
30
+ """
31
+
32
+ from collections.abc import Awaitable as _Awaitable
33
+ from collections.abc import Callable as _Callable
34
+ from typing import Any as _Any
35
+ from typing import TypeVar as _TypeVar
36
+
37
+ from pulse.transpiler.js_module import JsModule
38
+
39
+ T = _TypeVar("T")
40
+
41
+
42
+ class PulseChannelResetError(Exception):
43
+ """Error raised when a channel is closed or reset."""
44
+
45
+ pass
46
+
47
+
48
+ class ChannelBridge:
49
+ """A bridge for bidirectional communication between client and server.
50
+
51
+ Provides methods for emitting events, making requests, and subscribing
52
+ to server events on a specific channel.
53
+ """
54
+
55
+ @property
56
+ def id(self) -> str:
57
+ """The unique channel identifier."""
58
+ ...
59
+
60
+ def emit(self, event: str, payload: _Any = None) -> None:
61
+ """Emit an event to the server.
62
+
63
+ Args:
64
+ event: The event name to emit.
65
+ payload: Optional data to send with the event.
66
+ """
67
+ ...
68
+
69
+ def request(self, event: str, payload: _Any = None) -> _Awaitable[_Any]:
70
+ """Make a request to the server and await a response.
71
+
72
+ Args:
73
+ event: The event name to send.
74
+ payload: Optional data to send with the request.
75
+
76
+ Returns:
77
+ A Promise that resolves with the server's response.
78
+ """
79
+ ...
80
+
81
+ def on(self, event: str, handler: _Callable[[_Any], _Any]) -> _Callable[[], None]:
82
+ """Subscribe to events from the server.
83
+
84
+ Args:
85
+ event: The event name to listen for.
86
+ handler: A callback function that receives the event payload.
87
+ May be sync or async. For request events, the return value
88
+ is sent back to the server.
89
+
90
+ Returns:
91
+ A cleanup function that unsubscribes the handler.
92
+ """
93
+ ...
94
+
95
+
96
+ def usePulseChannel(channel_id: str) -> ChannelBridge:
97
+ """React hook to connect to a Pulse channel.
98
+
99
+ Must be called from within a React component. The channel connection
100
+ is automatically managed based on component lifecycle.
101
+
102
+ Args:
103
+ channel_id: The unique identifier for the channel to connect to.
104
+
105
+ Returns:
106
+ A ChannelBridge instance for interacting with the channel.
107
+ """
108
+ ...
109
+
110
+
111
+ # Register as a JS module with named imports from pulse-ui-client
112
+ JsModule.register(name="pulse", src="pulse-ui-client", values="named_import")