pulse-framework 0.1.50__py3-none-any.whl → 0.1.52__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. pulse/__init__.py +542 -562
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +0 -14
  4. pulse/cli/cmd.py +96 -80
  5. pulse/cli/dependencies.py +10 -41
  6. pulse/cli/folder_lock.py +3 -3
  7. pulse/cli/helpers.py +40 -67
  8. pulse/cli/logging.py +102 -0
  9. pulse/cli/packages.py +16 -0
  10. pulse/cli/processes.py +40 -23
  11. pulse/codegen/codegen.py +70 -35
  12. pulse/codegen/js.py +2 -4
  13. pulse/codegen/templates/route.py +94 -146
  14. pulse/component.py +115 -0
  15. pulse/components/for_.py +1 -1
  16. pulse/components/if_.py +1 -1
  17. pulse/components/react_router.py +16 -22
  18. pulse/{html → dom}/events.py +1 -1
  19. pulse/{html → dom}/props.py +6 -6
  20. pulse/{html → dom}/tags.py +11 -11
  21. pulse/dom/tags.pyi +480 -0
  22. pulse/form.py +7 -6
  23. pulse/hooks/init.py +1 -13
  24. pulse/js/__init__.py +37 -41
  25. pulse/js/__init__.pyi +22 -2
  26. pulse/js/_types.py +5 -3
  27. pulse/js/array.py +121 -38
  28. pulse/js/console.py +9 -9
  29. pulse/js/date.py +22 -19
  30. pulse/js/document.py +8 -4
  31. pulse/js/error.py +12 -14
  32. pulse/js/json.py +4 -3
  33. pulse/js/map.py +17 -7
  34. pulse/js/math.py +2 -2
  35. pulse/js/navigator.py +4 -4
  36. pulse/js/number.py +8 -8
  37. pulse/js/object.py +9 -13
  38. pulse/js/promise.py +25 -9
  39. pulse/js/regexp.py +6 -6
  40. pulse/js/set.py +20 -8
  41. pulse/js/string.py +7 -7
  42. pulse/js/weakmap.py +6 -6
  43. pulse/js/weakset.py +6 -6
  44. pulse/js/window.py +17 -14
  45. pulse/messages.py +1 -4
  46. pulse/react_component.py +3 -999
  47. pulse/render_session.py +74 -66
  48. pulse/renderer.py +311 -238
  49. pulse/routing.py +1 -10
  50. pulse/serializer.py +11 -1
  51. pulse/transpiler/__init__.py +84 -114
  52. pulse/transpiler/builtins.py +661 -343
  53. pulse/transpiler/errors.py +78 -2
  54. pulse/transpiler/function.py +463 -133
  55. pulse/transpiler/id.py +18 -0
  56. pulse/transpiler/imports.py +230 -325
  57. pulse/transpiler/js_module.py +218 -209
  58. pulse/transpiler/modules/__init__.py +16 -13
  59. pulse/transpiler/modules/asyncio.py +45 -26
  60. pulse/transpiler/modules/json.py +12 -8
  61. pulse/transpiler/modules/math.py +161 -216
  62. pulse/transpiler/modules/pulse/__init__.py +5 -0
  63. pulse/transpiler/modules/pulse/tags.py +231 -0
  64. pulse/transpiler/modules/typing.py +33 -28
  65. pulse/transpiler/nodes.py +1607 -923
  66. pulse/transpiler/py_module.py +118 -95
  67. pulse/transpiler/react_component.py +51 -0
  68. pulse/transpiler/transpiler.py +593 -437
  69. pulse/transpiler/vdom.py +255 -0
  70. {pulse_framework-0.1.50.dist-info → pulse_framework-0.1.52.dist-info}/METADATA +1 -1
  71. pulse_framework-0.1.52.dist-info/RECORD +120 -0
  72. pulse/html/tags.pyi +0 -470
  73. pulse/transpiler/constants.py +0 -110
  74. pulse/transpiler/context.py +0 -26
  75. pulse/transpiler/ids.py +0 -16
  76. pulse/transpiler/modules/re.py +0 -466
  77. pulse/transpiler/modules/tags.py +0 -268
  78. pulse/transpiler/utils.py +0 -4
  79. pulse/vdom.py +0 -667
  80. pulse_framework-0.1.50.dist-info/RECORD +0 -119
  81. /pulse/{html → dom}/__init__.py +0 -0
  82. /pulse/{html → dom}/elements.py +0 -0
  83. /pulse/{html → dom}/svg.py +0 -0
  84. {pulse_framework-0.1.50.dist-info → pulse_framework-0.1.52.dist-info}/WHEEL +0 -0
  85. {pulse_framework-0.1.50.dist-info → pulse_framework-0.1.52.dist-info}/entry_points.txt +0 -0
@@ -1,182 +1,361 @@
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
+
1
7
  from __future__ import annotations
2
8
 
3
9
  import ast
4
10
  import inspect
5
11
  import textwrap
6
12
  import types as pytypes
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass, field
7
15
  from typing import (
8
16
  Any,
9
- Callable,
10
- ClassVar,
11
17
  Generic,
18
+ Literal,
19
+ ParamSpec,
12
20
  TypeAlias,
13
21
  TypeVar,
14
22
  TypeVarTuple,
23
+ overload,
15
24
  override,
16
25
  )
17
26
 
18
- # Import module registrations to ensure they're available for dependency analysis
19
- import pulse.transpiler.modules # noqa: F401
20
27
  from pulse.helpers import getsourcecode
21
- from pulse.transpiler.builtins import BUILTINS
22
- from pulse.transpiler.constants import JsConstant, const_to_js
23
- from pulse.transpiler.context import is_interpreted_mode
24
- from pulse.transpiler.errors import JSCompilationError
25
- from pulse.transpiler.ids import generate_id
28
+ from pulse.transpiler.errors import TranspileError
29
+ from pulse.transpiler.id import next_id, reset_id_counter
26
30
  from pulse.transpiler.imports import Import
27
- from pulse.transpiler.js_module import JS_MODULES
28
- from pulse.transpiler.nodes import JSEXPR_REGISTRY, JSExpr, JSTransformer
29
- from pulse.transpiler.py_module import (
30
- PY_MODULES,
31
- PyModuleExpr,
31
+ from pulse.transpiler.nodes import (
32
+ EXPR_REGISTRY,
33
+ Arrow,
34
+ Expr,
35
+ Function,
36
+ Jsx,
37
+ Return,
38
+ to_js_identifier,
32
39
  )
33
- from pulse.transpiler.transpiler import JsTranspiler
40
+ from pulse.transpiler.transpiler import Transpiler
41
+ from pulse.transpiler.vdom import VDOMNode
34
42
 
35
43
  Args = TypeVarTuple("Args")
44
+ P = ParamSpec("P")
36
45
  R = TypeVar("R")
37
-
38
-
39
- AnyJsFunction: TypeAlias = "JsFunction[*tuple[Any, ...], Any]"
46
+ AnyJsFunction: TypeAlias = "JsFunction[*tuple[Any, ...], Any] | JsxFunction[..., Any]"
40
47
 
41
48
  # Global cache for deduplication across all transpiled functions
42
49
  # Registered BEFORE analyzing deps to handle mutual recursion
43
- FUNCTION_CACHE: dict[Callable[..., object], AnyJsFunction] = {}
50
+ # Stores JsFunction for regular @javascript, JsxFunction for @javascript(jsx=True)
51
+ FUNCTION_CACHE: dict[Callable[..., Any], AnyJsFunction] = {}
44
52
 
53
+ # Global registry for hoisted constants: id(value) -> Constant
54
+ # Used for deduplication of non-primitive values in transpiled functions
55
+ CONSTANT_REGISTRY: dict[int, "Constant"] = {}
45
56
 
46
- class JsFunction(JSExpr, Generic[*Args, R]):
47
- is_primary: ClassVar[bool] = True
48
57
 
49
- fn: Callable[[*Args], R]
58
+ def clear_function_cache() -> None:
59
+ """Clear function/constant/ref caches and reset the shared ID counters."""
60
+ from pulse.transpiler.imports import clear_import_registry
61
+
62
+ FUNCTION_CACHE.clear()
63
+ CONSTANT_REGISTRY.clear()
64
+ clear_import_registry()
65
+ reset_id_counter()
66
+
67
+
68
+ @dataclass(slots=True, init=False)
69
+ class Constant(Expr):
70
+ """A hoisted constant value with a unique identifier.
71
+
72
+ Used for non-primitive values (lists, dicts, sets) referenced in transpiled
73
+ functions. The value is emitted once at module scope, and the function
74
+ references it by name.
75
+
76
+ Example:
77
+ ITEMS = [1, 2, 3]
78
+
79
+ @javascript
80
+ def foo():
81
+ return ITEMS[0]
82
+
83
+ # Emits:
84
+ # const ITEMS_1 = [1, 2, 3];
85
+ # function foo_2() { return ITEMS_1[0]; }
86
+ """
87
+
88
+ value: Any
89
+ expr: Expr
50
90
  id: str
51
- deps: dict[str, JSExpr]
91
+ name: str
52
92
 
53
- def __init__(self, fn: Callable[[*Args], R]) -> None:
54
- self.fn = fn
55
- self.id = generate_id()
93
+ def __init__(self, value: Any, expr: Expr, name: str = "") -> None:
94
+ self.value = value
95
+ self.expr = expr
96
+ self.id = next_id()
97
+ self.name = name
98
+ # Register in global cache
99
+ CONSTANT_REGISTRY[id(value)] = self
56
100
 
57
- # Register self in cache BEFORE analyzing deps (handles cycles)
58
- FUNCTION_CACHE[fn] = self
101
+ @property
102
+ def js_name(self) -> str:
103
+ """Unique JS identifier for this constant."""
104
+ if self.name:
105
+ return f"{to_js_identifier(self.name)}_{self.id}"
106
+ return f"_const_{self.id}"
59
107
 
60
- # Analyze code object and resolve globals + closure vars
61
- effective_globals, all_names = analyze_code_object(fn)
108
+ @override
109
+ def emit(self, out: list[str]) -> None:
110
+ """Emit the unique JS identifier."""
111
+ out.append(self.js_name)
62
112
 
63
- # Build dependencies dictionary - all values are JSExpr
64
- deps: dict[str, JSExpr] = {}
113
+ @override
114
+ def render(self) -> VDOMNode:
115
+ """Render as a registry reference."""
116
+ return {"t": "ref", "key": self.id}
65
117
 
66
- for name in all_names:
67
- value = effective_globals.get(name)
118
+ @staticmethod
119
+ def wrap(value: Any, name: str = "") -> "Constant":
120
+ """Get or create a Constant for a value (cached by identity)."""
121
+ if (existing := CONSTANT_REGISTRY.get(id(value))) is not None:
122
+ return existing
123
+ expr = Expr.of(value)
124
+ return Constant(value, expr, name)
68
125
 
69
- if value is None:
70
- # Not in globals - check builtins (allows user to shadow builtins)
71
- # Note: co_names includes both global names AND attribute names (e.g., 'input'
72
- # from 'tags.input'). We only add supported builtins; unsupported ones are
73
- # skipped since they might be attribute accesses handled during transpilation.
74
- if name in BUILTINS:
75
- deps[name] = BUILTINS[name]
76
- continue
77
126
 
78
- # Already a JSExpr (JsFunction, JsConstant, Import, JSMember, etc.)
79
- if isinstance(value, JSExpr):
80
- deps[name] = value
81
- elif inspect.ismodule(value):
82
- if value in JS_MODULES:
83
- # import pulse.js.math as Math -> JSIdentifier or Import
84
- deps[name] = JS_MODULES[value].to_js_expr()
85
- elif value in PY_MODULES:
86
- deps[name] = PyModuleExpr(PY_MODULES[value])
87
- else:
88
- raise JSCompilationError(
89
- f"Could not resolve JavaScript module import for '{name}' (value: {value!r}). "
90
- + "Neither a registered Python module nor a known JS wrapper. "
91
- + "Check your import statement and module configuration."
92
- )
93
-
94
- elif id(value) in JSEXPR_REGISTRY:
95
- # JSEXPR_REGISTRY always contains JSExpr (wrapping happens in JSExpr.register)
96
- deps[name] = JSEXPR_REGISTRY[id(value)]
97
- elif inspect.isfunction(value):
98
- deps[name] = javascript(value)
99
- elif callable(value):
100
- raise JSCompilationError(
101
- f"Callable object '{name}' (type: {type(value).__name__}) is not supported. "
102
- + "Only functions can be transpiled."
103
- )
104
- else:
105
- deps[name] = const_to_js(value, name)
127
+ def registered_constants() -> list[Constant]:
128
+ """Get all registered constants."""
129
+ return list(CONSTANT_REGISTRY.values())
130
+
131
+
132
+ def _transpile_function_body(
133
+ fn: Callable[..., Any],
134
+ deps: dict[str, Expr],
135
+ *,
136
+ jsx: bool = False,
137
+ ) -> tuple[Function | Arrow, str]:
138
+ """Shared transpilation logic for JsFunction and JsxFunction.
139
+
140
+ Returns the transpiled Function/Arrow node and the source code.
141
+ """
142
+ # Get and parse source
143
+ src = getsourcecode(fn)
144
+ src = textwrap.dedent(src)
145
+ module = ast.parse(src)
146
+
147
+ # Find the function definition
148
+ fndefs = [
149
+ n for n in module.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
150
+ ]
151
+ if not fndefs:
152
+ raise TranspileError("No function definition found in source")
153
+ fndef = fndefs[-1]
154
+
155
+ # Get filename for error messages
156
+ try:
157
+ filename = inspect.getfile(fn)
158
+ except (TypeError, OSError):
159
+ filename = None
160
+
161
+ # Transpile with source context for errors
162
+ try:
163
+ transpiler = Transpiler(fndef, deps, jsx=jsx)
164
+ result = transpiler.transpile()
165
+ except TranspileError as e:
166
+ # Re-raise with source context if not already present
167
+ if e.source is None:
168
+ raise e.with_context(
169
+ source=src,
170
+ filename=filename,
171
+ func_name=fn.__name__,
172
+ ) from None
173
+ raise
174
+
175
+ return result, src
176
+
177
+
178
+ @dataclass(slots=True, init=False)
179
+ class JsFunction(Expr, Generic[*Args, R]):
180
+ """A transpiled JavaScript function.
181
+
182
+ Wraps a Python function with:
183
+ - A unique identifier for deduplication
184
+ - Resolved dependencies (other functions, imports, constants, etc.)
185
+ - The ability to transpile to JavaScript code
186
+
187
+ When emitted, produces the unique JS function name (e.g., "myFunc_1").
188
+ """
106
189
 
107
- self.deps = deps
190
+ fn: Callable[[*Args], R]
191
+ id: str
192
+ deps: dict[str, Expr]
193
+ _transpiled: Function | None = field(default=None)
194
+
195
+ def __init__(self, fn: Callable[..., Any], *, _register: bool = True) -> None:
196
+ self.fn = fn
197
+ self.id = next_id()
198
+ self._transpiled = None
199
+ if _register:
200
+ # Register self in cache BEFORE analyzing deps (handles cycles)
201
+ FUNCTION_CACHE[fn] = self
202
+ # Now analyze and build deps (may recursively call JsFunction() which will find us in cache)
203
+ self.deps = analyze_deps(fn)
204
+
205
+ @override
206
+ def __call__(self, *args: *Args) -> R: # pyright: ignore[reportIncompatibleMethodOverride]
207
+ return Expr.__call__(self, *args) # pyright: ignore[reportReturnType]
108
208
 
109
209
  @property
110
210
  def js_name(self) -> str:
111
211
  """Unique JS identifier for this function."""
112
- return f"{self.fn.__name__}_{self.id}"
212
+ return f"{to_js_identifier(self.fn.__name__)}_{self.id}"
113
213
 
114
214
  @override
115
- def emit(self) -> str:
116
- """Emit JS code for this function reference.
215
+ def emit(self, out: list[str]) -> None:
216
+ """Emit this function as its unique JS identifier."""
217
+ out.append(self.js_name)
117
218
 
118
- In normal mode: returns the unique JS name (e.g., "myFunc_1")
119
- In interpreted mode: returns a get_object call (e.g., "get_object('myFunc_1')")
120
- """
121
- base = self.js_name
122
- if is_interpreted_mode():
123
- return f"get_object('{base}')"
124
- return base
219
+ @override
220
+ def render(self) -> VDOMNode:
221
+ """Render as a registry reference."""
222
+ return {"t": "ref", "key": self.id}
125
223
 
126
- def imports(self) -> dict[str, Import]:
224
+ def transpile(self) -> Function:
225
+ """Transpile this function to a v2 Function node.
226
+
227
+ Returns the Function node (cached after first call).
228
+ """
229
+ if self._transpiled is not None:
230
+ return self._transpiled
231
+
232
+ result, _ = _transpile_function_body(self.fn, self.deps)
233
+
234
+ # Convert Arrow to Function if needed, and set the name
235
+ if isinstance(result, Function):
236
+ result = Function(
237
+ params=result.params,
238
+ body=result.body,
239
+ name=self.js_name,
240
+ is_async=result.is_async,
241
+ )
242
+ else:
243
+ # Arrow - wrap in Function with name
244
+ result = Function(
245
+ params=list(result.params),
246
+ body=[Return(result.body)]
247
+ if isinstance(result.body, Expr)
248
+ else result.body,
249
+ name=self.js_name,
250
+ is_async=False,
251
+ )
252
+
253
+ self._transpiled = result
254
+ return result
255
+
256
+ def imports(self) -> dict[str, Expr]:
127
257
  """Get all Import dependencies."""
128
258
  return {k: v for k, v in self.deps.items() if isinstance(v, Import)}
129
259
 
130
260
  def functions(self) -> dict[str, AnyJsFunction]:
131
261
  """Get all JsFunction dependencies."""
132
- return {k: v for k, v in self.deps.items() if isinstance(v, JsFunction)}
133
-
134
- def constants(self) -> dict[str, JsConstant]:
135
- """Get all JsConstant dependencies."""
136
- return {k: v for k, v in self.deps.items() if isinstance(v, JsConstant)}
137
-
138
- def modules(self) -> dict[str, PyModuleExpr]:
139
- """Get all PyModuleExpr dependencies."""
140
- return {k: v for k, v in self.deps.items() if isinstance(v, PyModuleExpr)}
141
-
142
- def module_functions(self) -> dict[str, JSTransformer]:
143
- """Get all module function JSTransformer dependencies (named imports from modules)."""
144
- from pulse.transpiler.builtins import BUILTINS
145
-
146
262
  return {
147
263
  k: v
148
264
  for k, v in self.deps.items()
149
- if isinstance(v, JSTransformer) and v.name not in BUILTINS
265
+ if isinstance(v, (JsFunction, JsxFunction))
150
266
  }
151
267
 
152
- def transpile(self) -> str:
153
- """Transpile this JsFunction to JavaScript code.
154
268
 
155
- Returns the complete JavaScript function code.
156
- """
269
+ @dataclass(slots=True, init=False)
270
+ class JsxFunction(Expr, Generic[P, R]):
271
+ """A transpiled JSX/React component function.
272
+
273
+ Like JsFunction, but transpiles to a React component that receives
274
+ a single props object with destructuring.
275
+
276
+ For a Python function like:
277
+ def Component(*children, visible=True): ...
278
+
279
+ Generates:
280
+ function Component_1({children, visible = true}) { ... }
281
+ """
282
+
283
+ fn: Callable[P, R]
284
+ id: str
285
+ deps: dict[str, Expr]
286
+ _transpiled: Function | None = field(default=None)
287
+
288
+ def __init__(self, fn: Callable[..., Any]) -> None:
289
+ self.fn = fn
290
+ self.id = next_id()
291
+ self._transpiled = None
292
+ # Register self in cache BEFORE analyzing deps (handles cycles)
293
+ FUNCTION_CACHE[fn] = self
294
+ # Now analyze and build deps
295
+ self.deps = analyze_deps(fn)
296
+
297
+ @property
298
+ def js_name(self) -> str:
299
+ """Unique JS identifier for this function."""
300
+ return f"{to_js_identifier(self.fn.__name__)}_{self.id}"
301
+
302
+ @override
303
+ def emit(self, out: list[str]) -> None:
304
+ """Emit this function as its unique JS identifier."""
305
+ out.append(self.js_name)
157
306
 
158
- # Get source code
159
- src = getsourcecode(self.fn)
160
- src = textwrap.dedent(src)
307
+ @override
308
+ def render(self) -> VDOMNode:
309
+ """Render as a registry reference."""
310
+ return {"t": "ref", "key": self.id}
311
+
312
+ def transpile(self) -> Function:
313
+ """Transpile this JSX function to a React component.
314
+
315
+ The Transpiler handles converting parameters to a destructured props object.
316
+ """
317
+ if self._transpiled is not None:
318
+ return self._transpiled
319
+
320
+ result, _ = _transpile_function_body(self.fn, self.deps, jsx=True)
321
+
322
+ # JSX transpilation always returns Function (never Arrow)
323
+ assert isinstance(result, Function), (
324
+ "JSX transpilation should always return Function"
325
+ )
326
+
327
+ # Set the unique name
328
+ self._transpiled = Function(
329
+ params=result.params,
330
+ body=result.body,
331
+ name=self.js_name,
332
+ is_async=result.is_async,
333
+ )
334
+ return self._transpiled
335
+
336
+ def imports(self) -> dict[str, Expr]:
337
+ """Get all Import dependencies."""
338
+ return {k: v for k, v in self.deps.items() if isinstance(v, Import)}
161
339
 
162
- # Parse to AST
163
- module = ast.parse(src)
164
- fndefs = [
165
- n
166
- for n in module.body
167
- if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
168
- ]
169
- if not fndefs:
170
- raise JSCompilationError("No function definition found in source")
171
- fndef = fndefs[-1]
340
+ def functions(self) -> dict[str, AnyJsFunction]:
341
+ """Get all function dependencies."""
342
+ return {
343
+ k: v
344
+ for k, v in self.deps.items()
345
+ if isinstance(v, (JsFunction, JsxFunction))
346
+ }
172
347
 
173
- # Get argument names
174
- arg_names = [arg.arg for arg in fndef.args.args]
348
+ @override
349
+ def transpile_call(
350
+ self, args: list[ast.expr], kwargs: dict[str, ast.expr], ctx: Transpiler
351
+ ) -> Expr:
352
+ # delegate JSX element building to the generic Jsx wrapper
353
+ return Jsx(self).transpile_call(args, kwargs, ctx)
175
354
 
176
- # Transpile - pass deps directly, transpiler handles dispatch
177
- visitor = JsTranspiler(fndef, args=arg_names, deps=self.deps)
178
- js_fn = visitor.transpile(name=self.js_name)
179
- return js_fn.emit()
355
+ @override
356
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: # pyright: ignore[reportIncompatibleMethodOverride]
357
+ # runtime/type-checking: produce Element via Jsx wrapper
358
+ return Jsx(self)(*args, **kwargs) # pyright: ignore[reportReturnType]
180
359
 
181
360
 
182
361
  def analyze_code_object(
@@ -225,21 +404,121 @@ def analyze_code_object(
225
404
  return effective_globals, all_names
226
405
 
227
406
 
228
- def javascript(fn: Callable[[*Args], R]) -> JsFunction[*Args, R]:
229
- """Decorator to convert a function into a JsFunction.
407
+ def analyze_deps(fn: Callable[..., Any]) -> dict[str, Expr]:
408
+ """Analyze a function and return its dependencies as Expr instances.
409
+
410
+ Walks the function's code object to find all referenced names,
411
+ then resolves them from globals/closure and converts to Expr.
412
+ """
413
+ # Analyze code object and resolve globals + closure vars
414
+ effective_globals, all_names = analyze_code_object(fn)
415
+
416
+ # Build dependencies dictionary - all values are Expr
417
+ deps: dict[str, Expr] = {}
418
+
419
+ for name in all_names:
420
+ value = effective_globals.get(name)
421
+
422
+ if value is None:
423
+ # Not in globals - could be a builtin or unresolved
424
+ # For now, skip - builtins will be handled by the transpiler
425
+ # TODO: Add builtin support
426
+ continue
427
+
428
+ # Already an Expr
429
+ if isinstance(value, Expr):
430
+ deps[name] = value
431
+ continue
432
+
433
+ # Check global registry (for registered values like math.floor)
434
+ if id(value) in EXPR_REGISTRY:
435
+ deps[name] = EXPR_REGISTRY[id(value)]
436
+ continue
437
+
438
+ # Module imports must be registered (module object itself is in EXPR_REGISTRY)
439
+ if inspect.ismodule(value):
440
+ raise TranspileError(
441
+ f"Could not resolve module '{name}' (value: {value!r}). "
442
+ + "Register the module (or its values) in EXPR_REGISTRY."
443
+ )
444
+
445
+ # Functions - check cache, then create JsFunction
446
+ if inspect.isfunction(value):
447
+ if value in FUNCTION_CACHE:
448
+ deps[name] = FUNCTION_CACHE[value]
449
+ else:
450
+ deps[name] = JsFunction(value)
451
+ continue
452
+
453
+ # Skip Expr subclasses (the classes themselves) as they are often
454
+ # used for type hinting or within function scope and handled
455
+ # by the transpiler via other means (e.g. BUILTINS or special cases)
456
+ if isinstance(value, type) and issubclass(value, Expr):
457
+ continue
458
+
459
+ # Other callables (classes, methods, etc.) - not supported
460
+ if callable(value): # pyright: ignore[reportUnknownArgumentType]
461
+ raise TranspileError(
462
+ f"Callable '{name}' (type: {type(value).__name__}) is not supported. " # pyright: ignore[reportUnknownArgumentType]
463
+ + "Only functions can be transpiled."
464
+ )
465
+
466
+ # Constants - primitives inline, non-primitives hoisted
467
+ if isinstance(value, (bool, int, float, str)) or value is None:
468
+ deps[name] = Expr.of(value)
469
+ else:
470
+ # Non-primitive: wrap in Constant for hoisting
471
+ try:
472
+ deps[name] = Constant.wrap(value, name)
473
+ except TypeError:
474
+ raise TranspileError(
475
+ f"Cannot convert '{name}' (type: {type(value).__name__}) to Expr"
476
+ ) from None
230
477
 
231
- Usage:
232
- @javascript
233
- def my_func(x: int) -> int:
234
- return x + 1
478
+ return deps
235
479
 
236
- # my_func is now a JsFunction instance
480
+
481
+ @overload
482
+ def javascript(fn: Callable[[*Args], R]) -> JsFunction[*Args, R]: ...
483
+
484
+
485
+ @overload
486
+ def javascript(
487
+ *, jsx: Literal[False] = ...
488
+ ) -> Callable[[Callable[[*Args], R]], JsFunction[*Args, R]]: ...
489
+
490
+
491
+ @overload
492
+ def javascript(*, jsx: Literal[True]) -> Callable[[Callable[P, R]], Jsx]: ...
493
+
494
+
495
+ def javascript(fn: Callable[[*Args], R] | None = None, *, jsx: bool = False) -> Any:
496
+ """Decorator to convert a Python function into a JsFunction or JsxFunction.
497
+
498
+ When jsx=False (default), returns a JsFunction instance.
499
+ When jsx=True, returns a JsxFunction instance.
500
+
501
+ Both are cached in FUNCTION_CACHE for deduplication and code generation.
237
502
  """
238
- result = FUNCTION_CACHE.get(fn)
239
- if not result:
240
- result = JsFunction(fn)
241
- FUNCTION_CACHE[fn] = result
242
- return result # pyright: ignore[reportReturnType]
503
+
504
+ def decorator(f: Callable[[*Args], R]) -> Any:
505
+ cached = FUNCTION_CACHE.get(f)
506
+ if cached is not None:
507
+ # Already cached - return as-is (respects original jsx setting)
508
+ return cached
509
+
510
+ if jsx:
511
+ # Create JsxFunction for React component semantics
512
+ jsx_fn = JsxFunction(f)
513
+ # Preserve the original function's type signature for type checkers
514
+ return jsx_fn.as_(type(f))
515
+
516
+ # Create regular JsFunction
517
+ return JsFunction(f)
518
+
519
+ if fn is not None:
520
+ return decorator(fn)
521
+ return decorator
243
522
 
244
523
 
245
524
  def registered_functions() -> list[AnyJsFunction]:
@@ -247,4 +526,55 @@ def registered_functions() -> list[AnyJsFunction]:
247
526
  return list(FUNCTION_CACHE.values())
248
527
 
249
528
 
250
- X = JsFunction[int]
529
+ def _unwrap_jsfunction(expr: Expr) -> AnyJsFunction | None:
530
+ """Unwrap common wrappers to get the underlying JsFunction or JsxFunction."""
531
+ if isinstance(expr, (JsFunction, JsxFunction)):
532
+ return expr
533
+ if isinstance(expr, Jsx):
534
+ inner = expr.expr
535
+ if isinstance(inner, Expr):
536
+ return _unwrap_jsfunction(inner)
537
+ return None
538
+
539
+
540
+ def collect_function_graph(
541
+ functions: list[AnyJsFunction] | None = None,
542
+ ) -> tuple[list[Constant], list[AnyJsFunction]]:
543
+ """Collect all constants and functions in dependency order (depth-first).
544
+
545
+ Args:
546
+ functions: Functions to walk. If None, uses all registered functions.
547
+
548
+ Returns:
549
+ Tuple of (constants, functions) in dependency order.
550
+ """
551
+ if functions is None:
552
+ functions = registered_functions()
553
+
554
+ seen_funcs: set[str] = set()
555
+ seen_consts: set[str] = set()
556
+ all_funcs: list[AnyJsFunction] = []
557
+ all_consts: list[Constant] = []
558
+
559
+ def walk(fn: AnyJsFunction) -> None:
560
+ if fn.id in seen_funcs:
561
+ return
562
+ seen_funcs.add(fn.id)
563
+
564
+ for dep in fn.deps.values():
565
+ if isinstance(dep, Constant):
566
+ if dep.id not in seen_consts:
567
+ seen_consts.add(dep.id)
568
+ all_consts.append(dep)
569
+ continue
570
+ if isinstance(dep, Expr):
571
+ inner_fn = _unwrap_jsfunction(dep)
572
+ if inner_fn is not None:
573
+ walk(inner_fn)
574
+
575
+ all_funcs.append(fn)
576
+
577
+ for fn in functions:
578
+ walk(fn)
579
+
580
+ 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