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.
- pulse/__init__.py +3 -3
- pulse/app.py +34 -20
- pulse/code_analysis.py +38 -0
- pulse/codegen/codegen.py +18 -50
- pulse/codegen/templates/route.py +100 -56
- pulse/component.py +24 -6
- pulse/components/for_.py +17 -2
- pulse/cookies.py +38 -2
- pulse/env.py +4 -4
- pulse/hooks/init.py +174 -14
- pulse/hooks/state.py +105 -0
- pulse/js/__init__.py +12 -9
- pulse/js/obj.py +79 -0
- pulse/js/pulse.py +112 -0
- pulse/js/react.py +457 -0
- pulse/messages.py +13 -13
- pulse/proxy.py +18 -5
- pulse/render_session.py +282 -266
- pulse/renderer.py +36 -73
- pulse/serializer.py +5 -2
- pulse/transpiler/__init__.py +13 -0
- pulse/transpiler/assets.py +66 -0
- pulse/transpiler/builtins.py +0 -20
- pulse/transpiler/dynamic_import.py +131 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +29 -11
- pulse/transpiler/function.py +36 -5
- pulse/transpiler/imports.py +33 -27
- pulse/transpiler/js_module.py +73 -20
- pulse/transpiler/modules/pulse/tags.py +35 -15
- pulse/transpiler/nodes.py +121 -36
- pulse/transpiler/py_module.py +1 -1
- pulse/transpiler/react_component.py +4 -11
- pulse/transpiler/transpiler.py +32 -26
- pulse/user_session.py +10 -0
- pulse_framework-0.1.55.dist-info/METADATA +196 -0
- {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/RECORD +39 -32
- pulse/hooks/states.py +0 -285
- pulse_framework-0.1.53.dist-info/METADATA +0 -18
- {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/WHEEL +0 -0
- {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 =
|
|
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=
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
377
|
-
|
|
378
|
-
) ->
|
|
379
|
-
disallowed
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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.
|
|
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
|
|
38
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
96
|
-
if name in
|
|
97
|
-
module = _importlib.import_module(
|
|
98
|
-
export =
|
|
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")
|