pulse-framework 0.1.69__py3-none-any.whl → 0.1.71__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 CHANGED
@@ -1328,8 +1328,12 @@ from pulse.queries.client import queries as queries
1328
1328
  from pulse.queries.common import ActionError as ActionError
1329
1329
  from pulse.queries.common import ActionResult as ActionResult
1330
1330
  from pulse.queries.common import ActionSuccess as ActionSuccess
1331
+ from pulse.queries.common import Key as Key
1331
1332
  from pulse.queries.common import QueryKey as QueryKey
1333
+ from pulse.queries.common import QueryKeys as QueryKeys
1332
1334
  from pulse.queries.common import QueryStatus as QueryStatus
1335
+ from pulse.queries.common import keys as keys
1336
+ from pulse.queries.common import normalize_key as normalize_key
1333
1337
  from pulse.queries.infinite_query import infinite_query as infinite_query
1334
1338
  from pulse.queries.mutation import mutation as mutation
1335
1339
  from pulse.queries.protocol import QueryResult as QueryResult
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,62 @@ 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:
38
+ raise TypeError("QueryKey values must be hashable") from None
39
+ obj = super().__new__(cls, parts)
40
+ obj._hash = key_hash
41
+ return obj
42
+ raise TypeError("QueryKey must be a list or tuple of hashable values")
43
+
44
+ @override
45
+ def __hash__(self) -> int:
46
+ return self._hash
47
+
48
+
49
+ QueryKey: TypeAlias = tuple[Hashable, ...] | list[Hashable] | Key
50
+ """List/tuple of hashable values identifying a query in the store.
23
51
 
24
52
  Used to uniquely identify queries for caching, deduplication, and invalidation.
25
- Keys are hierarchical tuples like ``("user", user_id)`` or ``("posts", "feed")``.
53
+ Keys are hierarchical lists/tuples like ``("user", user_id)`` or ``["posts", "feed"]``.
54
+ Lists are normalized to a tuple-backed Key internally.
26
55
  """
27
56
 
57
+
58
+ def normalize_key(key: QueryKey) -> Key:
59
+ """Convert a query key to a normalized key for use as a dict key."""
60
+ return Key(key)
61
+
62
+
63
+ @final
64
+ @dataclass(frozen=True, slots=True)
65
+ class QueryKeys:
66
+ """Wrapper for selecting multiple query keys."""
67
+
68
+ keys: tuple[Key, ...]
69
+
70
+ def __init__(self, *keys: QueryKey):
71
+ object.__setattr__(self, "keys", tuple(normalize_key(key) for key in keys))
72
+
73
+
74
+ def keys(*query_keys: QueryKey) -> QueryKeys:
75
+ """Create a QueryKeys wrapper for filtering by multiple keys."""
76
+ return QueryKeys(*query_keys)
77
+
78
+
28
79
  QueryStatus: TypeAlias = Literal["loading", "success", "error"]
29
80
  """Current status of a query.
30
81
 
@@ -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."""
@@ -703,8 +708,8 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
703
708
 
704
709
  page = await action.fetch_fn(action.param)
705
710
 
706
- if idx is None:
707
- # Page doesn't exist - jump to this page, clearing existing pages
711
+ if action.clear or idx is None:
712
+ # clear=True or page doesn't exist - replace all pages with just this one
708
713
  self.pages.clear()
709
714
  self.pages.append(Page(page, action.param))
710
715
  else:
@@ -782,12 +787,13 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
782
787
  *,
783
788
  observer: "InfiniteQueryResult[T, TParam] | None" = None,
784
789
  cancel_fetch: bool = False,
790
+ clear: bool = False,
785
791
  ) -> ActionResult[T | None]:
786
792
  """
787
793
  Refetch a page by its param. Queued for sequential execution.
788
794
 
789
- If the page doesn't exist, clears existing pages and loads the requested
790
- page as the new starting point.
795
+ If the page doesn't exist or clear=True, clears existing pages and loads
796
+ the requested page as the new starting point.
791
797
 
792
798
  Note: Prefer calling refetch_page() on InfiniteQueryResult to ensure the
793
799
  correct fetch function is used. When called directly on InfiniteQuery, uses
@@ -795,12 +801,13 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
795
801
  """
796
802
  fn = fetch_fn if fetch_fn is not None else self.fn
797
803
  action: RefetchPage[T, TParam] = RefetchPage(
798
- fetch_fn=fn, param=param, observer=observer
804
+ fetch_fn=fn, param=param, observer=observer, clear=clear
799
805
  )
800
806
  return await self._enqueue(action, cancel_fetch=cancel_fetch)
801
807
 
802
808
  @override
803
809
  def dispose(self):
810
+ self.cancel_gc()
804
811
  self._cancel_queue()
805
812
  if self._queue_task and not self._queue_task.done():
806
813
  self._queue_task.cancel()
@@ -1018,12 +1025,22 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
1018
1025
  page_param: TParam,
1019
1026
  *,
1020
1027
  cancel_fetch: bool = False,
1028
+ clear: bool = False,
1021
1029
  ) -> ActionResult[T | None]:
1030
+ """Fetch a specific page by its param.
1031
+
1032
+ Args:
1033
+ page_param: The page parameter to fetch.
1034
+ cancel_fetch: Cancel any in-flight fetches before starting.
1035
+ clear: If True, clears all other pages and keeps only the fetched page.
1036
+ Useful for resetting pagination to a specific page.
1037
+ """
1022
1038
  return await self._query().refetch_page(
1023
1039
  page_param,
1024
1040
  fetch_fn=self._fetch_fn,
1025
1041
  observer=self,
1026
1042
  cancel_fetch=cancel_fetch,
1043
+ clear=clear,
1027
1044
  )
1028
1045
 
1029
1046
  def set_initial_data(
@@ -1148,7 +1165,7 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1148
1165
  Callable[[TState, list[Page[T, TParam]]], TParam | None] | None
1149
1166
  )
1150
1167
  _max_pages: int
1151
- _key: QueryKey | Callable[[TState], QueryKey] | None
1168
+ _key: Key | Callable[[TState], Key] | None
1152
1169
  # Not using OnSuccessFn and OnErrorFn since unions of callables are not well
1153
1170
  # supported in the type system. We just need to be careful to use
1154
1171
  # call_flexible to invoke these functions.
@@ -1192,7 +1209,17 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1192
1209
  self._on_success_fn = None
1193
1210
  self._on_error_fn = None
1194
1211
  self._initial_data = MISSING
1195
- self._key = key
1212
+ if key is None:
1213
+ self._key = None
1214
+ elif callable(key):
1215
+ key_fn = key
1216
+
1217
+ def normalized_key(state: TState) -> Key:
1218
+ return normalize_key(key_fn(state))
1219
+
1220
+ self._key = normalized_key
1221
+ else:
1222
+ self._key = normalize_key(key)
1196
1223
  self._initial_data_updated_at = initial_data_updated_at
1197
1224
  self._enabled = enabled
1198
1225
  self._fetch_on_mount = fetch_on_mount
@@ -1203,7 +1230,11 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1203
1230
  raise RuntimeError(
1204
1231
  f"Cannot use @{self.name}.key decorator when a key is already provided to @infinite_query(key=...)."
1205
1232
  )
1206
- self._key = fn
1233
+
1234
+ def normalized_key(state: TState) -> Key:
1235
+ return normalize_key(fn(state))
1236
+
1237
+ self._key = normalized_key
1207
1238
  return fn
1208
1239
 
1209
1240
  def on_success(self, fn: OnSuccessFn[TState, list[T]]):
@@ -1383,6 +1414,7 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1383
1414
  def infinite_query(
1384
1415
  fn: Callable[[TState, TParam], Awaitable[T]],
1385
1416
  *,
1417
+ key: QueryKey | Callable[[TState], QueryKey] | None = None,
1386
1418
  initial_page_param: TParam,
1387
1419
  max_pages: int = 0,
1388
1420
  stale_time: float = 0.0,
@@ -1394,7 +1426,6 @@ def infinite_query(
1394
1426
  initial_data_updated_at: float | dt.datetime | None = None,
1395
1427
  enabled: bool = True,
1396
1428
  fetch_on_mount: bool = True,
1397
- key: QueryKey | None = None,
1398
1429
  ) -> InfiniteQueryProperty[T, TParam, TState]: ...
1399
1430
 
1400
1431
 
@@ -1402,6 +1433,7 @@ def infinite_query(
1402
1433
  def infinite_query(
1403
1434
  fn: None = None,
1404
1435
  *,
1436
+ key: QueryKey | Callable[[TState], QueryKey] | None = None,
1405
1437
  initial_page_param: TParam,
1406
1438
  max_pages: int = 0,
1407
1439
  stale_time: float = 0.0,
@@ -1413,7 +1445,6 @@ def infinite_query(
1413
1445
  initial_data_updated_at: float | dt.datetime | None = None,
1414
1446
  enabled: bool = True,
1415
1447
  fetch_on_mount: bool = True,
1416
- key: QueryKey | None = None,
1417
1448
  ) -> Callable[
1418
1449
  [Callable[[TState, Any], Awaitable[T]]],
1419
1450
  InfiniteQueryProperty[T, TParam, TState],
@@ -1423,6 +1454,7 @@ def infinite_query(
1423
1454
  def infinite_query(
1424
1455
  fn: Callable[[TState, TParam], Awaitable[T]] | None = None,
1425
1456
  *,
1457
+ key: QueryKey | Callable[[TState], QueryKey] | None = None,
1426
1458
  initial_page_param: TParam,
1427
1459
  max_pages: int = 0,
1428
1460
  stale_time: float = 0.0,
@@ -1434,7 +1466,6 @@ def infinite_query(
1434
1466
  initial_data_updated_at: float | dt.datetime | None = None,
1435
1467
  enabled: bool = True,
1436
1468
  fetch_on_mount: bool = True,
1437
- key: QueryKey | None = None,
1438
1469
  ) -> (
1439
1470
  InfiniteQueryProperty[T, TParam, TState]
1440
1471
  | Callable[
@@ -1448,7 +1479,8 @@ def infinite_query(
1448
1479
  pagination. Data is stored as a list of pages, each with its data and the
1449
1480
  parameter used to fetch it.
1450
1481
 
1451
- Requires ``@query_prop.key`` and ``@query_prop.get_next_page_param`` decorators.
1482
+ Requires a key (``key=`` or ``@query_prop.key``) and
1483
+ ``@query_prop.get_next_page_param`` decorator.
1452
1484
 
1453
1485
  Args:
1454
1486
  fn: The async method to decorate (when used without parentheses).
pulse/queries/query.py CHANGED
@@ -26,11 +26,13 @@ from pulse.queries.common import (
26
26
  ActionError,
27
27
  ActionResult,
28
28
  ActionSuccess,
29
+ Key,
29
30
  OnErrorFn,
30
31
  OnSuccessFn,
31
32
  QueryKey,
32
33
  QueryStatus,
33
34
  bind_state,
35
+ normalize_key,
34
36
  )
35
37
  from pulse.queries.effect import AsyncQueryEffect
36
38
  from pulse.reactive import Computed, Effect, Signal, Untrack
@@ -239,7 +241,8 @@ async def run_fetch_with_retries(
239
241
  result = await fetch_fn()
240
242
  state.set_success(result)
241
243
  if on_success:
242
- await maybe_await(call_flexible(on_success, result))
244
+ with Untrack():
245
+ await maybe_await(call_flexible(on_success, result))
243
246
  return
244
247
  except asyncio.CancelledError:
245
248
  raise
@@ -252,7 +255,8 @@ async def run_fetch_with_retries(
252
255
  state.retry_reason.write(e)
253
256
  state.apply_error(e)
254
257
  if on_error:
255
- await maybe_await(call_flexible(on_error, e))
258
+ with Untrack():
259
+ await maybe_await(call_flexible(on_error, e))
256
260
  return
257
261
 
258
262
 
@@ -263,7 +267,7 @@ class KeyedQuery(Generic[T], Disposable):
263
267
  Multiple observers can share the same query.
264
268
  """
265
269
 
266
- key: QueryKey
270
+ key: Key
267
271
  state: QueryState[T]
268
272
  observers: "list[KeyedQueryResult[T]]"
269
273
  _task: asyncio.Task[None] | None
@@ -283,7 +287,7 @@ class KeyedQuery(Generic[T], Disposable):
283
287
  gc_time: float = 300.0,
284
288
  on_dispose: Callable[[Any], None] | None = None,
285
289
  ):
286
- self.key = key
290
+ self.key = normalize_key(key)
287
291
  self.state = QueryState(
288
292
  name=str(key),
289
293
  retries=retries,
@@ -576,6 +580,7 @@ class KeyedQuery(Generic[T], Disposable):
576
580
  @override
577
581
  def dispose(self):
578
582
  """Clean up the query, cancelling any in-flight fetch."""
583
+ self.cancel_gc()
579
584
  self.cancel()
580
585
  if self._interval_effect is not None:
581
586
  self._interval_effect.dispose()
@@ -1054,7 +1059,7 @@ class QueryProperty(Generic[T, TState], InitializableProperty):
1054
1059
  _initial_data_updated_at: float | dt.datetime | None
1055
1060
  _enabled: bool
1056
1061
  _initial_data: T | Callable[[TState], T] | Missing | None
1057
- _key: QueryKey | Callable[[TState], QueryKey] | None
1062
+ _key: Key | Callable[[TState], Key] | None
1058
1063
  # Not using OnSuccessFn and OnErrorFn since unions of callables are not well
1059
1064
  # supported in the type system. We just need to be careful to use
1060
1065
  # call_flexible to invoke these functions.
@@ -1080,7 +1085,17 @@ class QueryProperty(Generic[T, TState], InitializableProperty):
1080
1085
  ):
1081
1086
  self.name = name
1082
1087
  self._fetch_fn = fetch_fn
1083
- self._key = key
1088
+ if key is None:
1089
+ self._key = None
1090
+ elif callable(key):
1091
+ key_fn = key
1092
+
1093
+ def normalized_key(state: TState) -> Key:
1094
+ return normalize_key(key_fn(state))
1095
+
1096
+ self._key = normalized_key
1097
+ else:
1098
+ self._key = normalize_key(key)
1084
1099
  self._on_success_fn = None
1085
1100
  self._on_error_fn = None
1086
1101
  self._keep_previous_data = keep_previous_data
@@ -1101,7 +1116,11 @@ class QueryProperty(Generic[T, TState], InitializableProperty):
1101
1116
  raise RuntimeError(
1102
1117
  f"Cannot use @{self.name}.key decorator when a key is already provided to @query(key=...)."
1103
1118
  )
1104
- self._key = fn
1119
+
1120
+ def normalized_key(state: TState) -> Key:
1121
+ return normalize_key(fn(state))
1122
+
1123
+ self._key = normalized_key
1105
1124
  return fn
1106
1125
 
1107
1126
  # Decorator to attach a function providing initial data
@@ -1269,6 +1288,7 @@ class QueryProperty(Generic[T, TState], InitializableProperty):
1269
1288
  def query(
1270
1289
  fn: Callable[[TState], Awaitable[T]],
1271
1290
  *,
1291
+ key: QueryKey | Callable[[TState], QueryKey] | None = None,
1272
1292
  stale_time: float = 0.0,
1273
1293
  gc_time: float | None = 300.0,
1274
1294
  refetch_interval: float | None = None,
@@ -1278,7 +1298,6 @@ def query(
1278
1298
  initial_data_updated_at: float | dt.datetime | None = None,
1279
1299
  enabled: bool = True,
1280
1300
  fetch_on_mount: bool = True,
1281
- key: QueryKey | None = None,
1282
1301
  ) -> QueryProperty[T, TState]: ...
1283
1302
 
1284
1303
 
@@ -1286,6 +1305,7 @@ def query(
1286
1305
  def query(
1287
1306
  fn: None = None,
1288
1307
  *,
1308
+ key: QueryKey | Callable[[TState], QueryKey] | None = None,
1289
1309
  stale_time: float = 0.0,
1290
1310
  gc_time: float | None = 300.0,
1291
1311
  refetch_interval: float | None = None,
@@ -1295,13 +1315,13 @@ def query(
1295
1315
  initial_data_updated_at: float | dt.datetime | None = None,
1296
1316
  enabled: bool = True,
1297
1317
  fetch_on_mount: bool = True,
1298
- key: QueryKey | None = None,
1299
1318
  ) -> Callable[[Callable[[TState], Awaitable[T]]], QueryProperty[T, TState]]: ...
1300
1319
 
1301
1320
 
1302
1321
  def query(
1303
1322
  fn: Callable[[TState], Awaitable[T]] | None = None,
1304
1323
  *,
1324
+ key: QueryKey | Callable[[TState], QueryKey] | None = None,
1305
1325
  stale_time: float = 0.0,
1306
1326
  gc_time: float | None = 300.0,
1307
1327
  refetch_interval: float | None = None,
@@ -1311,7 +1331,6 @@ def query(
1311
1331
  initial_data_updated_at: float | dt.datetime | None = None,
1312
1332
  enabled: bool = True,
1313
1333
  fetch_on_mount: bool = True,
1314
- key: QueryKey | None = None,
1315
1334
  ) -> (
1316
1335
  QueryProperty[T, TState]
1317
1336
  | Callable[[Callable[[TState], Awaitable[T]]], QueryProperty[T, TState]]
pulse/queries/store.py CHANGED
@@ -3,7 +3,7 @@ from collections.abc import Callable
3
3
  from typing import Any, TypeVar, cast
4
4
 
5
5
  from pulse.helpers import MISSING, Missing
6
- from pulse.queries.common import QueryKey
6
+ from pulse.queries.common import Key, QueryKey, normalize_key
7
7
  from pulse.queries.infinite_query import InfiniteQuery, Page
8
8
  from pulse.queries.query import RETRY_DELAY_DEFAULT, KeyedQuery
9
9
 
@@ -16,7 +16,7 @@ class QueryStore:
16
16
  """
17
17
 
18
18
  def __init__(self):
19
- self._entries: dict[QueryKey, KeyedQuery[Any] | InfiniteQuery[Any, Any]] = {}
19
+ self._entries: dict[Key, KeyedQuery[Any] | InfiniteQuery[Any, Any]] = {}
20
20
 
21
21
  def items(self):
22
22
  """Iterate over all (key, query) pairs in the store."""
@@ -24,7 +24,7 @@ class QueryStore:
24
24
 
25
25
  def get_any(self, key: QueryKey):
26
26
  """Get any query (regular or infinite) by key, or None if not found."""
27
- return self._entries.get(key)
27
+ return self._entries.get(normalize_key(key))
28
28
 
29
29
  def ensure(
30
30
  self,
@@ -35,8 +35,9 @@ class QueryStore:
35
35
  retries: int = 3,
36
36
  retry_delay: float = RETRY_DELAY_DEFAULT,
37
37
  ) -> KeyedQuery[T]:
38
+ nkey = normalize_key(key)
38
39
  # Return existing entry if present
39
- existing = self._entries.get(key)
40
+ existing = self._entries.get(nkey)
40
41
  if existing:
41
42
  if isinstance(existing, InfiniteQuery):
42
43
  raise TypeError(
@@ -49,7 +50,7 @@ class QueryStore:
49
50
  del self._entries[e.key]
50
51
 
51
52
  entry = KeyedQuery(
52
- key,
53
+ nkey,
53
54
  initial_data=initial_data,
54
55
  initial_data_updated_at=initial_data_updated_at,
55
56
  gc_time=gc_time,
@@ -57,14 +58,14 @@ class QueryStore:
57
58
  retry_delay=retry_delay,
58
59
  on_dispose=_on_dispose,
59
60
  )
60
- self._entries[key] = entry
61
+ self._entries[nkey] = entry
61
62
  return entry
62
63
 
63
64
  def get(self, key: QueryKey) -> KeyedQuery[Any] | None:
64
65
  """
65
66
  Get an existing regular query by key, or None if not found.
66
67
  """
67
- existing = self._entries.get(key)
68
+ existing = self._entries.get(normalize_key(key))
68
69
  if existing and isinstance(existing, InfiniteQuery):
69
70
  return None
70
71
  return existing
@@ -73,7 +74,7 @@ class QueryStore:
73
74
  """
74
75
  Get an existing infinite query by key, or None if not found.
75
76
  """
76
- existing = self._entries.get(key)
77
+ existing = self._entries.get(normalize_key(key))
77
78
  if existing and isinstance(existing, InfiniteQuery):
78
79
  return existing
79
80
  return None
@@ -93,7 +94,8 @@ class QueryStore:
93
94
  retries: int = 3,
94
95
  retry_delay: float = RETRY_DELAY_DEFAULT,
95
96
  ) -> InfiniteQuery[Any, Any]:
96
- existing = self._entries.get(key)
97
+ nkey = normalize_key(key)
98
+ existing = self._entries.get(nkey)
97
99
  if existing:
98
100
  if not isinstance(existing, InfiniteQuery):
99
101
  raise TypeError(
@@ -106,7 +108,7 @@ class QueryStore:
106
108
  del self._entries[e.key]
107
109
 
108
110
  entry = InfiniteQuery(
109
- key,
111
+ nkey,
110
112
  initial_page_param=initial_page_param,
111
113
  get_next_page_param=get_next_page_param,
112
114
  get_previous_page_param=get_previous_page_param,
@@ -118,7 +120,7 @@ class QueryStore:
118
120
  retry_delay=retry_delay,
119
121
  on_dispose=_on_dispose,
120
122
  )
121
- self._entries[key] = entry
123
+ self._entries[nkey] = entry
122
124
  return entry
123
125
 
124
126
  def dispose_all(self) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.69
3
+ Version: 0.1.71
4
4
  Summary: Pulse - Full-stack framework for building real-time React applications in Python
5
5
  Requires-Dist: websockets>=12.0
6
6
  Requires-Dist: fastapi>=0.128.0
@@ -1,4 +1,4 @@
1
- pulse/__init__.py,sha256=cXNVXz0aizbkOG1aj2zytgzodyVNv7nNylsXcWmH-Lc,32183
1
+ pulse/__init__.py,sha256=yeGbAC9Nkp5ug4sfi3U1epaRfI1_D0zBbMtPQpPEBHA,32393
2
2
  pulse/_examples.py,sha256=dFuhD2EVXsbvAeexoG57s4VuN4gWLaTMOEMNYvlPm9A,561
3
3
  pulse/app.py,sha256=TjMP21lpwYpRnkMkeKL8EA22V9xSKTA6_5GQI9Btw_o,36059
4
4
  pulse/channel.py,sha256=ePpvD2mDbddt_LMxxxDjNRgOLbVi8Ed6TmJFgkrALB0,15790
@@ -77,14 +77,14 @@ pulse/plugin.py,sha256=bu90qaUVFtZsIsW41dpshVK1vvIGHUsg6mFoiF0Wfso,2370
77
77
  pulse/proxy.py,sha256=tj30GIJJVNKMxMwPNJ39-gn3PAXbx5wua4PiQ7XupvQ,7857
78
78
  pulse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
79
79
  pulse/queries/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
80
- pulse/queries/client.py,sha256=52T4MvorDr-T5UsJlndcEgnrPDs3QxIYP-MZtBSEuvc,18583
81
- pulse/queries/common.py,sha256=TYhn6LyldfmOKYYurxINgCEr3C3WSEwB0cIki1a5iBM,2488
80
+ pulse/queries/client.py,sha256=KMGT92dESMrzpLlhd701fyh7Wrs3VKmM5cZoRQ0AEzg,18994
81
+ pulse/queries/common.py,sha256=24EK5AWeunTeC7xObG3xufOjqLUNvnLN7PEECtlgqGU,3705
82
82
  pulse/queries/effect.py,sha256=1ePUi2TwP49L9LhlkKI2qV_HhIO4jKj1r5jyPaWiUn8,1508
83
- pulse/queries/infinite_query.py,sha256=xWmFl5UqW7DXkquR-8EUPqlS6Z-ZF4l7qarEUUXjdN0,49091
83
+ pulse/queries/infinite_query.py,sha256=sfZ_nwznD8kO_zWOvJ9uiyhJRb8slN1QMYWgzUnRvVQ,50025
84
84
  pulse/queries/mutation.py,sha256=fhEpOZ7CuHImH4Y02QapYdTJrwe6K52-keb0d67wmms,8274
85
85
  pulse/queries/protocol.py,sha256=TOrUiI4QK55xuh0i4ch1u96apNl12QeYafkf6RVDd08,3544
86
- pulse/queries/query.py,sha256=2VlYMeLqHfEokoEtocKjld8zi6Oy-_lieV4baTfF5DU,41332
87
- pulse/queries/store.py,sha256=4pWTDSl71LUM7YqhWanKjZkFh3t8F_04o48js_H4ttQ,3728
86
+ pulse/queries/query.py,sha256=R5XXQ32pCSNX5tpedietIlI4saT1MVIJnR6ElnKQyVk,41819
87
+ pulse/queries/store.py,sha256=iw05_EFpyfiXv5_FV_x4aHtCo00mk0dDPFD461cajcg,3850
88
88
  pulse/react_component.py,sha256=8RLg4Bi7IcjqbnbEnp4hJpy8t1UsE7mG0UR1Q655LDk,2332
89
89
  pulse/reactive.py,sha256=GSh9wSH3THCBjDTafwWttyx7djeKBWV_KqjaKRYUNsA,31393
90
90
  pulse/reactive_extensions.py,sha256=yQ1PpdAh4kMvll7R15T72FOg8NFdG_HGBsGc63dawYk,33754
@@ -122,7 +122,7 @@ pulse/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
122
122
  pulse/types/event_handler.py,sha256=psQCydj-WEtBcFU5JU4mDwvyzkW8V2O0g_VFRU2EOHI,1618
123
123
  pulse/user_session.py,sha256=nsnsMgqq2xGJZLpbHRMHUHcLrElMP8WcA4gjGMrcoBk,10208
124
124
  pulse/version.py,sha256=711vaM1jVIQPgkisGgKZqwmw019qZIsc_QTae75K2pg,1895
125
- pulse_framework-0.1.69.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
126
- pulse_framework-0.1.69.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
127
- pulse_framework-0.1.69.dist-info/METADATA,sha256=X4vr-LRgEilEvL7pxzvDIz-mY7gq85oU6nVjaDILngc,8300
128
- pulse_framework-0.1.69.dist-info/RECORD,,
125
+ pulse_framework-0.1.71.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
126
+ pulse_framework-0.1.71.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
127
+ pulse_framework-0.1.71.dist-info/METADATA,sha256=emzemQv95geCAUlozNCamUpuVbiVyo2REoNCYsAx3JI,8300
128
+ pulse_framework-0.1.71.dist-info/RECORD,,