pulse-framework 0.1.54__py3-none-any.whl → 0.1.56__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 +5 -6
- pulse/app.py +144 -57
- pulse/channel.py +139 -7
- pulse/cli/cmd.py +16 -2
- pulse/code_analysis.py +38 -0
- pulse/codegen/codegen.py +61 -62
- pulse/codegen/templates/route.py +100 -56
- pulse/component.py +128 -6
- pulse/components/for_.py +30 -4
- pulse/components/if_.py +28 -5
- pulse/components/react_router.py +61 -3
- pulse/context.py +39 -5
- pulse/cookies.py +108 -4
- pulse/decorators.py +193 -24
- pulse/env.py +56 -2
- pulse/form.py +198 -5
- pulse/helpers.py +7 -1
- pulse/hooks/core.py +135 -5
- pulse/hooks/effects.py +61 -77
- pulse/hooks/init.py +60 -1
- pulse/hooks/runtime.py +241 -0
- pulse/hooks/setup.py +77 -0
- pulse/hooks/stable.py +58 -1
- pulse/hooks/state.py +107 -20
- pulse/js/__init__.py +41 -25
- pulse/js/array.py +9 -6
- pulse/js/console.py +15 -12
- pulse/js/date.py +9 -6
- pulse/js/document.py +5 -2
- pulse/js/error.py +7 -4
- pulse/js/json.py +9 -6
- pulse/js/map.py +8 -5
- pulse/js/math.py +9 -6
- pulse/js/navigator.py +5 -2
- pulse/js/number.py +9 -6
- pulse/js/obj.py +16 -13
- pulse/js/object.py +9 -6
- pulse/js/promise.py +19 -13
- pulse/js/pulse.py +28 -25
- pulse/js/react.py +190 -44
- pulse/js/regexp.py +7 -4
- pulse/js/set.py +8 -5
- pulse/js/string.py +9 -6
- pulse/js/weakmap.py +8 -5
- pulse/js/weakset.py +8 -5
- pulse/js/window.py +6 -3
- pulse/messages.py +5 -0
- pulse/middleware.py +147 -76
- pulse/plugin.py +76 -5
- pulse/queries/client.py +186 -39
- pulse/queries/common.py +52 -3
- pulse/queries/infinite_query.py +154 -2
- pulse/queries/mutation.py +127 -7
- pulse/queries/query.py +112 -11
- pulse/react_component.py +66 -3
- pulse/reactive.py +314 -30
- pulse/reactive_extensions.py +106 -26
- pulse/render_session.py +304 -173
- pulse/request.py +46 -11
- pulse/routing.py +140 -4
- pulse/serializer.py +71 -0
- pulse/state.py +177 -9
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +13 -3
- pulse/transpiler/assets.py +66 -0
- pulse/transpiler/dynamic_import.py +131 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/function.py +6 -2
- pulse/transpiler/imports.py +33 -27
- pulse/transpiler/js_module.py +64 -8
- pulse/transpiler/py_module.py +1 -7
- pulse/transpiler/transpiler.py +4 -0
- pulse/user_session.py +119 -18
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
- pulse_framework-0.1.56.dist-info/RECORD +127 -0
- pulse/js/react_dom.py +0 -30
- pulse/transpiler/react_component.py +0 -51
- pulse_framework-0.1.54.dist-info/RECORD +0 -124
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/entry_points.txt +0 -0
pulse/queries/client.py
CHANGED
|
@@ -6,6 +6,7 @@ from pulse.context import PulseContext
|
|
|
6
6
|
from pulse.queries.common import ActionResult, QueryKey
|
|
7
7
|
from pulse.queries.infinite_query import InfiniteQuery, Page
|
|
8
8
|
from pulse.queries.query import KeyedQuery
|
|
9
|
+
from pulse.queries.store import QueryStore
|
|
9
10
|
|
|
10
11
|
T = TypeVar("T")
|
|
11
12
|
|
|
@@ -42,17 +43,41 @@ def _prefix_filter(prefix: tuple[Any, ...]) -> Callable[[QueryKey], bool]:
|
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
class QueryClient:
|
|
45
|
-
"""
|
|
46
|
-
Client for managing queries and infinite queries in a session.
|
|
46
|
+
"""Client for managing queries and infinite queries in a session.
|
|
47
47
|
|
|
48
48
|
Provides methods to get, set, invalidate, and refetch queries by key
|
|
49
|
-
or using filter predicates.
|
|
49
|
+
or using filter predicates. Automatically resolves to the current
|
|
50
|
+
RenderSession's query store.
|
|
51
|
+
|
|
52
|
+
Access via ``ps.queries`` singleton:
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
# Get query data
|
|
58
|
+
user = ps.queries.get_data(("user", user_id))
|
|
50
59
|
|
|
51
|
-
|
|
60
|
+
# Invalidate queries by prefix
|
|
61
|
+
ps.queries.invalidate_prefix(("users",))
|
|
62
|
+
|
|
63
|
+
# Set data optimistically
|
|
64
|
+
ps.queries.set_data(("user", user_id), updated_user)
|
|
65
|
+
|
|
66
|
+
# Check if any query is fetching
|
|
67
|
+
if ps.queries.is_fetching(("user", user_id)):
|
|
68
|
+
show_loading()
|
|
69
|
+
```
|
|
52
70
|
"""
|
|
53
71
|
|
|
54
|
-
def _get_store(self):
|
|
55
|
-
"""Get the query store from the current PulseContext.
|
|
72
|
+
def _get_store(self) -> QueryStore:
|
|
73
|
+
"""Get the query store from the current PulseContext.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The QueryStore from the active render session.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
RuntimeError: If no render session is available.
|
|
80
|
+
"""
|
|
56
81
|
render = PulseContext.get().render
|
|
57
82
|
if render is None:
|
|
58
83
|
raise RuntimeError("No render session available")
|
|
@@ -62,12 +87,26 @@ class QueryClient:
|
|
|
62
87
|
# Query accessors
|
|
63
88
|
# ─────────────────────────────────────────────────────────────────────────
|
|
64
89
|
|
|
65
|
-
def get(self, key: QueryKey):
|
|
66
|
-
"""Get an existing regular query by key
|
|
90
|
+
def get(self, key: QueryKey) -> KeyedQuery[Any] | None:
|
|
91
|
+
"""Get an existing regular query by key.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
key: The query key tuple to look up.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The KeyedQuery instance, or None if not found.
|
|
98
|
+
"""
|
|
67
99
|
return self._get_store().get(key)
|
|
68
100
|
|
|
69
|
-
def get_infinite(self, key: QueryKey):
|
|
70
|
-
"""Get an existing infinite query by key
|
|
101
|
+
def get_infinite(self, key: QueryKey) -> InfiniteQuery[Any, Any] | None:
|
|
102
|
+
"""Get an existing infinite query by key.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
key: The query key tuple to look up.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
The InfiniteQuery instance, or None if not found.
|
|
109
|
+
"""
|
|
71
110
|
return self._get_store().get_infinite(key)
|
|
72
111
|
|
|
73
112
|
def get_all(
|
|
@@ -101,7 +140,15 @@ class QueryClient:
|
|
|
101
140
|
return results
|
|
102
141
|
|
|
103
142
|
def get_queries(self, filter: QueryFilter | None = None) -> list[KeyedQuery[Any]]:
|
|
104
|
-
"""Get all regular queries matching the filter.
|
|
143
|
+
"""Get all regular queries matching the filter.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
filter: Optional filter - exact key, list of keys, or predicate.
|
|
147
|
+
If None, returns all regular queries.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
List of matching KeyedQuery instances (excludes infinite queries).
|
|
151
|
+
"""
|
|
105
152
|
store = self._get_store()
|
|
106
153
|
predicate = _normalize_filter(filter)
|
|
107
154
|
results: list[KeyedQuery[Any]] = []
|
|
@@ -118,7 +165,15 @@ class QueryClient:
|
|
|
118
165
|
def get_infinite_queries(
|
|
119
166
|
self, filter: QueryFilter | None = None
|
|
120
167
|
) -> list[InfiniteQuery[Any, Any]]:
|
|
121
|
-
"""Get all infinite queries matching the filter.
|
|
168
|
+
"""Get all infinite queries matching the filter.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
filter: Optional filter - exact key, list of keys, or predicate.
|
|
172
|
+
If None, returns all infinite queries.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
List of matching InfiniteQuery instances.
|
|
176
|
+
"""
|
|
122
177
|
store = self._get_store()
|
|
123
178
|
predicate = _normalize_filter(filter)
|
|
124
179
|
results: list[InfiniteQuery[Any, Any]] = []
|
|
@@ -137,14 +192,28 @@ class QueryClient:
|
|
|
137
192
|
# ─────────────────────────────────────────────────────────────────────────
|
|
138
193
|
|
|
139
194
|
def get_data(self, key: QueryKey) -> Any | None:
|
|
140
|
-
"""Get the data for a query by key.
|
|
195
|
+
"""Get the data for a query by key.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
key: The query key tuple to look up.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The query data, or None if query not found or has no data.
|
|
202
|
+
"""
|
|
141
203
|
query = self.get(key)
|
|
142
204
|
if query is None:
|
|
143
205
|
return None
|
|
144
206
|
return query.data.read()
|
|
145
207
|
|
|
146
208
|
def get_infinite_data(self, key: QueryKey) -> list[Page[Any, Any]] | None:
|
|
147
|
-
"""Get the pages for an infinite query by key.
|
|
209
|
+
"""Get the pages for an infinite query by key.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
key: The query key tuple to look up.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
List of Page objects, or None if query not found.
|
|
216
|
+
"""
|
|
148
217
|
query = self.get_infinite(key)
|
|
149
218
|
if query is None:
|
|
150
219
|
return None
|
|
@@ -215,7 +284,16 @@ class QueryClient:
|
|
|
215
284
|
*,
|
|
216
285
|
updated_at: float | dt.datetime | None = None,
|
|
217
286
|
) -> bool:
|
|
218
|
-
"""Set pages for an infinite query by key.
|
|
287
|
+
"""Set pages for an infinite query by key.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
key: The query key tuple.
|
|
291
|
+
pages: New pages list or updater function.
|
|
292
|
+
updated_at: Optional timestamp to set.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
True if query was found and updated, False otherwise.
|
|
296
|
+
"""
|
|
219
297
|
query = self.get_infinite(key)
|
|
220
298
|
if query is None:
|
|
221
299
|
return False
|
|
@@ -291,11 +369,21 @@ class QueryClient:
|
|
|
291
369
|
*,
|
|
292
370
|
cancel_refetch: bool = False,
|
|
293
371
|
) -> int:
|
|
294
|
-
"""
|
|
295
|
-
|
|
372
|
+
"""Invalidate all queries whose keys start with the given prefix.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
prefix: Tuple prefix to match against query keys.
|
|
376
|
+
cancel_refetch: Cancel in-flight requests before refetch.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Count of invalidated queries.
|
|
296
380
|
|
|
297
381
|
Example:
|
|
298
|
-
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
# Invalidates ("users",), ("users", 1), ("users", 2, "posts"), etc.
|
|
385
|
+
ps.queries.invalidate_prefix(("users",))
|
|
386
|
+
```
|
|
299
387
|
"""
|
|
300
388
|
return self.invalidate(_prefix_filter(prefix), cancel_refetch=cancel_refetch)
|
|
301
389
|
|
|
@@ -309,10 +397,14 @@ class QueryClient:
|
|
|
309
397
|
*,
|
|
310
398
|
cancel_refetch: bool = True,
|
|
311
399
|
) -> ActionResult[Any] | None:
|
|
312
|
-
"""
|
|
313
|
-
Refetch a query by key and return the result.
|
|
400
|
+
"""Refetch a query by key and return the result.
|
|
314
401
|
|
|
315
|
-
|
|
402
|
+
Args:
|
|
403
|
+
key: The query key tuple to refetch.
|
|
404
|
+
cancel_refetch: Cancel in-flight request before refetching (default True).
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
ActionResult with data or error, or None if query doesn't exist.
|
|
316
408
|
"""
|
|
317
409
|
query = self.get(key)
|
|
318
410
|
if query is not None:
|
|
@@ -330,10 +422,15 @@ class QueryClient:
|
|
|
330
422
|
*,
|
|
331
423
|
cancel_refetch: bool = True,
|
|
332
424
|
) -> list[ActionResult[Any]]:
|
|
333
|
-
"""
|
|
334
|
-
|
|
425
|
+
"""Refetch all queries matching the filter.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
filter: Optional filter - exact key, list of keys, or predicate.
|
|
429
|
+
If None, refetches all queries.
|
|
430
|
+
cancel_refetch: Cancel in-flight requests before refetching.
|
|
335
431
|
|
|
336
|
-
Returns
|
|
432
|
+
Returns:
|
|
433
|
+
List of ActionResult for each refetched query.
|
|
337
434
|
"""
|
|
338
435
|
queries = self.get_all(filter)
|
|
339
436
|
results: list[ActionResult[Any]] = []
|
|
@@ -353,7 +450,15 @@ class QueryClient:
|
|
|
353
450
|
*,
|
|
354
451
|
cancel_refetch: bool = True,
|
|
355
452
|
) -> list[ActionResult[Any]]:
|
|
356
|
-
"""Refetch all queries whose keys start with the given prefix.
|
|
453
|
+
"""Refetch all queries whose keys start with the given prefix.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
prefix: Tuple prefix to match against query keys.
|
|
457
|
+
cancel_refetch: Cancel in-flight requests before refetching.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
List of ActionResult for each refetched query.
|
|
461
|
+
"""
|
|
357
462
|
return await self.refetch_all(
|
|
358
463
|
_prefix_filter(prefix), cancel_refetch=cancel_refetch
|
|
359
464
|
)
|
|
@@ -369,7 +474,16 @@ class QueryClient:
|
|
|
369
474
|
*,
|
|
370
475
|
updated_at: float | dt.datetime | None = None,
|
|
371
476
|
) -> bool:
|
|
372
|
-
"""Set error state on a query by key.
|
|
477
|
+
"""Set error state on a query by key.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
key: The query key tuple.
|
|
481
|
+
error: The exception to set.
|
|
482
|
+
updated_at: Optional timestamp to set.
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
True if query was found and error was set, False otherwise.
|
|
486
|
+
"""
|
|
373
487
|
query = self.get(key)
|
|
374
488
|
if query is not None:
|
|
375
489
|
query.set_error(error, updated_at=updated_at)
|
|
@@ -387,10 +501,13 @@ class QueryClient:
|
|
|
387
501
|
# ─────────────────────────────────────────────────────────────────────────
|
|
388
502
|
|
|
389
503
|
def remove(self, key: QueryKey) -> bool:
|
|
390
|
-
"""
|
|
391
|
-
|
|
504
|
+
"""Remove a query from the store, disposing it.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
key: The query key tuple to remove.
|
|
392
508
|
|
|
393
|
-
Returns
|
|
509
|
+
Returns:
|
|
510
|
+
True if query existed and was removed, False otherwise.
|
|
394
511
|
"""
|
|
395
512
|
store = self._get_store()
|
|
396
513
|
entry = store.get_any(key)
|
|
@@ -400,10 +517,14 @@ class QueryClient:
|
|
|
400
517
|
return True
|
|
401
518
|
|
|
402
519
|
def remove_all(self, filter: QueryFilter | None = None) -> int:
|
|
403
|
-
"""
|
|
404
|
-
|
|
520
|
+
"""Remove all queries matching the filter.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
filter: Optional filter - exact key, list of keys, or predicate.
|
|
524
|
+
If None, removes all queries.
|
|
405
525
|
|
|
406
|
-
Returns
|
|
526
|
+
Returns:
|
|
527
|
+
Count of removed queries.
|
|
407
528
|
"""
|
|
408
529
|
queries = self.get_all(filter)
|
|
409
530
|
for q in queries:
|
|
@@ -411,7 +532,14 @@ class QueryClient:
|
|
|
411
532
|
return len(queries)
|
|
412
533
|
|
|
413
534
|
def remove_prefix(self, prefix: tuple[Any, ...]) -> int:
|
|
414
|
-
"""Remove all queries whose keys start with the given prefix.
|
|
535
|
+
"""Remove all queries whose keys start with the given prefix.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
prefix: Tuple prefix to match against query keys.
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
Count of removed queries.
|
|
542
|
+
"""
|
|
415
543
|
return self.remove_all(_prefix_filter(prefix))
|
|
416
544
|
|
|
417
545
|
# ─────────────────────────────────────────────────────────────────────────
|
|
@@ -419,7 +547,15 @@ class QueryClient:
|
|
|
419
547
|
# ─────────────────────────────────────────────────────────────────────────
|
|
420
548
|
|
|
421
549
|
def is_fetching(self, filter: QueryFilter | None = None) -> bool:
|
|
422
|
-
"""Check if any query matching the filter is currently fetching.
|
|
550
|
+
"""Check if any query matching the filter is currently fetching.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
filter: Optional filter - exact key, list of keys, or predicate.
|
|
554
|
+
If None, checks all queries.
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
True if any matching query is fetching.
|
|
558
|
+
"""
|
|
423
559
|
queries = self.get_all(filter)
|
|
424
560
|
for q in queries:
|
|
425
561
|
if q.is_fetching():
|
|
@@ -427,7 +563,15 @@ class QueryClient:
|
|
|
427
563
|
return False
|
|
428
564
|
|
|
429
565
|
def is_loading(self, filter: QueryFilter | None = None) -> bool:
|
|
430
|
-
"""Check if any query matching the filter is in loading state.
|
|
566
|
+
"""Check if any query matching the filter is in loading state.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
filter: Optional filter - exact key, list of keys, or predicate.
|
|
570
|
+
If None, checks all queries.
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
True if any matching query has status "loading".
|
|
574
|
+
"""
|
|
431
575
|
queries = self.get_all(filter)
|
|
432
576
|
for q in queries:
|
|
433
577
|
if isinstance(q, InfiniteQuery):
|
|
@@ -442,10 +586,13 @@ class QueryClient:
|
|
|
442
586
|
# ─────────────────────────────────────────────────────────────────────────
|
|
443
587
|
|
|
444
588
|
async def wait(self, key: QueryKey) -> ActionResult[Any] | None:
|
|
445
|
-
"""
|
|
446
|
-
Wait for a query to complete and return the result.
|
|
589
|
+
"""Wait for a query to complete and return the result.
|
|
447
590
|
|
|
448
|
-
|
|
591
|
+
Args:
|
|
592
|
+
key: The query key tuple to wait for.
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
ActionResult with data or error, or None if query doesn't exist.
|
|
449
596
|
"""
|
|
450
597
|
query = self.get(key)
|
|
451
598
|
if query is not None:
|
|
@@ -458,5 +605,5 @@ class QueryClient:
|
|
|
458
605
|
return None
|
|
459
606
|
|
|
460
607
|
|
|
461
|
-
# Singleton instance
|
|
608
|
+
# Singleton instance accessible via ps.queries
|
|
462
609
|
queries = QueryClient()
|
pulse/queries/common.py
CHANGED
|
@@ -19,13 +19,41 @@ P = ParamSpec("P")
|
|
|
19
19
|
R = TypeVar("R")
|
|
20
20
|
|
|
21
21
|
QueryKey: TypeAlias = tuple[Hashable, ...]
|
|
22
|
+
"""Tuple of hashable values identifying a query in the store.
|
|
23
|
+
|
|
24
|
+
Used to uniquely identify queries for caching, deduplication, and invalidation.
|
|
25
|
+
Keys are hierarchical tuples like ``("user", user_id)`` or ``("posts", "feed")``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
22
28
|
QueryStatus: TypeAlias = Literal["loading", "success", "error"]
|
|
29
|
+
"""Current status of a query.
|
|
30
|
+
|
|
31
|
+
Values:
|
|
32
|
+
- ``"loading"``: Query is fetching data (initial load or refetch).
|
|
33
|
+
- ``"success"``: Query has successfully fetched data.
|
|
34
|
+
- ``"error"``: Query encountered an error during fetch.
|
|
35
|
+
"""
|
|
23
36
|
|
|
24
37
|
|
|
25
|
-
# Discriminated union result types for query actions
|
|
26
38
|
@dataclass(slots=True, frozen=True)
|
|
27
39
|
class ActionSuccess(Generic[T]):
|
|
28
|
-
"""Successful query action result.
|
|
40
|
+
"""Successful query action result.
|
|
41
|
+
|
|
42
|
+
Returned by query operations like ``refetch()`` and ``wait()`` when the
|
|
43
|
+
operation completes successfully.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
data: The fetched data of type T.
|
|
47
|
+
status: Always ``"success"`` for discriminated union matching.
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
result = await state.user.refetch()
|
|
53
|
+
if result.status == "success":
|
|
54
|
+
print(result.data)
|
|
55
|
+
```
|
|
56
|
+
"""
|
|
29
57
|
|
|
30
58
|
data: T
|
|
31
59
|
status: Literal["success"] = "success"
|
|
@@ -33,13 +61,34 @@ class ActionSuccess(Generic[T]):
|
|
|
33
61
|
|
|
34
62
|
@dataclass(slots=True, frozen=True)
|
|
35
63
|
class ActionError:
|
|
36
|
-
"""Failed query action result.
|
|
64
|
+
"""Failed query action result.
|
|
65
|
+
|
|
66
|
+
Returned by query operations like ``refetch()`` and ``wait()`` when the
|
|
67
|
+
operation fails after exhausting retries.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
error: The exception that caused the failure.
|
|
71
|
+
status: Always ``"error"`` for discriminated union matching.
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
result = await state.user.refetch()
|
|
77
|
+
if result.status == "error":
|
|
78
|
+
print(f"Failed: {result.error}")
|
|
79
|
+
```
|
|
80
|
+
"""
|
|
37
81
|
|
|
38
82
|
error: Exception
|
|
39
83
|
status: Literal["error"] = "error"
|
|
40
84
|
|
|
41
85
|
|
|
42
86
|
ActionResult: TypeAlias = ActionSuccess[T] | ActionError
|
|
87
|
+
"""Union type for query action results.
|
|
88
|
+
|
|
89
|
+
Either ``ActionSuccess[T]`` with data or ``ActionError`` with an exception.
|
|
90
|
+
Use the ``status`` field to discriminate between success and error cases.
|
|
91
|
+
"""
|
|
43
92
|
|
|
44
93
|
OnSuccessFn = Callable[[TState], Any] | Callable[[TState, T], Any]
|
|
45
94
|
OnErrorFn = Callable[[TState], Any] | Callable[[TState, Exception], Any]
|
pulse/queries/infinite_query.py
CHANGED
|
@@ -44,6 +44,26 @@ TState = TypeVar("TState", bound=State)
|
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
class Page(NamedTuple, Generic[T, TParam]):
|
|
47
|
+
"""Named tuple representing a page in an infinite query.
|
|
48
|
+
|
|
49
|
+
Each page contains the fetched data and the parameter used to fetch it,
|
|
50
|
+
enabling cursor-based or offset-based pagination.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
data: The fetched page data of type T.
|
|
54
|
+
param: The page parameter (cursor, offset, etc.) used to fetch this page.
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
# Access pages from infinite query result
|
|
60
|
+
for page in state.posts.data:
|
|
61
|
+
print(f"Page param: {page.param}")
|
|
62
|
+
for post in page.data:
|
|
63
|
+
print(post.title)
|
|
64
|
+
```
|
|
65
|
+
"""
|
|
66
|
+
|
|
47
67
|
data: T
|
|
48
68
|
param: TParam
|
|
49
69
|
|
|
@@ -714,8 +734,39 @@ def none_if_missing(value: Any):
|
|
|
714
734
|
|
|
715
735
|
|
|
716
736
|
class InfiniteQueryResult(Generic[T, TParam], Disposable):
|
|
717
|
-
"""
|
|
718
|
-
|
|
737
|
+
"""Observer wrapper for InfiniteQuery with lifecycle and stale tracking.
|
|
738
|
+
|
|
739
|
+
InfiniteQueryResult provides the interface for interacting with paginated
|
|
740
|
+
queries. It manages observation lifecycle, staleness tracking, and exposes
|
|
741
|
+
reactive properties and methods for pagination.
|
|
742
|
+
|
|
743
|
+
Attributes:
|
|
744
|
+
data: List of Page objects or None if not loaded.
|
|
745
|
+
pages: List of page data only (without params) or None.
|
|
746
|
+
page_params: List of page parameters only or None.
|
|
747
|
+
error: The last error encountered, or None.
|
|
748
|
+
status: Current QueryStatus ("loading", "success", "error").
|
|
749
|
+
is_loading: Whether status is "loading".
|
|
750
|
+
is_success: Whether status is "success".
|
|
751
|
+
is_error: Whether status is "error".
|
|
752
|
+
is_fetching: Whether any fetch is in progress.
|
|
753
|
+
has_next_page: Whether more pages are available forward.
|
|
754
|
+
has_previous_page: Whether previous pages are available.
|
|
755
|
+
is_fetching_next_page: Whether fetching the next page.
|
|
756
|
+
is_fetching_previous_page: Whether fetching the previous page.
|
|
757
|
+
|
|
758
|
+
Example:
|
|
759
|
+
|
|
760
|
+
```python
|
|
761
|
+
# Access infinite query result
|
|
762
|
+
if state.posts.is_loading:
|
|
763
|
+
show_skeleton()
|
|
764
|
+
elif state.posts.data:
|
|
765
|
+
for page in state.posts.data:
|
|
766
|
+
render_posts(page.data)
|
|
767
|
+
if state.posts.has_next_page:
|
|
768
|
+
Button("Load More", on_click=state.posts.fetch_next_page)
|
|
769
|
+
```
|
|
719
770
|
"""
|
|
720
771
|
|
|
721
772
|
_query: Computed[InfiniteQuery[T, TParam]]
|
|
@@ -957,6 +1008,47 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
|
|
|
957
1008
|
|
|
958
1009
|
|
|
959
1010
|
class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
|
|
1011
|
+
"""Descriptor for state-bound infinite queries created by the @infinite_query decorator.
|
|
1012
|
+
|
|
1013
|
+
InfiniteQueryProperty is the return type of the ``@infinite_query`` decorator.
|
|
1014
|
+
It acts as a descriptor that creates and manages InfiniteQueryResult instances
|
|
1015
|
+
for each State object.
|
|
1016
|
+
|
|
1017
|
+
When accessed on a State instance, returns an InfiniteQueryResult with reactive
|
|
1018
|
+
properties for pagination state and methods for fetching pages.
|
|
1019
|
+
|
|
1020
|
+
Required decorators:
|
|
1021
|
+
- ``@infinite_query_prop.key``: Define the query key (required).
|
|
1022
|
+
- ``@infinite_query_prop.get_next_page_param``: Define how to get next page param.
|
|
1023
|
+
|
|
1024
|
+
Optional decorators:
|
|
1025
|
+
- ``@infinite_query_prop.get_previous_page_param``: For bi-directional pagination.
|
|
1026
|
+
- ``@infinite_query_prop.on_success``: Handle successful fetch.
|
|
1027
|
+
- ``@infinite_query_prop.on_error``: Handle fetch errors.
|
|
1028
|
+
|
|
1029
|
+
Example:
|
|
1030
|
+
|
|
1031
|
+
```python
|
|
1032
|
+
class FeedState(ps.State):
|
|
1033
|
+
feed_type: str = "home"
|
|
1034
|
+
|
|
1035
|
+
@ps.infinite_query(initial_page_param=None)
|
|
1036
|
+
async def posts(self, cursor: str | None) -> list[Post]:
|
|
1037
|
+
return await api.get_posts(cursor=cursor)
|
|
1038
|
+
|
|
1039
|
+
@posts.key
|
|
1040
|
+
def _posts_key(self):
|
|
1041
|
+
return ("feed", self.feed_type)
|
|
1042
|
+
|
|
1043
|
+
@posts.get_next_page_param
|
|
1044
|
+
def _next_cursor(self, pages: list[Page]) -> str | None:
|
|
1045
|
+
if not pages:
|
|
1046
|
+
return None
|
|
1047
|
+
last = pages[-1]
|
|
1048
|
+
return last.data[-1].id if last.data else None
|
|
1049
|
+
```
|
|
1050
|
+
"""
|
|
1051
|
+
|
|
960
1052
|
name: str
|
|
961
1053
|
_fetch_fn: "Callable[[TState, TParam], Awaitable[T]]"
|
|
962
1054
|
_keep_alive: bool
|
|
@@ -1233,7 +1325,67 @@ def infinite_query(
|
|
|
1233
1325
|
enabled: bool = True,
|
|
1234
1326
|
fetch_on_mount: bool = True,
|
|
1235
1327
|
key: QueryKey | None = None,
|
|
1328
|
+
) -> (
|
|
1329
|
+
InfiniteQueryProperty[T, TParam, TState]
|
|
1330
|
+
| Callable[
|
|
1331
|
+
[Callable[[TState, Any], Awaitable[T]]],
|
|
1332
|
+
InfiniteQueryProperty[T, TParam, TState],
|
|
1333
|
+
]
|
|
1236
1334
|
):
|
|
1335
|
+
"""Decorator for paginated queries on State methods.
|
|
1336
|
+
|
|
1337
|
+
Creates a reactive infinite query that supports cursor-based or offset-based
|
|
1338
|
+
pagination. Data is stored as a list of pages, each with its data and the
|
|
1339
|
+
parameter used to fetch it.
|
|
1340
|
+
|
|
1341
|
+
Requires ``@query_prop.key`` and ``@query_prop.get_next_page_param`` decorators.
|
|
1342
|
+
|
|
1343
|
+
Args:
|
|
1344
|
+
fn: The async method to decorate (when used without parentheses).
|
|
1345
|
+
initial_page_param: The parameter for fetching the first page (required).
|
|
1346
|
+
max_pages: Maximum pages to keep in memory (0 = unlimited).
|
|
1347
|
+
stale_time: Seconds before data is considered stale (default 0.0).
|
|
1348
|
+
gc_time: Seconds to keep unused query in cache (default 300.0).
|
|
1349
|
+
refetch_interval: Auto-refetch interval in seconds (default None).
|
|
1350
|
+
keep_previous_data: Keep previous data while refetching (default False).
|
|
1351
|
+
retries: Number of retry attempts on failure (default 3).
|
|
1352
|
+
retry_delay: Delay between retries in seconds (default 2.0).
|
|
1353
|
+
initial_data_updated_at: Timestamp for initial data staleness.
|
|
1354
|
+
enabled: Whether query is enabled (default True).
|
|
1355
|
+
fetch_on_mount: Fetch when component mounts (default True).
|
|
1356
|
+
key: Static query key for sharing across instances.
|
|
1357
|
+
|
|
1358
|
+
Returns:
|
|
1359
|
+
InfiniteQueryProperty that creates InfiniteQueryResult instances when accessed.
|
|
1360
|
+
|
|
1361
|
+
Example:
|
|
1362
|
+
|
|
1363
|
+
```python
|
|
1364
|
+
class FeedState(ps.State):
|
|
1365
|
+
@ps.infinite_query(initial_page_param=None, key=("feed",))
|
|
1366
|
+
async def posts(self, cursor: str | None) -> list[Post]:
|
|
1367
|
+
return await api.get_posts(cursor=cursor)
|
|
1368
|
+
|
|
1369
|
+
@posts.key
|
|
1370
|
+
def _posts_key(self):
|
|
1371
|
+
return ("feed", self.feed_type)
|
|
1372
|
+
|
|
1373
|
+
@posts.get_next_page_param
|
|
1374
|
+
def _next_cursor(self, pages: list[Page]) -> str | None:
|
|
1375
|
+
if not pages:
|
|
1376
|
+
return None
|
|
1377
|
+
last = pages[-1]
|
|
1378
|
+
return last.data[-1].id if last.data else None
|
|
1379
|
+
|
|
1380
|
+
@posts.get_previous_page_param
|
|
1381
|
+
def _prev_cursor(self, pages: list[Page]) -> str | None:
|
|
1382
|
+
if not pages:
|
|
1383
|
+
return None
|
|
1384
|
+
first = pages[0]
|
|
1385
|
+
return first.data[0].id if first.data else None
|
|
1386
|
+
```
|
|
1387
|
+
"""
|
|
1388
|
+
|
|
1237
1389
|
def decorator(
|
|
1238
1390
|
func: Callable[[TState, TParam], Awaitable[T]], /
|
|
1239
1391
|
) -> InfiniteQueryProperty[T, TParam, TState]:
|