pulse-framework 0.1.62__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 (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,341 @@
1
+ """Import with auto-registration for transpiler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import (
9
+ Any,
10
+ ParamSpec,
11
+ TypeAlias,
12
+ TypeVar,
13
+ override,
14
+ )
15
+ from typing import Literal as Lit
16
+
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
20
+ from pulse.transpiler.id import next_id
21
+ from pulse.transpiler.nodes import Call, Expr, to_js_identifier
22
+ from pulse.transpiler.vdom import VDOMExpr
23
+
24
+ _P = ParamSpec("_P")
25
+ _R = TypeVar("_R")
26
+
27
+ ImportKind: TypeAlias = Lit["named", "default", "namespace", "side_effect"]
28
+
29
+ # JS-like extensions to try when resolving imports without extension (ESM convention)
30
+ _JS_EXTENSIONS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts")
31
+
32
+
33
+ def caller_file(depth: int = 2) -> Path:
34
+ """Get the file path of the caller."""
35
+ frame = inspect.currentframe()
36
+ try:
37
+ for _ in range(depth):
38
+ if frame is None:
39
+ raise RuntimeError("Could not determine caller file")
40
+ frame = frame.f_back
41
+ if frame is None:
42
+ raise RuntimeError("Could not determine caller file")
43
+ return Path(frame.f_code.co_filename)
44
+ finally:
45
+ del frame
46
+
47
+
48
+ def is_relative_path(path: str) -> bool:
49
+ """Check if path is a relative import (starts with ./ or ../)."""
50
+ return path.startswith("./") or path.startswith("../")
51
+
52
+
53
+ def is_absolute_path(path: str) -> bool:
54
+ """Check if path is an absolute filesystem path."""
55
+ return path.startswith("/")
56
+
57
+
58
+ def is_local_path(path: str) -> bool:
59
+ """Check if path is a local file path (relative or absolute)."""
60
+ return is_relative_path(path) or is_absolute_path(path)
61
+
62
+
63
+ def resolve_js_file(base_path: Path) -> Path | None:
64
+ """Resolve a JS-like import path to an actual file.
65
+
66
+ Follows ESM resolution order:
67
+ 1. Exact path (if has extension)
68
+ 2. Try JS extensions: .ts, .tsx, .js, .jsx, .mjs, .mts
69
+ 3. Try /index with each extension
70
+
71
+ Returns None if no file is found.
72
+ """
73
+ # If path already has an extension that exists, use it
74
+ if base_path.suffix and base_path.exists():
75
+ return base_path
76
+
77
+ # If no extension, try JS-like extensions
78
+ if not base_path.suffix:
79
+ for ext in _JS_EXTENSIONS:
80
+ candidate = base_path.with_suffix(ext)
81
+ if candidate.exists():
82
+ return candidate
83
+
84
+ # Try /index with each extension
85
+ for ext in _JS_EXTENSIONS:
86
+ candidate = base_path / f"index{ext}"
87
+ if candidate.exists():
88
+ return candidate
89
+
90
+ return None
91
+
92
+
93
+ def resolve_local_path(path: str, caller: Path | None = None) -> Path | None:
94
+ """Resolve a local import path to an actual file.
95
+
96
+ For relative paths, resolves relative to caller.
97
+ For absolute paths, uses the path directly.
98
+
99
+ For paths without extensions, tries JS-like resolution.
100
+ Falls back to the raw resolved path if the file doesn't exist
101
+ (might be a generated file or future file).
102
+
103
+ Returns None only for non-local paths or relative paths without a caller.
104
+ """
105
+ if is_relative_path(path):
106
+ if caller is None:
107
+ return None
108
+ base_path = (caller.parent / Path(path)).resolve()
109
+ elif is_absolute_path(path):
110
+ base_path = Path(path).resolve()
111
+ else:
112
+ return None
113
+
114
+ # If the path has an extension, return it (even if it doesn't exist)
115
+ if base_path.suffix:
116
+ return base_path
117
+
118
+ # Try JS-like resolution for existing files
119
+ resolved = resolve_js_file(base_path)
120
+ if resolved is not None:
121
+ return resolved
122
+
123
+ # Fallback: return the base path even if the file doesn't exist
124
+ return base_path
125
+
126
+
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]
131
+ _IMPORT_REGISTRY: dict[_ImportKey, "Import"] = {}
132
+
133
+
134
+ def get_registered_imports() -> list["Import"]:
135
+ """Get all registered imports."""
136
+ return list(_IMPORT_REGISTRY.values())
137
+
138
+
139
+ def clear_import_registry() -> None:
140
+ """Clear the import registry."""
141
+ _IMPORT_REGISTRY.clear()
142
+
143
+
144
+ @dataclass(slots=True, init=False)
145
+ class Import(Expr):
146
+ """JS import that auto-registers and dedupes.
147
+
148
+ An Expr that emits as its unique identifier (e.g., useState_1).
149
+ Overrides transpile_call for JSX component behavior and transpile_getattr for
150
+ member access.
151
+
152
+ Examples:
153
+ # Named import: import { useState } from "react"
154
+ useState = Import("useState", "react")
155
+
156
+ # Default import: import React from "react"
157
+ React = Import("react")
158
+
159
+ # Namespace import: import * as React from "react"
160
+ React = Import("*", "react")
161
+
162
+ # Side-effect import: import "./styles.css"
163
+ Import("./styles.css", side_effect=True)
164
+
165
+ # Type-only import: import type { Props } from "./types"
166
+ Props = Import("Props", "./types", is_type=True)
167
+
168
+ # JSX component import - wrap in Jsx() to create elements
169
+ Button = Jsx(Import("Button", "@mantine/core"))
170
+ # Button("Click me", disabled=True) -> <Button_1 disabled={true}>Click me</Button_1>
171
+
172
+ # Local file imports (relative or absolute paths)
173
+ Import("./styles.css", side_effect=True) # Local CSS
174
+ utils = Import("*", "./utils") # Local JS namespace (resolves extension)
175
+ config = Import("/absolute/path/config") # Absolute path default import
176
+
177
+ # Lazy import (generates factory for code-splitting)
178
+ Chart = Import("./Chart", lazy=True)
179
+ # Generates: const Chart_1 = () => import("./Chart")
180
+ """
181
+
182
+ name: str
183
+ src: str
184
+ kind: ImportKind
185
+ is_type: bool
186
+ lazy: bool
187
+ before: tuple[str, ...]
188
+ id: str
189
+ version: str | None = None
190
+ asset: LocalAsset | None = (
191
+ None # Registered local asset (for copying during codegen)
192
+ )
193
+
194
+ def __init__(
195
+ self,
196
+ name: str,
197
+ src: str | None = None,
198
+ *,
199
+ side_effect: bool = False,
200
+ is_type: bool = False,
201
+ lazy: bool = False,
202
+ version: str | None = None,
203
+ before: tuple[str, ...] | list[str] = (),
204
+ _caller_depth: int = 2,
205
+ ) -> None:
206
+ if src is None:
207
+ if name == "*":
208
+ raise TypeError("Import('*') requires a source")
209
+ src = name
210
+ if side_effect:
211
+ name = ""
212
+ kind: ImportKind = "side_effect"
213
+ else:
214
+ kind = "default"
215
+ else:
216
+ if side_effect:
217
+ raise TypeError("side_effect imports cannot specify a name")
218
+ if name == "*":
219
+ name = src
220
+ kind = "namespace"
221
+ else:
222
+ if not name:
223
+ raise TypeError("Import(name, src) requires a non-empty name")
224
+ kind = "named"
225
+
226
+ # Validate: lazy imports cannot be type-only
227
+ if lazy and is_type:
228
+ raise TranspileError("Import cannot be both lazy and type-only")
229
+
230
+ # Auto-resolve local paths (relative or absolute) to actual files
231
+ asset: LocalAsset | None = None
232
+ import_src = src
233
+
234
+ if is_local_path(src):
235
+ # Resolve to actual file (handles JS extension resolution)
236
+ caller = caller_file(depth=_caller_depth) if is_relative_path(src) else None
237
+ resolved = resolve_local_path(src, caller)
238
+ if resolved is not None:
239
+ # Register with unified asset registry
240
+ asset = register_local_asset(resolved)
241
+ import_src = str(resolved)
242
+
243
+ self.name = name
244
+ self.src = import_src
245
+ self.kind = kind
246
+ self.version = version
247
+ self.lazy = lazy
248
+ self.asset = asset
249
+
250
+ before_tuple = tuple(before) if isinstance(before, list) else before
251
+
252
+ # Dedupe key: includes lazy flag to keep lazy and eager imports separate
253
+ if kind == "named":
254
+ key: _ImportKey = (name, import_src, "named", lazy)
255
+ else:
256
+ key = ("", import_src, kind, lazy)
257
+
258
+ if key in _IMPORT_REGISTRY:
259
+ existing = _IMPORT_REGISTRY[key]
260
+
261
+ # Merge: type-only + regular = regular
262
+ if existing.is_type and not is_type:
263
+ existing.is_type = False
264
+
265
+ # Merge: union of before constraints
266
+ if before_tuple:
267
+ merged_before = set(existing.before) | set(before_tuple)
268
+ existing.before = tuple(sorted(merged_before))
269
+
270
+ # Merge: version
271
+ existing.version = pick_more_specific(existing.version, version)
272
+
273
+ # Reuse ID and merged values
274
+ self.id = existing.id
275
+ self.is_type = existing.is_type
276
+ self.before = existing.before
277
+ self.version = existing.version
278
+ else:
279
+ # New import
280
+ self.id = next_id()
281
+ self.is_type = is_type
282
+ self.before = before_tuple
283
+ _IMPORT_REGISTRY[key] = self
284
+
285
+ @property
286
+ def js_name(self) -> str:
287
+ """Unique JS identifier for this import."""
288
+ return f"{to_js_identifier(self.name)}_{self.id}"
289
+
290
+ @property
291
+ def is_local(self) -> bool:
292
+ """Check if this is a local file import (has registered asset)."""
293
+ return self.asset is not None
294
+
295
+ @property
296
+ def is_lazy(self) -> bool:
297
+ """Check if this is a lazy import."""
298
+ return self.lazy
299
+
300
+ # Convenience properties for kind checks
301
+ @property
302
+ def is_default(self) -> bool:
303
+ return self.kind == "default"
304
+
305
+ @property
306
+ def is_namespace(self) -> bool:
307
+ return self.kind == "namespace"
308
+
309
+ @property
310
+ def is_side_effect(self) -> bool:
311
+ return self.kind == "side_effect"
312
+
313
+ # -------------------------------------------------------------------------
314
+ # Expr.emit: outputs the unique identifier
315
+ # -------------------------------------------------------------------------
316
+
317
+ @override
318
+ def emit(self, out: list[str]) -> None:
319
+ """Emit this import as its unique JS identifier."""
320
+ out.append(self.js_name)
321
+
322
+ @override
323
+ def render(self) -> VDOMExpr:
324
+ """Render as a registry reference."""
325
+ return {"t": "ref", "key": self.id}
326
+
327
+ # -------------------------------------------------------------------------
328
+ # Python dunder methods: allow natural syntax in @javascript functions
329
+ # -------------------------------------------------------------------------
330
+
331
+ # Overloads for __call__:
332
+ # 1. Decorator usage: @Import(...) def fn(...) -> returns fn's type
333
+ # 2. Expression usage: Import(...)(...) -> returns Call
334
+
335
+ @override
336
+ def __call__(self, *args: Any, **kwargs: Any) -> "Call":
337
+ """Allow calling Import objects in Python code.
338
+
339
+ Returns a Call expression.
340
+ """
341
+ return Expr.__call__(self, *args, **kwargs)
@@ -0,0 +1,336 @@
1
+ """Core infrastructure for JavaScript module bindings in transpiler.
2
+
3
+ JS modules are Python modules that map to JavaScript modules/builtins.
4
+ Registration is done by calling register_js_module() from within the module itself.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import inspect
11
+ import sys
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Literal, override
14
+
15
+ from pulse.code_analysis import is_stub_function
16
+ from pulse.transpiler.errors import TranspileError
17
+ from pulse.transpiler.imports import Import
18
+ from pulse.transpiler.nodes import (
19
+ Expr,
20
+ Identifier,
21
+ Jsx,
22
+ Member,
23
+ New,
24
+ )
25
+ from pulse.transpiler.transpiler import Transpiler
26
+
27
+ _MODULE_DUNDERS = frozenset(
28
+ {
29
+ "__name__",
30
+ "__file__",
31
+ "__doc__",
32
+ "__package__",
33
+ "__path__",
34
+ "__cached__",
35
+ "__loader__",
36
+ "__spec__",
37
+ "__builtins__",
38
+ "__annotations__",
39
+ }
40
+ )
41
+
42
+
43
+ @dataclass(slots=True)
44
+ class Class(Expr):
45
+ """Expr wrapper that emits calls as `new ...(...)`.
46
+
47
+ Must also behave like the wrapped expression for attribute access,
48
+ so patterns like `Promise.resolve(...)` work even if `Promise(...)` emits
49
+ as `new Promise(...)`.
50
+ """
51
+
52
+ ctor: Expr
53
+ name: str = ""
54
+
55
+ @override
56
+ def emit(self, out: list[str]) -> None:
57
+ self.ctor.emit(out)
58
+
59
+ @override
60
+ def render(self):
61
+ return self.ctor.render()
62
+
63
+ @override
64
+ def transpile_call(
65
+ self,
66
+ args: list[ast.expr],
67
+ keywords: list[ast.keyword],
68
+ ctx: Transpiler,
69
+ ) -> Expr:
70
+ if keywords:
71
+ raise TranspileError("Keyword arguments not supported in constructor call")
72
+ return New(self.ctor, [ctx.emit_expr(a) for a in args])
73
+
74
+ @override
75
+ def transpile_getattr(self, attr: str, ctx: Transpiler) -> Expr:
76
+ # Convention: trailing underscore escapes Python keywords (e.g. from_ -> from, is_ -> is)
77
+ js_attr = attr[:-1] if attr.endswith("_") else attr
78
+ return Member(self.ctor, js_attr)
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class JsModule(Expr):
83
+ """Expr representing a JavaScript module binding.
84
+
85
+ Attributes:
86
+ name: The JavaScript identifier for the module binding (e.g., "Math", "React"),
87
+ or None for "global identifier" modules with no module expression.
88
+ py_name: Python module name for error messages (e.g., "pulse.js.math")
89
+ src: Import source path. None for builtins.
90
+ kind: Import kind - "default" or "namespace"
91
+ values: How attribute access is expressed:
92
+ - "member": Access as property (e.g., React.useState)
93
+ - "named_import": Each attribute is a named import (e.g., import { useState } from "react")
94
+ constructors: Set of names that are constructors (emit with 'new')
95
+ components: Set of names that are components (wrapped with Jsx)
96
+ """
97
+
98
+ name: str | None
99
+ py_name: str = ""
100
+ src: str | None = None
101
+ kind: Literal["default", "namespace"] = "namespace"
102
+ values: Literal["member", "named_import"] = "named_import"
103
+ constructors: frozenset[str] = field(default_factory=frozenset)
104
+ components: frozenset[str] = field(default_factory=frozenset)
105
+
106
+ @override
107
+ def emit(self, out: list[str]) -> None:
108
+ label = self.py_name or self.name or "JsModule"
109
+ raise TypeError(f"{label} cannot be emitted directly - access an attribute")
110
+
111
+ @override
112
+ def render(self):
113
+ label = self.py_name or self.name or "JsModule"
114
+ raise TypeError(f"{label} cannot be rendered directly - access an attribute")
115
+
116
+ @override
117
+ def transpile_call(
118
+ self,
119
+ args: list[ast.expr],
120
+ keywords: list[ast.keyword],
121
+ ctx: Transpiler,
122
+ ) -> Expr:
123
+ label = self.py_name or self.name or "JsModule"
124
+ raise TypeError(f"{label} cannot be called directly - access an attribute")
125
+
126
+ @override
127
+ def transpile_getattr(self, attr: str, ctx: Transpiler) -> Expr:
128
+ return self.get_value(attr)
129
+
130
+ @override
131
+ def transpile_subscript(self, key: ast.expr, ctx: Transpiler) -> Expr:
132
+ label = self.py_name or self.name or "JsModule"
133
+ raise TypeError(f"{label} cannot be subscripted")
134
+
135
+ @property
136
+ def is_builtin(self) -> bool:
137
+ return self.src is None
138
+
139
+ def to_expr(self) -> Identifier | Import:
140
+ """Generate the appropriate Expr for this module.
141
+
142
+ Returns Identifier for builtins, Import for external modules.
143
+
144
+ Raises TranspileError if name is None (module imports are disallowed).
145
+ """
146
+ if self.name is None:
147
+ label = self.py_name or "JS global module"
148
+ # If a module has no JS module expression, importing it as a module value is meaningless.
149
+ # Users should import members from the Python module instead.
150
+ if self.py_name:
151
+ msg = (
152
+ f"Cannot import module '{label}' directly. "
153
+ + f"Use 'from {self.py_name} import ...' instead."
154
+ )
155
+ else:
156
+ msg = f"Cannot import module '{label}' directly."
157
+ raise TranspileError(msg)
158
+
159
+ if self.src is None:
160
+ return Identifier(self.name)
161
+
162
+ if self.kind == "default":
163
+ return Import(self.src)
164
+ return Import("*", self.src)
165
+
166
+ def get_value(self, name: str) -> Member | Class | Jsx | Identifier | Import:
167
+ """Get a member of this module as an expression.
168
+
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)
172
+ For external modules with "member" style: returns Member (e.g., React.useState)
173
+ For external modules with "named_import" style: returns a named Import
174
+
175
+ If name is in constructors, returns a Class that emits `new ...(...)`.
176
+ If name is in components, returns a Jsx-wrapped expression.
177
+ """
178
+ # Convention: trailing underscore escapes Python keywords (e.g. from_ -> from, is_ -> is).
179
+ # We keep the original `name` for constructor detection, but emit the JS name.
180
+ js_name = name[:-1] if name.endswith("_") else name
181
+
182
+ expr: Member | Identifier | Import
183
+ if self.name is None:
184
+ # Virtual module exposing JS globals - members are just identifiers
185
+ expr = Identifier(js_name)
186
+ elif self.src is None:
187
+ # Builtin namespace (Math, console, etc.) - members accessed as properties
188
+ expr = Member(Identifier(self.name), js_name)
189
+ elif self.values == "named_import":
190
+ expr = Import(js_name, self.src)
191
+ else:
192
+ expr = Member(self.to_expr(), js_name)
193
+
194
+ if name in self.constructors:
195
+ return Class(expr, name=name)
196
+ if name in self.components:
197
+ return Jsx(expr)
198
+ return expr
199
+
200
+ @override
201
+ @staticmethod
202
+ def register( # pyright: ignore[reportIncompatibleMethodOverride]
203
+ *,
204
+ name: str | None,
205
+ src: str | None = None,
206
+ kind: Literal["default", "namespace"] = "namespace",
207
+ values: Literal["member", "named_import"] = "named_import",
208
+ ) -> None:
209
+ """Register the calling Python module as a JavaScript module binding.
210
+
211
+ Must be called from within the module being registered. The module is
212
+ automatically detected from the call stack.
213
+
214
+ This function sets up __getattr__ on the module for dynamic attribute access,
215
+ and registers the module object in EXPR_REGISTRY so it can be used as a
216
+ dependency (e.g., `import pulse.js.math as Math`) during transpilation.
217
+
218
+ Args:
219
+ name: The JavaScript identifier for the module binding (e.g., "Math"), or None
220
+ for modules that expose only global identifiers and cannot be imported as a whole.
221
+ src: Import source path. None for builtins.
222
+ kind: Import kind - "default" or "namespace"
223
+ values: How attribute access works:
224
+ - "member": Access as property (e.g., Math.sin, React.useState)
225
+ - "named_import": Each attribute is a named import
226
+
227
+ Example (inside pulse/js/math.py):
228
+ JsModule.register(name="Math") # builtin
229
+
230
+ Example (inside pulse/js/react.py):
231
+ JsModule.register(name="React", src="react") # namespace + named imports
232
+
233
+ Example (inside pulse/js/set.py):
234
+ JsModule.register(name=None) # global identifier builtin (no module binding)
235
+ """
236
+ from pulse.component import Component # Import here to avoid cycles
237
+
238
+ # Get the calling module from the stack frame
239
+ frame = inspect.currentframe()
240
+ assert frame is not None and frame.f_back is not None
241
+ module_name = frame.f_back.f_globals["__name__"]
242
+ module = sys.modules[module_name]
243
+
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
250
+ constructors: set[str] = set()
251
+ components: set[str] = set()
252
+ local_names: set[str] = set()
253
+ overrides: dict[str, Any] = {}
254
+
255
+ for attr_name in list(vars(module)):
256
+ if attr_name in _MODULE_DUNDERS or attr_name.startswith("_"):
257
+ continue
258
+
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
+
278
+ is_local = not hasattr(obj, "__module__") or obj.__module__ == module_name
279
+ if not is_local:
280
+ delattr(module, attr_name)
281
+ continue
282
+
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)
298
+ local_names.add(attr_name)
299
+ delattr(module, attr_name)
300
+ continue
301
+
302
+ # Stub function → regular import (existing behavior)
303
+ local_names.add(attr_name)
304
+ delattr(module, attr_name)
305
+
306
+ # Add annotated constants to local_names
307
+ if hasattr(module, "__annotations__"):
308
+ for ann_name in module.__annotations__:
309
+ if not ann_name.startswith("_"):
310
+ local_names.add(ann_name)
311
+ module.__annotations__.clear()
312
+
313
+ # Invariants: a module without a JS module binding cannot be imported from a JS source.
314
+ if name is None and src is not None:
315
+ raise ValueError("name=None is only supported for builtins (src=None)")
316
+
317
+ js_module = JsModule(
318
+ name=name,
319
+ py_name=module.__name__,
320
+ src=src,
321
+ kind=kind,
322
+ values=values,
323
+ constructors=frozenset(constructors),
324
+ components=frozenset(components),
325
+ )
326
+ # Register the module object itself so `import pulse.js.math as Math` resolves via EXPR_REGISTRY.
327
+ Expr.register(module, js_module)
328
+
329
+ def __getattr__(name: str) -> Member | Class | Jsx | Identifier | Import | Any:
330
+ if name in overrides:
331
+ return overrides[name]
332
+ if name.startswith("_") or name not in local_names:
333
+ raise AttributeError(name)
334
+ return js_module.get_value(name)
335
+
336
+ module.__getattr__ = __getattr__ # type: ignore[method-assign]
@@ -0,0 +1,33 @@
1
+ """Central registration point for all module transpilers.
2
+
3
+ This module registers all built-in Python modules for transpilation.
4
+ Import this module to ensure all transpilers are available.
5
+ """
6
+
7
+ import asyncio as asyncio_builtin
8
+ import json as json_builtin
9
+ import math as math_builtin
10
+ import typing as typing_builtin
11
+
12
+ import pulse as pulse_module
13
+ import pulse.dom.tags as pulseTags
14
+ from pulse.transpiler.modules.asyncio import PyAsyncio
15
+ from pulse.transpiler.modules.json import PyJson
16
+ from pulse.transpiler.modules.math import PyMath
17
+ from pulse.transpiler.modules.pulse.tags import PulseTags
18
+ from pulse.transpiler.modules.typing import PyTyping
19
+ from pulse.transpiler.py_module import PyModule
20
+
21
+ # Register built-in Python modules
22
+ PyModule.register(math_builtin, PyMath)
23
+ PyModule.register(json_builtin, PyJson)
24
+ PyModule.register(asyncio_builtin, PyAsyncio)
25
+ PyModule.register(typing_builtin, PyTyping)
26
+
27
+ # Register Pulse DOM tags for JSX transpilation
28
+ # This covers `from pulse.dom import tags; tags.div(...)`
29
+ PyModule.register(pulseTags, PulseTags)
30
+
31
+ # Register main pulse module for transpilation
32
+ # This covers `import pulse as ps; ps.div(...)`
33
+ PyModule.register(pulse_module, PulseTags)