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/transpiler/function.py
CHANGED
|
@@ -12,6 +12,7 @@ import textwrap
|
|
|
12
12
|
import types as pytypes
|
|
13
13
|
from collections.abc import Callable
|
|
14
14
|
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
15
16
|
from typing import (
|
|
16
17
|
Any,
|
|
17
18
|
Generic,
|
|
@@ -57,11 +58,13 @@ CONSTANT_REGISTRY: dict[int, "Constant"] = {}
|
|
|
57
58
|
|
|
58
59
|
def clear_function_cache() -> None:
|
|
59
60
|
"""Clear function/constant/ref caches and reset the shared ID counters."""
|
|
61
|
+
from pulse.transpiler.assets import clear_asset_registry
|
|
60
62
|
from pulse.transpiler.imports import clear_import_registry
|
|
61
63
|
|
|
62
64
|
FUNCTION_CACHE.clear()
|
|
63
65
|
CONSTANT_REGISTRY.clear()
|
|
64
66
|
clear_import_registry()
|
|
67
|
+
clear_asset_registry()
|
|
65
68
|
reset_id_counter()
|
|
66
69
|
|
|
67
70
|
|
|
@@ -142,6 +145,10 @@ def _transpile_function_body(
|
|
|
142
145
|
# Get and parse source
|
|
143
146
|
src = getsourcecode(fn)
|
|
144
147
|
src = textwrap.dedent(src)
|
|
148
|
+
try:
|
|
149
|
+
source_start_line = inspect.getsourcelines(fn)[1]
|
|
150
|
+
except (OSError, TypeError):
|
|
151
|
+
source_start_line = None
|
|
145
152
|
module = ast.parse(src)
|
|
146
153
|
|
|
147
154
|
# Find the function definition
|
|
@@ -152,7 +159,7 @@ def _transpile_function_body(
|
|
|
152
159
|
raise TranspileError("No function definition found in source")
|
|
153
160
|
fndef = fndefs[-1]
|
|
154
161
|
|
|
155
|
-
# Get filename for error messages
|
|
162
|
+
# Get filename for error messages and source file resolution
|
|
156
163
|
try:
|
|
157
164
|
filename = inspect.getfile(fn)
|
|
158
165
|
except (TypeError, OSError):
|
|
@@ -160,7 +167,8 @@ def _transpile_function_body(
|
|
|
160
167
|
|
|
161
168
|
# Transpile with source context for errors
|
|
162
169
|
try:
|
|
163
|
-
|
|
170
|
+
source_file = Path(filename) if filename else None
|
|
171
|
+
transpiler = Transpiler(fndef, deps, jsx=jsx, source_file=source_file)
|
|
164
172
|
result = transpiler.transpile()
|
|
165
173
|
except TranspileError as e:
|
|
166
174
|
# Re-raise with source context if not already present
|
|
@@ -169,6 +177,7 @@ def _transpile_function_body(
|
|
|
169
177
|
source=src,
|
|
170
178
|
filename=filename,
|
|
171
179
|
func_name=fn.__name__,
|
|
180
|
+
source_start_line=source_start_line,
|
|
172
181
|
) from None
|
|
173
182
|
raise
|
|
174
183
|
|
|
@@ -347,10 +356,10 @@ class JsxFunction(Expr, Generic[P, R]):
|
|
|
347
356
|
|
|
348
357
|
@override
|
|
349
358
|
def transpile_call(
|
|
350
|
-
self, args: list[ast.expr],
|
|
359
|
+
self, args: list[ast.expr], keywords: list[ast.keyword], ctx: Transpiler
|
|
351
360
|
) -> Expr:
|
|
352
361
|
# delegate JSX element building to the generic Jsx wrapper
|
|
353
|
-
return Jsx(self).transpile_call(args,
|
|
362
|
+
return Jsx(self).transpile_call(args, keywords, ctx)
|
|
354
363
|
|
|
355
364
|
@override
|
|
356
365
|
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
@@ -367,18 +376,40 @@ def analyze_code_object(
|
|
|
367
376
|
- effective_globals: dict mapping names to their values (includes closure vars)
|
|
368
377
|
- all_names: set of all names referenced in the code (including nested functions)
|
|
369
378
|
"""
|
|
379
|
+
import dis
|
|
380
|
+
|
|
370
381
|
code = fn.__code__
|
|
371
382
|
|
|
372
383
|
# Collect all names from code object and nested functions in one pass
|
|
373
384
|
seen_codes: set[int] = set()
|
|
374
385
|
all_names: set[str] = set()
|
|
375
386
|
|
|
387
|
+
# Opcodes that load names from globals/locals (not attributes)
|
|
388
|
+
GLOBAL_LOAD_OPS = frozenset(
|
|
389
|
+
{
|
|
390
|
+
"LOAD_GLOBAL",
|
|
391
|
+
"LOAD_NAME",
|
|
392
|
+
"STORE_GLOBAL",
|
|
393
|
+
"STORE_NAME",
|
|
394
|
+
"DELETE_GLOBAL",
|
|
395
|
+
"DELETE_NAME",
|
|
396
|
+
}
|
|
397
|
+
)
|
|
398
|
+
|
|
376
399
|
def walk_code(c: pytypes.CodeType) -> None:
|
|
377
400
|
if id(c) in seen_codes:
|
|
378
401
|
return
|
|
379
402
|
seen_codes.add(id(c))
|
|
380
|
-
|
|
403
|
+
|
|
404
|
+
# Only collect names that are actually loaded as globals, not attributes
|
|
405
|
+
# co_names contains both global names and attribute names, so we need
|
|
406
|
+
# to check the bytecode to distinguish them
|
|
407
|
+
for instr in dis.get_instructions(c):
|
|
408
|
+
if instr.opname in GLOBAL_LOAD_OPS and instr.argval is not None:
|
|
409
|
+
all_names.add(instr.argval)
|
|
410
|
+
|
|
381
411
|
all_names.update(c.co_freevars) # Include closure variables
|
|
412
|
+
|
|
382
413
|
for const in c.co_consts:
|
|
383
414
|
if isinstance(const, pytypes.CodeType):
|
|
384
415
|
walk_code(const)
|
pulse/transpiler/imports.py
CHANGED
|
@@ -15,6 +15,8 @@ from typing import (
|
|
|
15
15
|
from typing import Literal as Lit
|
|
16
16
|
|
|
17
17
|
from pulse.cli.packages import pick_more_specific
|
|
18
|
+
from pulse.transpiler.assets import LocalAsset, register_local_asset
|
|
19
|
+
from pulse.transpiler.errors import TranspileError
|
|
18
20
|
from pulse.transpiler.id import next_id
|
|
19
21
|
from pulse.transpiler.nodes import Call, Expr, to_js_identifier
|
|
20
22
|
from pulse.transpiler.vdom import VDOMNode
|
|
@@ -122,10 +124,10 @@ def resolve_local_path(path: str, caller: Path | None = None) -> Path | None:
|
|
|
122
124
|
return base_path
|
|
123
125
|
|
|
124
126
|
|
|
125
|
-
# Registry key depends on kind:
|
|
126
|
-
# - named: (name, src, "named")
|
|
127
|
-
# - default/namespace/side_effect: ("", src, kind) - only one per src
|
|
128
|
-
_ImportKey: TypeAlias = tuple[str, str, str]
|
|
127
|
+
# Registry key depends on kind and lazy:
|
|
128
|
+
# - named: (name, src, "named", lazy)
|
|
129
|
+
# - default/namespace/side_effect: ("", src, kind, lazy) - only one per src
|
|
130
|
+
_ImportKey: TypeAlias = tuple[str, str, str, bool]
|
|
129
131
|
_IMPORT_REGISTRY: dict[_ImportKey, "Import"] = {}
|
|
130
132
|
|
|
131
133
|
|
|
@@ -171,17 +173,22 @@ class Import(Expr):
|
|
|
171
173
|
Import("", "./styles.css", kind="side_effect") # Local CSS
|
|
172
174
|
utils = Import("utils", "./utils", kind="namespace") # Local JS (resolves extension)
|
|
173
175
|
config = Import("config", "/absolute/path/config", kind="default") # Absolute path
|
|
176
|
+
|
|
177
|
+
# Lazy import (generates factory for code-splitting)
|
|
178
|
+
Chart = Import("Chart", "./Chart", kind="default", lazy=True)
|
|
179
|
+
# Generates: const Chart_1 = () => import("./Chart")
|
|
174
180
|
"""
|
|
175
181
|
|
|
176
182
|
name: str
|
|
177
183
|
src: str
|
|
178
184
|
kind: ImportKind
|
|
179
185
|
is_type: bool
|
|
186
|
+
lazy: bool
|
|
180
187
|
before: tuple[str, ...]
|
|
181
188
|
id: str
|
|
182
189
|
version: str | None = None
|
|
183
|
-
|
|
184
|
-
None #
|
|
190
|
+
asset: LocalAsset | None = (
|
|
191
|
+
None # Registered local asset (for copying during codegen)
|
|
185
192
|
)
|
|
186
193
|
|
|
187
194
|
def __init__(
|
|
@@ -191,12 +198,17 @@ class Import(Expr):
|
|
|
191
198
|
*,
|
|
192
199
|
kind: ImportKind | None = None,
|
|
193
200
|
is_type: bool = False,
|
|
201
|
+
lazy: bool = False,
|
|
194
202
|
version: str | None = None,
|
|
195
203
|
before: tuple[str, ...] | list[str] = (),
|
|
196
204
|
_caller_depth: int = 2,
|
|
197
205
|
) -> None:
|
|
206
|
+
# Validate: lazy imports cannot be type-only
|
|
207
|
+
if lazy and is_type:
|
|
208
|
+
raise TranspileError("Import cannot be both lazy and type-only")
|
|
209
|
+
|
|
198
210
|
# Auto-resolve local paths (relative or absolute) to actual files
|
|
199
|
-
|
|
211
|
+
asset: LocalAsset | None = None
|
|
200
212
|
import_src = src
|
|
201
213
|
|
|
202
214
|
if is_local_path(src):
|
|
@@ -204,7 +216,8 @@ class Import(Expr):
|
|
|
204
216
|
caller = caller_file(depth=_caller_depth) if is_relative_path(src) else None
|
|
205
217
|
resolved = resolve_local_path(src, caller)
|
|
206
218
|
if resolved is not None:
|
|
207
|
-
|
|
219
|
+
# Register with unified asset registry
|
|
220
|
+
asset = register_local_asset(resolved)
|
|
208
221
|
import_src = str(resolved)
|
|
209
222
|
|
|
210
223
|
# Default kind to "named" if not specified
|
|
@@ -215,16 +228,16 @@ class Import(Expr):
|
|
|
215
228
|
self.src = import_src
|
|
216
229
|
self.kind = kind
|
|
217
230
|
self.version = version
|
|
218
|
-
self.
|
|
231
|
+
self.lazy = lazy
|
|
232
|
+
self.asset = asset
|
|
219
233
|
|
|
220
234
|
before_tuple = tuple(before) if isinstance(before, list) else before
|
|
221
235
|
|
|
222
|
-
# Dedupe key:
|
|
223
|
-
# For default/namespace/side_effect, only one per src: ("", src, kind)
|
|
236
|
+
# Dedupe key: includes lazy flag to keep lazy and eager imports separate
|
|
224
237
|
if kind == "named":
|
|
225
|
-
key: _ImportKey = (name, import_src, "named")
|
|
238
|
+
key: _ImportKey = (name, import_src, "named", lazy)
|
|
226
239
|
else:
|
|
227
|
-
key = ("", import_src, kind)
|
|
240
|
+
key = ("", import_src, kind, lazy)
|
|
228
241
|
|
|
229
242
|
if key in _IMPORT_REGISTRY:
|
|
230
243
|
existing = _IMPORT_REGISTRY[key]
|
|
@@ -260,8 +273,13 @@ class Import(Expr):
|
|
|
260
273
|
|
|
261
274
|
@property
|
|
262
275
|
def is_local(self) -> bool:
|
|
263
|
-
"""Check if this is a local file import (has
|
|
264
|
-
return self.
|
|
276
|
+
"""Check if this is a local file import (has registered asset)."""
|
|
277
|
+
return self.asset is not None
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def is_lazy(self) -> bool:
|
|
281
|
+
"""Check if this is a lazy import."""
|
|
282
|
+
return self.lazy
|
|
265
283
|
|
|
266
284
|
# Convenience properties for kind checks
|
|
267
285
|
@property
|
|
@@ -276,18 +294,6 @@ class Import(Expr):
|
|
|
276
294
|
def is_side_effect(self) -> bool:
|
|
277
295
|
return self.kind == "side_effect"
|
|
278
296
|
|
|
279
|
-
def asset_filename(self) -> str:
|
|
280
|
-
"""Get the filename for this import when copied to assets folder.
|
|
281
|
-
|
|
282
|
-
Uses the import ID for uniqueness and preserves the original extension.
|
|
283
|
-
For JS-like files, uses the resolved extension.
|
|
284
|
-
"""
|
|
285
|
-
if self.source_path is None:
|
|
286
|
-
raise ValueError("Cannot get asset filename for non-local import")
|
|
287
|
-
stem = self.source_path.stem
|
|
288
|
-
suffix = self.source_path.suffix
|
|
289
|
-
return f"{stem}_{self.id}{suffix}"
|
|
290
|
-
|
|
291
297
|
# -------------------------------------------------------------------------
|
|
292
298
|
# Expr.emit: outputs the unique identifier
|
|
293
299
|
# -------------------------------------------------------------------------
|
pulse/transpiler/js_module.py
CHANGED
|
@@ -10,13 +10,15 @@ import ast
|
|
|
10
10
|
import inspect
|
|
11
11
|
import sys
|
|
12
12
|
from dataclasses import dataclass, field
|
|
13
|
-
from typing import Literal, override
|
|
13
|
+
from typing import Any, Literal, override
|
|
14
14
|
|
|
15
|
+
from pulse.code_analysis import is_stub_function
|
|
15
16
|
from pulse.transpiler.errors import TranspileError
|
|
16
17
|
from pulse.transpiler.imports import Import
|
|
17
18
|
from pulse.transpiler.nodes import (
|
|
18
19
|
Expr,
|
|
19
20
|
Identifier,
|
|
21
|
+
Jsx,
|
|
20
22
|
Member,
|
|
21
23
|
New,
|
|
22
24
|
)
|
|
@@ -63,10 +65,10 @@ class Class(Expr):
|
|
|
63
65
|
def transpile_call(
|
|
64
66
|
self,
|
|
65
67
|
args: list[ast.expr],
|
|
66
|
-
|
|
68
|
+
keywords: list[ast.keyword],
|
|
67
69
|
ctx: Transpiler,
|
|
68
70
|
) -> Expr:
|
|
69
|
-
if
|
|
71
|
+
if keywords:
|
|
70
72
|
raise TranspileError("Keyword arguments not supported in constructor call")
|
|
71
73
|
return New(self.ctor, [ctx.emit_expr(a) for a in args])
|
|
72
74
|
|
|
@@ -91,6 +93,7 @@ class JsModule(Expr):
|
|
|
91
93
|
- "member": Access as property (e.g., React.useState)
|
|
92
94
|
- "named_import": Each attribute is a named import (e.g., import { useState } from "react")
|
|
93
95
|
constructors: Set of names that are constructors (emit with 'new')
|
|
96
|
+
components: Set of names that are components (wrapped with Jsx)
|
|
94
97
|
"""
|
|
95
98
|
|
|
96
99
|
name: str | None
|
|
@@ -99,6 +102,7 @@ class JsModule(Expr):
|
|
|
99
102
|
kind: Literal["default", "namespace"] = "namespace"
|
|
100
103
|
values: Literal["member", "named_import"] = "named_import"
|
|
101
104
|
constructors: frozenset[str] = field(default_factory=frozenset)
|
|
105
|
+
components: frozenset[str] = field(default_factory=frozenset)
|
|
102
106
|
|
|
103
107
|
@override
|
|
104
108
|
def emit(self, out: list[str]) -> None:
|
|
@@ -114,7 +118,7 @@ class JsModule(Expr):
|
|
|
114
118
|
def transpile_call(
|
|
115
119
|
self,
|
|
116
120
|
args: list[ast.expr],
|
|
117
|
-
|
|
121
|
+
keywords: list[ast.keyword],
|
|
118
122
|
ctx: Transpiler,
|
|
119
123
|
) -> Expr:
|
|
120
124
|
label = self.py_name or self.name or "JsModule"
|
|
@@ -159,16 +163,17 @@ class JsModule(Expr):
|
|
|
159
163
|
import_kind = "default" if self.kind == "default" else "named"
|
|
160
164
|
return Import(self.name, self.src, kind=import_kind)
|
|
161
165
|
|
|
162
|
-
def get_value(self, name: str) -> Member | Class | Identifier | Import:
|
|
166
|
+
def get_value(self, name: str) -> Member | Class | Jsx | Identifier | Import:
|
|
163
167
|
"""Get a member of this module as an expression.
|
|
164
168
|
|
|
165
|
-
For global-identifier modules (name=None): returns Identifier
|
|
166
|
-
|
|
167
|
-
|
|
169
|
+
For global-identifier modules (name=None): returns Identifier directly (e.g., Set -> Set)
|
|
170
|
+
These are "virtual" Python modules exposing JS globals - no actual JS module exists.
|
|
171
|
+
For builtin namespaces (src=None): returns Member (e.g., Math.floor)
|
|
168
172
|
For external modules with "member" style: returns Member (e.g., React.useState)
|
|
169
173
|
For external modules with "named_import" style: returns a named Import
|
|
170
174
|
|
|
171
175
|
If name is in constructors, returns a Class that emits `new ...(...)`.
|
|
176
|
+
If name is in components, returns a Jsx-wrapped expression.
|
|
172
177
|
"""
|
|
173
178
|
# Convention: trailing underscore escapes Python keywords (e.g. from_ -> from, is_ -> is).
|
|
174
179
|
# We keep the original `name` for constructor detection, but emit the JS name.
|
|
@@ -176,14 +181,11 @@ class JsModule(Expr):
|
|
|
176
181
|
|
|
177
182
|
expr: Member | Identifier | Import
|
|
178
183
|
if self.name is None:
|
|
179
|
-
#
|
|
184
|
+
# Virtual module exposing JS globals - members are just identifiers
|
|
180
185
|
expr = Identifier(js_name)
|
|
181
186
|
elif self.src is None:
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
expr = Identifier(js_name)
|
|
185
|
-
else:
|
|
186
|
-
expr = Member(Identifier(self.name), js_name)
|
|
187
|
+
# Builtin namespace (Math, console, etc.) - members accessed as properties
|
|
188
|
+
expr = Member(Identifier(self.name), js_name)
|
|
187
189
|
elif self.values == "named_import":
|
|
188
190
|
expr = Import(js_name, self.src)
|
|
189
191
|
else:
|
|
@@ -191,6 +193,8 @@ class JsModule(Expr):
|
|
|
191
193
|
|
|
192
194
|
if name in self.constructors:
|
|
193
195
|
return Class(expr, name=name)
|
|
196
|
+
if name in self.components:
|
|
197
|
+
return Jsx(expr)
|
|
194
198
|
return expr
|
|
195
199
|
|
|
196
200
|
@override
|
|
@@ -229,28 +233,74 @@ class JsModule(Expr):
|
|
|
229
233
|
Example (inside pulse/js/set.py):
|
|
230
234
|
JsModule.register(name=None) # global identifier builtin (no module binding)
|
|
231
235
|
"""
|
|
236
|
+
from pulse.component import Component # Import here to avoid cycles
|
|
237
|
+
|
|
232
238
|
# Get the calling module from the stack frame
|
|
233
239
|
frame = inspect.currentframe()
|
|
234
240
|
assert frame is not None and frame.f_back is not None
|
|
235
241
|
module_name = frame.f_back.f_globals["__name__"]
|
|
236
242
|
module = sys.modules[module_name]
|
|
237
243
|
|
|
238
|
-
# Collect locally defined names and clean up module namespace
|
|
244
|
+
# Collect locally defined names and clean up module namespace.
|
|
245
|
+
# Priority order for categorization:
|
|
246
|
+
# 1. Overrides: real functions (with body), Exprs, real @components → returned directly
|
|
247
|
+
# 2. Components: stub @component functions → wrapped as Jsx(Import(...))
|
|
248
|
+
# 3. Constructors: classes → emitted with 'new' keyword
|
|
249
|
+
# 4. Regular imports: stub functions → standard named imports
|
|
239
250
|
constructors: set[str] = set()
|
|
251
|
+
components: set[str] = set()
|
|
240
252
|
local_names: set[str] = set()
|
|
253
|
+
overrides: dict[str, Any] = {}
|
|
241
254
|
|
|
242
255
|
for attr_name in list(vars(module)):
|
|
243
|
-
if attr_name in _MODULE_DUNDERS:
|
|
256
|
+
if attr_name in _MODULE_DUNDERS or attr_name.startswith("_"):
|
|
244
257
|
continue
|
|
245
258
|
|
|
246
259
|
obj = getattr(module, attr_name)
|
|
260
|
+
|
|
261
|
+
# Component-wrapped function - check the raw function's module
|
|
262
|
+
if isinstance(obj, Component):
|
|
263
|
+
raw_fn = obj._raw_fn # pyright: ignore[reportPrivateUsage]
|
|
264
|
+
fn_module = getattr(raw_fn, "__module__", None)
|
|
265
|
+
if fn_module != module_name:
|
|
266
|
+
delattr(module, attr_name)
|
|
267
|
+
continue
|
|
268
|
+
if is_stub_function(raw_fn):
|
|
269
|
+
# Stub component → becomes Jsx(Import(...))
|
|
270
|
+
components.add(attr_name)
|
|
271
|
+
local_names.add(attr_name)
|
|
272
|
+
else:
|
|
273
|
+
# Real component → preserve as override
|
|
274
|
+
overrides[attr_name] = obj
|
|
275
|
+
delattr(module, attr_name)
|
|
276
|
+
continue
|
|
277
|
+
|
|
247
278
|
is_local = not hasattr(obj, "__module__") or obj.__module__ == module_name
|
|
279
|
+
if not is_local:
|
|
280
|
+
delattr(module, attr_name)
|
|
281
|
+
continue
|
|
248
282
|
|
|
249
|
-
|
|
283
|
+
# Existing Expr (e.g., manually created Jsx)
|
|
284
|
+
if isinstance(obj, Expr):
|
|
285
|
+
overrides[attr_name] = obj
|
|
286
|
+
delattr(module, attr_name)
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
# Real function with body → preserve as override
|
|
290
|
+
if inspect.isfunction(obj) and not is_stub_function(obj):
|
|
291
|
+
overrides[attr_name] = obj
|
|
292
|
+
delattr(module, attr_name)
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
# Class → constructor (existing behavior)
|
|
296
|
+
if inspect.isclass(obj):
|
|
297
|
+
constructors.add(attr_name)
|
|
250
298
|
local_names.add(attr_name)
|
|
251
|
-
|
|
252
|
-
|
|
299
|
+
delattr(module, attr_name)
|
|
300
|
+
continue
|
|
253
301
|
|
|
302
|
+
# Stub function → regular import (existing behavior)
|
|
303
|
+
local_names.add(attr_name)
|
|
254
304
|
delattr(module, attr_name)
|
|
255
305
|
|
|
256
306
|
# Add annotated constants to local_names
|
|
@@ -271,11 +321,14 @@ class JsModule(Expr):
|
|
|
271
321
|
kind=kind,
|
|
272
322
|
values=values,
|
|
273
323
|
constructors=frozenset(constructors),
|
|
324
|
+
components=frozenset(components),
|
|
274
325
|
)
|
|
275
326
|
# Register the module object itself so `import pulse.js.math as Math` resolves via EXPR_REGISTRY.
|
|
276
327
|
Expr.register(module, js_module)
|
|
277
328
|
|
|
278
|
-
def __getattr__(name: str) -> Member | Class | Identifier | Import:
|
|
329
|
+
def __getattr__(name: str) -> Member | Class | Jsx | Identifier | Import | Any:
|
|
330
|
+
if name in overrides:
|
|
331
|
+
return overrides[name]
|
|
279
332
|
if name.startswith("_") or name not in local_names:
|
|
280
333
|
raise AttributeError(name)
|
|
281
334
|
return js_module.get_value(name)
|
|
@@ -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
|