pulse-framework 0.1.63__py3-none-any.whl → 0.1.65__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.
@@ -19,8 +19,8 @@ from pulse.context import PulseContext
19
19
  from pulse.helpers import (
20
20
  MISSING,
21
21
  Disposable,
22
+ Missing,
22
23
  call_flexible,
23
- later,
24
24
  maybe_await,
25
25
  )
26
26
  from pulse.queries.common import (
@@ -36,6 +36,7 @@ from pulse.queries.common import (
36
36
  from pulse.queries.query import RETRY_DELAY_DEFAULT, QueryConfig
37
37
  from pulse.reactive import Computed, Effect, Signal, Untrack
38
38
  from pulse.reactive_extensions import ReactiveList, unwrap
39
+ from pulse.scheduling import TimerHandleLike, create_task, later
39
40
  from pulse.state import InitializableProperty, State
40
41
 
41
42
  T = TypeVar("T")
@@ -152,6 +153,61 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
152
153
  )
153
154
  return self._observers[0]._fetch_fn # pyright: ignore[reportPrivateUsage]
154
155
 
156
+ @property
157
+ def has_interval(self) -> bool:
158
+ return self._interval is not None
159
+
160
+ def _select_interval_observer(
161
+ self,
162
+ ) -> tuple[float | None, "InfiniteQueryResult[T, TParam] | None"]:
163
+ min_interval: float | None = None
164
+ selected: "InfiniteQueryResult[T, TParam] | None" = None
165
+
166
+ for obs in reversed(self._observers):
167
+ interval = obs._refetch_interval # pyright: ignore[reportPrivateUsage]
168
+ if interval is None:
169
+ continue
170
+ if not obs._enabled.value: # pyright: ignore[reportPrivateUsage]
171
+ continue
172
+ if min_interval is None or interval < min_interval:
173
+ min_interval = interval
174
+ selected = obs
175
+
176
+ return min_interval, selected
177
+
178
+ def _create_interval_effect(self, interval: float) -> Effect:
179
+ def interval_fn():
180
+ observer = self._interval_observer
181
+ if observer is None:
182
+ return
183
+ self.invalidate(fetch_fn=observer._fetch_fn, observer=observer) # pyright: ignore[reportPrivateUsage]
184
+
185
+ return Effect(
186
+ interval_fn,
187
+ name=f"inf_query_interval({self.key})",
188
+ interval=interval,
189
+ immediate=True,
190
+ )
191
+
192
+ def _update_interval(self) -> None:
193
+ new_interval, new_observer = self._select_interval_observer()
194
+ interval_changed = new_interval != self._interval
195
+
196
+ self._interval = new_interval
197
+ self._interval_observer = new_observer
198
+
199
+ if not interval_changed:
200
+ if self._interval_effect is None and new_interval is not None:
201
+ self._interval_effect = self._create_interval_effect(new_interval)
202
+ return
203
+
204
+ if self._interval_effect is not None:
205
+ self._interval_effect.dispose()
206
+ self._interval_effect = None
207
+
208
+ if new_interval is not None:
209
+ self._interval_effect = self._create_interval_effect(new_interval)
210
+
155
211
  # Reactive state
156
212
  pages: ReactiveList[Page[T, TParam]]
157
213
  error: Signal[Exception | None]
@@ -170,7 +226,10 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
170
226
  _queue_task: asyncio.Task[None] | None
171
227
 
172
228
  _observers: "list[InfiniteQueryResult[T, TParam]]"
173
- _gc_handle: asyncio.TimerHandle | None
229
+ _gc_handle: TimerHandleLike | None
230
+ _interval_effect: Effect | None
231
+ _interval: float | None
232
+ _interval_observer: "InfiniteQueryResult[T, TParam] | None"
174
233
 
175
234
  def __init__(
176
235
  self,
@@ -184,7 +243,7 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
184
243
  max_pages: int = 0,
185
244
  retries: int = 3,
186
245
  retry_delay: float = RETRY_DELAY_DEFAULT,
187
- initial_data: list[Page[T, TParam]] | None | Any = MISSING,
246
+ initial_data: list[Page[T, TParam]] | Missing | None = MISSING,
188
247
  initial_data_updated_at: float | dt.datetime | None = None,
189
248
  gc_time: float = 300.0,
190
249
  on_dispose: Callable[[Any], None] | None = None,
@@ -208,7 +267,7 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
208
267
  if initial_data is MISSING:
209
268
  initial_pages = []
210
269
  else:
211
- initial_pages = cast(list[Page[T, TParam]], initial_data) or []
270
+ initial_pages = cast(list[Page[T, TParam]] | None, initial_data) or []
212
271
 
213
272
  self.pages = ReactiveList(initial_pages)
214
273
  self.error = Signal(None, name=f"inf_query.error({key})")
@@ -232,6 +291,9 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
232
291
  self._queue_task = None
233
292
  self._observers = []
234
293
  self._gc_handle = None
294
+ self._interval_effect = None
295
+ self._interval = None
296
+ self._interval_observer = None
235
297
 
236
298
  # ─────────────────────────────────────────────────────────────────────────
237
299
  # Commit functions - update state after pages have been modified
@@ -326,13 +388,7 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
326
388
  fetch_fn: Callable[[TParam], Awaitable[T]] | None = None,
327
389
  observer: "InfiniteQueryResult[T, TParam] | None" = None,
328
390
  ) -> ActionResult[list[Page[T, TParam]]]:
329
- """Wait for initial data or until queue is empty."""
330
- # If no data and loading, enqueue initial fetch (unless already processing)
331
- if len(self.pages) == 0 and self.status() == "loading":
332
- if self._queue_task is None or self._queue_task.done():
333
- # Use provided fetch_fn or fall back to first observer's fetch_fn
334
- fn = fetch_fn if fetch_fn is not None else self.fn
335
- self._enqueue(Refetch(fetch_fn=fn, observer=observer))
391
+ """Wait for any in-flight queue processing to complete."""
336
392
  # Wait for any in-progress queue processing
337
393
  if self._queue_task and not self._queue_task.done():
338
394
  await self._queue_task
@@ -341,17 +397,31 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
341
397
  return ActionError(cast(Exception, self.error()))
342
398
  return ActionSuccess(list(self.pages))
343
399
 
400
+ async def ensure(
401
+ self,
402
+ fetch_fn: Callable[[TParam], Awaitable[T]] | None = None,
403
+ observer: "InfiniteQueryResult[T, TParam] | None" = None,
404
+ ) -> ActionResult[list[Page[T, TParam]]]:
405
+ """Ensure an initial fetch has started, then wait for completion."""
406
+ if len(self.pages) == 0 and self.status() == "loading":
407
+ if self._queue_task is None or self._queue_task.done():
408
+ fn = fetch_fn if fetch_fn is not None else self.fn
409
+ self._enqueue(Refetch(fetch_fn=fn, observer=observer))
410
+ return await self.wait()
411
+
344
412
  def observe(self, observer: Any):
345
413
  self._observers.append(observer)
346
414
  self.cancel_gc()
347
415
  gc_time = getattr(observer, "_gc_time", 0)
348
416
  if gc_time and gc_time > 0:
349
417
  self.cfg.gc_time = max(self.cfg.gc_time, gc_time)
418
+ self._update_interval()
350
419
 
351
420
  def unobserve(self, observer: "InfiniteQueryResult[T, TParam]"):
352
421
  """Unregister an observer. Cancels pending actions. Schedules GC if no observers remain."""
353
422
  if observer in self._observers:
354
423
  self._observers.remove(observer)
424
+ self._update_interval()
355
425
 
356
426
  # Cancel pending actions from this observer
357
427
  self._cancel_observer_actions(observer)
@@ -493,7 +563,7 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
493
563
  if self._queue_task is None or self._queue_task.done():
494
564
  # Create task with no reactive scope to avoid inheriting deps from caller
495
565
  with Untrack():
496
- self._queue_task = asyncio.create_task(self._process_queue())
566
+ self._queue_task = create_task(self._process_queue())
497
567
  return self._queue_task
498
568
 
499
569
  async def _process_queue(self):
@@ -630,11 +700,17 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
630
700
  (i for i, p in enumerate(self.pages) if p.param == action.param),
631
701
  None,
632
702
  )
633
- if idx is None:
634
- return None
635
703
 
636
704
  page = await action.fetch_fn(action.param)
637
- self.pages[idx] = Page(page, action.param)
705
+
706
+ if idx is None:
707
+ # Page doesn't exist - jump to this page, clearing existing pages
708
+ self.pages.clear()
709
+ self.pages.append(Page(page, action.param))
710
+ else:
711
+ # Page exists, update it
712
+ self.pages[idx] = Page(page, action.param)
713
+
638
714
  await self.commit()
639
715
  return page
640
716
 
@@ -708,7 +784,10 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
708
784
  cancel_fetch: bool = False,
709
785
  ) -> ActionResult[T | None]:
710
786
  """
711
- Refetch an existing page by its param. Queued for sequential execution.
787
+ Refetch a page by its param. Queued for sequential execution.
788
+
789
+ If the page doesn't exist, clears existing pages and loads the requested
790
+ page as the new starting point.
712
791
 
713
792
  Note: Prefer calling refetch_page() on InfiniteQueryResult to ensure the
714
793
  correct fetch function is used. When called directly on InfiniteQuery, uses
@@ -725,6 +804,9 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
725
804
  self._cancel_queue()
726
805
  if self._queue_task and not self._queue_task.done():
727
806
  self._queue_task.cancel()
807
+ if self._interval_effect is not None:
808
+ self._interval_effect.dispose()
809
+ self._interval_effect = None
728
810
  if self.cfg.on_dispose:
729
811
  self.cfg.on_dispose(self)
730
812
 
@@ -778,8 +860,7 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
778
860
  _on_success: Callable[[list[Page[T, TParam]]], Awaitable[None] | None] | None
779
861
  _on_error: Callable[[Exception], Awaitable[None] | None] | None
780
862
  _observe_effect: Effect
781
- _interval_effect: Effect | None
782
- _data_computed: Computed[list[Page[T, TParam]] | None]
863
+ _data_computed: Computed[list[Page[T, TParam]] | None | Missing]
783
864
  _enabled: Signal[bool]
784
865
  _fetch_on_mount: bool
785
866
 
@@ -801,13 +882,17 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
801
882
  self._fetch_fn = fetch_fn
802
883
  self._stale_time = stale_time
803
884
  self._gc_time = gc_time
804
- self._refetch_interval = refetch_interval
885
+ interval = (
886
+ refetch_interval
887
+ if refetch_interval is not None and refetch_interval > 0
888
+ else None
889
+ )
890
+ self._refetch_interval = interval
805
891
  self._keep_previous_data = keep_previous_data
806
892
  self._on_success = on_success
807
893
  self._on_error = on_error
808
894
  self._enabled = Signal(enabled, name=f"inf_query.enabled({query().key})")
809
895
  self._fetch_on_mount = fetch_on_mount
810
- self._interval_effect = None
811
896
 
812
897
  def observe_effect():
813
898
  q = self._query()
@@ -816,8 +901,13 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
816
901
  with Untrack():
817
902
  q.observe(self)
818
903
 
819
- if enabled and fetch_on_mount and self.is_stale():
820
- q.invalidate()
904
+ # Skip if query interval is active - interval effect handles initial fetch
905
+ if enabled and fetch_on_mount and not q.has_interval:
906
+ # Fetch if no data loaded yet or if existing data is stale
907
+ if not q.is_fetching() and (
908
+ q.status() == "loading" or self.is_stale()
909
+ ):
910
+ q.invalidate()
821
911
 
822
912
  # Return cleanup function that captures the query (old query on key change)
823
913
  def cleanup():
@@ -831,26 +921,9 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
831
921
  immediate=True,
832
922
  )
833
923
  self._data_computed = Computed(
834
- self._data_computed_fn, name=f"inf_query_data({self._query().key})"
835
- )
836
-
837
- # Set up interval effect if interval is specified
838
- if refetch_interval is not None and refetch_interval > 0:
839
- self._setup_interval_effect(refetch_interval)
840
-
841
- def _setup_interval_effect(self, interval: float):
842
- """Create an effect that invalidates the query at the specified interval."""
843
-
844
- def interval_fn():
845
- # Read enabled to make this effect reactive to enabled changes
846
- if self._enabled():
847
- self._query().invalidate()
848
-
849
- self._interval_effect = Effect(
850
- interval_fn,
851
- name=f"inf_query_interval({self._query().key})",
852
- interval=interval,
853
- immediate=True,
924
+ self._data_computed_fn,
925
+ name=f"inf_query_data({self._query().key})",
926
+ initial_value=MISSING,
854
927
  )
855
928
 
856
929
  @property
@@ -878,18 +951,19 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
878
951
  return self._query().error.read()
879
952
 
880
953
  def _data_computed_fn(
881
- self, prev: list[Page[T, TParam]] | None
882
- ) -> list[Page[T, TParam]] | None:
954
+ self, prev: list[Page[T, TParam]] | None | Missing
955
+ ) -> list[Page[T, TParam]] | None | Missing:
883
956
  query = self._query()
884
- if self._keep_previous_data and query.status() != "success":
957
+ if self._keep_previous_data and query.status() == "loading":
885
958
  return prev
886
959
  # Access pages.version to subscribe to structural changes
887
- result = unwrap(query.pages) if len(query.pages) > 0 else None
888
- return result
960
+ if len(query.pages) == 0:
961
+ return MISSING
962
+ return unwrap(query.pages)
889
963
 
890
964
  @property
891
965
  def data(self) -> list[Page[T, TParam]] | None:
892
- return self._data_computed()
966
+ return none_if_missing(self._data_computed())
893
967
 
894
968
  @property
895
969
  def pages(self) -> list[T] | None:
@@ -918,8 +992,6 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
918
992
  return isinstance(self._query().current_action(), FetchPrevious)
919
993
 
920
994
  def is_stale(self) -> bool:
921
- if self._stale_time <= 0:
922
- return False
923
995
  query = self._query()
924
996
  return (time.time() - query.last_updated.read()) > self._stale_time
925
997
 
@@ -985,15 +1057,20 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
985
1057
  async def wait(self) -> ActionResult[list[Page[T, TParam]]]:
986
1058
  return await self._query().wait(fetch_fn=self._fetch_fn, observer=self)
987
1059
 
1060
+ async def ensure(self) -> ActionResult[list[Page[T, TParam]]]:
1061
+ return await self._query().ensure(fetch_fn=self._fetch_fn, observer=self)
1062
+
988
1063
  def invalidate(self):
989
1064
  query = self._query()
990
1065
  query.invalidate(fetch_fn=self._fetch_fn, observer=self)
991
1066
 
992
1067
  def enable(self):
993
1068
  self._enabled.write(True)
1069
+ self._query()._update_interval() # pyright: ignore[reportPrivateUsage]
994
1070
 
995
1071
  def disable(self):
996
1072
  self._enabled.write(False)
1073
+ self._query()._update_interval() # pyright: ignore[reportPrivateUsage]
997
1074
 
998
1075
  def set_error(self, error: Exception):
999
1076
  query = self._query()
@@ -1002,8 +1079,6 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
1002
1079
  @override
1003
1080
  def dispose(self):
1004
1081
  """Clean up the result and its observe effect."""
1005
- if self._interval_effect is not None:
1006
- self._interval_effect.dispose()
1007
1082
  self._observe_effect.dispose()
1008
1083
 
1009
1084
 
@@ -1023,6 +1098,7 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1023
1098
 
1024
1099
  Optional decorators:
1025
1100
  - ``@infinite_query_prop.get_previous_page_param``: For bi-directional pagination.
1101
+ - ``@infinite_query_prop.initial_data``: Provide initial pages.
1026
1102
  - ``@infinite_query_prop.on_success``: Handle successful fetch.
1027
1103
  - ``@infinite_query_prop.on_error``: Handle fetch errors.
1028
1104
 
@@ -1058,6 +1134,12 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1058
1134
  _refetch_interval: float | None
1059
1135
  _retries: int
1060
1136
  _retry_delay: float
1137
+ _initial_data: (
1138
+ list[Page[T, TParam]]
1139
+ | Callable[[TState], list[Page[T, TParam]]]
1140
+ | Missing
1141
+ | None
1142
+ )
1061
1143
  _initial_page_param: TParam
1062
1144
  _get_next_page_param: (
1063
1145
  Callable[[TState, list[Page[T, TParam]]], TParam | None] | None
@@ -1109,6 +1191,7 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1109
1191
  self._retry_delay = retry_delay
1110
1192
  self._on_success_fn = None
1111
1193
  self._on_error_fn = None
1194
+ self._initial_data = MISSING
1112
1195
  self._key = key
1113
1196
  self._initial_data_updated_at = initial_data_updated_at
1114
1197
  self._enabled = enabled
@@ -1139,6 +1222,16 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1139
1222
  self._on_error_fn = fn # pyright: ignore[reportAttributeAccessIssue]
1140
1223
  return fn
1141
1224
 
1225
+ def initial_data(
1226
+ self, fn: Callable[[TState], list[Page[T, TParam]]]
1227
+ ) -> Callable[[TState], list[Page[T, TParam]]]:
1228
+ if self._initial_data is not MISSING:
1229
+ raise RuntimeError(
1230
+ f"Duplicate initial_data() decorator for infinite query '{self.name}'. Only one is allowed."
1231
+ )
1232
+ self._initial_data = fn
1233
+ return fn
1234
+
1142
1235
  def get_next_page_param(
1143
1236
  self,
1144
1237
  fn: Callable[[TState, list[Page[T, TParam]]], TParam | None],
@@ -1187,8 +1280,23 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1187
1280
  raise RuntimeError(
1188
1281
  f"key is required for infinite query '{self.name}'. Provide a key via @infinite_query(key=...) or @{self.name}.key decorator."
1189
1282
  )
1283
+ raw_initial = (
1284
+ call_flexible(self._initial_data, state)
1285
+ if callable(self._initial_data)
1286
+ else self._initial_data
1287
+ )
1288
+ initial_data = (
1289
+ MISSING
1290
+ if raw_initial is MISSING
1291
+ else cast(list[Page[T, TParam]] | None, raw_initial)
1292
+ )
1190
1293
  query = self._resolve_keyed(
1191
- state, fetch_fn, next_fn, prev_fn, self._initial_data_updated_at
1294
+ state,
1295
+ fetch_fn,
1296
+ next_fn,
1297
+ prev_fn,
1298
+ initial_data,
1299
+ self._initial_data_updated_at,
1192
1300
  )
1193
1301
 
1194
1302
  on_success = None
@@ -1224,6 +1332,7 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1224
1332
  fetch_fn: Callable[[TParam], Awaitable[T]],
1225
1333
  next_fn: Callable[[list[Page[T, TParam]]], TParam | None],
1226
1334
  prev_fn: Callable[[list[Page[T, TParam]]], TParam | None] | None,
1335
+ initial_data: list[Page[T, TParam]] | Missing | None,
1227
1336
  initial_data_updated_at: float | dt.datetime | None,
1228
1337
  ) -> Computed[InfiniteQuery[T, TParam]]:
1229
1338
  assert self._key is not None
@@ -1254,6 +1363,7 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1254
1363
  get_next_page_param=next_fn,
1255
1364
  get_previous_page_param=prev_fn,
1256
1365
  max_pages=self._max_pages,
1366
+ initial_data=initial_data,
1257
1367
  gc_time=self._gc_time,
1258
1368
  retries=self._retries,
1259
1369
  retry_delay=self._retry_delay,
@@ -1347,7 +1457,7 @@ def infinite_query(
1347
1457
  stale_time: Seconds before data is considered stale (default 0.0).
1348
1458
  gc_time: Seconds to keep unused query in cache (default 300.0).
1349
1459
  refetch_interval: Auto-refetch interval in seconds (default None).
1350
- keep_previous_data: Keep previous data while refetching (default False).
1460
+ keep_previous_data: Keep previous data while loading (default False).
1351
1461
  retries: Number of retry attempts on failure (default 3).
1352
1462
  retry_delay: Delay between retries in seconds (default 2.0).
1353
1463
  initial_data_updated_at: Timestamp for initial data staleness.
pulse/queries/protocol.py CHANGED
@@ -90,6 +90,15 @@ class QueryResult(Protocol[T]):
90
90
  """
91
91
  ...
92
92
 
93
+ async def ensure(self) -> ActionResult[T]:
94
+ """
95
+ Ensure an initial fetch has started, then wait for completion.
96
+
97
+ Returns:
98
+ ActionResult containing either the data or an error.
99
+ """
100
+ ...
101
+
93
102
  def invalidate(self) -> None:
94
103
  """Mark the query as stale and trigger a refetch if observed."""
95
104
  ...