pulse-framework 0.1.55__py3-none-any.whl → 0.1.56__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 -6
- pulse/app.py +144 -57
- pulse/channel.py +139 -7
- pulse/cli/cmd.py +16 -2
- pulse/codegen/codegen.py +43 -12
- pulse/component.py +104 -0
- pulse/components/for_.py +30 -4
- pulse/components/if_.py +28 -5
- pulse/components/react_router.py +61 -3
- pulse/context.py +39 -5
- pulse/cookies.py +108 -4
- pulse/decorators.py +193 -24
- pulse/env.py +56 -2
- pulse/form.py +198 -5
- pulse/helpers.py +7 -1
- pulse/hooks/core.py +135 -5
- pulse/hooks/effects.py +61 -77
- pulse/hooks/init.py +60 -1
- pulse/hooks/runtime.py +241 -0
- pulse/hooks/setup.py +77 -0
- pulse/hooks/stable.py +58 -1
- pulse/hooks/state.py +107 -20
- pulse/js/__init__.py +40 -24
- pulse/js/array.py +9 -6
- pulse/js/console.py +15 -12
- pulse/js/date.py +9 -6
- pulse/js/document.py +5 -2
- pulse/js/error.py +7 -4
- pulse/js/json.py +9 -6
- pulse/js/map.py +8 -5
- pulse/js/math.py +9 -6
- pulse/js/navigator.py +5 -2
- pulse/js/number.py +9 -6
- pulse/js/obj.py +16 -13
- pulse/js/object.py +9 -6
- pulse/js/promise.py +19 -13
- pulse/js/pulse.py +28 -25
- pulse/js/react.py +94 -55
- pulse/js/regexp.py +7 -4
- pulse/js/set.py +8 -5
- pulse/js/string.py +9 -6
- pulse/js/weakmap.py +8 -5
- pulse/js/weakset.py +8 -5
- pulse/js/window.py +6 -3
- pulse/messages.py +5 -0
- pulse/middleware.py +147 -76
- pulse/plugin.py +76 -5
- pulse/queries/client.py +186 -39
- pulse/queries/common.py +52 -3
- pulse/queries/infinite_query.py +154 -2
- pulse/queries/mutation.py +127 -7
- pulse/queries/query.py +112 -11
- pulse/react_component.py +66 -3
- pulse/reactive.py +314 -30
- pulse/reactive_extensions.py +106 -26
- pulse/render_session.py +304 -173
- pulse/request.py +46 -11
- pulse/routing.py +140 -4
- pulse/serializer.py +71 -0
- pulse/state.py +177 -9
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +0 -3
- pulse/transpiler/py_module.py +1 -7
- pulse/user_session.py +119 -18
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
- pulse_framework-0.1.56.dist-info/RECORD +127 -0
- pulse/transpiler/react_component.py +0 -44
- pulse_framework-0.1.55.dist-info/RECORD +0 -127
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/entry_points.txt +0 -0
pulse/reactive.py
CHANGED
|
@@ -2,6 +2,7 @@ import asyncio
|
|
|
2
2
|
import copy
|
|
3
3
|
import inspect
|
|
4
4
|
from collections.abc import Awaitable, Callable
|
|
5
|
+
from contextlib import contextmanager
|
|
5
6
|
from contextvars import ContextVar, Token
|
|
6
7
|
from typing import (
|
|
7
8
|
Any,
|
|
@@ -26,6 +27,29 @@ P = ParamSpec("P")
|
|
|
26
27
|
|
|
27
28
|
|
|
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
|
+
|
|
29
53
|
value: T
|
|
30
54
|
name: str | None
|
|
31
55
|
last_change: int
|
|
@@ -38,16 +62,30 @@ class Signal(Generic[T]):
|
|
|
38
62
|
self.last_change = -1
|
|
39
63
|
|
|
40
64
|
def read(self) -> T:
|
|
65
|
+
"""Read the value, registering a dependency in the current scope.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The current value.
|
|
69
|
+
"""
|
|
41
70
|
rc = REACTIVE_CONTEXT.get()
|
|
42
71
|
if rc.scope is not None:
|
|
43
72
|
rc.scope.register_dep(self)
|
|
44
73
|
return self.value
|
|
45
74
|
|
|
46
75
|
def __call__(self) -> T:
|
|
76
|
+
"""Alias for read().
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The current value.
|
|
80
|
+
"""
|
|
47
81
|
return self.read()
|
|
48
82
|
|
|
49
83
|
def unwrap(self) -> T:
|
|
50
|
-
"""
|
|
84
|
+
"""Alias for read().
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The current value while registering subscriptions.
|
|
88
|
+
"""
|
|
51
89
|
return self.read()
|
|
52
90
|
|
|
53
91
|
def __copy__(self):
|
|
@@ -87,6 +125,13 @@ class Signal(Generic[T]):
|
|
|
87
125
|
return off
|
|
88
126
|
|
|
89
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
|
+
"""
|
|
90
135
|
if values_equal(value, self.value):
|
|
91
136
|
return
|
|
92
137
|
increment_epoch()
|
|
@@ -97,6 +142,33 @@ class Signal(Generic[T]):
|
|
|
97
142
|
|
|
98
143
|
|
|
99
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
|
+
|
|
100
172
|
fn: Callable[..., T_co]
|
|
101
173
|
name: str | None
|
|
102
174
|
dirty: bool
|
|
@@ -128,6 +200,14 @@ class Computed(Generic[T_co]):
|
|
|
128
200
|
)
|
|
129
201
|
|
|
130
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
|
+
"""
|
|
131
211
|
if self.on_stack:
|
|
132
212
|
raise RuntimeError("Circular dependency detected")
|
|
133
213
|
|
|
@@ -141,10 +221,19 @@ class Computed(Generic[T_co]):
|
|
|
141
221
|
return self.value
|
|
142
222
|
|
|
143
223
|
def __call__(self) -> T_co:
|
|
224
|
+
"""Alias for read().
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
The computed value.
|
|
228
|
+
"""
|
|
144
229
|
return self.read()
|
|
145
230
|
|
|
146
231
|
def unwrap(self) -> T_co:
|
|
147
|
-
"""
|
|
232
|
+
"""Alias for read().
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
The computed value while registering subscriptions.
|
|
236
|
+
"""
|
|
148
237
|
return self.read()
|
|
149
238
|
|
|
150
239
|
def __copy__(self):
|
|
@@ -259,9 +348,32 @@ AsyncEffectFn = Callable[[], Awaitable[EffectCleanup | None]]
|
|
|
259
348
|
|
|
260
349
|
|
|
261
350
|
class Effect(Disposable):
|
|
262
|
-
"""
|
|
351
|
+
"""Runs a function when dependencies change.
|
|
352
|
+
|
|
263
353
|
Synchronous effect and base class. Use AsyncEffect for async effects.
|
|
264
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
|
+
```
|
|
265
377
|
"""
|
|
266
378
|
|
|
267
379
|
fn: EffectFn
|
|
@@ -273,7 +385,7 @@ class Effect(Disposable):
|
|
|
273
385
|
_lazy: bool
|
|
274
386
|
_interval: float | None
|
|
275
387
|
_interval_handle: asyncio.TimerHandle | None
|
|
276
|
-
|
|
388
|
+
update_deps: bool
|
|
277
389
|
batch: "Batch | None"
|
|
278
390
|
paused: bool
|
|
279
391
|
|
|
@@ -285,6 +397,7 @@ class Effect(Disposable):
|
|
|
285
397
|
lazy: bool = False,
|
|
286
398
|
on_error: Callable[[Exception], None] | None = None,
|
|
287
399
|
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
400
|
+
update_deps: bool | None = None,
|
|
288
401
|
interval: float | None = None,
|
|
289
402
|
):
|
|
290
403
|
self.fn = fn # type: ignore[assignment]
|
|
@@ -298,7 +411,10 @@ class Effect(Disposable):
|
|
|
298
411
|
self.last_run = -1
|
|
299
412
|
self.scope: Scope | None = None
|
|
300
413
|
self.batch = None
|
|
301
|
-
|
|
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
|
|
302
418
|
self.immediate = immediate
|
|
303
419
|
self._lazy = lazy
|
|
304
420
|
self._interval = interval
|
|
@@ -308,7 +424,7 @@ class Effect(Disposable):
|
|
|
308
424
|
if immediate and lazy:
|
|
309
425
|
raise ValueError("An effect cannot be boht immediate and lazy")
|
|
310
426
|
|
|
311
|
-
# Register explicit dependencies immediately upon initialization
|
|
427
|
+
# Register seeded/explicit dependencies immediately upon initialization
|
|
312
428
|
if deps is not None:
|
|
313
429
|
self.deps = {dep: dep.last_change for dep in deps}
|
|
314
430
|
for dep in deps:
|
|
@@ -331,6 +447,7 @@ class Effect(Disposable):
|
|
|
331
447
|
|
|
332
448
|
@override
|
|
333
449
|
def dispose(self):
|
|
450
|
+
"""Clean up the effect, run cleanup function, remove from dependencies."""
|
|
334
451
|
self.cancel(cancel_interval=True)
|
|
335
452
|
for child in self.children.copy():
|
|
336
453
|
child.dispose()
|
|
@@ -338,7 +455,7 @@ class Effect(Disposable):
|
|
|
338
455
|
self.cleanup_fn()
|
|
339
456
|
for dep in self.deps:
|
|
340
457
|
dep.obs.remove(self)
|
|
341
|
-
if self.parent:
|
|
458
|
+
if self.parent and self in self.parent.children:
|
|
342
459
|
self.parent.children.remove(self)
|
|
343
460
|
|
|
344
461
|
def _schedule_interval(self):
|
|
@@ -362,7 +479,7 @@ class Effect(Disposable):
|
|
|
362
479
|
self._interval_handle = None
|
|
363
480
|
|
|
364
481
|
def pause(self):
|
|
365
|
-
"""Pause the effect
|
|
482
|
+
"""Pause the effect; it won't run when dependencies change."""
|
|
366
483
|
self.paused = True
|
|
367
484
|
self.cancel(cancel_interval=True)
|
|
368
485
|
|
|
@@ -373,6 +490,7 @@ class Effect(Disposable):
|
|
|
373
490
|
self.schedule()
|
|
374
491
|
|
|
375
492
|
def schedule(self):
|
|
493
|
+
"""Schedule the effect to run in the current batch."""
|
|
376
494
|
if self.paused:
|
|
377
495
|
return
|
|
378
496
|
# Immediate effects run right away when scheduled and do not enter a batch
|
|
@@ -423,7 +541,7 @@ class Effect(Disposable):
|
|
|
423
541
|
def __call__(self):
|
|
424
542
|
self.run()
|
|
425
543
|
|
|
426
|
-
def flush(self)
|
|
544
|
+
def flush(self):
|
|
427
545
|
"""If scheduled in a batch, remove and run immediately."""
|
|
428
546
|
if self.batch is not None:
|
|
429
547
|
self.batch.effects.remove(self)
|
|
@@ -447,7 +565,7 @@ class Effect(Disposable):
|
|
|
447
565
|
captured_last_changes: dict[Signal[Any] | Computed[Any], int] | None = None,
|
|
448
566
|
) -> None:
|
|
449
567
|
# Apply captured last_change values at the end for explicit deps
|
|
450
|
-
if self.
|
|
568
|
+
if not self.update_deps:
|
|
451
569
|
assert captured_last_changes is not None
|
|
452
570
|
for dep, last_change in captured_last_changes.items():
|
|
453
571
|
self.deps[dep] = last_change
|
|
@@ -458,8 +576,7 @@ class Effect(Disposable):
|
|
|
458
576
|
child.parent = self
|
|
459
577
|
|
|
460
578
|
prev_deps = set(self.deps)
|
|
461
|
-
|
|
462
|
-
self.deps = scope.deps
|
|
579
|
+
self.deps = scope.deps
|
|
463
580
|
new_deps = set(self.deps)
|
|
464
581
|
add_deps = new_deps - prev_deps
|
|
465
582
|
remove_deps = prev_deps - new_deps
|
|
@@ -476,7 +593,7 @@ class Effect(Disposable):
|
|
|
476
593
|
|
|
477
594
|
def _copy_kwargs(self) -> dict[str, Any]:
|
|
478
595
|
deps = None
|
|
479
|
-
if self.
|
|
596
|
+
if not self.update_deps or (self.update_deps and self.runs == 0 and self.deps):
|
|
480
597
|
deps = list(self.deps.keys())
|
|
481
598
|
return {
|
|
482
599
|
"fn": self.fn,
|
|
@@ -485,6 +602,7 @@ class Effect(Disposable):
|
|
|
485
602
|
"lazy": self._lazy,
|
|
486
603
|
"on_error": self.on_error,
|
|
487
604
|
"deps": deps,
|
|
605
|
+
"update_deps": self.update_deps,
|
|
488
606
|
"interval": self._interval,
|
|
489
607
|
}
|
|
490
608
|
|
|
@@ -507,6 +625,7 @@ class Effect(Disposable):
|
|
|
507
625
|
return new_effect
|
|
508
626
|
|
|
509
627
|
def run(self):
|
|
628
|
+
"""Execute the effect immediately."""
|
|
510
629
|
with Untrack():
|
|
511
630
|
try:
|
|
512
631
|
self._cleanup_before_run()
|
|
@@ -518,7 +637,7 @@ class Effect(Disposable):
|
|
|
518
637
|
execution_epoch = epoch()
|
|
519
638
|
# Capture last_change for explicit deps before running
|
|
520
639
|
captured_last_changes: dict[Signal[Any] | Computed[Any], int] | None = None
|
|
521
|
-
if self.
|
|
640
|
+
if not self.update_deps:
|
|
522
641
|
captured_last_changes = {dep: dep.last_change for dep in self.deps}
|
|
523
642
|
with Scope() as scope:
|
|
524
643
|
# Clear batch *before* running as we may update a signal that causes
|
|
@@ -535,8 +654,63 @@ class Effect(Disposable):
|
|
|
535
654
|
if self._interval is not None and self._interval_handle is None:
|
|
536
655
|
self._schedule_interval()
|
|
537
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
|
+
|
|
538
698
|
|
|
539
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
|
+
|
|
540
714
|
fn: AsyncEffectFn # pyright: ignore[reportIncompatibleMethodOverride]
|
|
541
715
|
batch: None # pyright: ignore[reportIncompatibleVariableOverride]
|
|
542
716
|
_task: asyncio.Task[None] | None
|
|
@@ -549,6 +723,7 @@ class AsyncEffect(Effect):
|
|
|
549
723
|
lazy: bool = False,
|
|
550
724
|
on_error: Callable[[Exception], None] | None = None,
|
|
551
725
|
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
726
|
+
update_deps: bool | None = None,
|
|
552
727
|
interval: float | None = None,
|
|
553
728
|
):
|
|
554
729
|
# Track an async task when running async effects
|
|
@@ -561,6 +736,7 @@ class AsyncEffect(Effect):
|
|
|
561
736
|
lazy=lazy,
|
|
562
737
|
on_error=on_error,
|
|
563
738
|
deps=deps,
|
|
739
|
+
update_deps=update_deps,
|
|
564
740
|
interval=interval,
|
|
565
741
|
)
|
|
566
742
|
|
|
@@ -595,9 +771,10 @@ class AsyncEffect(Effect):
|
|
|
595
771
|
|
|
596
772
|
@override
|
|
597
773
|
def run(self) -> asyncio.Task[Any]: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
598
|
-
"""
|
|
599
|
-
|
|
600
|
-
Returns
|
|
774
|
+
"""Start the async effect, cancelling any previous run.
|
|
775
|
+
|
|
776
|
+
Returns:
|
|
777
|
+
The asyncio.Task running the effect.
|
|
601
778
|
"""
|
|
602
779
|
execution_epoch = epoch()
|
|
603
780
|
|
|
@@ -620,7 +797,7 @@ class AsyncEffect(Effect):
|
|
|
620
797
|
captured_last_changes: dict[Signal[Any] | Computed[Any], int] | None = (
|
|
621
798
|
None
|
|
622
799
|
)
|
|
623
|
-
if self.
|
|
800
|
+
if not self.update_deps:
|
|
624
801
|
captured_last_changes = {dep: dep.last_change for dep in self.deps}
|
|
625
802
|
|
|
626
803
|
with Scope() as scope:
|
|
@@ -669,10 +846,10 @@ class AsyncEffect(Effect):
|
|
|
669
846
|
self._cancel_interval()
|
|
670
847
|
|
|
671
848
|
async def wait(self) -> None:
|
|
672
|
-
"""
|
|
673
|
-
|
|
674
|
-
Does not start a new task if none is running.
|
|
675
|
-
|
|
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.
|
|
676
853
|
"""
|
|
677
854
|
while True:
|
|
678
855
|
if self._task is None or self._task.done():
|
|
@@ -702,30 +879,61 @@ class AsyncEffect(Effect):
|
|
|
702
879
|
self.cleanup_fn()
|
|
703
880
|
for dep in self.deps:
|
|
704
881
|
dep.obs.remove(self)
|
|
705
|
-
if self.parent:
|
|
882
|
+
if self.parent and self in self.parent.children:
|
|
706
883
|
self.parent.children.remove(self)
|
|
707
884
|
|
|
708
885
|
|
|
709
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
|
+
|
|
710
909
|
name: str | None
|
|
910
|
+
flush_id: int
|
|
711
911
|
|
|
712
912
|
def __init__(
|
|
713
913
|
self, effects: list[Effect] | None = None, name: str | None = None
|
|
714
914
|
) -> None:
|
|
715
915
|
self.effects: list[Effect] = effects or []
|
|
716
916
|
self.name = name
|
|
917
|
+
self.flush_id = 0
|
|
717
918
|
self._token: "Token[ReactiveContext] | None" = None
|
|
718
919
|
|
|
719
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
|
+
"""
|
|
720
926
|
if effect not in self.effects:
|
|
721
927
|
self.effects.append(effect)
|
|
722
928
|
|
|
723
929
|
def flush(self):
|
|
930
|
+
"""Run all scheduled effects."""
|
|
724
931
|
token = None
|
|
725
932
|
rc = REACTIVE_CONTEXT.get()
|
|
726
933
|
if rc.batch is not self:
|
|
727
934
|
token = REACTIVE_CONTEXT.set(ReactiveContext(rc.epoch, self, rc.scope))
|
|
728
935
|
|
|
936
|
+
self.flush_id += 1
|
|
729
937
|
MAX_ITERS = 10000
|
|
730
938
|
iters = 0
|
|
731
939
|
|
|
@@ -821,9 +1029,27 @@ class Epoch:
|
|
|
821
1029
|
self.current = current
|
|
822
1030
|
|
|
823
1031
|
|
|
824
|
-
# Used to track dependencies and effects created within a certain function or
|
|
825
|
-
# context.
|
|
826
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
|
+
|
|
827
1053
|
def __init__(self):
|
|
828
1054
|
# Dict preserves insertion order. Maps dependency -> last_change
|
|
829
1055
|
self.deps: dict[Signal[Any] | Computed[Any], int] = {}
|
|
@@ -857,11 +1083,49 @@ class Scope:
|
|
|
857
1083
|
return False
|
|
858
1084
|
|
|
859
1085
|
|
|
860
|
-
class Untrack(Scope):
|
|
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
|
+
...
|
|
861
1100
|
|
|
862
1101
|
|
|
863
|
-
# --- Reactive Context (composite of epoch, batch, scope) ---
|
|
864
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
|
+
|
|
865
1129
|
epoch: Epoch
|
|
866
1130
|
batch: Batch
|
|
867
1131
|
scope: Scope | None
|
|
@@ -902,11 +1166,20 @@ class ReactiveContext:
|
|
|
902
1166
|
return False
|
|
903
1167
|
|
|
904
1168
|
|
|
905
|
-
def epoch():
|
|
1169
|
+
def epoch() -> int:
|
|
1170
|
+
"""Get the current reactive epoch (version counter).
|
|
1171
|
+
|
|
1172
|
+
Returns:
|
|
1173
|
+
The current epoch value.
|
|
1174
|
+
"""
|
|
906
1175
|
return REACTIVE_CONTEXT.get().get_epoch()
|
|
907
1176
|
|
|
908
1177
|
|
|
909
|
-
def increment_epoch():
|
|
1178
|
+
def increment_epoch() -> None:
|
|
1179
|
+
"""Increment the reactive epoch.
|
|
1180
|
+
|
|
1181
|
+
Called automatically on signal writes.
|
|
1182
|
+
"""
|
|
910
1183
|
return REACTIVE_CONTEXT.get().increment_epoch()
|
|
911
1184
|
|
|
912
1185
|
|
|
@@ -917,7 +1190,18 @@ REACTIVE_CONTEXT: ContextVar[ReactiveContext] = ContextVar(
|
|
|
917
1190
|
)
|
|
918
1191
|
|
|
919
1192
|
|
|
920
|
-
def flush_effects():
|
|
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
|
+
"""
|
|
921
1205
|
REACTIVE_CONTEXT.get().batch.flush()
|
|
922
1206
|
|
|
923
1207
|
|