pulse-framework 0.1.41__py3-none-any.whl → 0.1.43__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pulse/__init__.py +17 -3
- pulse/context.py +3 -2
- pulse/decorators.py +8 -172
- pulse/helpers.py +39 -23
- pulse/hooks/core.py +4 -6
- pulse/hooks/init.py +460 -0
- pulse/queries/client.py +462 -0
- pulse/queries/common.py +28 -0
- pulse/queries/effect.py +39 -0
- pulse/queries/infinite_query.py +1157 -0
- pulse/queries/mutation.py +47 -0
- pulse/queries/query.py +560 -53
- pulse/queries/store.py +81 -18
- pulse/react_component.py +2 -1
- pulse/reactive.py +102 -23
- pulse/reactive_extensions.py +19 -7
- pulse/state.py +5 -0
- pulse/vdom.py +3 -1
- {pulse_framework-0.1.41.dist-info → pulse_framework-0.1.43.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.41.dist-info → pulse_framework-0.1.43.dist-info}/RECORD +22 -19
- pulse/queries/query_observer.py +0 -365
- {pulse_framework-0.1.41.dist-info → pulse_framework-0.1.43.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.41.dist-info → pulse_framework-0.1.43.dist-info}/entry_points.txt +0 -0
pulse/queries/client.py
ADDED
|
@@ -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
|
|
pulse/queries/effect.py
ADDED
|
@@ -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()
|