pulse-framework 0.1.54__py3-none-any.whl → 0.1.56__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.
Files changed (80) hide show
  1. pulse/__init__.py +5 -6
  2. pulse/app.py +144 -57
  3. pulse/channel.py +139 -7
  4. pulse/cli/cmd.py +16 -2
  5. pulse/code_analysis.py +38 -0
  6. pulse/codegen/codegen.py +61 -62
  7. pulse/codegen/templates/route.py +100 -56
  8. pulse/component.py +128 -6
  9. pulse/components/for_.py +30 -4
  10. pulse/components/if_.py +28 -5
  11. pulse/components/react_router.py +61 -3
  12. pulse/context.py +39 -5
  13. pulse/cookies.py +108 -4
  14. pulse/decorators.py +193 -24
  15. pulse/env.py +56 -2
  16. pulse/form.py +198 -5
  17. pulse/helpers.py +7 -1
  18. pulse/hooks/core.py +135 -5
  19. pulse/hooks/effects.py +61 -77
  20. pulse/hooks/init.py +60 -1
  21. pulse/hooks/runtime.py +241 -0
  22. pulse/hooks/setup.py +77 -0
  23. pulse/hooks/stable.py +58 -1
  24. pulse/hooks/state.py +107 -20
  25. pulse/js/__init__.py +41 -25
  26. pulse/js/array.py +9 -6
  27. pulse/js/console.py +15 -12
  28. pulse/js/date.py +9 -6
  29. pulse/js/document.py +5 -2
  30. pulse/js/error.py +7 -4
  31. pulse/js/json.py +9 -6
  32. pulse/js/map.py +8 -5
  33. pulse/js/math.py +9 -6
  34. pulse/js/navigator.py +5 -2
  35. pulse/js/number.py +9 -6
  36. pulse/js/obj.py +16 -13
  37. pulse/js/object.py +9 -6
  38. pulse/js/promise.py +19 -13
  39. pulse/js/pulse.py +28 -25
  40. pulse/js/react.py +190 -44
  41. pulse/js/regexp.py +7 -4
  42. pulse/js/set.py +8 -5
  43. pulse/js/string.py +9 -6
  44. pulse/js/weakmap.py +8 -5
  45. pulse/js/weakset.py +8 -5
  46. pulse/js/window.py +6 -3
  47. pulse/messages.py +5 -0
  48. pulse/middleware.py +147 -76
  49. pulse/plugin.py +76 -5
  50. pulse/queries/client.py +186 -39
  51. pulse/queries/common.py +52 -3
  52. pulse/queries/infinite_query.py +154 -2
  53. pulse/queries/mutation.py +127 -7
  54. pulse/queries/query.py +112 -11
  55. pulse/react_component.py +66 -3
  56. pulse/reactive.py +314 -30
  57. pulse/reactive_extensions.py +106 -26
  58. pulse/render_session.py +304 -173
  59. pulse/request.py +46 -11
  60. pulse/routing.py +140 -4
  61. pulse/serializer.py +71 -0
  62. pulse/state.py +177 -9
  63. pulse/test_helpers.py +15 -0
  64. pulse/transpiler/__init__.py +13 -3
  65. pulse/transpiler/assets.py +66 -0
  66. pulse/transpiler/dynamic_import.py +131 -0
  67. pulse/transpiler/emit_context.py +49 -0
  68. pulse/transpiler/function.py +6 -2
  69. pulse/transpiler/imports.py +33 -27
  70. pulse/transpiler/js_module.py +64 -8
  71. pulse/transpiler/py_module.py +1 -7
  72. pulse/transpiler/transpiler.py +4 -0
  73. pulse/user_session.py +119 -18
  74. {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
  75. pulse_framework-0.1.56.dist-info/RECORD +127 -0
  76. pulse/js/react_dom.py +0 -30
  77. pulse/transpiler/react_component.py +0 -51
  78. pulse_framework-0.1.54.dist-info/RECORD +0 -124
  79. {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
  80. {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.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)
@@ -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
- transpiler = Transpiler(fndef, deps, jsx=jsx)
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
@@ -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
- source_path: Path | None = (
184
- None # Resolved local file path (for copying during codegen)
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
- source_path: Path | None = None
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
- source_path = resolved
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.source_path = source_path
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: for named imports use (name, src, "named")
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 resolved source_path)."""
264
- return self.source_path is not None
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
  # -------------------------------------------------------------------------
@@ -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
- if is_local and not attr_name.startswith("_"):
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
- if inspect.isclass(obj):
249
- constructors.add(attr_name)
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)
@@ -124,13 +124,7 @@ class PyModule(Expr):
124
124
  elif hasattr(transpilation, "_transpiler"):
125
125
  transpiler_dict = transpilation._transpiler
126
126
  else:
127
- # Legacy: class namespace without PyModule inheritance
128
- items = (
129
- (name, getattr(transpilation, name))
130
- for name in dir(transpilation)
131
- if not name.startswith("_")
132
- )
133
- transpiler_dict = PyModule._build_transpiler(items)
127
+ raise TypeError("PyModule.register expects a PyModule subclass or dict")
134
128
 
135
129
  # Register individual values for lookup by id
136
130
  for attr_name, expr in transpiler_dict.items():
@@ -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: