pulse-framework 0.1.44__py3-none-any.whl → 0.1.47__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 +10 -24
- pulse/app.py +3 -25
- pulse/codegen/codegen.py +43 -88
- pulse/codegen/js.py +35 -5
- pulse/codegen/templates/route.py +341 -254
- pulse/form.py +1 -1
- pulse/helpers.py +40 -8
- pulse/hooks/core.py +2 -2
- pulse/hooks/effects.py +1 -1
- pulse/hooks/init.py +2 -1
- pulse/hooks/setup.py +1 -1
- pulse/hooks/stable.py +2 -2
- pulse/hooks/states.py +2 -2
- pulse/html/props.py +3 -2
- pulse/html/tags.py +135 -0
- pulse/html/tags.pyi +4 -0
- pulse/js/__init__.py +110 -0
- pulse/js/__init__.pyi +95 -0
- pulse/js/_types.py +297 -0
- pulse/js/array.py +253 -0
- pulse/js/console.py +47 -0
- pulse/js/date.py +113 -0
- pulse/js/document.py +138 -0
- pulse/js/error.py +139 -0
- pulse/js/json.py +62 -0
- pulse/js/map.py +84 -0
- pulse/js/math.py +66 -0
- pulse/js/navigator.py +76 -0
- pulse/js/number.py +54 -0
- pulse/js/object.py +173 -0
- pulse/js/promise.py +150 -0
- pulse/js/regexp.py +54 -0
- pulse/js/set.py +109 -0
- pulse/js/string.py +35 -0
- pulse/js/weakmap.py +50 -0
- pulse/js/weakset.py +45 -0
- pulse/js/window.py +199 -0
- pulse/messages.py +22 -3
- pulse/queries/client.py +7 -7
- pulse/queries/effect.py +16 -0
- pulse/queries/infinite_query.py +138 -29
- pulse/queries/mutation.py +1 -15
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +610 -174
- pulse/queries/store.py +11 -14
- pulse/react_component.py +167 -14
- pulse/reactive.py +19 -1
- pulse/reactive_extensions.py +5 -5
- pulse/render_session.py +185 -59
- pulse/renderer.py +80 -158
- pulse/routing.py +1 -18
- pulse/transpiler/__init__.py +131 -0
- pulse/transpiler/builtins.py +731 -0
- pulse/transpiler/constants.py +110 -0
- pulse/transpiler/context.py +26 -0
- pulse/transpiler/errors.py +2 -0
- pulse/transpiler/function.py +250 -0
- pulse/transpiler/ids.py +16 -0
- pulse/transpiler/imports.py +409 -0
- pulse/transpiler/js_module.py +274 -0
- pulse/transpiler/modules/__init__.py +30 -0
- pulse/transpiler/modules/asyncio.py +38 -0
- pulse/transpiler/modules/json.py +20 -0
- pulse/transpiler/modules/math.py +320 -0
- pulse/transpiler/modules/re.py +466 -0
- pulse/transpiler/modules/tags.py +268 -0
- pulse/transpiler/modules/typing.py +59 -0
- pulse/transpiler/nodes.py +1216 -0
- pulse/transpiler/py_module.py +119 -0
- pulse/transpiler/transpiler.py +938 -0
- pulse/transpiler/utils.py +4 -0
- pulse/types/event_handler.py +3 -2
- pulse/vdom.py +212 -13
- {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/METADATA +1 -1
- pulse_framework-0.1.47.dist-info/RECORD +119 -0
- pulse/codegen/imports.py +0 -204
- pulse/css.py +0 -155
- pulse_framework-0.1.44.dist-info/RECORD +0 -79
- {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Unified JS import system for javascript_v2."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Callable, Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import (
|
|
7
|
+
ClassVar,
|
|
8
|
+
TypeAlias,
|
|
9
|
+
TypeVar,
|
|
10
|
+
TypeVarTuple,
|
|
11
|
+
overload,
|
|
12
|
+
override,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from pulse.transpiler.context import is_interpreted_mode
|
|
16
|
+
from pulse.transpiler.ids import generate_id
|
|
17
|
+
from pulse.transpiler.nodes import JSExpr
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
Args = TypeVarTuple("Args")
|
|
21
|
+
R = TypeVar("R")
|
|
22
|
+
|
|
23
|
+
# Registry key: (name, src, is_default)
|
|
24
|
+
# - Named imports: (name, src, False)
|
|
25
|
+
# - Default/side-effect imports: ("", src, is_default) - dedupe by src only
|
|
26
|
+
_ImportKey: TypeAlias = tuple[str, str, bool]
|
|
27
|
+
_REGISTRY: dict[_ImportKey, "Import"] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _caller_file(depth: int = 2) -> Path:
|
|
31
|
+
"""Get the file path of the caller.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
depth: How many frames to go back (2 = caller of the function that calls this)
|
|
35
|
+
"""
|
|
36
|
+
frame = inspect.currentframe()
|
|
37
|
+
try:
|
|
38
|
+
# Walk up the call stack
|
|
39
|
+
for _ in range(depth):
|
|
40
|
+
if frame is None:
|
|
41
|
+
raise RuntimeError("Cannot determine caller frame")
|
|
42
|
+
frame = frame.f_back
|
|
43
|
+
if frame is None:
|
|
44
|
+
raise RuntimeError("Cannot determine caller frame")
|
|
45
|
+
return Path(frame.f_code.co_filename).resolve()
|
|
46
|
+
finally:
|
|
47
|
+
del frame
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_local_css_path(path: str) -> bool:
|
|
51
|
+
"""Check if a CSS path refers to a local file vs a package import.
|
|
52
|
+
|
|
53
|
+
Local paths:
|
|
54
|
+
- Start with './' or '../' (relative)
|
|
55
|
+
- Start with '/' (absolute)
|
|
56
|
+
- Don't start with '@' (scoped npm package)
|
|
57
|
+
- Don't look like a bare module specifier
|
|
58
|
+
|
|
59
|
+
Package imports:
|
|
60
|
+
- Start with '@' (e.g., '@mantine/core/styles.css')
|
|
61
|
+
- Bare specifiers (e.g., 'some-package/styles.css')
|
|
62
|
+
"""
|
|
63
|
+
# Relative paths are local
|
|
64
|
+
if path.startswith("./") or path.startswith("../"):
|
|
65
|
+
return True
|
|
66
|
+
# Absolute paths are local
|
|
67
|
+
if path.startswith("/"):
|
|
68
|
+
return True
|
|
69
|
+
# Scoped packages
|
|
70
|
+
if path.startswith("@"):
|
|
71
|
+
return False
|
|
72
|
+
# If it contains no slashes, it could be a local file in current dir
|
|
73
|
+
# But without './' prefix, treat bare names as package imports for safety
|
|
74
|
+
# Exception: if it ends with .css and doesn't look like a package
|
|
75
|
+
if "/" not in path and path.endswith(".css"):
|
|
76
|
+
# Ambiguous - could be local or package. Require explicit ./
|
|
77
|
+
return False
|
|
78
|
+
# Everything else (bare specifiers with paths) are package imports
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Import(JSExpr):
|
|
83
|
+
"""Universal import descriptor.
|
|
84
|
+
|
|
85
|
+
Import identity is determined by (name, src, is_default):
|
|
86
|
+
- Named imports: unique by (name, src)
|
|
87
|
+
- Default imports: unique by src (name is the local binding)
|
|
88
|
+
- Side-effect imports: unique by src (name is empty)
|
|
89
|
+
|
|
90
|
+
When two Import objects reference the same underlying import, they share
|
|
91
|
+
the same ID, allowing multiple Import objects to target different properties
|
|
92
|
+
of the same import.
|
|
93
|
+
|
|
94
|
+
Examples:
|
|
95
|
+
# Named import: import { foo } from "./module"
|
|
96
|
+
foo = Import("foo", "./module")
|
|
97
|
+
|
|
98
|
+
# Default import: import React from "react"
|
|
99
|
+
React = Import("React", "react", is_default=True)
|
|
100
|
+
|
|
101
|
+
# Type-only import: import type { Foo } from "./types"
|
|
102
|
+
Foo = Import("Foo", "./types", is_type_only=True)
|
|
103
|
+
|
|
104
|
+
# Side-effect import: import "./styles.css"
|
|
105
|
+
Import.side_effect("./styles.css")
|
|
106
|
+
|
|
107
|
+
# CSS module import with class access
|
|
108
|
+
styles = Import.css_module("./styles.module.css", relative=True)
|
|
109
|
+
styles.container # Returns JSMember for 'container' class
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
__slots__ = ( # pyright: ignore[reportUnannotatedClassAttribute]
|
|
113
|
+
"name",
|
|
114
|
+
"src",
|
|
115
|
+
"is_default",
|
|
116
|
+
"is_namespace",
|
|
117
|
+
"is_type_only",
|
|
118
|
+
"before",
|
|
119
|
+
"id",
|
|
120
|
+
"source_path",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
is_primary: ClassVar[bool] = True
|
|
124
|
+
|
|
125
|
+
name: str
|
|
126
|
+
src: str
|
|
127
|
+
is_default: bool
|
|
128
|
+
is_namespace: bool
|
|
129
|
+
is_type_only: bool
|
|
130
|
+
before: tuple[str, ...]
|
|
131
|
+
id: str
|
|
132
|
+
source_path: Path | None # For local CSS files that need to be copied
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
name: str,
|
|
137
|
+
src: str,
|
|
138
|
+
*,
|
|
139
|
+
is_default: bool = False,
|
|
140
|
+
is_namespace: bool = False,
|
|
141
|
+
is_type_only: bool = False,
|
|
142
|
+
before: Sequence[str] = (),
|
|
143
|
+
source_path: Path | None = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
self.name = name
|
|
146
|
+
self.src = src
|
|
147
|
+
self.is_default = is_default
|
|
148
|
+
self.is_namespace = is_namespace
|
|
149
|
+
self.source_path = source_path
|
|
150
|
+
|
|
151
|
+
before_tuple = tuple(before)
|
|
152
|
+
|
|
153
|
+
# Dedupe key: for default/side-effect/namespace imports, only src matters
|
|
154
|
+
key: _ImportKey = (
|
|
155
|
+
("", src, is_default or is_namespace)
|
|
156
|
+
if (is_default or is_namespace or name == "")
|
|
157
|
+
else (name, src, False)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if key in _REGISTRY:
|
|
161
|
+
existing = _REGISTRY[key]
|
|
162
|
+
|
|
163
|
+
# Merge: type-only + regular = regular
|
|
164
|
+
if existing.is_type_only and not is_type_only:
|
|
165
|
+
existing.is_type_only = False
|
|
166
|
+
|
|
167
|
+
# Merge: union of before constraints
|
|
168
|
+
if before_tuple:
|
|
169
|
+
merged_before = set(existing.before) | set(before_tuple)
|
|
170
|
+
existing.before = tuple(sorted(merged_before))
|
|
171
|
+
|
|
172
|
+
# Reuse ID and merged values
|
|
173
|
+
self.id = existing.id
|
|
174
|
+
self.is_type_only = existing.is_type_only
|
|
175
|
+
self.before = existing.before
|
|
176
|
+
else:
|
|
177
|
+
# New import
|
|
178
|
+
self.id = generate_id()
|
|
179
|
+
self.is_type_only = is_type_only
|
|
180
|
+
self.before = before_tuple
|
|
181
|
+
_REGISTRY[key] = self
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def default(
|
|
185
|
+
cls,
|
|
186
|
+
name: str,
|
|
187
|
+
src: str,
|
|
188
|
+
*,
|
|
189
|
+
is_type_only: bool = False,
|
|
190
|
+
before: Sequence[str] = (),
|
|
191
|
+
) -> "Import":
|
|
192
|
+
"""Create a default import."""
|
|
193
|
+
return cls(
|
|
194
|
+
name,
|
|
195
|
+
src,
|
|
196
|
+
is_default=True,
|
|
197
|
+
is_type_only=is_type_only,
|
|
198
|
+
before=before,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def named(
|
|
203
|
+
cls,
|
|
204
|
+
name: str,
|
|
205
|
+
src: str,
|
|
206
|
+
*,
|
|
207
|
+
is_type_only: bool = False,
|
|
208
|
+
before: Sequence[str] = (),
|
|
209
|
+
) -> "Import":
|
|
210
|
+
"""Create a named import."""
|
|
211
|
+
return cls(
|
|
212
|
+
name,
|
|
213
|
+
src,
|
|
214
|
+
is_default=False,
|
|
215
|
+
is_type_only=is_type_only,
|
|
216
|
+
before=before,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def namespace(
|
|
221
|
+
cls,
|
|
222
|
+
name: str,
|
|
223
|
+
src: str,
|
|
224
|
+
*,
|
|
225
|
+
before: Sequence[str] = (),
|
|
226
|
+
) -> "Import":
|
|
227
|
+
"""Create a namespace import: import * as name from src."""
|
|
228
|
+
return cls(
|
|
229
|
+
name,
|
|
230
|
+
src,
|
|
231
|
+
is_namespace=True,
|
|
232
|
+
before=before,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
@classmethod
|
|
236
|
+
def type_(
|
|
237
|
+
cls,
|
|
238
|
+
name: str,
|
|
239
|
+
src: str,
|
|
240
|
+
*,
|
|
241
|
+
is_default: bool = False,
|
|
242
|
+
before: Sequence[str] = (),
|
|
243
|
+
) -> "Import":
|
|
244
|
+
"""Create a type-only import."""
|
|
245
|
+
return cls(name, src, is_default=is_default, is_type_only=True, before=before)
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def is_side_effect(self) -> bool:
|
|
249
|
+
"""True if this is a side-effect only import (no bindings)."""
|
|
250
|
+
return self.name == "" and not self.is_default
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def js_name(self) -> str:
|
|
254
|
+
"""Unique JS identifier for this import."""
|
|
255
|
+
return f"{self.name}_{self.id}"
|
|
256
|
+
|
|
257
|
+
@override
|
|
258
|
+
def emit(self) -> str:
|
|
259
|
+
"""Emit JS code for this import.
|
|
260
|
+
|
|
261
|
+
In normal mode: returns the unique JS name (e.g., "Button_1")
|
|
262
|
+
In interpreted mode: returns a get_object call (e.g., "get_object('Button_1')")
|
|
263
|
+
"""
|
|
264
|
+
base = self.js_name
|
|
265
|
+
if is_interpreted_mode():
|
|
266
|
+
return f"get_object('{base}')"
|
|
267
|
+
return base
|
|
268
|
+
|
|
269
|
+
@override
|
|
270
|
+
def __repr__(self) -> str:
|
|
271
|
+
parts = [f"name={self.name!r}", f"src={self.src!r}"]
|
|
272
|
+
if self.is_default:
|
|
273
|
+
parts.append("is_default=True")
|
|
274
|
+
if self.is_namespace:
|
|
275
|
+
parts.append("is_namespace=True")
|
|
276
|
+
if self.is_type_only:
|
|
277
|
+
parts.append("is_type_only=True")
|
|
278
|
+
if self.source_path:
|
|
279
|
+
parts.append(f"source_path={self.source_path!r}")
|
|
280
|
+
return f"Import({', '.join(parts)})"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class CssImport(Import):
|
|
284
|
+
"""Import for CSS files (both local files and npm packages).
|
|
285
|
+
|
|
286
|
+
For local files, tracks the source path and provides a generated filename
|
|
287
|
+
for the output directory. For npm packages, acts as a regular import.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
path: Path to CSS file. Can be:
|
|
291
|
+
- Package path (e.g., "@mantine/core/styles.css")
|
|
292
|
+
- Relative path with relative=True (e.g., "./global.css")
|
|
293
|
+
- Absolute path (e.g., "/path/to/styles.css")
|
|
294
|
+
module: If True, import as a CSS module (default export for class access).
|
|
295
|
+
If False, import for side effects only (global styles).
|
|
296
|
+
relative: If True, resolve path relative to the caller's file.
|
|
297
|
+
before: List of import sources that should come after this import.
|
|
298
|
+
|
|
299
|
+
Examples:
|
|
300
|
+
# Side-effect CSS import (global styles)
|
|
301
|
+
CssImport("@mantine/core/styles.css")
|
|
302
|
+
|
|
303
|
+
# CSS module for class access
|
|
304
|
+
styles = CssImport("./styles.module.css", module=True, relative=True)
|
|
305
|
+
styles.container # Returns JSMember for 'container' class
|
|
306
|
+
|
|
307
|
+
# Local CSS file (will be copied during codegen)
|
|
308
|
+
CssImport("./global.css", relative=True)
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
def __init__(
|
|
312
|
+
self,
|
|
313
|
+
path: str,
|
|
314
|
+
*,
|
|
315
|
+
module: bool = False,
|
|
316
|
+
relative: bool = False,
|
|
317
|
+
before: Sequence[str] = (),
|
|
318
|
+
) -> None:
|
|
319
|
+
source_path: Path | None = None
|
|
320
|
+
import_src = path
|
|
321
|
+
|
|
322
|
+
if relative:
|
|
323
|
+
# Resolve relative to caller's file (depth=2: _caller_file -> __init__ -> caller)
|
|
324
|
+
caller = _caller_file(depth=2)
|
|
325
|
+
source_path = (caller.parent / Path(path)).resolve()
|
|
326
|
+
if not source_path.exists():
|
|
327
|
+
kind = "CSS module" if module else "CSS file"
|
|
328
|
+
raise FileNotFoundError(
|
|
329
|
+
f"{kind} '{path}' not found relative to {caller.parent}"
|
|
330
|
+
)
|
|
331
|
+
import_src = str(source_path)
|
|
332
|
+
elif _is_local_css_path(path):
|
|
333
|
+
# Absolute local path
|
|
334
|
+
source_path = Path(path).resolve()
|
|
335
|
+
if not source_path.exists():
|
|
336
|
+
kind = "CSS module" if module else "CSS file"
|
|
337
|
+
raise FileNotFoundError(f"{kind} '{path}' not found")
|
|
338
|
+
import_src = str(source_path)
|
|
339
|
+
|
|
340
|
+
# CSS modules are default imports with "css" name prefix
|
|
341
|
+
# Side-effect imports have empty name and is_default=False
|
|
342
|
+
name = "css" if module else ""
|
|
343
|
+
is_default = module
|
|
344
|
+
|
|
345
|
+
super().__init__(
|
|
346
|
+
name,
|
|
347
|
+
import_src,
|
|
348
|
+
is_default=is_default,
|
|
349
|
+
is_type_only=False,
|
|
350
|
+
before=before,
|
|
351
|
+
source_path=source_path,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def is_local(self) -> bool:
|
|
356
|
+
"""True if this is a local CSS file (not an npm package)."""
|
|
357
|
+
return self.source_path is not None
|
|
358
|
+
|
|
359
|
+
@property
|
|
360
|
+
def generated_filename(self) -> str | None:
|
|
361
|
+
"""Generated filename for local CSS files, or None for package imports."""
|
|
362
|
+
if self.source_path is None:
|
|
363
|
+
return None
|
|
364
|
+
if self.source_path.name.endswith(".module.css"):
|
|
365
|
+
return f"css_{self.id}.module.css"
|
|
366
|
+
return f"css_{self.id}.css"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def registered_imports() -> list[Import]:
|
|
370
|
+
"""Get all registered imports."""
|
|
371
|
+
return list(_REGISTRY.values())
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def clear_import_registry() -> None:
|
|
375
|
+
"""Clear the import registry."""
|
|
376
|
+
_REGISTRY.clear()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# =============================================================================
|
|
380
|
+
# js_import decorator/function
|
|
381
|
+
# =============================================================================
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@overload
|
|
385
|
+
def import_js(
|
|
386
|
+
name: str, src: str, *, is_default: bool = False
|
|
387
|
+
) -> Callable[[Callable[[*Args], R]], Callable[[*Args], R]]:
|
|
388
|
+
"Import a JS function for use in `@javascript` functions"
|
|
389
|
+
...
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@overload
|
|
393
|
+
def import_js(name: str, src: str, type_: type[T], *, is_default: bool = False) -> T:
|
|
394
|
+
"Import a JS value for use in `@javascript` functions"
|
|
395
|
+
...
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def import_js(
|
|
399
|
+
name: str, src: str, type_: type[T] | None = None, *, is_default: bool = False
|
|
400
|
+
) -> T | Callable[[Callable[[*Args], R]], Callable[[*Args], R]]:
|
|
401
|
+
imp = Import.default(name, src) if is_default else Import.named(name, src)
|
|
402
|
+
|
|
403
|
+
if type_ is not None:
|
|
404
|
+
return imp # pyright: ignore[reportReturnType]
|
|
405
|
+
|
|
406
|
+
def decorator(fn: Callable[[*Args], R]) -> Callable[[*Args], R]:
|
|
407
|
+
return imp # pyright: ignore[reportReturnType]
|
|
408
|
+
|
|
409
|
+
return decorator
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Core infrastructure for JavaScript module bindings.
|
|
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 inspect
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from types import FunctionType, ModuleType
|
|
14
|
+
from typing import Any, ClassVar, Literal, TypeVar, override
|
|
15
|
+
|
|
16
|
+
from pulse.transpiler.errors import JSCompilationError
|
|
17
|
+
from pulse.transpiler.imports import Import
|
|
18
|
+
from pulse.transpiler.nodes import JSExpr, JSIdentifier, JSMember, JSNew
|
|
19
|
+
|
|
20
|
+
# Track functions marked as constructors (by id, since we delete them)
|
|
21
|
+
CONSTRUCTORS: set[int] = set()
|
|
22
|
+
|
|
23
|
+
F = TypeVar("F", bound=Callable[..., object])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class JsModule:
|
|
28
|
+
"""Configuration for a JavaScript module binding.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
name: The JavaScript identifier (e.g., "Math", "lodash")
|
|
32
|
+
src: Import source path. None for builtins.
|
|
33
|
+
kind: Import kind - "default" or "namespace"
|
|
34
|
+
values: How attribute access is expressed:
|
|
35
|
+
- "member": Access as property (e.g., React.useState)
|
|
36
|
+
- "named_import": Each attribute is a named import (e.g., import { useState } from "react")
|
|
37
|
+
constructors: Set of names that are constructors (emit with 'new')
|
|
38
|
+
global_scope: If True, members are registered in global scope. Module imports are disallowed.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
src: str | None = None
|
|
43
|
+
kind: Literal["default", "namespace"] = "namespace"
|
|
44
|
+
values: Literal["member", "named_import"] = "named_import"
|
|
45
|
+
constructors: frozenset[str] = field(default_factory=frozenset)
|
|
46
|
+
global_scope: bool = False
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_builtin(self) -> bool:
|
|
50
|
+
return self.src is None
|
|
51
|
+
|
|
52
|
+
def to_js_expr(self) -> JSIdentifier | Import:
|
|
53
|
+
"""Generate the appropriate JSExpr for this module.
|
|
54
|
+
|
|
55
|
+
Returns JSIdentifier for builtins, Import for external modules.
|
|
56
|
+
|
|
57
|
+
Raises JSCompilationError if global_scope=True (module imports are disallowed).
|
|
58
|
+
"""
|
|
59
|
+
if self.global_scope:
|
|
60
|
+
module_name_lower = self.name.lower()
|
|
61
|
+
msg = (
|
|
62
|
+
f"Cannot import module '{self.name}' directly. "
|
|
63
|
+
+ f"Use 'from pulse.js.{module_name_lower} import ...' instead."
|
|
64
|
+
)
|
|
65
|
+
raise JSCompilationError(msg)
|
|
66
|
+
|
|
67
|
+
if self.src is None:
|
|
68
|
+
return JSIdentifier(self.name)
|
|
69
|
+
|
|
70
|
+
if self.kind == "default":
|
|
71
|
+
return Import.default(self.name, self.src)
|
|
72
|
+
return Import.namespace(self.name, self.src)
|
|
73
|
+
|
|
74
|
+
def get_value(self, name: str) -> JSMember | JSConstructor | JSIdentifier | Import:
|
|
75
|
+
"""Get a member of this module as a JS expression.
|
|
76
|
+
|
|
77
|
+
For global_scope modules: returns JSIdentifier(name) directly (e.g., Set -> Set)
|
|
78
|
+
For builtins: returns JSMember (e.g., Math.sin), or JSIdentifier if name
|
|
79
|
+
matches the module name (e.g., Set -> Set, not Set.Set)
|
|
80
|
+
For external modules with "member" style: returns JSMember (e.g., React.useState)
|
|
81
|
+
For external modules with "named_import" style: returns a named Import (e.g., import { useState } from "react")
|
|
82
|
+
|
|
83
|
+
If name is in constructors, wraps the result in JSConstructor.
|
|
84
|
+
"""
|
|
85
|
+
expr: JSMember | JSIdentifier | Import
|
|
86
|
+
if self.global_scope:
|
|
87
|
+
# Global scope: members are just identifiers, not members of a module
|
|
88
|
+
expr = JSIdentifier(name)
|
|
89
|
+
elif self.src is None:
|
|
90
|
+
# Builtins: use identifier when name matches module name (Set.Set -> Set)
|
|
91
|
+
if name == self.name:
|
|
92
|
+
expr = JSIdentifier(name)
|
|
93
|
+
else:
|
|
94
|
+
expr = JSMember(JSIdentifier(self.name), name)
|
|
95
|
+
elif self.values == "named_import":
|
|
96
|
+
expr = Import.named(name, self.src)
|
|
97
|
+
else:
|
|
98
|
+
expr = JSMember(self.to_js_expr(), name)
|
|
99
|
+
|
|
100
|
+
if name in self.constructors:
|
|
101
|
+
return JSConstructor(expr)
|
|
102
|
+
return expr
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class JSConstructor(JSExpr):
|
|
107
|
+
"""Wrapper that emits constructor calls with 'new' keyword.
|
|
108
|
+
|
|
109
|
+
When this expression is called, it produces JSNew instead of JSCall.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
ctor: JSExpr
|
|
113
|
+
is_primary: ClassVar[bool] = True
|
|
114
|
+
|
|
115
|
+
@override
|
|
116
|
+
def emit(self) -> str:
|
|
117
|
+
return self.ctor.emit()
|
|
118
|
+
|
|
119
|
+
@override
|
|
120
|
+
def emit_call(self, args: list[Any], kwargs: dict[str, Any]) -> JSExpr:
|
|
121
|
+
if kwargs:
|
|
122
|
+
raise JSCompilationError(
|
|
123
|
+
"Keyword arguments not supported in constructor call"
|
|
124
|
+
)
|
|
125
|
+
return JSNew(self.ctor, [JSExpr.of(a) for a in args])
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Registry: Python module -> JsModule config
|
|
129
|
+
JS_MODULES: dict[ModuleType, JsModule] = {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def register_js_module(
|
|
133
|
+
*,
|
|
134
|
+
name: str,
|
|
135
|
+
src: str | None = None,
|
|
136
|
+
kind: Literal["default", "namespace"] = "namespace",
|
|
137
|
+
values: Literal["member", "named_import"] = "named_import",
|
|
138
|
+
global_scope: bool = False,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Register the calling Python module as a JavaScript module binding.
|
|
141
|
+
|
|
142
|
+
Must be called from within the module being registered. The module is
|
|
143
|
+
automatically detected from the call stack.
|
|
144
|
+
|
|
145
|
+
This function:
|
|
146
|
+
1. Creates a JsModule config and adds it to JS_MODULES
|
|
147
|
+
2. Sets up __getattr__ on the module for dynamic attribute access
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
name: The JavaScript identifier (e.g., "Math")
|
|
151
|
+
src: Import source path. None for builtins.
|
|
152
|
+
kind: Import kind - "default" or "namespace"
|
|
153
|
+
values: How attribute access works:
|
|
154
|
+
- "member": Access as property (e.g., Math.sin, React.useState)
|
|
155
|
+
- "named_import": Each attribute is a named import (e.g., import { useState } from "react")
|
|
156
|
+
global_scope: If True, members are registered in global scope. Module imports
|
|
157
|
+
(e.g., `import pulse.js.set as Set`) are disallowed. Members are transpiled
|
|
158
|
+
as direct identifiers (e.g., `Set` -> `Set`, not `Set.Set`).
|
|
159
|
+
|
|
160
|
+
Example (inside pulse/js/math.py):
|
|
161
|
+
register_js_module(name="Math") # builtin
|
|
162
|
+
|
|
163
|
+
Example (inside pulse/js/react.py):
|
|
164
|
+
register_js_module(name="React", src="react") # namespace + named imports (default)
|
|
165
|
+
|
|
166
|
+
Example (inside pulse/js/set.py):
|
|
167
|
+
register_js_module(name="Set", global_scope=True) # global scope builtin
|
|
168
|
+
"""
|
|
169
|
+
# Get the calling module from the stack frame
|
|
170
|
+
frame = inspect.currentframe()
|
|
171
|
+
assert frame is not None and frame.f_back is not None
|
|
172
|
+
module_name = frame.f_back.f_globals["__name__"]
|
|
173
|
+
module = sys.modules[module_name]
|
|
174
|
+
|
|
175
|
+
# Collect constructor names before deleting functions/classes
|
|
176
|
+
# Classes are automatically treated as constructors
|
|
177
|
+
# Only items defined in this module are considered (not imported ones)
|
|
178
|
+
# Track locally defined names so __getattr__ can distinguish them from imported ones
|
|
179
|
+
|
|
180
|
+
def is_defined_in_module(obj: Any) -> bool:
|
|
181
|
+
"""Check if an object is defined in the current module (not imported)."""
|
|
182
|
+
return hasattr(obj, "__module__") and obj.__module__ == module_name
|
|
183
|
+
|
|
184
|
+
ctor_names: set[str] = set()
|
|
185
|
+
locally_defined_names: set[str] = set()
|
|
186
|
+
|
|
187
|
+
for attr_name in list(vars(module)):
|
|
188
|
+
# Skip special module attributes
|
|
189
|
+
if attr_name in (
|
|
190
|
+
"__name__",
|
|
191
|
+
"__file__",
|
|
192
|
+
"__doc__",
|
|
193
|
+
"__package__",
|
|
194
|
+
"__path__",
|
|
195
|
+
"__cached__",
|
|
196
|
+
"__loader__",
|
|
197
|
+
"__spec__",
|
|
198
|
+
):
|
|
199
|
+
continue
|
|
200
|
+
attr = getattr(module, attr_name)
|
|
201
|
+
# Check if this is an imported name (has __module__ that doesn't match)
|
|
202
|
+
is_imported = hasattr(attr, "__module__") and attr.__module__ != module_name
|
|
203
|
+
if isinstance(attr, FunctionType):
|
|
204
|
+
# Functions without __module__ are assumed to be locally defined (stub functions)
|
|
205
|
+
if not is_imported and (
|
|
206
|
+
not hasattr(attr, "__module__") or is_defined_in_module(attr)
|
|
207
|
+
):
|
|
208
|
+
# Only track non-underscore-prefixed names for exports
|
|
209
|
+
if not attr_name.startswith("_"):
|
|
210
|
+
locally_defined_names.add(attr_name)
|
|
211
|
+
if id(attr) in CONSTRUCTORS:
|
|
212
|
+
ctor_names.add(attr_name)
|
|
213
|
+
delattr(module, attr_name)
|
|
214
|
+
else:
|
|
215
|
+
# Delete imported functions (including underscore-prefixed)
|
|
216
|
+
delattr(module, attr_name)
|
|
217
|
+
elif inspect.isclass(attr):
|
|
218
|
+
# Only consider classes defined in this module (not imported)
|
|
219
|
+
if not is_imported and is_defined_in_module(attr):
|
|
220
|
+
# Only track non-underscore-prefixed names for exports
|
|
221
|
+
if not attr_name.startswith("_"):
|
|
222
|
+
locally_defined_names.add(attr_name)
|
|
223
|
+
ctor_names.add(attr_name)
|
|
224
|
+
delattr(module, attr_name)
|
|
225
|
+
else:
|
|
226
|
+
# Delete imported classes (including underscore-prefixed)
|
|
227
|
+
delattr(module, attr_name)
|
|
228
|
+
elif is_imported:
|
|
229
|
+
# Delete all imported objects (TypeVar, Generic, etc.) including underscore-prefixed
|
|
230
|
+
delattr(module, attr_name)
|
|
231
|
+
else:
|
|
232
|
+
# For objects without __module__, assume they're locally defined
|
|
233
|
+
# (constants, etc.) - but constants are usually just annotations
|
|
234
|
+
# so they won't be in vars(module) anyway
|
|
235
|
+
if not attr_name.startswith("_"):
|
|
236
|
+
locally_defined_names.add(attr_name)
|
|
237
|
+
delattr(module, attr_name)
|
|
238
|
+
|
|
239
|
+
# Collect constant names from annotations (constants are just type annotations)
|
|
240
|
+
# before clearing them
|
|
241
|
+
constant_names: set[str] = set()
|
|
242
|
+
if hasattr(module, "__annotations__"):
|
|
243
|
+
for ann_name in module.__annotations__:
|
|
244
|
+
if not ann_name.startswith("_") and ann_name not in locally_defined_names:
|
|
245
|
+
constant_names.add(ann_name)
|
|
246
|
+
# Clear annotations (they're just for IDE hints, not runtime values)
|
|
247
|
+
module.__annotations__.clear()
|
|
248
|
+
|
|
249
|
+
js_module = JsModule(
|
|
250
|
+
name=name,
|
|
251
|
+
src=src,
|
|
252
|
+
kind=kind,
|
|
253
|
+
values=values,
|
|
254
|
+
constructors=frozenset(ctor_names),
|
|
255
|
+
global_scope=global_scope,
|
|
256
|
+
)
|
|
257
|
+
JS_MODULES[module] = js_module
|
|
258
|
+
|
|
259
|
+
# Include constants in locally_defined_names so they're accessible via __getattr__
|
|
260
|
+
locally_defined_names.update(constant_names)
|
|
261
|
+
|
|
262
|
+
# Set up __getattr__ - all attribute access now goes through here
|
|
263
|
+
# Capture locally_defined_names in closure
|
|
264
|
+
_defined_names = locally_defined_names
|
|
265
|
+
|
|
266
|
+
def __getattr__(name: str) -> JSMember | JSConstructor | JSIdentifier | Import:
|
|
267
|
+
if name.startswith("_"):
|
|
268
|
+
raise AttributeError(name)
|
|
269
|
+
# Only allow access to locally defined names (functions, classes, constants)
|
|
270
|
+
if name not in _defined_names:
|
|
271
|
+
raise AttributeError(name)
|
|
272
|
+
return js_module.get_value(name)
|
|
273
|
+
|
|
274
|
+
module.__getattr__ = __getattr__ # type: ignore[method-assign]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Central registration point for all module transpilers.
|
|
2
|
+
|
|
3
|
+
This module registers all built-in Python and JavaScript 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 re as re_builtin
|
|
11
|
+
import typing as typing_builtin
|
|
12
|
+
|
|
13
|
+
import pulse.html.tags as pulse_tags
|
|
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.re import PyRe
|
|
18
|
+
from pulse.transpiler.modules.tags import PyTags
|
|
19
|
+
from pulse.transpiler.modules.typing import PyTyping
|
|
20
|
+
from pulse.transpiler.py_module import register_module
|
|
21
|
+
|
|
22
|
+
# Register built-in Python modules
|
|
23
|
+
register_module(asyncio_builtin, PyAsyncio)
|
|
24
|
+
register_module(json_builtin, PyJson)
|
|
25
|
+
register_module(math_builtin, PyMath)
|
|
26
|
+
register_module(re_builtin, PyRe)
|
|
27
|
+
register_module(typing_builtin, PyTyping)
|
|
28
|
+
|
|
29
|
+
# Register Pulse HTML tags for JSX transpilation
|
|
30
|
+
register_module(pulse_tags, PyTags)
|