pulse-framework 0.1.52__py3-none-any.whl → 0.1.54__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/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 +350 -0
- pulse/js/react_dom.py +30 -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/builtins.py +0 -20
- pulse/transpiler/errors.py +29 -11
- pulse/transpiler/function.py +30 -3
- pulse/transpiler/js_module.py +9 -12
- pulse/transpiler/modules/pulse/tags.py +35 -15
- pulse/transpiler/nodes.py +121 -36
- pulse/transpiler/py_module.py +1 -1
- pulse/transpiler/transpiler.py +28 -26
- pulse/user_session.py +10 -0
- pulse_framework-0.1.54.dist-info/METADATA +196 -0
- {pulse_framework-0.1.52.dist-info → pulse_framework-0.1.54.dist-info}/RECORD +30 -26
- pulse/hooks/states.py +0 -285
- pulse_framework-0.1.52.dist-info/METADATA +0 -18
- {pulse_framework-0.1.52.dist-info → pulse_framework-0.1.54.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.52.dist-info → pulse_framework-0.1.54.dist-info}/entry_points.txt +0 -0
pulse/transpiler/nodes.py
CHANGED
|
@@ -88,19 +88,23 @@ class Expr(ABC):
|
|
|
88
88
|
def transpile_call(
|
|
89
89
|
self,
|
|
90
90
|
args: list[ast.expr],
|
|
91
|
-
|
|
91
|
+
keywords: list[ast.keyword],
|
|
92
92
|
ctx: Transpiler,
|
|
93
93
|
) -> Expr:
|
|
94
94
|
"""Called when this expression is used as a function: expr(args).
|
|
95
95
|
|
|
96
96
|
Override to customize call behavior.
|
|
97
|
-
Default
|
|
97
|
+
Default emits a Call expression with args transpiled.
|
|
98
98
|
|
|
99
|
-
Args and
|
|
99
|
+
Args and keywords are raw Python AST nodes (not yet transpiled).
|
|
100
100
|
Use ctx.emit_expr() to convert them to Expr as needed.
|
|
101
|
+
Keywords with kw.arg=None are **spread syntax.
|
|
101
102
|
"""
|
|
102
|
-
if
|
|
103
|
-
|
|
103
|
+
if keywords:
|
|
104
|
+
has_spread = any(kw.arg is None for kw in keywords)
|
|
105
|
+
if has_spread:
|
|
106
|
+
raise TranspileError("Spread (**expr) not supported in this call")
|
|
107
|
+
raise TranspileError("Keyword arguments not supported in call")
|
|
104
108
|
return Call(self, [ctx.emit_expr(a) for a in args])
|
|
105
109
|
|
|
106
110
|
def transpile_getattr(self, attr: str, ctx: Transpiler) -> Expr:
|
|
@@ -341,10 +345,10 @@ class ExprWrapper(Expr):
|
|
|
341
345
|
def transpile_call(
|
|
342
346
|
self,
|
|
343
347
|
args: list[ast.expr],
|
|
344
|
-
|
|
348
|
+
keywords: list[ast.keyword],
|
|
345
349
|
ctx: Transpiler,
|
|
346
350
|
) -> Expr:
|
|
347
|
-
return self.expr.transpile_call(args,
|
|
351
|
+
return self.expr.transpile_call(args, keywords, ctx)
|
|
348
352
|
|
|
349
353
|
@override
|
|
350
354
|
def transpile_getattr(self, attr: str, ctx: Transpiler) -> Expr:
|
|
@@ -395,28 +399,33 @@ class Jsx(ExprWrapper):
|
|
|
395
399
|
def transpile_call(
|
|
396
400
|
self,
|
|
397
401
|
args: list[ast.expr],
|
|
398
|
-
|
|
402
|
+
keywords: list[ast.keyword],
|
|
399
403
|
ctx: "Transpiler",
|
|
400
404
|
) -> Expr:
|
|
401
405
|
"""Transpile a call to this JSX wrapper into an Element.
|
|
402
406
|
|
|
403
407
|
Positional args become children, keyword args become props.
|
|
404
|
-
The `key` kwarg is extracted specially.
|
|
408
|
+
The `key` kwarg is extracted specially. Spread (**expr) is supported.
|
|
405
409
|
"""
|
|
406
410
|
children: list[Node] = [ctx.emit_expr(a) for a in args]
|
|
407
411
|
|
|
408
|
-
props:
|
|
412
|
+
props: list[tuple[str, Prop] | Spread] = []
|
|
409
413
|
key: str | Expr | None = None
|
|
410
|
-
for
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if isinstance(v, Literal) and isinstance(v.value, str):
|
|
415
|
-
key = v.value # Optimize string literals
|
|
416
|
-
else:
|
|
417
|
-
key = v # Keep as expression
|
|
414
|
+
for kw in keywords:
|
|
415
|
+
if kw.arg is None:
|
|
416
|
+
# **spread syntax
|
|
417
|
+
props.append(spread_dict(ctx.emit_expr(kw.value)))
|
|
418
418
|
else:
|
|
419
|
-
|
|
419
|
+
k = kw.arg
|
|
420
|
+
v = ctx.emit_expr(kw.value)
|
|
421
|
+
if k == "key":
|
|
422
|
+
# Accept any expression as key for transpilation
|
|
423
|
+
if isinstance(v, Literal) and isinstance(v.value, str):
|
|
424
|
+
key = v.value # Optimize string literals
|
|
425
|
+
else:
|
|
426
|
+
key = v # Keep as expression
|
|
427
|
+
else:
|
|
428
|
+
props.append((k, v))
|
|
420
429
|
|
|
421
430
|
return Element(
|
|
422
431
|
tag=self.expr,
|
|
@@ -435,7 +444,7 @@ class Jsx(ExprWrapper):
|
|
|
435
444
|
"""
|
|
436
445
|
|
|
437
446
|
# Normal call: build Element
|
|
438
|
-
props: dict[str,
|
|
447
|
+
props: dict[str, Any] = {}
|
|
439
448
|
key: str | None = None
|
|
440
449
|
children: list[Node] = list(args)
|
|
441
450
|
|
|
@@ -490,7 +499,9 @@ class Value(Expr):
|
|
|
490
499
|
|
|
491
500
|
@override
|
|
492
501
|
def render(self) -> VDOMNode:
|
|
493
|
-
raise TypeError(
|
|
502
|
+
raise TypeError(
|
|
503
|
+
"Value cannot be rendered as VDOMExpr; unwrap with .value instead"
|
|
504
|
+
)
|
|
494
505
|
|
|
495
506
|
|
|
496
507
|
class Element(Expr):
|
|
@@ -501,19 +512,23 @@ class Element(Expr):
|
|
|
501
512
|
- "div", "span", etc.: HTML element
|
|
502
513
|
- "$$ComponentId": Client component (registered in JS registry)
|
|
503
514
|
- Expr (Import, Member, etc.): Direct component reference for transpilation
|
|
515
|
+
|
|
516
|
+
Props can be either:
|
|
517
|
+
- tuple[str, Prop]: key-value pair
|
|
518
|
+
- Spread: spread expression (...expr)
|
|
504
519
|
"""
|
|
505
520
|
|
|
506
521
|
__slots__: tuple[str, ...] = ("tag", "props", "children", "key")
|
|
507
522
|
|
|
508
523
|
tag: str | Expr
|
|
509
|
-
props: dict[str, Any] | None
|
|
524
|
+
props: Sequence[tuple[str, Prop] | Spread] | dict[str, Any] | None
|
|
510
525
|
children: Sequence[Node] | None
|
|
511
526
|
key: str | Expr | None
|
|
512
527
|
|
|
513
528
|
def __init__(
|
|
514
529
|
self,
|
|
515
530
|
tag: str | Expr,
|
|
516
|
-
props: dict[str, Any] | None = None,
|
|
531
|
+
props: Sequence[tuple[str, Prop] | Spread] | dict[str, Any] | None = None,
|
|
517
532
|
children: Sequence[Node] | None = None,
|
|
518
533
|
key: str | Expr | None = None,
|
|
519
534
|
) -> None:
|
|
@@ -534,8 +549,6 @@ class Element(Expr):
|
|
|
534
549
|
warn_stacklevel=5,
|
|
535
550
|
)
|
|
536
551
|
self.key = key
|
|
537
|
-
if self.key is None and self.props:
|
|
538
|
-
self.key = self.props.pop("key", None)
|
|
539
552
|
|
|
540
553
|
def _emit_key(self, out: list[str]) -> None:
|
|
541
554
|
"""Emit key prop (string or expression)."""
|
|
@@ -583,10 +596,24 @@ class Element(Expr):
|
|
|
583
596
|
if self.key is not None:
|
|
584
597
|
self._emit_key(props_out)
|
|
585
598
|
if self.props:
|
|
586
|
-
|
|
599
|
+
# Handle both dict (from render path) and sequence (from transpilation)
|
|
600
|
+
# Dict case: items() yields tuple[str, Any], never Spread
|
|
601
|
+
# Sequence case: already list[tuple[str, Prop] | Spread]
|
|
602
|
+
props_iter: Iterable[tuple[str, Any]] | Sequence[tuple[str, Prop] | Spread]
|
|
603
|
+
if isinstance(self.props, dict):
|
|
604
|
+
props_iter = self.props.items()
|
|
605
|
+
else:
|
|
606
|
+
props_iter = self.props
|
|
607
|
+
for prop in props_iter:
|
|
587
608
|
if props_out:
|
|
588
609
|
props_out.append(" ")
|
|
589
|
-
|
|
610
|
+
if isinstance(prop, Spread):
|
|
611
|
+
props_out.append("{...")
|
|
612
|
+
prop.expr.emit(props_out)
|
|
613
|
+
props_out.append("}")
|
|
614
|
+
else:
|
|
615
|
+
name, value = prop
|
|
616
|
+
_emit_jsx_prop(name, value, props_out)
|
|
590
617
|
|
|
591
618
|
# Build children into a separate buffer to check if empty
|
|
592
619
|
children_out: list[str] = []
|
|
@@ -682,6 +709,27 @@ class Element(Expr):
|
|
|
682
709
|
key=self.key,
|
|
683
710
|
)
|
|
684
711
|
|
|
712
|
+
def props_dict(self) -> dict[str, Any]:
|
|
713
|
+
"""Convert props to dict for rendering.
|
|
714
|
+
|
|
715
|
+
Raises TypeError if props contain Spread (only valid in transpilation).
|
|
716
|
+
"""
|
|
717
|
+
if not self.props:
|
|
718
|
+
return {}
|
|
719
|
+
# Already a dict (from renderer reconciliation)
|
|
720
|
+
if isinstance(self.props, dict):
|
|
721
|
+
return self.props
|
|
722
|
+
# Sequence of (key, value) pairs or Spread
|
|
723
|
+
result: dict[str, Any] = {}
|
|
724
|
+
for prop in self.props:
|
|
725
|
+
if isinstance(prop, Spread):
|
|
726
|
+
raise TypeError(
|
|
727
|
+
"Element with spread props cannot be rendered; spread is only valid during transpilation"
|
|
728
|
+
)
|
|
729
|
+
k, v = prop
|
|
730
|
+
result[k] = v
|
|
731
|
+
return result
|
|
732
|
+
|
|
685
733
|
@override
|
|
686
734
|
def render(self) -> VDOMNode:
|
|
687
735
|
"""Element rendering is handled by Renderer.render_node(), not render().
|
|
@@ -938,25 +986,40 @@ class Array(Expr):
|
|
|
938
986
|
|
|
939
987
|
@dataclass(slots=True)
|
|
940
988
|
class Object(Expr):
|
|
941
|
-
"""JS object: { key: value }
|
|
989
|
+
"""JS object: { key: value, ...spread }
|
|
990
|
+
|
|
991
|
+
Props can be either:
|
|
992
|
+
- tuple[str, Expr]: key-value pair
|
|
993
|
+
- Spread: spread expression (...expr)
|
|
994
|
+
"""
|
|
942
995
|
|
|
943
|
-
props: Sequence[tuple[str, Expr]]
|
|
996
|
+
props: Sequence[tuple[str, Expr] | Spread]
|
|
944
997
|
|
|
945
998
|
@override
|
|
946
999
|
def emit(self, out: list[str]) -> None:
|
|
947
1000
|
out.append("{")
|
|
948
|
-
for i,
|
|
1001
|
+
for i, prop in enumerate(self.props):
|
|
949
1002
|
if i > 0:
|
|
950
1003
|
out.append(", ")
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1004
|
+
if isinstance(prop, Spread):
|
|
1005
|
+
prop.emit(out)
|
|
1006
|
+
else:
|
|
1007
|
+
k, v = prop
|
|
1008
|
+
out.append('"')
|
|
1009
|
+
out.append(_escape_string(k))
|
|
1010
|
+
out.append('": ')
|
|
1011
|
+
v.emit(out)
|
|
955
1012
|
out.append("}")
|
|
956
1013
|
|
|
957
1014
|
@override
|
|
958
1015
|
def render(self) -> VDOMNode:
|
|
959
|
-
|
|
1016
|
+
rendered_props: dict[str, VDOMNode] = {}
|
|
1017
|
+
for prop in self.props:
|
|
1018
|
+
if isinstance(prop, Spread):
|
|
1019
|
+
raise TypeError("Object spread cannot be rendered to VDOM")
|
|
1020
|
+
k, v = prop
|
|
1021
|
+
rendered_props[k] = v.render()
|
|
1022
|
+
return {"t": "object", "props": rendered_props}
|
|
960
1023
|
|
|
961
1024
|
|
|
962
1025
|
@dataclass(slots=True)
|
|
@@ -1214,6 +1277,21 @@ class Spread(Expr):
|
|
|
1214
1277
|
raise TypeError("Spread cannot be rendered as VDOMExpr directly")
|
|
1215
1278
|
|
|
1216
1279
|
|
|
1280
|
+
def spread_dict(expr: Expr) -> Spread:
|
|
1281
|
+
"""Wrap a spread expression with Map-to-object conversion.
|
|
1282
|
+
|
|
1283
|
+
Python dicts transpile to Map, which has no enumerable own properties.
|
|
1284
|
+
This wraps the spread with an IIFE that converts Map to object:
|
|
1285
|
+
(...expr) -> ...($s => $s instanceof Map ? Object.fromEntries($s) : $s)(expr)
|
|
1286
|
+
|
|
1287
|
+
The IIFE ensures expr is evaluated only once.
|
|
1288
|
+
"""
|
|
1289
|
+
s = Identifier("$s")
|
|
1290
|
+
is_map = Binary(s, "instanceof", Identifier("Map"))
|
|
1291
|
+
as_obj = Call(Member(Identifier("Object"), "fromEntries"), [s])
|
|
1292
|
+
return Spread(Call(Arrow(["$s"], Ternary(is_map, as_obj, s)), [expr]))
|
|
1293
|
+
|
|
1294
|
+
|
|
1217
1295
|
@dataclass(slots=True)
|
|
1218
1296
|
class New(Expr):
|
|
1219
1297
|
"""JS new expression: new Ctor(args)"""
|
|
@@ -1272,9 +1350,16 @@ class Transformer(Expr, Generic[_F]):
|
|
|
1272
1350
|
def transpile_call(
|
|
1273
1351
|
self,
|
|
1274
1352
|
args: list[ast.expr],
|
|
1275
|
-
|
|
1353
|
+
keywords: list[ast.keyword],
|
|
1276
1354
|
ctx: Transpiler,
|
|
1277
1355
|
) -> Expr:
|
|
1356
|
+
# Convert keywords to dict, reject spreads
|
|
1357
|
+
kwargs: dict[str, ast.expr] = {}
|
|
1358
|
+
for kw in keywords:
|
|
1359
|
+
if kw.arg is None:
|
|
1360
|
+
label = self.name or "Function"
|
|
1361
|
+
raise TranspileError(f"{label} does not support **spread")
|
|
1362
|
+
kwargs[kw.arg] = kw.value
|
|
1278
1363
|
if kwargs:
|
|
1279
1364
|
return self.fn(*args, ctx=ctx, **kwargs)
|
|
1280
1365
|
return self.fn(*args, ctx=ctx)
|
pulse/transpiler/py_module.py
CHANGED
pulse/transpiler/transpiler.py
CHANGED
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import ast
|
|
11
11
|
import re
|
|
12
12
|
from collections.abc import Callable, Mapping
|
|
13
|
-
from typing import Any
|
|
13
|
+
from typing import Any, cast
|
|
14
14
|
|
|
15
15
|
from pulse.transpiler.builtins import BUILTINS, emit_method
|
|
16
16
|
from pulse.transpiler.errors import TranspileError
|
|
@@ -31,6 +31,7 @@ from pulse.transpiler.nodes import (
|
|
|
31
31
|
If,
|
|
32
32
|
Literal,
|
|
33
33
|
Member,
|
|
34
|
+
New,
|
|
34
35
|
Return,
|
|
35
36
|
Spread,
|
|
36
37
|
Stmt,
|
|
@@ -465,7 +466,7 @@ class Transpiler:
|
|
|
465
466
|
return self._emit_dict(node)
|
|
466
467
|
|
|
467
468
|
if isinstance(node, ast.Set):
|
|
468
|
-
return
|
|
469
|
+
return New(
|
|
469
470
|
Identifier("Set"),
|
|
470
471
|
[Array([self.emit_expr(e) for e in node.elts])],
|
|
471
472
|
)
|
|
@@ -515,14 +516,14 @@ class Transpiler:
|
|
|
515
516
|
arr = self._emit_comprehension_chain(
|
|
516
517
|
node.generators, lambda: self.emit_expr(node.elt)
|
|
517
518
|
)
|
|
518
|
-
return
|
|
519
|
+
return New(Identifier("Set"), [arr])
|
|
519
520
|
|
|
520
521
|
if isinstance(node, ast.DictComp):
|
|
521
522
|
pairs = self._emit_comprehension_chain(
|
|
522
523
|
node.generators,
|
|
523
524
|
lambda: Array([self.emit_expr(node.key), self.emit_expr(node.value)]),
|
|
524
525
|
)
|
|
525
|
-
return
|
|
526
|
+
return New(Identifier("Map"), [pairs])
|
|
526
527
|
|
|
527
528
|
if isinstance(node, ast.Lambda):
|
|
528
529
|
return self._emit_lambda(node)
|
|
@@ -596,7 +597,7 @@ class Transpiler:
|
|
|
596
597
|
key_expr = self.emit_expr(k)
|
|
597
598
|
val_expr = self.emit_expr(v)
|
|
598
599
|
entries.append(Array([key_expr, val_expr]))
|
|
599
|
-
return
|
|
600
|
+
return New(Identifier("Map"), [Array(entries)])
|
|
600
601
|
|
|
601
602
|
def _emit_binop(self, node: ast.BinOp) -> Expr:
|
|
602
603
|
"""Emit a binary operation."""
|
|
@@ -720,37 +721,38 @@ class Transpiler:
|
|
|
720
721
|
|
|
721
722
|
def _emit_call(self, node: ast.Call) -> Expr:
|
|
722
723
|
"""Emit a function call."""
|
|
723
|
-
# Collect args and kwargs as raw AST values
|
|
724
|
-
args_raw = list(node.args)
|
|
725
|
-
kwargs_raw: dict[str, Any] = {}
|
|
726
|
-
for kw in node.keywords:
|
|
727
|
-
if kw.arg is None:
|
|
728
|
-
raise TranspileError(
|
|
729
|
-
"Spread props (**kwargs) not yet supported in v2 transpiler"
|
|
730
|
-
)
|
|
731
|
-
kwargs_raw[kw.arg] = kw.value
|
|
732
|
-
|
|
733
724
|
# Method call: obj.method(args) - try builtin method dispatch
|
|
734
725
|
if isinstance(node.func, ast.Attribute):
|
|
726
|
+
# Check for spreads - if present, skip builtin method handling
|
|
727
|
+
# (let transpile_call decide on spread support)
|
|
728
|
+
has_spread = any(kw.arg is None for kw in node.keywords)
|
|
729
|
+
|
|
735
730
|
obj = self.emit_expr(node.func.value)
|
|
736
731
|
method = node.func.attr
|
|
737
|
-
args: list[Expr] = [self.emit_expr(a) for a in args_raw]
|
|
738
|
-
kwargs: dict[str, Expr] = {
|
|
739
|
-
k: self.emit_expr(v) for k, v in kwargs_raw.items()
|
|
740
|
-
}
|
|
741
732
|
|
|
742
|
-
# Try builtin method handling
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
733
|
+
# Try builtin method handling only if no spreads
|
|
734
|
+
if not has_spread:
|
|
735
|
+
# Safe to cast: has_spread=False means all kw.arg are str (not None)
|
|
736
|
+
kwargs_raw: dict[str, Any] = {
|
|
737
|
+
cast(str, kw.arg): kw.value for kw in node.keywords
|
|
738
|
+
}
|
|
739
|
+
args: list[Expr] = [self.emit_expr(a) for a in node.args]
|
|
740
|
+
kwargs: dict[str, Expr] = {
|
|
741
|
+
k: self.emit_expr(v) for k, v in kwargs_raw.items()
|
|
742
|
+
}
|
|
743
|
+
result = emit_method(obj, method, args, kwargs)
|
|
744
|
+
if result is not None:
|
|
745
|
+
return result
|
|
746
746
|
|
|
747
747
|
# IMPORTANT: derive method expr via transpile_getattr
|
|
748
748
|
method_expr = obj.transpile_getattr(method, self)
|
|
749
|
-
return method_expr.transpile_call(
|
|
749
|
+
return method_expr.transpile_call(
|
|
750
|
+
list(node.args), list(node.keywords), self
|
|
751
|
+
)
|
|
750
752
|
|
|
751
|
-
# Function call (
|
|
753
|
+
# Function call - pass raw keywords (let callee decide on spread support)
|
|
752
754
|
callee = self.emit_expr(node.func)
|
|
753
|
-
return callee.transpile_call(
|
|
755
|
+
return callee.transpile_call(list(node.args), list(node.keywords), self)
|
|
754
756
|
|
|
755
757
|
def _emit_attribute(self, node: ast.Attribute) -> Expr:
|
|
756
758
|
"""Emit an attribute access."""
|
pulse/user_session.py
CHANGED
|
@@ -61,6 +61,10 @@ class UserSession(Disposable):
|
|
|
61
61
|
# unwrap subscribes the effect to all signals in the session ReactiveDict
|
|
62
62
|
data = unwrap(self.data)
|
|
63
63
|
signed_cookie = app.session_store.encode(self.sid, data)
|
|
64
|
+
if app.cookie.secure is None:
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
"Cookie.secure is not resolved. This is likely an internal error. Ensure App.setup() ran before sessions."
|
|
67
|
+
)
|
|
64
68
|
self.set_cookie(
|
|
65
69
|
name=app.cookie.name,
|
|
66
70
|
value=signed_cookie,
|
|
@@ -83,6 +87,12 @@ class UserSession(Disposable):
|
|
|
83
87
|
self._queued_cookies.clear()
|
|
84
88
|
self.scheduled_cookie_refresh = False
|
|
85
89
|
|
|
90
|
+
def get_cookie_value(self, name: str) -> str | None:
|
|
91
|
+
cookie = self._queued_cookies.get(name)
|
|
92
|
+
if cookie is None:
|
|
93
|
+
return None
|
|
94
|
+
return cookie.value
|
|
95
|
+
|
|
86
96
|
def set_cookie(
|
|
87
97
|
self,
|
|
88
98
|
name: str,
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pulse-framework
|
|
3
|
+
Version: 0.1.54
|
|
4
|
+
Summary: Pulse - Full-stack framework for building real-time React applications in Python
|
|
5
|
+
Requires-Dist: websockets>=12.0
|
|
6
|
+
Requires-Dist: fastapi>=0.104.0
|
|
7
|
+
Requires-Dist: uvicorn>=0.24.0
|
|
8
|
+
Requires-Dist: mako>=1.3.10
|
|
9
|
+
Requires-Dist: typer>=0.16.0
|
|
10
|
+
Requires-Dist: python-socketio>=5.13.0
|
|
11
|
+
Requires-Dist: rich>=13.7.1
|
|
12
|
+
Requires-Dist: python-multipart>=0.0.20
|
|
13
|
+
Requires-Dist: python-dateutil>=2.9.0.post0
|
|
14
|
+
Requires-Dist: watchfiles>=1.1.0
|
|
15
|
+
Requires-Dist: httpx>=0.28.1
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# Pulse Python
|
|
20
|
+
|
|
21
|
+
Core Python framework for building full-stack reactive web apps with React frontends.
|
|
22
|
+
|
|
23
|
+
## Architecture
|
|
24
|
+
|
|
25
|
+
Server-driven UI model: Python components render to VDOM, synced to React via WebSocket. State changes trigger re-renders; diffs are sent to client.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
29
|
+
│ Python Server │
|
|
30
|
+
│ ┌──────────┐ ┌───────────────┐ ┌──────────────────────────┐ │
|
|
31
|
+
│ │ App │──│ RenderSession │──│ VDOM Renderer │ │
|
|
32
|
+
│ │ (FastAPI)│ │ (per browser) │ │ (diff & serialize) │ │
|
|
33
|
+
│ └──────────┘ └───────────────┘ └──────────────────────────┘ │
|
|
34
|
+
│ │ │ │ │
|
|
35
|
+
│ │ ┌──────┴───────┐ │ │
|
|
36
|
+
│ │ │ Hooks │ │ │
|
|
37
|
+
│ │ │ (state/setup)│ │ │
|
|
38
|
+
│ │ └──────────────┘ │ │
|
|
39
|
+
└───────┼───────────────────────────────────────┼─────────────────┘
|
|
40
|
+
│ Socket.IO │ VDOM updates
|
|
41
|
+
▼ ▼
|
|
42
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
43
|
+
│ Browser (React) │
|
|
44
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Folder Structure
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
src/pulse/
|
|
51
|
+
├── app.py # Main App class, FastAPI + Socket.IO setup
|
|
52
|
+
├── channel.py # Bidirectional real-time channels
|
|
53
|
+
├── routing.py # Route/Layout definitions, URL matching
|
|
54
|
+
├── vdom.py # VDOM node types (Element, Component, Node)
|
|
55
|
+
├── renderer.py # VDOM rendering and diffing
|
|
56
|
+
├── render_session.py # Per-browser session, manages mounted routes
|
|
57
|
+
├── reactive.py # Signal/Computed/Effect primitives
|
|
58
|
+
├── reactive_extensions.py # ReactiveList, ReactiveDict, ReactiveSet
|
|
59
|
+
├── state.py # State management
|
|
60
|
+
├── serializer.py # Python<->JSON serialization
|
|
61
|
+
├── middleware.py # Request middleware (prerender, connect, message)
|
|
62
|
+
├── plugin.py # Plugin interface for extensions
|
|
63
|
+
├── form.py # Form handling
|
|
64
|
+
├── context.py # PulseContext (request/session context)
|
|
65
|
+
├── cookies.py # Cookie management
|
|
66
|
+
├── request.py # PulseRequest abstraction
|
|
67
|
+
├── user_session.py # User session storage
|
|
68
|
+
├── helpers.py # Utilities (CSSProperties, later, repeat)
|
|
69
|
+
├── decorators.py # @computed, @effect decorators
|
|
70
|
+
├── messages.py # Client<->server message types
|
|
71
|
+
├── react_component.py # ReactComponent wrapper for JS libraries
|
|
72
|
+
│
|
|
73
|
+
├── hooks/ # Server-side hooks (like React hooks)
|
|
74
|
+
│ ├── core.py # Hook registry, HooksAPI
|
|
75
|
+
│ ├── runtime.py # session(), route(), navigate(), redirect()
|
|
76
|
+
│ ├── states.py # Reactive state hook
|
|
77
|
+
│ ├── effects.py # Side effects hook
|
|
78
|
+
│ ├── setup.py # Initialization hook
|
|
79
|
+
│ ├── init.py # One-time setup hook
|
|
80
|
+
│ └── stable.py # Memoization hook
|
|
81
|
+
│
|
|
82
|
+
├── queries/ # Data fetching (like TanStack Query)
|
|
83
|
+
│ ├── query.py # @query decorator
|
|
84
|
+
│ ├── mutation.py # @mutation decorator
|
|
85
|
+
│ ├── infinite_query.py # Pagination support
|
|
86
|
+
│ ├── client.py # QueryClient for cache management
|
|
87
|
+
│ └── store.py # Query state store
|
|
88
|
+
│
|
|
89
|
+
├── components/ # Built-in components
|
|
90
|
+
│ ├── for_.py # <For> loop component
|
|
91
|
+
│ ├── if_.py # <If> conditional component
|
|
92
|
+
│ └── react_router.py # Link, Outlet for routing
|
|
93
|
+
│
|
|
94
|
+
├── html/ # HTML element bindings
|
|
95
|
+
│ ├── tags.py # div, span, button, etc.
|
|
96
|
+
│ ├── props.py # Typed props for HTML elements
|
|
97
|
+
│ ├── events.py # Event types (MouseEvent, etc.)
|
|
98
|
+
│ └── elements.py # Element type definitions
|
|
99
|
+
│
|
|
100
|
+
├── transpiler/ # Python->JS transpilation
|
|
101
|
+
│ ├── function.py # JsFunction, @javascript decorator
|
|
102
|
+
│ └── imports.py # Import/CssImport for client-side JS
|
|
103
|
+
│
|
|
104
|
+
├── codegen/ # Code generation for React Router
|
|
105
|
+
│ ├── codegen.py # Generates routes.ts, loaders
|
|
106
|
+
│ └── templates/ # Mako templates for generated code
|
|
107
|
+
│
|
|
108
|
+
├── cli/ # Command-line interface
|
|
109
|
+
│ ├── cmd.py # pulse run, pulse build
|
|
110
|
+
│ └── processes.py # Dev server process management
|
|
111
|
+
│
|
|
112
|
+
└── js/ # JS API stubs for transpilation
|
|
113
|
+
├── window.py, document.py, navigator.py
|
|
114
|
+
├── array.py, object.py, string.py
|
|
115
|
+
└── ...
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Key Concepts
|
|
119
|
+
|
|
120
|
+
### App
|
|
121
|
+
|
|
122
|
+
Entry point defining routes, middleware, plugins.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
import pulse as ps
|
|
126
|
+
|
|
127
|
+
app = ps.App(routes=[
|
|
128
|
+
ps.Route("/", home),
|
|
129
|
+
ps.Layout("/dashboard", layout, children=[
|
|
130
|
+
ps.Route("/", dashboard),
|
|
131
|
+
]),
|
|
132
|
+
])
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Components
|
|
136
|
+
|
|
137
|
+
Functions returning VDOM. Use `@ps.component` for stateful components.
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
def greeting(name: str):
|
|
141
|
+
return ps.div(f"Hello, {name}!")
|
|
142
|
+
|
|
143
|
+
@ps.component
|
|
144
|
+
def counter():
|
|
145
|
+
count = ps.states.use(0)
|
|
146
|
+
return ps.button(f"Count: {count()}", onClick=lambda _: count.set(count() + 1))
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Reactivity
|
|
150
|
+
|
|
151
|
+
- `Signal[T]` - reactive value
|
|
152
|
+
- `Computed[T]` - derived value
|
|
153
|
+
- `Effect` - side effect on change
|
|
154
|
+
|
|
155
|
+
### Hooks
|
|
156
|
+
|
|
157
|
+
Server-side hooks via `ps.states`, `ps.effects`, `ps.setup`:
|
|
158
|
+
- `states.use(initial)` - reactive state
|
|
159
|
+
- `effects.use(fn, deps)` - side effects
|
|
160
|
+
- `setup.use(fn)` - one-time initialization
|
|
161
|
+
|
|
162
|
+
### Queries
|
|
163
|
+
|
|
164
|
+
Data fetching with caching:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
@ps.query
|
|
168
|
+
async def fetch_user(id: str):
|
|
169
|
+
return await db.get_user(id)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Channels
|
|
173
|
+
|
|
174
|
+
Bidirectional real-time messaging:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
ch = ps.channel("chat")
|
|
178
|
+
|
|
179
|
+
@ch.on("message")
|
|
180
|
+
def handle_message(data):
|
|
181
|
+
ch.broadcast("new_message", data)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Main Exports
|
|
185
|
+
|
|
186
|
+
- `App`, `Route`, `Layout` - app/routing
|
|
187
|
+
- `component` - server-side component decorator
|
|
188
|
+
- `states`, `effects`, `setup`, `init` - hooks
|
|
189
|
+
- `query`, `mutation`, `infinite_query` - data fetching
|
|
190
|
+
- `channel` - real-time channels
|
|
191
|
+
- `State`, `@computed`, `@effect` - reactivity
|
|
192
|
+
- `ReactiveList`, `ReactiveDict`, `ReactiveSet` - reactive containers
|
|
193
|
+
- `div`, `span`, `button`, ... - HTML elements
|
|
194
|
+
- `For`, `If`, `Link`, `Outlet` - built-in components
|
|
195
|
+
- `@react_component` - wrap JS components
|
|
196
|
+
- `@javascript` - transpile Python to JS
|