pulse-framework 0.1.42__py3-none-any.whl → 0.1.43__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 +12 -3
- pulse/decorators.py +8 -172
- pulse/helpers.py +39 -23
- pulse/queries/client.py +462 -0
- pulse/queries/common.py +28 -0
- pulse/queries/effect.py +39 -0
- pulse/queries/infinite_query.py +1157 -0
- pulse/queries/mutation.py +47 -0
- pulse/queries/query.py +560 -53
- pulse/queries/store.py +81 -18
- pulse/reactive.py +95 -20
- pulse/reactive_extensions.py +19 -7
- pulse/state.py +5 -0
- {pulse_framework-0.1.42.dist-info → pulse_framework-0.1.43.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.42.dist-info → pulse_framework-0.1.43.dist-info}/RECORD +17 -15
- pulse/queries/query_observer.py +0 -365
- {pulse_framework-0.1.42.dist-info → pulse_framework-0.1.43.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.42.dist-info → pulse_framework-0.1.43.dist-info}/entry_points.txt +0 -0
pulse/queries/store.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import datetime as dt
|
|
1
2
|
from collections.abc import Awaitable, Callable
|
|
2
|
-
from typing import Any, TypeVar
|
|
3
|
+
from typing import Any, TypeVar, cast
|
|
3
4
|
|
|
4
|
-
from pulse.queries.
|
|
5
|
+
from pulse.queries.common import QueryKey
|
|
6
|
+
from pulse.queries.infinite_query import InfiniteQuery, Page
|
|
7
|
+
from pulse.queries.query import RETRY_DELAY_DEFAULT, Query
|
|
5
8
|
|
|
6
9
|
T = TypeVar("T")
|
|
7
10
|
|
|
@@ -12,9 +15,14 @@ class QueryStore:
|
|
|
12
15
|
"""
|
|
13
16
|
|
|
14
17
|
def __init__(self):
|
|
15
|
-
self._entries: dict[QueryKey, Query[Any]] = {}
|
|
18
|
+
self._entries: dict[QueryKey, Query[Any] | InfiniteQuery[Any, Any]] = {}
|
|
16
19
|
|
|
17
|
-
def
|
|
20
|
+
def items(self):
|
|
21
|
+
"""Iterate over all (key, query) pairs in the store."""
|
|
22
|
+
return self._entries.items()
|
|
23
|
+
|
|
24
|
+
def get_any(self, key: QueryKey) -> Query[Any] | InfiniteQuery[Any, Any] | None:
|
|
25
|
+
"""Get any query (regular or infinite) by key, or None if not found."""
|
|
18
26
|
return self._entries.get(key)
|
|
19
27
|
|
|
20
28
|
def ensure(
|
|
@@ -22,13 +30,19 @@ class QueryStore:
|
|
|
22
30
|
key: QueryKey,
|
|
23
31
|
fetch_fn: Callable[[], Awaitable[T]],
|
|
24
32
|
initial_data: T | None = None,
|
|
33
|
+
initial_data_updated_at: float | dt.datetime | None = None,
|
|
25
34
|
gc_time: float = 300.0,
|
|
26
35
|
retries: int = 3,
|
|
27
36
|
retry_delay: float = RETRY_DELAY_DEFAULT,
|
|
28
37
|
) -> Query[T]:
|
|
29
38
|
# Return existing entry if present
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
existing = self._entries.get(key)
|
|
40
|
+
if existing:
|
|
41
|
+
if isinstance(existing, InfiniteQuery):
|
|
42
|
+
raise TypeError(
|
|
43
|
+
"Query key is already used for an infinite query; cannot reuse for regular query"
|
|
44
|
+
)
|
|
45
|
+
return cast(Query[T], existing)
|
|
32
46
|
|
|
33
47
|
def _on_dispose(e: Query[Any]) -> None:
|
|
34
48
|
if e.key in self._entries and self._entries[e.key] is e:
|
|
@@ -38,6 +52,7 @@ class QueryStore:
|
|
|
38
52
|
key,
|
|
39
53
|
fetch_fn,
|
|
40
54
|
initial_data=initial_data,
|
|
55
|
+
initial_data_updated_at=initial_data_updated_at,
|
|
41
56
|
gc_time=gc_time,
|
|
42
57
|
retries=retries,
|
|
43
58
|
retry_delay=retry_delay,
|
|
@@ -46,15 +61,63 @@ class QueryStore:
|
|
|
46
61
|
self._entries[key] = entry
|
|
47
62
|
return entry
|
|
48
63
|
|
|
49
|
-
def
|
|
50
|
-
|
|
51
|
-
if
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
def get(self, key: QueryKey) -> Query[Any] | None:
|
|
65
|
+
"""
|
|
66
|
+
Get an existing regular query by key, or None if not found.
|
|
67
|
+
"""
|
|
68
|
+
existing = self._entries.get(key)
|
|
69
|
+
if existing and isinstance(existing, InfiniteQuery):
|
|
70
|
+
return None
|
|
71
|
+
return existing
|
|
72
|
+
|
|
73
|
+
def get_infinite(self, key: QueryKey) -> InfiniteQuery[Any, Any] | None:
|
|
74
|
+
"""
|
|
75
|
+
Get an existing infinite query by key, or None if not found.
|
|
76
|
+
"""
|
|
77
|
+
existing = self._entries.get(key)
|
|
78
|
+
if existing and isinstance(existing, InfiniteQuery):
|
|
79
|
+
return existing
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def ensure_infinite(
|
|
83
|
+
self,
|
|
84
|
+
key: QueryKey,
|
|
85
|
+
query_fn: Callable[[Any], Awaitable[Any]],
|
|
86
|
+
*,
|
|
87
|
+
initial_page_param: Any,
|
|
88
|
+
get_next_page_param: Callable[[list[Page[Any, Any]]], Any | None],
|
|
89
|
+
get_previous_page_param: Callable[[list[Page[Any, Any]]], Any | None]
|
|
90
|
+
| None = None,
|
|
91
|
+
max_pages: int = 0,
|
|
92
|
+
initial_data_updated_at: float | dt.datetime | None = None,
|
|
93
|
+
gc_time: float = 300.0,
|
|
94
|
+
retries: int = 3,
|
|
95
|
+
retry_delay: float = RETRY_DELAY_DEFAULT,
|
|
96
|
+
) -> InfiniteQuery[Any, Any]:
|
|
97
|
+
existing = self._entries.get(key)
|
|
98
|
+
if existing:
|
|
99
|
+
if not isinstance(existing, InfiniteQuery):
|
|
100
|
+
raise TypeError(
|
|
101
|
+
"Query key is already used for a regular query; cannot reuse for infinite query"
|
|
102
|
+
)
|
|
103
|
+
return existing
|
|
104
|
+
|
|
105
|
+
def _on_dispose(e: InfiniteQuery[Any, Any]) -> None:
|
|
106
|
+
if e.key in self._entries and self._entries[e.key] is e:
|
|
107
|
+
del self._entries[e.key]
|
|
108
|
+
|
|
109
|
+
entry = InfiniteQuery(
|
|
110
|
+
key,
|
|
111
|
+
query_fn,
|
|
112
|
+
initial_page_param=initial_page_param,
|
|
113
|
+
get_next_page_param=get_next_page_param,
|
|
114
|
+
get_previous_page_param=get_previous_page_param,
|
|
115
|
+
max_pages=max_pages,
|
|
116
|
+
initial_data_updated_at=initial_data_updated_at,
|
|
117
|
+
gc_time=gc_time,
|
|
118
|
+
retries=retries,
|
|
119
|
+
retry_delay=retry_delay,
|
|
120
|
+
on_dispose=_on_dispose,
|
|
121
|
+
)
|
|
122
|
+
self._entries[key] = entry
|
|
123
|
+
return entry
|
pulse/reactive.py
CHANGED
|
@@ -21,6 +21,7 @@ from pulse.helpers import (
|
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
T = TypeVar("T")
|
|
24
|
+
T_co = TypeVar("T_co", covariant=True)
|
|
24
25
|
P = ParamSpec("P")
|
|
25
26
|
|
|
26
27
|
|
|
@@ -95,16 +96,16 @@ class Signal(Generic[T]):
|
|
|
95
96
|
obs.push_change()
|
|
96
97
|
|
|
97
98
|
|
|
98
|
-
class Computed(Generic[
|
|
99
|
-
fn: Callable[...,
|
|
99
|
+
class Computed(Generic[T_co]):
|
|
100
|
+
fn: Callable[..., T_co]
|
|
100
101
|
name: str | None
|
|
101
102
|
dirty: bool
|
|
102
103
|
on_stack: bool
|
|
103
104
|
accepts_prev_value: bool
|
|
104
105
|
|
|
105
|
-
def __init__(self, fn: Callable[...,
|
|
106
|
+
def __init__(self, fn: Callable[..., T_co], name: str | None = None):
|
|
106
107
|
self.fn = fn
|
|
107
|
-
self.value:
|
|
108
|
+
self.value: T_co = None # pyright: ignore[reportAttributeAccessIssue]
|
|
108
109
|
self.name = name
|
|
109
110
|
self.dirty = False
|
|
110
111
|
self.on_stack = False
|
|
@@ -126,7 +127,7 @@ class Computed(Generic[T]):
|
|
|
126
127
|
for p in params
|
|
127
128
|
)
|
|
128
129
|
|
|
129
|
-
def read(self) ->
|
|
130
|
+
def read(self) -> T_co:
|
|
130
131
|
if self.on_stack:
|
|
131
132
|
raise RuntimeError("Circular dependency detected")
|
|
132
133
|
|
|
@@ -139,10 +140,10 @@ class Computed(Generic[T]):
|
|
|
139
140
|
rc.scope.register_dep(self)
|
|
140
141
|
return self.value
|
|
141
142
|
|
|
142
|
-
def __call__(self) ->
|
|
143
|
+
def __call__(self) -> T_co:
|
|
143
144
|
return self.read()
|
|
144
145
|
|
|
145
|
-
def unwrap(self) ->
|
|
146
|
+
def unwrap(self) -> T_co:
|
|
146
147
|
"""Return the current value while registering subscriptions."""
|
|
147
148
|
return self.read()
|
|
148
149
|
|
|
@@ -269,6 +270,8 @@ class Effect(Disposable):
|
|
|
269
270
|
last_run: int
|
|
270
271
|
immediate: bool
|
|
271
272
|
_lazy: bool
|
|
273
|
+
_interval: float | None
|
|
274
|
+
_interval_handle: asyncio.TimerHandle | None
|
|
272
275
|
explicit_deps: bool
|
|
273
276
|
batch: "Batch | None"
|
|
274
277
|
|
|
@@ -280,6 +283,7 @@ class Effect(Disposable):
|
|
|
280
283
|
lazy: bool = False,
|
|
281
284
|
on_error: Callable[[Exception], None] | None = None,
|
|
282
285
|
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
286
|
+
interval: float | None = None,
|
|
283
287
|
):
|
|
284
288
|
self.fn = fn # type: ignore[assignment]
|
|
285
289
|
self.name = name
|
|
@@ -295,6 +299,8 @@ class Effect(Disposable):
|
|
|
295
299
|
self.explicit_deps = deps is not None
|
|
296
300
|
self.immediate = immediate
|
|
297
301
|
self._lazy = lazy
|
|
302
|
+
self._interval = interval
|
|
303
|
+
self._interval_handle = None
|
|
298
304
|
|
|
299
305
|
if immediate and lazy:
|
|
300
306
|
raise ValueError("An effect cannot be boht immediate and lazy")
|
|
@@ -322,7 +328,7 @@ class Effect(Disposable):
|
|
|
322
328
|
|
|
323
329
|
@override
|
|
324
330
|
def dispose(self):
|
|
325
|
-
self.
|
|
331
|
+
self.cancel(cancel_interval=True)
|
|
326
332
|
for child in self.children.copy():
|
|
327
333
|
child.dispose()
|
|
328
334
|
if self.cleanup_fn:
|
|
@@ -332,6 +338,26 @@ class Effect(Disposable):
|
|
|
332
338
|
if self.parent:
|
|
333
339
|
self.parent.children.remove(self)
|
|
334
340
|
|
|
341
|
+
def _schedule_interval(self):
|
|
342
|
+
"""Schedule the next interval run if interval is set."""
|
|
343
|
+
if self._interval is not None and self._interval > 0:
|
|
344
|
+
from pulse.helpers import later
|
|
345
|
+
|
|
346
|
+
self._interval_handle = later(self._interval, self._on_interval)
|
|
347
|
+
|
|
348
|
+
def _on_interval(self):
|
|
349
|
+
"""Called when the interval timer fires."""
|
|
350
|
+
if self._interval is not None:
|
|
351
|
+
# Run directly instead of scheduling - interval runs are unconditional
|
|
352
|
+
self.run()
|
|
353
|
+
self._schedule_interval()
|
|
354
|
+
|
|
355
|
+
def _cancel_interval(self):
|
|
356
|
+
"""Cancel the interval timer."""
|
|
357
|
+
if self._interval_handle is not None:
|
|
358
|
+
self._interval_handle.cancel()
|
|
359
|
+
self._interval_handle = None
|
|
360
|
+
|
|
335
361
|
def schedule(self):
|
|
336
362
|
# Immediate effects run right away when scheduled and do not enter a batch
|
|
337
363
|
if self.immediate:
|
|
@@ -342,12 +368,26 @@ class Effect(Disposable):
|
|
|
342
368
|
batch.register_effect(self)
|
|
343
369
|
self.batch = batch
|
|
344
370
|
|
|
345
|
-
def
|
|
371
|
+
def cancel(self, cancel_interval: bool = True):
|
|
372
|
+
"""
|
|
373
|
+
Cancel the effect. For sync effects, removes from batch.
|
|
374
|
+
For async effects (override), also cancels the running task.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
cancel_interval: If True (default), also cancels the interval timer.
|
|
378
|
+
"""
|
|
346
379
|
if self.batch is not None:
|
|
347
380
|
self.batch.effects.remove(self)
|
|
348
381
|
self.batch = None
|
|
382
|
+
if cancel_interval:
|
|
383
|
+
self._cancel_interval()
|
|
349
384
|
|
|
350
385
|
def push_change(self):
|
|
386
|
+
# Short-circuit if already scheduled in a batch.
|
|
387
|
+
# This avoids redundant schedule() calls and O(n) list checks
|
|
388
|
+
# when the same effect is reached through multiple dependency paths.
|
|
389
|
+
if self.batch is not None:
|
|
390
|
+
return
|
|
351
391
|
self.schedule()
|
|
352
392
|
|
|
353
393
|
def should_run(self):
|
|
@@ -427,6 +467,7 @@ class Effect(Disposable):
|
|
|
427
467
|
"lazy": self._lazy,
|
|
428
468
|
"on_error": self.on_error,
|
|
429
469
|
"deps": deps,
|
|
470
|
+
"interval": self._interval,
|
|
430
471
|
}
|
|
431
472
|
|
|
432
473
|
def __copy__(self):
|
|
@@ -472,12 +513,16 @@ class Effect(Disposable):
|
|
|
472
513
|
self.runs += 1
|
|
473
514
|
self.last_run = execution_epoch
|
|
474
515
|
self._apply_scope_results(scope, captured_last_changes)
|
|
516
|
+
# Start/restart interval if set and not currently scheduled
|
|
517
|
+
if self._interval is not None and self._interval_handle is None:
|
|
518
|
+
self._schedule_interval()
|
|
475
519
|
|
|
476
520
|
|
|
477
521
|
class AsyncEffect(Effect):
|
|
478
522
|
fn: AsyncEffectFn # pyright: ignore[reportIncompatibleMethodOverride]
|
|
479
523
|
batch: None # pyright: ignore[reportIncompatibleVariableOverride]
|
|
480
524
|
_task: asyncio.Task[None] | None
|
|
525
|
+
_task_started: bool
|
|
481
526
|
|
|
482
527
|
def __init__(
|
|
483
528
|
self,
|
|
@@ -486,9 +531,11 @@ class AsyncEffect(Effect):
|
|
|
486
531
|
lazy: bool = False,
|
|
487
532
|
on_error: Callable[[Exception], None] | None = None,
|
|
488
533
|
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
534
|
+
interval: float | None = None,
|
|
489
535
|
):
|
|
490
536
|
# Track an async task when running async effects
|
|
491
537
|
self._task = None
|
|
538
|
+
self._task_started = False
|
|
492
539
|
super().__init__(
|
|
493
540
|
fn=fn, # pyright: ignore[reportArgumentType]
|
|
494
541
|
name=name,
|
|
@@ -496,8 +543,19 @@ class AsyncEffect(Effect):
|
|
|
496
543
|
lazy=lazy,
|
|
497
544
|
on_error=on_error,
|
|
498
545
|
deps=deps,
|
|
546
|
+
interval=interval,
|
|
499
547
|
)
|
|
500
548
|
|
|
549
|
+
@override
|
|
550
|
+
def push_change(self):
|
|
551
|
+
# Short-circuit if task exists but hasn't started executing yet.
|
|
552
|
+
# This avoids cancelling and recreating tasks multiple times when reached
|
|
553
|
+
# through multiple dependency paths before the event loop runs.
|
|
554
|
+
# Once the task starts running, new push_change calls will cancel and restart.
|
|
555
|
+
if self._task is not None and not self._task.done() and not self._task_started:
|
|
556
|
+
return
|
|
557
|
+
self.schedule()
|
|
558
|
+
|
|
501
559
|
@override
|
|
502
560
|
def schedule(self):
|
|
503
561
|
"""
|
|
@@ -525,13 +583,14 @@ class AsyncEffect(Effect):
|
|
|
525
583
|
"""
|
|
526
584
|
execution_epoch = epoch()
|
|
527
585
|
|
|
528
|
-
# Cancel any previous run still in flight
|
|
529
|
-
self.cancel()
|
|
586
|
+
# Cancel any previous run still in flight, but preserve the interval
|
|
587
|
+
self.cancel(cancel_interval=False)
|
|
530
588
|
this_task: asyncio.Task[None] | None = None
|
|
531
589
|
|
|
532
590
|
async def _runner():
|
|
533
591
|
nonlocal execution_epoch, this_task
|
|
534
592
|
try:
|
|
593
|
+
self._task_started = True
|
|
535
594
|
# Perform cleanups in the new task
|
|
536
595
|
with Untrack():
|
|
537
596
|
try:
|
|
@@ -558,10 +617,14 @@ class AsyncEffect(Effect):
|
|
|
558
617
|
self.runs += 1
|
|
559
618
|
self.last_run = execution_epoch
|
|
560
619
|
self._apply_scope_results(scope, captured_last_changes)
|
|
620
|
+
# Start/restart interval if set and not currently scheduled
|
|
621
|
+
if self._interval is not None and self._interval_handle is None:
|
|
622
|
+
self._schedule_interval()
|
|
561
623
|
finally:
|
|
562
624
|
# Clear the task reference when it finishes
|
|
563
625
|
if self._task is this_task:
|
|
564
626
|
self._task = None
|
|
627
|
+
self._task_started = False
|
|
565
628
|
|
|
566
629
|
this_task = create_task(_runner(), name=f"effect:{self.name or 'unnamed'}")
|
|
567
630
|
self._task = this_task
|
|
@@ -571,23 +634,34 @@ class AsyncEffect(Effect):
|
|
|
571
634
|
async def __call__(self): # pyright: ignore[reportIncompatibleMethodOverride]
|
|
572
635
|
await self.run()
|
|
573
636
|
|
|
574
|
-
|
|
575
|
-
|
|
637
|
+
@override
|
|
638
|
+
def cancel(self, cancel_interval: bool = True) -> None:
|
|
639
|
+
"""
|
|
640
|
+
Cancel the async effect. Cancels the running task and optionally the interval.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
cancel_interval: If True (default), also cancels the interval timer.
|
|
644
|
+
"""
|
|
576
645
|
if self._task:
|
|
577
646
|
t = self._task
|
|
578
647
|
self._task = None
|
|
579
648
|
if not t.cancelled():
|
|
580
649
|
t.cancel()
|
|
650
|
+
if cancel_interval:
|
|
651
|
+
self._cancel_interval()
|
|
581
652
|
|
|
582
653
|
async def wait(self) -> None:
|
|
583
654
|
"""
|
|
584
|
-
Wait until the completion of the current task if it's already running
|
|
585
|
-
|
|
586
|
-
the new task
|
|
655
|
+
Wait until the completion of the current task if it's already running.
|
|
656
|
+
Does not start a new task if none is running.
|
|
657
|
+
If the task is cancelled while waiting, waits for a new task if one is started.
|
|
587
658
|
"""
|
|
588
659
|
while True:
|
|
660
|
+
if self._task is None or self._task.done():
|
|
661
|
+
# No task running, return immediately
|
|
662
|
+
return
|
|
589
663
|
try:
|
|
590
|
-
await
|
|
664
|
+
await self._task
|
|
591
665
|
return
|
|
592
666
|
except asyncio.CancelledError:
|
|
593
667
|
# If wait() itself is cancelled, propagate it
|
|
@@ -596,13 +670,14 @@ class AsyncEffect(Effect):
|
|
|
596
670
|
current_task.cancelling() > 0 or current_task.cancelled()
|
|
597
671
|
):
|
|
598
672
|
raise
|
|
599
|
-
# Effect task was cancelled,
|
|
673
|
+
# Effect task was cancelled, check if a new task was started
|
|
674
|
+
# and continue waiting if so
|
|
600
675
|
continue
|
|
601
676
|
|
|
602
677
|
@override
|
|
603
678
|
def dispose(self):
|
|
604
|
-
# Run children cleanups first, then cancel in-flight task
|
|
605
|
-
self.cancel()
|
|
679
|
+
# Run children cleanups first, then cancel in-flight task and interval
|
|
680
|
+
self.cancel(cancel_interval=True)
|
|
606
681
|
for child in self.children.copy():
|
|
607
682
|
child.dispose()
|
|
608
683
|
if self.cleanup_fn:
|
pulse/reactive_extensions.py
CHANGED
|
@@ -405,18 +405,19 @@ class ReactiveList(list[T1]):
|
|
|
405
405
|
- Setting an index writes to that index's Signal
|
|
406
406
|
- Structural operations (append/insert/pop/remove/clear/extend/sort/reverse/slice assigns)
|
|
407
407
|
trigger a structural version Signal so consumers can listen for changes that affect layout
|
|
408
|
-
- Iteration
|
|
408
|
+
- Iteration subscribes to all item signals and structural changes
|
|
409
|
+
- len() subscribes to structural changes
|
|
409
410
|
"""
|
|
410
411
|
|
|
411
412
|
__slots__: tuple[str, ...] = ("_signals", "_structure")
|
|
412
413
|
|
|
413
|
-
def __init__(self, initial: Iterable[
|
|
414
|
+
def __init__(self, initial: Iterable[T1] | None = None) -> None:
|
|
414
415
|
super().__init__()
|
|
415
|
-
self._signals: list[Signal[
|
|
416
|
+
self._signals: list[Signal[T1]] = []
|
|
416
417
|
self._structure: Signal[int] = Signal(0)
|
|
417
418
|
if initial:
|
|
418
419
|
for item in initial:
|
|
419
|
-
v =
|
|
420
|
+
v = reactive(item)
|
|
420
421
|
self._signals.append(Signal(v))
|
|
421
422
|
super().append(v)
|
|
422
423
|
|
|
@@ -617,7 +618,8 @@ class ReactiveList(list[T1]):
|
|
|
617
618
|
@override
|
|
618
619
|
def __iter__(self) -> Iterator[T1]:
|
|
619
620
|
self._structure.read()
|
|
620
|
-
|
|
621
|
+
for sig in self._signals:
|
|
622
|
+
yield sig.read()
|
|
621
623
|
|
|
622
624
|
def __copy__(self):
|
|
623
625
|
result = type(self)()
|
|
@@ -640,7 +642,7 @@ class ReactiveSet(set[T1]):
|
|
|
640
642
|
|
|
641
643
|
- `x in s` reads a membership Signal for element `x`
|
|
642
644
|
- Mutations update membership Signals for affected elements
|
|
643
|
-
- Iteration
|
|
645
|
+
- Iteration subscribes to membership signals for all elements
|
|
644
646
|
"""
|
|
645
647
|
|
|
646
648
|
__slots__: tuple[str, ...] = ("_signals",)
|
|
@@ -664,6 +666,12 @@ class ReactiveSet(set[T1]):
|
|
|
664
666
|
sig = self._signals[element]
|
|
665
667
|
return bool(sig.read())
|
|
666
668
|
|
|
669
|
+
@override
|
|
670
|
+
def __iter__(self) -> Iterator[T1]:
|
|
671
|
+
# Subscribe to membership signals and return present elements
|
|
672
|
+
present = [elem for elem, sig in self._signals.items() if sig.read()]
|
|
673
|
+
return iter(present)
|
|
674
|
+
|
|
667
675
|
@override
|
|
668
676
|
def add(self, element: T1) -> None:
|
|
669
677
|
element = reactive(element)
|
|
@@ -1068,7 +1076,11 @@ def unwrap(value: _Any, untrack: bool = False) -> _Any:
|
|
|
1068
1076
|
return {k: _unwrap(val) for k, val in v.items()}
|
|
1069
1077
|
if isinstance(v, Sequence) and not isinstance(v, (str, bytes, bytearray)):
|
|
1070
1078
|
if isinstance(v, tuple):
|
|
1071
|
-
|
|
1079
|
+
# Preserve namedtuple types
|
|
1080
|
+
if hasattr(v, "_fields"):
|
|
1081
|
+
return type(v)(*(_unwrap(val) for val in v))
|
|
1082
|
+
else:
|
|
1083
|
+
return tuple(_unwrap(val) for val in v)
|
|
1072
1084
|
return [_unwrap(val) for val in v]
|
|
1073
1085
|
if isinstance(v, set):
|
|
1074
1086
|
return {_unwrap(val) for val in v}
|
pulse/state.py
CHANGED
|
@@ -80,6 +80,7 @@ class StateEffect(Generic[T], InitializableProperty):
|
|
|
80
80
|
on_error: "Callable[[Exception], None] | None"
|
|
81
81
|
lazy: bool
|
|
82
82
|
deps: "list[Signal[Any] | Computed[Any]] | None"
|
|
83
|
+
interval: float | None
|
|
83
84
|
|
|
84
85
|
def __init__(
|
|
85
86
|
self,
|
|
@@ -89,6 +90,7 @@ class StateEffect(Generic[T], InitializableProperty):
|
|
|
89
90
|
lazy: bool = False,
|
|
90
91
|
on_error: "Callable[[Exception], None] | None" = None,
|
|
91
92
|
deps: "list[Signal[Any] | Computed[Any]] | None" = None,
|
|
93
|
+
interval: float | None = None,
|
|
92
94
|
):
|
|
93
95
|
self.fn = fn
|
|
94
96
|
self.name = name
|
|
@@ -96,6 +98,7 @@ class StateEffect(Generic[T], InitializableProperty):
|
|
|
96
98
|
self.on_error = on_error
|
|
97
99
|
self.lazy = lazy
|
|
98
100
|
self.deps = deps
|
|
101
|
+
self.interval = interval
|
|
99
102
|
|
|
100
103
|
@override
|
|
101
104
|
def initialize(self, state: "State", name: str):
|
|
@@ -108,6 +111,7 @@ class StateEffect(Generic[T], InitializableProperty):
|
|
|
108
111
|
lazy=self.lazy,
|
|
109
112
|
on_error=self.on_error,
|
|
110
113
|
deps=self.deps,
|
|
114
|
+
interval=self.interval,
|
|
111
115
|
)
|
|
112
116
|
else:
|
|
113
117
|
effect = Effect(
|
|
@@ -117,6 +121,7 @@ class StateEffect(Generic[T], InitializableProperty):
|
|
|
117
121
|
lazy=self.lazy,
|
|
118
122
|
on_error=self.on_error,
|
|
119
123
|
deps=self.deps,
|
|
124
|
+
interval=self.interval,
|
|
120
125
|
)
|
|
121
126
|
setattr(state, name, effect)
|
|
122
127
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
pulse/__init__.py,sha256=
|
|
1
|
+
pulse/__init__.py,sha256=Acb_t45WP9HsPBRBtKsx5rj-u-pl8gRmdwsKKVPokK4,32721
|
|
2
2
|
pulse/app.py,sha256=cVEqazFcgSnmZSxqqz6HWk2QsI8rnKbO7Y_L88BcHSc,32082
|
|
3
3
|
pulse/channel.py,sha256=d9eLxgyB0P9UBVkPkXV7MHkC4LWED1Cq3GKsEu_SYy4,13056
|
|
4
4
|
pulse/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -27,10 +27,10 @@ pulse/components/react_router.py,sha256=TbRec-NVliUqrvAMeFXCrnDWV1rh6TGTPfRhqLuL
|
|
|
27
27
|
pulse/context.py,sha256=fMK6GdQY4q_3452v5DJli2f2_urVihnpzb-O-O9cJ1Q,1734
|
|
28
28
|
pulse/cookies.py,sha256=c7ua1Lv6mNe1nYnA4SFVvewvRQAbYy9fN5G3Hr_Dr5c,5000
|
|
29
29
|
pulse/css.py,sha256=-FyQQQ0EZI1Ins30qiF3l4z9yDb1V9qWuJKWxHcKGkw,3910
|
|
30
|
-
pulse/decorators.py,sha256=
|
|
30
|
+
pulse/decorators.py,sha256=ywNgLN6VFcKOM5fbFdUUzh-DWk4BuSXdD1BTfd1N-0U,4827
|
|
31
31
|
pulse/env.py,sha256=p3XI8KG1ZCcXPD3LJP7fW8JPYfyvoYY5ENwae2o0PiA,2889
|
|
32
32
|
pulse/form.py,sha256=P7W8guUdGbgqNOk8cSUCWuY6qWre0me6_fypv1qOvqw,8987
|
|
33
|
-
pulse/helpers.py,sha256=
|
|
33
|
+
pulse/helpers.py,sha256=aF0SD3z2oGWFRt3BUhoI8JU5SwDAy1a4eg9BaW0LTUg,13745
|
|
34
34
|
pulse/hooks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
35
35
|
pulse/hooks/core.py,sha256=JTZbVxNOEs_GAeK6Bh6hemSTkgwZPtEi_wt55cvOdik,7381
|
|
36
36
|
pulse/hooks/effects.py,sha256=CQvt5viAweGLSxaGGlWm155GlEQiwQnGussw7OfiCGc,2393
|
|
@@ -52,26 +52,28 @@ pulse/plugin.py,sha256=RfGl6Vtr7VRHb8bp4Ob4dOX9dVzvc4Riu7HWnStMPpk,580
|
|
|
52
52
|
pulse/proxy.py,sha256=zh4v5lmYNg5IBE_xdHHmGPwbMQNSXb2npeLXvw_O1Oc,6591
|
|
53
53
|
pulse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
54
54
|
pulse/queries/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
55
|
-
pulse/queries/
|
|
56
|
-
pulse/queries/
|
|
57
|
-
pulse/queries/
|
|
58
|
-
pulse/queries/
|
|
59
|
-
pulse/queries/
|
|
55
|
+
pulse/queries/client.py,sha256=hZB3rAfH_fl_6ld_s9z81Vxvt8Cg0gXWU4Gj1qAL0LE,15254
|
|
56
|
+
pulse/queries/common.py,sha256=Cr_NV0dWz5DQ7Qg771jvUms1o2-EnTYqjZJe4tVeoVk,1160
|
|
57
|
+
pulse/queries/effect.py,sha256=8U7iAo3B4WB5lIcT6bE7YRnyfEYTLzNESq9KdDQsVYA,835
|
|
58
|
+
pulse/queries/infinite_query.py,sha256=XBMuVApwBnzsB-toQfsyYRziMwtQhQq8wjtBzCq0qv4,36098
|
|
59
|
+
pulse/queries/mutation.py,sha256=_WVRFU4BwkHrHQjEZc2mjyZTUx9Pad73xFWaFPF7I1Y,5315
|
|
60
|
+
pulse/queries/query.py,sha256=MEKV8eKANFQmiWWs12ytgD9szYNbR2226EW_DkoFiNo,22037
|
|
61
|
+
pulse/queries/store.py,sha256=p_WO7X-REytwkEyGjJi6XDv9tlrJX0v6E1sk1bBrPqM,3541
|
|
60
62
|
pulse/react_component.py,sha256=hPibKBEkVdpBKNSpMQ6bZ-7GnJQcNQwcw2SvfY1chHA,26026
|
|
61
|
-
pulse/reactive.py,sha256=
|
|
62
|
-
pulse/reactive_extensions.py,sha256=
|
|
63
|
+
pulse/reactive.py,sha256=0CqzeowZHAENMxM2PFhi_Q6KkDzG844JofzDk0Wxckw,23961
|
|
64
|
+
pulse/reactive_extensions.py,sha256=WAx4hlB1ByZLFVpgtRnaWXAQ3N2QQplf647oQXDL5vg,31901
|
|
63
65
|
pulse/render_session.py,sha256=kqLfZ9AxCrB2mIJqegATL1KA7CI-LZSBQwRYr7Uxo9g,14581
|
|
64
66
|
pulse/renderer.py,sha256=dJiX9VeHr9kC1UBw5oaKB8Mv-3OCMGTrHiKgLJ5FL50,16759
|
|
65
67
|
pulse/request.py,sha256=sPsSRWi5KvReSPBLIs_kzqomn1wlRk1BTLZ5s0chQr4,4979
|
|
66
68
|
pulse/routing.py,sha256=RlrGHyK4F28_zUHMYNeKp4pNbvqrt4GY4t5xNdhzunI,13926
|
|
67
69
|
pulse/serializer.py,sha256=8RAITNoSNm5-U38elHpWmkBpcM_rxZFMCluJSfldfk4,5420
|
|
68
|
-
pulse/state.py,sha256=
|
|
70
|
+
pulse/state.py,sha256=ikQbK4R8PieV96qd4uWREUvs0jXo9sCapawY7i6oCYo,10776
|
|
69
71
|
pulse/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
70
72
|
pulse/types/event_handler.py,sha256=tfKa6OEA5XvzuYbllQZJ03ooN7rGSYOtaPBstSL4OLU,1642
|
|
71
73
|
pulse/user_session.py,sha256=Nn9ZZha1Rruw31OSoK14QaEL0erGVFbryFhJYrtMZsQ,7599
|
|
72
74
|
pulse/vdom.py,sha256=1UAjOYSmpdZeSVELqejh47Jer4mA73T_q2HtAogOphs,12514
|
|
73
75
|
pulse/version.py,sha256=711vaM1jVIQPgkisGgKZqwmw019qZIsc_QTae75K2pg,1895
|
|
74
|
-
pulse_framework-0.1.
|
|
75
|
-
pulse_framework-0.1.
|
|
76
|
-
pulse_framework-0.1.
|
|
77
|
-
pulse_framework-0.1.
|
|
76
|
+
pulse_framework-0.1.43.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
77
|
+
pulse_framework-0.1.43.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
|
|
78
|
+
pulse_framework-0.1.43.dist-info/METADATA,sha256=sXMEvuwI6Pa_bnIgQyYUdhhnBpEXXrtQFWtXG13xMls,580
|
|
79
|
+
pulse_framework-0.1.43.dist-info/RECORD,,
|