pulse-framework 0.1.72__py3-none-any.whl → 0.1.74__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 +16 -4
- pulse/cli/processes.py +2 -0
- pulse/debounce.py +79 -0
- pulse/decorators.py +4 -3
- pulse/hooks/effects.py +5 -5
- pulse/hooks/runtime.py +25 -8
- pulse/hooks/setup.py +6 -10
- pulse/hooks/stable.py +5 -9
- pulse/hooks/state.py +4 -8
- pulse/queries/common.py +1 -1
- pulse/queries/infinite_query.py +2 -1
- pulse/queries/mutation.py +2 -1
- pulse/queries/query.py +2 -1
- pulse/render_session.py +2 -2
- pulse/renderer.py +30 -2
- pulse/routing.py +19 -5
- pulse/serializer.py +38 -19
- pulse/state/__init__.py +1 -0
- pulse/state/property.py +218 -0
- pulse/state/query_param.py +538 -0
- pulse/{state.py → state/state.py} +66 -220
- pulse/transpiler/__init__.py +5 -0
- pulse/transpiler/function.py +56 -32
- pulse/transpiler/nodes.py +43 -4
- pulse/transpiler/parse.py +70 -0
- pulse/transpiler/transpiler.py +413 -81
- pulse/transpiler/vdom.py +1 -1
- {pulse_framework-0.1.72.dist-info → pulse_framework-0.1.74.dist-info}/METADATA +2 -2
- {pulse_framework-0.1.72.dist-info → pulse_framework-0.1.74.dist-info}/RECORD +31 -26
- {pulse_framework-0.1.72.dist-info → pulse_framework-0.1.74.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.72.dist-info → pulse_framework-0.1.74.dist-info}/entry_points.txt +0 -0
|
@@ -5,223 +5,23 @@ This module provides the base State class and reactive property system
|
|
|
5
5
|
that enables automatic re-rendering when state changes.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
import
|
|
9
|
-
from abc import ABC, ABCMeta
|
|
10
|
-
from collections.abc import
|
|
8
|
+
import sys
|
|
9
|
+
from abc import ABC, ABCMeta
|
|
10
|
+
from collections.abc import Iterator
|
|
11
11
|
from enum import IntEnum
|
|
12
|
-
from
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
from typing import Any, get_type_hints, override
|
|
13
14
|
|
|
14
15
|
from pulse.helpers import Disposable
|
|
15
|
-
from pulse.reactive import
|
|
16
|
-
AsyncEffect,
|
|
17
|
-
Computed,
|
|
18
|
-
Effect,
|
|
19
|
-
Scope,
|
|
20
|
-
Signal,
|
|
21
|
-
)
|
|
16
|
+
from pulse.reactive import Computed, Effect, Scope, Signal
|
|
22
17
|
from pulse.reactive_extensions import ReactiveProperty
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
StateProperty wraps a Signal and provides automatic reactivity for
|
|
32
|
-
class attributes. When a property is read, it subscribes to the underlying
|
|
33
|
-
Signal. When written, it updates the Signal and triggers re-renders.
|
|
34
|
-
|
|
35
|
-
This class is typically not used directly. Instead, declare typed attributes
|
|
36
|
-
on a State subclass, and the StateMeta metaclass will automatically convert
|
|
37
|
-
them into StateProperty instances.
|
|
38
|
-
|
|
39
|
-
Example:
|
|
40
|
-
|
|
41
|
-
```python
|
|
42
|
-
class MyState(ps.State):
|
|
43
|
-
count: int = 0 # Automatically becomes a StateProperty
|
|
44
|
-
name: str = "default"
|
|
45
|
-
|
|
46
|
-
state = MyState()
|
|
47
|
-
state.count = 5 # Updates the underlying Signal
|
|
48
|
-
print(state.count) # Reads from the Signal, subscribes to changes
|
|
49
|
-
```
|
|
50
|
-
"""
|
|
51
|
-
|
|
52
|
-
pass
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
class InitializableProperty(ABC):
|
|
56
|
-
@abstractmethod
|
|
57
|
-
def initialize(self, state: "State", name: str) -> Any: ...
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
class ComputedProperty(Generic[T]):
|
|
61
|
-
"""
|
|
62
|
-
Descriptor for computed (derived) properties on State classes.
|
|
63
|
-
|
|
64
|
-
ComputedProperty wraps a method that derives its value from other reactive
|
|
65
|
-
properties. The computed value is cached and only recalculated when its
|
|
66
|
-
dependencies change. Reading a computed property subscribes to it.
|
|
67
|
-
|
|
68
|
-
Created automatically when using the @ps.computed decorator on a State method.
|
|
69
|
-
|
|
70
|
-
Args:
|
|
71
|
-
name: The property name (used for debugging and the private storage key).
|
|
72
|
-
fn: The method that computes the value. Must take only `self` as argument.
|
|
73
|
-
|
|
74
|
-
Example:
|
|
75
|
-
|
|
76
|
-
```python
|
|
77
|
-
class MyState(ps.State):
|
|
78
|
-
count: int = 0
|
|
79
|
-
|
|
80
|
-
@ps.computed
|
|
81
|
-
def doubled(self):
|
|
82
|
-
return self.count * 2
|
|
83
|
-
|
|
84
|
-
state = MyState()
|
|
85
|
-
print(state.doubled) # 0
|
|
86
|
-
state.count = 5
|
|
87
|
-
print(state.doubled) # 10 (automatically recomputed)
|
|
88
|
-
```
|
|
89
|
-
"""
|
|
90
|
-
|
|
91
|
-
name: str
|
|
92
|
-
private_name: str
|
|
93
|
-
fn: "Callable[[State], T]"
|
|
94
|
-
|
|
95
|
-
def __init__(self, name: str, fn: "Callable[[State], T]"):
|
|
96
|
-
self.name = name
|
|
97
|
-
self.private_name = f"__computed_{name}"
|
|
98
|
-
# The computed_template holds the original method
|
|
99
|
-
self.fn = fn
|
|
100
|
-
|
|
101
|
-
def get_computed(self, obj: Any) -> Computed[T]:
|
|
102
|
-
if not isinstance(obj, State):
|
|
103
|
-
raise ValueError(
|
|
104
|
-
f"Computed property {self.name} defined on a non-State class"
|
|
105
|
-
)
|
|
106
|
-
if not hasattr(obj, self.private_name):
|
|
107
|
-
# Create the computed on first access for this instance
|
|
108
|
-
bound_method = self.fn.__get__(obj, obj.__class__)
|
|
109
|
-
new_computed = Computed(
|
|
110
|
-
bound_method,
|
|
111
|
-
name=f"{obj.__class__.__name__}.{self.name}",
|
|
112
|
-
)
|
|
113
|
-
setattr(obj, self.private_name, new_computed)
|
|
114
|
-
return getattr(obj, self.private_name)
|
|
115
|
-
|
|
116
|
-
def __get__(self, obj: Any, objtype: Any = None) -> T:
|
|
117
|
-
if obj is None:
|
|
118
|
-
return self # pyright: ignore[reportReturnType]
|
|
119
|
-
|
|
120
|
-
return self.get_computed(obj).read()
|
|
121
|
-
|
|
122
|
-
def __set__(self, obj: Any, value: Any) -> Never:
|
|
123
|
-
raise AttributeError(f"Cannot set computed property '{self.name}'")
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
class StateEffect(Generic[T], InitializableProperty):
|
|
127
|
-
"""
|
|
128
|
-
Descriptor for side effects on State classes.
|
|
129
|
-
|
|
130
|
-
StateEffect wraps a method that performs side effects when its dependencies
|
|
131
|
-
change. The effect is initialized when the State instance is created and
|
|
132
|
-
disposed when the State is disposed.
|
|
133
|
-
|
|
134
|
-
Created automatically when using the @ps.effect decorator on a State method.
|
|
135
|
-
Supports both sync and async methods.
|
|
136
|
-
|
|
137
|
-
Args:
|
|
138
|
-
fn: The effect function. Must take only `self` as argument.
|
|
139
|
-
Can return a cleanup function that runs before the next execution
|
|
140
|
-
or when the effect is disposed.
|
|
141
|
-
name: Debug name for the effect. Defaults to "ClassName.method_name".
|
|
142
|
-
immediate: If True, run synchronously when scheduled (sync effects only).
|
|
143
|
-
lazy: If True, don't run on creation; wait for first dependency change.
|
|
144
|
-
on_error: Callback for handling errors during effect execution.
|
|
145
|
-
deps: Explicit dependencies. If provided, auto-tracking is disabled.
|
|
146
|
-
interval: Re-run interval in seconds for polling effects.
|
|
147
|
-
|
|
148
|
-
Example:
|
|
149
|
-
|
|
150
|
-
```python
|
|
151
|
-
class MyState(ps.State):
|
|
152
|
-
count: int = 0
|
|
153
|
-
|
|
154
|
-
@ps.effect
|
|
155
|
-
def log_count(self):
|
|
156
|
-
print(f"Count changed to: {self.count}")
|
|
157
|
-
|
|
158
|
-
@ps.effect
|
|
159
|
-
async def fetch_data(self):
|
|
160
|
-
data = await api.fetch(self.query)
|
|
161
|
-
self.data = data
|
|
162
|
-
|
|
163
|
-
@ps.effect
|
|
164
|
-
def subscribe(self):
|
|
165
|
-
unsub = event_bus.subscribe(self.handle_event)
|
|
166
|
-
return unsub # Cleanup function
|
|
167
|
-
```
|
|
168
|
-
"""
|
|
169
|
-
|
|
170
|
-
fn: "Callable[[State], T]"
|
|
171
|
-
name: str | None
|
|
172
|
-
immediate: bool
|
|
173
|
-
on_error: "Callable[[Exception], None] | None"
|
|
174
|
-
lazy: bool
|
|
175
|
-
deps: "list[Signal[Any] | Computed[Any]] | None"
|
|
176
|
-
update_deps: bool | None
|
|
177
|
-
interval: float | None
|
|
178
|
-
|
|
179
|
-
def __init__(
|
|
180
|
-
self,
|
|
181
|
-
fn: "Callable[[State], T]",
|
|
182
|
-
name: str | None = None,
|
|
183
|
-
immediate: bool = False,
|
|
184
|
-
lazy: bool = False,
|
|
185
|
-
on_error: "Callable[[Exception], None] | None" = None,
|
|
186
|
-
deps: "list[Signal[Any] | Computed[Any]] | None" = None,
|
|
187
|
-
update_deps: bool | None = None,
|
|
188
|
-
interval: float | None = None,
|
|
189
|
-
):
|
|
190
|
-
self.fn = fn
|
|
191
|
-
self.name = name
|
|
192
|
-
self.immediate = immediate
|
|
193
|
-
self.on_error = on_error
|
|
194
|
-
self.lazy = lazy
|
|
195
|
-
self.deps = deps
|
|
196
|
-
self.update_deps = update_deps
|
|
197
|
-
self.interval = interval
|
|
198
|
-
|
|
199
|
-
@override
|
|
200
|
-
def initialize(self, state: "State", name: str):
|
|
201
|
-
bound_method = self.fn.__get__(state, state.__class__)
|
|
202
|
-
# Select sync/async effect type based on bound method
|
|
203
|
-
if inspect.iscoroutinefunction(bound_method):
|
|
204
|
-
effect: Effect = AsyncEffect(
|
|
205
|
-
bound_method, # type: ignore[arg-type]
|
|
206
|
-
name=self.name or f"{state.__class__.__name__}.{name}",
|
|
207
|
-
lazy=self.lazy,
|
|
208
|
-
on_error=self.on_error,
|
|
209
|
-
deps=self.deps,
|
|
210
|
-
update_deps=self.update_deps,
|
|
211
|
-
interval=self.interval,
|
|
212
|
-
)
|
|
213
|
-
else:
|
|
214
|
-
effect = Effect(
|
|
215
|
-
bound_method, # type: ignore[arg-type]
|
|
216
|
-
name=self.name or f"{state.__class__.__name__}.{name}",
|
|
217
|
-
immediate=self.immediate,
|
|
218
|
-
lazy=self.lazy,
|
|
219
|
-
on_error=self.on_error,
|
|
220
|
-
deps=self.deps,
|
|
221
|
-
update_deps=self.update_deps,
|
|
222
|
-
interval=self.interval,
|
|
223
|
-
)
|
|
224
|
-
setattr(state, name, effect)
|
|
18
|
+
from pulse.state.property import (
|
|
19
|
+
ComputedProperty,
|
|
20
|
+
InitializableProperty,
|
|
21
|
+
StateEffect,
|
|
22
|
+
StateProperty,
|
|
23
|
+
)
|
|
24
|
+
from pulse.state.query_param import QueryParam, QueryParamProperty, extract_query_param
|
|
225
25
|
|
|
226
26
|
|
|
227
27
|
class StateMeta(ABCMeta):
|
|
@@ -258,18 +58,62 @@ class StateMeta(ABCMeta):
|
|
|
258
58
|
namespace: dict[str, Any],
|
|
259
59
|
**kwargs: Any,
|
|
260
60
|
):
|
|
261
|
-
|
|
61
|
+
declared_annotations = dict(namespace.get("__annotations__", {}))
|
|
62
|
+
cls = super().__new__(mcs, name, bases, namespace)
|
|
63
|
+
resolved_annotations: dict[str, Any] = {}
|
|
64
|
+
if declared_annotations:
|
|
65
|
+
module = sys.modules.get(cls.__module__)
|
|
66
|
+
globalns = module.__dict__ if module else {}
|
|
67
|
+
if "QueryParam" not in globalns:
|
|
68
|
+
globalns["QueryParam"] = QueryParam
|
|
69
|
+
localns = dict(cls.__dict__)
|
|
70
|
+
try:
|
|
71
|
+
hints = get_type_hints(
|
|
72
|
+
cls,
|
|
73
|
+
globalns=globalns,
|
|
74
|
+
localns=localns,
|
|
75
|
+
)
|
|
76
|
+
except Exception:
|
|
77
|
+
hints = None
|
|
78
|
+
if hints is not None:
|
|
79
|
+
for key, value in declared_annotations.items():
|
|
80
|
+
resolved_annotations[key] = hints.get(key, value)
|
|
81
|
+
else:
|
|
82
|
+
for key, value in declared_annotations.items():
|
|
83
|
+
try:
|
|
84
|
+
holder = SimpleNamespace(__annotations__={key: value})
|
|
85
|
+
resolved = get_type_hints(
|
|
86
|
+
holder,
|
|
87
|
+
globalns=globalns,
|
|
88
|
+
localns=localns,
|
|
89
|
+
).get(key, value)
|
|
90
|
+
except Exception:
|
|
91
|
+
resolved = value
|
|
92
|
+
resolved_annotations[key] = resolved
|
|
262
93
|
|
|
263
94
|
# 1) Turn annotated fields into StateProperty descriptors
|
|
264
|
-
for attr_name in
|
|
95
|
+
for attr_name, annotation in resolved_annotations.items():
|
|
265
96
|
# Do not wrap private/dunder attributes as reactive
|
|
266
97
|
if attr_name.startswith("_"):
|
|
267
98
|
continue
|
|
268
|
-
default_value =
|
|
269
|
-
|
|
99
|
+
default_value = cls.__dict__.get(attr_name)
|
|
100
|
+
value_type, is_query_param = extract_query_param(annotation)
|
|
101
|
+
if is_query_param:
|
|
102
|
+
cls.__annotations__[attr_name] = value_type
|
|
103
|
+
prop = QueryParamProperty(
|
|
104
|
+
attr_name,
|
|
105
|
+
default_value,
|
|
106
|
+
value_type,
|
|
107
|
+
)
|
|
108
|
+
setattr(cls, attr_name, prop)
|
|
109
|
+
prop.__set_name__(cls, attr_name)
|
|
110
|
+
else:
|
|
111
|
+
prop = StateProperty(attr_name, default_value)
|
|
112
|
+
setattr(cls, attr_name, prop)
|
|
113
|
+
prop.__set_name__(cls, attr_name)
|
|
270
114
|
|
|
271
115
|
# 2) Turn non-annotated plain values into StateProperty descriptors
|
|
272
|
-
for attr_name, value in list(
|
|
116
|
+
for attr_name, value in list(cls.__dict__.items()):
|
|
273
117
|
# Do not wrap private/dunder attributes as reactive
|
|
274
118
|
if attr_name.startswith("_"):
|
|
275
119
|
continue
|
|
@@ -285,9 +129,11 @@ class StateMeta(ABCMeta):
|
|
|
285
129
|
):
|
|
286
130
|
continue
|
|
287
131
|
# Convert plain class var into a StateProperty
|
|
288
|
-
|
|
132
|
+
prop = StateProperty(attr_name, value)
|
|
133
|
+
setattr(cls, attr_name, prop)
|
|
134
|
+
prop.__set_name__(cls, attr_name)
|
|
289
135
|
|
|
290
|
-
return
|
|
136
|
+
return cls
|
|
291
137
|
|
|
292
138
|
@override
|
|
293
139
|
def __call__(cls, *args: Any, **kwargs: Any):
|
pulse/transpiler/__init__.py
CHANGED
|
@@ -106,6 +106,11 @@ from pulse.transpiler.nodes import While as While
|
|
|
106
106
|
# Emit
|
|
107
107
|
from pulse.transpiler.nodes import emit as emit
|
|
108
108
|
|
|
109
|
+
# Parse helpers
|
|
110
|
+
from pulse.transpiler.parse import ParsedSource as ParsedSource
|
|
111
|
+
from pulse.transpiler.parse import get_ast as get_ast
|
|
112
|
+
from pulse.transpiler.parse import get_source as get_source
|
|
113
|
+
|
|
109
114
|
# Transpiler
|
|
110
115
|
from pulse.transpiler.transpiler import Transpiler as Transpiler
|
|
111
116
|
from pulse.transpiler.transpiler import transpile as transpile
|
pulse/transpiler/function.py
CHANGED
|
@@ -7,8 +7,8 @@ and JsFunction which wraps transpiled functions with their dependencies.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import ast
|
|
10
|
+
import dis
|
|
10
11
|
import inspect
|
|
11
|
-
import textwrap
|
|
12
12
|
import types as pytypes
|
|
13
13
|
from collections.abc import Callable
|
|
14
14
|
from dataclasses import dataclass, field
|
|
@@ -25,7 +25,6 @@ from typing import (
|
|
|
25
25
|
override,
|
|
26
26
|
)
|
|
27
27
|
|
|
28
|
-
from pulse.helpers import getsourcecode
|
|
29
28
|
from pulse.transpiler.errors import TranspileError
|
|
30
29
|
from pulse.transpiler.id import next_id, reset_id_counter
|
|
31
30
|
from pulse.transpiler.imports import Import
|
|
@@ -38,6 +37,7 @@ from pulse.transpiler.nodes import (
|
|
|
38
37
|
Return,
|
|
39
38
|
to_js_identifier,
|
|
40
39
|
)
|
|
40
|
+
from pulse.transpiler.parse import clear_parse_cache, get_ast, get_source
|
|
41
41
|
from pulse.transpiler.transpiler import Transpiler
|
|
42
42
|
from pulse.transpiler.vdom import VDOMExpr
|
|
43
43
|
|
|
@@ -63,6 +63,7 @@ def clear_function_cache() -> None:
|
|
|
63
63
|
|
|
64
64
|
FUNCTION_CACHE.clear()
|
|
65
65
|
CONSTANT_REGISTRY.clear()
|
|
66
|
+
clear_parse_cache()
|
|
66
67
|
clear_import_registry()
|
|
67
68
|
clear_asset_registry()
|
|
68
69
|
reset_id_counter()
|
|
@@ -137,33 +138,17 @@ def _transpile_function_body(
|
|
|
137
138
|
deps: dict[str, Expr],
|
|
138
139
|
*,
|
|
139
140
|
jsx: bool = False,
|
|
140
|
-
) ->
|
|
141
|
+
) -> Function | Arrow:
|
|
141
142
|
"""Shared transpilation logic for JsFunction and JsxFunction.
|
|
142
143
|
|
|
143
|
-
Returns the transpiled Function/Arrow node
|
|
144
|
+
Returns the transpiled Function/Arrow node.
|
|
144
145
|
"""
|
|
145
146
|
# Get and parse source
|
|
146
|
-
|
|
147
|
-
src =
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
147
|
+
parsed = get_source(fn)
|
|
148
|
+
src = parsed.source
|
|
149
|
+
fndef = get_ast(fn)
|
|
150
|
+
filename = parsed.filename
|
|
151
|
+
source_start_line = parsed.source_start_line
|
|
167
152
|
|
|
168
153
|
# Transpile with source context for errors
|
|
169
154
|
try:
|
|
@@ -181,7 +166,7 @@ def _transpile_function_body(
|
|
|
181
166
|
) from None
|
|
182
167
|
raise
|
|
183
168
|
|
|
184
|
-
return result
|
|
169
|
+
return result
|
|
185
170
|
|
|
186
171
|
|
|
187
172
|
@dataclass(slots=True, init=False)
|
|
@@ -238,7 +223,7 @@ class JsFunction(Expr, Generic[*Args, R]):
|
|
|
238
223
|
if self._transpiled is not None:
|
|
239
224
|
return self._transpiled
|
|
240
225
|
|
|
241
|
-
result
|
|
226
|
+
result = _transpile_function_body(self.fn, self.deps)
|
|
242
227
|
|
|
243
228
|
# Convert Arrow to Function if needed, and set the name
|
|
244
229
|
if isinstance(result, Function):
|
|
@@ -326,7 +311,7 @@ class JsxFunction(Expr, Generic[P, R]):
|
|
|
326
311
|
if self._transpiled is not None:
|
|
327
312
|
return self._transpiled
|
|
328
313
|
|
|
329
|
-
result
|
|
314
|
+
result = _transpile_function_body(self.fn, self.deps, jsx=True)
|
|
330
315
|
|
|
331
316
|
# JSX transpilation always returns Function (never Arrow)
|
|
332
317
|
assert isinstance(result, Function), (
|
|
@@ -376,7 +361,6 @@ def analyze_code_object(
|
|
|
376
361
|
- effective_globals: dict mapping names to their values (includes closure vars)
|
|
377
362
|
- all_names: set of all names referenced in the code (including nested functions)
|
|
378
363
|
"""
|
|
379
|
-
import dis
|
|
380
364
|
|
|
381
365
|
code = fn.__code__
|
|
382
366
|
|
|
@@ -443,14 +427,54 @@ def analyze_deps(fn: Callable[..., Any]) -> dict[str, Expr]:
|
|
|
443
427
|
"""
|
|
444
428
|
# Analyze code object and resolve globals + closure vars
|
|
445
429
|
effective_globals, all_names = analyze_code_object(fn)
|
|
430
|
+
code_names = set(all_names)
|
|
431
|
+
default_names: set[str] = set()
|
|
432
|
+
default_name_values: dict[str, Any] = {}
|
|
433
|
+
|
|
434
|
+
# Include names referenced only in default expressions (not in bytecode)
|
|
435
|
+
try:
|
|
436
|
+
args = get_ast(fn).args
|
|
437
|
+
pos_defaults = list(args.defaults)
|
|
438
|
+
py_defaults = fn.__defaults__ or ()
|
|
439
|
+
num_args = len(args.args)
|
|
440
|
+
num_defaults = len(pos_defaults)
|
|
441
|
+
for i, _arg in enumerate(args.args):
|
|
442
|
+
default_idx = i - (num_args - num_defaults)
|
|
443
|
+
if default_idx < 0 or default_idx >= len(pos_defaults):
|
|
444
|
+
continue
|
|
445
|
+
default_node = pos_defaults[default_idx]
|
|
446
|
+
if isinstance(default_node, ast.Name) and default_idx < len(py_defaults):
|
|
447
|
+
default_name_values[default_node.id] = py_defaults[default_idx]
|
|
448
|
+
for node in ast.walk(default_node):
|
|
449
|
+
if isinstance(node, ast.Name):
|
|
450
|
+
default_names.add(node.id)
|
|
451
|
+
|
|
452
|
+
py_kwdefaults = fn.__kwdefaults__ or {}
|
|
453
|
+
for i, kwarg in enumerate(args.kwonlyargs):
|
|
454
|
+
default_node = args.kw_defaults[i]
|
|
455
|
+
if default_node is None:
|
|
456
|
+
continue
|
|
457
|
+
if isinstance(default_node, ast.Name) and kwarg.arg in py_kwdefaults:
|
|
458
|
+
default_name_values[default_node.id] = py_kwdefaults[kwarg.arg]
|
|
459
|
+
for node in ast.walk(default_node):
|
|
460
|
+
if isinstance(node, ast.Name):
|
|
461
|
+
default_names.add(node.id)
|
|
462
|
+
except (OSError, TypeError, SyntaxError, TranspileError):
|
|
463
|
+
pass
|
|
464
|
+
|
|
465
|
+
all_names.update(default_names)
|
|
466
|
+
default_only_names = default_names - code_names
|
|
446
467
|
|
|
447
468
|
# Build dependencies dictionary - all values are Expr
|
|
448
469
|
deps: dict[str, Expr] = {}
|
|
449
470
|
|
|
471
|
+
missing = object()
|
|
450
472
|
for name in all_names:
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
473
|
+
if name in default_only_names and name in default_name_values:
|
|
474
|
+
value = default_name_values[name]
|
|
475
|
+
else:
|
|
476
|
+
value = effective_globals.get(name, missing)
|
|
477
|
+
if value is missing:
|
|
454
478
|
# Not in globals - could be a builtin or unresolved
|
|
455
479
|
# For now, skip - builtins will be handled by the transpiler
|
|
456
480
|
# TODO: Add builtin support
|
pulse/transpiler/nodes.py
CHANGED
|
@@ -1150,6 +1150,15 @@ class Unary(Expr):
|
|
|
1150
1150
|
out.append(" ")
|
|
1151
1151
|
else:
|
|
1152
1152
|
out.append(self.op)
|
|
1153
|
+
if (
|
|
1154
|
+
self.op in {"+", "-"}
|
|
1155
|
+
and isinstance(self.operand, Unary)
|
|
1156
|
+
and self.operand.op == self.op
|
|
1157
|
+
):
|
|
1158
|
+
out.append("(")
|
|
1159
|
+
self.operand.emit(out)
|
|
1160
|
+
out.append(")")
|
|
1161
|
+
return
|
|
1153
1162
|
_emit_paren(self.operand, self.op, "unary", out)
|
|
1154
1163
|
|
|
1155
1164
|
@override
|
|
@@ -1508,7 +1517,7 @@ class If(Stmt):
|
|
|
1508
1517
|
|
|
1509
1518
|
@dataclass(slots=True)
|
|
1510
1519
|
class ForOf(Stmt):
|
|
1511
|
-
"""JS for-of loop: for (
|
|
1520
|
+
"""JS for-of loop: for (x of iter) { ... }
|
|
1512
1521
|
|
|
1513
1522
|
target can be a single name or array pattern for destructuring: [a, b]
|
|
1514
1523
|
"""
|
|
@@ -1519,7 +1528,7 @@ class ForOf(Stmt):
|
|
|
1519
1528
|
|
|
1520
1529
|
@override
|
|
1521
1530
|
def emit(self, out: list[str]) -> None:
|
|
1522
|
-
out.append("for (
|
|
1531
|
+
out.append("for (")
|
|
1523
1532
|
out.append(self.target)
|
|
1524
1533
|
out.append(" of ")
|
|
1525
1534
|
self.iter.emit(out)
|
|
@@ -1574,17 +1583,32 @@ class Assign(Stmt):
|
|
|
1574
1583
|
op: None for =, or "+", "-", etc. for augmented assignment
|
|
1575
1584
|
"""
|
|
1576
1585
|
|
|
1577
|
-
target: str
|
|
1586
|
+
target: str | Identifier | Member | Subscript
|
|
1578
1587
|
value: Expr
|
|
1579
1588
|
declare: Lit["let", "const"] | None = None
|
|
1580
1589
|
op: str | None = None # For augmented: +=, -=, etc.
|
|
1581
1590
|
|
|
1591
|
+
@staticmethod
|
|
1592
|
+
def _validate_target(target: object) -> None:
|
|
1593
|
+
if not isinstance(target, (str, Identifier, Member, Subscript)):
|
|
1594
|
+
raise TypeError(
|
|
1595
|
+
"Assign target must be str, Identifier, Member, or Subscript; "
|
|
1596
|
+
+ f"got {type(target).__name__}: {target!r}"
|
|
1597
|
+
)
|
|
1598
|
+
|
|
1599
|
+
def __post_init__(self) -> None:
|
|
1600
|
+
self._validate_target(self.target)
|
|
1601
|
+
|
|
1582
1602
|
@override
|
|
1583
1603
|
def emit(self, out: list[str]) -> None:
|
|
1604
|
+
self._validate_target(self.target)
|
|
1584
1605
|
if self.declare:
|
|
1585
1606
|
out.append(self.declare)
|
|
1586
1607
|
out.append(" ")
|
|
1587
|
-
|
|
1608
|
+
if isinstance(self.target, str):
|
|
1609
|
+
out.append(self.target)
|
|
1610
|
+
else:
|
|
1611
|
+
_emit_primary(self.target, out)
|
|
1588
1612
|
if self.op:
|
|
1589
1613
|
out.append(" ")
|
|
1590
1614
|
out.append(self.op)
|
|
@@ -1595,6 +1619,21 @@ class Assign(Stmt):
|
|
|
1595
1619
|
out.append(";")
|
|
1596
1620
|
|
|
1597
1621
|
|
|
1622
|
+
@dataclass(slots=True)
|
|
1623
|
+
class LetDecl(Stmt):
|
|
1624
|
+
"""JS let declaration: let a, b;"""
|
|
1625
|
+
|
|
1626
|
+
names: Sequence[str]
|
|
1627
|
+
|
|
1628
|
+
@override
|
|
1629
|
+
def emit(self, out: list[str]) -> None:
|
|
1630
|
+
if not self.names:
|
|
1631
|
+
return
|
|
1632
|
+
out.append("let ")
|
|
1633
|
+
out.append(", ".join(self.names))
|
|
1634
|
+
out.append(";")
|
|
1635
|
+
|
|
1636
|
+
|
|
1598
1637
|
@dataclass(slots=True)
|
|
1599
1638
|
class ExprStmt(Stmt):
|
|
1600
1639
|
"""JS expression statement: expr;"""
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Cached parsing helpers for transpiler source inspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import inspect
|
|
7
|
+
import textwrap
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pulse.helpers import getsourcecode
|
|
13
|
+
from pulse.transpiler.errors import TranspileError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class ParsedSource:
|
|
18
|
+
source: str
|
|
19
|
+
filename: str | None
|
|
20
|
+
source_start_line: int | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_SOURCE_CACHE: dict[Callable[..., Any], ParsedSource] = {}
|
|
24
|
+
_AST_CACHE: dict[Callable[..., Any], ast.FunctionDef | ast.AsyncFunctionDef] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def clear_parse_cache() -> None:
|
|
28
|
+
_SOURCE_CACHE.clear()
|
|
29
|
+
_AST_CACHE.clear()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_source(fn: Callable[..., Any]) -> ParsedSource:
|
|
33
|
+
cached = _SOURCE_CACHE.get(fn)
|
|
34
|
+
if cached is not None:
|
|
35
|
+
return cached
|
|
36
|
+
|
|
37
|
+
src = getsourcecode(fn)
|
|
38
|
+
src = textwrap.dedent(src)
|
|
39
|
+
try:
|
|
40
|
+
source_start_line = inspect.getsourcelines(fn)[1]
|
|
41
|
+
except (OSError, TypeError):
|
|
42
|
+
source_start_line = None
|
|
43
|
+
try:
|
|
44
|
+
filename = inspect.getfile(fn)
|
|
45
|
+
except (TypeError, OSError):
|
|
46
|
+
filename = None
|
|
47
|
+
|
|
48
|
+
parsed = ParsedSource(
|
|
49
|
+
source=src,
|
|
50
|
+
filename=filename,
|
|
51
|
+
source_start_line=source_start_line,
|
|
52
|
+
)
|
|
53
|
+
_SOURCE_CACHE[fn] = parsed
|
|
54
|
+
return parsed
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_ast(fn: Callable[..., Any]) -> ast.FunctionDef | ast.AsyncFunctionDef:
|
|
58
|
+
cached = _AST_CACHE.get(fn)
|
|
59
|
+
if cached is not None:
|
|
60
|
+
return cached
|
|
61
|
+
|
|
62
|
+
module = ast.parse(get_source(fn).source)
|
|
63
|
+
fndefs = [
|
|
64
|
+
n for n in module.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
65
|
+
]
|
|
66
|
+
if not fndefs:
|
|
67
|
+
raise TranspileError("No function definition found in source")
|
|
68
|
+
fndef = fndefs[-1]
|
|
69
|
+
_AST_CACHE[fn] = fndef
|
|
70
|
+
return fndef
|