pulse-framework 0.1.70__py3-none-any.whl → 0.1.72__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/queries/client.py CHANGED
@@ -4,7 +4,7 @@ from typing import Any, TypeVar, overload
4
4
 
5
5
  from pulse.context import PulseContext
6
6
  from pulse.helpers import MISSING
7
- from pulse.queries.common import ActionResult, QueryKey
7
+ from pulse.queries.common import ActionResult, Key, QueryKey, QueryKeys, normalize_key
8
8
  from pulse.queries.infinite_query import InfiniteQuery, Page
9
9
  from pulse.queries.query import KeyedQuery
10
10
  from pulse.queries.store import QueryStore
@@ -13,34 +13,32 @@ T = TypeVar("T")
13
13
 
14
14
  # Query filter types
15
15
  QueryFilter = (
16
- QueryKey # exact key match
17
- | list[QueryKey] # explicit list of keys
18
- | Callable[[QueryKey], bool] # predicate function
16
+ QueryKey # exact key match (tuple or list)
17
+ | QueryKeys # explicit set of keys
18
+ | Callable[[Key], bool] # predicate function
19
19
  )
20
20
 
21
21
 
22
22
  def _normalize_filter(
23
23
  filter: QueryFilter | None,
24
- ) -> Callable[[QueryKey], bool] | None:
25
- """Convert any QueryFilter to a predicate function."""
24
+ ) -> tuple[Key | None, Callable[[Key], bool] | None]:
25
+ """Return normalized exact key (if any) and a predicate for filtering."""
26
26
  if filter is None:
27
- return None
28
- if isinstance(filter, tuple):
29
- # Exact key match
30
- exact_key = filter
31
- return lambda k: k == exact_key
32
- if isinstance(filter, list):
33
- # List of keys
34
- key_set = set(filter)
35
- return lambda k: k in key_set
36
- # Already a callable predicate
37
- return filter
38
-
39
-
40
- def _prefix_filter(prefix: tuple[Any, ...]) -> Callable[[QueryKey], bool]:
27
+ return None, None
28
+ if callable(filter):
29
+ return None, filter
30
+ if isinstance(filter, QueryKeys):
31
+ key_set = set(filter.keys)
32
+ return None, lambda k: k in key_set
33
+ exact_key = normalize_key(filter)
34
+ return exact_key, lambda k: k == exact_key
35
+
36
+
37
+ def _prefix_filter(prefix: QueryKey) -> Callable[[Key], bool]:
41
38
  """Create a predicate that matches keys starting with the given prefix."""
42
- prefix_len = len(prefix)
43
- return lambda k: len(k) >= prefix_len and k[:prefix_len] == prefix
39
+ normalized = normalize_key(prefix)
40
+ prefix_len = len(normalized)
41
+ return lambda k: len(k) >= prefix_len and k[:prefix_len] == normalized
44
42
 
45
43
 
46
44
  class QueryClient:
@@ -120,7 +118,7 @@ class QueryClient:
120
118
  Get all queries matching the filter.
121
119
 
122
120
  Args:
123
- filter: Optional filter - can be an exact key, list of keys, or predicate.
121
+ filter: Optional filter - exact key, QueryKeys, or predicate.
124
122
  If None, returns all queries.
125
123
  include_infinite: Whether to include infinite queries (default True).
126
124
 
@@ -128,9 +126,16 @@ class QueryClient:
128
126
  List of matching Query or InfiniteQuery instances.
129
127
  """
130
128
  store = self._get_store()
131
- predicate = _normalize_filter(filter)
129
+ exact_key, predicate = _normalize_filter(filter)
132
130
  results: list[KeyedQuery[Any] | InfiniteQuery[Any, Any]] = []
133
131
 
132
+ if exact_key is not None:
133
+ if include_infinite:
134
+ entry = store.get_any(exact_key)
135
+ else:
136
+ entry = store.get(exact_key)
137
+ return [entry] if entry is not None else []
138
+
134
139
  for key, entry in store.items():
135
140
  if predicate is not None and not predicate(key):
136
141
  continue
@@ -144,16 +149,20 @@ class QueryClient:
144
149
  """Get all regular queries matching the filter.
145
150
 
146
151
  Args:
147
- filter: Optional filter - exact key, list of keys, or predicate.
152
+ filter: Optional filter - exact key, QueryKeys, or predicate.
148
153
  If None, returns all regular queries.
149
154
 
150
155
  Returns:
151
156
  List of matching KeyedQuery instances (excludes infinite queries).
152
157
  """
153
158
  store = self._get_store()
154
- predicate = _normalize_filter(filter)
159
+ exact_key, predicate = _normalize_filter(filter)
155
160
  results: list[KeyedQuery[Any]] = []
156
161
 
162
+ if exact_key is not None:
163
+ entry = store.get(exact_key)
164
+ return [entry] if entry is not None else []
165
+
157
166
  for key, entry in store.items():
158
167
  if isinstance(entry, InfiniteQuery):
159
168
  continue
@@ -169,16 +178,20 @@ class QueryClient:
169
178
  """Get all infinite queries matching the filter.
170
179
 
171
180
  Args:
172
- filter: Optional filter - exact key, list of keys, or predicate.
181
+ filter: Optional filter - exact key, QueryKeys, or predicate.
173
182
  If None, returns all infinite queries.
174
183
 
175
184
  Returns:
176
185
  List of matching InfiniteQuery instances.
177
186
  """
178
187
  store = self._get_store()
179
- predicate = _normalize_filter(filter)
188
+ exact_key, predicate = _normalize_filter(filter)
180
189
  results: list[InfiniteQuery[Any, Any]] = []
181
190
 
191
+ if exact_key is not None:
192
+ entry = store.get_infinite(exact_key)
193
+ return [entry] if entry is not None else []
194
+
182
195
  for key, entry in store.items():
183
196
  if not isinstance(entry, InfiniteQuery):
184
197
  continue
@@ -239,7 +252,7 @@ class QueryClient:
239
252
  @overload
240
253
  def set_data(
241
254
  self,
242
- key_or_filter: list[QueryKey] | Callable[[QueryKey], bool],
255
+ key_or_filter: QueryKeys | Callable[[Key], bool],
243
256
  data: Callable[[Any], Any],
244
257
  *,
245
258
  updated_at: float | dt.datetime | None = None,
@@ -247,7 +260,7 @@ class QueryClient:
247
260
 
248
261
  def set_data(
249
262
  self,
250
- key_or_filter: QueryKey | list[QueryKey] | Callable[[QueryKey], bool],
263
+ key_or_filter: QueryKey | QueryKeys | Callable[[Key], bool],
251
264
  data: Any | Callable[[Any], Any],
252
265
  *,
253
266
  updated_at: float | dt.datetime | None = None,
@@ -266,16 +279,15 @@ class QueryClient:
266
279
  Returns:
267
280
  bool if exact key, int count if filter.
268
281
  """
269
- # Single key case
270
- if isinstance(key_or_filter, tuple):
271
- query = self.get(key_or_filter)
282
+ exact_key, predicate = _normalize_filter(key_or_filter)
283
+ if exact_key is not None:
284
+ query = self.get(exact_key)
272
285
  if query is None:
273
286
  return False
274
287
  query.set_data(data, updated_at=updated_at)
275
288
  return True
276
289
 
277
- # Filter case
278
- queries = self.get_queries(key_or_filter)
290
+ queries = self.get_queries(predicate)
279
291
  for q in queries:
280
292
  q.set_data(data, updated_at=updated_at)
281
293
  return len(queries)
@@ -319,17 +331,14 @@ class QueryClient:
319
331
  @overload
320
332
  def invalidate(
321
333
  self,
322
- key_or_filter: list[QueryKey] | Callable[[QueryKey], bool] | None = None,
334
+ key_or_filter: QueryKeys | Callable[[Key], bool] | None = None,
323
335
  *,
324
336
  cancel_refetch: bool = False,
325
337
  ) -> int: ...
326
338
 
327
339
  def invalidate(
328
340
  self,
329
- key_or_filter: QueryKey
330
- | list[QueryKey]
331
- | Callable[[QueryKey], bool]
332
- | None = None,
341
+ key_or_filter: QueryKey | QueryKeys | Callable[[Key], bool] | None = None,
333
342
  *,
334
343
  cancel_refetch: bool = False,
335
344
  ) -> bool | int:
@@ -346,20 +355,19 @@ class QueryClient:
346
355
  Returns:
347
356
  bool if exact key, int count if filter/None.
348
357
  """
349
- # Single key case
350
- if isinstance(key_or_filter, tuple):
351
- query = self.get(key_or_filter)
358
+ exact_key, predicate = _normalize_filter(key_or_filter)
359
+ if exact_key is not None:
360
+ query = self.get(exact_key)
352
361
  if query is not None:
353
362
  query.invalidate(cancel_refetch=cancel_refetch)
354
363
  return True
355
- inf_query = self.get_infinite(key_or_filter)
364
+ inf_query = self.get_infinite(exact_key)
356
365
  if inf_query is not None:
357
366
  inf_query.invalidate(cancel_fetch=cancel_refetch)
358
367
  return True
359
368
  return False
360
369
 
361
- # Filter case
362
- queries = self.get_all(key_or_filter)
370
+ queries = self.get_all(predicate)
363
371
  for q in queries:
364
372
  if isinstance(q, InfiniteQuery):
365
373
  q.invalidate(cancel_fetch=cancel_refetch)
@@ -369,14 +377,14 @@ class QueryClient:
369
377
 
370
378
  def invalidate_prefix(
371
379
  self,
372
- prefix: tuple[Any, ...],
380
+ prefix: QueryKey,
373
381
  *,
374
382
  cancel_refetch: bool = False,
375
383
  ) -> int:
376
384
  """Invalidate all queries whose keys start with the given prefix.
377
385
 
378
386
  Args:
379
- prefix: Tuple prefix to match against query keys.
387
+ prefix: Key prefix to match against query keys.
380
388
  cancel_refetch: Cancel in-flight requests before refetch.
381
389
 
382
390
  Returns:
@@ -429,7 +437,7 @@ class QueryClient:
429
437
  """Refetch all queries matching the filter.
430
438
 
431
439
  Args:
432
- filter: Optional filter - exact key, list of keys, or predicate.
440
+ filter: Optional filter - exact key, QueryKeys, or predicate.
433
441
  If None, refetches all queries.
434
442
  cancel_refetch: Cancel in-flight requests before refetching.
435
443
 
@@ -450,14 +458,14 @@ class QueryClient:
450
458
 
451
459
  async def refetch_prefix(
452
460
  self,
453
- prefix: tuple[Any, ...],
461
+ prefix: QueryKey,
454
462
  *,
455
463
  cancel_refetch: bool = True,
456
464
  ) -> list[ActionResult[Any]]:
457
465
  """Refetch all queries whose keys start with the given prefix.
458
466
 
459
467
  Args:
460
- prefix: Tuple prefix to match against query keys.
468
+ prefix: Key prefix to match against query keys.
461
469
  cancel_refetch: Cancel in-flight requests before refetching.
462
470
 
463
471
  Returns:
@@ -524,7 +532,7 @@ class QueryClient:
524
532
  """Remove all queries matching the filter.
525
533
 
526
534
  Args:
527
- filter: Optional filter - exact key, list of keys, or predicate.
535
+ filter: Optional filter - exact key, QueryKeys, or predicate.
528
536
  If None, removes all queries.
529
537
 
530
538
  Returns:
@@ -535,11 +543,11 @@ class QueryClient:
535
543
  q.dispose()
536
544
  return len(queries)
537
545
 
538
- def remove_prefix(self, prefix: tuple[Any, ...]) -> int:
546
+ def remove_prefix(self, prefix: QueryKey) -> int:
539
547
  """Remove all queries whose keys start with the given prefix.
540
548
 
541
549
  Args:
542
- prefix: Tuple prefix to match against query keys.
550
+ prefix: Key prefix to match against query keys.
543
551
 
544
552
  Returns:
545
553
  Count of removed queries.
@@ -554,7 +562,7 @@ class QueryClient:
554
562
  """Check if any query matching the filter is currently fetching.
555
563
 
556
564
  Args:
557
- filter: Optional filter - exact key, list of keys, or predicate.
565
+ filter: Optional filter - exact key, QueryKeys, or predicate.
558
566
  If None, checks all queries.
559
567
 
560
568
  Returns:
@@ -570,7 +578,7 @@ class QueryClient:
570
578
  """Check if any query matching the filter is in loading state.
571
579
 
572
580
  Args:
573
- filter: Optional filter - exact key, list of keys, or predicate.
581
+ filter: Optional filter - exact key, QueryKeys, or predicate.
574
582
  If None, checks all queries.
575
583
 
576
584
  Returns:
pulse/queries/common.py CHANGED
@@ -9,6 +9,8 @@ from typing import (
9
9
  ParamSpec,
10
10
  TypeAlias,
11
11
  TypeVar,
12
+ final,
13
+ override,
12
14
  )
13
15
 
14
16
  from pulse.state import State
@@ -18,13 +20,74 @@ TState = TypeVar("TState", bound="State")
18
20
  P = ParamSpec("P")
19
21
  R = TypeVar("R")
20
22
 
21
- QueryKey: TypeAlias = tuple[Hashable, ...]
22
- """Tuple of hashable values identifying a query in the store.
23
+
24
+ @final
25
+ class Key(tuple[Hashable, ...]):
26
+ """Normalized query key with a precomputed hash."""
27
+
28
+ _hash: int = 0
29
+
30
+ def __new__(cls, key: "QueryKey"):
31
+ if isinstance(key, Key):
32
+ return key
33
+ if isinstance(key, (list, tuple)):
34
+ parts = tuple(key)
35
+ try:
36
+ key_hash = hash(parts)
37
+ except TypeError as e:
38
+ raise TypeError(
39
+ f"Query key contains unhashable value: {e}.\n\n"
40
+ + "Keys must contain only hashable values (strings, numbers, tuples).\n"
41
+ + f"Got: {key!r}\n\n"
42
+ + "If using a dict or list inside the key, convert it to a tuple:\n"
43
+ + " key=('users', tuple(user_ids)) # instead of list"
44
+ ) from None
45
+ obj = super().__new__(cls, parts)
46
+ obj._hash = key_hash
47
+ return obj
48
+ raise TypeError(
49
+ f"Query key must be a tuple or list, got {type(key).__name__}: {key!r}\n\n"
50
+ + "Examples of valid keys:\n"
51
+ + " key=('users',) # single-element tuple\n"
52
+ + " key=('user', user_id) # tuple with dynamic value\n"
53
+ + " key=['posts', 'feed'] # list form also works"
54
+ )
55
+
56
+ @override
57
+ def __hash__(self) -> int:
58
+ return self._hash
59
+
60
+
61
+ QueryKey: TypeAlias = tuple[Hashable, ...] | list[Hashable] | Key # pyright: ignore[reportImplicitStringConcatenation]
62
+ """List/tuple of hashable values identifying a query in the store.
23
63
 
24
64
  Used to uniquely identify queries for caching, deduplication, and invalidation.
25
- Keys are hierarchical tuples like ``("user", user_id)`` or ``("posts", "feed")``.
65
+ Keys are hierarchical lists/tuples like ``("user", user_id)`` or ``["posts", "feed"]``.
66
+ Lists are normalized to a tuple-backed Key internally.
26
67
  """
27
68
 
69
+
70
+ def normalize_key(key: QueryKey) -> Key:
71
+ """Convert a query key to a normalized key for use as a dict key."""
72
+ return Key(key)
73
+
74
+
75
+ @final
76
+ @dataclass(frozen=True, slots=True)
77
+ class QueryKeys:
78
+ """Wrapper for selecting multiple query keys."""
79
+
80
+ keys: tuple[Key, ...]
81
+
82
+ def __init__(self, *keys: QueryKey):
83
+ object.__setattr__(self, "keys", tuple(normalize_key(key) for key in keys))
84
+
85
+
86
+ def keys(*query_keys: QueryKey) -> QueryKeys:
87
+ """Create a QueryKeys wrapper for filtering by multiple keys."""
88
+ return QueryKeys(*query_keys)
89
+
90
+
28
91
  QueryStatus: TypeAlias = Literal["loading", "success", "error"]
29
92
  """Current status of a query.
30
93
 
@@ -27,11 +27,13 @@ from pulse.queries.common import (
27
27
  ActionError,
28
28
  ActionResult,
29
29
  ActionSuccess,
30
+ Key,
30
31
  OnErrorFn,
31
32
  OnSuccessFn,
32
33
  QueryKey,
33
34
  QueryStatus,
34
35
  bind_state,
36
+ normalize_key,
35
37
  )
36
38
  from pulse.queries.query import RETRY_DELAY_DEFAULT, QueryConfig
37
39
  from pulse.reactive import Computed, Effect, Signal, Untrack
@@ -115,6 +117,7 @@ class RefetchPage(Generic[T, TParam]):
115
117
  fetch_fn: Callable[[TParam], Awaitable[T]]
116
118
  param: TParam
117
119
  observer: "InfiniteQueryResult[T, TParam] | None" = None
120
+ clear: bool = False
118
121
  future: "asyncio.Future[ActionResult[T | None]]" = field(
119
122
  default_factory=asyncio.Future
120
123
  )
@@ -141,7 +144,7 @@ class InfiniteQueryConfig(QueryConfig[list[Page[T, TParam]]], Generic[T, TParam]
141
144
  class InfiniteQuery(Generic[T, TParam], Disposable):
142
145
  """Paginated query that stores data as a list of Page(data, param)."""
143
146
 
144
- key: QueryKey
147
+ key: Key
145
148
  cfg: InfiniteQueryConfig[T, TParam]
146
149
 
147
150
  @property
@@ -248,7 +251,7 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
248
251
  gc_time: float = 300.0,
249
252
  on_dispose: Callable[[Any], None] | None = None,
250
253
  ):
251
- self.key = key
254
+ self.key = normalize_key(key)
252
255
 
253
256
  self.cfg = InfiniteQueryConfig(
254
257
  retries=retries,
@@ -305,7 +308,8 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
305
308
 
306
309
  for obs in self._observers:
307
310
  if obs._on_success is not None: # pyright: ignore[reportPrivateUsage]
308
- await maybe_await(call_flexible(obs._on_success, self.pages)) # pyright: ignore[reportPrivateUsage]
311
+ with Untrack():
312
+ await maybe_await(call_flexible(obs._on_success, self.pages)) # pyright: ignore[reportPrivateUsage]
309
313
 
310
314
  async def _commit_error(self, error: Exception):
311
315
  """Commit error state and run error callbacks."""
@@ -313,7 +317,8 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
313
317
 
314
318
  for obs in self._observers:
315
319
  if obs._on_error is not None: # pyright: ignore[reportPrivateUsage]
316
- await maybe_await(call_flexible(obs._on_error, error)) # pyright: ignore[reportPrivateUsage]
320
+ with Untrack():
321
+ await maybe_await(call_flexible(obs._on_error, error)) # pyright: ignore[reportPrivateUsage]
317
322
 
318
323
  def _commit_sync(self):
319
324
  """Synchronous commit - updates state based on current pages."""
@@ -427,7 +432,8 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
427
432
  self._cancel_observer_actions(observer)
428
433
 
429
434
  if len(self._observers) == 0:
430
- self.schedule_gc()
435
+ if not self.__disposed__:
436
+ self.schedule_gc()
431
437
 
432
438
  def invalidate(
433
439
  self,
@@ -703,8 +709,8 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
703
709
 
704
710
  page = await action.fetch_fn(action.param)
705
711
 
706
- if idx is None:
707
- # Page doesn't exist - jump to this page, clearing existing pages
712
+ if action.clear or idx is None:
713
+ # clear=True or page doesn't exist - replace all pages with just this one
708
714
  self.pages.clear()
709
715
  self.pages.append(Page(page, action.param))
710
716
  else:
@@ -782,12 +788,13 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
782
788
  *,
783
789
  observer: "InfiniteQueryResult[T, TParam] | None" = None,
784
790
  cancel_fetch: bool = False,
791
+ clear: bool = False,
785
792
  ) -> ActionResult[T | None]:
786
793
  """
787
794
  Refetch a page by its param. Queued for sequential execution.
788
795
 
789
- If the page doesn't exist, clears existing pages and loads the requested
790
- page as the new starting point.
796
+ If the page doesn't exist or clear=True, clears existing pages and loads
797
+ the requested page as the new starting point.
791
798
 
792
799
  Note: Prefer calling refetch_page() on InfiniteQueryResult to ensure the
793
800
  correct fetch function is used. When called directly on InfiniteQuery, uses
@@ -795,7 +802,7 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
795
802
  """
796
803
  fn = fetch_fn if fetch_fn is not None else self.fn
797
804
  action: RefetchPage[T, TParam] = RefetchPage(
798
- fetch_fn=fn, param=param, observer=observer
805
+ fetch_fn=fn, param=param, observer=observer, clear=clear
799
806
  )
800
807
  return await self._enqueue(action, cancel_fetch=cancel_fetch)
801
808
 
@@ -1019,12 +1026,22 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
1019
1026
  page_param: TParam,
1020
1027
  *,
1021
1028
  cancel_fetch: bool = False,
1029
+ clear: bool = False,
1022
1030
  ) -> ActionResult[T | None]:
1031
+ """Fetch a specific page by its param.
1032
+
1033
+ Args:
1034
+ page_param: The page parameter to fetch.
1035
+ cancel_fetch: Cancel any in-flight fetches before starting.
1036
+ clear: If True, clears all other pages and keeps only the fetched page.
1037
+ Useful for resetting pagination to a specific page.
1038
+ """
1023
1039
  return await self._query().refetch_page(
1024
1040
  page_param,
1025
1041
  fetch_fn=self._fetch_fn,
1026
1042
  observer=self,
1027
1043
  cancel_fetch=cancel_fetch,
1044
+ clear=clear,
1028
1045
  )
1029
1046
 
1030
1047
  def set_initial_data(
@@ -1149,7 +1166,7 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1149
1166
  Callable[[TState, list[Page[T, TParam]]], TParam | None] | None
1150
1167
  )
1151
1168
  _max_pages: int
1152
- _key: QueryKey | Callable[[TState], QueryKey] | None
1169
+ _key: Key | Callable[[TState], Key] | None
1153
1170
  # Not using OnSuccessFn and OnErrorFn since unions of callables are not well
1154
1171
  # supported in the type system. We just need to be careful to use
1155
1172
  # call_flexible to invoke these functions.
@@ -1193,7 +1210,17 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1193
1210
  self._on_success_fn = None
1194
1211
  self._on_error_fn = None
1195
1212
  self._initial_data = MISSING
1196
- self._key = key
1213
+ if key is None:
1214
+ self._key = None
1215
+ elif callable(key):
1216
+ key_fn = key
1217
+
1218
+ def normalized_key(state: TState) -> Key:
1219
+ return normalize_key(key_fn(state))
1220
+
1221
+ self._key = normalized_key
1222
+ else:
1223
+ self._key = normalize_key(key)
1197
1224
  self._initial_data_updated_at = initial_data_updated_at
1198
1225
  self._enabled = enabled
1199
1226
  self._fetch_on_mount = fetch_on_mount
@@ -1204,7 +1231,11 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1204
1231
  raise RuntimeError(
1205
1232
  f"Cannot use @{self.name}.key decorator when a key is already provided to @infinite_query(key=...)."
1206
1233
  )
1207
- self._key = fn
1234
+
1235
+ def normalized_key(state: TState) -> Key:
1236
+ return normalize_key(fn(state))
1237
+
1238
+ self._key = normalized_key
1208
1239
  return fn
1209
1240
 
1210
1241
  def on_success(self, fn: OnSuccessFn[TState, list[T]]):
@@ -1278,8 +1309,17 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1278
1309
  )
1279
1310
 
1280
1311
  if self._key is None:
1312
+ # pyright: ignore[reportImplicitStringConcatenation]
1281
1313
  raise RuntimeError(
1282
- f"key is required for infinite query '{self.name}'. Provide a key via @infinite_query(key=...) or @{self.name}.key decorator."
1314
+ f"Missing query key for @infinite_query '{self.name}'. "
1315
+ + "A key is required to cache and share query results.\n\n"
1316
+ + f"Fix: Add key=(...) to the decorator or use the @{self.name}.key decorator:\n\n"
1317
+ + " @ps.infinite_query(initial_page_param=..., key=('my_query',))\n"
1318
+ + f" async def {self.name}(self, param): ...\n\n"
1319
+ + "Or with a dynamic key:\n\n"
1320
+ + f" @{self.name}.key\n"
1321
+ + f" def _{self.name}_key(self):\n"
1322
+ + " return ('my_query', self.some_param)"
1283
1323
  )
1284
1324
  raw_initial = (
1285
1325
  call_flexible(self._initial_data, state)
@@ -1384,6 +1424,7 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1384
1424
  def infinite_query(
1385
1425
  fn: Callable[[TState, TParam], Awaitable[T]],
1386
1426
  *,
1427
+ key: QueryKey | Callable[[TState], QueryKey] | None = None,
1387
1428
  initial_page_param: TParam,
1388
1429
  max_pages: int = 0,
1389
1430
  stale_time: float = 0.0,
@@ -1395,7 +1436,6 @@ def infinite_query(
1395
1436
  initial_data_updated_at: float | dt.datetime | None = None,
1396
1437
  enabled: bool = True,
1397
1438
  fetch_on_mount: bool = True,
1398
- key: QueryKey | None = None,
1399
1439
  ) -> InfiniteQueryProperty[T, TParam, TState]: ...
1400
1440
 
1401
1441
 
@@ -1403,6 +1443,7 @@ def infinite_query(
1403
1443
  def infinite_query(
1404
1444
  fn: None = None,
1405
1445
  *,
1446
+ key: QueryKey | Callable[[TState], QueryKey] | None = None,
1406
1447
  initial_page_param: TParam,
1407
1448
  max_pages: int = 0,
1408
1449
  stale_time: float = 0.0,
@@ -1414,7 +1455,6 @@ def infinite_query(
1414
1455
  initial_data_updated_at: float | dt.datetime | None = None,
1415
1456
  enabled: bool = True,
1416
1457
  fetch_on_mount: bool = True,
1417
- key: QueryKey | None = None,
1418
1458
  ) -> Callable[
1419
1459
  [Callable[[TState, Any], Awaitable[T]]],
1420
1460
  InfiniteQueryProperty[T, TParam, TState],
@@ -1424,6 +1464,7 @@ def infinite_query(
1424
1464
  def infinite_query(
1425
1465
  fn: Callable[[TState, TParam], Awaitable[T]] | None = None,
1426
1466
  *,
1467
+ key: QueryKey | Callable[[TState], QueryKey] | None = None,
1427
1468
  initial_page_param: TParam,
1428
1469
  max_pages: int = 0,
1429
1470
  stale_time: float = 0.0,
@@ -1435,7 +1476,6 @@ def infinite_query(
1435
1476
  initial_data_updated_at: float | dt.datetime | None = None,
1436
1477
  enabled: bool = True,
1437
1478
  fetch_on_mount: bool = True,
1438
- key: QueryKey | None = None,
1439
1479
  ) -> (
1440
1480
  InfiniteQueryProperty[T, TParam, TState]
1441
1481
  | Callable[
@@ -1449,7 +1489,8 @@ def infinite_query(
1449
1489
  pagination. Data is stored as a list of pages, each with its data and the
1450
1490
  parameter used to fetch it.
1451
1491
 
1452
- Requires ``@query_prop.key`` and ``@query_prop.get_next_page_param`` decorators.
1492
+ Requires a key (``key=`` or ``@query_prop.key``) and
1493
+ ``@query_prop.get_next_page_param`` decorator.
1453
1494
 
1454
1495
  Args:
1455
1496
  fn: The async method to decorate (when used without parentheses).