pulse-framework 0.1.62__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 +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/queries/client.py
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
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 KeyedQuery
|
|
9
|
+
from pulse.queries.store import QueryStore
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
# Query filter types
|
|
14
|
+
QueryFilter = (
|
|
15
|
+
QueryKey # exact key match
|
|
16
|
+
| list[QueryKey] # explicit list of keys
|
|
17
|
+
| Callable[[QueryKey], bool] # predicate function
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _normalize_filter(
|
|
22
|
+
filter: QueryFilter | None,
|
|
23
|
+
) -> Callable[[QueryKey], bool] | None:
|
|
24
|
+
"""Convert any QueryFilter to a predicate function."""
|
|
25
|
+
if filter is None:
|
|
26
|
+
return None
|
|
27
|
+
if isinstance(filter, tuple):
|
|
28
|
+
# Exact key match
|
|
29
|
+
exact_key = filter
|
|
30
|
+
return lambda k: k == exact_key
|
|
31
|
+
if isinstance(filter, list):
|
|
32
|
+
# List of keys
|
|
33
|
+
key_set = set(filter)
|
|
34
|
+
return lambda k: k in key_set
|
|
35
|
+
# Already a callable predicate
|
|
36
|
+
return filter
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _prefix_filter(prefix: tuple[Any, ...]) -> Callable[[QueryKey], bool]:
|
|
40
|
+
"""Create a predicate that matches keys starting with the given prefix."""
|
|
41
|
+
prefix_len = len(prefix)
|
|
42
|
+
return lambda k: len(k) >= prefix_len and k[:prefix_len] == prefix
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class QueryClient:
|
|
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. 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))
|
|
59
|
+
|
|
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
|
+
```
|
|
70
|
+
"""
|
|
71
|
+
|
|
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
|
+
"""
|
|
81
|
+
render = PulseContext.get().render
|
|
82
|
+
if render is None:
|
|
83
|
+
raise RuntimeError("No render session available")
|
|
84
|
+
return render.query_store
|
|
85
|
+
|
|
86
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
87
|
+
# Query accessors
|
|
88
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
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
|
+
"""
|
|
99
|
+
return self._get_store().get(key)
|
|
100
|
+
|
|
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
|
+
"""
|
|
110
|
+
return self._get_store().get_infinite(key)
|
|
111
|
+
|
|
112
|
+
def get_all(
|
|
113
|
+
self,
|
|
114
|
+
filter: QueryFilter | None = None,
|
|
115
|
+
*,
|
|
116
|
+
include_infinite: bool = True,
|
|
117
|
+
) -> list[KeyedQuery[Any] | InfiniteQuery[Any, Any]]:
|
|
118
|
+
"""
|
|
119
|
+
Get all queries matching the filter.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
filter: Optional filter - can be an exact key, list of keys, or predicate.
|
|
123
|
+
If None, returns all queries.
|
|
124
|
+
include_infinite: Whether to include infinite queries (default True).
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
List of matching Query or InfiniteQuery instances.
|
|
128
|
+
"""
|
|
129
|
+
store = self._get_store()
|
|
130
|
+
predicate = _normalize_filter(filter)
|
|
131
|
+
results: list[KeyedQuery[Any] | InfiniteQuery[Any, Any]] = []
|
|
132
|
+
|
|
133
|
+
for key, entry in store.items():
|
|
134
|
+
if predicate is not None and not predicate(key):
|
|
135
|
+
continue
|
|
136
|
+
if not include_infinite and isinstance(entry, InfiniteQuery):
|
|
137
|
+
continue
|
|
138
|
+
results.append(entry)
|
|
139
|
+
|
|
140
|
+
return results
|
|
141
|
+
|
|
142
|
+
def get_queries(self, filter: QueryFilter | None = None) -> list[KeyedQuery[Any]]:
|
|
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
|
+
"""
|
|
152
|
+
store = self._get_store()
|
|
153
|
+
predicate = _normalize_filter(filter)
|
|
154
|
+
results: list[KeyedQuery[Any]] = []
|
|
155
|
+
|
|
156
|
+
for key, entry in store.items():
|
|
157
|
+
if isinstance(entry, InfiniteQuery):
|
|
158
|
+
continue
|
|
159
|
+
if predicate is not None and not predicate(key):
|
|
160
|
+
continue
|
|
161
|
+
results.append(entry)
|
|
162
|
+
|
|
163
|
+
return results
|
|
164
|
+
|
|
165
|
+
def get_infinite_queries(
|
|
166
|
+
self, filter: QueryFilter | None = None
|
|
167
|
+
) -> list[InfiniteQuery[Any, Any]]:
|
|
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
|
+
"""
|
|
177
|
+
store = self._get_store()
|
|
178
|
+
predicate = _normalize_filter(filter)
|
|
179
|
+
results: list[InfiniteQuery[Any, Any]] = []
|
|
180
|
+
|
|
181
|
+
for key, entry in store.items():
|
|
182
|
+
if not isinstance(entry, InfiniteQuery):
|
|
183
|
+
continue
|
|
184
|
+
if predicate is not None and not predicate(key):
|
|
185
|
+
continue
|
|
186
|
+
results.append(entry)
|
|
187
|
+
|
|
188
|
+
return results
|
|
189
|
+
|
|
190
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
191
|
+
# Data accessors
|
|
192
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
def get_data(self, key: QueryKey) -> Any | None:
|
|
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
|
+
"""
|
|
203
|
+
query = self.get(key)
|
|
204
|
+
if query is None:
|
|
205
|
+
return None
|
|
206
|
+
return query.data.read()
|
|
207
|
+
|
|
208
|
+
def get_infinite_data(self, key: QueryKey) -> list[Page[Any, Any]] | None:
|
|
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
|
+
"""
|
|
217
|
+
query = self.get_infinite(key)
|
|
218
|
+
if query is None:
|
|
219
|
+
return None
|
|
220
|
+
return list(query.pages)
|
|
221
|
+
|
|
222
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
223
|
+
# Data setters
|
|
224
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
@overload
|
|
227
|
+
def set_data(
|
|
228
|
+
self,
|
|
229
|
+
key_or_filter: QueryKey,
|
|
230
|
+
data: T | Callable[[T | None], T],
|
|
231
|
+
*,
|
|
232
|
+
updated_at: float | dt.datetime | None = None,
|
|
233
|
+
) -> bool: ...
|
|
234
|
+
|
|
235
|
+
@overload
|
|
236
|
+
def set_data(
|
|
237
|
+
self,
|
|
238
|
+
key_or_filter: list[QueryKey] | Callable[[QueryKey], bool],
|
|
239
|
+
data: Callable[[Any], Any],
|
|
240
|
+
*,
|
|
241
|
+
updated_at: float | dt.datetime | None = None,
|
|
242
|
+
) -> int: ...
|
|
243
|
+
|
|
244
|
+
def set_data(
|
|
245
|
+
self,
|
|
246
|
+
key_or_filter: QueryKey | list[QueryKey] | Callable[[QueryKey], bool],
|
|
247
|
+
data: Any | Callable[[Any], Any],
|
|
248
|
+
*,
|
|
249
|
+
updated_at: float | dt.datetime | None = None,
|
|
250
|
+
) -> bool | int:
|
|
251
|
+
"""
|
|
252
|
+
Set data for queries matching the key or filter.
|
|
253
|
+
|
|
254
|
+
When using a single key, returns True if query exists and was updated.
|
|
255
|
+
When using a filter, returns count of updated queries.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
key_or_filter: Exact key or filter predicate.
|
|
259
|
+
data: New data value or updater function.
|
|
260
|
+
updated_at: Optional timestamp to set.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
bool if exact key, int count if filter.
|
|
264
|
+
"""
|
|
265
|
+
# Single key case
|
|
266
|
+
if isinstance(key_or_filter, tuple):
|
|
267
|
+
query = self.get(key_or_filter)
|
|
268
|
+
if query is None:
|
|
269
|
+
return False
|
|
270
|
+
query.set_data(data, updated_at=updated_at)
|
|
271
|
+
return True
|
|
272
|
+
|
|
273
|
+
# Filter case
|
|
274
|
+
queries = self.get_queries(key_or_filter)
|
|
275
|
+
for q in queries:
|
|
276
|
+
q.set_data(data, updated_at=updated_at)
|
|
277
|
+
return len(queries)
|
|
278
|
+
|
|
279
|
+
def set_infinite_data(
|
|
280
|
+
self,
|
|
281
|
+
key: QueryKey,
|
|
282
|
+
pages: list[Page[Any, Any]]
|
|
283
|
+
| Callable[[list[Page[Any, Any]]], list[Page[Any, Any]]],
|
|
284
|
+
*,
|
|
285
|
+
updated_at: float | dt.datetime | None = None,
|
|
286
|
+
) -> bool:
|
|
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
|
+
"""
|
|
297
|
+
query = self.get_infinite(key)
|
|
298
|
+
if query is None:
|
|
299
|
+
return False
|
|
300
|
+
query.set_data(pages, updated_at=updated_at)
|
|
301
|
+
return True
|
|
302
|
+
|
|
303
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
304
|
+
# Invalidation
|
|
305
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
@overload
|
|
308
|
+
def invalidate(
|
|
309
|
+
self,
|
|
310
|
+
key_or_filter: QueryKey,
|
|
311
|
+
*,
|
|
312
|
+
cancel_refetch: bool = False,
|
|
313
|
+
) -> bool: ...
|
|
314
|
+
|
|
315
|
+
@overload
|
|
316
|
+
def invalidate(
|
|
317
|
+
self,
|
|
318
|
+
key_or_filter: list[QueryKey] | Callable[[QueryKey], bool] | None = None,
|
|
319
|
+
*,
|
|
320
|
+
cancel_refetch: bool = False,
|
|
321
|
+
) -> int: ...
|
|
322
|
+
|
|
323
|
+
def invalidate(
|
|
324
|
+
self,
|
|
325
|
+
key_or_filter: QueryKey
|
|
326
|
+
| list[QueryKey]
|
|
327
|
+
| Callable[[QueryKey], bool]
|
|
328
|
+
| None = None,
|
|
329
|
+
*,
|
|
330
|
+
cancel_refetch: bool = False,
|
|
331
|
+
) -> bool | int:
|
|
332
|
+
"""
|
|
333
|
+
Invalidate queries matching the key or filter.
|
|
334
|
+
|
|
335
|
+
For regular queries: marks as stale and refetches if observed.
|
|
336
|
+
For infinite queries: triggers refetch of all pages if observed.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
key_or_filter: Exact key, filter predicate, or None for all.
|
|
340
|
+
cancel_refetch: Cancel in-flight requests before refetch.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
bool if exact key, int count if filter/None.
|
|
344
|
+
"""
|
|
345
|
+
# Single key case
|
|
346
|
+
if isinstance(key_or_filter, tuple):
|
|
347
|
+
query = self.get(key_or_filter)
|
|
348
|
+
if query is not None:
|
|
349
|
+
query.invalidate(cancel_refetch=cancel_refetch)
|
|
350
|
+
return True
|
|
351
|
+
inf_query = self.get_infinite(key_or_filter)
|
|
352
|
+
if inf_query is not None:
|
|
353
|
+
inf_query.invalidate(cancel_fetch=cancel_refetch)
|
|
354
|
+
return True
|
|
355
|
+
return False
|
|
356
|
+
|
|
357
|
+
# Filter case
|
|
358
|
+
queries = self.get_all(key_or_filter)
|
|
359
|
+
for q in queries:
|
|
360
|
+
if isinstance(q, InfiniteQuery):
|
|
361
|
+
q.invalidate(cancel_fetch=cancel_refetch)
|
|
362
|
+
else:
|
|
363
|
+
q.invalidate(cancel_refetch=cancel_refetch)
|
|
364
|
+
return len(queries)
|
|
365
|
+
|
|
366
|
+
def invalidate_prefix(
|
|
367
|
+
self,
|
|
368
|
+
prefix: tuple[Any, ...],
|
|
369
|
+
*,
|
|
370
|
+
cancel_refetch: bool = False,
|
|
371
|
+
) -> int:
|
|
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.
|
|
380
|
+
|
|
381
|
+
Example:
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
# Invalidates ("users",), ("users", 1), ("users", 2, "posts"), etc.
|
|
385
|
+
ps.queries.invalidate_prefix(("users",))
|
|
386
|
+
```
|
|
387
|
+
"""
|
|
388
|
+
return self.invalidate(_prefix_filter(prefix), cancel_refetch=cancel_refetch)
|
|
389
|
+
|
|
390
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
391
|
+
# Refetch
|
|
392
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
async def refetch(
|
|
395
|
+
self,
|
|
396
|
+
key: QueryKey,
|
|
397
|
+
*,
|
|
398
|
+
cancel_refetch: bool = True,
|
|
399
|
+
) -> ActionResult[Any] | None:
|
|
400
|
+
"""Refetch a query by key and return the result.
|
|
401
|
+
|
|
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.
|
|
408
|
+
"""
|
|
409
|
+
query = self.get(key)
|
|
410
|
+
if query is not None:
|
|
411
|
+
return await query.refetch(cancel_refetch=cancel_refetch)
|
|
412
|
+
|
|
413
|
+
inf_query = self.get_infinite(key)
|
|
414
|
+
if inf_query is not None:
|
|
415
|
+
return await inf_query.refetch(cancel_fetch=cancel_refetch)
|
|
416
|
+
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
async def refetch_all(
|
|
420
|
+
self,
|
|
421
|
+
filter: QueryFilter | None = None,
|
|
422
|
+
*,
|
|
423
|
+
cancel_refetch: bool = True,
|
|
424
|
+
) -> list[ActionResult[Any]]:
|
|
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.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
List of ActionResult for each refetched query.
|
|
434
|
+
"""
|
|
435
|
+
queries = self.get_all(filter)
|
|
436
|
+
results: list[ActionResult[Any]] = []
|
|
437
|
+
|
|
438
|
+
for q in queries:
|
|
439
|
+
if isinstance(q, InfiniteQuery):
|
|
440
|
+
result = await q.refetch(cancel_fetch=cancel_refetch)
|
|
441
|
+
else:
|
|
442
|
+
result = await q.refetch(cancel_refetch=cancel_refetch)
|
|
443
|
+
results.append(result)
|
|
444
|
+
|
|
445
|
+
return results
|
|
446
|
+
|
|
447
|
+
async def refetch_prefix(
|
|
448
|
+
self,
|
|
449
|
+
prefix: tuple[Any, ...],
|
|
450
|
+
*,
|
|
451
|
+
cancel_refetch: bool = True,
|
|
452
|
+
) -> list[ActionResult[Any]]:
|
|
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
|
+
"""
|
|
462
|
+
return await self.refetch_all(
|
|
463
|
+
_prefix_filter(prefix), cancel_refetch=cancel_refetch
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
467
|
+
# Error handling
|
|
468
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
def set_error(
|
|
471
|
+
self,
|
|
472
|
+
key: QueryKey,
|
|
473
|
+
error: Exception,
|
|
474
|
+
*,
|
|
475
|
+
updated_at: float | dt.datetime | None = None,
|
|
476
|
+
) -> bool:
|
|
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
|
+
"""
|
|
487
|
+
query = self.get(key)
|
|
488
|
+
if query is not None:
|
|
489
|
+
query.set_error(error, updated_at=updated_at)
|
|
490
|
+
return True
|
|
491
|
+
|
|
492
|
+
inf_query = self.get_infinite(key)
|
|
493
|
+
if inf_query is not None:
|
|
494
|
+
inf_query.set_error(error, updated_at=updated_at)
|
|
495
|
+
return True
|
|
496
|
+
|
|
497
|
+
return False
|
|
498
|
+
|
|
499
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
500
|
+
# Reset / Remove
|
|
501
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
def remove(self, key: QueryKey) -> bool:
|
|
504
|
+
"""Remove a query from the store, disposing it.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
key: The query key tuple to remove.
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
True if query existed and was removed, False otherwise.
|
|
511
|
+
"""
|
|
512
|
+
store = self._get_store()
|
|
513
|
+
entry = store.get_any(key)
|
|
514
|
+
if entry is None:
|
|
515
|
+
return False
|
|
516
|
+
entry.dispose()
|
|
517
|
+
return True
|
|
518
|
+
|
|
519
|
+
def remove_all(self, filter: QueryFilter | None = None) -> int:
|
|
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.
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
Count of removed queries.
|
|
528
|
+
"""
|
|
529
|
+
queries = self.get_all(filter)
|
|
530
|
+
for q in queries:
|
|
531
|
+
q.dispose()
|
|
532
|
+
return len(queries)
|
|
533
|
+
|
|
534
|
+
def remove_prefix(self, prefix: tuple[Any, ...]) -> int:
|
|
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
|
+
"""
|
|
543
|
+
return self.remove_all(_prefix_filter(prefix))
|
|
544
|
+
|
|
545
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
546
|
+
# State queries
|
|
547
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
548
|
+
|
|
549
|
+
def is_fetching(self, filter: QueryFilter | None = None) -> bool:
|
|
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
|
+
"""
|
|
559
|
+
queries = self.get_all(filter)
|
|
560
|
+
for q in queries:
|
|
561
|
+
if q.is_fetching():
|
|
562
|
+
return True
|
|
563
|
+
return False
|
|
564
|
+
|
|
565
|
+
def is_loading(self, filter: QueryFilter | None = None) -> bool:
|
|
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
|
+
"""
|
|
575
|
+
queries = self.get_all(filter)
|
|
576
|
+
for q in queries:
|
|
577
|
+
if isinstance(q, InfiniteQuery):
|
|
578
|
+
if q.status() == "loading":
|
|
579
|
+
return True
|
|
580
|
+
elif q.status() == "loading":
|
|
581
|
+
return True
|
|
582
|
+
return False
|
|
583
|
+
|
|
584
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
585
|
+
# Wait helpers
|
|
586
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
587
|
+
|
|
588
|
+
async def wait(self, key: QueryKey) -> ActionResult[Any] | None:
|
|
589
|
+
"""Wait for a query to complete and return the result.
|
|
590
|
+
|
|
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.
|
|
596
|
+
"""
|
|
597
|
+
query = self.get(key)
|
|
598
|
+
if query is not None:
|
|
599
|
+
return await query.wait()
|
|
600
|
+
|
|
601
|
+
inf_query = self.get_infinite(key)
|
|
602
|
+
if inf_query is not None:
|
|
603
|
+
return await inf_query.wait()
|
|
604
|
+
|
|
605
|
+
return None
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
# Singleton instance accessible via ps.queries
|
|
609
|
+
queries = QueryClient()
|
pulse/queries/common.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import (
|
|
4
|
+
Any,
|
|
5
|
+
Concatenate,
|
|
6
|
+
Generic,
|
|
7
|
+
Hashable,
|
|
8
|
+
Literal,
|
|
9
|
+
ParamSpec,
|
|
10
|
+
TypeAlias,
|
|
11
|
+
TypeVar,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from pulse.state import State
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
TState = TypeVar("TState", bound="State")
|
|
18
|
+
P = ParamSpec("P")
|
|
19
|
+
R = TypeVar("R")
|
|
20
|
+
|
|
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
|
+
|
|
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
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True, frozen=True)
|
|
39
|
+
class ActionSuccess(Generic[T]):
|
|
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
|
+
"""
|
|
57
|
+
|
|
58
|
+
data: T
|
|
59
|
+
status: Literal["success"] = "success"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True, frozen=True)
|
|
63
|
+
class ActionError:
|
|
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
|
+
"""
|
|
81
|
+
|
|
82
|
+
error: Exception
|
|
83
|
+
status: Literal["error"] = "error"
|
|
84
|
+
|
|
85
|
+
|
|
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
|
+
"""
|
|
92
|
+
|
|
93
|
+
OnSuccessFn = Callable[[TState], Any] | Callable[[TState, T], Any]
|
|
94
|
+
OnErrorFn = Callable[[TState], Any] | Callable[[TState, Exception], Any]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def bind_state(
|
|
98
|
+
state: TState, fn: Callable[Concatenate[TState, P], R]
|
|
99
|
+
) -> Callable[P, R]:
|
|
100
|
+
"Type-safe helper to bind a method to a state"
|
|
101
|
+
return fn.__get__(state, state.__class__)
|