pulse-framework 0.1.44__py3-none-any.whl → 0.1.47__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 +10 -24
- pulse/app.py +3 -25
- pulse/codegen/codegen.py +43 -88
- pulse/codegen/js.py +35 -5
- pulse/codegen/templates/route.py +341 -254
- pulse/form.py +1 -1
- pulse/helpers.py +40 -8
- pulse/hooks/core.py +2 -2
- pulse/hooks/effects.py +1 -1
- pulse/hooks/init.py +2 -1
- pulse/hooks/setup.py +1 -1
- pulse/hooks/stable.py +2 -2
- pulse/hooks/states.py +2 -2
- pulse/html/props.py +3 -2
- pulse/html/tags.py +135 -0
- pulse/html/tags.pyi +4 -0
- pulse/js/__init__.py +110 -0
- pulse/js/__init__.pyi +95 -0
- pulse/js/_types.py +297 -0
- pulse/js/array.py +253 -0
- pulse/js/console.py +47 -0
- pulse/js/date.py +113 -0
- pulse/js/document.py +138 -0
- pulse/js/error.py +139 -0
- pulse/js/json.py +62 -0
- pulse/js/map.py +84 -0
- pulse/js/math.py +66 -0
- pulse/js/navigator.py +76 -0
- pulse/js/number.py +54 -0
- pulse/js/object.py +173 -0
- pulse/js/promise.py +150 -0
- pulse/js/regexp.py +54 -0
- pulse/js/set.py +109 -0
- pulse/js/string.py +35 -0
- pulse/js/weakmap.py +50 -0
- pulse/js/weakset.py +45 -0
- pulse/js/window.py +199 -0
- pulse/messages.py +22 -3
- pulse/queries/client.py +7 -7
- pulse/queries/effect.py +16 -0
- pulse/queries/infinite_query.py +138 -29
- pulse/queries/mutation.py +1 -15
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +610 -174
- pulse/queries/store.py +11 -14
- pulse/react_component.py +167 -14
- pulse/reactive.py +19 -1
- pulse/reactive_extensions.py +5 -5
- pulse/render_session.py +185 -59
- pulse/renderer.py +80 -158
- pulse/routing.py +1 -18
- pulse/transpiler/__init__.py +131 -0
- pulse/transpiler/builtins.py +731 -0
- pulse/transpiler/constants.py +110 -0
- pulse/transpiler/context.py +26 -0
- pulse/transpiler/errors.py +2 -0
- pulse/transpiler/function.py +250 -0
- pulse/transpiler/ids.py +16 -0
- pulse/transpiler/imports.py +409 -0
- pulse/transpiler/js_module.py +274 -0
- pulse/transpiler/modules/__init__.py +30 -0
- pulse/transpiler/modules/asyncio.py +38 -0
- pulse/transpiler/modules/json.py +20 -0
- pulse/transpiler/modules/math.py +320 -0
- pulse/transpiler/modules/re.py +466 -0
- pulse/transpiler/modules/tags.py +268 -0
- pulse/transpiler/modules/typing.py +59 -0
- pulse/transpiler/nodes.py +1216 -0
- pulse/transpiler/py_module.py +119 -0
- pulse/transpiler/transpiler.py +938 -0
- pulse/transpiler/utils.py +4 -0
- pulse/types/event_handler.py +3 -2
- pulse/vdom.py +212 -13
- {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/METADATA +1 -1
- pulse_framework-0.1.47.dist-info/RECORD +119 -0
- pulse/codegen/imports.py +0 -204
- pulse/css.py +0 -155
- pulse_framework-0.1.44.dist-info/RECORD +0 -79
- {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/entry_points.txt +0 -0
pulse/queries/query.py
CHANGED
|
@@ -5,6 +5,7 @@ import time
|
|
|
5
5
|
from collections.abc import Awaitable, Callable
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from typing import (
|
|
8
|
+
TYPE_CHECKING,
|
|
8
9
|
Any,
|
|
9
10
|
Generic,
|
|
10
11
|
TypeVar,
|
|
@@ -33,9 +34,12 @@ from pulse.queries.common import (
|
|
|
33
34
|
bind_state,
|
|
34
35
|
)
|
|
35
36
|
from pulse.queries.effect import AsyncQueryEffect
|
|
36
|
-
from pulse.reactive import
|
|
37
|
+
from pulse.reactive import Computed, Effect, Signal, Untrack
|
|
37
38
|
from pulse.state import InitializableProperty, State
|
|
38
39
|
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from pulse.queries.protocol import QueryResult
|
|
42
|
+
|
|
39
43
|
T = TypeVar("T")
|
|
40
44
|
TState = TypeVar("TState", bound=State)
|
|
41
45
|
|
|
@@ -52,14 +56,12 @@ class QueryConfig(Generic[T]):
|
|
|
52
56
|
on_dispose: Callable[[Any], None] | None
|
|
53
57
|
|
|
54
58
|
|
|
55
|
-
class
|
|
59
|
+
class QueryState(Generic[T]):
|
|
56
60
|
"""
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
Container for query state signals and manipulation methods.
|
|
62
|
+
Used by both KeyedQuery and UnkeyedQuery via composition.
|
|
59
63
|
"""
|
|
60
64
|
|
|
61
|
-
key: QueryKey | None
|
|
62
|
-
fn: Callable[[], Awaitable[T]]
|
|
63
65
|
cfg: QueryConfig[T]
|
|
64
66
|
|
|
65
67
|
# Reactive signals for query state
|
|
@@ -71,14 +73,9 @@ class Query(Generic[T], Disposable):
|
|
|
71
73
|
retries: Signal[int]
|
|
72
74
|
retry_reason: Signal[Exception | None]
|
|
73
75
|
|
|
74
|
-
_observers: "list[QueryResult[T]]"
|
|
75
|
-
_effect: AsyncEffect | None
|
|
76
|
-
_gc_handle: asyncio.TimerHandle | None
|
|
77
|
-
|
|
78
76
|
def __init__(
|
|
79
77
|
self,
|
|
80
|
-
|
|
81
|
-
fn: Callable[[], Awaitable[T]],
|
|
78
|
+
name: str,
|
|
82
79
|
retries: int = 3,
|
|
83
80
|
retry_delay: float = RETRY_DELAY_DEFAULT,
|
|
84
81
|
initial_data: T | None = MISSING,
|
|
@@ -86,8 +83,6 @@ class Query(Generic[T], Disposable):
|
|
|
86
83
|
gc_time: float = 300.0,
|
|
87
84
|
on_dispose: Callable[[Any], None] | None = None,
|
|
88
85
|
):
|
|
89
|
-
self.key = key
|
|
90
|
-
self.fn = fn
|
|
91
86
|
self.cfg = QueryConfig(
|
|
92
87
|
retries=retries,
|
|
93
88
|
retry_delay=retry_delay,
|
|
@@ -99,29 +94,25 @@ class Query(Generic[T], Disposable):
|
|
|
99
94
|
|
|
100
95
|
# Initialize reactive signals
|
|
101
96
|
self.data = Signal(
|
|
102
|
-
None if initial_data is MISSING else initial_data,
|
|
97
|
+
None if initial_data is MISSING else initial_data,
|
|
98
|
+
name=f"query.data({name})",
|
|
103
99
|
)
|
|
104
|
-
self.error = Signal(None, name=f"query.error({
|
|
100
|
+
self.error = Signal(None, name=f"query.error({name})")
|
|
105
101
|
|
|
106
102
|
self.last_updated = Signal(
|
|
107
103
|
0.0,
|
|
108
|
-
name=f"query.last_updated({
|
|
104
|
+
name=f"query.last_updated({name})",
|
|
109
105
|
)
|
|
110
106
|
if initial_data_updated_at:
|
|
111
107
|
self.set_updated_at(initial_data_updated_at)
|
|
112
108
|
|
|
113
109
|
self.status = Signal(
|
|
114
110
|
"loading" if initial_data is MISSING else "success",
|
|
115
|
-
name=f"query.status({
|
|
111
|
+
name=f"query.status({name})",
|
|
116
112
|
)
|
|
117
|
-
self.is_fetching = Signal(False, name=f"query.is_fetching({
|
|
118
|
-
self.retries = Signal(0, name=f"query.retries({
|
|
119
|
-
self.retry_reason = Signal(None, name=f"query.retry_reason({
|
|
120
|
-
|
|
121
|
-
self._observers = []
|
|
122
|
-
self._gc_handle = None
|
|
123
|
-
# Effect is created lazily on first observation
|
|
124
|
-
self._effect = None
|
|
113
|
+
self.is_fetching = Signal(False, name=f"query.is_fetching({name})")
|
|
114
|
+
self.retries = Signal(0, name=f"query.retries({name})")
|
|
115
|
+
self.retry_reason = Signal(None, name=f"query.retry_reason({name})")
|
|
125
116
|
|
|
126
117
|
def set_data(
|
|
127
118
|
self,
|
|
@@ -132,7 +123,7 @@ class Query(Generic[T], Disposable):
|
|
|
132
123
|
"""Set data manually, accepting a value or updater function."""
|
|
133
124
|
current = self.data.read()
|
|
134
125
|
new_value = cast(T, data(current) if callable(data) else data)
|
|
135
|
-
self.
|
|
126
|
+
self.set_success(new_value, manual=True)
|
|
136
127
|
if updated_at is not None:
|
|
137
128
|
self.set_updated_at(updated_at)
|
|
138
129
|
|
|
@@ -158,11 +149,12 @@ class Query(Generic[T], Disposable):
|
|
|
158
149
|
def set_error(
|
|
159
150
|
self, error: Exception, *, updated_at: float | dt.datetime | None = None
|
|
160
151
|
):
|
|
161
|
-
self.
|
|
152
|
+
self.apply_error(error, manual=True)
|
|
162
153
|
if updated_at is not None:
|
|
163
154
|
self.set_updated_at(updated_at)
|
|
164
155
|
|
|
165
|
-
def
|
|
156
|
+
def set_success(self, data: T, manual: bool = False):
|
|
157
|
+
"""Set success state with data."""
|
|
166
158
|
self.data.write(data)
|
|
167
159
|
self.last_updated.write(time.time())
|
|
168
160
|
self.error.write(None)
|
|
@@ -172,105 +164,304 @@ class Query(Generic[T], Disposable):
|
|
|
172
164
|
self.retries.write(0)
|
|
173
165
|
self.retry_reason.write(None)
|
|
174
166
|
|
|
175
|
-
def
|
|
167
|
+
def apply_error(self, error: Exception, manual: bool = False):
|
|
168
|
+
"""Apply error state to the query."""
|
|
176
169
|
self.error.write(error)
|
|
177
170
|
self.last_updated.write(time.time())
|
|
178
171
|
self.status.write("error")
|
|
179
172
|
if not manual:
|
|
180
173
|
self.is_fetching.write(False)
|
|
181
174
|
# Don't reset retries on final error - preserve for debugging
|
|
182
|
-
# retry_reason is updated to the final error in _run
|
|
183
175
|
|
|
184
|
-
def
|
|
176
|
+
def failed_retry(self, reason: Exception):
|
|
177
|
+
"""Record a failed retry attempt."""
|
|
185
178
|
self.retries.write(self.retries.read() + 1)
|
|
186
179
|
self.retry_reason.write(reason)
|
|
187
180
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
"""Lazy property that creates the query effect on first access."""
|
|
191
|
-
if self._effect is None:
|
|
192
|
-
self._effect = AsyncQueryEffect(
|
|
193
|
-
self._run,
|
|
194
|
-
fetcher=self,
|
|
195
|
-
name=f"query_effect({self.key})",
|
|
196
|
-
deps=[] if self.key is not None else None,
|
|
197
|
-
)
|
|
198
|
-
return self._effect
|
|
199
|
-
|
|
200
|
-
async def _run(self):
|
|
201
|
-
# Reset retries at start of run
|
|
181
|
+
def reset_retries(self):
|
|
182
|
+
"""Reset retry state at start of fetch."""
|
|
202
183
|
self.retries.write(0)
|
|
203
184
|
self.retry_reason.write(None)
|
|
204
185
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
186
|
+
|
|
187
|
+
async def run_fetch_with_retries(
|
|
188
|
+
state: QueryState[T],
|
|
189
|
+
fetch_fn: Callable[[], Awaitable[T]],
|
|
190
|
+
on_success: Callable[[T], Awaitable[None] | None] | None = None,
|
|
191
|
+
on_error: Callable[[Exception], Awaitable[None] | None] | None = None,
|
|
192
|
+
untrack: bool = False,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""
|
|
195
|
+
Execute a fetch with retry logic, updating QueryState.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
state: The QueryState to update
|
|
199
|
+
fetch_fn: Async function to fetch data
|
|
200
|
+
on_success: Optional callback on success
|
|
201
|
+
on_error: Optional callback on error
|
|
202
|
+
untrack: If True, wrap fetch_fn in Untrack() to prevent dependency tracking.
|
|
203
|
+
Use for keyed queries where fetch is triggered via asyncio.create_task.
|
|
204
|
+
"""
|
|
205
|
+
state.reset_retries()
|
|
206
|
+
|
|
207
|
+
while True:
|
|
208
|
+
try:
|
|
209
|
+
if untrack:
|
|
210
|
+
with Untrack():
|
|
211
|
+
result = await fetch_fn()
|
|
212
|
+
else:
|
|
213
|
+
result = await fetch_fn()
|
|
214
|
+
state.set_success(result)
|
|
215
|
+
if on_success:
|
|
216
|
+
await maybe_await(call_flexible(on_success, result))
|
|
217
|
+
return
|
|
218
|
+
except asyncio.CancelledError:
|
|
219
|
+
raise
|
|
220
|
+
except Exception as e:
|
|
221
|
+
current_retries = state.retries.read()
|
|
222
|
+
if current_retries < state.cfg.retries:
|
|
223
|
+
state.failed_retry(e)
|
|
224
|
+
await asyncio.sleep(state.cfg.retry_delay)
|
|
225
|
+
else:
|
|
226
|
+
state.retry_reason.write(e)
|
|
227
|
+
state.apply_error(e)
|
|
228
|
+
if on_error:
|
|
229
|
+
await maybe_await(call_flexible(on_error, e))
|
|
212
230
|
return
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class KeyedQuery(Generic[T], Disposable):
|
|
234
|
+
"""
|
|
235
|
+
Query for keyed queries (shared across observers).
|
|
236
|
+
Uses direct task management without dependency tracking.
|
|
237
|
+
Multiple observers can share the same query.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
key: QueryKey
|
|
241
|
+
state: QueryState[T]
|
|
242
|
+
observers: "list[KeyedQueryResult[T]]"
|
|
243
|
+
_task: asyncio.Task[None] | None
|
|
244
|
+
_task_initiator: "KeyedQueryResult[T] | None"
|
|
245
|
+
_gc_handle: asyncio.TimerHandle | None
|
|
246
|
+
|
|
247
|
+
def __init__(
|
|
248
|
+
self,
|
|
249
|
+
key: QueryKey,
|
|
250
|
+
retries: int = 3,
|
|
251
|
+
retry_delay: float = RETRY_DELAY_DEFAULT,
|
|
252
|
+
initial_data: T | None = MISSING,
|
|
253
|
+
initial_data_updated_at: float | dt.datetime | None = None,
|
|
254
|
+
gc_time: float = 300.0,
|
|
255
|
+
on_dispose: Callable[[Any], None] | None = None,
|
|
256
|
+
):
|
|
257
|
+
self.key = key
|
|
258
|
+
self.state = QueryState(
|
|
259
|
+
name=str(key),
|
|
260
|
+
retries=retries,
|
|
261
|
+
retry_delay=retry_delay,
|
|
262
|
+
initial_data=initial_data,
|
|
263
|
+
initial_data_updated_at=initial_data_updated_at,
|
|
264
|
+
gc_time=gc_time,
|
|
265
|
+
on_dispose=on_dispose,
|
|
266
|
+
)
|
|
267
|
+
self.observers = []
|
|
268
|
+
self._task = None
|
|
269
|
+
self._task_initiator = None
|
|
270
|
+
self._gc_handle = None
|
|
271
|
+
|
|
272
|
+
# --- Delegate signal access to state ---
|
|
273
|
+
@property
|
|
274
|
+
def data(self) -> Signal[T | None]:
|
|
275
|
+
return self.state.data
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def error(self) -> Signal[Exception | None]:
|
|
279
|
+
return self.state.error
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def last_updated(self) -> Signal[float]:
|
|
283
|
+
return self.state.last_updated
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def status(self) -> Signal[QueryStatus]:
|
|
287
|
+
return self.state.status
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def is_fetching(self) -> Signal[bool]:
|
|
291
|
+
return self.state.is_fetching
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def retries(self) -> Signal[int]:
|
|
295
|
+
return self.state.retries
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def retry_reason(self) -> Signal[Exception | None]:
|
|
299
|
+
return self.state.retry_reason
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def cfg(self) -> QueryConfig[T]:
|
|
303
|
+
return self.state.cfg
|
|
304
|
+
|
|
305
|
+
# --- Delegate state methods ---
|
|
306
|
+
def set_data(
|
|
307
|
+
self,
|
|
308
|
+
data: T | Callable[[T | None], T],
|
|
309
|
+
*,
|
|
310
|
+
updated_at: float | dt.datetime | None = None,
|
|
311
|
+
):
|
|
312
|
+
self.state.set_data(data, updated_at=updated_at)
|
|
313
|
+
|
|
314
|
+
def set_initial_data(
|
|
315
|
+
self,
|
|
316
|
+
data: T | Callable[[], T],
|
|
317
|
+
*,
|
|
318
|
+
updated_at: float | dt.datetime | None = None,
|
|
319
|
+
):
|
|
320
|
+
self.state.set_initial_data(data, updated_at=updated_at)
|
|
321
|
+
|
|
322
|
+
def set_error(
|
|
323
|
+
self, error: Exception, *, updated_at: float | dt.datetime | None = None
|
|
324
|
+
):
|
|
325
|
+
self.state.set_error(error, updated_at=updated_at)
|
|
326
|
+
|
|
327
|
+
# --- Query-specific methods ---
|
|
328
|
+
@property
|
|
329
|
+
def is_scheduled(self) -> bool:
|
|
330
|
+
"""Check if a fetch is currently scheduled/running."""
|
|
331
|
+
return self._task is not None and not self._task.done()
|
|
332
|
+
|
|
333
|
+
async def _run_fetch(
|
|
334
|
+
self,
|
|
335
|
+
fetch_fn: Callable[[], Awaitable[T]],
|
|
336
|
+
observers: "list[KeyedQueryResult[T]]",
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Execute the fetch with retry logic."""
|
|
339
|
+
|
|
340
|
+
async def on_success(result: T):
|
|
341
|
+
for obs in observers:
|
|
342
|
+
if obs._on_success: # pyright: ignore[reportPrivateUsage]
|
|
343
|
+
await maybe_await(call_flexible(obs._on_success, result)) # pyright: ignore[reportPrivateUsage]
|
|
344
|
+
|
|
345
|
+
async def on_error(e: Exception):
|
|
346
|
+
for obs in observers:
|
|
347
|
+
if obs._on_error: # pyright: ignore[reportPrivateUsage]
|
|
348
|
+
await maybe_await(call_flexible(obs._on_error, e)) # pyright: ignore[reportPrivateUsage]
|
|
349
|
+
|
|
350
|
+
await run_fetch_with_retries(
|
|
351
|
+
self.state,
|
|
352
|
+
fetch_fn,
|
|
353
|
+
on_success=on_success,
|
|
354
|
+
on_error=on_error,
|
|
355
|
+
untrack=True, # Keyed queries use asyncio.create_task, need to untrack
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def run_fetch(
|
|
359
|
+
self,
|
|
360
|
+
fetch_fn: Callable[[], Awaitable[T]],
|
|
361
|
+
cancel_previous: bool = True,
|
|
362
|
+
initiator: "KeyedQueryResult[T] | None" = None,
|
|
363
|
+
) -> asyncio.Task[None]:
|
|
364
|
+
"""
|
|
365
|
+
Start a fetch with the given fetch function.
|
|
366
|
+
Cancels any in-flight fetch if cancel_previous is True.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
fetch_fn: The async function to fetch data.
|
|
370
|
+
cancel_previous: If True, cancels any in-flight fetch before starting.
|
|
371
|
+
initiator: The KeyedQueryResult observer that initiated this fetch (for cancellation tracking).
|
|
372
|
+
"""
|
|
373
|
+
if cancel_previous and self._task and not self._task.done():
|
|
374
|
+
self._task.cancel()
|
|
375
|
+
|
|
376
|
+
self.state.is_fetching.write(True)
|
|
377
|
+
# Capture current observers at fetch start
|
|
378
|
+
observers = list(self.observers)
|
|
379
|
+
self._task = asyncio.create_task(self._run_fetch(fetch_fn, observers))
|
|
380
|
+
self._task_initiator = initiator
|
|
381
|
+
return self._task
|
|
382
|
+
|
|
383
|
+
async def wait(self) -> ActionResult[T]:
|
|
384
|
+
"""Wait for the current fetch to complete."""
|
|
385
|
+
while self._task and not self._task.done():
|
|
386
|
+
try:
|
|
387
|
+
await self._task
|
|
213
388
|
except asyncio.CancelledError:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
389
|
+
# Task was cancelled (probably by a new refetch).
|
|
390
|
+
# If there's a new task, wait for that one instead.
|
|
391
|
+
# If no new task, re-raise the cancellation.
|
|
392
|
+
# Note: self._task may have been reassigned by run_fetch() after await
|
|
393
|
+
if self._task is None or self._task.done(): # pyright: ignore[reportUnnecessaryComparison]
|
|
394
|
+
raise
|
|
395
|
+
# Otherwise, loop and wait for the new task
|
|
396
|
+
# Return result based on current state
|
|
397
|
+
if self.state.status() == "error":
|
|
398
|
+
return ActionError(cast(Exception, self.state.error.read()))
|
|
399
|
+
return ActionSuccess(cast(T, self.state.data.read()))
|
|
400
|
+
|
|
401
|
+
def cancel(self) -> None:
|
|
402
|
+
"""Cancel the current fetch if running."""
|
|
403
|
+
if self._task and not self._task.done():
|
|
404
|
+
self._task.cancel()
|
|
405
|
+
self._task = None
|
|
406
|
+
self._task_initiator = None
|
|
407
|
+
|
|
408
|
+
def _get_first_observer_fetch_fn(self) -> Callable[[], Awaitable[T]]:
|
|
409
|
+
"""Get the fetch function from the first observer."""
|
|
410
|
+
if len(self.observers) == 0:
|
|
411
|
+
raise RuntimeError(
|
|
412
|
+
f"Query '{self.key}' has no observers. Cannot access fetch function."
|
|
413
|
+
)
|
|
414
|
+
return self.observers[0]._fetch_fn # pyright: ignore[reportPrivateUsage]
|
|
230
415
|
|
|
231
416
|
async def refetch(self, cancel_refetch: bool = True) -> ActionResult[T]:
|
|
232
417
|
"""
|
|
233
418
|
Reruns the query and returns the result.
|
|
234
|
-
|
|
235
|
-
|
|
419
|
+
Uses the first observer's fetch function.
|
|
420
|
+
|
|
421
|
+
Note: Prefer calling refetch() on KeyedQueryResult to ensure the correct fetch function is used.
|
|
236
422
|
"""
|
|
423
|
+
fetch_fn = self._get_first_observer_fetch_fn()
|
|
237
424
|
if cancel_refetch or not self.is_fetching():
|
|
238
|
-
self.
|
|
425
|
+
self.run_fetch(fetch_fn, cancel_previous=cancel_refetch)
|
|
239
426
|
return await self.wait()
|
|
240
427
|
|
|
241
|
-
async def wait(self) -> ActionResult[T]:
|
|
242
|
-
# If loading and no task, schedule a refetch
|
|
243
|
-
if self.status() == "loading" and not self.is_fetching():
|
|
244
|
-
self.effect.schedule()
|
|
245
|
-
await self.effect.wait()
|
|
246
|
-
# Return result based on current state
|
|
247
|
-
if self.status() == "error":
|
|
248
|
-
return ActionError(cast(Exception, self.error.read()))
|
|
249
|
-
return ActionSuccess(cast(T, self.data.read()))
|
|
250
|
-
|
|
251
428
|
def invalidate(self, cancel_refetch: bool = False):
|
|
252
429
|
"""
|
|
253
430
|
Marks query as stale. If there are active observers, triggers a refetch.
|
|
254
|
-
|
|
255
|
-
should_schedule = not self.effect.is_scheduled or cancel_refetch
|
|
256
|
-
if should_schedule and len(self._observers) > 0:
|
|
257
|
-
self.effect.schedule()
|
|
431
|
+
Uses the first observer's fetch function.
|
|
258
432
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
433
|
+
Note: Prefer calling invalidate() on KeyedQueryResult to ensure the correct fetch function is used.
|
|
434
|
+
"""
|
|
435
|
+
if len(self.observers) > 0:
|
|
436
|
+
fetch_fn = self._get_first_observer_fetch_fn()
|
|
437
|
+
if not self.is_scheduled or cancel_refetch:
|
|
438
|
+
self.run_fetch(fetch_fn, cancel_previous=cancel_refetch)
|
|
439
|
+
|
|
440
|
+
def observe(self, observer: "KeyedQueryResult[T]"):
|
|
441
|
+
"""Register an observer."""
|
|
442
|
+
self.observers.append(observer)
|
|
265
443
|
self.cancel_gc()
|
|
266
444
|
if observer._gc_time > 0: # pyright: ignore[reportPrivateUsage]
|
|
267
445
|
self.cfg.gc_time = max(self.cfg.gc_time, observer._gc_time) # pyright: ignore[reportPrivateUsage]
|
|
268
446
|
|
|
269
|
-
def unobserve(self, observer: "
|
|
447
|
+
def unobserve(self, observer: "KeyedQueryResult[T]"):
|
|
270
448
|
"""Unregister an observer. Schedules GC if no observers remain."""
|
|
271
|
-
if observer in self.
|
|
272
|
-
self.
|
|
273
|
-
|
|
449
|
+
if observer in self.observers:
|
|
450
|
+
self.observers.remove(observer)
|
|
451
|
+
|
|
452
|
+
# If the departing observer initiated the ongoing fetch, cancel it
|
|
453
|
+
if self._task_initiator is observer and self._task and not self._task.done():
|
|
454
|
+
self._task.cancel()
|
|
455
|
+
self._task = None
|
|
456
|
+
self._task_initiator = None
|
|
457
|
+
# Reschedule from another observer if any remain
|
|
458
|
+
if len(self.observers) > 0:
|
|
459
|
+
fetch_fn = self._get_first_observer_fetch_fn()
|
|
460
|
+
self.run_fetch(
|
|
461
|
+
fetch_fn, cancel_previous=False, initiator=self.observers[0]
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
if len(self.observers) == 0:
|
|
274
465
|
self.schedule_gc()
|
|
275
466
|
|
|
276
467
|
def schedule_gc(self):
|
|
@@ -287,43 +478,252 @@ class Query(Generic[T], Disposable):
|
|
|
287
478
|
|
|
288
479
|
@override
|
|
289
480
|
def dispose(self):
|
|
290
|
-
"""
|
|
291
|
-
|
|
292
|
-
"""
|
|
293
|
-
if self._effect:
|
|
294
|
-
self._effect.dispose()
|
|
295
|
-
|
|
481
|
+
"""Clean up the query, cancelling any in-flight fetch."""
|
|
482
|
+
self.cancel()
|
|
296
483
|
if self.cfg.on_dispose:
|
|
297
484
|
self.cfg.on_dispose(self)
|
|
298
485
|
|
|
299
486
|
|
|
300
|
-
class
|
|
487
|
+
class UnkeyedQueryResult(Generic[T], Disposable):
|
|
488
|
+
"""
|
|
489
|
+
Query for unkeyed queries (single observer with dependency tracking).
|
|
490
|
+
Uses an AsyncEffect to track dependencies and re-run on changes.
|
|
491
|
+
|
|
492
|
+
Unlike KeyedQuery which separates the query from its observer (KeyedQueryResult),
|
|
493
|
+
UnkeyedQuery combines both since there's always exactly one observer.
|
|
301
494
|
"""
|
|
302
|
-
Thin wrapper around Query that adds callbacks, staleness tracking,
|
|
303
|
-
and observation lifecycle.
|
|
304
495
|
|
|
305
|
-
|
|
496
|
+
state: QueryState[T]
|
|
497
|
+
_effect: AsyncQueryEffect
|
|
498
|
+
_fetch_fn: Callable[[], Awaitable[T]]
|
|
499
|
+
_on_success: Callable[[T], Awaitable[None] | None] | None
|
|
500
|
+
_on_error: Callable[[Exception], Awaitable[None] | None] | None
|
|
501
|
+
_stale_time: float
|
|
502
|
+
_refetch_interval: float | None
|
|
503
|
+
_keep_previous_data: bool
|
|
504
|
+
_enabled: Signal[bool]
|
|
505
|
+
_interval_effect: Effect | None
|
|
506
|
+
_data_computed: Computed[T | None]
|
|
507
|
+
|
|
508
|
+
def __init__(
|
|
509
|
+
self,
|
|
510
|
+
fetch_fn: Callable[[], Awaitable[T]],
|
|
511
|
+
on_success: Callable[[T], Awaitable[None] | None] | None = None,
|
|
512
|
+
on_error: Callable[[Exception], Awaitable[None] | None] | None = None,
|
|
513
|
+
retries: int = 3,
|
|
514
|
+
retry_delay: float = RETRY_DELAY_DEFAULT,
|
|
515
|
+
initial_data: T | None = MISSING,
|
|
516
|
+
initial_data_updated_at: float | dt.datetime | None = None,
|
|
517
|
+
gc_time: float = 300.0,
|
|
518
|
+
stale_time: float = 0.0,
|
|
519
|
+
refetch_interval: float | None = None,
|
|
520
|
+
keep_previous_data: bool = False,
|
|
521
|
+
enabled: bool = True,
|
|
522
|
+
fetch_on_mount: bool = True,
|
|
523
|
+
):
|
|
524
|
+
self.state = QueryState(
|
|
525
|
+
name="unkeyed",
|
|
526
|
+
retries=retries,
|
|
527
|
+
retry_delay=retry_delay,
|
|
528
|
+
initial_data=initial_data,
|
|
529
|
+
initial_data_updated_at=initial_data_updated_at,
|
|
530
|
+
gc_time=gc_time,
|
|
531
|
+
on_dispose=None,
|
|
532
|
+
)
|
|
533
|
+
self._fetch_fn = fetch_fn
|
|
534
|
+
self._on_success = on_success
|
|
535
|
+
self._on_error = on_error
|
|
536
|
+
self._stale_time = stale_time
|
|
537
|
+
self._refetch_interval = refetch_interval
|
|
538
|
+
self._keep_previous_data = keep_previous_data
|
|
539
|
+
self._enabled = Signal(enabled, name="query.enabled(unkeyed)")
|
|
540
|
+
self._interval_effect = None
|
|
541
|
+
|
|
542
|
+
# Create effect with auto-tracking (deps=None)
|
|
543
|
+
# Pass state as fetcher since it has the Signal attributes directly
|
|
544
|
+
self._effect = AsyncQueryEffect(
|
|
545
|
+
self._run,
|
|
546
|
+
fetcher=self.state,
|
|
547
|
+
name="unkeyed_query_effect",
|
|
548
|
+
deps=None, # Auto-track dependencies
|
|
549
|
+
lazy=True,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
# Computed for keep_previous_data logic
|
|
553
|
+
self._data_computed = Computed(
|
|
554
|
+
self._data_computed_fn, name="query_data(unkeyed)"
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Schedule initial fetch if stale (untracked to avoid reactive loop)
|
|
558
|
+
with Untrack():
|
|
559
|
+
if enabled and fetch_on_mount and self.is_stale():
|
|
560
|
+
self.schedule()
|
|
561
|
+
|
|
562
|
+
# Set up interval effect if interval is specified
|
|
563
|
+
if refetch_interval is not None and refetch_interval > 0:
|
|
564
|
+
self._setup_interval_effect(refetch_interval)
|
|
565
|
+
|
|
566
|
+
def _setup_interval_effect(self, interval: float):
|
|
567
|
+
"""Create an effect that invalidates the query at the specified interval."""
|
|
568
|
+
|
|
569
|
+
def interval_fn():
|
|
570
|
+
if self._enabled():
|
|
571
|
+
self.schedule()
|
|
572
|
+
|
|
573
|
+
self._interval_effect = Effect(
|
|
574
|
+
interval_fn,
|
|
575
|
+
name="query_interval(unkeyed)",
|
|
576
|
+
interval=interval,
|
|
577
|
+
immediate=True,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
def _data_computed_fn(self, prev: T | None) -> T | None:
|
|
581
|
+
if self._keep_previous_data and self.state.status() != "success":
|
|
582
|
+
return prev
|
|
583
|
+
raw = self.state.data()
|
|
584
|
+
if raw is None:
|
|
585
|
+
return None
|
|
586
|
+
return raw
|
|
587
|
+
|
|
588
|
+
# --- Status properties ---
|
|
589
|
+
@property
|
|
590
|
+
def status(self) -> QueryStatus:
|
|
591
|
+
return self.state.status()
|
|
592
|
+
|
|
593
|
+
@property
|
|
594
|
+
def is_loading(self) -> bool:
|
|
595
|
+
return self.status == "loading"
|
|
596
|
+
|
|
597
|
+
@property
|
|
598
|
+
def is_success(self) -> bool:
|
|
599
|
+
return self.status == "success"
|
|
600
|
+
|
|
601
|
+
@property
|
|
602
|
+
def is_error(self) -> bool:
|
|
603
|
+
return self.status == "error"
|
|
604
|
+
|
|
605
|
+
@property
|
|
606
|
+
def is_fetching(self) -> bool:
|
|
607
|
+
return self.state.is_fetching()
|
|
608
|
+
|
|
609
|
+
@property
|
|
610
|
+
def error(self) -> Exception | None:
|
|
611
|
+
return self.state.error.read()
|
|
612
|
+
|
|
613
|
+
@property
|
|
614
|
+
def data(self) -> T | None:
|
|
615
|
+
return self._data_computed()
|
|
616
|
+
|
|
617
|
+
# --- State methods ---
|
|
618
|
+
def set_data(self, data: T | Callable[[T | None], T]):
|
|
619
|
+
"""Optimistically set data without changing loading/error state."""
|
|
620
|
+
self.state.set_data(data)
|
|
621
|
+
|
|
622
|
+
def set_initial_data(
|
|
623
|
+
self,
|
|
624
|
+
data: T | Callable[[], T],
|
|
625
|
+
*,
|
|
626
|
+
updated_at: float | dt.datetime | None = None,
|
|
627
|
+
):
|
|
628
|
+
"""Seed initial data and optional freshness timestamp."""
|
|
629
|
+
self.state.set_initial_data(data, updated_at=updated_at)
|
|
630
|
+
|
|
631
|
+
def set_error(self, error: Exception):
|
|
632
|
+
"""Set error state on the query."""
|
|
633
|
+
self.state.set_error(error)
|
|
634
|
+
|
|
635
|
+
def enable(self):
|
|
636
|
+
"""Enable the query."""
|
|
637
|
+
self._enabled.write(True)
|
|
638
|
+
|
|
639
|
+
def disable(self):
|
|
640
|
+
"""Disable the query, preventing it from fetching."""
|
|
641
|
+
self._enabled.write(False)
|
|
642
|
+
|
|
643
|
+
# --- Query operations ---
|
|
644
|
+
def is_stale(self) -> bool:
|
|
645
|
+
"""Check if the query data is stale based on stale_time."""
|
|
646
|
+
return (time.time() - self.state.last_updated.read()) > self._stale_time
|
|
647
|
+
|
|
648
|
+
async def _run(self):
|
|
649
|
+
"""Run the fetch through the effect (for dependency tracking)."""
|
|
650
|
+
# Unkeyed queries run inside AsyncEffect which has its own scope,
|
|
651
|
+
# so we don't need untrack=True here - deps should be tracked
|
|
652
|
+
await run_fetch_with_retries(
|
|
653
|
+
self.state,
|
|
654
|
+
self._fetch_fn,
|
|
655
|
+
on_success=self._on_success,
|
|
656
|
+
on_error=self._on_error,
|
|
657
|
+
untrack=False,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
def schedule(self):
|
|
661
|
+
"""Schedule the effect to run."""
|
|
662
|
+
self._effect.schedule()
|
|
663
|
+
|
|
664
|
+
@property
|
|
665
|
+
def is_scheduled(self) -> bool:
|
|
666
|
+
"""Check if a fetch is currently scheduled/running."""
|
|
667
|
+
return self._effect.is_scheduled
|
|
668
|
+
|
|
669
|
+
async def refetch(self, cancel_refetch: bool = True) -> ActionResult[T]:
|
|
670
|
+
"""Refetch the query data through the effect."""
|
|
671
|
+
if cancel_refetch:
|
|
672
|
+
self.cancel()
|
|
673
|
+
self.schedule()
|
|
674
|
+
return await self.wait()
|
|
675
|
+
|
|
676
|
+
async def wait(self) -> ActionResult[T]:
|
|
677
|
+
"""Wait for the current query to complete."""
|
|
678
|
+
# If loading and no task, schedule a fetch
|
|
679
|
+
if self.state.status() == "loading" and not self.state.is_fetching():
|
|
680
|
+
self.schedule()
|
|
681
|
+
await self._effect.wait()
|
|
682
|
+
if self.state.status() == "error":
|
|
683
|
+
return ActionError(cast(Exception, self.state.error.read()))
|
|
684
|
+
return ActionSuccess(cast(T, self.state.data.read()))
|
|
685
|
+
|
|
686
|
+
def invalidate(self):
|
|
687
|
+
"""Mark the query as stale and refetch through the effect."""
|
|
688
|
+
if not self.is_scheduled:
|
|
689
|
+
self.schedule()
|
|
690
|
+
|
|
691
|
+
def cancel(self) -> None:
|
|
692
|
+
"""Cancel the current fetch if running."""
|
|
693
|
+
self._effect.cancel(cancel_interval=False)
|
|
694
|
+
|
|
695
|
+
@override
|
|
696
|
+
def dispose(self):
|
|
697
|
+
"""Clean up the query and its effect."""
|
|
698
|
+
if self._interval_effect is not None:
|
|
699
|
+
self._interval_effect.dispose()
|
|
700
|
+
self._effect.dispose()
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
class KeyedQueryResult(Generic[T], Disposable):
|
|
704
|
+
"""
|
|
705
|
+
Observer wrapper for keyed queries.
|
|
706
|
+
Handles observation lifecycle, staleness tracking, and provides query operations.
|
|
306
707
|
"""
|
|
307
708
|
|
|
308
|
-
_query: Computed[
|
|
709
|
+
_query: Computed[KeyedQuery[T]]
|
|
710
|
+
_fetch_fn: Callable[[], Awaitable[T]]
|
|
309
711
|
_stale_time: float
|
|
310
712
|
_gc_time: float
|
|
311
713
|
_refetch_interval: float | None
|
|
312
714
|
_keep_previous_data: bool
|
|
313
715
|
_on_success: Callable[[T], Awaitable[None] | None] | None
|
|
314
716
|
_on_error: Callable[[Exception], Awaitable[None] | None] | None
|
|
315
|
-
_callback_effect: Effect
|
|
316
717
|
_observe_effect: Effect
|
|
317
718
|
_interval_effect: Effect | None
|
|
318
719
|
_data_computed: Computed[T | None]
|
|
319
|
-
_disposed_data: T | None
|
|
320
720
|
_enabled: Signal[bool]
|
|
321
721
|
_fetch_on_mount: bool
|
|
322
|
-
_is_observing: bool
|
|
323
722
|
|
|
324
723
|
def __init__(
|
|
325
724
|
self,
|
|
326
|
-
query: Computed[
|
|
725
|
+
query: Computed[KeyedQuery[T]],
|
|
726
|
+
fetch_fn: Callable[[], Awaitable[T]],
|
|
327
727
|
stale_time: float = 0.0,
|
|
328
728
|
gc_time: float = 300.0,
|
|
329
729
|
refetch_interval: float | None = None,
|
|
@@ -334,29 +734,30 @@ class QueryResult(Generic[T], Disposable):
|
|
|
334
734
|
fetch_on_mount: bool = True,
|
|
335
735
|
):
|
|
336
736
|
self._query = query
|
|
737
|
+
self._fetch_fn = fetch_fn
|
|
337
738
|
self._stale_time = stale_time
|
|
338
739
|
self._gc_time = gc_time
|
|
339
740
|
self._refetch_interval = refetch_interval
|
|
340
741
|
self._keep_previous_data = keep_previous_data
|
|
341
742
|
self._on_success = on_success
|
|
342
743
|
self._on_error = on_error
|
|
343
|
-
self._disposed_data = None
|
|
344
744
|
self._enabled = Signal(enabled, name=f"query.enabled({query().key})")
|
|
345
745
|
self._interval_effect = None
|
|
346
746
|
|
|
347
747
|
def observe_effect():
|
|
348
|
-
|
|
748
|
+
q = self._query()
|
|
349
749
|
enabled = self._enabled()
|
|
750
|
+
|
|
350
751
|
with Untrack():
|
|
351
|
-
|
|
752
|
+
q.observe(self)
|
|
352
753
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
754
|
+
# If stale or loading, schedule refetch (only when enabled)
|
|
755
|
+
if enabled and fetch_on_mount and self.is_stale():
|
|
756
|
+
self.invalidate()
|
|
356
757
|
|
|
357
|
-
# Return cleanup function that captures the
|
|
758
|
+
# Return cleanup function that captures the query (old query on key change)
|
|
358
759
|
def cleanup():
|
|
359
|
-
|
|
760
|
+
q.unobserve(self)
|
|
360
761
|
|
|
361
762
|
return cleanup
|
|
362
763
|
|
|
@@ -379,7 +780,7 @@ class QueryResult(Generic[T], Disposable):
|
|
|
379
780
|
def interval_fn():
|
|
380
781
|
# Read enabled to make this effect reactive to enabled changes
|
|
381
782
|
if self._enabled():
|
|
382
|
-
self.
|
|
783
|
+
self.invalidate()
|
|
383
784
|
|
|
384
785
|
self._interval_effect = Effect(
|
|
385
786
|
interval_fn,
|
|
@@ -392,7 +793,6 @@ class QueryResult(Generic[T], Disposable):
|
|
|
392
793
|
def status(self) -> QueryStatus:
|
|
393
794
|
return self._query().status()
|
|
394
795
|
|
|
395
|
-
# Forward property reads to the query's signals (with automatic reactive tracking)
|
|
396
796
|
@property
|
|
397
797
|
def is_loading(self) -> bool:
|
|
398
798
|
return self.status == "loading"
|
|
@@ -409,6 +809,10 @@ class QueryResult(Generic[T], Disposable):
|
|
|
409
809
|
def is_fetching(self) -> bool:
|
|
410
810
|
return self._query().is_fetching()
|
|
411
811
|
|
|
812
|
+
@property
|
|
813
|
+
def is_scheduled(self) -> bool:
|
|
814
|
+
return self._query().is_scheduled
|
|
815
|
+
|
|
412
816
|
@property
|
|
413
817
|
def error(self) -> Exception | None:
|
|
414
818
|
return self._query().error.read()
|
|
@@ -432,16 +836,31 @@ class QueryResult(Generic[T], Disposable):
|
|
|
432
836
|
return (time.time() - query.last_updated.read()) > self._stale_time
|
|
433
837
|
|
|
434
838
|
async def refetch(self, cancel_refetch: bool = True) -> ActionResult[T]:
|
|
435
|
-
"""
|
|
436
|
-
|
|
839
|
+
"""
|
|
840
|
+
Refetch the query data using this observer's fetch function.
|
|
841
|
+
If cancel_refetch is True (default), cancels any in-flight request and starts a new one.
|
|
842
|
+
If cancel_refetch is False, deduplicates requests if one is already in flight.
|
|
843
|
+
"""
|
|
844
|
+
query = self._query()
|
|
845
|
+
if cancel_refetch or not query.is_fetching():
|
|
846
|
+
query.run_fetch(
|
|
847
|
+
self._fetch_fn, cancel_previous=cancel_refetch, initiator=self
|
|
848
|
+
)
|
|
849
|
+
return await self.wait()
|
|
437
850
|
|
|
438
851
|
async def wait(self) -> ActionResult[T]:
|
|
439
|
-
|
|
852
|
+
"""Wait for the current query to complete."""
|
|
853
|
+
query = self._query()
|
|
854
|
+
# If loading and no task, start a fetch with this observer's fetch function
|
|
855
|
+
if query.status() == "loading" and not query.is_fetching():
|
|
856
|
+
query.run_fetch(self._fetch_fn, initiator=self)
|
|
857
|
+
return await query.wait()
|
|
440
858
|
|
|
441
859
|
def invalidate(self):
|
|
442
|
-
"""Mark the query as stale and refetch
|
|
860
|
+
"""Mark the query as stale and refetch using this observer's fetch function."""
|
|
443
861
|
query = self._query()
|
|
444
|
-
query.
|
|
862
|
+
if not query.is_scheduled and len(query.observers) > 0:
|
|
863
|
+
query.run_fetch(self._fetch_fn, cancel_previous=False, initiator=self)
|
|
445
864
|
|
|
446
865
|
def set_data(self, data: T | Callable[[T | None], T]):
|
|
447
866
|
"""Optimistically set data without changing loading/error state."""
|
|
@@ -474,9 +893,10 @@ class QueryResult(Generic[T], Disposable):
|
|
|
474
893
|
@override
|
|
475
894
|
def dispose(self):
|
|
476
895
|
"""Clean up the result and its observe effect."""
|
|
477
|
-
if self._interval_effect is not None:
|
|
896
|
+
if self._interval_effect is not None and not self._interval_effect.__disposed__:
|
|
478
897
|
self._interval_effect.dispose()
|
|
479
|
-
self._observe_effect.
|
|
898
|
+
if not self._observe_effect.__disposed__:
|
|
899
|
+
self._observe_effect.dispose()
|
|
480
900
|
|
|
481
901
|
|
|
482
902
|
class QueryProperty(Generic[T, TState], InitializableProperty):
|
|
@@ -583,9 +1003,13 @@ class QueryProperty(Generic[T, TState], InitializableProperty):
|
|
|
583
1003
|
return fn
|
|
584
1004
|
|
|
585
1005
|
@override
|
|
586
|
-
def initialize(
|
|
1006
|
+
def initialize(
|
|
1007
|
+
self, state: Any, name: str
|
|
1008
|
+
) -> KeyedQueryResult[T] | UnkeyedQueryResult[T]:
|
|
587
1009
|
# Return cached query instance if present
|
|
588
|
-
result:
|
|
1010
|
+
result: KeyedQueryResult[T] | UnkeyedQueryResult[T] | None = getattr(
|
|
1011
|
+
state, self._priv_result, None
|
|
1012
|
+
)
|
|
589
1013
|
if result:
|
|
590
1014
|
# Don't re-initialize, just return the cached instance
|
|
591
1015
|
return result
|
|
@@ -602,50 +1026,34 @@ class QueryProperty(Generic[T, TState], InitializableProperty):
|
|
|
602
1026
|
)
|
|
603
1027
|
|
|
604
1028
|
if self._key is None:
|
|
605
|
-
# Unkeyed query: create
|
|
606
|
-
|
|
1029
|
+
# Unkeyed query: create UnkeyedQuery with single observer
|
|
1030
|
+
result = self._create_unkeyed(
|
|
607
1031
|
fetch_fn,
|
|
608
1032
|
initial_data,
|
|
609
1033
|
self._initial_data_updated_at,
|
|
1034
|
+
state,
|
|
610
1035
|
)
|
|
611
1036
|
else:
|
|
612
1037
|
# Keyed query: use session-wide QueryStore
|
|
613
|
-
|
|
1038
|
+
result = self._create_keyed(
|
|
614
1039
|
state,
|
|
615
1040
|
fetch_fn,
|
|
616
1041
|
initial_data,
|
|
617
1042
|
self._initial_data_updated_at,
|
|
618
1043
|
)
|
|
619
1044
|
|
|
620
|
-
# Wrap query in QueryResult
|
|
621
|
-
result = QueryResult[T](
|
|
622
|
-
query=query,
|
|
623
|
-
stale_time=self._stale_time,
|
|
624
|
-
keep_previous_data=self._keep_previous_data,
|
|
625
|
-
gc_time=self._gc_time,
|
|
626
|
-
refetch_interval=self._refetch_interval,
|
|
627
|
-
on_success=bind_state(state, self._on_success_fn)
|
|
628
|
-
if self._on_success_fn
|
|
629
|
-
else None,
|
|
630
|
-
on_error=bind_state(state, self._on_error_fn)
|
|
631
|
-
if self._on_error_fn
|
|
632
|
-
else None,
|
|
633
|
-
enabled=self._enabled,
|
|
634
|
-
fetch_on_mount=self._fetch_on_mount,
|
|
635
|
-
)
|
|
636
|
-
|
|
637
1045
|
# Store result on the instance
|
|
638
1046
|
setattr(state, self._priv_result, result)
|
|
639
1047
|
return result
|
|
640
1048
|
|
|
641
|
-
def
|
|
1049
|
+
def _create_keyed(
|
|
642
1050
|
self,
|
|
643
1051
|
state: TState,
|
|
644
1052
|
fetch_fn: Callable[[], Awaitable[T]],
|
|
645
1053
|
initial_data: T | None,
|
|
646
1054
|
initial_data_updated_at: float | dt.datetime | None,
|
|
647
|
-
) ->
|
|
648
|
-
"""Create or get a keyed query from the session store
|
|
1055
|
+
) -> KeyedQueryResult[T]:
|
|
1056
|
+
"""Create or get a keyed query from the session store."""
|
|
649
1057
|
assert self._key is not None
|
|
650
1058
|
|
|
651
1059
|
# Create a Computed for the key - passthrough for constant keys, reactive for function keys
|
|
@@ -662,39 +1070,67 @@ class QueryProperty(Generic[T, TState], InitializableProperty):
|
|
|
662
1070
|
raise RuntimeError("No render session available")
|
|
663
1071
|
store = render.query_store
|
|
664
1072
|
|
|
665
|
-
def query() ->
|
|
1073
|
+
def query() -> KeyedQuery[T]:
|
|
666
1074
|
key = key_computed()
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1075
|
+
# Use Untrack to avoid an error due to creating an Effect within a computed
|
|
1076
|
+
with Untrack():
|
|
1077
|
+
return store.ensure(
|
|
1078
|
+
key,
|
|
1079
|
+
initial_data,
|
|
1080
|
+
initial_data_updated_at=initial_data_updated_at,
|
|
1081
|
+
gc_time=self._gc_time,
|
|
1082
|
+
retries=self._retries,
|
|
1083
|
+
retry_delay=self._retry_delay,
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
query_computed = Computed(query, name=f"query.{self.name}")
|
|
1087
|
+
|
|
1088
|
+
return KeyedQueryResult[T](
|
|
1089
|
+
query=query_computed,
|
|
1090
|
+
fetch_fn=fetch_fn,
|
|
1091
|
+
stale_time=self._stale_time,
|
|
1092
|
+
keep_previous_data=self._keep_previous_data,
|
|
1093
|
+
gc_time=self._gc_time,
|
|
1094
|
+
refetch_interval=self._refetch_interval,
|
|
1095
|
+
on_success=bind_state(state, self._on_success_fn)
|
|
1096
|
+
if self._on_success_fn
|
|
1097
|
+
else None,
|
|
1098
|
+
on_error=bind_state(state, self._on_error_fn)
|
|
1099
|
+
if self._on_error_fn
|
|
1100
|
+
else None,
|
|
1101
|
+
enabled=self._enabled,
|
|
1102
|
+
fetch_on_mount=self._fetch_on_mount,
|
|
1103
|
+
)
|
|
678
1104
|
|
|
679
|
-
def
|
|
1105
|
+
def _create_unkeyed(
|
|
680
1106
|
self,
|
|
681
1107
|
fetch_fn: Callable[[], Awaitable[T]],
|
|
682
1108
|
initial_data: T | None,
|
|
683
1109
|
initial_data_updated_at: float | dt.datetime | None,
|
|
684
|
-
|
|
1110
|
+
state: TState,
|
|
1111
|
+
) -> UnkeyedQueryResult[T]:
|
|
685
1112
|
"""Create a private unkeyed query."""
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1113
|
+
return UnkeyedQueryResult[T](
|
|
1114
|
+
fetch_fn=fetch_fn,
|
|
1115
|
+
on_success=bind_state(state, self._on_success_fn)
|
|
1116
|
+
if self._on_success_fn
|
|
1117
|
+
else None,
|
|
1118
|
+
on_error=bind_state(state, self._on_error_fn)
|
|
1119
|
+
if self._on_error_fn
|
|
1120
|
+
else None,
|
|
1121
|
+
retries=self._retries,
|
|
1122
|
+
retry_delay=self._retry_delay,
|
|
689
1123
|
initial_data=initial_data,
|
|
690
1124
|
initial_data_updated_at=initial_data_updated_at,
|
|
691
1125
|
gc_time=self._gc_time,
|
|
692
|
-
|
|
693
|
-
|
|
1126
|
+
stale_time=self._stale_time,
|
|
1127
|
+
keep_previous_data=self._keep_previous_data,
|
|
1128
|
+
refetch_interval=self._refetch_interval,
|
|
1129
|
+
enabled=self._enabled,
|
|
1130
|
+
fetch_on_mount=self._fetch_on_mount,
|
|
694
1131
|
)
|
|
695
|
-
return Computed(lambda: query, name=f"query.{self.name}")
|
|
696
1132
|
|
|
697
|
-
def __get__(self, obj: Any, objtype: Any = None) -> QueryResult[T]:
|
|
1133
|
+
def __get__(self, obj: Any, objtype: Any = None) -> "QueryResult[T]":
|
|
698
1134
|
if obj is None:
|
|
699
1135
|
return self # pyright: ignore[reportReturnType]
|
|
700
1136
|
return self.initialize(obj, self.name)
|