pulse-framework 0.1.46__py3-none-any.whl → 0.1.47__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. pulse/__init__.py +9 -23
  2. pulse/app.py +2 -24
  3. pulse/codegen/codegen.py +43 -88
  4. pulse/codegen/js.py +35 -5
  5. pulse/codegen/templates/route.py +341 -254
  6. pulse/form.py +1 -1
  7. pulse/helpers.py +40 -8
  8. pulse/hooks/core.py +2 -2
  9. pulse/hooks/effects.py +1 -1
  10. pulse/hooks/init.py +2 -1
  11. pulse/hooks/setup.py +1 -1
  12. pulse/hooks/stable.py +2 -2
  13. pulse/hooks/states.py +2 -2
  14. pulse/html/props.py +3 -2
  15. pulse/html/tags.py +135 -0
  16. pulse/html/tags.pyi +4 -0
  17. pulse/js/__init__.py +110 -0
  18. pulse/js/__init__.pyi +95 -0
  19. pulse/js/_types.py +297 -0
  20. pulse/js/array.py +253 -0
  21. pulse/js/console.py +47 -0
  22. pulse/js/date.py +113 -0
  23. pulse/js/document.py +138 -0
  24. pulse/js/error.py +139 -0
  25. pulse/js/json.py +62 -0
  26. pulse/js/map.py +84 -0
  27. pulse/js/math.py +66 -0
  28. pulse/js/navigator.py +76 -0
  29. pulse/js/number.py +54 -0
  30. pulse/js/object.py +173 -0
  31. pulse/js/promise.py +150 -0
  32. pulse/js/regexp.py +54 -0
  33. pulse/js/set.py +109 -0
  34. pulse/js/string.py +35 -0
  35. pulse/js/weakmap.py +50 -0
  36. pulse/js/weakset.py +45 -0
  37. pulse/js/window.py +199 -0
  38. pulse/messages.py +22 -3
  39. pulse/react_component.py +167 -14
  40. pulse/reactive_extensions.py +5 -5
  41. pulse/render_session.py +144 -34
  42. pulse/renderer.py +80 -115
  43. pulse/routing.py +1 -18
  44. pulse/transpiler/__init__.py +131 -0
  45. pulse/transpiler/builtins.py +731 -0
  46. pulse/transpiler/constants.py +110 -0
  47. pulse/transpiler/context.py +26 -0
  48. pulse/transpiler/errors.py +2 -0
  49. pulse/transpiler/function.py +250 -0
  50. pulse/transpiler/ids.py +16 -0
  51. pulse/transpiler/imports.py +409 -0
  52. pulse/transpiler/js_module.py +274 -0
  53. pulse/transpiler/modules/__init__.py +30 -0
  54. pulse/transpiler/modules/asyncio.py +38 -0
  55. pulse/transpiler/modules/json.py +20 -0
  56. pulse/transpiler/modules/math.py +320 -0
  57. pulse/transpiler/modules/re.py +466 -0
  58. pulse/transpiler/modules/tags.py +268 -0
  59. pulse/transpiler/modules/typing.py +59 -0
  60. pulse/transpiler/nodes.py +1216 -0
  61. pulse/transpiler/py_module.py +119 -0
  62. pulse/transpiler/transpiler.py +938 -0
  63. pulse/transpiler/utils.py +4 -0
  64. pulse/vdom.py +112 -6
  65. {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.47.dist-info}/METADATA +1 -1
  66. pulse_framework-0.1.47.dist-info/RECORD +119 -0
  67. pulse/codegen/imports.py +0 -204
  68. pulse/css.py +0 -155
  69. pulse_framework-0.1.46.dist-info/RECORD +0 -80
  70. {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.47.dist-info}/WHEEL +0 -0
  71. {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.47.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import ClassVar, TypeAlias, override
4
+
5
+ from pulse.transpiler.context import is_interpreted_mode
6
+ from pulse.transpiler.errors import JSCompilationError
7
+ from pulse.transpiler.ids import generate_id
8
+ from pulse.transpiler.nodes import (
9
+ JSArray,
10
+ JSBoolean,
11
+ JSExpr,
12
+ JSIdentifier,
13
+ JSNew,
14
+ JSNumber,
15
+ JSString,
16
+ )
17
+
18
+ JsPrimitive: TypeAlias = bool | int | float | str | None
19
+ JsValue: TypeAlias = "JsPrimitive | list[JsValue] | tuple[JsValue, ...] | set[JsValue] | frozenset[JsValue] | dict[str, JsValue]"
20
+ JsVar: TypeAlias = "JsValue | JSExpr"
21
+
22
+ # Global cache for deduplication across all transpiled functions
23
+ CONSTANTS_CACHE: dict[int, "JsConstant"] = {} # id(value) -> JsConstant
24
+
25
+
26
+ class JsConstant(JSExpr):
27
+ """Wrapper for constant values used in transpiled JS functions."""
28
+
29
+ is_primary: ClassVar[bool] = True
30
+
31
+ value: object
32
+ expr: JSExpr
33
+ id: str
34
+ name: str # Original Python variable name (set by codegen)
35
+
36
+ def __init__(self, value: object, expr: JSExpr, name: str = "") -> None:
37
+ self.value = value
38
+ self.expr = expr
39
+ self.id = generate_id()
40
+ self.name = name
41
+
42
+ @property
43
+ def js_name(self) -> str:
44
+ """Unique JS identifier for this constant."""
45
+ return f"{self.name}_{self.id}" if self.name else f"_const_{self.id}"
46
+
47
+ @override
48
+ def emit(self) -> str:
49
+ """Emit JS code for this constant.
50
+
51
+ In normal mode: returns the unique JS name (e.g., "CONSTANT_1")
52
+ In interpreted mode: returns a get_object call (e.g., "get_object('CONSTANT_1')")
53
+ """
54
+ base = self.js_name
55
+ if is_interpreted_mode():
56
+ return f"get_object('{base}')"
57
+ return base
58
+
59
+
60
+ def _value_to_expr(value: JsValue) -> JSExpr:
61
+ """Convert a Python value to a JSExpr (no caching)."""
62
+ if value is None:
63
+ return JSIdentifier("undefined")
64
+ elif isinstance(value, bool):
65
+ return JSBoolean(value)
66
+ elif isinstance(value, (int, float)):
67
+ return JSNumber(value)
68
+ elif isinstance(value, str):
69
+ return JSString(value)
70
+ elif isinstance(value, (list, tuple)):
71
+ return JSArray([_value_to_expr(v) for v in value])
72
+ elif isinstance(value, (set, frozenset)):
73
+ return JSNew(
74
+ JSIdentifier("Set"),
75
+ [JSArray([_value_to_expr(v) for v in value])],
76
+ )
77
+ elif isinstance(value, dict):
78
+ entries: list[JSExpr] = []
79
+ for k, v in value.items():
80
+ if not isinstance(k, str):
81
+ raise JSCompilationError("Only string keys supported in constant dicts")
82
+ entries.append(JSArray([JSString(k), _value_to_expr(v)]))
83
+ return JSNew(JSIdentifier("Map"), [JSArray(entries)])
84
+ else:
85
+ raise JSCompilationError(
86
+ f"Unsupported global constant: {type(value).__name__} (value: {value!r})"
87
+ )
88
+
89
+
90
+ def const_to_js(value: JsValue, name: str = "") -> JsConstant:
91
+ """Convert a Python value to a JsConstant (cached by identity)."""
92
+ value_id = id(value)
93
+ if value_id in CONSTANTS_CACHE:
94
+ return CONSTANTS_CACHE[value_id]
95
+
96
+ expr = _value_to_expr(value)
97
+ result = JsConstant(value, expr, name)
98
+ CONSTANTS_CACHE[value_id] = result
99
+ return result
100
+
101
+
102
+ def jsify(value: JsVar) -> JSExpr:
103
+ if not isinstance(value, JSExpr):
104
+ return const_to_js(value).expr
105
+ return value
106
+
107
+
108
+ def registered_constants() -> list[JsConstant]:
109
+ """Get all registered JS constants."""
110
+ return list(CONSTANTS_CACHE.values())
@@ -0,0 +1,26 @@
1
+ """Context for JSExpr emit mode."""
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from contextvars import ContextVar
6
+
7
+ # When True, JSExpr.emit() returns code suitable for client-side interpretation
8
+ # (e.g., "get_object('Button_1')" instead of "Button_1")
9
+ _interpreted_mode: ContextVar[bool] = ContextVar(
10
+ "jsexpr_interpreted_mode", default=False
11
+ )
12
+
13
+
14
+ def is_interpreted_mode() -> bool:
15
+ """Check if we're in interpreted mode."""
16
+ return _interpreted_mode.get()
17
+
18
+
19
+ @contextmanager
20
+ def interpreted_mode() -> Iterator[None]:
21
+ """Context manager to enable interpreted mode for JSExpr.emit()."""
22
+ token = _interpreted_mode.set(True)
23
+ try:
24
+ yield
25
+ finally:
26
+ _interpreted_mode.reset(token)
@@ -0,0 +1,2 @@
1
+ class JSCompilationError(Exception):
2
+ pass
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import inspect
5
+ import textwrap
6
+ import types as pytypes
7
+ from typing import (
8
+ Any,
9
+ Callable,
10
+ ClassVar,
11
+ Generic,
12
+ TypeAlias,
13
+ TypeVar,
14
+ TypeVarTuple,
15
+ override,
16
+ )
17
+
18
+ # Import module registrations to ensure they're available for dependency analysis
19
+ import pulse.transpiler.modules # noqa: F401
20
+ 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
26
+ 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,
32
+ )
33
+ from pulse.transpiler.transpiler import JsTranspiler
34
+
35
+ Args = TypeVarTuple("Args")
36
+ R = TypeVar("R")
37
+
38
+
39
+ AnyJsFunction: TypeAlias = "JsFunction[*tuple[Any, ...], Any]"
40
+
41
+ # Global cache for deduplication across all transpiled functions
42
+ # Registered BEFORE analyzing deps to handle mutual recursion
43
+ FUNCTION_CACHE: dict[Callable[..., object], AnyJsFunction] = {}
44
+
45
+
46
+ class JsFunction(JSExpr, Generic[*Args, R]):
47
+ is_primary: ClassVar[bool] = True
48
+
49
+ fn: Callable[[*Args], R]
50
+ id: str
51
+ deps: dict[str, JSExpr]
52
+
53
+ def __init__(self, fn: Callable[[*Args], R]) -> None:
54
+ self.fn = fn
55
+ self.id = generate_id()
56
+
57
+ # Register self in cache BEFORE analyzing deps (handles cycles)
58
+ FUNCTION_CACHE[fn] = self
59
+
60
+ # Analyze code object and resolve globals + closure vars
61
+ effective_globals, all_names = analyze_code_object(fn)
62
+
63
+ # Build dependencies dictionary - all values are JSExpr
64
+ deps: dict[str, JSExpr] = {}
65
+
66
+ for name in all_names:
67
+ value = effective_globals.get(name)
68
+
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
+
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)
106
+
107
+ self.deps = deps
108
+
109
+ @property
110
+ def js_name(self) -> str:
111
+ """Unique JS identifier for this function."""
112
+ return f"{self.fn.__name__}_{self.id}"
113
+
114
+ @override
115
+ def emit(self) -> str:
116
+ """Emit JS code for this function reference.
117
+
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
125
+
126
+ def imports(self) -> dict[str, Import]:
127
+ """Get all Import dependencies."""
128
+ return {k: v for k, v in self.deps.items() if isinstance(v, Import)}
129
+
130
+ def functions(self) -> dict[str, AnyJsFunction]:
131
+ """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
+ return {
147
+ k: v
148
+ for k, v in self.deps.items()
149
+ if isinstance(v, JSTransformer) and v.name not in BUILTINS
150
+ }
151
+
152
+ def transpile(self) -> str:
153
+ """Transpile this JsFunction to JavaScript code.
154
+
155
+ Returns the complete JavaScript function code.
156
+ """
157
+
158
+ # Get source code
159
+ src = getsourcecode(self.fn)
160
+ src = textwrap.dedent(src)
161
+
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]
172
+
173
+ # Get argument names
174
+ arg_names = [arg.arg for arg in fndef.args.args]
175
+
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()
180
+
181
+
182
+ def analyze_code_object(
183
+ fn: Callable[..., object],
184
+ ) -> tuple[dict[str, Any], set[str]]:
185
+ """Analyze code object and resolve globals + closure variables.
186
+
187
+ Returns a tuple of:
188
+ - effective_globals: dict mapping names to their values (includes closure vars)
189
+ - all_names: set of all names referenced in the code (including nested functions)
190
+ """
191
+ code = fn.__code__
192
+
193
+ # Collect all names from code object and nested functions in one pass
194
+ seen_codes: set[int] = set()
195
+ all_names: set[str] = set()
196
+
197
+ def walk_code(c: pytypes.CodeType) -> None:
198
+ if id(c) in seen_codes:
199
+ return
200
+ seen_codes.add(id(c))
201
+ all_names.update(c.co_names)
202
+ all_names.update(c.co_freevars) # Include closure variables
203
+ for const in c.co_consts:
204
+ if isinstance(const, pytypes.CodeType):
205
+ walk_code(const)
206
+
207
+ walk_code(code)
208
+
209
+ # Build effective globals dict: start with function's globals, then add closure values
210
+ effective_globals = dict(fn.__globals__)
211
+
212
+ # Resolve closure variables from closure cells
213
+ if code.co_freevars and fn.__closure__:
214
+ closure = fn.__closure__
215
+ for i, freevar_name in enumerate(code.co_freevars):
216
+ if i < len(closure):
217
+ cell = closure[i]
218
+ # Get the value from the closure cell
219
+ try:
220
+ effective_globals[freevar_name] = cell.cell_contents
221
+ except ValueError:
222
+ # Cell is empty (unbound), skip it
223
+ pass
224
+
225
+ return effective_globals, all_names
226
+
227
+
228
+ def javascript(fn: Callable[[*Args], R]) -> JsFunction[*Args, R]:
229
+ """Decorator to convert a function into a JsFunction.
230
+
231
+ Usage:
232
+ @javascript
233
+ def my_func(x: int) -> int:
234
+ return x + 1
235
+
236
+ # my_func is now a JsFunction instance
237
+ """
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]
243
+
244
+
245
+ def registered_functions() -> list[AnyJsFunction]:
246
+ """Get all registered JS functions."""
247
+ return list(FUNCTION_CACHE.values())
248
+
249
+
250
+ X = JsFunction[int]
@@ -0,0 +1,16 @@
1
+ """Unique ID generator for JavaScript codegen."""
2
+
3
+ from itertools import count
4
+
5
+ _counter = count(1)
6
+
7
+
8
+ def generate_id() -> str:
9
+ """Generate unique hex ID like '1', '2', 'a', 'ff', etc."""
10
+ return f"{next(_counter):x}"
11
+
12
+
13
+ def reset_id_counter() -> None:
14
+ """Reset counter (for testing)."""
15
+ global _counter
16
+ _counter = count(1)