pulse-framework 0.1.42__py3-none-any.whl → 0.1.44__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.
@@ -0,0 +1,462 @@
1
+ import datetime as dt
2
+ from collections.abc import Callable
3
+ from typing import Any, TypeVar, overload
4
+
5
+ from pulse.context import PulseContext
6
+ from pulse.queries.common import ActionResult, QueryKey
7
+ from pulse.queries.infinite_query import InfiniteQuery, Page
8
+ from pulse.queries.query import Query
9
+
10
+ T = TypeVar("T")
11
+
12
+ # Query filter types
13
+ QueryFilter = (
14
+ QueryKey # exact key match
15
+ | list[QueryKey] # explicit list of keys
16
+ | Callable[[QueryKey], bool] # predicate function
17
+ )
18
+
19
+
20
+ def _normalize_filter(
21
+ filter: QueryFilter | None,
22
+ ) -> Callable[[QueryKey], bool] | None:
23
+ """Convert any QueryFilter to a predicate function."""
24
+ if filter is None:
25
+ return None
26
+ if isinstance(filter, tuple):
27
+ # Exact key match
28
+ exact_key = filter
29
+ return lambda k: k == exact_key
30
+ if isinstance(filter, list):
31
+ # List of keys
32
+ key_set = set(filter)
33
+ return lambda k: k in key_set
34
+ # Already a callable predicate
35
+ return filter
36
+
37
+
38
+ def _prefix_filter(prefix: tuple[Any, ...]) -> Callable[[QueryKey], bool]:
39
+ """Create a predicate that matches keys starting with the given prefix."""
40
+ prefix_len = len(prefix)
41
+ return lambda k: len(k) >= prefix_len and k[:prefix_len] == prefix
42
+
43
+
44
+ class QueryClient:
45
+ """
46
+ Client for managing queries and infinite queries in a session.
47
+
48
+ Provides methods to get, set, invalidate, and refetch queries by key
49
+ or using filter predicates.
50
+
51
+ Automatically resolves to the current RenderSession's query store.
52
+ """
53
+
54
+ def _get_store(self):
55
+ """Get the query store from the current PulseContext."""
56
+ render = PulseContext.get().render
57
+ if render is None:
58
+ raise RuntimeError("No render session available")
59
+ return render.query_store
60
+
61
+ # ─────────────────────────────────────────────────────────────────────────
62
+ # Query accessors
63
+ # ─────────────────────────────────────────────────────────────────────────
64
+
65
+ def get(self, key: QueryKey) -> Query[Any] | None:
66
+ """Get an existing regular query by key, or None if not found."""
67
+ return self._get_store().get(key)
68
+
69
+ def get_infinite(self, key: QueryKey) -> InfiniteQuery[Any, Any] | None:
70
+ """Get an existing infinite query by key, or None if not found."""
71
+ return self._get_store().get_infinite(key)
72
+
73
+ def get_all(
74
+ self,
75
+ filter: QueryFilter | None = None,
76
+ *,
77
+ include_infinite: bool = True,
78
+ ) -> list[Query[Any] | InfiniteQuery[Any, Any]]:
79
+ """
80
+ Get all queries matching the filter.
81
+
82
+ Args:
83
+ filter: Optional filter - can be an exact key, list of keys, or predicate.
84
+ If None, returns all queries.
85
+ include_infinite: Whether to include infinite queries (default True).
86
+
87
+ Returns:
88
+ List of matching Query or InfiniteQuery instances.
89
+ """
90
+ store = self._get_store()
91
+ predicate = _normalize_filter(filter)
92
+ results: list[Query[Any] | InfiniteQuery[Any, Any]] = []
93
+
94
+ for key, entry in store.items():
95
+ if predicate is not None and not predicate(key):
96
+ continue
97
+ if not include_infinite and isinstance(entry, InfiniteQuery):
98
+ continue
99
+ results.append(entry)
100
+
101
+ return results
102
+
103
+ def get_queries(self, filter: QueryFilter | None = None) -> list[Query[Any]]:
104
+ """Get all regular queries matching the filter."""
105
+ store = self._get_store()
106
+ predicate = _normalize_filter(filter)
107
+ results: list[Query[Any]] = []
108
+
109
+ for key, entry in store.items():
110
+ if isinstance(entry, InfiniteQuery):
111
+ continue
112
+ if predicate is not None and not predicate(key):
113
+ continue
114
+ results.append(entry)
115
+
116
+ return results
117
+
118
+ def get_infinite_queries(
119
+ self, filter: QueryFilter | None = None
120
+ ) -> list[InfiniteQuery[Any, Any]]:
121
+ """Get all infinite queries matching the filter."""
122
+ store = self._get_store()
123
+ predicate = _normalize_filter(filter)
124
+ results: list[InfiniteQuery[Any, Any]] = []
125
+
126
+ for key, entry in store.items():
127
+ if not isinstance(entry, InfiniteQuery):
128
+ continue
129
+ if predicate is not None and not predicate(key):
130
+ continue
131
+ results.append(entry)
132
+
133
+ return results
134
+
135
+ # ─────────────────────────────────────────────────────────────────────────
136
+ # Data accessors
137
+ # ─────────────────────────────────────────────────────────────────────────
138
+
139
+ def get_data(self, key: QueryKey) -> Any | None:
140
+ """Get the data for a query by key. Returns None if not found or no data."""
141
+ query = self.get(key)
142
+ if query is None:
143
+ return None
144
+ return query.data.read()
145
+
146
+ def get_infinite_data(self, key: QueryKey) -> list[Page[Any, Any]] | None:
147
+ """Get the pages for an infinite query by key."""
148
+ query = self.get_infinite(key)
149
+ if query is None:
150
+ return None
151
+ return list(query.pages)
152
+
153
+ # ─────────────────────────────────────────────────────────────────────────
154
+ # Data setters
155
+ # ─────────────────────────────────────────────────────────────────────────
156
+
157
+ @overload
158
+ def set_data(
159
+ self,
160
+ key_or_filter: QueryKey,
161
+ data: T | Callable[[T | None], T],
162
+ *,
163
+ updated_at: float | dt.datetime | None = None,
164
+ ) -> bool: ...
165
+
166
+ @overload
167
+ def set_data(
168
+ self,
169
+ key_or_filter: list[QueryKey] | Callable[[QueryKey], bool],
170
+ data: Callable[[Any], Any],
171
+ *,
172
+ updated_at: float | dt.datetime | None = None,
173
+ ) -> int: ...
174
+
175
+ def set_data(
176
+ self,
177
+ key_or_filter: QueryKey | list[QueryKey] | Callable[[QueryKey], bool],
178
+ data: Any | Callable[[Any], Any],
179
+ *,
180
+ updated_at: float | dt.datetime | None = None,
181
+ ) -> bool | int:
182
+ """
183
+ Set data for queries matching the key or filter.
184
+
185
+ When using a single key, returns True if query exists and was updated.
186
+ When using a filter, returns count of updated queries.
187
+
188
+ Args:
189
+ key_or_filter: Exact key or filter predicate.
190
+ data: New data value or updater function.
191
+ updated_at: Optional timestamp to set.
192
+
193
+ Returns:
194
+ bool if exact key, int count if filter.
195
+ """
196
+ # Single key case
197
+ if isinstance(key_or_filter, tuple):
198
+ query = self.get(key_or_filter)
199
+ if query is None:
200
+ return False
201
+ query.set_data(data, updated_at=updated_at)
202
+ return True
203
+
204
+ # Filter case
205
+ queries = self.get_queries(key_or_filter)
206
+ for q in queries:
207
+ q.set_data(data, updated_at=updated_at)
208
+ return len(queries)
209
+
210
+ def set_infinite_data(
211
+ self,
212
+ key: QueryKey,
213
+ pages: list[Page[Any, Any]]
214
+ | Callable[[list[Page[Any, Any]]], list[Page[Any, Any]]],
215
+ *,
216
+ updated_at: float | dt.datetime | None = None,
217
+ ) -> bool:
218
+ """Set pages for an infinite query by key."""
219
+ query = self.get_infinite(key)
220
+ if query is None:
221
+ return False
222
+ query.set_data(pages, updated_at=updated_at)
223
+ return True
224
+
225
+ # ─────────────────────────────────────────────────────────────────────────
226
+ # Invalidation
227
+ # ─────────────────────────────────────────────────────────────────────────
228
+
229
+ @overload
230
+ def invalidate(
231
+ self,
232
+ key_or_filter: QueryKey,
233
+ *,
234
+ cancel_refetch: bool = False,
235
+ ) -> bool: ...
236
+
237
+ @overload
238
+ def invalidate(
239
+ self,
240
+ key_or_filter: list[QueryKey] | Callable[[QueryKey], bool] | None = None,
241
+ *,
242
+ cancel_refetch: bool = False,
243
+ ) -> int: ...
244
+
245
+ def invalidate(
246
+ self,
247
+ key_or_filter: QueryKey
248
+ | list[QueryKey]
249
+ | Callable[[QueryKey], bool]
250
+ | None = None,
251
+ *,
252
+ cancel_refetch: bool = False,
253
+ ) -> bool | int:
254
+ """
255
+ Invalidate queries matching the key or filter.
256
+
257
+ For regular queries: marks as stale and refetches if observed.
258
+ For infinite queries: triggers refetch of all pages if observed.
259
+
260
+ Args:
261
+ key_or_filter: Exact key, filter predicate, or None for all.
262
+ cancel_refetch: Cancel in-flight requests before refetch.
263
+
264
+ Returns:
265
+ bool if exact key, int count if filter/None.
266
+ """
267
+ # Single key case
268
+ if isinstance(key_or_filter, tuple):
269
+ query = self.get(key_or_filter)
270
+ if query is not None:
271
+ query.invalidate(cancel_refetch=cancel_refetch)
272
+ return True
273
+ inf_query = self.get_infinite(key_or_filter)
274
+ if inf_query is not None:
275
+ inf_query.invalidate(cancel_fetch=cancel_refetch)
276
+ return True
277
+ return False
278
+
279
+ # Filter case
280
+ queries = self.get_all(key_or_filter)
281
+ for q in queries:
282
+ if isinstance(q, InfiniteQuery):
283
+ q.invalidate(cancel_fetch=cancel_refetch)
284
+ else:
285
+ q.invalidate(cancel_refetch=cancel_refetch)
286
+ return len(queries)
287
+
288
+ def invalidate_prefix(
289
+ self,
290
+ prefix: tuple[Any, ...],
291
+ *,
292
+ cancel_refetch: bool = False,
293
+ ) -> int:
294
+ """
295
+ Invalidate all queries whose keys start with the given prefix.
296
+
297
+ Example:
298
+ ps.queries.invalidate_prefix(("users",)) # invalidates ("users",), ("users", 1), etc.
299
+ """
300
+ return self.invalidate(_prefix_filter(prefix), cancel_refetch=cancel_refetch)
301
+
302
+ # ─────────────────────────────────────────────────────────────────────────
303
+ # Refetch
304
+ # ─────────────────────────────────────────────────────────────────────────
305
+
306
+ async def refetch(
307
+ self,
308
+ key: QueryKey,
309
+ *,
310
+ cancel_refetch: bool = True,
311
+ ) -> ActionResult[Any] | None:
312
+ """
313
+ Refetch a query by key and return the result.
314
+
315
+ Returns None if the query doesn't exist.
316
+ """
317
+ query = self.get(key)
318
+ if query is not None:
319
+ return await query.refetch(cancel_refetch=cancel_refetch)
320
+
321
+ inf_query = self.get_infinite(key)
322
+ if inf_query is not None:
323
+ return await inf_query.refetch(cancel_fetch=cancel_refetch)
324
+
325
+ return None
326
+
327
+ async def refetch_all(
328
+ self,
329
+ filter: QueryFilter | None = None,
330
+ *,
331
+ cancel_refetch: bool = True,
332
+ ) -> list[ActionResult[Any]]:
333
+ """
334
+ Refetch all queries matching the filter.
335
+
336
+ Returns list of ActionResult for each refetched query.
337
+ """
338
+ queries = self.get_all(filter)
339
+ results: list[ActionResult[Any]] = []
340
+
341
+ for q in queries:
342
+ if isinstance(q, InfiniteQuery):
343
+ result = await q.refetch(cancel_fetch=cancel_refetch)
344
+ else:
345
+ result = await q.refetch(cancel_refetch=cancel_refetch)
346
+ results.append(result)
347
+
348
+ return results
349
+
350
+ async def refetch_prefix(
351
+ self,
352
+ prefix: tuple[Any, ...],
353
+ *,
354
+ cancel_refetch: bool = True,
355
+ ) -> list[ActionResult[Any]]:
356
+ """Refetch all queries whose keys start with the given prefix."""
357
+ return await self.refetch_all(
358
+ _prefix_filter(prefix), cancel_refetch=cancel_refetch
359
+ )
360
+
361
+ # ─────────────────────────────────────────────────────────────────────────
362
+ # Error handling
363
+ # ─────────────────────────────────────────────────────────────────────────
364
+
365
+ def set_error(
366
+ self,
367
+ key: QueryKey,
368
+ error: Exception,
369
+ *,
370
+ updated_at: float | dt.datetime | None = None,
371
+ ) -> bool:
372
+ """Set error state on a query by key."""
373
+ query = self.get(key)
374
+ if query is not None:
375
+ query.set_error(error, updated_at=updated_at)
376
+ return True
377
+
378
+ inf_query = self.get_infinite(key)
379
+ if inf_query is not None:
380
+ inf_query.set_error(error, updated_at=updated_at)
381
+ return True
382
+
383
+ return False
384
+
385
+ # ─────────────────────────────────────────────────────────────────────────
386
+ # Reset / Remove
387
+ # ─────────────────────────────────────────────────────────────────────────
388
+
389
+ def remove(self, key: QueryKey) -> bool:
390
+ """
391
+ Remove a query from the store, disposing it.
392
+
393
+ Returns True if query existed and was removed.
394
+ """
395
+ store = self._get_store()
396
+ entry = store.get_any(key)
397
+ if entry is None:
398
+ return False
399
+ entry.dispose()
400
+ return True
401
+
402
+ def remove_all(self, filter: QueryFilter | None = None) -> int:
403
+ """
404
+ Remove all queries matching the filter.
405
+
406
+ Returns count of removed queries.
407
+ """
408
+ queries = self.get_all(filter)
409
+ for q in queries:
410
+ q.dispose()
411
+ return len(queries)
412
+
413
+ def remove_prefix(self, prefix: tuple[Any, ...]) -> int:
414
+ """Remove all queries whose keys start with the given prefix."""
415
+ return self.remove_all(_prefix_filter(prefix))
416
+
417
+ # ─────────────────────────────────────────────────────────────────────────
418
+ # State queries
419
+ # ─────────────────────────────────────────────────────────────────────────
420
+
421
+ def is_fetching(self, filter: QueryFilter | None = None) -> bool:
422
+ """Check if any query matching the filter is currently fetching."""
423
+ queries = self.get_all(filter)
424
+ for q in queries:
425
+ if q.is_fetching():
426
+ return True
427
+ return False
428
+
429
+ def is_loading(self, filter: QueryFilter | None = None) -> bool:
430
+ """Check if any query matching the filter is in loading state."""
431
+ queries = self.get_all(filter)
432
+ for q in queries:
433
+ if isinstance(q, InfiniteQuery):
434
+ if q.status() == "loading":
435
+ return True
436
+ elif q.status() == "loading":
437
+ return True
438
+ return False
439
+
440
+ # ─────────────────────────────────────────────────────────────────────────
441
+ # Wait helpers
442
+ # ─────────────────────────────────────────────────────────────────────────
443
+
444
+ async def wait(self, key: QueryKey) -> ActionResult[Any] | None:
445
+ """
446
+ Wait for a query to complete and return the result.
447
+
448
+ Returns None if the query doesn't exist.
449
+ """
450
+ query = self.get(key)
451
+ if query is not None:
452
+ return await query.wait()
453
+
454
+ inf_query = self.get_infinite(key)
455
+ if inf_query is not None:
456
+ return await inf_query.wait()
457
+
458
+ return None
459
+
460
+
461
+ # Singleton instance
462
+ queries = QueryClient()
pulse/queries/common.py CHANGED
@@ -1,8 +1,13 @@
1
1
  from collections.abc import Callable
2
+ from dataclasses import dataclass
2
3
  from typing import (
3
4
  Any,
4
5
  Concatenate,
6
+ Generic,
7
+ Hashable,
8
+ Literal,
5
9
  ParamSpec,
10
+ TypeAlias,
6
11
  TypeVar,
7
12
  )
8
13
 
@@ -13,6 +18,29 @@ TState = TypeVar("TState", bound="State")
13
18
  P = ParamSpec("P")
14
19
  R = TypeVar("R")
15
20
 
21
+ QueryKey: TypeAlias = tuple[Hashable, ...]
22
+ QueryStatus: TypeAlias = Literal["loading", "success", "error"]
23
+
24
+
25
+ # Discriminated union result types for query actions
26
+ @dataclass(slots=True, frozen=True)
27
+ class ActionSuccess(Generic[T]):
28
+ """Successful query action result."""
29
+
30
+ data: T
31
+ status: Literal["success"] = "success"
32
+
33
+
34
+ @dataclass(slots=True, frozen=True)
35
+ class ActionError:
36
+ """Failed query action result."""
37
+
38
+ error: Exception
39
+ status: Literal["error"] = "error"
40
+
41
+
42
+ ActionResult: TypeAlias = ActionSuccess[T] | ActionError
43
+
16
44
  OnSuccessFn = Callable[[TState], Any] | Callable[[TState, T], Any]
17
45
  OnErrorFn = Callable[[TState], Any] | Callable[[TState, Exception], Any]
18
46
 
@@ -0,0 +1,39 @@
1
+ import asyncio
2
+ from collections.abc import Awaitable, Callable
3
+ from typing import (
4
+ Any,
5
+ Protocol,
6
+ override,
7
+ )
8
+
9
+ from pulse.reactive import AsyncEffect, Computed, Signal
10
+
11
+
12
+ class Fetcher(Protocol):
13
+ is_fetching: Signal[bool]
14
+
15
+
16
+ class AsyncQueryEffect(AsyncEffect):
17
+ """
18
+ Specialized AsyncEffect for queries that synchronously sets loading state
19
+ when rescheduled/run.
20
+ """
21
+
22
+ fetcher: Fetcher
23
+
24
+ def __init__(
25
+ self,
26
+ fn: Callable[[], Awaitable[None]],
27
+ fetcher: Fetcher,
28
+ name: str | None = None,
29
+ lazy: bool = False,
30
+ deps: list[Signal[Any] | Computed[Any]] | None = None,
31
+ ):
32
+ self.fetcher = fetcher
33
+ super().__init__(fn, name=name, lazy=lazy, deps=deps)
34
+
35
+ @override
36
+ def run(self) -> asyncio.Task[Any]:
37
+ # Immediately set loading state before running the effect
38
+ self.fetcher.is_fetching.write(True)
39
+ return super().run()