pulse-framework 0.1.53__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.53.dist-info → pulse_framework-0.1.54.dist-info}/RECORD +30 -26
- 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.54.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.54.dist-info}/entry_points.txt +0 -0
pulse/renderer.py
CHANGED
|
@@ -65,48 +65,43 @@ class RenderPropTask(NamedTuple):
|
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
class RenderTree:
|
|
68
|
-
|
|
68
|
+
element: Node
|
|
69
69
|
callbacks: Callbacks
|
|
70
|
-
|
|
71
|
-
_normalized: Node | None
|
|
70
|
+
rendered: bool
|
|
72
71
|
|
|
73
|
-
def __init__(self,
|
|
74
|
-
self.
|
|
72
|
+
def __init__(self, element: Node) -> None:
|
|
73
|
+
self.element = element
|
|
75
74
|
self.callbacks = {}
|
|
76
|
-
self.
|
|
77
|
-
self._normalized = None
|
|
75
|
+
self.rendered = False
|
|
78
76
|
|
|
79
77
|
def render(self) -> VDOM:
|
|
78
|
+
"""First render. Returns VDOM."""
|
|
80
79
|
renderer = Renderer()
|
|
81
|
-
vdom,
|
|
82
|
-
self.root = normalized
|
|
80
|
+
vdom, self.element = renderer.render_tree(self.element)
|
|
83
81
|
self.callbacks = renderer.callbacks
|
|
84
|
-
self.
|
|
82
|
+
self.rendered = True
|
|
85
83
|
return vdom
|
|
86
84
|
|
|
87
|
-
def
|
|
88
|
-
|
|
89
|
-
raise RuntimeError("RenderTree.render must be called before diff")
|
|
85
|
+
def rerender(self, new_element: Node | None = None) -> list[VDOMOperation]:
|
|
86
|
+
"""Re-render and return update operations.
|
|
90
87
|
|
|
88
|
+
If new_element is provided, reconciles against it (for testing).
|
|
89
|
+
Otherwise, reconciles against the current element (production use).
|
|
90
|
+
"""
|
|
91
|
+
if not self.rendered:
|
|
92
|
+
raise RuntimeError("render() must be called before rerender()")
|
|
93
|
+
target = new_element if new_element is not None else self.element
|
|
91
94
|
renderer = Renderer()
|
|
92
|
-
|
|
93
|
-
|
|
95
|
+
self.element = renderer.reconcile_tree(self.element, target, path="")
|
|
94
96
|
self.callbacks = renderer.callbacks
|
|
95
|
-
self._normalized = normalized
|
|
96
|
-
self.root = normalized
|
|
97
|
-
|
|
98
97
|
return renderer.operations
|
|
99
98
|
|
|
100
99
|
def unmount(self) -> None:
|
|
101
|
-
if self.
|
|
102
|
-
unmount_element(self.
|
|
103
|
-
self.
|
|
100
|
+
if self.rendered:
|
|
101
|
+
unmount_element(self.element)
|
|
102
|
+
self.rendered = False
|
|
104
103
|
self.callbacks.clear()
|
|
105
104
|
|
|
106
|
-
@property
|
|
107
|
-
def normalized(self) -> Node | None:
|
|
108
|
-
return self._normalized
|
|
109
|
-
|
|
110
105
|
|
|
111
106
|
class Renderer:
|
|
112
107
|
def __init__(self) -> None:
|
|
@@ -123,13 +118,11 @@ class Renderer:
|
|
|
123
118
|
if isinstance(node, Element):
|
|
124
119
|
return self.render_node(node, path)
|
|
125
120
|
if isinstance(node, Value):
|
|
126
|
-
|
|
127
|
-
return json_value, json_value
|
|
121
|
+
return node.value, node.value
|
|
128
122
|
if isinstance(node, Expr):
|
|
129
123
|
return node.render(), node
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
raise TypeError(f"Unsupported node type: {type(node).__name__}")
|
|
124
|
+
# Pass through any other value - serializer will validate
|
|
125
|
+
return node, node
|
|
133
126
|
|
|
134
127
|
def render_component(
|
|
135
128
|
self, component: PulseNode, path: str
|
|
@@ -148,7 +141,7 @@ class Renderer:
|
|
|
148
141
|
if (key_val := key_value(element)) is not None:
|
|
149
142
|
vdom_node["key"] = key_val
|
|
150
143
|
|
|
151
|
-
props = element.
|
|
144
|
+
props = element.props_dict()
|
|
152
145
|
props_result = self.diff_props({}, props, path, prev_eval=set())
|
|
153
146
|
if props_result.delta_set:
|
|
154
147
|
vdom_node["props"] = props_result.delta_set
|
|
@@ -188,9 +181,9 @@ class Renderer:
|
|
|
188
181
|
path: str = "",
|
|
189
182
|
) -> Node:
|
|
190
183
|
if isinstance(current, Value):
|
|
191
|
-
current =
|
|
184
|
+
current = current.value
|
|
192
185
|
if isinstance(previous, Value):
|
|
193
|
-
previous =
|
|
186
|
+
previous = previous.value
|
|
194
187
|
if not same_node(previous, current):
|
|
195
188
|
unmount_element(previous)
|
|
196
189
|
new_vdom, normalized = self.render_tree(current, path)
|
|
@@ -239,8 +232,8 @@ class Renderer:
|
|
|
239
232
|
current: Element,
|
|
240
233
|
path: str,
|
|
241
234
|
) -> Element:
|
|
242
|
-
prev_props = previous.
|
|
243
|
-
new_props = current.
|
|
235
|
+
prev_props = previous.props_dict()
|
|
236
|
+
new_props = current.props_dict()
|
|
244
237
|
prev_eval = eval_keys_for_props(prev_props)
|
|
245
238
|
props_result = self.diff_props(prev_props, new_props, path, prev_eval)
|
|
246
239
|
|
|
@@ -389,14 +382,14 @@ class Renderer:
|
|
|
389
382
|
continue
|
|
390
383
|
|
|
391
384
|
if isinstance(value, Value):
|
|
392
|
-
|
|
385
|
+
unwrapped = value.value
|
|
393
386
|
if normalized is None:
|
|
394
387
|
normalized = current.copy()
|
|
395
|
-
normalized[key] =
|
|
388
|
+
normalized[key] = unwrapped
|
|
396
389
|
if isinstance(old_value, (Element, PulseNode)):
|
|
397
390
|
unmount_element(old_value)
|
|
398
|
-
if key not in previous or not values_equal(
|
|
399
|
-
updated[key] = cast(VDOMPropValue,
|
|
391
|
+
if key not in previous or not values_equal(unwrapped, old_value):
|
|
392
|
+
updated[key] = cast(VDOMPropValue, unwrapped)
|
|
400
393
|
continue
|
|
401
394
|
|
|
402
395
|
if isinstance(value, Expr):
|
|
@@ -422,16 +415,11 @@ class Renderer:
|
|
|
422
415
|
updated[key] = CALLBACK_PLACEHOLDER
|
|
423
416
|
continue
|
|
424
417
|
|
|
425
|
-
json_value = coerce_json(value, prop_path)
|
|
426
418
|
if isinstance(old_value, (Element, PulseNode)):
|
|
427
419
|
unmount_element(old_value)
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
normalized = current.copy()
|
|
432
|
-
normalized[key] = json_value
|
|
433
|
-
if key not in previous or not values_equal(json_value, old_value):
|
|
434
|
-
updated[key] = cast(VDOMPropValue, json_value)
|
|
420
|
+
# No normalization needed - value passes through unchanged
|
|
421
|
+
if key not in previous or not values_equal(value, old_value):
|
|
422
|
+
updated[key] = cast(VDOMPropValue, value)
|
|
435
423
|
|
|
436
424
|
for key in removed_keys:
|
|
437
425
|
old_value = previous.get(key)
|
|
@@ -488,31 +476,6 @@ def registry_ref(expr: Expr) -> RegistryRef | None:
|
|
|
488
476
|
return None
|
|
489
477
|
|
|
490
478
|
|
|
491
|
-
def is_json_primitive(value: Any) -> bool:
|
|
492
|
-
return value is None or isinstance(value, (str, int, float, bool))
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
def coerce_json(value: Any, path: str) -> Any:
|
|
496
|
-
"""Convert Python value to JSON-compatible structure.
|
|
497
|
-
|
|
498
|
-
Performs runtime conversions:
|
|
499
|
-
- tuple → list
|
|
500
|
-
- validates dict keys are strings
|
|
501
|
-
"""
|
|
502
|
-
if is_json_primitive(value):
|
|
503
|
-
return value
|
|
504
|
-
if isinstance(value, (list, tuple)):
|
|
505
|
-
return [coerce_json(v, path) for v in value]
|
|
506
|
-
if isinstance(value, dict):
|
|
507
|
-
out: dict[str, Any] = {}
|
|
508
|
-
for k, v in value.items():
|
|
509
|
-
if not isinstance(k, str):
|
|
510
|
-
raise TypeError(f"Non-string prop key at {path}: {k!r}")
|
|
511
|
-
out[k] = coerce_json(v, path)
|
|
512
|
-
return out
|
|
513
|
-
raise TypeError(f"Unsupported JSON value at {path}: {type(value).__name__}")
|
|
514
|
-
|
|
515
|
-
|
|
516
479
|
def prop_requires_eval(value: PropValue) -> bool:
|
|
517
480
|
if isinstance(value, Value):
|
|
518
481
|
return False
|
|
@@ -611,7 +574,7 @@ def unmount_element(element: Node) -> None:
|
|
|
611
574
|
return
|
|
612
575
|
|
|
613
576
|
if isinstance(element, Element):
|
|
614
|
-
props = element.
|
|
577
|
+
props = element.props_dict()
|
|
615
578
|
for value in props.values():
|
|
616
579
|
if isinstance(value, (Element, PulseNode)):
|
|
617
580
|
unmount_element(value)
|
pulse/serializer.py
CHANGED
|
@@ -86,8 +86,11 @@ def serialize(data: Any) -> Serialized:
|
|
|
86
86
|
if isinstance(value, dict):
|
|
87
87
|
result_dict: dict[str, PlainJSON] = {}
|
|
88
88
|
for key, entry in value.items():
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
if not isinstance(key, str):
|
|
90
|
+
raise TypeError(
|
|
91
|
+
f"Dict keys must be strings, got {type(key).__name__}: {key!r}" # pyright: ignore[reportUnknownArgumentType]
|
|
92
|
+
)
|
|
93
|
+
result_dict[key] = process(entry)
|
|
91
94
|
return result_dict
|
|
92
95
|
|
|
93
96
|
if isinstance(value, (list, tuple)):
|
pulse/transpiler/builtins.py
CHANGED
|
@@ -21,7 +21,6 @@ from pulse.transpiler.nodes import (
|
|
|
21
21
|
Literal,
|
|
22
22
|
Member,
|
|
23
23
|
New,
|
|
24
|
-
Object,
|
|
25
24
|
Spread,
|
|
26
25
|
Subscript,
|
|
27
26
|
Template,
|
|
@@ -173,25 +172,6 @@ def emit_dict(*args: Any, ctx: Transpiler) -> Expr:
|
|
|
173
172
|
raise TranspileError("dict() expects at most one argument")
|
|
174
173
|
|
|
175
174
|
|
|
176
|
-
@transformer("obj")
|
|
177
|
-
def obj(*args: Any, ctx: Transpiler, **kwargs: Any) -> Expr:
|
|
178
|
-
"""obj(key=value, ...) -> { key: value, ... }
|
|
179
|
-
|
|
180
|
-
Creates a plain JavaScript object literal.
|
|
181
|
-
Use this instead of dict() when you need a plain object (e.g., for React props).
|
|
182
|
-
|
|
183
|
-
Example:
|
|
184
|
-
style=obj(display="block", color="red")
|
|
185
|
-
-> style={{ display: "block", color: "red" }}
|
|
186
|
-
"""
|
|
187
|
-
if args:
|
|
188
|
-
raise TranspileError("obj() only accepts keyword arguments")
|
|
189
|
-
props: list[tuple[str, Expr]] = []
|
|
190
|
-
for key, value in kwargs.items():
|
|
191
|
-
props.append((key, ctx.emit_expr(value)))
|
|
192
|
-
return Object(props)
|
|
193
|
-
|
|
194
|
-
|
|
195
175
|
@transformer("filter")
|
|
196
176
|
def emit_filter(*args: Any, ctx: Transpiler) -> Expr:
|
|
197
177
|
"""filter(func, iterable) -> iterable.filter(func)"""
|
pulse/transpiler/errors.py
CHANGED
|
@@ -13,6 +13,7 @@ class TranspileError(Exception):
|
|
|
13
13
|
source: str | None
|
|
14
14
|
filename: str | None
|
|
15
15
|
func_name: str | None
|
|
16
|
+
source_start_line: int | None
|
|
16
17
|
|
|
17
18
|
def __init__(
|
|
18
19
|
self,
|
|
@@ -22,12 +23,14 @@ class TranspileError(Exception):
|
|
|
22
23
|
source: str | None = None,
|
|
23
24
|
filename: str | None = None,
|
|
24
25
|
func_name: str | None = None,
|
|
26
|
+
source_start_line: int | None = None,
|
|
25
27
|
) -> None:
|
|
26
28
|
self.message = message
|
|
27
29
|
self.node = node
|
|
28
30
|
self.source = source
|
|
29
31
|
self.filename = filename
|
|
30
32
|
self.func_name = func_name
|
|
33
|
+
self.source_start_line = source_start_line
|
|
31
34
|
super().__init__(self._format_message())
|
|
32
35
|
|
|
33
36
|
def _format_message(self) -> str:
|
|
@@ -38,25 +41,38 @@ class TranspileError(Exception):
|
|
|
38
41
|
loc_parts: list[str] = []
|
|
39
42
|
if self.func_name:
|
|
40
43
|
loc_parts.append(f"in {self.func_name}")
|
|
44
|
+
display_lineno = self.node.lineno
|
|
45
|
+
if self.source_start_line is not None:
|
|
46
|
+
display_lineno = self.source_start_line + self.node.lineno - 1
|
|
41
47
|
if self.filename:
|
|
42
|
-
loc_parts.append(f"at {self.filename}:{
|
|
48
|
+
loc_parts.append(f"at {self.filename}:{display_lineno}")
|
|
43
49
|
else:
|
|
44
|
-
loc_parts.append(f"at line {
|
|
45
|
-
if hasattr(self.node, "col_offset"):
|
|
46
|
-
loc_parts[-1] += f":{self.node.col_offset}"
|
|
47
|
-
|
|
48
|
-
if loc_parts:
|
|
49
|
-
parts.append(" ".join(loc_parts))
|
|
50
|
+
loc_parts.append(f"at line {display_lineno}")
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
display_line = None
|
|
53
|
+
display_col = None
|
|
52
54
|
if self.source:
|
|
53
55
|
lines = self.source.splitlines()
|
|
54
56
|
if 0 < self.node.lineno <= len(lines):
|
|
55
57
|
source_line = lines[self.node.lineno - 1]
|
|
56
|
-
|
|
57
|
-
# Add caret pointing to column
|
|
58
|
+
display_line = source_line.expandtabs(4)
|
|
58
59
|
if hasattr(self.node, "col_offset"):
|
|
59
|
-
|
|
60
|
+
prefix = source_line[: self.node.col_offset]
|
|
61
|
+
display_col = len(prefix.expandtabs(4))
|
|
62
|
+
|
|
63
|
+
if hasattr(self.node, "col_offset"):
|
|
64
|
+
col = display_col if display_col is not None else self.node.col_offset
|
|
65
|
+
loc_parts[-1] += f":{col}"
|
|
66
|
+
|
|
67
|
+
if loc_parts:
|
|
68
|
+
parts.append(" ".join(loc_parts))
|
|
69
|
+
|
|
70
|
+
# Show the source line if available
|
|
71
|
+
if display_line is not None:
|
|
72
|
+
parts.append(f"\n {display_line}")
|
|
73
|
+
# Add caret pointing to column
|
|
74
|
+
if display_col is not None:
|
|
75
|
+
parts.append(" " + " " * display_col + "^")
|
|
60
76
|
|
|
61
77
|
return "\n".join(parts) if len(parts) > 1 else parts[0]
|
|
62
78
|
|
|
@@ -67,6 +83,7 @@ class TranspileError(Exception):
|
|
|
67
83
|
source: str | None = None,
|
|
68
84
|
filename: str | None = None,
|
|
69
85
|
func_name: str | None = None,
|
|
86
|
+
source_start_line: int | None = None,
|
|
70
87
|
) -> TranspileError:
|
|
71
88
|
"""Return a new TranspileError with additional context."""
|
|
72
89
|
return TranspileError(
|
|
@@ -75,4 +92,5 @@ class TranspileError(Exception):
|
|
|
75
92
|
source=source or self.source,
|
|
76
93
|
filename=filename or self.filename,
|
|
77
94
|
func_name=func_name or self.func_name,
|
|
95
|
+
source_start_line=source_start_line or self.source_start_line,
|
|
78
96
|
)
|
pulse/transpiler/function.py
CHANGED
|
@@ -142,6 +142,10 @@ def _transpile_function_body(
|
|
|
142
142
|
# Get and parse source
|
|
143
143
|
src = getsourcecode(fn)
|
|
144
144
|
src = textwrap.dedent(src)
|
|
145
|
+
try:
|
|
146
|
+
source_start_line = inspect.getsourcelines(fn)[1]
|
|
147
|
+
except (OSError, TypeError):
|
|
148
|
+
source_start_line = None
|
|
145
149
|
module = ast.parse(src)
|
|
146
150
|
|
|
147
151
|
# Find the function definition
|
|
@@ -169,6 +173,7 @@ def _transpile_function_body(
|
|
|
169
173
|
source=src,
|
|
170
174
|
filename=filename,
|
|
171
175
|
func_name=fn.__name__,
|
|
176
|
+
source_start_line=source_start_line,
|
|
172
177
|
) from None
|
|
173
178
|
raise
|
|
174
179
|
|
|
@@ -347,10 +352,10 @@ class JsxFunction(Expr, Generic[P, R]):
|
|
|
347
352
|
|
|
348
353
|
@override
|
|
349
354
|
def transpile_call(
|
|
350
|
-
self, args: list[ast.expr],
|
|
355
|
+
self, args: list[ast.expr], keywords: list[ast.keyword], ctx: Transpiler
|
|
351
356
|
) -> Expr:
|
|
352
357
|
# delegate JSX element building to the generic Jsx wrapper
|
|
353
|
-
return Jsx(self).transpile_call(args,
|
|
358
|
+
return Jsx(self).transpile_call(args, keywords, ctx)
|
|
354
359
|
|
|
355
360
|
@override
|
|
356
361
|
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
@@ -367,18 +372,40 @@ def analyze_code_object(
|
|
|
367
372
|
- effective_globals: dict mapping names to their values (includes closure vars)
|
|
368
373
|
- all_names: set of all names referenced in the code (including nested functions)
|
|
369
374
|
"""
|
|
375
|
+
import dis
|
|
376
|
+
|
|
370
377
|
code = fn.__code__
|
|
371
378
|
|
|
372
379
|
# Collect all names from code object and nested functions in one pass
|
|
373
380
|
seen_codes: set[int] = set()
|
|
374
381
|
all_names: set[str] = set()
|
|
375
382
|
|
|
383
|
+
# Opcodes that load names from globals/locals (not attributes)
|
|
384
|
+
GLOBAL_LOAD_OPS = frozenset(
|
|
385
|
+
{
|
|
386
|
+
"LOAD_GLOBAL",
|
|
387
|
+
"LOAD_NAME",
|
|
388
|
+
"STORE_GLOBAL",
|
|
389
|
+
"STORE_NAME",
|
|
390
|
+
"DELETE_GLOBAL",
|
|
391
|
+
"DELETE_NAME",
|
|
392
|
+
}
|
|
393
|
+
)
|
|
394
|
+
|
|
376
395
|
def walk_code(c: pytypes.CodeType) -> None:
|
|
377
396
|
if id(c) in seen_codes:
|
|
378
397
|
return
|
|
379
398
|
seen_codes.add(id(c))
|
|
380
|
-
|
|
399
|
+
|
|
400
|
+
# Only collect names that are actually loaded as globals, not attributes
|
|
401
|
+
# co_names contains both global names and attribute names, so we need
|
|
402
|
+
# to check the bytecode to distinguish them
|
|
403
|
+
for instr in dis.get_instructions(c):
|
|
404
|
+
if instr.opname in GLOBAL_LOAD_OPS and instr.argval is not None:
|
|
405
|
+
all_names.add(instr.argval)
|
|
406
|
+
|
|
381
407
|
all_names.update(c.co_freevars) # Include closure variables
|
|
408
|
+
|
|
382
409
|
for const in c.co_consts:
|
|
383
410
|
if isinstance(const, pytypes.CodeType):
|
|
384
411
|
walk_code(const)
|
pulse/transpiler/js_module.py
CHANGED
|
@@ -63,10 +63,10 @@ class Class(Expr):
|
|
|
63
63
|
def transpile_call(
|
|
64
64
|
self,
|
|
65
65
|
args: list[ast.expr],
|
|
66
|
-
|
|
66
|
+
keywords: list[ast.keyword],
|
|
67
67
|
ctx: Transpiler,
|
|
68
68
|
) -> Expr:
|
|
69
|
-
if
|
|
69
|
+
if keywords:
|
|
70
70
|
raise TranspileError("Keyword arguments not supported in constructor call")
|
|
71
71
|
return New(self.ctor, [ctx.emit_expr(a) for a in args])
|
|
72
72
|
|
|
@@ -114,7 +114,7 @@ class JsModule(Expr):
|
|
|
114
114
|
def transpile_call(
|
|
115
115
|
self,
|
|
116
116
|
args: list[ast.expr],
|
|
117
|
-
|
|
117
|
+
keywords: list[ast.keyword],
|
|
118
118
|
ctx: Transpiler,
|
|
119
119
|
) -> Expr:
|
|
120
120
|
label = self.py_name or self.name or "JsModule"
|
|
@@ -162,9 +162,9 @@ class JsModule(Expr):
|
|
|
162
162
|
def get_value(self, name: str) -> Member | Class | Identifier | Import:
|
|
163
163
|
"""Get a member of this module as an expression.
|
|
164
164
|
|
|
165
|
-
For global-identifier modules (name=None): returns Identifier
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
For global-identifier modules (name=None): returns Identifier directly (e.g., Set -> Set)
|
|
166
|
+
These are "virtual" Python modules exposing JS globals - no actual JS module exists.
|
|
167
|
+
For builtin namespaces (src=None): returns Member (e.g., Math.floor)
|
|
168
168
|
For external modules with "member" style: returns Member (e.g., React.useState)
|
|
169
169
|
For external modules with "named_import" style: returns a named Import
|
|
170
170
|
|
|
@@ -176,14 +176,11 @@ class JsModule(Expr):
|
|
|
176
176
|
|
|
177
177
|
expr: Member | Identifier | Import
|
|
178
178
|
if self.name is None:
|
|
179
|
-
#
|
|
179
|
+
# Virtual module exposing JS globals - members are just identifiers
|
|
180
180
|
expr = Identifier(js_name)
|
|
181
181
|
elif self.src is None:
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
expr = Identifier(js_name)
|
|
185
|
-
else:
|
|
186
|
-
expr = Member(Identifier(self.name), js_name)
|
|
182
|
+
# Builtin namespace (Math, console, etc.) - members accessed as properties
|
|
183
|
+
expr = Member(Identifier(self.name), js_name)
|
|
187
184
|
elif self.values == "named_import":
|
|
188
185
|
expr = Import(js_name, self.src)
|
|
189
186
|
else:
|
|
@@ -16,7 +16,16 @@ import ast
|
|
|
16
16
|
from dataclasses import dataclass
|
|
17
17
|
from typing import Any, final, override
|
|
18
18
|
|
|
19
|
-
from pulse.
|
|
19
|
+
from pulse.components.for_ import emit_for
|
|
20
|
+
from pulse.transpiler.nodes import (
|
|
21
|
+
Element,
|
|
22
|
+
Expr,
|
|
23
|
+
Literal,
|
|
24
|
+
Node,
|
|
25
|
+
Prop,
|
|
26
|
+
Spread,
|
|
27
|
+
spread_dict,
|
|
28
|
+
)
|
|
20
29
|
from pulse.transpiler.py_module import PyModule
|
|
21
30
|
from pulse.transpiler.transpiler import Transpiler
|
|
22
31
|
from pulse.transpiler.vdom import VDOMNode
|
|
@@ -44,30 +53,38 @@ class TagExpr(Expr):
|
|
|
44
53
|
def transpile_call(
|
|
45
54
|
self,
|
|
46
55
|
args: list[ast.expr],
|
|
47
|
-
|
|
56
|
+
keywords: list[ast.keyword],
|
|
48
57
|
ctx: Transpiler,
|
|
49
58
|
) -> Expr:
|
|
50
|
-
"""Handle tag calls: positional args are children, kwargs are props.
|
|
59
|
+
"""Handle tag calls: positional args are children, kwargs are props.
|
|
60
|
+
|
|
61
|
+
Spread (**expr) is supported for prop spreading.
|
|
62
|
+
"""
|
|
51
63
|
# Build children from positional args
|
|
52
64
|
children: list[Node] = []
|
|
53
65
|
for a in args:
|
|
54
66
|
children.append(ctx.emit_expr(a))
|
|
55
67
|
|
|
56
68
|
# Build props from kwargs
|
|
57
|
-
props:
|
|
69
|
+
props: list[tuple[str, Prop] | Spread] = []
|
|
58
70
|
key: str | Expr | None = None
|
|
59
|
-
for
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if isinstance(prop_value, Literal) and isinstance(
|
|
64
|
-
prop_value.value, str
|
|
65
|
-
):
|
|
66
|
-
key = prop_value.value # Optimize string literals
|
|
67
|
-
else:
|
|
68
|
-
key = prop_value # Keep as expression
|
|
71
|
+
for kw in keywords:
|
|
72
|
+
if kw.arg is None:
|
|
73
|
+
# **spread syntax
|
|
74
|
+
props.append(spread_dict(ctx.emit_expr(kw.value)))
|
|
69
75
|
else:
|
|
70
|
-
|
|
76
|
+
k = kw.arg
|
|
77
|
+
prop_value = ctx.emit_expr(kw.value)
|
|
78
|
+
if k == "key":
|
|
79
|
+
# Accept any expression as key for transpilation
|
|
80
|
+
if isinstance(prop_value, Literal) and isinstance(
|
|
81
|
+
prop_value.value, str
|
|
82
|
+
):
|
|
83
|
+
key = prop_value.value # Optimize string literals
|
|
84
|
+
else:
|
|
85
|
+
key = prop_value # Keep as expression
|
|
86
|
+
else:
|
|
87
|
+
props.append((k, prop_value))
|
|
71
88
|
|
|
72
89
|
return Element(
|
|
73
90
|
tag=self.tag,
|
|
@@ -229,3 +246,6 @@ class PulseTags(PyModule):
|
|
|
229
246
|
|
|
230
247
|
# React fragment
|
|
231
248
|
fragment = TagExpr("")
|
|
249
|
+
|
|
250
|
+
# For component - maps to array.map()
|
|
251
|
+
For = emit_for
|