pulse-framework 0.1.42__py3-none-any.whl → 0.1.44__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,365 +0,0 @@
1
- import inspect
2
- import time
3
- from collections.abc import Awaitable, Callable
4
- from typing import (
5
- Any,
6
- Generic,
7
- TypeVar,
8
- cast,
9
- override,
10
- )
11
-
12
- from pulse.context import PulseContext
13
- from pulse.helpers import MISSING, Disposable
14
- from pulse.queries.common import OnErrorFn, OnSuccessFn, bind_state
15
- from pulse.queries.query import (
16
- RETRY_DELAY_DEFAULT,
17
- Query,
18
- QueryFetchStatus,
19
- QueryKey,
20
- QueryStatus,
21
- )
22
- from pulse.reactive import Computed, Effect, Untrack
23
- from pulse.state import InitializableProperty, State
24
-
25
- T = TypeVar("T")
26
- TState = TypeVar("TState", bound=State)
27
-
28
-
29
- class QueryResult(Generic[T], Disposable):
30
- """
31
- Thin wrapper around Query that adds callbacks, staleness tracking,
32
- and observation lifecycle.
33
-
34
- For keyed queries, uses a Computed to resolve the correct query based on the key.
35
- """
36
-
37
- _query: Computed[Query[T]]
38
- _stale_time: float
39
- _gc_time: float
40
- _keep_previous_data: bool
41
- _on_success: Callable[[T], Awaitable[None] | None] | None
42
- _on_error: Callable[[Exception], Awaitable[None] | None] | None
43
- _callback_effect: Effect
44
- _observe_effect: Effect
45
- _data_computed: Computed[T | None]
46
- _disposed_data: T | None
47
-
48
- def __init__(
49
- self,
50
- query: Computed[Query[T]],
51
- stale_time: float = 0.0,
52
- gc_time: float = 300.0,
53
- keep_previous_data: bool = False,
54
- on_success: Callable[[T], Awaitable[None] | None] | None = None,
55
- on_error: Callable[[Exception], Awaitable[None] | None] | None = None,
56
- ):
57
- self._query = query
58
- self._stale_time = stale_time
59
- self._gc_time = gc_time
60
- self._keep_previous_data = keep_previous_data
61
- self._on_success = on_success
62
- self._on_error = on_error
63
- self._disposed_data = None
64
-
65
- def observe_effect():
66
- query = self._query()
67
- with Untrack():
68
- # This may create an effect, which should live independently of our observe effect
69
- query.observe(self)
70
-
71
- # If stale or loading, schedule refetch
72
- if self.is_stale():
73
- query.invalidate()
74
-
75
- # Return cleanup function that captures the observer
76
- return lambda: query.unobserve(self)
77
-
78
- self._observe_effect = Effect(
79
- observe_effect,
80
- name=f"query_observe({self._query().key})",
81
- immediate=True,
82
- )
83
- self._data_computed = Computed(
84
- self._data_computed_fn, name=f"query_data({self._query().key})"
85
- )
86
-
87
- @property
88
- def status(self) -> QueryStatus:
89
- return self._query().status()
90
-
91
- @property
92
- def fetch_status(self) -> QueryFetchStatus:
93
- return self._query().fetch_status()
94
-
95
- # Forward property reads to the query's signals (with automatic reactive tracking)
96
- @property
97
- def is_loading(self) -> bool:
98
- return self.status == "loading"
99
-
100
- @property
101
- def is_success(self) -> bool:
102
- return self.status == "success"
103
-
104
- @property
105
- def is_error(self) -> bool:
106
- return self.status == "error"
107
-
108
- @property
109
- def is_fetching(self) -> bool:
110
- return self.fetch_status == "fetching"
111
-
112
- @property
113
- def error(self) -> Exception | None:
114
- return self._query().error.read()
115
-
116
- def _data_computed_fn(self, prev: T | None) -> T | None:
117
- query = self._query()
118
- if self._keep_previous_data and query.status() != "success":
119
- return prev
120
- return query.data()
121
-
122
- @property
123
- def data(self) -> T | None:
124
- return self._data_computed()
125
-
126
- @property
127
- def has_loaded(self) -> bool:
128
- return self.status in ("success", "error")
129
-
130
- def is_stale(self) -> bool:
131
- """Check if the query data is stale based on stale_time."""
132
- query = self._query()
133
- return (time.time() - query.last_updated.read()) > self._stale_time
134
-
135
- async def refetch(self, cancel_refetch: bool = True) -> T:
136
- """Refetch the query data."""
137
- return await self._query().refetch(cancel_refetch=cancel_refetch)
138
-
139
- async def wait(self) -> T:
140
- return await self._query().wait()
141
-
142
- def invalidate(self):
143
- """Mark the query as stale and refetch if there are observers."""
144
- query = self._query()
145
- query.invalidate()
146
-
147
- def set_data(self, data: T):
148
- """Optimistically set data without changing loading/error state."""
149
- query = self._query()
150
- query.data.write(data)
151
-
152
- @override
153
- def dispose(self):
154
- """Clean up the result and its observe effect."""
155
- self._observe_effect.dispose()
156
-
157
-
158
- class QueryProperty(Generic[T, TState], InitializableProperty):
159
- """
160
- Descriptor for state-bound queries.
161
-
162
- Usage:
163
- class S(ps.State):
164
- @ps.query()
165
- async def user(self) -> User: ...
166
-
167
- @user.key
168
- def _user_key(self):
169
- return ("user", self.user_id)
170
- """
171
-
172
- name: str
173
- _fetch_fn: "Callable[[TState], Awaitable[T]]"
174
- _keep_alive: bool
175
- _keep_previous_data: bool
176
- _stale_time: float
177
- _gc_time: float
178
- _retries: int
179
- _retry_delay: float
180
- _initial_data: T | Callable[[TState], T] | None
181
- _key_fn: Callable[[TState], QueryKey] | None
182
- # Not using OnSuccessFn and OnErrorFn since unions of callables are not well
183
- # supported in the type system. We just need to be careful to use
184
- # call_flexible to invoke these functions.
185
- _on_success_fn: Callable[[TState, T], Any] | None
186
- _on_error_fn: Callable[[TState, Exception], Any] | None
187
- _priv_result: str
188
-
189
- def __init__(
190
- self,
191
- name: str,
192
- fetch_fn: "Callable[[TState], Awaitable[T]]",
193
- keep_previous_data: bool = False,
194
- stale_time: float = 0.0,
195
- gc_time: float = 300.0,
196
- retries: int = 3,
197
- retry_delay: float = RETRY_DELAY_DEFAULT,
198
- initial: T | Callable[[TState], T] | None = MISSING,
199
- key: Callable[[TState], QueryKey] | None = None,
200
- on_success: OnSuccessFn[TState, T] | None = None,
201
- on_error: OnErrorFn[TState] | None = None,
202
- ):
203
- self.name = name
204
- self._fetch_fn = fetch_fn
205
- self._key_fn = None
206
- self._on_success_fn = on_success # pyright: ignore[reportAttributeAccessIssue]
207
- self._on_error_fn = on_error # pyright: ignore[reportAttributeAccessIssue]
208
- self._keep_previous_data = keep_previous_data
209
- self._stale_time = stale_time
210
- self._gc_time = gc_time
211
- self._retries = retries
212
- self._retry_delay = retry_delay
213
- self._initial_data = initial
214
- self._priv_result = f"__query_{name}"
215
-
216
- # Decorator to attach a key function
217
- def key(self, fn: Callable[[TState], QueryKey]):
218
- if self._key_fn is not None:
219
- raise RuntimeError(
220
- f"Duplicate key() decorator for query '{self.name}'. Only one is allowed."
221
- )
222
- self._key_fn = fn
223
- return fn
224
-
225
- # Decorator to attach a function providing initial data
226
- def initial_data(self, fn: Callable[[TState], T]):
227
- if self._initial_data is not MISSING:
228
- raise RuntimeError(
229
- f"Duplicate initial_data() decorator for query '{self.name}'. Only one is allowed."
230
- )
231
- self._initial_data = fn
232
- return fn
233
-
234
- # Decorator to attach an on-success handler (sync or async)
235
- def on_success(self, fn: OnSuccessFn[TState, T]):
236
- if self._on_success_fn is not None:
237
- raise RuntimeError(
238
- f"Duplicate on_success() decorator for query '{self.name}'. Only one is allowed."
239
- )
240
- self._on_success_fn = fn # pyright: ignore[reportAttributeAccessIssue]
241
- return fn
242
-
243
- # Decorator to attach an on-error handler (sync or async)
244
- def on_error(self, fn: OnErrorFn[TState]):
245
- if self._on_error_fn is not None:
246
- raise RuntimeError(
247
- f"Duplicate on_error() decorator for query '{self.name}'. Only one is allowed."
248
- )
249
- self._on_error_fn = fn # pyright: ignore[reportAttributeAccessIssue]
250
- return fn
251
-
252
- @override
253
- def initialize(self, state: Any, name: str) -> QueryResult[T]:
254
- # Return cached query instance if present
255
- result: QueryResult[T] | None = getattr(state, self._priv_result, None)
256
- if result:
257
- # Don't re-initialize, just return the cached instance
258
- return result
259
-
260
- # Bind methods to this instance
261
- fetch_fn = bind_state(state, self._fetch_fn)
262
- initial_data = cast(
263
- T | None,
264
- (
265
- self._initial_data(state)
266
- if callable(self._initial_data)
267
- and len(inspect.signature(self._initial_data).parameters) == 1
268
- else self._initial_data
269
- ),
270
- )
271
-
272
- if self._key_fn:
273
- # Keyed query: use session-wide QueryStore
274
- query = self._resolve_keyed(state, fetch_fn, initial_data)
275
- else:
276
- # Unkeyed query: create private Query
277
- query = self._resolve_unkeyed(fetch_fn, initial_data)
278
-
279
- # Wrap query in QueryResult
280
- result = QueryResult[T](
281
- query=query,
282
- stale_time=self._stale_time,
283
- keep_previous_data=self._keep_previous_data,
284
- gc_time=self._gc_time,
285
- on_success=bind_state(state, self._on_success_fn)
286
- if self._on_success_fn
287
- else None,
288
- on_error=bind_state(state, self._on_error_fn)
289
- if self._on_error_fn
290
- else None,
291
- )
292
-
293
- # Store result on the instance
294
- setattr(state, self._priv_result, result)
295
- return result
296
-
297
- def _resolve_keyed(
298
- self,
299
- state: TState,
300
- fetch_fn: Callable[[], Awaitable[T]],
301
- initial_data: T | None,
302
- ) -> Computed[Query[T]]:
303
- """Create or get a keyed query from the session store using a Computed."""
304
- assert self._key_fn is not None
305
-
306
- key_computed = Computed(
307
- bind_state(state, self._key_fn), name=f"query.key.{self.name}"
308
- )
309
- render = PulseContext.get().render
310
- if render is None:
311
- raise RuntimeError("No render session available")
312
- store = render.query_store
313
-
314
- def query() -> Query[T]:
315
- key = key_computed()
316
- return store.ensure(
317
- key,
318
- fetch_fn,
319
- initial_data,
320
- gc_time=self._gc_time,
321
- retries=self._retries,
322
- retry_delay=self._retry_delay,
323
- )
324
-
325
- return Computed(query, name=f"query.{self.name}")
326
-
327
- def _resolve_unkeyed(
328
- self,
329
- fetch_fn: Callable[[], Awaitable[T]],
330
- initial_data: T | None,
331
- ) -> Computed[Query[T]]:
332
- """Create a private unkeyed query."""
333
- query = Query[T](
334
- key=None,
335
- fn=fetch_fn,
336
- initial_data=initial_data,
337
- gc_time=self._gc_time,
338
- retries=self._retries,
339
- retry_delay=self._retry_delay,
340
- )
341
- return Computed(lambda: query, name=f"query.{self.name}")
342
-
343
- def __get__(self, obj: Any, objtype: Any = None) -> QueryResult[T]:
344
- if obj is None:
345
- return self # pyright: ignore[reportReturnType]
346
- return self.initialize(obj, self.name)
347
-
348
-
349
- class QueryResultWithInitial(QueryResult[T]):
350
- @property
351
- @override
352
- def data(self) -> T:
353
- return cast(T, super().data)
354
-
355
- @property
356
- @override
357
- def has_loaded(self) -> bool: # mirror base for completeness
358
- return super().has_loaded
359
-
360
-
361
- class QueryPropertyWithInitial(QueryProperty[T, TState]):
362
- @override
363
- def __get__(self, obj: Any, objtype: Any = None) -> QueryResultWithInitial[T]:
364
- # Reuse base initialization but narrow the return type for type-checkers
365
- return cast(QueryResultWithInitial[T], super().__get__(obj, objtype))