pulse-framework 0.1.41__py3-none-any.whl → 0.1.42__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 +5 -0
- pulse/context.py +3 -2
- pulse/hooks/core.py +4 -6
- pulse/hooks/init.py +460 -0
- pulse/react_component.py +2 -1
- pulse/reactive.py +7 -3
- pulse/vdom.py +3 -1
- {pulse_framework-0.1.41.dist-info → pulse_framework-0.1.42.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.41.dist-info → pulse_framework-0.1.42.dist-info}/RECORD +11 -10
- {pulse_framework-0.1.41.dist-info → pulse_framework-0.1.42.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.41.dist-info → pulse_framework-0.1.42.dist-info}/entry_points.txt +0 -0
pulse/__init__.py
CHANGED
|
@@ -161,6 +161,11 @@ from pulse.hooks.core import (
|
|
|
161
161
|
# Hooks - Effects
|
|
162
162
|
from pulse.hooks.effects import EffectsHookState as EffectsHookState
|
|
163
163
|
from pulse.hooks.effects import effects as effects
|
|
164
|
+
|
|
165
|
+
# Hooks - Init
|
|
166
|
+
from pulse.hooks.init import (
|
|
167
|
+
init as init,
|
|
168
|
+
)
|
|
164
169
|
from pulse.hooks.runtime import (
|
|
165
170
|
GLOBAL_STATES as GLOBAL_STATES,
|
|
166
171
|
)
|
pulse/context.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
from contextvars import ContextVar, Token
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from types import TracebackType
|
|
5
|
-
from typing import TYPE_CHECKING
|
|
5
|
+
from typing import TYPE_CHECKING, Literal
|
|
6
6
|
|
|
7
7
|
from pulse.routing import RouteContext
|
|
8
8
|
|
|
@@ -58,10 +58,11 @@ class PulseContext:
|
|
|
58
58
|
exc_type: type[BaseException] | None = None,
|
|
59
59
|
exc_val: BaseException | None = None,
|
|
60
60
|
exc_tb: TracebackType | None = None,
|
|
61
|
-
):
|
|
61
|
+
) -> Literal[False]:
|
|
62
62
|
if self._token is not None:
|
|
63
63
|
PULSE_CONTEXT.reset(self._token)
|
|
64
64
|
self._token = None
|
|
65
|
+
return False
|
|
65
66
|
|
|
66
67
|
|
|
67
68
|
PULSE_CONTEXT: ContextVar["PulseContext | None"] = ContextVar(
|
pulse/hooks/core.py
CHANGED
|
@@ -3,7 +3,7 @@ import logging
|
|
|
3
3
|
from collections.abc import Callable, Mapping
|
|
4
4
|
from contextvars import ContextVar, Token
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
-
from typing import Any, Generic, TypeVar, override
|
|
6
|
+
from typing import Any, Generic, Literal, TypeVar, override
|
|
7
7
|
|
|
8
8
|
from pulse.helpers import Disposable, call_flexible
|
|
9
9
|
|
|
@@ -40,10 +40,7 @@ class HookMetadata:
|
|
|
40
40
|
class HookState(Disposable):
|
|
41
41
|
"""Base class returned by hook factories."""
|
|
42
42
|
|
|
43
|
-
render_cycle: int
|
|
44
|
-
|
|
45
|
-
def __init__(self) -> None:
|
|
46
|
-
self.render_cycle = 0
|
|
43
|
+
render_cycle: int = 0
|
|
47
44
|
|
|
48
45
|
def on_render_start(self, render_cycle: int) -> None:
|
|
49
46
|
self.render_cycle = render_cycle
|
|
@@ -177,13 +174,14 @@ class HookContext:
|
|
|
177
174
|
exc_type: type[BaseException] | None,
|
|
178
175
|
exc_val: BaseException | None,
|
|
179
176
|
exc_tb: Any,
|
|
180
|
-
):
|
|
177
|
+
) -> Literal[False]:
|
|
181
178
|
if self._token is not None:
|
|
182
179
|
HOOK_CONTEXT.reset(self._token)
|
|
183
180
|
self._token = None
|
|
184
181
|
|
|
185
182
|
for namespace in self.namespaces.values():
|
|
186
183
|
namespace.on_render_end(self.render_cycle)
|
|
184
|
+
return False
|
|
187
185
|
|
|
188
186
|
def namespace_for(self, hook: Hook[T]) -> HookNamespace[T]:
|
|
189
187
|
namespace = self.namespaces.get(hook.name)
|
pulse/hooks/init.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
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.hooks.core import HookState, hooks
|
|
13
|
+
|
|
14
|
+
# Storage keyed by (code object, lineno) of the `with ps.init()` call site.
|
|
15
|
+
_init_hook = hooks.create("init_storage", lambda: InitState())
|
|
16
|
+
|
|
17
|
+
_CAN_USE_CPYTHON = hasattr(ctypes.pythonapi, "PyFrame_LocalsToFast")
|
|
18
|
+
if _CAN_USE_CPYTHON:
|
|
19
|
+
PyFrame_LocalsToFast = ctypes.pythonapi.PyFrame_LocalsToFast
|
|
20
|
+
PyFrame_LocalsToFast.argtypes = [ctypes.py_object, ctypes.c_int]
|
|
21
|
+
PyFrame_LocalsToFast.restype = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def previous_frame() -> types.FrameType:
|
|
25
|
+
"""Get the previous frame (caller's frame) with assertions.
|
|
26
|
+
|
|
27
|
+
This skips the frame of this helper function and its immediate caller
|
|
28
|
+
to return the actual previous frame.
|
|
29
|
+
"""
|
|
30
|
+
current = inspect.currentframe()
|
|
31
|
+
assert current is not None, "currentframe() returned None"
|
|
32
|
+
# Skip this helper function's frame
|
|
33
|
+
caller = current.f_back
|
|
34
|
+
assert caller is not None, "f_back is None"
|
|
35
|
+
# Skip the caller's frame (e.g., __enter__) to get the actual previous frame
|
|
36
|
+
frame = caller.f_back
|
|
37
|
+
assert frame is not None, "f_back.f_back is None"
|
|
38
|
+
return frame
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InitContext:
|
|
42
|
+
"""Context that captures locals on first render and restores thereafter."""
|
|
43
|
+
|
|
44
|
+
callsite: tuple[Any, int] | None
|
|
45
|
+
frame: types.FrameType | None
|
|
46
|
+
first_render: bool
|
|
47
|
+
pre_keys: set[str]
|
|
48
|
+
saved: dict[str, Any]
|
|
49
|
+
|
|
50
|
+
def __init__(self):
|
|
51
|
+
self.callsite = None
|
|
52
|
+
self.frame = None
|
|
53
|
+
self.first_render = False
|
|
54
|
+
self.pre_keys = set()
|
|
55
|
+
self.saved = {}
|
|
56
|
+
|
|
57
|
+
def __enter__(self):
|
|
58
|
+
self.frame = previous_frame()
|
|
59
|
+
self.pre_keys = set(self.frame.f_locals.keys())
|
|
60
|
+
# Use code object to disambiguate identical line numbers in different fns.
|
|
61
|
+
self.callsite = (self.frame.f_code, self.frame.f_lineno)
|
|
62
|
+
|
|
63
|
+
storage = _init_hook().storage
|
|
64
|
+
entry = storage.get(self.callsite)
|
|
65
|
+
if entry is None:
|
|
66
|
+
self.first_render = True
|
|
67
|
+
self.saved = {}
|
|
68
|
+
else:
|
|
69
|
+
self.first_render = False
|
|
70
|
+
self.saved = entry["vars"]
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def restore_variables(self):
|
|
74
|
+
if self.first_render:
|
|
75
|
+
return
|
|
76
|
+
frame = self.frame if self.frame is not None else previous_frame()
|
|
77
|
+
frame.f_locals.update(self.saved)
|
|
78
|
+
PyFrame_LocalsToFast(frame, 1)
|
|
79
|
+
|
|
80
|
+
def save(self, values: dict[str, Any]):
|
|
81
|
+
self.saved = values
|
|
82
|
+
assert self.callsite is not None, "callsite is None"
|
|
83
|
+
storage = _init_hook().storage
|
|
84
|
+
storage[self.callsite] = {"vars": values}
|
|
85
|
+
|
|
86
|
+
def _capture_new_locals(self) -> dict[str, Any]:
|
|
87
|
+
frame = self.frame
|
|
88
|
+
assert frame is not None, "frame is None"
|
|
89
|
+
captured = {}
|
|
90
|
+
for name, value in frame.f_locals.items():
|
|
91
|
+
if name in self.pre_keys:
|
|
92
|
+
continue
|
|
93
|
+
if value is self:
|
|
94
|
+
continue
|
|
95
|
+
captured[name] = value
|
|
96
|
+
return captured
|
|
97
|
+
|
|
98
|
+
def __exit__(
|
|
99
|
+
self,
|
|
100
|
+
exc_type: type[BaseException] | None,
|
|
101
|
+
exc_value: BaseException | None,
|
|
102
|
+
exc_tb: Any,
|
|
103
|
+
) -> Literal[False]:
|
|
104
|
+
if exc_type is None:
|
|
105
|
+
captured = self._capture_new_locals()
|
|
106
|
+
assert self.callsite is not None, "callsite None"
|
|
107
|
+
storage = _init_hook().storage
|
|
108
|
+
storage[self.callsite] = {"vars": captured}
|
|
109
|
+
self.frame = None
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def init() -> InitContext:
|
|
114
|
+
return InitContext()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------- AST rewriting -------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class InitCPythonRewriter(ast.NodeTransformer):
|
|
121
|
+
counter: int
|
|
122
|
+
_init_names: set[str]
|
|
123
|
+
_init_modules: set[str]
|
|
124
|
+
|
|
125
|
+
def __init__(self, init_names: set[str], init_modules: set[str]):
|
|
126
|
+
super().__init__()
|
|
127
|
+
self.counter = 0
|
|
128
|
+
self._init_names = init_names
|
|
129
|
+
self._init_modules = init_modules
|
|
130
|
+
|
|
131
|
+
@override
|
|
132
|
+
def visit_With(self, node: ast.With):
|
|
133
|
+
node = cast(ast.With, self.generic_visit(node))
|
|
134
|
+
if not node.items:
|
|
135
|
+
return node
|
|
136
|
+
|
|
137
|
+
item = node.items[0]
|
|
138
|
+
if self.is_init_call(item.context_expr):
|
|
139
|
+
ctx_name = f"_init_ctx_{self.counter}"
|
|
140
|
+
self.counter += 1
|
|
141
|
+
new_item = ast.withitem(
|
|
142
|
+
context_expr=item.context_expr,
|
|
143
|
+
optional_vars=ast.Name(id=ctx_name, ctx=ast.Store()),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
restore_call = ast.Expr(
|
|
147
|
+
value=ast.Call(
|
|
148
|
+
func=ast.Attribute(
|
|
149
|
+
value=ast.Name(id=ctx_name, ctx=ast.Load()),
|
|
150
|
+
attr="restore_variables",
|
|
151
|
+
ctx=ast.Load(),
|
|
152
|
+
),
|
|
153
|
+
args=[],
|
|
154
|
+
keywords=[],
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
new_if = ast.If(
|
|
159
|
+
test=ast.Attribute(
|
|
160
|
+
value=ast.Name(id=ctx_name, ctx=ast.Load()),
|
|
161
|
+
attr="first_render",
|
|
162
|
+
ctx=ast.Load(),
|
|
163
|
+
),
|
|
164
|
+
body=node.body,
|
|
165
|
+
orelse=[restore_call],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return ast.With(
|
|
169
|
+
items=[new_item],
|
|
170
|
+
body=[new_if],
|
|
171
|
+
type_comment=getattr(node, "type_comment", None),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return node
|
|
175
|
+
|
|
176
|
+
def is_init_call(self, expr: ast.AST) -> bool:
|
|
177
|
+
if not isinstance(expr, ast.Call):
|
|
178
|
+
return False
|
|
179
|
+
func = expr.func
|
|
180
|
+
if isinstance(func, ast.Name) and func.id in self._init_names:
|
|
181
|
+
return True
|
|
182
|
+
if (
|
|
183
|
+
isinstance(func, ast.Attribute)
|
|
184
|
+
and isinstance(func.value, ast.Name)
|
|
185
|
+
and func.value.id in self._init_modules
|
|
186
|
+
and func.attr == "init"
|
|
187
|
+
):
|
|
188
|
+
return True
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class InitFallbackRewriter(ast.NodeTransformer):
|
|
193
|
+
"""Rewrite using explicit rebinding (portable, no LocalsToFast)."""
|
|
194
|
+
|
|
195
|
+
counter: int
|
|
196
|
+
_init_names: set[str]
|
|
197
|
+
_init_modules: set[str]
|
|
198
|
+
|
|
199
|
+
def __init__(self, init_names: set[str], init_modules: set[str]):
|
|
200
|
+
super().__init__()
|
|
201
|
+
self.counter = 0
|
|
202
|
+
self._init_names = init_names
|
|
203
|
+
self._init_modules = init_modules
|
|
204
|
+
|
|
205
|
+
@override
|
|
206
|
+
def visit_With(self, node: ast.With):
|
|
207
|
+
node = cast(ast.With, self.generic_visit(node))
|
|
208
|
+
if not node.items:
|
|
209
|
+
return node
|
|
210
|
+
|
|
211
|
+
item = node.items[0]
|
|
212
|
+
if not self.is_init_call(item.context_expr):
|
|
213
|
+
return node
|
|
214
|
+
|
|
215
|
+
ctx_name = f"_init_ctx_{self.counter}"
|
|
216
|
+
self.counter += 1
|
|
217
|
+
new_item = ast.withitem(
|
|
218
|
+
context_expr=item.context_expr,
|
|
219
|
+
optional_vars=ast.Name(id=ctx_name, ctx=ast.Store()),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
assigned = _collect_assigned_names(node.body)
|
|
223
|
+
|
|
224
|
+
save_call = ast.Expr(
|
|
225
|
+
value=ast.Call(
|
|
226
|
+
func=ast.Attribute(
|
|
227
|
+
value=ast.Name(id=ctx_name, ctx=ast.Load()),
|
|
228
|
+
attr="save",
|
|
229
|
+
ctx=ast.Load(),
|
|
230
|
+
),
|
|
231
|
+
args=[
|
|
232
|
+
ast.Dict(
|
|
233
|
+
keys=[ast.Constant(n) for n in assigned],
|
|
234
|
+
values=[ast.Name(id=n, ctx=ast.Load()) for n in assigned],
|
|
235
|
+
)
|
|
236
|
+
],
|
|
237
|
+
keywords=[],
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
restore_assigns: Sequence[ast.stmt] = [
|
|
242
|
+
ast.Assign(
|
|
243
|
+
targets=[ast.Name(id=name, ctx=ast.Store())],
|
|
244
|
+
value=ast.Subscript(
|
|
245
|
+
value=ast.Attribute(
|
|
246
|
+
value=ast.Name(id=ctx_name, ctx=ast.Load()),
|
|
247
|
+
attr="saved",
|
|
248
|
+
ctx=ast.Load(),
|
|
249
|
+
),
|
|
250
|
+
slice=ast.Constant(name),
|
|
251
|
+
ctx=ast.Load(),
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
for name in assigned
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
new_if = ast.If(
|
|
258
|
+
test=ast.Attribute(
|
|
259
|
+
value=ast.Name(id=ctx_name, ctx=ast.Load()),
|
|
260
|
+
attr="first_render",
|
|
261
|
+
ctx=ast.Load(),
|
|
262
|
+
),
|
|
263
|
+
body=node.body + [save_call],
|
|
264
|
+
orelse=list(restore_assigns),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return ast.With(
|
|
268
|
+
items=[new_item],
|
|
269
|
+
body=[new_if],
|
|
270
|
+
type_comment=getattr(node, "type_comment", None),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def is_init_call(self, expr: ast.AST) -> bool:
|
|
274
|
+
if not isinstance(expr, ast.Call):
|
|
275
|
+
return False
|
|
276
|
+
func = expr.func
|
|
277
|
+
if isinstance(func, ast.Name) and func.id in self._init_names:
|
|
278
|
+
return True
|
|
279
|
+
if (
|
|
280
|
+
isinstance(func, ast.Attribute)
|
|
281
|
+
and isinstance(func.value, ast.Name)
|
|
282
|
+
and func.value.id in self._init_modules
|
|
283
|
+
and func.attr == "init"
|
|
284
|
+
):
|
|
285
|
+
return True
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _collect_assigned_names(body: list[ast.stmt]) -> list[str]:
|
|
290
|
+
names: set[str] = set()
|
|
291
|
+
|
|
292
|
+
def add_target(target: ast.AST):
|
|
293
|
+
if isinstance(target, ast.Name):
|
|
294
|
+
names.add(target.id)
|
|
295
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
296
|
+
for elt in target.elts:
|
|
297
|
+
add_target(elt)
|
|
298
|
+
|
|
299
|
+
for stmt in body:
|
|
300
|
+
if isinstance(stmt, ast.Assign):
|
|
301
|
+
for target in stmt.targets:
|
|
302
|
+
add_target(target)
|
|
303
|
+
elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
|
304
|
+
names.add(stmt.target.id)
|
|
305
|
+
elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
306
|
+
names.add(stmt.name)
|
|
307
|
+
return list(names)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def rewrite_init_blocks(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
311
|
+
"""Rewrite `with ps.init()` blocks in the provided function, if present."""
|
|
312
|
+
|
|
313
|
+
source = _get_source(func) # raises immediately if missing
|
|
314
|
+
|
|
315
|
+
if "init" not in source: # quick prefilter, allow alias detection later
|
|
316
|
+
return func
|
|
317
|
+
|
|
318
|
+
tree = ast.parse(source)
|
|
319
|
+
|
|
320
|
+
init_names, init_modules = _resolve_init_bindings(func)
|
|
321
|
+
|
|
322
|
+
# Remove decorators so the re-exec'd function isn't double-wrapped.
|
|
323
|
+
for node in ast.walk(tree):
|
|
324
|
+
if (
|
|
325
|
+
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
326
|
+
and node.name == func.__name__
|
|
327
|
+
):
|
|
328
|
+
node.decorator_list = []
|
|
329
|
+
|
|
330
|
+
if not _contains_ps_init(tree, init_names, init_modules):
|
|
331
|
+
return func
|
|
332
|
+
|
|
333
|
+
if _has_disallowed_control_flow(tree, init_names, init_modules):
|
|
334
|
+
raise RuntimeError(
|
|
335
|
+
"ps.init blocks cannot contain control flow (if/for/while/try/with/match)"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
rewriter: ast.NodeTransformer
|
|
339
|
+
if _CAN_USE_CPYTHON:
|
|
340
|
+
rewriter = InitCPythonRewriter(init_names, init_modules)
|
|
341
|
+
else:
|
|
342
|
+
rewriter = InitFallbackRewriter(init_names, init_modules)
|
|
343
|
+
|
|
344
|
+
tree = rewriter.visit(tree)
|
|
345
|
+
ast.fix_missing_locations(tree)
|
|
346
|
+
|
|
347
|
+
filename = inspect.getsourcefile(func) or "<rewrite>"
|
|
348
|
+
compiled = compile(tree, filename=filename, mode="exec")
|
|
349
|
+
|
|
350
|
+
global_ns = dict(func.__globals__)
|
|
351
|
+
closure_vars = inspect.getclosurevars(func)
|
|
352
|
+
global_ns.update(closure_vars.nonlocals)
|
|
353
|
+
# Ensure `ps` resolves during exec.
|
|
354
|
+
if "ps" not in global_ns:
|
|
355
|
+
try:
|
|
356
|
+
import pulse as ps
|
|
357
|
+
|
|
358
|
+
global_ns["ps"] = ps
|
|
359
|
+
except Exception:
|
|
360
|
+
pass
|
|
361
|
+
local_ns: dict[str, Any] = {}
|
|
362
|
+
exec(compiled, global_ns, local_ns)
|
|
363
|
+
rewritten = local_ns.get(func.__name__) or global_ns[func.__name__]
|
|
364
|
+
functools.update_wrapper(rewritten, func)
|
|
365
|
+
return rewritten
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _contains_ps_init(
|
|
369
|
+
tree: ast.AST, init_names: set[str], init_modules: set[str]
|
|
370
|
+
) -> bool:
|
|
371
|
+
checker = _InitCallChecker(init_names, init_modules)
|
|
372
|
+
return checker.contains_init(tree)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _has_disallowed_control_flow(
|
|
376
|
+
tree: ast.AST, init_names: set[str], init_modules: set[str]
|
|
377
|
+
) -> bool:
|
|
378
|
+
disallowed = (ast.If, ast.For, ast.While, ast.Try, ast.With, ast.Match)
|
|
379
|
+
checker = _InitCallChecker(init_names, init_modules)
|
|
380
|
+
for node in ast.walk(tree):
|
|
381
|
+
if isinstance(node, ast.With):
|
|
382
|
+
first = node.items[0] if node.items else None
|
|
383
|
+
if first and checker.is_init_call(first.context_expr):
|
|
384
|
+
continue
|
|
385
|
+
if isinstance(node, disallowed):
|
|
386
|
+
return True
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class _InitCallChecker:
|
|
391
|
+
init_names: set[str]
|
|
392
|
+
init_modules: set[str]
|
|
393
|
+
|
|
394
|
+
def __init__(self, init_names: set[str], init_modules: set[str]):
|
|
395
|
+
self.init_names = init_names
|
|
396
|
+
self.init_modules = init_modules
|
|
397
|
+
|
|
398
|
+
def is_init_call(self, expr: ast.AST) -> bool:
|
|
399
|
+
if not isinstance(expr, ast.Call):
|
|
400
|
+
return False
|
|
401
|
+
func = expr.func
|
|
402
|
+
if isinstance(func, ast.Name) and func.id in self.init_names:
|
|
403
|
+
return True
|
|
404
|
+
if (
|
|
405
|
+
isinstance(func, ast.Attribute)
|
|
406
|
+
and isinstance(func.value, ast.Name)
|
|
407
|
+
and func.value.id in self.init_modules
|
|
408
|
+
and func.attr == "init"
|
|
409
|
+
):
|
|
410
|
+
return True
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
def contains_init(self, tree: ast.AST) -> bool:
|
|
414
|
+
for node in ast.walk(tree):
|
|
415
|
+
if self.is_init_call(node):
|
|
416
|
+
return True
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _get_source(func: Callable[..., Any]) -> str:
|
|
421
|
+
try:
|
|
422
|
+
return textwrap.dedent(inspect.getsource(func))
|
|
423
|
+
except OSError as exc:
|
|
424
|
+
src = getattr(func, "__source__", None)
|
|
425
|
+
if src is None:
|
|
426
|
+
raise RuntimeError(
|
|
427
|
+
f"ps.init rewrite failed: unable to read source ({exc})"
|
|
428
|
+
) from exc
|
|
429
|
+
return textwrap.dedent(src)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _resolve_init_bindings(func: Callable[..., Any]) -> tuple[set[str], set[str]]:
|
|
433
|
+
"""Find names/modules that resolve to pulse.init in the function scope."""
|
|
434
|
+
|
|
435
|
+
init_names: set[str] = set()
|
|
436
|
+
init_modules: set[str] = set()
|
|
437
|
+
|
|
438
|
+
closure = inspect.getclosurevars(func)
|
|
439
|
+
scopes = [func.__globals__, closure.nonlocals, closure.globals]
|
|
440
|
+
|
|
441
|
+
for scope in scopes:
|
|
442
|
+
for name, val in scope.items():
|
|
443
|
+
if val is init:
|
|
444
|
+
init_names.add(name)
|
|
445
|
+
try:
|
|
446
|
+
if getattr(val, "init", None) is init:
|
|
447
|
+
init_modules.add(name)
|
|
448
|
+
except Exception:
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
return init_names, init_modules
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class InitState(HookState):
|
|
455
|
+
def __init__(self) -> None:
|
|
456
|
+
self.storage: dict[tuple[Any, int], dict[str, Any]] = {}
|
|
457
|
+
|
|
458
|
+
@override
|
|
459
|
+
def dispose(self) -> None:
|
|
460
|
+
self.storage.clear()
|
pulse/react_component.py
CHANGED
|
@@ -535,10 +535,11 @@ class ComponentRegistry:
|
|
|
535
535
|
exc_type: type[BaseException] | None,
|
|
536
536
|
exc_val: BaseException | None,
|
|
537
537
|
exc_tb: Any,
|
|
538
|
-
):
|
|
538
|
+
) -> Literal[False]:
|
|
539
539
|
if self._token:
|
|
540
540
|
COMPONENT_REGISTRY.reset(self._token)
|
|
541
541
|
self._token = None
|
|
542
|
+
return False
|
|
542
543
|
|
|
543
544
|
|
|
544
545
|
COMPONENT_REGISTRY: ContextVar[ComponentRegistry] = ContextVar(
|
pulse/reactive.py
CHANGED
|
@@ -6,6 +6,7 @@ from contextvars import ContextVar, Token
|
|
|
6
6
|
from typing import (
|
|
7
7
|
Any,
|
|
8
8
|
Generic,
|
|
9
|
+
Literal,
|
|
9
10
|
ParamSpec,
|
|
10
11
|
TypeVar,
|
|
11
12
|
override,
|
|
@@ -675,11 +676,12 @@ class Batch:
|
|
|
675
676
|
exc_type: type[BaseException] | None,
|
|
676
677
|
exc_value: BaseException | None,
|
|
677
678
|
exc_traceback: Any,
|
|
678
|
-
):
|
|
679
|
+
) -> Literal[False]:
|
|
679
680
|
self.flush()
|
|
680
681
|
# Restore previous reactive context
|
|
681
682
|
if self._token:
|
|
682
683
|
REACTIVE_CONTEXT.reset(self._token)
|
|
684
|
+
return False
|
|
683
685
|
|
|
684
686
|
|
|
685
687
|
class GlobalBatch(Batch):
|
|
@@ -755,10 +757,11 @@ class Scope:
|
|
|
755
757
|
exc_type: type[BaseException] | None,
|
|
756
758
|
exc_value: BaseException | None,
|
|
757
759
|
exc_traceback: Any,
|
|
758
|
-
):
|
|
760
|
+
) -> Literal[False]:
|
|
759
761
|
# Restore previous reactive context
|
|
760
762
|
if self._token:
|
|
761
763
|
REACTIVE_CONTEXT.reset(self._token)
|
|
764
|
+
return False
|
|
762
765
|
|
|
763
766
|
|
|
764
767
|
class Untrack(Scope): ...
|
|
@@ -801,8 +804,9 @@ class ReactiveContext:
|
|
|
801
804
|
exc_type: type[BaseException] | None,
|
|
802
805
|
exc_value: BaseException | None,
|
|
803
806
|
exc_tb: Any,
|
|
804
|
-
):
|
|
807
|
+
) -> Literal[False]:
|
|
805
808
|
REACTIVE_CONTEXT.reset(self._tokens.pop())
|
|
809
|
+
return False
|
|
806
810
|
|
|
807
811
|
|
|
808
812
|
def epoch():
|
pulse/vdom.py
CHANGED
|
@@ -23,6 +23,7 @@ from typing import (
|
|
|
23
23
|
)
|
|
24
24
|
|
|
25
25
|
from pulse.hooks.core import HookContext
|
|
26
|
+
from pulse.hooks.init import rewrite_init_blocks
|
|
26
27
|
|
|
27
28
|
# ============================================================================
|
|
28
29
|
# Core VDOM
|
|
@@ -291,7 +292,8 @@ def component(
|
|
|
291
292
|
fn: "Callable[P, Element] | None" = None, *, name: str | None = None
|
|
292
293
|
) -> "Component[P] | Callable[[Callable[P, Element]], Component[P]]":
|
|
293
294
|
def decorator(fn: Callable[P, Element]):
|
|
294
|
-
|
|
295
|
+
rewritten = rewrite_init_blocks(fn)
|
|
296
|
+
return Component(rewritten, name)
|
|
295
297
|
|
|
296
298
|
if fn is not None:
|
|
297
299
|
return decorator(fn)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
pulse/__init__.py,sha256=
|
|
1
|
+
pulse/__init__.py,sha256=w7LVrYNiho18v9JyDQ8DZGdBYV4dZYtlEFiRi32ICiw,32166
|
|
2
2
|
pulse/app.py,sha256=cVEqazFcgSnmZSxqqz6HWk2QsI8rnKbO7Y_L88BcHSc,32082
|
|
3
3
|
pulse/channel.py,sha256=d9eLxgyB0P9UBVkPkXV7MHkC4LWED1Cq3GKsEu_SYy4,13056
|
|
4
4
|
pulse/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -24,7 +24,7 @@ pulse/components/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,
|
|
|
24
24
|
pulse/components/for_.py,sha256=LUyJEUlDM6b9oPjvUFgSsddxu6b6usF4BQdXe8FIiGI,1302
|
|
25
25
|
pulse/components/if_.py,sha256=rQywsmdirNpkb-61ZEdF-tgzUh-37JWd4YFGblkzIdQ,1624
|
|
26
26
|
pulse/components/react_router.py,sha256=TbRec-NVliUqrvAMeFXCrnDWV1rh6TGTPfRhqLuLubk,1129
|
|
27
|
-
pulse/context.py,sha256=
|
|
27
|
+
pulse/context.py,sha256=fMK6GdQY4q_3452v5DJli2f2_urVihnpzb-O-O9cJ1Q,1734
|
|
28
28
|
pulse/cookies.py,sha256=c7ua1Lv6mNe1nYnA4SFVvewvRQAbYy9fN5G3Hr_Dr5c,5000
|
|
29
29
|
pulse/css.py,sha256=-FyQQQ0EZI1Ins30qiF3l4z9yDb1V9qWuJKWxHcKGkw,3910
|
|
30
30
|
pulse/decorators.py,sha256=hRfgb9XU1yizmtdhuBln_3Gy-Cz2Smo4rYvAqlURrLQ,9348
|
|
@@ -32,8 +32,9 @@ pulse/env.py,sha256=p3XI8KG1ZCcXPD3LJP7fW8JPYfyvoYY5ENwae2o0PiA,2889
|
|
|
32
32
|
pulse/form.py,sha256=P7W8guUdGbgqNOk8cSUCWuY6qWre0me6_fypv1qOvqw,8987
|
|
33
33
|
pulse/helpers.py,sha256=BBtf--LZxvfpwJU8p92QrZWtOKIWfB3DOiAtGxhet90,13232
|
|
34
34
|
pulse/hooks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
35
|
-
pulse/hooks/core.py,sha256=
|
|
35
|
+
pulse/hooks/core.py,sha256=JTZbVxNOEs_GAeK6Bh6hemSTkgwZPtEi_wt55cvOdik,7381
|
|
36
36
|
pulse/hooks/effects.py,sha256=CQvt5viAweGLSxaGGlWm155GlEQiwQnGussw7OfiCGc,2393
|
|
37
|
+
pulse/hooks/init.py,sha256=snTy3PJtkSnnKBrAjcNOJbem2896xJzHD0DHLVVeyAo,11924
|
|
37
38
|
pulse/hooks/runtime.py,sha256=k5LZ8hnlNBMKOiEkQcAvs8BKwYxV6gwea2WCfju5K7Y,5106
|
|
38
39
|
pulse/hooks/setup.py,sha256=GJLSE6hLBNKHR9aLhvsS6KXwpOXQiSx1V3E2IkGADWM,4461
|
|
39
40
|
pulse/hooks/stable.py,sha256=uHEJ2E22r2kHx4uFjWjDepQ6OtPjLd7tT5ju-yKlkCU,1702
|
|
@@ -56,8 +57,8 @@ pulse/queries/mutation.py,sha256=_0-o2g2yux52hTsRLGuWwFUdGrBx9YJqi67oBw9iNcc,420
|
|
|
56
57
|
pulse/queries/query.py,sha256=WxBEaEjtzDGWWKpSi8-xl9xBvPvREYH1Tonl_lOY-VQ,7347
|
|
57
58
|
pulse/queries/query_observer.py,sha256=Wd3pk5OsZB_ze6DnpOkASv4Ny5EuSbwUlUEPXIXxSgk,10229
|
|
58
59
|
pulse/queries/store.py,sha256=ylSCOHiXp8vEyEWc5Et8zLWkyHj5OQYKOVfs0OehpX8,1465
|
|
59
|
-
pulse/react_component.py,sha256=
|
|
60
|
-
pulse/reactive.py,sha256=
|
|
60
|
+
pulse/react_component.py,sha256=hPibKBEkVdpBKNSpMQ6bZ-7GnJQcNQwcw2SvfY1chHA,26026
|
|
61
|
+
pulse/reactive.py,sha256=cKZDafbUQFdnNRAxI71THnsFZEbWZ5mU06pMuP6spo8,21187
|
|
61
62
|
pulse/reactive_extensions.py,sha256=gTLkQ0urwANjWNHWMkg-P9zvpevHCnNKd5BSM8G0pno,31521
|
|
62
63
|
pulse/render_session.py,sha256=kqLfZ9AxCrB2mIJqegATL1KA7CI-LZSBQwRYr7Uxo9g,14581
|
|
63
64
|
pulse/renderer.py,sha256=dJiX9VeHr9kC1UBw5oaKB8Mv-3OCMGTrHiKgLJ5FL50,16759
|
|
@@ -68,9 +69,9 @@ pulse/state.py,sha256=mytXlQjmLIBjB2XDgCg9E1fHCcyoNQ02cBqZ_vldxuc,10636
|
|
|
68
69
|
pulse/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
69
70
|
pulse/types/event_handler.py,sha256=tfKa6OEA5XvzuYbllQZJ03ooN7rGSYOtaPBstSL4OLU,1642
|
|
70
71
|
pulse/user_session.py,sha256=Nn9ZZha1Rruw31OSoK14QaEL0erGVFbryFhJYrtMZsQ,7599
|
|
71
|
-
pulse/vdom.py,sha256=
|
|
72
|
+
pulse/vdom.py,sha256=1UAjOYSmpdZeSVELqejh47Jer4mA73T_q2HtAogOphs,12514
|
|
72
73
|
pulse/version.py,sha256=711vaM1jVIQPgkisGgKZqwmw019qZIsc_QTae75K2pg,1895
|
|
73
|
-
pulse_framework-0.1.
|
|
74
|
-
pulse_framework-0.1.
|
|
75
|
-
pulse_framework-0.1.
|
|
76
|
-
pulse_framework-0.1.
|
|
74
|
+
pulse_framework-0.1.42.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
75
|
+
pulse_framework-0.1.42.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
|
|
76
|
+
pulse_framework-0.1.42.dist-info/METADATA,sha256=Pyy5qb02JHkAFwGpy6m8SkrlPH_mmL6BPxDfvQ41ly4,580
|
|
77
|
+
pulse_framework-0.1.42.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|