pulse-framework 0.1.41__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 +17 -3
- pulse/context.py +3 -2
- pulse/decorators.py +8 -172
- pulse/helpers.py +39 -23
- pulse/hooks/core.py +4 -6
- pulse/hooks/init.py +460 -0
- 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/react_component.py +2 -1
- pulse/reactive.py +102 -23
- pulse/reactive_extensions.py +19 -7
- pulse/state.py +5 -0
- pulse/vdom.py +3 -1
- {pulse_framework-0.1.41.dist-info → pulse_framework-0.1.43.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.41.dist-info → pulse_framework-0.1.43.dist-info}/RECORD +22 -19
- pulse/queries/query_observer.py +0 -365
- {pulse_framework-0.1.41.dist-info → pulse_framework-0.1.43.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.41.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/react_component.py
CHANGED
|
@@ -535,10 +535,11 @@ class ComponentRegistry:
|
|
|
535
535
|
exc_type: type[BaseException] | None,
|
|
536
536
|
exc_val: BaseException | None,
|
|
537
537
|
exc_tb: Any,
|
|
538
|
-
):
|
|
538
|
+
) -> Literal[False]:
|
|
539
539
|
if self._token:
|
|
540
540
|
COMPONENT_REGISTRY.reset(self._token)
|
|
541
541
|
self._token = None
|
|
542
|
+
return False
|
|
542
543
|
|
|
543
544
|
|
|
544
545
|
COMPONENT_REGISTRY: ContextVar[ComponentRegistry] = ContextVar(
|
pulse/reactive.py
CHANGED
|
@@ -6,6 +6,7 @@ from contextvars import ContextVar, Token
|
|
|
6
6
|
from typing import (
|
|
7
7
|
Any,
|
|
8
8
|
Generic,
|
|
9
|
+
Literal,
|
|
9
10
|
ParamSpec,
|
|
10
11
|
TypeVar,
|
|
11
12
|
override,
|
|
@@ -20,6 +21,7 @@ from pulse.helpers import (
|
|
|
20
21
|
)
|
|
21
22
|
|
|
22
23
|
T = TypeVar("T")
|
|
24
|
+
T_co = TypeVar("T_co", covariant=True)
|
|
23
25
|
P = ParamSpec("P")
|
|
24
26
|
|
|
25
27
|
|
|
@@ -94,16 +96,16 @@ class Signal(Generic[T]):
|
|
|
94
96
|
obs.push_change()
|
|
95
97
|
|
|
96
98
|
|
|
97
|
-
class Computed(Generic[
|
|
98
|
-
fn: Callable[...,
|
|
99
|
+
class Computed(Generic[T_co]):
|
|
100
|
+
fn: Callable[..., T_co]
|
|
99
101
|
name: str | None
|
|
100
102
|
dirty: bool
|
|
101
103
|
on_stack: bool
|
|
102
104
|
accepts_prev_value: bool
|
|
103
105
|
|
|
104
|
-
def __init__(self, fn: Callable[...,
|
|
106
|
+
def __init__(self, fn: Callable[..., T_co], name: str | None = None):
|
|
105
107
|
self.fn = fn
|
|
106
|
-
self.value:
|
|
108
|
+
self.value: T_co = None # pyright: ignore[reportAttributeAccessIssue]
|
|
107
109
|
self.name = name
|
|
108
110
|
self.dirty = False
|
|
109
111
|
self.on_stack = False
|
|
@@ -125,7 +127,7 @@ class Computed(Generic[T]):
|
|
|
125
127
|
for p in params
|
|
126
128
|
)
|
|
127
129
|
|
|
128
|
-
def read(self) ->
|
|
130
|
+
def read(self) -> T_co:
|
|
129
131
|
if self.on_stack:
|
|
130
132
|
raise RuntimeError("Circular dependency detected")
|
|
131
133
|
|
|
@@ -138,10 +140,10 @@ class Computed(Generic[T]):
|
|
|
138
140
|
rc.scope.register_dep(self)
|
|
139
141
|
return self.value
|
|
140
142
|
|
|
141
|
-
def __call__(self) ->
|
|
143
|
+
def __call__(self) -> T_co:
|
|
142
144
|
return self.read()
|
|
143
145
|
|
|
144
|
-
def unwrap(self) ->
|
|
146
|
+
def unwrap(self) -> T_co:
|
|
145
147
|
"""Return the current value while registering subscriptions."""
|
|
146
148
|
return self.read()
|
|
147
149
|
|
|
@@ -268,6 +270,8 @@ class Effect(Disposable):
|
|
|
268
270
|
last_run: int
|
|
269
271
|
immediate: bool
|
|
270
272
|
_lazy: bool
|
|
273
|
+
_interval: float | None
|
|
274
|
+
_interval_handle: asyncio.TimerHandle | None
|
|
271
275
|
explicit_deps: bool
|
|
272
276
|
batch: "Batch | None"
|
|
273
277
|
|
|
@@ -279,6 +283,7 @@ class Effect(Disposable):
|
|
|
279
283
|
lazy: bool = False,
|
|
280
284
|
on_error: Callable[[Exception], None] | None = None,
|
|
281
285
|
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
286
|
+
interval: float | None = None,
|
|
282
287
|
):
|
|
283
288
|
self.fn = fn # type: ignore[assignment]
|
|
284
289
|
self.name = name
|
|
@@ -294,6 +299,8 @@ class Effect(Disposable):
|
|
|
294
299
|
self.explicit_deps = deps is not None
|
|
295
300
|
self.immediate = immediate
|
|
296
301
|
self._lazy = lazy
|
|
302
|
+
self._interval = interval
|
|
303
|
+
self._interval_handle = None
|
|
297
304
|
|
|
298
305
|
if immediate and lazy:
|
|
299
306
|
raise ValueError("An effect cannot be boht immediate and lazy")
|
|
@@ -321,7 +328,7 @@ class Effect(Disposable):
|
|
|
321
328
|
|
|
322
329
|
@override
|
|
323
330
|
def dispose(self):
|
|
324
|
-
self.
|
|
331
|
+
self.cancel(cancel_interval=True)
|
|
325
332
|
for child in self.children.copy():
|
|
326
333
|
child.dispose()
|
|
327
334
|
if self.cleanup_fn:
|
|
@@ -331,6 +338,26 @@ class Effect(Disposable):
|
|
|
331
338
|
if self.parent:
|
|
332
339
|
self.parent.children.remove(self)
|
|
333
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
|
+
|
|
334
361
|
def schedule(self):
|
|
335
362
|
# Immediate effects run right away when scheduled and do not enter a batch
|
|
336
363
|
if self.immediate:
|
|
@@ -341,12 +368,26 @@ class Effect(Disposable):
|
|
|
341
368
|
batch.register_effect(self)
|
|
342
369
|
self.batch = batch
|
|
343
370
|
|
|
344
|
-
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
|
+
"""
|
|
345
379
|
if self.batch is not None:
|
|
346
380
|
self.batch.effects.remove(self)
|
|
347
381
|
self.batch = None
|
|
382
|
+
if cancel_interval:
|
|
383
|
+
self._cancel_interval()
|
|
348
384
|
|
|
349
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
|
|
350
391
|
self.schedule()
|
|
351
392
|
|
|
352
393
|
def should_run(self):
|
|
@@ -426,6 +467,7 @@ class Effect(Disposable):
|
|
|
426
467
|
"lazy": self._lazy,
|
|
427
468
|
"on_error": self.on_error,
|
|
428
469
|
"deps": deps,
|
|
470
|
+
"interval": self._interval,
|
|
429
471
|
}
|
|
430
472
|
|
|
431
473
|
def __copy__(self):
|
|
@@ -471,12 +513,16 @@ class Effect(Disposable):
|
|
|
471
513
|
self.runs += 1
|
|
472
514
|
self.last_run = execution_epoch
|
|
473
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()
|
|
474
519
|
|
|
475
520
|
|
|
476
521
|
class AsyncEffect(Effect):
|
|
477
522
|
fn: AsyncEffectFn # pyright: ignore[reportIncompatibleMethodOverride]
|
|
478
523
|
batch: None # pyright: ignore[reportIncompatibleVariableOverride]
|
|
479
524
|
_task: asyncio.Task[None] | None
|
|
525
|
+
_task_started: bool
|
|
480
526
|
|
|
481
527
|
def __init__(
|
|
482
528
|
self,
|
|
@@ -485,9 +531,11 @@ class AsyncEffect(Effect):
|
|
|
485
531
|
lazy: bool = False,
|
|
486
532
|
on_error: Callable[[Exception], None] | None = None,
|
|
487
533
|
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
534
|
+
interval: float | None = None,
|
|
488
535
|
):
|
|
489
536
|
# Track an async task when running async effects
|
|
490
537
|
self._task = None
|
|
538
|
+
self._task_started = False
|
|
491
539
|
super().__init__(
|
|
492
540
|
fn=fn, # pyright: ignore[reportArgumentType]
|
|
493
541
|
name=name,
|
|
@@ -495,8 +543,19 @@ class AsyncEffect(Effect):
|
|
|
495
543
|
lazy=lazy,
|
|
496
544
|
on_error=on_error,
|
|
497
545
|
deps=deps,
|
|
546
|
+
interval=interval,
|
|
498
547
|
)
|
|
499
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
|
+
|
|
500
559
|
@override
|
|
501
560
|
def schedule(self):
|
|
502
561
|
"""
|
|
@@ -524,13 +583,14 @@ class AsyncEffect(Effect):
|
|
|
524
583
|
"""
|
|
525
584
|
execution_epoch = epoch()
|
|
526
585
|
|
|
527
|
-
# Cancel any previous run still in flight
|
|
528
|
-
self.cancel()
|
|
586
|
+
# Cancel any previous run still in flight, but preserve the interval
|
|
587
|
+
self.cancel(cancel_interval=False)
|
|
529
588
|
this_task: asyncio.Task[None] | None = None
|
|
530
589
|
|
|
531
590
|
async def _runner():
|
|
532
591
|
nonlocal execution_epoch, this_task
|
|
533
592
|
try:
|
|
593
|
+
self._task_started = True
|
|
534
594
|
# Perform cleanups in the new task
|
|
535
595
|
with Untrack():
|
|
536
596
|
try:
|
|
@@ -557,10 +617,14 @@ class AsyncEffect(Effect):
|
|
|
557
617
|
self.runs += 1
|
|
558
618
|
self.last_run = execution_epoch
|
|
559
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()
|
|
560
623
|
finally:
|
|
561
624
|
# Clear the task reference when it finishes
|
|
562
625
|
if self._task is this_task:
|
|
563
626
|
self._task = None
|
|
627
|
+
self._task_started = False
|
|
564
628
|
|
|
565
629
|
this_task = create_task(_runner(), name=f"effect:{self.name or 'unnamed'}")
|
|
566
630
|
self._task = this_task
|
|
@@ -570,23 +634,34 @@ class AsyncEffect(Effect):
|
|
|
570
634
|
async def __call__(self): # pyright: ignore[reportIncompatibleMethodOverride]
|
|
571
635
|
await self.run()
|
|
572
636
|
|
|
573
|
-
|
|
574
|
-
|
|
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
|
+
"""
|
|
575
645
|
if self._task:
|
|
576
646
|
t = self._task
|
|
577
647
|
self._task = None
|
|
578
648
|
if not t.cancelled():
|
|
579
649
|
t.cancel()
|
|
650
|
+
if cancel_interval:
|
|
651
|
+
self._cancel_interval()
|
|
580
652
|
|
|
581
653
|
async def wait(self) -> None:
|
|
582
654
|
"""
|
|
583
|
-
Wait until the completion of the current task if it's already running
|
|
584
|
-
|
|
585
|
-
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.
|
|
586
658
|
"""
|
|
587
659
|
while True:
|
|
660
|
+
if self._task is None or self._task.done():
|
|
661
|
+
# No task running, return immediately
|
|
662
|
+
return
|
|
588
663
|
try:
|
|
589
|
-
await
|
|
664
|
+
await self._task
|
|
590
665
|
return
|
|
591
666
|
except asyncio.CancelledError:
|
|
592
667
|
# If wait() itself is cancelled, propagate it
|
|
@@ -595,13 +670,14 @@ class AsyncEffect(Effect):
|
|
|
595
670
|
current_task.cancelling() > 0 or current_task.cancelled()
|
|
596
671
|
):
|
|
597
672
|
raise
|
|
598
|
-
# Effect task was cancelled,
|
|
673
|
+
# Effect task was cancelled, check if a new task was started
|
|
674
|
+
# and continue waiting if so
|
|
599
675
|
continue
|
|
600
676
|
|
|
601
677
|
@override
|
|
602
678
|
def dispose(self):
|
|
603
|
-
# Run children cleanups first, then cancel in-flight task
|
|
604
|
-
self.cancel()
|
|
679
|
+
# Run children cleanups first, then cancel in-flight task and interval
|
|
680
|
+
self.cancel(cancel_interval=True)
|
|
605
681
|
for child in self.children.copy():
|
|
606
682
|
child.dispose()
|
|
607
683
|
if self.cleanup_fn:
|
|
@@ -675,11 +751,12 @@ class Batch:
|
|
|
675
751
|
exc_type: type[BaseException] | None,
|
|
676
752
|
exc_value: BaseException | None,
|
|
677
753
|
exc_traceback: Any,
|
|
678
|
-
):
|
|
754
|
+
) -> Literal[False]:
|
|
679
755
|
self.flush()
|
|
680
756
|
# Restore previous reactive context
|
|
681
757
|
if self._token:
|
|
682
758
|
REACTIVE_CONTEXT.reset(self._token)
|
|
759
|
+
return False
|
|
683
760
|
|
|
684
761
|
|
|
685
762
|
class GlobalBatch(Batch):
|
|
@@ -755,10 +832,11 @@ class Scope:
|
|
|
755
832
|
exc_type: type[BaseException] | None,
|
|
756
833
|
exc_value: BaseException | None,
|
|
757
834
|
exc_traceback: Any,
|
|
758
|
-
):
|
|
835
|
+
) -> Literal[False]:
|
|
759
836
|
# Restore previous reactive context
|
|
760
837
|
if self._token:
|
|
761
838
|
REACTIVE_CONTEXT.reset(self._token)
|
|
839
|
+
return False
|
|
762
840
|
|
|
763
841
|
|
|
764
842
|
class Untrack(Scope): ...
|
|
@@ -801,8 +879,9 @@ class ReactiveContext:
|
|
|
801
879
|
exc_type: type[BaseException] | None,
|
|
802
880
|
exc_value: BaseException | None,
|
|
803
881
|
exc_tb: Any,
|
|
804
|
-
):
|
|
882
|
+
) -> Literal[False]:
|
|
805
883
|
REACTIVE_CONTEXT.reset(self._tokens.pop())
|
|
884
|
+
return False
|
|
806
885
|
|
|
807
886
|
|
|
808
887
|
def epoch():
|
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
|
|
pulse/vdom.py
CHANGED
|
@@ -23,6 +23,7 @@ from typing import (
|
|
|
23
23
|
)
|
|
24
24
|
|
|
25
25
|
from pulse.hooks.core import HookContext
|
|
26
|
+
from pulse.hooks.init import rewrite_init_blocks
|
|
26
27
|
|
|
27
28
|
# ============================================================================
|
|
28
29
|
# Core VDOM
|
|
@@ -291,7 +292,8 @@ def component(
|
|
|
291
292
|
fn: "Callable[P, Element] | None" = None, *, name: str | None = None
|
|
292
293
|
) -> "Component[P] | Callable[[Callable[P, Element]], Component[P]]":
|
|
293
294
|
def decorator(fn: Callable[P, Element]):
|
|
294
|
-
|
|
295
|
+
rewritten = rewrite_init_blocks(fn)
|
|
296
|
+
return Component(rewritten, name)
|
|
295
297
|
|
|
296
298
|
if fn is not None:
|
|
297
299
|
return decorator(fn)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
pulse/__init__.py,sha256=
|
|
1
|
+
pulse/__init__.py,sha256=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
|
|
@@ -24,16 +24,17 @@ pulse/components/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,
|
|
|
24
24
|
pulse/components/for_.py,sha256=LUyJEUlDM6b9oPjvUFgSsddxu6b6usF4BQdXe8FIiGI,1302
|
|
25
25
|
pulse/components/if_.py,sha256=rQywsmdirNpkb-61ZEdF-tgzUh-37JWd4YFGblkzIdQ,1624
|
|
26
26
|
pulse/components/react_router.py,sha256=TbRec-NVliUqrvAMeFXCrnDWV1rh6TGTPfRhqLuLubk,1129
|
|
27
|
-
pulse/context.py,sha256=
|
|
27
|
+
pulse/context.py,sha256=fMK6GdQY4q_3452v5DJli2f2_urVihnpzb-O-O9cJ1Q,1734
|
|
28
28
|
pulse/cookies.py,sha256=c7ua1Lv6mNe1nYnA4SFVvewvRQAbYy9fN5G3Hr_Dr5c,5000
|
|
29
29
|
pulse/css.py,sha256=-FyQQQ0EZI1Ins30qiF3l4z9yDb1V9qWuJKWxHcKGkw,3910
|
|
30
|
-
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
|
-
pulse/hooks/core.py,sha256=
|
|
35
|
+
pulse/hooks/core.py,sha256=JTZbVxNOEs_GAeK6Bh6hemSTkgwZPtEi_wt55cvOdik,7381
|
|
36
36
|
pulse/hooks/effects.py,sha256=CQvt5viAweGLSxaGGlWm155GlEQiwQnGussw7OfiCGc,2393
|
|
37
|
+
pulse/hooks/init.py,sha256=snTy3PJtkSnnKBrAjcNOJbem2896xJzHD0DHLVVeyAo,11924
|
|
37
38
|
pulse/hooks/runtime.py,sha256=k5LZ8hnlNBMKOiEkQcAvs8BKwYxV6gwea2WCfju5K7Y,5106
|
|
38
39
|
pulse/hooks/setup.py,sha256=GJLSE6hLBNKHR9aLhvsS6KXwpOXQiSx1V3E2IkGADWM,4461
|
|
39
40
|
pulse/hooks/stable.py,sha256=uHEJ2E22r2kHx4uFjWjDepQ6OtPjLd7tT5ju-yKlkCU,1702
|
|
@@ -51,26 +52,28 @@ pulse/plugin.py,sha256=RfGl6Vtr7VRHb8bp4Ob4dOX9dVzvc4Riu7HWnStMPpk,580
|
|
|
51
52
|
pulse/proxy.py,sha256=zh4v5lmYNg5IBE_xdHHmGPwbMQNSXb2npeLXvw_O1Oc,6591
|
|
52
53
|
pulse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
53
54
|
pulse/queries/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
54
|
-
pulse/queries/
|
|
55
|
-
pulse/queries/
|
|
56
|
-
pulse/queries/
|
|
57
|
-
pulse/queries/
|
|
58
|
-
pulse/queries/
|
|
59
|
-
pulse/
|
|
60
|
-
pulse/
|
|
61
|
-
pulse/
|
|
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
|
|
62
|
+
pulse/react_component.py,sha256=hPibKBEkVdpBKNSpMQ6bZ-7GnJQcNQwcw2SvfY1chHA,26026
|
|
63
|
+
pulse/reactive.py,sha256=0CqzeowZHAENMxM2PFhi_Q6KkDzG844JofzDk0Wxckw,23961
|
|
64
|
+
pulse/reactive_extensions.py,sha256=WAx4hlB1ByZLFVpgtRnaWXAQ3N2QQplf647oQXDL5vg,31901
|
|
62
65
|
pulse/render_session.py,sha256=kqLfZ9AxCrB2mIJqegATL1KA7CI-LZSBQwRYr7Uxo9g,14581
|
|
63
66
|
pulse/renderer.py,sha256=dJiX9VeHr9kC1UBw5oaKB8Mv-3OCMGTrHiKgLJ5FL50,16759
|
|
64
67
|
pulse/request.py,sha256=sPsSRWi5KvReSPBLIs_kzqomn1wlRk1BTLZ5s0chQr4,4979
|
|
65
68
|
pulse/routing.py,sha256=RlrGHyK4F28_zUHMYNeKp4pNbvqrt4GY4t5xNdhzunI,13926
|
|
66
69
|
pulse/serializer.py,sha256=8RAITNoSNm5-U38elHpWmkBpcM_rxZFMCluJSfldfk4,5420
|
|
67
|
-
pulse/state.py,sha256=
|
|
70
|
+
pulse/state.py,sha256=ikQbK4R8PieV96qd4uWREUvs0jXo9sCapawY7i6oCYo,10776
|
|
68
71
|
pulse/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
69
72
|
pulse/types/event_handler.py,sha256=tfKa6OEA5XvzuYbllQZJ03ooN7rGSYOtaPBstSL4OLU,1642
|
|
70
73
|
pulse/user_session.py,sha256=Nn9ZZha1Rruw31OSoK14QaEL0erGVFbryFhJYrtMZsQ,7599
|
|
71
|
-
pulse/vdom.py,sha256=
|
|
74
|
+
pulse/vdom.py,sha256=1UAjOYSmpdZeSVELqejh47Jer4mA73T_q2HtAogOphs,12514
|
|
72
75
|
pulse/version.py,sha256=711vaM1jVIQPgkisGgKZqwmw019qZIsc_QTae75K2pg,1895
|
|
73
|
-
pulse_framework-0.1.
|
|
74
|
-
pulse_framework-0.1.
|
|
75
|
-
pulse_framework-0.1.
|
|
76
|
-
pulse_framework-0.1.
|
|
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,,
|