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.
Files changed (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. 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): ...