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/reactive.py
ADDED
|
@@ -0,0 +1,1208 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import copy
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from contextvars import ContextVar, Token
|
|
7
|
+
from typing import (
|
|
8
|
+
Any,
|
|
9
|
+
Generic,
|
|
10
|
+
Literal,
|
|
11
|
+
ParamSpec,
|
|
12
|
+
TypeVar,
|
|
13
|
+
override,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from pulse.helpers import (
|
|
17
|
+
Disposable,
|
|
18
|
+
create_task,
|
|
19
|
+
maybe_await,
|
|
20
|
+
schedule_on_loop,
|
|
21
|
+
values_equal,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
T_co = TypeVar("T_co", covariant=True)
|
|
26
|
+
P = ParamSpec("P")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Signal(Generic[T]):
|
|
30
|
+
"""A reactive value container.
|
|
31
|
+
|
|
32
|
+
Reading registers a dependency; writing notifies observers.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
value: Initial value.
|
|
36
|
+
name: Debug name for the signal.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
value: Current value (direct access, no tracking).
|
|
40
|
+
name: Debug name.
|
|
41
|
+
last_change: Epoch when last changed.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
count = Signal(0, name="count")
|
|
47
|
+
print(count()) # 0 (registers dependency)
|
|
48
|
+
count.write(1) # Updates and notifies observers
|
|
49
|
+
print(count.value) # 1 (no dependency tracking)
|
|
50
|
+
```
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
value: T
|
|
54
|
+
name: str | None
|
|
55
|
+
last_change: int
|
|
56
|
+
|
|
57
|
+
def __init__(self, value: T, name: str | None = None):
|
|
58
|
+
self.value = value
|
|
59
|
+
self.name = name
|
|
60
|
+
self.obs: list[Computed[Any] | Effect] = []
|
|
61
|
+
self._obs_change_listeners: list[Callable[[int], None]] = []
|
|
62
|
+
self.last_change = -1
|
|
63
|
+
|
|
64
|
+
def read(self) -> T:
|
|
65
|
+
"""Read the value, registering a dependency in the current scope.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The current value.
|
|
69
|
+
"""
|
|
70
|
+
rc = REACTIVE_CONTEXT.get()
|
|
71
|
+
if rc.scope is not None:
|
|
72
|
+
rc.scope.register_dep(self)
|
|
73
|
+
return self.value
|
|
74
|
+
|
|
75
|
+
def __call__(self) -> T:
|
|
76
|
+
"""Alias for read().
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The current value.
|
|
80
|
+
"""
|
|
81
|
+
return self.read()
|
|
82
|
+
|
|
83
|
+
def unwrap(self) -> T:
|
|
84
|
+
"""Alias for read().
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The current value while registering subscriptions.
|
|
88
|
+
"""
|
|
89
|
+
return self.read()
|
|
90
|
+
|
|
91
|
+
def __copy__(self):
|
|
92
|
+
return self.__class__(self.value, name=self.name)
|
|
93
|
+
|
|
94
|
+
def __deepcopy__(self, memo: dict[int, Any]):
|
|
95
|
+
if id(self) in memo:
|
|
96
|
+
return memo[id(self)]
|
|
97
|
+
new_value = copy.deepcopy(self.value, memo)
|
|
98
|
+
new_signal = self.__class__(new_value, name=self.name)
|
|
99
|
+
memo[id(self)] = new_signal
|
|
100
|
+
return new_signal
|
|
101
|
+
|
|
102
|
+
def add_obs(self, obs: "Computed[Any] | Effect"):
|
|
103
|
+
prev = len(self.obs)
|
|
104
|
+
self.obs.append(obs)
|
|
105
|
+
if prev == 0 and len(self.obs) == 1:
|
|
106
|
+
for cb in list(self._obs_change_listeners):
|
|
107
|
+
cb(len(self.obs))
|
|
108
|
+
|
|
109
|
+
def remove_obs(self, obs: "Computed[Any] | Effect"):
|
|
110
|
+
if obs in self.obs:
|
|
111
|
+
self.obs.remove(obs)
|
|
112
|
+
if len(self.obs) == 0:
|
|
113
|
+
for cb in list(self._obs_change_listeners):
|
|
114
|
+
cb(0)
|
|
115
|
+
|
|
116
|
+
def on_observer_change(self, cb: Callable[[int], None]) -> Callable[[], None]:
|
|
117
|
+
self._obs_change_listeners.append(cb)
|
|
118
|
+
|
|
119
|
+
def off():
|
|
120
|
+
try:
|
|
121
|
+
self._obs_change_listeners.remove(cb)
|
|
122
|
+
except ValueError:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
return off
|
|
126
|
+
|
|
127
|
+
def write(self, value: T):
|
|
128
|
+
"""Update the value and notify observers.
|
|
129
|
+
|
|
130
|
+
No-op if the new value equals the current value.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
value: The new value to set.
|
|
134
|
+
"""
|
|
135
|
+
if values_equal(value, self.value):
|
|
136
|
+
return
|
|
137
|
+
increment_epoch()
|
|
138
|
+
self.value = value
|
|
139
|
+
self.last_change = epoch()
|
|
140
|
+
for obs in self.obs:
|
|
141
|
+
obs.push_change()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Computed(Generic[T_co]):
|
|
145
|
+
"""A derived value that auto-updates when dependencies change.
|
|
146
|
+
|
|
147
|
+
Lazy evaluation: only recomputes when read and dirty. Throws if a signal
|
|
148
|
+
is written inside the computed function.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
fn: Function computing the value. May optionally accept prev_value
|
|
152
|
+
as first positional argument for incremental computation.
|
|
153
|
+
name: Debug name for the computed.
|
|
154
|
+
|
|
155
|
+
Attributes:
|
|
156
|
+
value: Cached computed value.
|
|
157
|
+
name: Debug name.
|
|
158
|
+
dirty: Whether recompute is needed.
|
|
159
|
+
last_change: Epoch when value last changed.
|
|
160
|
+
|
|
161
|
+
Example:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
count = Signal(5)
|
|
165
|
+
doubled = Computed(lambda: count() * 2)
|
|
166
|
+
print(doubled()) # 10
|
|
167
|
+
count.write(10)
|
|
168
|
+
print(doubled()) # 20
|
|
169
|
+
```
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
fn: Callable[..., T_co]
|
|
173
|
+
name: str | None
|
|
174
|
+
dirty: bool
|
|
175
|
+
on_stack: bool
|
|
176
|
+
accepts_prev_value: bool
|
|
177
|
+
|
|
178
|
+
def __init__(self, fn: Callable[..., T_co], name: str | None = None):
|
|
179
|
+
self.fn = fn
|
|
180
|
+
self.value: T_co = None # pyright: ignore[reportAttributeAccessIssue]
|
|
181
|
+
self.name = name
|
|
182
|
+
self.dirty = False
|
|
183
|
+
self.on_stack = False
|
|
184
|
+
self.last_change: int = -1
|
|
185
|
+
# Dep -> last_change
|
|
186
|
+
self.deps: dict[Signal[Any] | Computed[Any], int] = {}
|
|
187
|
+
self.obs: list[Computed[Any] | Effect] = []
|
|
188
|
+
self._obs_change_listeners: list[Callable[[int], None]] = []
|
|
189
|
+
sig = inspect.signature(self.fn)
|
|
190
|
+
params = list(sig.parameters.values())
|
|
191
|
+
# Check if function has at least one positional parameter
|
|
192
|
+
# (excluding *args and **kwargs, and keyword-only params)
|
|
193
|
+
self.accepts_prev_value = any(
|
|
194
|
+
p.kind
|
|
195
|
+
in (
|
|
196
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
197
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
198
|
+
)
|
|
199
|
+
for p in params
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def read(self) -> T_co:
|
|
203
|
+
"""Get the computed value, recomputing if dirty, and register a dependency.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
The computed value.
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
RuntimeError: If circular dependency detected.
|
|
210
|
+
"""
|
|
211
|
+
if self.on_stack:
|
|
212
|
+
raise RuntimeError("Circular dependency detected")
|
|
213
|
+
|
|
214
|
+
rc = REACTIVE_CONTEXT.get()
|
|
215
|
+
# Ensure this computed is up-to-date before registering as a dep
|
|
216
|
+
self.recompute_if_necessary()
|
|
217
|
+
if rc.scope is not None:
|
|
218
|
+
# Register after potential recompute so the scope records the
|
|
219
|
+
# latest observed version for this computed
|
|
220
|
+
rc.scope.register_dep(self)
|
|
221
|
+
return self.value
|
|
222
|
+
|
|
223
|
+
def __call__(self) -> T_co:
|
|
224
|
+
"""Alias for read().
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
The computed value.
|
|
228
|
+
"""
|
|
229
|
+
return self.read()
|
|
230
|
+
|
|
231
|
+
def unwrap(self) -> T_co:
|
|
232
|
+
"""Alias for read().
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
The computed value while registering subscriptions.
|
|
236
|
+
"""
|
|
237
|
+
return self.read()
|
|
238
|
+
|
|
239
|
+
def __copy__(self):
|
|
240
|
+
return self.__class__(self.fn, name=self.name)
|
|
241
|
+
|
|
242
|
+
def __deepcopy__(self, memo: dict[int, Any]):
|
|
243
|
+
if id(self) in memo:
|
|
244
|
+
return memo[id(self)]
|
|
245
|
+
fn_copy = copy.deepcopy(self.fn, memo)
|
|
246
|
+
name_copy = copy.deepcopy(self.name, memo)
|
|
247
|
+
new_computed = self.__class__(fn_copy, name=name_copy)
|
|
248
|
+
memo[id(self)] = new_computed
|
|
249
|
+
return new_computed
|
|
250
|
+
|
|
251
|
+
def push_change(self):
|
|
252
|
+
if self.dirty:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
self.dirty = True
|
|
256
|
+
for obs in self.obs:
|
|
257
|
+
obs.push_change()
|
|
258
|
+
|
|
259
|
+
def _recompute(self):
|
|
260
|
+
prev_value = self.value
|
|
261
|
+
prev_deps = set(self.deps)
|
|
262
|
+
with Scope() as scope:
|
|
263
|
+
if self.on_stack:
|
|
264
|
+
raise RuntimeError("Circular dependency detected")
|
|
265
|
+
self.on_stack = True
|
|
266
|
+
try:
|
|
267
|
+
execution_epoch = epoch()
|
|
268
|
+
if self.accepts_prev_value:
|
|
269
|
+
self.value = self.fn(prev_value)
|
|
270
|
+
else:
|
|
271
|
+
self.value = self.fn()
|
|
272
|
+
if epoch() != execution_epoch:
|
|
273
|
+
raise RuntimeError(
|
|
274
|
+
f"Detected write to a signal in computed {self.name}. Computeds should be read-only."
|
|
275
|
+
)
|
|
276
|
+
self.dirty = False
|
|
277
|
+
if not values_equal(prev_value, self.value):
|
|
278
|
+
self.last_change = execution_epoch
|
|
279
|
+
|
|
280
|
+
if len(scope.effects) > 0:
|
|
281
|
+
raise RuntimeError(
|
|
282
|
+
"An effect was created within a computed variable's function. "
|
|
283
|
+
+ "This is most likely unintended. If you need to create an effect here, "
|
|
284
|
+
+ "wrap the effect creation with Untrack()."
|
|
285
|
+
)
|
|
286
|
+
finally:
|
|
287
|
+
self.on_stack = False
|
|
288
|
+
|
|
289
|
+
# Update deps and their observed versions to the values seen during this recompute
|
|
290
|
+
self.deps = scope.deps
|
|
291
|
+
new_deps = set(self.deps)
|
|
292
|
+
add_deps = new_deps - prev_deps
|
|
293
|
+
remove_deps = prev_deps - new_deps
|
|
294
|
+
for dep in add_deps:
|
|
295
|
+
dep.add_obs(self)
|
|
296
|
+
for dep in remove_deps:
|
|
297
|
+
dep.remove_obs(self)
|
|
298
|
+
|
|
299
|
+
def recompute_if_necessary(self):
|
|
300
|
+
if self.last_change < 0:
|
|
301
|
+
self._recompute()
|
|
302
|
+
return
|
|
303
|
+
if not self.dirty:
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
for dep in self.deps:
|
|
307
|
+
if isinstance(dep, Computed):
|
|
308
|
+
dep.recompute_if_necessary()
|
|
309
|
+
# Only recompute if a dependency has changed beyond the version
|
|
310
|
+
# we last observed during our previous recompute
|
|
311
|
+
last_seen = self.deps.get(dep, -1)
|
|
312
|
+
if dep.last_change > last_seen:
|
|
313
|
+
self._recompute()
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
self.dirty = False
|
|
317
|
+
|
|
318
|
+
def add_obs(self, obs: "Computed[Any] | Effect"):
|
|
319
|
+
prev = len(self.obs)
|
|
320
|
+
self.obs.append(obs)
|
|
321
|
+
if prev == 0 and len(self.obs) == 1:
|
|
322
|
+
for cb in list(self._obs_change_listeners):
|
|
323
|
+
cb(len(self.obs))
|
|
324
|
+
|
|
325
|
+
def remove_obs(self, obs: "Computed[Any] | Effect"):
|
|
326
|
+
if obs in self.obs:
|
|
327
|
+
self.obs.remove(obs)
|
|
328
|
+
if len(self.obs) == 0:
|
|
329
|
+
for cb in list(self._obs_change_listeners):
|
|
330
|
+
cb(0)
|
|
331
|
+
|
|
332
|
+
def on_observer_change(self, cb: Callable[[int], None]) -> Callable[[], None]:
|
|
333
|
+
self._obs_change_listeners.append(cb)
|
|
334
|
+
|
|
335
|
+
def off():
|
|
336
|
+
try:
|
|
337
|
+
self._obs_change_listeners.remove(cb)
|
|
338
|
+
except ValueError:
|
|
339
|
+
pass
|
|
340
|
+
|
|
341
|
+
return off
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
EffectCleanup = Callable[[], None]
|
|
345
|
+
# Split effect function types into sync and async for clearer typing
|
|
346
|
+
EffectFn = Callable[[], EffectCleanup | None]
|
|
347
|
+
AsyncEffectFn = Callable[[], Awaitable[EffectCleanup | None]]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class Effect(Disposable):
|
|
351
|
+
"""Runs a function when dependencies change.
|
|
352
|
+
|
|
353
|
+
Synchronous effect and base class. Use AsyncEffect for async effects.
|
|
354
|
+
Both are isinstance(Effect).
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
fn: Effect function. May return a cleanup function to run before the
|
|
358
|
+
next execution or on disposal.
|
|
359
|
+
name: Debug name for the effect.
|
|
360
|
+
immediate: If True, run synchronously when scheduled instead of batching.
|
|
361
|
+
lazy: If True, don't run on creation.
|
|
362
|
+
on_error: Error handler for exceptions in the effect function.
|
|
363
|
+
deps: Explicit dependencies (disables auto-tracking).
|
|
364
|
+
interval: Re-run interval in seconds.
|
|
365
|
+
|
|
366
|
+
Example:
|
|
367
|
+
|
|
368
|
+
```python
|
|
369
|
+
count = Signal(0)
|
|
370
|
+
def log_count():
|
|
371
|
+
print(f"Count: {count()}")
|
|
372
|
+
return lambda: print("Cleanup")
|
|
373
|
+
effect = Effect(log_count)
|
|
374
|
+
count.write(1) # Effect runs after batch flush
|
|
375
|
+
effect.dispose()
|
|
376
|
+
```
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
fn: EffectFn
|
|
380
|
+
name: str | None
|
|
381
|
+
on_error: Callable[[Exception], None] | None
|
|
382
|
+
runs: int
|
|
383
|
+
last_run: int
|
|
384
|
+
immediate: bool
|
|
385
|
+
_lazy: bool
|
|
386
|
+
_interval: float | None
|
|
387
|
+
_interval_handle: asyncio.TimerHandle | None
|
|
388
|
+
update_deps: bool
|
|
389
|
+
batch: "Batch | None"
|
|
390
|
+
paused: bool
|
|
391
|
+
|
|
392
|
+
def __init__(
|
|
393
|
+
self,
|
|
394
|
+
fn: EffectFn,
|
|
395
|
+
name: str | None = None,
|
|
396
|
+
immediate: bool = False,
|
|
397
|
+
lazy: bool = False,
|
|
398
|
+
on_error: Callable[[Exception], None] | None = None,
|
|
399
|
+
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
400
|
+
update_deps: bool | None = None,
|
|
401
|
+
interval: float | None = None,
|
|
402
|
+
):
|
|
403
|
+
self.fn = fn # type: ignore[assignment]
|
|
404
|
+
self.name = name
|
|
405
|
+
self.on_error = on_error
|
|
406
|
+
self.cleanup_fn: EffectCleanup | None = None
|
|
407
|
+
self.deps: dict[Signal[Any] | Computed[Any], int] = {}
|
|
408
|
+
self.children: list[Effect] = []
|
|
409
|
+
self.parent: Effect | None = None
|
|
410
|
+
self.runs = 0
|
|
411
|
+
self.last_run = -1
|
|
412
|
+
self.scope: Scope | None = None
|
|
413
|
+
self.batch = None
|
|
414
|
+
if deps is None:
|
|
415
|
+
self.update_deps = True if update_deps is None else update_deps
|
|
416
|
+
else:
|
|
417
|
+
self.update_deps = False if update_deps is None else update_deps
|
|
418
|
+
self.immediate = immediate
|
|
419
|
+
self._lazy = lazy
|
|
420
|
+
self._interval = interval
|
|
421
|
+
self._interval_handle = None
|
|
422
|
+
self.paused = False
|
|
423
|
+
|
|
424
|
+
if immediate and lazy:
|
|
425
|
+
raise ValueError("An effect cannot be boht immediate and lazy")
|
|
426
|
+
|
|
427
|
+
# Register seeded/explicit dependencies immediately upon initialization
|
|
428
|
+
if deps is not None:
|
|
429
|
+
self.deps = {dep: dep.last_change for dep in deps}
|
|
430
|
+
for dep in deps:
|
|
431
|
+
dep.add_obs(self)
|
|
432
|
+
|
|
433
|
+
rc = REACTIVE_CONTEXT.get()
|
|
434
|
+
if rc.scope is not None:
|
|
435
|
+
rc.scope.register_effect(self)
|
|
436
|
+
|
|
437
|
+
if immediate:
|
|
438
|
+
self.run()
|
|
439
|
+
elif not lazy:
|
|
440
|
+
self.schedule()
|
|
441
|
+
|
|
442
|
+
def _cleanup_before_run(self):
|
|
443
|
+
for child in self.children:
|
|
444
|
+
child._cleanup_before_run()
|
|
445
|
+
if self.cleanup_fn:
|
|
446
|
+
self.cleanup_fn()
|
|
447
|
+
|
|
448
|
+
@override
|
|
449
|
+
def dispose(self):
|
|
450
|
+
"""Clean up the effect, run cleanup function, remove from dependencies."""
|
|
451
|
+
self.cancel(cancel_interval=True)
|
|
452
|
+
for child in self.children.copy():
|
|
453
|
+
child.dispose()
|
|
454
|
+
if self.cleanup_fn:
|
|
455
|
+
self.cleanup_fn()
|
|
456
|
+
for dep in self.deps:
|
|
457
|
+
dep.obs.remove(self)
|
|
458
|
+
if self.parent and self in self.parent.children:
|
|
459
|
+
self.parent.children.remove(self)
|
|
460
|
+
|
|
461
|
+
def _schedule_interval(self):
|
|
462
|
+
"""Schedule the next interval run if interval is set."""
|
|
463
|
+
if self._interval is not None and self._interval > 0:
|
|
464
|
+
from pulse.helpers import later
|
|
465
|
+
|
|
466
|
+
self._interval_handle = later(self._interval, self._on_interval)
|
|
467
|
+
|
|
468
|
+
def _on_interval(self):
|
|
469
|
+
"""Called when the interval timer fires."""
|
|
470
|
+
if self._interval is not None:
|
|
471
|
+
# Run directly instead of scheduling - interval runs are unconditional
|
|
472
|
+
self.run()
|
|
473
|
+
self._schedule_interval()
|
|
474
|
+
|
|
475
|
+
def _cancel_interval(self):
|
|
476
|
+
"""Cancel the interval timer."""
|
|
477
|
+
if self._interval_handle is not None:
|
|
478
|
+
self._interval_handle.cancel()
|
|
479
|
+
self._interval_handle = None
|
|
480
|
+
|
|
481
|
+
def pause(self):
|
|
482
|
+
"""Pause the effect; it won't run when dependencies change."""
|
|
483
|
+
self.paused = True
|
|
484
|
+
self.cancel(cancel_interval=True)
|
|
485
|
+
|
|
486
|
+
def resume(self):
|
|
487
|
+
"""Resume a paused effect and schedule it to run."""
|
|
488
|
+
if self.paused:
|
|
489
|
+
self.paused = False
|
|
490
|
+
self.schedule()
|
|
491
|
+
|
|
492
|
+
def schedule(self):
|
|
493
|
+
"""Schedule the effect to run in the current batch."""
|
|
494
|
+
if self.paused:
|
|
495
|
+
return
|
|
496
|
+
# Immediate effects run right away when scheduled and do not enter a batch
|
|
497
|
+
if self.immediate:
|
|
498
|
+
self.run()
|
|
499
|
+
return
|
|
500
|
+
rc = REACTIVE_CONTEXT.get()
|
|
501
|
+
batch = rc.batch
|
|
502
|
+
batch.register_effect(self)
|
|
503
|
+
self.batch = batch
|
|
504
|
+
|
|
505
|
+
def cancel(self, cancel_interval: bool = True):
|
|
506
|
+
"""
|
|
507
|
+
Cancel the effect. For sync effects, removes from batch.
|
|
508
|
+
For async effects (override), also cancels the running task.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
cancel_interval: If True (default), also cancels the interval timer.
|
|
512
|
+
"""
|
|
513
|
+
if self.batch is not None:
|
|
514
|
+
self.batch.effects.remove(self)
|
|
515
|
+
self.batch = None
|
|
516
|
+
if cancel_interval:
|
|
517
|
+
self._cancel_interval()
|
|
518
|
+
|
|
519
|
+
def push_change(self):
|
|
520
|
+
if self.paused:
|
|
521
|
+
return
|
|
522
|
+
# Short-circuit if already scheduled in a batch.
|
|
523
|
+
# This avoids redundant schedule() calls and O(n) list checks
|
|
524
|
+
# when the same effect is reached through multiple dependency paths.
|
|
525
|
+
if self.batch is not None:
|
|
526
|
+
return
|
|
527
|
+
self.schedule()
|
|
528
|
+
|
|
529
|
+
def should_run(self):
|
|
530
|
+
return self.runs == 0 or self._deps_changed_since_last_run()
|
|
531
|
+
|
|
532
|
+
def _deps_changed_since_last_run(self):
|
|
533
|
+
for dep in self.deps:
|
|
534
|
+
if isinstance(dep, Computed):
|
|
535
|
+
dep.recompute_if_necessary()
|
|
536
|
+
last_seen = self.deps.get(dep, -1)
|
|
537
|
+
if dep.last_change > last_seen:
|
|
538
|
+
return True
|
|
539
|
+
return False
|
|
540
|
+
|
|
541
|
+
def __call__(self):
|
|
542
|
+
self.run()
|
|
543
|
+
|
|
544
|
+
def flush(self):
|
|
545
|
+
"""If scheduled in a batch, remove and run immediately."""
|
|
546
|
+
if self.batch is not None:
|
|
547
|
+
self.batch.effects.remove(self)
|
|
548
|
+
self.batch = None
|
|
549
|
+
# Run now (respects IS_PRERENDERING and error handling)
|
|
550
|
+
self.run()
|
|
551
|
+
|
|
552
|
+
def handle_error(self, exc: Exception) -> None:
|
|
553
|
+
if callable(self.on_error):
|
|
554
|
+
self.on_error(exc)
|
|
555
|
+
return
|
|
556
|
+
handler = getattr(REACTIVE_CONTEXT.get(), "on_effect_error", None)
|
|
557
|
+
if callable(handler):
|
|
558
|
+
handler(self, exc)
|
|
559
|
+
return
|
|
560
|
+
raise exc
|
|
561
|
+
|
|
562
|
+
def _apply_scope_results(
|
|
563
|
+
self,
|
|
564
|
+
scope: "Scope",
|
|
565
|
+
captured_last_changes: dict[Signal[Any] | Computed[Any], int] | None = None,
|
|
566
|
+
) -> None:
|
|
567
|
+
# Apply captured last_change values at the end for explicit deps
|
|
568
|
+
if not self.update_deps:
|
|
569
|
+
assert captured_last_changes is not None
|
|
570
|
+
for dep, last_change in captured_last_changes.items():
|
|
571
|
+
self.deps[dep] = last_change
|
|
572
|
+
return
|
|
573
|
+
|
|
574
|
+
self.children = scope.effects
|
|
575
|
+
for child in self.children:
|
|
576
|
+
child.parent = self
|
|
577
|
+
|
|
578
|
+
prev_deps = set(self.deps)
|
|
579
|
+
self.deps = scope.deps
|
|
580
|
+
new_deps = set(self.deps)
|
|
581
|
+
add_deps = new_deps - prev_deps
|
|
582
|
+
remove_deps = prev_deps - new_deps
|
|
583
|
+
for dep in add_deps:
|
|
584
|
+
dep.add_obs(self)
|
|
585
|
+
is_dirty = isinstance(dep, Computed) and dep.dirty
|
|
586
|
+
has_changed = isinstance(dep, Signal) and dep.last_change > self.deps.get(
|
|
587
|
+
dep, -1
|
|
588
|
+
)
|
|
589
|
+
if is_dirty or has_changed:
|
|
590
|
+
self.schedule()
|
|
591
|
+
for dep in remove_deps:
|
|
592
|
+
dep.remove_obs(self)
|
|
593
|
+
|
|
594
|
+
def _copy_kwargs(self) -> dict[str, Any]:
|
|
595
|
+
deps = None
|
|
596
|
+
if not self.update_deps or (self.update_deps and self.runs == 0 and self.deps):
|
|
597
|
+
deps = list(self.deps.keys())
|
|
598
|
+
return {
|
|
599
|
+
"fn": self.fn,
|
|
600
|
+
"name": self.name,
|
|
601
|
+
"immediate": self.immediate,
|
|
602
|
+
"lazy": self._lazy,
|
|
603
|
+
"on_error": self.on_error,
|
|
604
|
+
"deps": deps,
|
|
605
|
+
"update_deps": self.update_deps,
|
|
606
|
+
"interval": self._interval,
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
def __copy__(self):
|
|
610
|
+
kwargs = self._copy_kwargs()
|
|
611
|
+
return type(self)(**kwargs)
|
|
612
|
+
|
|
613
|
+
def __deepcopy__(self, memo: dict[int, Any]):
|
|
614
|
+
if id(self) in memo:
|
|
615
|
+
return memo[id(self)]
|
|
616
|
+
kwargs = self._copy_kwargs()
|
|
617
|
+
kwargs["fn"] = copy.deepcopy(self.fn, memo)
|
|
618
|
+
kwargs["name"] = copy.deepcopy(self.name, memo)
|
|
619
|
+
kwargs["on_error"] = copy.deepcopy(self.on_error, memo)
|
|
620
|
+
deps = kwargs.get("deps")
|
|
621
|
+
if deps is not None:
|
|
622
|
+
kwargs["deps"] = list(deps)
|
|
623
|
+
new_effect = type(self)(**kwargs)
|
|
624
|
+
memo[id(self)] = new_effect
|
|
625
|
+
return new_effect
|
|
626
|
+
|
|
627
|
+
def run(self):
|
|
628
|
+
"""Execute the effect immediately."""
|
|
629
|
+
with Untrack():
|
|
630
|
+
try:
|
|
631
|
+
self._cleanup_before_run()
|
|
632
|
+
except Exception as e:
|
|
633
|
+
self.handle_error(e)
|
|
634
|
+
self._execute()
|
|
635
|
+
|
|
636
|
+
def _execute(self) -> None:
|
|
637
|
+
execution_epoch = epoch()
|
|
638
|
+
# Capture last_change for explicit deps before running
|
|
639
|
+
captured_last_changes: dict[Signal[Any] | Computed[Any], int] | None = None
|
|
640
|
+
if not self.update_deps:
|
|
641
|
+
captured_last_changes = {dep: dep.last_change for dep in self.deps}
|
|
642
|
+
with Scope() as scope:
|
|
643
|
+
# Clear batch *before* running as we may update a signal that causes
|
|
644
|
+
# this effect to be rescheduled.
|
|
645
|
+
self.batch = None
|
|
646
|
+
try:
|
|
647
|
+
self.cleanup_fn = self.fn()
|
|
648
|
+
except Exception as e:
|
|
649
|
+
self.handle_error(e)
|
|
650
|
+
self.runs += 1
|
|
651
|
+
self.last_run = execution_epoch
|
|
652
|
+
self._apply_scope_results(scope, captured_last_changes)
|
|
653
|
+
# Start/restart interval if set and not currently scheduled
|
|
654
|
+
if self._interval is not None and self._interval_handle is None:
|
|
655
|
+
self._schedule_interval()
|
|
656
|
+
|
|
657
|
+
def set_deps(
|
|
658
|
+
self,
|
|
659
|
+
deps: list[Signal[Any] | Computed[Any]]
|
|
660
|
+
| dict[Signal[Any] | Computed[Any], int],
|
|
661
|
+
*,
|
|
662
|
+
update_deps: bool | None = None,
|
|
663
|
+
) -> None:
|
|
664
|
+
if update_deps is not None:
|
|
665
|
+
self.update_deps = update_deps
|
|
666
|
+
if isinstance(deps, dict):
|
|
667
|
+
new_deps = dict(deps)
|
|
668
|
+
else:
|
|
669
|
+
new_deps = {dep: dep.last_change for dep in deps}
|
|
670
|
+
prev_deps = set(self.deps)
|
|
671
|
+
new_dep_keys = set(new_deps)
|
|
672
|
+
add_deps = new_dep_keys - prev_deps
|
|
673
|
+
remove_deps = prev_deps - new_dep_keys
|
|
674
|
+
for dep in remove_deps:
|
|
675
|
+
dep.remove_obs(self)
|
|
676
|
+
self.deps = new_deps
|
|
677
|
+
for dep in add_deps:
|
|
678
|
+
dep.add_obs(self)
|
|
679
|
+
for dep, last_seen in self.deps.items():
|
|
680
|
+
if isinstance(dep, Computed):
|
|
681
|
+
if dep.dirty or dep.last_change > last_seen:
|
|
682
|
+
self.schedule()
|
|
683
|
+
break
|
|
684
|
+
continue
|
|
685
|
+
if dep.last_change > last_seen:
|
|
686
|
+
self.schedule()
|
|
687
|
+
break
|
|
688
|
+
|
|
689
|
+
@contextmanager
|
|
690
|
+
def capture_deps(self, update_deps: bool | None = None):
|
|
691
|
+
scope = Scope()
|
|
692
|
+
try:
|
|
693
|
+
with scope:
|
|
694
|
+
yield
|
|
695
|
+
finally:
|
|
696
|
+
self.set_deps(scope.deps, update_deps=update_deps)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
class AsyncEffect(Effect):
|
|
700
|
+
"""Async version of Effect for coroutine functions.
|
|
701
|
+
|
|
702
|
+
Does not use batching; cancels and restarts on each dependency change.
|
|
703
|
+
The `immediate` parameter is not supported (raises if passed).
|
|
704
|
+
|
|
705
|
+
Args:
|
|
706
|
+
fn: Async effect function returning an awaitable.
|
|
707
|
+
name: Debug name for the effect.
|
|
708
|
+
lazy: If True, don't run on creation.
|
|
709
|
+
on_error: Error handler for exceptions in the effect function.
|
|
710
|
+
deps: Explicit dependencies (disables auto-tracking).
|
|
711
|
+
interval: Re-run interval in seconds.
|
|
712
|
+
"""
|
|
713
|
+
|
|
714
|
+
fn: AsyncEffectFn # pyright: ignore[reportIncompatibleMethodOverride]
|
|
715
|
+
batch: None # pyright: ignore[reportIncompatibleVariableOverride]
|
|
716
|
+
_task: asyncio.Task[None] | None
|
|
717
|
+
_task_started: bool
|
|
718
|
+
|
|
719
|
+
def __init__(
|
|
720
|
+
self,
|
|
721
|
+
fn: AsyncEffectFn,
|
|
722
|
+
name: str | None = None,
|
|
723
|
+
lazy: bool = False,
|
|
724
|
+
on_error: Callable[[Exception], None] | None = None,
|
|
725
|
+
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
726
|
+
update_deps: bool | None = None,
|
|
727
|
+
interval: float | None = None,
|
|
728
|
+
):
|
|
729
|
+
# Track an async task when running async effects
|
|
730
|
+
self._task = None
|
|
731
|
+
self._task_started = False
|
|
732
|
+
super().__init__(
|
|
733
|
+
fn=fn, # pyright: ignore[reportArgumentType]
|
|
734
|
+
name=name,
|
|
735
|
+
immediate=False,
|
|
736
|
+
lazy=lazy,
|
|
737
|
+
on_error=on_error,
|
|
738
|
+
deps=deps,
|
|
739
|
+
update_deps=update_deps,
|
|
740
|
+
interval=interval,
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
@override
|
|
744
|
+
def push_change(self):
|
|
745
|
+
# Short-circuit if task exists but hasn't started executing yet.
|
|
746
|
+
# This avoids cancelling and recreating tasks multiple times when reached
|
|
747
|
+
# through multiple dependency paths before the event loop runs.
|
|
748
|
+
# Once the task starts running, new push_change calls will cancel and restart.
|
|
749
|
+
if self._task is not None and not self._task.done() and not self._task_started:
|
|
750
|
+
return
|
|
751
|
+
self.schedule()
|
|
752
|
+
|
|
753
|
+
@override
|
|
754
|
+
def schedule(self):
|
|
755
|
+
"""
|
|
756
|
+
Schedule the async effect. Unlike synchronous effects, async effects do not
|
|
757
|
+
go through batches, they cancel the previous run and create a new task
|
|
758
|
+
immediately..
|
|
759
|
+
"""
|
|
760
|
+
self.run()
|
|
761
|
+
|
|
762
|
+
@property
|
|
763
|
+
def is_scheduled(self) -> bool:
|
|
764
|
+
return self._task is not None
|
|
765
|
+
|
|
766
|
+
@override
|
|
767
|
+
def _copy_kwargs(self):
|
|
768
|
+
kwargs = super()._copy_kwargs()
|
|
769
|
+
kwargs.pop("immediate", None)
|
|
770
|
+
return kwargs
|
|
771
|
+
|
|
772
|
+
@override
|
|
773
|
+
def run(self) -> asyncio.Task[Any]: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
774
|
+
"""Start the async effect, cancelling any previous run.
|
|
775
|
+
|
|
776
|
+
Returns:
|
|
777
|
+
The asyncio.Task running the effect.
|
|
778
|
+
"""
|
|
779
|
+
execution_epoch = epoch()
|
|
780
|
+
|
|
781
|
+
# Cancel any previous run still in flight, but preserve the interval
|
|
782
|
+
self.cancel(cancel_interval=False)
|
|
783
|
+
this_task: asyncio.Task[None] | None = None
|
|
784
|
+
|
|
785
|
+
async def _runner():
|
|
786
|
+
nonlocal execution_epoch, this_task
|
|
787
|
+
try:
|
|
788
|
+
self._task_started = True
|
|
789
|
+
# Perform cleanups in the new task
|
|
790
|
+
with Untrack():
|
|
791
|
+
try:
|
|
792
|
+
self._cleanup_before_run()
|
|
793
|
+
except Exception as e:
|
|
794
|
+
self.handle_error(e)
|
|
795
|
+
|
|
796
|
+
# Capture last_change for explicit deps before running
|
|
797
|
+
captured_last_changes: dict[Signal[Any] | Computed[Any], int] | None = (
|
|
798
|
+
None
|
|
799
|
+
)
|
|
800
|
+
if not self.update_deps:
|
|
801
|
+
captured_last_changes = {dep: dep.last_change for dep in self.deps}
|
|
802
|
+
|
|
803
|
+
with Scope() as scope:
|
|
804
|
+
try:
|
|
805
|
+
result = self.fn()
|
|
806
|
+
self.cleanup_fn = await maybe_await(result)
|
|
807
|
+
except asyncio.CancelledError:
|
|
808
|
+
# Re-raise so finally block executes to clear task reference
|
|
809
|
+
raise
|
|
810
|
+
except Exception as e:
|
|
811
|
+
self.handle_error(e)
|
|
812
|
+
self.runs += 1
|
|
813
|
+
self.last_run = execution_epoch
|
|
814
|
+
self._apply_scope_results(scope, captured_last_changes)
|
|
815
|
+
# Start/restart interval if set and not currently scheduled
|
|
816
|
+
if self._interval is not None and self._interval_handle is None:
|
|
817
|
+
self._schedule_interval()
|
|
818
|
+
finally:
|
|
819
|
+
# Clear the task reference when it finishes
|
|
820
|
+
if self._task is this_task:
|
|
821
|
+
self._task = None
|
|
822
|
+
self._task_started = False
|
|
823
|
+
|
|
824
|
+
this_task = create_task(_runner(), name=f"effect:{self.name or 'unnamed'}")
|
|
825
|
+
self._task = this_task
|
|
826
|
+
return this_task
|
|
827
|
+
|
|
828
|
+
@override
|
|
829
|
+
async def __call__(self): # pyright: ignore[reportIncompatibleMethodOverride]
|
|
830
|
+
await self.run()
|
|
831
|
+
|
|
832
|
+
@override
|
|
833
|
+
def cancel(self, cancel_interval: bool = True) -> None:
|
|
834
|
+
"""
|
|
835
|
+
Cancel the async effect. Cancels the running task and optionally the interval.
|
|
836
|
+
|
|
837
|
+
Args:
|
|
838
|
+
cancel_interval: If True (default), also cancels the interval timer.
|
|
839
|
+
"""
|
|
840
|
+
if self._task:
|
|
841
|
+
t = self._task
|
|
842
|
+
self._task = None
|
|
843
|
+
if not t.cancelled():
|
|
844
|
+
t.cancel()
|
|
845
|
+
if cancel_interval:
|
|
846
|
+
self._cancel_interval()
|
|
847
|
+
|
|
848
|
+
async def wait(self) -> None:
|
|
849
|
+
"""Wait for the current task to complete.
|
|
850
|
+
|
|
851
|
+
Does not start a new task if none is running. If the task is cancelled
|
|
852
|
+
while waiting, waits for a new task if one is started.
|
|
853
|
+
"""
|
|
854
|
+
while True:
|
|
855
|
+
if self._task is None or self._task.done():
|
|
856
|
+
# No task running, return immediately
|
|
857
|
+
return
|
|
858
|
+
try:
|
|
859
|
+
await self._task
|
|
860
|
+
return
|
|
861
|
+
except asyncio.CancelledError:
|
|
862
|
+
# If wait() itself is cancelled, propagate it
|
|
863
|
+
current_task = asyncio.current_task()
|
|
864
|
+
if current_task is not None and (
|
|
865
|
+
current_task.cancelling() > 0 or current_task.cancelled()
|
|
866
|
+
):
|
|
867
|
+
raise
|
|
868
|
+
# Effect task was cancelled, check if a new task was started
|
|
869
|
+
# and continue waiting if so
|
|
870
|
+
continue
|
|
871
|
+
|
|
872
|
+
@override
|
|
873
|
+
def dispose(self):
|
|
874
|
+
# Run children cleanups first, then cancel in-flight task and interval
|
|
875
|
+
self.cancel(cancel_interval=True)
|
|
876
|
+
for child in self.children.copy():
|
|
877
|
+
child.dispose()
|
|
878
|
+
if self.cleanup_fn:
|
|
879
|
+
self.cleanup_fn()
|
|
880
|
+
for dep in self.deps:
|
|
881
|
+
dep.obs.remove(self)
|
|
882
|
+
if self.parent and self in self.parent.children:
|
|
883
|
+
self.parent.children.remove(self)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
class Batch:
|
|
887
|
+
"""Groups reactive updates to run effects once after all writes.
|
|
888
|
+
|
|
889
|
+
By default, effects are scheduled in a global batch that flushes on the
|
|
890
|
+
next event loop iteration. Use as a context manager to create an explicit
|
|
891
|
+
batch that flushes on exit.
|
|
892
|
+
|
|
893
|
+
Args:
|
|
894
|
+
effects: Initial list of effects to schedule.
|
|
895
|
+
name: Debug name for the batch.
|
|
896
|
+
|
|
897
|
+
Example:
|
|
898
|
+
|
|
899
|
+
```python
|
|
900
|
+
count = Signal(0)
|
|
901
|
+
with Batch() as batch:
|
|
902
|
+
count.write(1)
|
|
903
|
+
count.write(2)
|
|
904
|
+
count.write(3)
|
|
905
|
+
# Effects run once here with final value 3
|
|
906
|
+
```
|
|
907
|
+
"""
|
|
908
|
+
|
|
909
|
+
name: str | None
|
|
910
|
+
flush_id: int
|
|
911
|
+
|
|
912
|
+
def __init__(
|
|
913
|
+
self, effects: list[Effect] | None = None, name: str | None = None
|
|
914
|
+
) -> None:
|
|
915
|
+
self.effects: list[Effect] = effects or []
|
|
916
|
+
self.name = name
|
|
917
|
+
self.flush_id = 0
|
|
918
|
+
self._token: "Token[ReactiveContext] | None" = None
|
|
919
|
+
|
|
920
|
+
def register_effect(self, effect: Effect):
|
|
921
|
+
"""Add an effect to run when the batch flushes.
|
|
922
|
+
|
|
923
|
+
Args:
|
|
924
|
+
effect: The effect to schedule.
|
|
925
|
+
"""
|
|
926
|
+
if effect not in self.effects:
|
|
927
|
+
self.effects.append(effect)
|
|
928
|
+
|
|
929
|
+
def flush(self):
|
|
930
|
+
"""Run all scheduled effects."""
|
|
931
|
+
token = None
|
|
932
|
+
rc = REACTIVE_CONTEXT.get()
|
|
933
|
+
if rc.batch is not self:
|
|
934
|
+
token = REACTIVE_CONTEXT.set(ReactiveContext(rc.epoch, self, rc.scope))
|
|
935
|
+
|
|
936
|
+
self.flush_id += 1
|
|
937
|
+
MAX_ITERS = 10000
|
|
938
|
+
iters = 0
|
|
939
|
+
|
|
940
|
+
while len(self.effects) > 0:
|
|
941
|
+
if iters > MAX_ITERS:
|
|
942
|
+
raise RuntimeError(
|
|
943
|
+
f"Pulse's reactive system registered more than {MAX_ITERS} iterations. There is likely an update cycle in your application.\n"
|
|
944
|
+
+ "This is most often caused through a state update during rerender or in an effect that ends up triggering the same rerender or effect."
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
# This ensures the epoch is incremented *after* all the signal
|
|
948
|
+
# writes and associated effects have been run.
|
|
949
|
+
|
|
950
|
+
current_effects = self.effects
|
|
951
|
+
self.effects = []
|
|
952
|
+
|
|
953
|
+
for effect in current_effects:
|
|
954
|
+
effect.batch = None
|
|
955
|
+
if not effect.should_run():
|
|
956
|
+
continue
|
|
957
|
+
try:
|
|
958
|
+
effect.run()
|
|
959
|
+
except Exception as exc:
|
|
960
|
+
effect.handle_error(exc)
|
|
961
|
+
|
|
962
|
+
iters += 1
|
|
963
|
+
|
|
964
|
+
if token:
|
|
965
|
+
REACTIVE_CONTEXT.reset(token)
|
|
966
|
+
|
|
967
|
+
def __enter__(self):
|
|
968
|
+
rc = REACTIVE_CONTEXT.get()
|
|
969
|
+
# Create a new immutable reactive context with updated batch
|
|
970
|
+
self._token = REACTIVE_CONTEXT.set(
|
|
971
|
+
ReactiveContext(rc.epoch, self, rc.scope, rc.on_effect_error)
|
|
972
|
+
)
|
|
973
|
+
return self
|
|
974
|
+
|
|
975
|
+
def __exit__(
|
|
976
|
+
self,
|
|
977
|
+
exc_type: type[BaseException] | None,
|
|
978
|
+
exc_value: BaseException | None,
|
|
979
|
+
exc_traceback: Any,
|
|
980
|
+
) -> Literal[False]:
|
|
981
|
+
self.flush()
|
|
982
|
+
# Restore previous reactive context
|
|
983
|
+
if self._token:
|
|
984
|
+
REACTIVE_CONTEXT.reset(self._token)
|
|
985
|
+
return False
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
class GlobalBatch(Batch):
|
|
989
|
+
is_scheduled: bool
|
|
990
|
+
|
|
991
|
+
def __init__(self) -> None:
|
|
992
|
+
self.is_scheduled = False
|
|
993
|
+
super().__init__()
|
|
994
|
+
|
|
995
|
+
@override
|
|
996
|
+
def register_effect(self, effect: Effect):
|
|
997
|
+
if not self.is_scheduled:
|
|
998
|
+
schedule_on_loop(self.flush)
|
|
999
|
+
self.is_scheduled = True
|
|
1000
|
+
return super().register_effect(effect)
|
|
1001
|
+
|
|
1002
|
+
@override
|
|
1003
|
+
def flush(self):
|
|
1004
|
+
super().flush()
|
|
1005
|
+
self.is_scheduled = False
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
class IgnoreBatch(Batch):
|
|
1009
|
+
"""
|
|
1010
|
+
A batch that ignores effect registrations and does nothing when flushed.
|
|
1011
|
+
Used during State initialization to prevent effects from running during setup.
|
|
1012
|
+
"""
|
|
1013
|
+
|
|
1014
|
+
@override
|
|
1015
|
+
def register_effect(self, effect: Effect):
|
|
1016
|
+
# Silently ignore effect registrations during initialization
|
|
1017
|
+
pass
|
|
1018
|
+
|
|
1019
|
+
@override
|
|
1020
|
+
def flush(self):
|
|
1021
|
+
# No-op: don't run any effects
|
|
1022
|
+
pass
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
class Epoch:
|
|
1026
|
+
current: int
|
|
1027
|
+
|
|
1028
|
+
def __init__(self, current: int = 0) -> None:
|
|
1029
|
+
self.current = current
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
class Scope:
|
|
1033
|
+
"""Tracks dependencies and effects created within a context.
|
|
1034
|
+
|
|
1035
|
+
Use as a context manager to capture which signals/computeds are read
|
|
1036
|
+
and which effects are created.
|
|
1037
|
+
|
|
1038
|
+
Attributes:
|
|
1039
|
+
deps: Tracked dependencies mapping Signal/Computed to last_change epoch.
|
|
1040
|
+
effects: Effects created in this scope.
|
|
1041
|
+
|
|
1042
|
+
Example:
|
|
1043
|
+
|
|
1044
|
+
```python
|
|
1045
|
+
with Scope() as scope:
|
|
1046
|
+
value = signal() # Dependency tracked
|
|
1047
|
+
effect = Effect(fn) # Effect registered
|
|
1048
|
+
print(scope.deps) # {signal: last_change}
|
|
1049
|
+
print(scope.effects) # [effect]
|
|
1050
|
+
```
|
|
1051
|
+
"""
|
|
1052
|
+
|
|
1053
|
+
def __init__(self):
|
|
1054
|
+
# Dict preserves insertion order. Maps dependency -> last_change
|
|
1055
|
+
self.deps: dict[Signal[Any] | Computed[Any], int] = {}
|
|
1056
|
+
self.effects: list[Effect] = []
|
|
1057
|
+
self._token: "Token[ReactiveContext] | None" = None
|
|
1058
|
+
|
|
1059
|
+
def register_effect(self, effect: "Effect"):
|
|
1060
|
+
if effect not in self.effects:
|
|
1061
|
+
self.effects.append(effect)
|
|
1062
|
+
|
|
1063
|
+
def register_dep(self, value: "Signal[Any] | Computed[Any]"):
|
|
1064
|
+
self.deps[value] = value.last_change
|
|
1065
|
+
|
|
1066
|
+
def __enter__(self):
|
|
1067
|
+
rc = REACTIVE_CONTEXT.get()
|
|
1068
|
+
# Create a new immutable reactive context with updated scope
|
|
1069
|
+
self._token = REACTIVE_CONTEXT.set(
|
|
1070
|
+
ReactiveContext(rc.epoch, rc.batch, self, rc.on_effect_error)
|
|
1071
|
+
)
|
|
1072
|
+
return self
|
|
1073
|
+
|
|
1074
|
+
def __exit__(
|
|
1075
|
+
self,
|
|
1076
|
+
exc_type: type[BaseException] | None,
|
|
1077
|
+
exc_value: BaseException | None,
|
|
1078
|
+
exc_traceback: Any,
|
|
1079
|
+
) -> Literal[False]:
|
|
1080
|
+
# Restore previous reactive context
|
|
1081
|
+
if self._token:
|
|
1082
|
+
REACTIVE_CONTEXT.reset(self._token)
|
|
1083
|
+
return False
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
class Untrack(Scope):
|
|
1087
|
+
"""A scope that disables dependency tracking.
|
|
1088
|
+
|
|
1089
|
+
Use as a context manager to read signals without registering dependencies.
|
|
1090
|
+
|
|
1091
|
+
Example:
|
|
1092
|
+
|
|
1093
|
+
```python
|
|
1094
|
+
with Untrack():
|
|
1095
|
+
value = signal() # No dependency registered
|
|
1096
|
+
```
|
|
1097
|
+
"""
|
|
1098
|
+
|
|
1099
|
+
...
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
class ReactiveContext:
|
|
1103
|
+
"""Composite context holding epoch, batch, and scope.
|
|
1104
|
+
|
|
1105
|
+
Use as a context manager to set up a complete reactive environment.
|
|
1106
|
+
|
|
1107
|
+
Args:
|
|
1108
|
+
epoch: Global version counter. Defaults to a new Epoch.
|
|
1109
|
+
batch: Current batch for effect scheduling. Defaults to GlobalBatch.
|
|
1110
|
+
scope: Current scope for dependency tracking.
|
|
1111
|
+
on_effect_error: Global effect error handler.
|
|
1112
|
+
|
|
1113
|
+
Attributes:
|
|
1114
|
+
epoch: Global version counter.
|
|
1115
|
+
batch: Current batch for effect scheduling.
|
|
1116
|
+
scope: Current scope for dependency tracking.
|
|
1117
|
+
on_effect_error: Global effect error handler.
|
|
1118
|
+
|
|
1119
|
+
Example:
|
|
1120
|
+
|
|
1121
|
+
```python
|
|
1122
|
+
ctx = ReactiveContext()
|
|
1123
|
+
with ctx:
|
|
1124
|
+
# All reactive operations use this context
|
|
1125
|
+
pass
|
|
1126
|
+
```
|
|
1127
|
+
"""
|
|
1128
|
+
|
|
1129
|
+
epoch: Epoch
|
|
1130
|
+
batch: Batch
|
|
1131
|
+
scope: Scope | None
|
|
1132
|
+
on_effect_error: Callable[[Effect, Exception], None] | None
|
|
1133
|
+
_tokens: list[Any]
|
|
1134
|
+
|
|
1135
|
+
def __init__(
|
|
1136
|
+
self,
|
|
1137
|
+
epoch: Epoch | None = None,
|
|
1138
|
+
batch: Batch | None = None,
|
|
1139
|
+
scope: Scope | None = None,
|
|
1140
|
+
on_effect_error: Callable[[Effect, Exception], None] | None = None,
|
|
1141
|
+
) -> None:
|
|
1142
|
+
self.epoch = epoch or Epoch()
|
|
1143
|
+
self.batch = batch or GlobalBatch()
|
|
1144
|
+
self.scope = scope
|
|
1145
|
+
# Optional effect error handler set by integrators (e.g., session)
|
|
1146
|
+
self.on_effect_error = on_effect_error
|
|
1147
|
+
self._tokens = []
|
|
1148
|
+
|
|
1149
|
+
def get_epoch(self) -> int:
|
|
1150
|
+
return self.epoch.current
|
|
1151
|
+
|
|
1152
|
+
def increment_epoch(self) -> None:
|
|
1153
|
+
self.epoch.current += 1
|
|
1154
|
+
|
|
1155
|
+
def __enter__(self):
|
|
1156
|
+
self._tokens.append(REACTIVE_CONTEXT.set(self))
|
|
1157
|
+
return self
|
|
1158
|
+
|
|
1159
|
+
def __exit__(
|
|
1160
|
+
self,
|
|
1161
|
+
exc_type: type[BaseException] | None,
|
|
1162
|
+
exc_value: BaseException | None,
|
|
1163
|
+
exc_tb: Any,
|
|
1164
|
+
) -> Literal[False]:
|
|
1165
|
+
REACTIVE_CONTEXT.reset(self._tokens.pop())
|
|
1166
|
+
return False
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def epoch() -> int:
|
|
1170
|
+
"""Get the current reactive epoch (version counter).
|
|
1171
|
+
|
|
1172
|
+
Returns:
|
|
1173
|
+
The current epoch value.
|
|
1174
|
+
"""
|
|
1175
|
+
return REACTIVE_CONTEXT.get().get_epoch()
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def increment_epoch() -> None:
|
|
1179
|
+
"""Increment the reactive epoch.
|
|
1180
|
+
|
|
1181
|
+
Called automatically on signal writes.
|
|
1182
|
+
"""
|
|
1183
|
+
return REACTIVE_CONTEXT.get().increment_epoch()
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
# Default global context (used in tests / outside app)
|
|
1187
|
+
REACTIVE_CONTEXT: ContextVar[ReactiveContext] = ContextVar(
|
|
1188
|
+
"pulse_reactive_context",
|
|
1189
|
+
default=ReactiveContext(Epoch(), GlobalBatch()), # noqa: B039
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def flush_effects() -> None:
|
|
1194
|
+
"""Flush the current batch, running all scheduled effects.
|
|
1195
|
+
|
|
1196
|
+
Example:
|
|
1197
|
+
|
|
1198
|
+
```python
|
|
1199
|
+
count = Signal(0)
|
|
1200
|
+
Effect(lambda: print(count()))
|
|
1201
|
+
count.write(1)
|
|
1202
|
+
flush_effects() # Prints: 1
|
|
1203
|
+
```
|
|
1204
|
+
"""
|
|
1205
|
+
REACTIVE_CONTEXT.get().batch.flush()
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
class InvariantError(Exception): ...
|