pulse-framework 0.1.54__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/code_analysis.py +38 -0
- pulse/codegen/codegen.py +18 -50
- pulse/codegen/templates/route.py +100 -56
- pulse/component.py +24 -6
- pulse/js/__init__.py +1 -1
- pulse/js/react.py +114 -7
- pulse/transpiler/__init__.py +13 -0
- pulse/transpiler/assets.py +66 -0
- pulse/transpiler/dynamic_import.py +131 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/function.py +6 -2
- pulse/transpiler/imports.py +33 -27
- pulse/transpiler/js_module.py +64 -8
- pulse/transpiler/react_component.py +4 -11
- pulse/transpiler/transpiler.py +4 -0
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.55.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.55.dist-info}/RECORD +19 -16
- pulse/js/react_dom.py +0 -30
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.55.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.55.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Unified asset registry for local files that need copying.
|
|
2
|
+
|
|
3
|
+
Used by both Import (static imports) and DynamicImport (inline dynamic imports)
|
|
4
|
+
to track local files that should be copied to the assets folder during codegen.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import posixpath
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pulse.transpiler.emit_context import EmitContext
|
|
14
|
+
from pulse.transpiler.id import next_id
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class LocalAsset:
|
|
19
|
+
"""A local file registered for copying to assets."""
|
|
20
|
+
|
|
21
|
+
source_path: Path
|
|
22
|
+
id: str
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def asset_filename(self) -> str:
|
|
26
|
+
"""Filename in assets folder: stem_id.ext"""
|
|
27
|
+
return f"{self.source_path.stem}_{self.id}{self.source_path.suffix}"
|
|
28
|
+
|
|
29
|
+
def import_path(self) -> str:
|
|
30
|
+
"""Get import path for this asset.
|
|
31
|
+
|
|
32
|
+
If EmitContext is set, returns path relative to route file.
|
|
33
|
+
Otherwise returns the absolute source path (useful for tests/debugging).
|
|
34
|
+
"""
|
|
35
|
+
ctx = EmitContext.get()
|
|
36
|
+
if ctx is None:
|
|
37
|
+
return str(self.source_path)
|
|
38
|
+
# Compute relative path from route file directory to asset
|
|
39
|
+
# route_file_path is like "routes/users/index.tsx"
|
|
40
|
+
# asset is in "assets/{asset_filename}"
|
|
41
|
+
route_dir = posixpath.dirname(ctx.route_file_path)
|
|
42
|
+
asset_path = f"assets/{self.asset_filename}"
|
|
43
|
+
return posixpath.relpath(asset_path, route_dir)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Registry keyed by resolved source_path (dedupes same file)
|
|
47
|
+
_ASSET_REGISTRY: dict[Path, LocalAsset] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def register_local_asset(source_path: Path) -> LocalAsset:
|
|
51
|
+
"""Register a local file for copying. Returns existing if already registered."""
|
|
52
|
+
if source_path in _ASSET_REGISTRY:
|
|
53
|
+
return _ASSET_REGISTRY[source_path]
|
|
54
|
+
asset = LocalAsset(source_path, next_id())
|
|
55
|
+
_ASSET_REGISTRY[source_path] = asset
|
|
56
|
+
return asset
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_registered_assets() -> list[LocalAsset]:
|
|
60
|
+
"""Get all registered local assets."""
|
|
61
|
+
return list(_ASSET_REGISTRY.values())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def clear_asset_registry() -> None:
|
|
65
|
+
"""Clear asset registry (for tests)."""
|
|
66
|
+
_ASSET_REGISTRY.clear()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Dynamic import primitive for code-splitting.
|
|
2
|
+
|
|
3
|
+
Provides `import_` for inline dynamic imports in @javascript functions:
|
|
4
|
+
|
|
5
|
+
@javascript
|
|
6
|
+
def load_chart():
|
|
7
|
+
return import_("./Chart").then(lambda m: m.default)
|
|
8
|
+
|
|
9
|
+
For lazy-loaded React components, use Import(lazy=True) with React.lazy:
|
|
10
|
+
|
|
11
|
+
from pulse.js.react import React, lazy
|
|
12
|
+
|
|
13
|
+
# Low-level: Import(lazy=True) creates a factory, wrap with React.lazy
|
|
14
|
+
factory = Import("Chart", "./Chart", kind="default", lazy=True)
|
|
15
|
+
LazyChart = Jsx(React.lazy(factory))
|
|
16
|
+
|
|
17
|
+
# High-level: lazy() helper combines both
|
|
18
|
+
LazyChart = lazy("./Chart")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import ast
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import TYPE_CHECKING, override
|
|
26
|
+
|
|
27
|
+
from pulse.transpiler.assets import LocalAsset, register_local_asset
|
|
28
|
+
from pulse.transpiler.errors import TranspileError
|
|
29
|
+
from pulse.transpiler.imports import is_local_path, resolve_local_path
|
|
30
|
+
from pulse.transpiler.nodes import Expr, Member
|
|
31
|
+
from pulse.transpiler.vdom import VDOMNode
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from pulse.transpiler.transpiler import Transpiler
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class DynamicImport(Expr):
|
|
39
|
+
"""Represents a dynamic import() expression.
|
|
40
|
+
|
|
41
|
+
Emits as: import("src")
|
|
42
|
+
|
|
43
|
+
Supports method chaining for .then():
|
|
44
|
+
import_("./foo").then(lambda m: m.bar)
|
|
45
|
+
-> import("./foo").then(m => m.bar)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
src: str
|
|
49
|
+
asset: LocalAsset | None = None
|
|
50
|
+
|
|
51
|
+
@override
|
|
52
|
+
def emit(self, out: list[str]) -> None:
|
|
53
|
+
if self.asset:
|
|
54
|
+
out.append(f'import("{self.asset.import_path()}")')
|
|
55
|
+
else:
|
|
56
|
+
out.append(f'import("{self.src}")')
|
|
57
|
+
|
|
58
|
+
@override
|
|
59
|
+
def render(self) -> VDOMNode:
|
|
60
|
+
raise TypeError("DynamicImport cannot be rendered to VDOM")
|
|
61
|
+
|
|
62
|
+
@override
|
|
63
|
+
def transpile_getattr(self, attr: str, ctx: Transpiler) -> Expr:
|
|
64
|
+
"""Allow .then() and other method chaining."""
|
|
65
|
+
return Member(self, attr)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class DynamicImportFn(Expr):
|
|
69
|
+
"""Sentinel expr that intercepts import_() calls.
|
|
70
|
+
|
|
71
|
+
When used in a @javascript function:
|
|
72
|
+
import_("./module")
|
|
73
|
+
|
|
74
|
+
Transpiles to:
|
|
75
|
+
import("./module")
|
|
76
|
+
|
|
77
|
+
For local paths, resolves the file and registers it for asset copying.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
@override
|
|
81
|
+
def emit(self, out: list[str]) -> None:
|
|
82
|
+
raise TypeError(
|
|
83
|
+
"import_ cannot be emitted directly - call it with a source path"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@override
|
|
87
|
+
def render(self) -> VDOMNode:
|
|
88
|
+
raise TypeError("import_ cannot be rendered to VDOM")
|
|
89
|
+
|
|
90
|
+
@override
|
|
91
|
+
def transpile_call(
|
|
92
|
+
self,
|
|
93
|
+
args: list[ast.expr],
|
|
94
|
+
keywords: list[ast.keyword],
|
|
95
|
+
ctx: Transpiler,
|
|
96
|
+
) -> Expr:
|
|
97
|
+
"""Handle import_("source") calls."""
|
|
98
|
+
if keywords:
|
|
99
|
+
raise TranspileError("import_() does not accept keyword arguments")
|
|
100
|
+
if len(args) != 1:
|
|
101
|
+
raise TranspileError("import_() takes exactly 1 argument")
|
|
102
|
+
|
|
103
|
+
# Extract string literal from AST
|
|
104
|
+
src_node = args[0]
|
|
105
|
+
if not isinstance(src_node, ast.Constant) or not isinstance(
|
|
106
|
+
src_node.value, str
|
|
107
|
+
):
|
|
108
|
+
raise TranspileError("import_() argument must be a string literal")
|
|
109
|
+
|
|
110
|
+
src = src_node.value
|
|
111
|
+
asset: LocalAsset | None = None
|
|
112
|
+
|
|
113
|
+
# Resolve local paths and register asset
|
|
114
|
+
if is_local_path(src):
|
|
115
|
+
if ctx.source_file is None:
|
|
116
|
+
raise TranspileError(
|
|
117
|
+
"Cannot resolve relative import_() path: source file unknown"
|
|
118
|
+
)
|
|
119
|
+
source_path = resolve_local_path(src, ctx.source_file)
|
|
120
|
+
if source_path:
|
|
121
|
+
asset = register_local_asset(source_path)
|
|
122
|
+
else:
|
|
123
|
+
raise TranspileError(
|
|
124
|
+
f"import_({src!r}) references a local path that does not exist"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return DynamicImport(src, asset)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Singleton for use in deps
|
|
131
|
+
import_ = DynamicImportFn()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Emit context for code generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextvars import ContextVar, Token
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class EmitContext:
|
|
13
|
+
"""Context for emit operations during route code generation.
|
|
14
|
+
|
|
15
|
+
Stores information about the current route file being generated,
|
|
16
|
+
allowing emit methods to compute correct relative paths.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
with EmitContext(route_file_path="routes/users/index.tsx"):
|
|
20
|
+
js_code = emit(fn.transpile())
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
route_file_path: str
|
|
24
|
+
"""Path to route file from pulse folder root, e.g. 'routes/users/index.tsx'"""
|
|
25
|
+
|
|
26
|
+
_token: Token[EmitContext | None] | None = field(default=None, repr=False)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def get(cls) -> EmitContext | None:
|
|
30
|
+
"""Get current emit context, or None if not set."""
|
|
31
|
+
return _EMIT_CONTEXT.get()
|
|
32
|
+
|
|
33
|
+
def __enter__(self) -> EmitContext:
|
|
34
|
+
self._token = _EMIT_CONTEXT.set(self)
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def __exit__(
|
|
38
|
+
self,
|
|
39
|
+
exc_type: type[BaseException] | None = None,
|
|
40
|
+
exc_val: BaseException | None = None,
|
|
41
|
+
exc_tb: TracebackType | None = None,
|
|
42
|
+
) -> Literal[False]:
|
|
43
|
+
if self._token is not None:
|
|
44
|
+
_EMIT_CONTEXT.reset(self._token)
|
|
45
|
+
self._token = None
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_EMIT_CONTEXT: ContextVar[EmitContext | None] = ContextVar("emit_context", default=None)
|
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
|
|
|
@@ -156,7 +159,7 @@ def _transpile_function_body(
|
|
|
156
159
|
raise TranspileError("No function definition found in source")
|
|
157
160
|
fndef = fndefs[-1]
|
|
158
161
|
|
|
159
|
-
# Get filename for error messages
|
|
162
|
+
# Get filename for error messages and source file resolution
|
|
160
163
|
try:
|
|
161
164
|
filename = inspect.getfile(fn)
|
|
162
165
|
except (TypeError, OSError):
|
|
@@ -164,7 +167,8 @@ def _transpile_function_body(
|
|
|
164
167
|
|
|
165
168
|
# Transpile with source context for errors
|
|
166
169
|
try:
|
|
167
|
-
|
|
170
|
+
source_file = Path(filename) if filename else None
|
|
171
|
+
transpiler = Transpiler(fndef, deps, jsx=jsx, source_file=source_file)
|
|
168
172
|
result = transpiler.transpile()
|
|
169
173
|
except TranspileError as e:
|
|
170
174
|
# Re-raise with source context if not already present
|
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
|
)
|
|
@@ -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:
|
|
@@ -159,7 +163,7 @@ 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
169
|
For global-identifier modules (name=None): returns Identifier directly (e.g., Set -> Set)
|
|
@@ -169,6 +173,7 @@ class JsModule(Expr):
|
|
|
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.
|
|
@@ -188,6 +193,8 @@ class JsModule(Expr):
|
|
|
188
193
|
|
|
189
194
|
if name in self.constructors:
|
|
190
195
|
return Class(expr, name=name)
|
|
196
|
+
if name in self.components:
|
|
197
|
+
return Jsx(expr)
|
|
191
198
|
return expr
|
|
192
199
|
|
|
193
200
|
@override
|
|
@@ -226,28 +233,74 @@ class JsModule(Expr):
|
|
|
226
233
|
Example (inside pulse/js/set.py):
|
|
227
234
|
JsModule.register(name=None) # global identifier builtin (no module binding)
|
|
228
235
|
"""
|
|
236
|
+
from pulse.component import Component # Import here to avoid cycles
|
|
237
|
+
|
|
229
238
|
# Get the calling module from the stack frame
|
|
230
239
|
frame = inspect.currentframe()
|
|
231
240
|
assert frame is not None and frame.f_back is not None
|
|
232
241
|
module_name = frame.f_back.f_globals["__name__"]
|
|
233
242
|
module = sys.modules[module_name]
|
|
234
243
|
|
|
235
|
-
# 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
|
|
236
250
|
constructors: set[str] = set()
|
|
251
|
+
components: set[str] = set()
|
|
237
252
|
local_names: set[str] = set()
|
|
253
|
+
overrides: dict[str, Any] = {}
|
|
238
254
|
|
|
239
255
|
for attr_name in list(vars(module)):
|
|
240
|
-
if attr_name in _MODULE_DUNDERS:
|
|
256
|
+
if attr_name in _MODULE_DUNDERS or attr_name.startswith("_"):
|
|
241
257
|
continue
|
|
242
258
|
|
|
243
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
|
+
|
|
244
278
|
is_local = not hasattr(obj, "__module__") or obj.__module__ == module_name
|
|
279
|
+
if not is_local:
|
|
280
|
+
delattr(module, attr_name)
|
|
281
|
+
continue
|
|
245
282
|
|
|
246
|
-
|
|
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)
|
|
247
298
|
local_names.add(attr_name)
|
|
248
|
-
|
|
249
|
-
|
|
299
|
+
delattr(module, attr_name)
|
|
300
|
+
continue
|
|
250
301
|
|
|
302
|
+
# Stub function → regular import (existing behavior)
|
|
303
|
+
local_names.add(attr_name)
|
|
251
304
|
delattr(module, attr_name)
|
|
252
305
|
|
|
253
306
|
# Add annotated constants to local_names
|
|
@@ -268,11 +321,14 @@ class JsModule(Expr):
|
|
|
268
321
|
kind=kind,
|
|
269
322
|
values=values,
|
|
270
323
|
constructors=frozenset(constructors),
|
|
324
|
+
components=frozenset(components),
|
|
271
325
|
)
|
|
272
326
|
# Register the module object itself so `import pulse.js.math as Math` resolves via EXPR_REGISTRY.
|
|
273
327
|
Expr.register(module, js_module)
|
|
274
328
|
|
|
275
|
-
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]
|
|
276
332
|
if name.startswith("_") or name not in local_names:
|
|
277
333
|
raise AttributeError(name)
|
|
278
334
|
return js_module.get_value(name)
|
|
@@ -22,17 +22,15 @@ def default_signature(
|
|
|
22
22
|
) -> Element: ...
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def react_component(
|
|
26
|
-
expr: Expr,
|
|
27
|
-
*,
|
|
28
|
-
lazy: bool = False,
|
|
29
|
-
):
|
|
25
|
+
def react_component(expr: Expr):
|
|
30
26
|
"""Decorator that uses the decorated function solely as a typed signature.
|
|
31
27
|
|
|
32
28
|
Returns a Jsx(expr) that preserves the function's type signature for type
|
|
33
29
|
checkers and produces Element nodes when called in transpiled code.
|
|
34
30
|
|
|
35
|
-
|
|
31
|
+
For lazy loading, use Import(lazy=True) directly:
|
|
32
|
+
LazyChart = Import("Chart", "./Chart", kind="default", lazy=True)
|
|
33
|
+
React.lazy(LazyChart) # LazyChart is already a factory
|
|
36
34
|
"""
|
|
37
35
|
|
|
38
36
|
def decorator(fn: Callable[P, Any]) -> Callable[P, Element]:
|
|
@@ -41,11 +39,6 @@ def react_component(
|
|
|
41
39
|
|
|
42
40
|
# Wrap expr: Jsx provides Element generation
|
|
43
41
|
jsx_wrapper = expr if isinstance(expr, Jsx) else Jsx(expr)
|
|
44
|
-
|
|
45
|
-
# Note: lazy flag is not currently wired into codegen
|
|
46
|
-
# Could store it via a separate side-registry if needed in future
|
|
47
|
-
_ = lazy # Suppress unused variable warning
|
|
48
|
-
|
|
49
42
|
return jsx_wrapper
|
|
50
43
|
|
|
51
44
|
return decorator
|
pulse/transpiler/transpiler.py
CHANGED
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import ast
|
|
11
11
|
import re
|
|
12
12
|
from collections.abc import Callable, Mapping
|
|
13
|
+
from pathlib import Path
|
|
13
14
|
from typing import Any, cast
|
|
14
15
|
|
|
15
16
|
from pulse.transpiler.builtins import BUILTINS, emit_method
|
|
@@ -94,6 +95,7 @@ class Transpiler:
|
|
|
94
95
|
deps: Mapping[str, Expr]
|
|
95
96
|
locals: set[str]
|
|
96
97
|
jsx: bool
|
|
98
|
+
source_file: Path | None
|
|
97
99
|
_temp_counter: int
|
|
98
100
|
|
|
99
101
|
def __init__(
|
|
@@ -102,8 +104,10 @@ class Transpiler:
|
|
|
102
104
|
deps: Mapping[str, Expr],
|
|
103
105
|
*,
|
|
104
106
|
jsx: bool = False,
|
|
107
|
+
source_file: Path | None = None,
|
|
105
108
|
) -> None:
|
|
106
109
|
self.fndef = fndef
|
|
110
|
+
self.source_file = source_file
|
|
107
111
|
# Collect all argument names (regular, vararg, kwonly, kwarg)
|
|
108
112
|
args: list[str] = [arg.arg for arg in fndef.args.args]
|
|
109
113
|
if fndef.args.vararg:
|