pulse-framework 0.1.62__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,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