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,611 @@
|
|
|
1
|
+
"""Function transpilation system for transpiler.
|
|
2
|
+
|
|
3
|
+
Provides the @javascript decorator for marking Python functions for JS transpilation,
|
|
4
|
+
and JsFunction which wraps transpiled functions with their dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import inspect
|
|
11
|
+
import textwrap
|
|
12
|
+
import types as pytypes
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import (
|
|
17
|
+
Any,
|
|
18
|
+
Generic,
|
|
19
|
+
Literal,
|
|
20
|
+
ParamSpec,
|
|
21
|
+
TypeAlias,
|
|
22
|
+
TypeVar,
|
|
23
|
+
TypeVarTuple,
|
|
24
|
+
overload,
|
|
25
|
+
override,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from pulse.helpers import getsourcecode
|
|
29
|
+
from pulse.transpiler.errors import TranspileError
|
|
30
|
+
from pulse.transpiler.id import next_id, reset_id_counter
|
|
31
|
+
from pulse.transpiler.imports import Import
|
|
32
|
+
from pulse.transpiler.nodes import (
|
|
33
|
+
EXPR_REGISTRY,
|
|
34
|
+
Arrow,
|
|
35
|
+
Expr,
|
|
36
|
+
Function,
|
|
37
|
+
Jsx,
|
|
38
|
+
Return,
|
|
39
|
+
to_js_identifier,
|
|
40
|
+
)
|
|
41
|
+
from pulse.transpiler.transpiler import Transpiler
|
|
42
|
+
from pulse.transpiler.vdom import VDOMExpr
|
|
43
|
+
|
|
44
|
+
Args = TypeVarTuple("Args")
|
|
45
|
+
P = ParamSpec("P")
|
|
46
|
+
R = TypeVar("R")
|
|
47
|
+
AnyJsFunction: TypeAlias = "JsFunction[*tuple[Any, ...], Any] | JsxFunction[..., Any]"
|
|
48
|
+
|
|
49
|
+
# Global cache for deduplication across all transpiled functions
|
|
50
|
+
# Registered BEFORE analyzing deps to handle mutual recursion
|
|
51
|
+
# Stores JsFunction for regular @javascript, JsxFunction for @javascript(jsx=True)
|
|
52
|
+
FUNCTION_CACHE: dict[Callable[..., Any], AnyJsFunction] = {}
|
|
53
|
+
|
|
54
|
+
# Global registry for hoisted constants: id(value) -> Constant
|
|
55
|
+
# Used for deduplication of non-primitive values in transpiled functions
|
|
56
|
+
CONSTANT_REGISTRY: dict[int, "Constant"] = {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def clear_function_cache() -> None:
|
|
60
|
+
"""Clear function/constant/ref caches and reset the shared ID counters."""
|
|
61
|
+
from pulse.transpiler.assets import clear_asset_registry
|
|
62
|
+
from pulse.transpiler.imports import clear_import_registry
|
|
63
|
+
|
|
64
|
+
FUNCTION_CACHE.clear()
|
|
65
|
+
CONSTANT_REGISTRY.clear()
|
|
66
|
+
clear_import_registry()
|
|
67
|
+
clear_asset_registry()
|
|
68
|
+
reset_id_counter()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(slots=True, init=False)
|
|
72
|
+
class Constant(Expr):
|
|
73
|
+
"""A hoisted constant value with a unique identifier.
|
|
74
|
+
|
|
75
|
+
Used for non-primitive values (lists, dicts, sets) referenced in transpiled
|
|
76
|
+
functions. The value is emitted once at module scope, and the function
|
|
77
|
+
references it by name.
|
|
78
|
+
|
|
79
|
+
Example:
|
|
80
|
+
ITEMS = [1, 2, 3]
|
|
81
|
+
|
|
82
|
+
@javascript
|
|
83
|
+
def foo():
|
|
84
|
+
return ITEMS[0]
|
|
85
|
+
|
|
86
|
+
# Emits:
|
|
87
|
+
# const ITEMS_1 = [1, 2, 3];
|
|
88
|
+
# function foo_2() { return ITEMS_1[0]; }
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
value: Any
|
|
92
|
+
expr: Expr
|
|
93
|
+
id: str
|
|
94
|
+
name: str
|
|
95
|
+
|
|
96
|
+
def __init__(self, value: Any, expr: Expr, name: str = "") -> None:
|
|
97
|
+
self.value = value
|
|
98
|
+
self.expr = expr
|
|
99
|
+
self.id = next_id()
|
|
100
|
+
self.name = name
|
|
101
|
+
# Register in global cache
|
|
102
|
+
CONSTANT_REGISTRY[id(value)] = self
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def js_name(self) -> str:
|
|
106
|
+
"""Unique JS identifier for this constant."""
|
|
107
|
+
if self.name:
|
|
108
|
+
return f"{to_js_identifier(self.name)}_{self.id}"
|
|
109
|
+
return f"_const_{self.id}"
|
|
110
|
+
|
|
111
|
+
@override
|
|
112
|
+
def emit(self, out: list[str]) -> None:
|
|
113
|
+
"""Emit the unique JS identifier."""
|
|
114
|
+
out.append(self.js_name)
|
|
115
|
+
|
|
116
|
+
@override
|
|
117
|
+
def render(self) -> VDOMExpr:
|
|
118
|
+
"""Render as a registry reference."""
|
|
119
|
+
return {"t": "ref", "key": self.id}
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def wrap(value: Any, name: str = "") -> "Constant":
|
|
123
|
+
"""Get or create a Constant for a value (cached by identity)."""
|
|
124
|
+
if (existing := CONSTANT_REGISTRY.get(id(value))) is not None:
|
|
125
|
+
return existing
|
|
126
|
+
expr = Expr.of(value)
|
|
127
|
+
return Constant(value, expr, name)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def registered_constants() -> list[Constant]:
|
|
131
|
+
"""Get all registered constants."""
|
|
132
|
+
return list(CONSTANT_REGISTRY.values())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _transpile_function_body(
|
|
136
|
+
fn: Callable[..., Any],
|
|
137
|
+
deps: dict[str, Expr],
|
|
138
|
+
*,
|
|
139
|
+
jsx: bool = False,
|
|
140
|
+
) -> tuple[Function | Arrow, str]:
|
|
141
|
+
"""Shared transpilation logic for JsFunction and JsxFunction.
|
|
142
|
+
|
|
143
|
+
Returns the transpiled Function/Arrow node and the source code.
|
|
144
|
+
"""
|
|
145
|
+
# Get and parse source
|
|
146
|
+
src = getsourcecode(fn)
|
|
147
|
+
src = textwrap.dedent(src)
|
|
148
|
+
try:
|
|
149
|
+
source_start_line = inspect.getsourcelines(fn)[1]
|
|
150
|
+
except (OSError, TypeError):
|
|
151
|
+
source_start_line = None
|
|
152
|
+
module = ast.parse(src)
|
|
153
|
+
|
|
154
|
+
# Find the function definition
|
|
155
|
+
fndefs = [
|
|
156
|
+
n for n in module.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
157
|
+
]
|
|
158
|
+
if not fndefs:
|
|
159
|
+
raise TranspileError("No function definition found in source")
|
|
160
|
+
fndef = fndefs[-1]
|
|
161
|
+
|
|
162
|
+
# Get filename for error messages and source file resolution
|
|
163
|
+
try:
|
|
164
|
+
filename = inspect.getfile(fn)
|
|
165
|
+
except (TypeError, OSError):
|
|
166
|
+
filename = None
|
|
167
|
+
|
|
168
|
+
# Transpile with source context for errors
|
|
169
|
+
try:
|
|
170
|
+
source_file = Path(filename) if filename else None
|
|
171
|
+
transpiler = Transpiler(fndef, deps, jsx=jsx, source_file=source_file)
|
|
172
|
+
result = transpiler.transpile()
|
|
173
|
+
except TranspileError as e:
|
|
174
|
+
# Re-raise with source context if not already present
|
|
175
|
+
if e.source is None:
|
|
176
|
+
raise e.with_context(
|
|
177
|
+
source=src,
|
|
178
|
+
filename=filename,
|
|
179
|
+
func_name=fn.__name__,
|
|
180
|
+
source_start_line=source_start_line,
|
|
181
|
+
) from None
|
|
182
|
+
raise
|
|
183
|
+
|
|
184
|
+
return result, src
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(slots=True, init=False)
|
|
188
|
+
class JsFunction(Expr, Generic[*Args, R]):
|
|
189
|
+
"""A transpiled JavaScript function.
|
|
190
|
+
|
|
191
|
+
Wraps a Python function with:
|
|
192
|
+
- A unique identifier for deduplication
|
|
193
|
+
- Resolved dependencies (other functions, imports, constants, etc.)
|
|
194
|
+
- The ability to transpile to JavaScript code
|
|
195
|
+
|
|
196
|
+
When emitted, produces the unique JS function name (e.g., "myFunc_1").
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
fn: Callable[[*Args], R]
|
|
200
|
+
id: str
|
|
201
|
+
deps: dict[str, Expr]
|
|
202
|
+
_transpiled: Function | None = field(default=None)
|
|
203
|
+
|
|
204
|
+
def __init__(self, fn: Callable[..., Any], *, _register: bool = True) -> None:
|
|
205
|
+
self.fn = fn
|
|
206
|
+
self.id = next_id()
|
|
207
|
+
self._transpiled = None
|
|
208
|
+
if _register:
|
|
209
|
+
# Register self in cache BEFORE analyzing deps (handles cycles)
|
|
210
|
+
FUNCTION_CACHE[fn] = self
|
|
211
|
+
# Now analyze and build deps (may recursively call JsFunction() which will find us in cache)
|
|
212
|
+
self.deps = analyze_deps(fn)
|
|
213
|
+
|
|
214
|
+
@override
|
|
215
|
+
def __call__(self, *args: *Args) -> R: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
216
|
+
return Expr.__call__(self, *args) # pyright: ignore[reportReturnType]
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def js_name(self) -> str:
|
|
220
|
+
"""Unique JS identifier for this function."""
|
|
221
|
+
return f"{to_js_identifier(self.fn.__name__)}_{self.id}"
|
|
222
|
+
|
|
223
|
+
@override
|
|
224
|
+
def emit(self, out: list[str]) -> None:
|
|
225
|
+
"""Emit this function as its unique JS identifier."""
|
|
226
|
+
out.append(self.js_name)
|
|
227
|
+
|
|
228
|
+
@override
|
|
229
|
+
def render(self) -> VDOMExpr:
|
|
230
|
+
"""Render as a registry reference."""
|
|
231
|
+
return {"t": "ref", "key": self.id}
|
|
232
|
+
|
|
233
|
+
def transpile(self) -> Function:
|
|
234
|
+
"""Transpile this function to a v2 Function node.
|
|
235
|
+
|
|
236
|
+
Returns the Function node (cached after first call).
|
|
237
|
+
"""
|
|
238
|
+
if self._transpiled is not None:
|
|
239
|
+
return self._transpiled
|
|
240
|
+
|
|
241
|
+
result, _ = _transpile_function_body(self.fn, self.deps)
|
|
242
|
+
|
|
243
|
+
# Convert Arrow to Function if needed, and set the name
|
|
244
|
+
if isinstance(result, Function):
|
|
245
|
+
result = Function(
|
|
246
|
+
params=result.params,
|
|
247
|
+
body=result.body,
|
|
248
|
+
name=self.js_name,
|
|
249
|
+
is_async=result.is_async,
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
# Arrow - wrap in Function with name
|
|
253
|
+
result = Function(
|
|
254
|
+
params=list(result.params),
|
|
255
|
+
body=[Return(result.body)]
|
|
256
|
+
if isinstance(result.body, Expr)
|
|
257
|
+
else result.body,
|
|
258
|
+
name=self.js_name,
|
|
259
|
+
is_async=False,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
self._transpiled = result
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
def imports(self) -> dict[str, Expr]:
|
|
266
|
+
"""Get all Import dependencies."""
|
|
267
|
+
return {k: v for k, v in self.deps.items() if isinstance(v, Import)}
|
|
268
|
+
|
|
269
|
+
def functions(self) -> dict[str, AnyJsFunction]:
|
|
270
|
+
"""Get all JsFunction dependencies."""
|
|
271
|
+
return {
|
|
272
|
+
k: v
|
|
273
|
+
for k, v in self.deps.items()
|
|
274
|
+
if isinstance(v, (JsFunction, JsxFunction))
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@dataclass(slots=True, init=False)
|
|
279
|
+
class JsxFunction(Expr, Generic[P, R]):
|
|
280
|
+
"""A transpiled JSX/React component function.
|
|
281
|
+
|
|
282
|
+
Like JsFunction, but transpiles to a React component that receives
|
|
283
|
+
a single props object with destructuring.
|
|
284
|
+
|
|
285
|
+
For a Python function like:
|
|
286
|
+
def Component(*children, visible=True): ...
|
|
287
|
+
|
|
288
|
+
Generates:
|
|
289
|
+
function Component_1({children, visible = true}) { ... }
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
fn: Callable[P, R]
|
|
293
|
+
id: str
|
|
294
|
+
deps: dict[str, Expr]
|
|
295
|
+
_transpiled: Function | None = field(default=None)
|
|
296
|
+
|
|
297
|
+
def __init__(self, fn: Callable[..., Any]) -> None:
|
|
298
|
+
self.fn = fn
|
|
299
|
+
self.id = next_id()
|
|
300
|
+
self._transpiled = None
|
|
301
|
+
# Register self in cache BEFORE analyzing deps (handles cycles)
|
|
302
|
+
FUNCTION_CACHE[fn] = self
|
|
303
|
+
# Now analyze and build deps
|
|
304
|
+
self.deps = analyze_deps(fn)
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def js_name(self) -> str:
|
|
308
|
+
"""Unique JS identifier for this function."""
|
|
309
|
+
return f"{to_js_identifier(self.fn.__name__)}_{self.id}"
|
|
310
|
+
|
|
311
|
+
@override
|
|
312
|
+
def emit(self, out: list[str]) -> None:
|
|
313
|
+
"""Emit this function as its unique JS identifier."""
|
|
314
|
+
out.append(self.js_name)
|
|
315
|
+
|
|
316
|
+
@override
|
|
317
|
+
def render(self) -> VDOMExpr:
|
|
318
|
+
"""Render as a registry reference."""
|
|
319
|
+
return {"t": "ref", "key": self.id}
|
|
320
|
+
|
|
321
|
+
def transpile(self) -> Function:
|
|
322
|
+
"""Transpile this JSX function to a React component.
|
|
323
|
+
|
|
324
|
+
The Transpiler handles converting parameters to a destructured props object.
|
|
325
|
+
"""
|
|
326
|
+
if self._transpiled is not None:
|
|
327
|
+
return self._transpiled
|
|
328
|
+
|
|
329
|
+
result, _ = _transpile_function_body(self.fn, self.deps, jsx=True)
|
|
330
|
+
|
|
331
|
+
# JSX transpilation always returns Function (never Arrow)
|
|
332
|
+
assert isinstance(result, Function), (
|
|
333
|
+
"JSX transpilation should always return Function"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Set the unique name
|
|
337
|
+
self._transpiled = Function(
|
|
338
|
+
params=result.params,
|
|
339
|
+
body=result.body,
|
|
340
|
+
name=self.js_name,
|
|
341
|
+
is_async=result.is_async,
|
|
342
|
+
)
|
|
343
|
+
return self._transpiled
|
|
344
|
+
|
|
345
|
+
def imports(self) -> dict[str, Expr]:
|
|
346
|
+
"""Get all Import dependencies."""
|
|
347
|
+
return {k: v for k, v in self.deps.items() if isinstance(v, Import)}
|
|
348
|
+
|
|
349
|
+
def functions(self) -> dict[str, AnyJsFunction]:
|
|
350
|
+
"""Get all function dependencies."""
|
|
351
|
+
return {
|
|
352
|
+
k: v
|
|
353
|
+
for k, v in self.deps.items()
|
|
354
|
+
if isinstance(v, (JsFunction, JsxFunction))
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
@override
|
|
358
|
+
def transpile_call(
|
|
359
|
+
self, args: list[ast.expr], keywords: list[ast.keyword], ctx: Transpiler
|
|
360
|
+
) -> Expr:
|
|
361
|
+
# delegate JSX element building to the generic Jsx wrapper
|
|
362
|
+
return Jsx(self).transpile_call(args, keywords, ctx)
|
|
363
|
+
|
|
364
|
+
@override
|
|
365
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
366
|
+
# runtime/type-checking: produce Element via Jsx wrapper
|
|
367
|
+
return Jsx(self)(*args, **kwargs) # pyright: ignore[reportReturnType]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def analyze_code_object(
|
|
371
|
+
fn: Callable[..., object],
|
|
372
|
+
) -> tuple[dict[str, Any], set[str]]:
|
|
373
|
+
"""Analyze code object and resolve globals + closure variables.
|
|
374
|
+
|
|
375
|
+
Returns a tuple of:
|
|
376
|
+
- effective_globals: dict mapping names to their values (includes closure vars)
|
|
377
|
+
- all_names: set of all names referenced in the code (including nested functions)
|
|
378
|
+
"""
|
|
379
|
+
import dis
|
|
380
|
+
|
|
381
|
+
code = fn.__code__
|
|
382
|
+
|
|
383
|
+
# Collect all names from code object and nested functions in one pass
|
|
384
|
+
seen_codes: set[int] = set()
|
|
385
|
+
all_names: set[str] = set()
|
|
386
|
+
|
|
387
|
+
# Opcodes that load names from globals/locals (not attributes)
|
|
388
|
+
GLOBAL_LOAD_OPS = frozenset(
|
|
389
|
+
{
|
|
390
|
+
"LOAD_GLOBAL",
|
|
391
|
+
"LOAD_NAME",
|
|
392
|
+
"STORE_GLOBAL",
|
|
393
|
+
"STORE_NAME",
|
|
394
|
+
"DELETE_GLOBAL",
|
|
395
|
+
"DELETE_NAME",
|
|
396
|
+
}
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def walk_code(c: pytypes.CodeType) -> None:
|
|
400
|
+
if id(c) in seen_codes:
|
|
401
|
+
return
|
|
402
|
+
seen_codes.add(id(c))
|
|
403
|
+
|
|
404
|
+
# Only collect names that are actually loaded as globals, not attributes
|
|
405
|
+
# co_names contains both global names and attribute names, so we need
|
|
406
|
+
# to check the bytecode to distinguish them
|
|
407
|
+
for instr in dis.get_instructions(c):
|
|
408
|
+
if instr.opname in GLOBAL_LOAD_OPS and instr.argval is not None:
|
|
409
|
+
all_names.add(instr.argval)
|
|
410
|
+
|
|
411
|
+
all_names.update(c.co_freevars) # Include closure variables
|
|
412
|
+
|
|
413
|
+
for const in c.co_consts:
|
|
414
|
+
if isinstance(const, pytypes.CodeType):
|
|
415
|
+
walk_code(const)
|
|
416
|
+
|
|
417
|
+
walk_code(code)
|
|
418
|
+
|
|
419
|
+
# Build effective globals dict: start with function's globals, then add closure values
|
|
420
|
+
effective_globals = dict(fn.__globals__)
|
|
421
|
+
|
|
422
|
+
# Resolve closure variables from closure cells
|
|
423
|
+
if code.co_freevars and fn.__closure__:
|
|
424
|
+
closure = fn.__closure__
|
|
425
|
+
for i, freevar_name in enumerate(code.co_freevars):
|
|
426
|
+
if i < len(closure):
|
|
427
|
+
cell = closure[i]
|
|
428
|
+
# Get the value from the closure cell
|
|
429
|
+
try:
|
|
430
|
+
effective_globals[freevar_name] = cell.cell_contents
|
|
431
|
+
except ValueError:
|
|
432
|
+
# Cell is empty (unbound), skip it
|
|
433
|
+
pass
|
|
434
|
+
|
|
435
|
+
return effective_globals, all_names
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def analyze_deps(fn: Callable[..., Any]) -> dict[str, Expr]:
|
|
439
|
+
"""Analyze a function and return its dependencies as Expr instances.
|
|
440
|
+
|
|
441
|
+
Walks the function's code object to find all referenced names,
|
|
442
|
+
then resolves them from globals/closure and converts to Expr.
|
|
443
|
+
"""
|
|
444
|
+
# Analyze code object and resolve globals + closure vars
|
|
445
|
+
effective_globals, all_names = analyze_code_object(fn)
|
|
446
|
+
|
|
447
|
+
# Build dependencies dictionary - all values are Expr
|
|
448
|
+
deps: dict[str, Expr] = {}
|
|
449
|
+
|
|
450
|
+
for name in all_names:
|
|
451
|
+
value = effective_globals.get(name)
|
|
452
|
+
|
|
453
|
+
if value is None:
|
|
454
|
+
# Not in globals - could be a builtin or unresolved
|
|
455
|
+
# For now, skip - builtins will be handled by the transpiler
|
|
456
|
+
# TODO: Add builtin support
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
# Already an Expr
|
|
460
|
+
if isinstance(value, Expr):
|
|
461
|
+
deps[name] = value
|
|
462
|
+
continue
|
|
463
|
+
|
|
464
|
+
# Check global registry (for registered values like math.floor)
|
|
465
|
+
if id(value) in EXPR_REGISTRY:
|
|
466
|
+
deps[name] = EXPR_REGISTRY[id(value)]
|
|
467
|
+
continue
|
|
468
|
+
|
|
469
|
+
# Module imports must be registered (module object itself is in EXPR_REGISTRY)
|
|
470
|
+
if inspect.ismodule(value):
|
|
471
|
+
raise TranspileError(
|
|
472
|
+
f"Could not resolve module '{name}' (value: {value!r}). "
|
|
473
|
+
+ "Register the module (or its values) in EXPR_REGISTRY."
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Functions - check cache, then create JsFunction
|
|
477
|
+
if inspect.isfunction(value):
|
|
478
|
+
if value in FUNCTION_CACHE:
|
|
479
|
+
deps[name] = FUNCTION_CACHE[value]
|
|
480
|
+
else:
|
|
481
|
+
deps[name] = JsFunction(value)
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
# Skip Expr subclasses (the classes themselves) as they are often
|
|
485
|
+
# used for type hinting or within function scope and handled
|
|
486
|
+
# by the transpiler via other means (e.g. BUILTINS or special cases)
|
|
487
|
+
if isinstance(value, type) and issubclass(value, Expr):
|
|
488
|
+
continue
|
|
489
|
+
|
|
490
|
+
# Other callables (classes, methods, etc.) - not supported
|
|
491
|
+
if callable(value): # pyright: ignore[reportUnknownArgumentType]
|
|
492
|
+
raise TranspileError(
|
|
493
|
+
f"Callable '{name}' (type: {type(value).__name__}) is not supported. " # pyright: ignore[reportUnknownArgumentType]
|
|
494
|
+
+ "Only functions can be transpiled."
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# Constants - primitives inline, non-primitives hoisted
|
|
498
|
+
if isinstance(value, (bool, int, float, str)) or value is None:
|
|
499
|
+
deps[name] = Expr.of(value)
|
|
500
|
+
else:
|
|
501
|
+
# Non-primitive: wrap in Constant for hoisting
|
|
502
|
+
try:
|
|
503
|
+
deps[name] = Constant.wrap(value, name)
|
|
504
|
+
except TypeError:
|
|
505
|
+
raise TranspileError(
|
|
506
|
+
f"Cannot convert '{name}' (type: {type(value).__name__}) to Expr"
|
|
507
|
+
) from None
|
|
508
|
+
|
|
509
|
+
return deps
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
@overload
|
|
513
|
+
def javascript(fn: Callable[[*Args], R]) -> JsFunction[*Args, R]: ...
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
@overload
|
|
517
|
+
def javascript(
|
|
518
|
+
*, jsx: Literal[False] = ...
|
|
519
|
+
) -> Callable[[Callable[[*Args], R]], JsFunction[*Args, R]]: ...
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@overload
|
|
523
|
+
def javascript(*, jsx: Literal[True]) -> Callable[[Callable[P, R]], Jsx]: ...
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def javascript(fn: Callable[[*Args], R] | None = None, *, jsx: bool = False) -> Any:
|
|
527
|
+
"""Decorator to convert a Python function into a JsFunction or JsxFunction.
|
|
528
|
+
|
|
529
|
+
When jsx=False (default), returns a JsFunction instance.
|
|
530
|
+
When jsx=True, returns a JsxFunction instance.
|
|
531
|
+
|
|
532
|
+
Both are cached in FUNCTION_CACHE for deduplication and code generation.
|
|
533
|
+
"""
|
|
534
|
+
|
|
535
|
+
def decorator(f: Callable[[*Args], R]) -> Any:
|
|
536
|
+
cached = FUNCTION_CACHE.get(f)
|
|
537
|
+
if cached is not None:
|
|
538
|
+
# Already cached - return as-is (respects original jsx setting)
|
|
539
|
+
return cached
|
|
540
|
+
|
|
541
|
+
if jsx:
|
|
542
|
+
# Create JsxFunction for React component semantics
|
|
543
|
+
jsx_fn = JsxFunction(f)
|
|
544
|
+
# Preserve the original function's type signature for type checkers
|
|
545
|
+
return jsx_fn.as_(type(f))
|
|
546
|
+
|
|
547
|
+
# Create regular JsFunction
|
|
548
|
+
return JsFunction(f)
|
|
549
|
+
|
|
550
|
+
if fn is not None:
|
|
551
|
+
return decorator(fn)
|
|
552
|
+
return decorator
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def registered_functions() -> list[AnyJsFunction]:
|
|
556
|
+
"""Get all registered JS functions."""
|
|
557
|
+
return list(FUNCTION_CACHE.values())
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _unwrap_jsfunction(expr: Expr) -> AnyJsFunction | None:
|
|
561
|
+
"""Unwrap common wrappers to get the underlying JsFunction or JsxFunction."""
|
|
562
|
+
if isinstance(expr, (JsFunction, JsxFunction)):
|
|
563
|
+
return expr
|
|
564
|
+
if isinstance(expr, Jsx):
|
|
565
|
+
inner = expr.expr
|
|
566
|
+
if isinstance(inner, Expr):
|
|
567
|
+
return _unwrap_jsfunction(inner)
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def collect_function_graph(
|
|
572
|
+
functions: list[AnyJsFunction] | None = None,
|
|
573
|
+
) -> tuple[list[Constant], list[AnyJsFunction]]:
|
|
574
|
+
"""Collect all constants and functions in dependency order (depth-first).
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
functions: Functions to walk. If None, uses all registered functions.
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
Tuple of (constants, functions) in dependency order.
|
|
581
|
+
"""
|
|
582
|
+
if functions is None:
|
|
583
|
+
functions = registered_functions()
|
|
584
|
+
|
|
585
|
+
seen_funcs: set[str] = set()
|
|
586
|
+
seen_consts: set[str] = set()
|
|
587
|
+
all_funcs: list[AnyJsFunction] = []
|
|
588
|
+
all_consts: list[Constant] = []
|
|
589
|
+
|
|
590
|
+
def walk(fn: AnyJsFunction) -> None:
|
|
591
|
+
if fn.id in seen_funcs:
|
|
592
|
+
return
|
|
593
|
+
seen_funcs.add(fn.id)
|
|
594
|
+
|
|
595
|
+
for dep in fn.deps.values():
|
|
596
|
+
if isinstance(dep, Constant):
|
|
597
|
+
if dep.id not in seen_consts:
|
|
598
|
+
seen_consts.add(dep.id)
|
|
599
|
+
all_consts.append(dep)
|
|
600
|
+
continue
|
|
601
|
+
if isinstance(dep, Expr):
|
|
602
|
+
inner_fn = _unwrap_jsfunction(dep)
|
|
603
|
+
if inner_fn is not None:
|
|
604
|
+
walk(inner_fn)
|
|
605
|
+
|
|
606
|
+
all_funcs.append(fn)
|
|
607
|
+
|
|
608
|
+
for fn in functions:
|
|
609
|
+
walk(fn)
|
|
610
|
+
|
|
611
|
+
return all_consts, all_funcs
|
pulse/transpiler/id.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Shared ID generator for unique identifiers across imports, functions, constants."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_id_counter: int = 0
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def next_id() -> str:
|
|
9
|
+
"""Generate a unique ID for imports, functions, or constants."""
|
|
10
|
+
global _id_counter
|
|
11
|
+
_id_counter += 1
|
|
12
|
+
return str(_id_counter)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def reset_id_counter() -> None:
|
|
16
|
+
"""Reset the shared ID counter. Called by clear_* functions."""
|
|
17
|
+
global _id_counter
|
|
18
|
+
_id_counter = 0
|