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.
- pulse/__init__.py +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- 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)
|