pulse-framework 0.1.62__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pulse/__init__.py +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/hooks/init.py
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import ctypes
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
import textwrap
|
|
8
|
+
import types
|
|
9
|
+
from collections.abc import Callable, Sequence
|
|
10
|
+
from typing import Any, Literal, cast, override
|
|
11
|
+
|
|
12
|
+
from pulse.helpers import getsourcecode
|
|
13
|
+
from pulse.hooks.core import HookState, hooks
|
|
14
|
+
from pulse.transpiler.errors import TranspileError
|
|
15
|
+
|
|
16
|
+
# Storage keyed by (code object, lineno) of the `with ps.init()` call site.
|
|
17
|
+
_init_hook = hooks.create("init_storage", lambda: InitState())
|
|
18
|
+
|
|
19
|
+
_CAN_USE_CPYTHON = hasattr(ctypes.pythonapi, "PyFrame_LocalsToFast")
|
|
20
|
+
if _CAN_USE_CPYTHON:
|
|
21
|
+
PyFrame_LocalsToFast = ctypes.pythonapi.PyFrame_LocalsToFast
|
|
22
|
+
PyFrame_LocalsToFast.argtypes = [ctypes.py_object, ctypes.c_int]
|
|
23
|
+
PyFrame_LocalsToFast.restype = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def previous_frame() -> types.FrameType:
|
|
27
|
+
"""Get the previous frame (caller's frame) with assertions.
|
|
28
|
+
|
|
29
|
+
This skips the frame of this helper function and its immediate caller
|
|
30
|
+
to return the actual previous frame.
|
|
31
|
+
"""
|
|
32
|
+
current = inspect.currentframe()
|
|
33
|
+
assert current is not None, "currentframe() returned None"
|
|
34
|
+
# Skip this helper function's frame
|
|
35
|
+
caller = current.f_back
|
|
36
|
+
assert caller is not None, "f_back is None"
|
|
37
|
+
# Skip the caller's frame (e.g., __enter__) to get the actual previous frame
|
|
38
|
+
frame = caller.f_back
|
|
39
|
+
assert frame is not None, "f_back.f_back is None"
|
|
40
|
+
return frame
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InitContext:
|
|
44
|
+
"""Context manager for one-time initialization in components.
|
|
45
|
+
|
|
46
|
+
Variables assigned inside the block persist across re-renders. On first render,
|
|
47
|
+
the code inside runs normally and variables are captured. On subsequent renders,
|
|
48
|
+
the block is skipped and variables are restored from storage.
|
|
49
|
+
|
|
50
|
+
This class is returned by ``ps.init()`` and should be used as a context manager.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
callsite: Tuple of (code object, line number) identifying the call site.
|
|
54
|
+
frame: The stack frame where init was called.
|
|
55
|
+
first_render: True if this is the first render cycle.
|
|
56
|
+
pre_keys: Set of variable names that existed before entering the block.
|
|
57
|
+
saved: Dictionary of captured variable values.
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
def my_component():
|
|
63
|
+
with ps.init():
|
|
64
|
+
counter = 0
|
|
65
|
+
api = ApiClient()
|
|
66
|
+
data = fetch_initial_data()
|
|
67
|
+
# counter, api, data retain their values across renders
|
|
68
|
+
return m.Text(f"Counter: {counter}")
|
|
69
|
+
```
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
callsite: tuple[Any, int] | None
|
|
73
|
+
frame: types.FrameType | None
|
|
74
|
+
first_render: bool
|
|
75
|
+
pre_keys: set[str]
|
|
76
|
+
saved: dict[str, Any]
|
|
77
|
+
|
|
78
|
+
def __init__(self):
|
|
79
|
+
self.callsite = None
|
|
80
|
+
self.frame = None
|
|
81
|
+
self.first_render = False
|
|
82
|
+
self.pre_keys = set()
|
|
83
|
+
self.saved = {}
|
|
84
|
+
|
|
85
|
+
def __enter__(self):
|
|
86
|
+
self.frame = previous_frame()
|
|
87
|
+
self.pre_keys = set(self.frame.f_locals.keys())
|
|
88
|
+
# Use code object to disambiguate identical line numbers in different fns.
|
|
89
|
+
self.callsite = (self.frame.f_code, self.frame.f_lineno)
|
|
90
|
+
|
|
91
|
+
storage = _init_hook().storage
|
|
92
|
+
entry = storage.get(self.callsite)
|
|
93
|
+
if entry is None:
|
|
94
|
+
self.first_render = True
|
|
95
|
+
self.saved = {}
|
|
96
|
+
else:
|
|
97
|
+
self.first_render = False
|
|
98
|
+
self.saved = entry["vars"]
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def restore_variables(self):
|
|
102
|
+
if self.first_render:
|
|
103
|
+
return
|
|
104
|
+
frame = self.frame if self.frame is not None else previous_frame()
|
|
105
|
+
frame.f_locals.update(self.saved)
|
|
106
|
+
PyFrame_LocalsToFast(frame, 1)
|
|
107
|
+
|
|
108
|
+
def save(self, values: dict[str, Any]):
|
|
109
|
+
self.saved = values
|
|
110
|
+
assert self.callsite is not None, "callsite is None"
|
|
111
|
+
storage = _init_hook().storage
|
|
112
|
+
storage[self.callsite] = {"vars": values}
|
|
113
|
+
|
|
114
|
+
def _capture_new_locals(self) -> dict[str, Any]:
|
|
115
|
+
frame = self.frame
|
|
116
|
+
assert frame is not None, "frame is None"
|
|
117
|
+
captured = {}
|
|
118
|
+
for name, value in frame.f_locals.items():
|
|
119
|
+
if name in self.pre_keys:
|
|
120
|
+
continue
|
|
121
|
+
if value is self:
|
|
122
|
+
continue
|
|
123
|
+
captured[name] = value
|
|
124
|
+
return captured
|
|
125
|
+
|
|
126
|
+
def __exit__(
|
|
127
|
+
self,
|
|
128
|
+
exc_type: type[BaseException] | None,
|
|
129
|
+
exc_value: BaseException | None,
|
|
130
|
+
exc_tb: Any,
|
|
131
|
+
) -> Literal[False]:
|
|
132
|
+
if exc_type is None:
|
|
133
|
+
captured = self._capture_new_locals()
|
|
134
|
+
assert self.callsite is not None, "callsite None"
|
|
135
|
+
storage = _init_hook().storage
|
|
136
|
+
storage[self.callsite] = {"vars": captured}
|
|
137
|
+
self.frame = None
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def init() -> InitContext:
|
|
142
|
+
"""Context manager for one-time initialization in components.
|
|
143
|
+
|
|
144
|
+
Variables assigned inside the block persist across re-renders. Uses AST
|
|
145
|
+
rewriting to transform the code at decoration time.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
InitContext: Context manager that captures and restores variables.
|
|
149
|
+
|
|
150
|
+
Example:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
def my_component():
|
|
154
|
+
with ps.init():
|
|
155
|
+
counter = 0
|
|
156
|
+
api = ApiClient()
|
|
157
|
+
data = fetch_initial_data()
|
|
158
|
+
# counter, api, data retain their values across renders
|
|
159
|
+
return m.Text(f"Counter: {counter}")
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Rules:
|
|
163
|
+
- Can only be used once per component
|
|
164
|
+
- Must be at the top level of the component function (not inside
|
|
165
|
+
conditionals, loops, or nested functions)
|
|
166
|
+
- Cannot contain control flow (if, for, while, try, with, match)
|
|
167
|
+
- Cannot use ``as`` binding (``with ps.init() as ctx:`` not allowed)
|
|
168
|
+
- Variables are restored from first render on subsequent renders
|
|
169
|
+
|
|
170
|
+
Notes:
|
|
171
|
+
If you encounter issues with ``ps.init()`` (e.g., source code not
|
|
172
|
+
available in some deployment environments), use ``ps.setup()`` instead.
|
|
173
|
+
It provides the same functionality without AST rewriting.
|
|
174
|
+
"""
|
|
175
|
+
return InitContext()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------- AST rewriting -------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class InitCPythonRewriter(ast.NodeTransformer):
|
|
182
|
+
counter: int
|
|
183
|
+
_init_names: set[str]
|
|
184
|
+
_init_modules: set[str]
|
|
185
|
+
|
|
186
|
+
def __init__(self, init_names: set[str], init_modules: set[str]):
|
|
187
|
+
super().__init__()
|
|
188
|
+
self.counter = 0
|
|
189
|
+
self._init_names = init_names
|
|
190
|
+
self._init_modules = init_modules
|
|
191
|
+
|
|
192
|
+
@override
|
|
193
|
+
def visit_With(self, node: ast.With):
|
|
194
|
+
node = cast(ast.With, self.generic_visit(node))
|
|
195
|
+
if not node.items:
|
|
196
|
+
return node
|
|
197
|
+
|
|
198
|
+
item = node.items[0]
|
|
199
|
+
if self.is_init_call(item.context_expr):
|
|
200
|
+
ctx_name = f"_init_ctx_{self.counter}"
|
|
201
|
+
self.counter += 1
|
|
202
|
+
new_item = ast.withitem(
|
|
203
|
+
context_expr=item.context_expr,
|
|
204
|
+
optional_vars=ast.Name(id=ctx_name, ctx=ast.Store()),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
restore_call = ast.Expr(
|
|
208
|
+
value=ast.Call(
|
|
209
|
+
func=ast.Attribute(
|
|
210
|
+
value=ast.Name(id=ctx_name, ctx=ast.Load()),
|
|
211
|
+
attr="restore_variables",
|
|
212
|
+
ctx=ast.Load(),
|
|
213
|
+
),
|
|
214
|
+
args=[],
|
|
215
|
+
keywords=[],
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
new_if = ast.If(
|
|
220
|
+
test=ast.Attribute(
|
|
221
|
+
value=ast.Name(id=ctx_name, ctx=ast.Load()),
|
|
222
|
+
attr="first_render",
|
|
223
|
+
ctx=ast.Load(),
|
|
224
|
+
),
|
|
225
|
+
body=node.body,
|
|
226
|
+
orelse=[restore_call],
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
return ast.With(
|
|
230
|
+
items=[new_item],
|
|
231
|
+
body=[new_if],
|
|
232
|
+
type_comment=getattr(node, "type_comment", None),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return node
|
|
236
|
+
|
|
237
|
+
def is_init_call(self, expr: ast.AST) -> bool:
|
|
238
|
+
if not isinstance(expr, ast.Call):
|
|
239
|
+
return False
|
|
240
|
+
func = expr.func
|
|
241
|
+
if isinstance(func, ast.Name) and func.id in self._init_names:
|
|
242
|
+
return True
|
|
243
|
+
if (
|
|
244
|
+
isinstance(func, ast.Attribute)
|
|
245
|
+
and isinstance(func.value, ast.Name)
|
|
246
|
+
and func.value.id in self._init_modules
|
|
247
|
+
and func.attr == "init"
|
|
248
|
+
):
|
|
249
|
+
return True
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class InitFallbackRewriter(ast.NodeTransformer):
|
|
254
|
+
"""Rewrite using explicit rebinding (portable, no LocalsToFast)."""
|
|
255
|
+
|
|
256
|
+
counter: int
|
|
257
|
+
_init_names: set[str]
|
|
258
|
+
_init_modules: set[str]
|
|
259
|
+
|
|
260
|
+
def __init__(self, init_names: set[str], init_modules: set[str]):
|
|
261
|
+
super().__init__()
|
|
262
|
+
self.counter = 0
|
|
263
|
+
self._init_names = init_names
|
|
264
|
+
self._init_modules = init_modules
|
|
265
|
+
|
|
266
|
+
@override
|
|
267
|
+
def visit_With(self, node: ast.With):
|
|
268
|
+
node = cast(ast.With, self.generic_visit(node))
|
|
269
|
+
if not node.items:
|
|
270
|
+
return node
|
|
271
|
+
|
|
272
|
+
item = node.items[0]
|
|
273
|
+
if not self.is_init_call(item.context_expr):
|
|
274
|
+
return node
|
|
275
|
+
|
|
276
|
+
ctx_name = f"_init_ctx_{self.counter}"
|
|
277
|
+
self.counter += 1
|
|
278
|
+
new_item = ast.withitem(
|
|
279
|
+
context_expr=item.context_expr,
|
|
280
|
+
optional_vars=ast.Name(id=ctx_name, ctx=ast.Store()),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
assigned = _collect_assigned_names(node.body)
|
|
284
|
+
|
|
285
|
+
save_call = ast.Expr(
|
|
286
|
+
value=ast.Call(
|
|
287
|
+
func=ast.Attribute(
|
|
288
|
+
value=ast.Name(id=ctx_name, ctx=ast.Load()),
|
|
289
|
+
attr="save",
|
|
290
|
+
ctx=ast.Load(),
|
|
291
|
+
),
|
|
292
|
+
args=[
|
|
293
|
+
ast.Dict(
|
|
294
|
+
keys=[ast.Constant(n) for n in assigned],
|
|
295
|
+
values=[ast.Name(id=n, ctx=ast.Load()) for n in assigned],
|
|
296
|
+
)
|
|
297
|
+
],
|
|
298
|
+
keywords=[],
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
restore_assigns: Sequence[ast.stmt] = [
|
|
303
|
+
ast.Assign(
|
|
304
|
+
targets=[ast.Name(id=name, ctx=ast.Store())],
|
|
305
|
+
value=ast.Subscript(
|
|
306
|
+
value=ast.Attribute(
|
|
307
|
+
value=ast.Name(id=ctx_name, ctx=ast.Load()),
|
|
308
|
+
attr="saved",
|
|
309
|
+
ctx=ast.Load(),
|
|
310
|
+
),
|
|
311
|
+
slice=ast.Constant(name),
|
|
312
|
+
ctx=ast.Load(),
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
for name in assigned
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
new_if = ast.If(
|
|
319
|
+
test=ast.Attribute(
|
|
320
|
+
value=ast.Name(id=ctx_name, ctx=ast.Load()),
|
|
321
|
+
attr="first_render",
|
|
322
|
+
ctx=ast.Load(),
|
|
323
|
+
),
|
|
324
|
+
body=node.body + [save_call],
|
|
325
|
+
orelse=list(restore_assigns),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
return ast.With(
|
|
329
|
+
items=[new_item],
|
|
330
|
+
body=[new_if],
|
|
331
|
+
type_comment=getattr(node, "type_comment", None),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def is_init_call(self, expr: ast.AST) -> bool:
|
|
335
|
+
if not isinstance(expr, ast.Call):
|
|
336
|
+
return False
|
|
337
|
+
func = expr.func
|
|
338
|
+
if isinstance(func, ast.Name) and func.id in self._init_names:
|
|
339
|
+
return True
|
|
340
|
+
if (
|
|
341
|
+
isinstance(func, ast.Attribute)
|
|
342
|
+
and isinstance(func.value, ast.Name)
|
|
343
|
+
and func.value.id in self._init_modules
|
|
344
|
+
and func.attr == "init"
|
|
345
|
+
):
|
|
346
|
+
return True
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _collect_assigned_names(body: list[ast.stmt]) -> list[str]:
|
|
351
|
+
names: set[str] = set()
|
|
352
|
+
|
|
353
|
+
def add_target(target: ast.AST):
|
|
354
|
+
if isinstance(target, ast.Name):
|
|
355
|
+
names.add(target.id)
|
|
356
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
357
|
+
for elt in target.elts:
|
|
358
|
+
add_target(elt)
|
|
359
|
+
|
|
360
|
+
for stmt in body:
|
|
361
|
+
if isinstance(stmt, ast.Assign):
|
|
362
|
+
for target in stmt.targets:
|
|
363
|
+
add_target(target)
|
|
364
|
+
elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
|
365
|
+
names.add(stmt.target.id)
|
|
366
|
+
elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
367
|
+
names.add(stmt.name)
|
|
368
|
+
return list(names)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def rewrite_init_blocks(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
372
|
+
"""Rewrite `with ps.init()` blocks in the provided function, if present."""
|
|
373
|
+
|
|
374
|
+
source = textwrap.dedent(getsourcecode(func)) # raises immediately if missing
|
|
375
|
+
try:
|
|
376
|
+
source_start_line = inspect.getsourcelines(func)[1]
|
|
377
|
+
except (OSError, TypeError):
|
|
378
|
+
source_start_line = None
|
|
379
|
+
|
|
380
|
+
if "init" not in source: # quick prefilter, allow alias detection later
|
|
381
|
+
return func
|
|
382
|
+
|
|
383
|
+
tree = ast.parse(source)
|
|
384
|
+
|
|
385
|
+
init_names, init_modules = _resolve_init_bindings(func)
|
|
386
|
+
|
|
387
|
+
target_def: ast.FunctionDef | ast.AsyncFunctionDef | None = None
|
|
388
|
+
# Remove decorators so the re-exec'd function isn't double-wrapped.
|
|
389
|
+
for node in ast.walk(tree):
|
|
390
|
+
if (
|
|
391
|
+
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
392
|
+
and node.name == func.__name__
|
|
393
|
+
):
|
|
394
|
+
node.decorator_list = []
|
|
395
|
+
target_def = node
|
|
396
|
+
|
|
397
|
+
if target_def is None:
|
|
398
|
+
return func
|
|
399
|
+
|
|
400
|
+
if not _contains_ps_init(tree, init_names, init_modules):
|
|
401
|
+
return func
|
|
402
|
+
|
|
403
|
+
init_items = _find_init_items(target_def.body, init_names, init_modules)
|
|
404
|
+
if len(init_items) > 1:
|
|
405
|
+
try:
|
|
406
|
+
filename = inspect.getsourcefile(func) or inspect.getfile(func)
|
|
407
|
+
except (TypeError, OSError):
|
|
408
|
+
filename = None
|
|
409
|
+
raise TranspileError(
|
|
410
|
+
"ps.init may only be used once per component render",
|
|
411
|
+
node=init_items[1].context_expr,
|
|
412
|
+
source=source,
|
|
413
|
+
filename=filename,
|
|
414
|
+
func_name=func.__name__,
|
|
415
|
+
source_start_line=source_start_line,
|
|
416
|
+
) from None
|
|
417
|
+
|
|
418
|
+
if init_items and init_items[0].optional_vars is not None:
|
|
419
|
+
try:
|
|
420
|
+
filename = inspect.getsourcefile(func) or inspect.getfile(func)
|
|
421
|
+
except (TypeError, OSError):
|
|
422
|
+
filename = None
|
|
423
|
+
raise TranspileError(
|
|
424
|
+
"ps.init does not support 'as' bindings",
|
|
425
|
+
node=init_items[0].optional_vars,
|
|
426
|
+
source=source,
|
|
427
|
+
filename=filename,
|
|
428
|
+
func_name=func.__name__,
|
|
429
|
+
source_start_line=source_start_line,
|
|
430
|
+
) from None
|
|
431
|
+
|
|
432
|
+
disallowed = _find_disallowed_control_flow(
|
|
433
|
+
target_def.body, init_names, init_modules
|
|
434
|
+
)
|
|
435
|
+
if disallowed is not None:
|
|
436
|
+
try:
|
|
437
|
+
filename = inspect.getsourcefile(func) or inspect.getfile(func)
|
|
438
|
+
except (TypeError, OSError):
|
|
439
|
+
filename = None
|
|
440
|
+
raise TranspileError(
|
|
441
|
+
"ps.init blocks cannot contain control flow (if/for/while/try/with/match)",
|
|
442
|
+
node=disallowed,
|
|
443
|
+
source=source,
|
|
444
|
+
filename=filename,
|
|
445
|
+
func_name=func.__name__,
|
|
446
|
+
source_start_line=source_start_line,
|
|
447
|
+
) from None
|
|
448
|
+
|
|
449
|
+
rewriter: ast.NodeTransformer
|
|
450
|
+
if _CAN_USE_CPYTHON:
|
|
451
|
+
rewriter = InitCPythonRewriter(init_names, init_modules)
|
|
452
|
+
else:
|
|
453
|
+
rewriter = InitFallbackRewriter(init_names, init_modules)
|
|
454
|
+
|
|
455
|
+
tree = rewriter.visit(tree)
|
|
456
|
+
ast.fix_missing_locations(tree)
|
|
457
|
+
|
|
458
|
+
filename = inspect.getsourcefile(func) or "<rewrite>"
|
|
459
|
+
compiled = compile(tree, filename=filename, mode="exec")
|
|
460
|
+
|
|
461
|
+
global_ns = dict(func.__globals__)
|
|
462
|
+
closure_vars = inspect.getclosurevars(func)
|
|
463
|
+
global_ns.update(closure_vars.nonlocals)
|
|
464
|
+
# Ensure `ps` resolves during exec.
|
|
465
|
+
if "ps" not in global_ns:
|
|
466
|
+
try:
|
|
467
|
+
import pulse as ps
|
|
468
|
+
|
|
469
|
+
global_ns["ps"] = ps
|
|
470
|
+
except Exception:
|
|
471
|
+
pass
|
|
472
|
+
local_ns: dict[str, Any] = {}
|
|
473
|
+
exec(compiled, global_ns, local_ns)
|
|
474
|
+
rewritten = local_ns.get(func.__name__) or global_ns[func.__name__]
|
|
475
|
+
functools.update_wrapper(rewritten, func)
|
|
476
|
+
return rewritten
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _contains_ps_init(
|
|
480
|
+
tree: ast.AST, init_names: set[str], init_modules: set[str]
|
|
481
|
+
) -> bool:
|
|
482
|
+
checker = _InitCallChecker(init_names, init_modules)
|
|
483
|
+
return checker.contains_init(tree)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _find_disallowed_control_flow(
|
|
487
|
+
body: Sequence[ast.stmt], init_names: set[str], init_modules: set[str]
|
|
488
|
+
) -> ast.stmt | None:
|
|
489
|
+
disallowed: tuple[type[ast.AST], ...] = (
|
|
490
|
+
ast.If,
|
|
491
|
+
ast.For,
|
|
492
|
+
ast.AsyncFor,
|
|
493
|
+
ast.While,
|
|
494
|
+
ast.Try,
|
|
495
|
+
ast.With,
|
|
496
|
+
ast.AsyncWith,
|
|
497
|
+
ast.Match,
|
|
498
|
+
)
|
|
499
|
+
checker = _InitCallChecker(init_names, init_modules)
|
|
500
|
+
|
|
501
|
+
class _Finder(ast.NodeVisitor):
|
|
502
|
+
found: ast.stmt | None
|
|
503
|
+
|
|
504
|
+
def __init__(self) -> None:
|
|
505
|
+
self.found = None
|
|
506
|
+
|
|
507
|
+
@override
|
|
508
|
+
def visit(self, node: ast.AST) -> Any: # type: ignore[override]
|
|
509
|
+
if self.found is not None:
|
|
510
|
+
return None
|
|
511
|
+
if isinstance(node, disallowed):
|
|
512
|
+
self.found = cast(ast.stmt, node)
|
|
513
|
+
return None
|
|
514
|
+
return super().visit(node)
|
|
515
|
+
|
|
516
|
+
@override
|
|
517
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
@override
|
|
521
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
@override
|
|
525
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> Any:
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
finder = _Finder()
|
|
529
|
+
|
|
530
|
+
class _WithFinder(ast.NodeVisitor):
|
|
531
|
+
@override
|
|
532
|
+
def visit_With(self, node: ast.With) -> Any: # type: ignore[override]
|
|
533
|
+
first = node.items[0] if node.items else None
|
|
534
|
+
if first and checker.is_init_call(first.context_expr):
|
|
535
|
+
for stmt in node.body:
|
|
536
|
+
finder.visit(stmt)
|
|
537
|
+
if finder.found is not None:
|
|
538
|
+
return None
|
|
539
|
+
self.generic_visit(node)
|
|
540
|
+
|
|
541
|
+
@override
|
|
542
|
+
def visit_AsyncWith(self, node: ast.AsyncWith) -> Any: # type: ignore[override]
|
|
543
|
+
first = node.items[0] if node.items else None
|
|
544
|
+
if first and checker.is_init_call(first.context_expr):
|
|
545
|
+
for stmt in node.body:
|
|
546
|
+
finder.visit(stmt)
|
|
547
|
+
if finder.found is not None:
|
|
548
|
+
return None
|
|
549
|
+
self.generic_visit(node)
|
|
550
|
+
|
|
551
|
+
@override
|
|
552
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
|
|
553
|
+
return None
|
|
554
|
+
|
|
555
|
+
@override
|
|
556
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
@override
|
|
560
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> Any:
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
with_finder = _WithFinder()
|
|
564
|
+
for stmt in body:
|
|
565
|
+
with_finder.visit(stmt)
|
|
566
|
+
if finder.found is not None:
|
|
567
|
+
return finder.found
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _find_init_items(
|
|
572
|
+
body: Sequence[ast.stmt], init_names: set[str], init_modules: set[str]
|
|
573
|
+
) -> list[ast.withitem]:
|
|
574
|
+
checker = _InitCallChecker(init_names, init_modules)
|
|
575
|
+
items: list[ast.withitem] = []
|
|
576
|
+
|
|
577
|
+
class _Finder(ast.NodeVisitor):
|
|
578
|
+
@override
|
|
579
|
+
def visit_With(self, node: ast.With) -> Any: # type: ignore[override]
|
|
580
|
+
first = node.items[0] if node.items else None
|
|
581
|
+
if first and checker.is_init_call(first.context_expr):
|
|
582
|
+
items.append(first)
|
|
583
|
+
self.generic_visit(node)
|
|
584
|
+
|
|
585
|
+
@override
|
|
586
|
+
def visit_AsyncWith(self, node: ast.AsyncWith) -> Any: # type: ignore[override]
|
|
587
|
+
first = node.items[0] if node.items else None
|
|
588
|
+
if first and checker.is_init_call(first.context_expr):
|
|
589
|
+
items.append(first)
|
|
590
|
+
self.generic_visit(node)
|
|
591
|
+
|
|
592
|
+
@override
|
|
593
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
@override
|
|
597
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
|
|
598
|
+
return None
|
|
599
|
+
|
|
600
|
+
@override
|
|
601
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> Any:
|
|
602
|
+
return None
|
|
603
|
+
|
|
604
|
+
finder = _Finder()
|
|
605
|
+
for stmt in body:
|
|
606
|
+
finder.visit(stmt)
|
|
607
|
+
return items
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
class _InitCallChecker:
|
|
611
|
+
init_names: set[str]
|
|
612
|
+
init_modules: set[str]
|
|
613
|
+
|
|
614
|
+
def __init__(self, init_names: set[str], init_modules: set[str]):
|
|
615
|
+
self.init_names = init_names
|
|
616
|
+
self.init_modules = init_modules
|
|
617
|
+
|
|
618
|
+
def is_init_call(self, expr: ast.AST) -> bool:
|
|
619
|
+
if not isinstance(expr, ast.Call):
|
|
620
|
+
return False
|
|
621
|
+
func = expr.func
|
|
622
|
+
if isinstance(func, ast.Name) and func.id in self.init_names:
|
|
623
|
+
return True
|
|
624
|
+
if (
|
|
625
|
+
isinstance(func, ast.Attribute)
|
|
626
|
+
and isinstance(func.value, ast.Name)
|
|
627
|
+
and func.value.id in self.init_modules
|
|
628
|
+
and func.attr == "init"
|
|
629
|
+
):
|
|
630
|
+
return True
|
|
631
|
+
return False
|
|
632
|
+
|
|
633
|
+
def contains_init(self, tree: ast.AST) -> bool:
|
|
634
|
+
for node in ast.walk(tree):
|
|
635
|
+
if self.is_init_call(node):
|
|
636
|
+
return True
|
|
637
|
+
return False
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _resolve_init_bindings(func: Callable[..., Any]) -> tuple[set[str], set[str]]:
|
|
641
|
+
"""Find names/modules that resolve to pulse.init in the function scope."""
|
|
642
|
+
|
|
643
|
+
init_names: set[str] = set()
|
|
644
|
+
init_modules: set[str] = set()
|
|
645
|
+
|
|
646
|
+
closure = inspect.getclosurevars(func)
|
|
647
|
+
scopes = [func.__globals__, closure.nonlocals, closure.globals]
|
|
648
|
+
|
|
649
|
+
for scope in scopes:
|
|
650
|
+
for name, val in scope.items():
|
|
651
|
+
if val is init:
|
|
652
|
+
init_names.add(name)
|
|
653
|
+
try:
|
|
654
|
+
if getattr(val, "init", None) is init:
|
|
655
|
+
init_modules.add(name)
|
|
656
|
+
except Exception:
|
|
657
|
+
continue
|
|
658
|
+
|
|
659
|
+
return init_names, init_modules
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
class InitState(HookState):
|
|
663
|
+
def __init__(self) -> None:
|
|
664
|
+
self.storage: dict[tuple[Any, int], dict[str, Any]] = {}
|
|
665
|
+
|
|
666
|
+
@override
|
|
667
|
+
def dispose(self) -> None:
|
|
668
|
+
self.storage.clear()
|