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/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/__init__.py
CHANGED
|
@@ -3,10 +3,23 @@
|
|
|
3
3
|
# Ensure built-in Python modules (e.g., math) are registered on import.
|
|
4
4
|
from pulse.transpiler import modules as _modules # noqa: F401
|
|
5
5
|
|
|
6
|
+
# Asset registry (unified for Import and DynamicImport)
|
|
7
|
+
from pulse.transpiler.assets import LocalAsset as LocalAsset
|
|
8
|
+
from pulse.transpiler.assets import clear_asset_registry as clear_asset_registry
|
|
9
|
+
from pulse.transpiler.assets import get_registered_assets as get_registered_assets
|
|
10
|
+
from pulse.transpiler.assets import register_local_asset as register_local_asset
|
|
11
|
+
|
|
6
12
|
# Builtins
|
|
7
13
|
from pulse.transpiler.builtins import BUILTINS as BUILTINS
|
|
8
14
|
from pulse.transpiler.builtins import emit_method as emit_method
|
|
9
15
|
|
|
16
|
+
# Dynamic import primitive
|
|
17
|
+
from pulse.transpiler.dynamic_import import DynamicImport as DynamicImport
|
|
18
|
+
from pulse.transpiler.dynamic_import import import_ as import_
|
|
19
|
+
|
|
20
|
+
# Emit context
|
|
21
|
+
from pulse.transpiler.emit_context import EmitContext as EmitContext
|
|
22
|
+
|
|
10
23
|
# Errors
|
|
11
24
|
from pulse.transpiler.errors import TranspileError as TranspileError
|
|
12
25
|
|
|
@@ -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()
|
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)"""
|
|
@@ -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/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
|
)
|