pulse-framework 0.1.40__py3-none-any.whl → 0.1.42__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.
@@ -0,0 +1,24 @@
1
+ from collections.abc import Callable
2
+ from typing import (
3
+ Any,
4
+ Concatenate,
5
+ ParamSpec,
6
+ TypeVar,
7
+ )
8
+
9
+ from pulse.state import State
10
+
11
+ T = TypeVar("T")
12
+ TState = TypeVar("TState", bound="State")
13
+ P = ParamSpec("P")
14
+ R = TypeVar("R")
15
+
16
+ OnSuccessFn = Callable[[TState], Any] | Callable[[TState, T], Any]
17
+ OnErrorFn = Callable[[TState], Any] | Callable[[TState, Exception], Any]
18
+
19
+
20
+ def bind_state(
21
+ state: TState, fn: Callable[Concatenate[TState, P], R]
22
+ ) -> Callable[P, R]:
23
+ "Type-safe helper to bind a method to a state"
24
+ return fn.__get__(state, state.__class__)
@@ -0,0 +1,142 @@
1
+ from collections.abc import Awaitable, Callable
2
+ from typing import (
3
+ Any,
4
+ Concatenate,
5
+ Generic,
6
+ ParamSpec,
7
+ TypeVar,
8
+ override,
9
+ )
10
+
11
+ from pulse.helpers import call_flexible, maybe_await
12
+ from pulse.queries.common import OnErrorFn, OnSuccessFn, bind_state
13
+ from pulse.reactive import Signal
14
+ from pulse.state import InitializableProperty, State
15
+
16
+ T = TypeVar("T")
17
+ TState = TypeVar("TState", bound=State)
18
+ R = TypeVar("R")
19
+ P = ParamSpec("P")
20
+
21
+
22
+ class MutationResult(Generic[T, P]):
23
+ """
24
+ Result object for mutations that provides reactive access to mutation state
25
+ and is callable to execute the mutation.
26
+ """
27
+
28
+ _data: Signal[T | None]
29
+ _is_running: Signal[bool]
30
+ _error: Signal[Exception | None]
31
+ _fn: Callable[P, Awaitable[T]]
32
+ _on_success: Callable[[T], Any] | None
33
+ _on_error: Callable[[Exception], Any] | None
34
+
35
+ def __init__(
36
+ self,
37
+ fn: Callable[P, Awaitable[T]],
38
+ on_success: Callable[[T], Any] | None = None,
39
+ on_error: Callable[[Exception], Any] | None = None,
40
+ ):
41
+ self._data = Signal(None, name="mutation.data")
42
+ self._is_running = Signal(False, name="mutation.is_running")
43
+ self._error = Signal(None, name="mutation.error")
44
+ self._fn = fn
45
+ self._on_success = on_success
46
+ self._on_error = on_error
47
+
48
+ @property
49
+ def data(self) -> T | None:
50
+ return self._data()
51
+
52
+ @property
53
+ def is_running(self) -> bool:
54
+ return self._is_running()
55
+
56
+ @property
57
+ def error(self) -> Exception | None:
58
+ return self._error()
59
+
60
+ async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T:
61
+ self._is_running.write(True)
62
+ self._error.write(None)
63
+ try:
64
+ mutation_result = await self._fn(*args, **kwargs)
65
+ self._data.write(mutation_result)
66
+ if self._on_success:
67
+ await maybe_await(call_flexible(self._on_success, mutation_result))
68
+ return mutation_result
69
+ except Exception as e:
70
+ self._error.write(e)
71
+ if self._on_error:
72
+ await maybe_await(call_flexible(self._on_error, e))
73
+ raise e
74
+ finally:
75
+ self._is_running.write(False)
76
+
77
+
78
+ class MutationProperty(Generic[T, TState, P], InitializableProperty):
79
+ _on_success_fn: Callable[[TState, T], Any] | None
80
+ _on_error_fn: Callable[[TState, Exception], Any] | None
81
+ name: str
82
+ fn: Callable[Concatenate[TState, P], Awaitable[T]]
83
+
84
+ def __init__(
85
+ self,
86
+ name: str,
87
+ fn: Callable[Concatenate[TState, P], Awaitable[T]],
88
+ on_success: OnSuccessFn[TState, T] | None = None,
89
+ on_error: OnErrorFn[TState] | None = None,
90
+ ):
91
+ self.name = name
92
+ self.fn = fn
93
+ self._on_success_fn = on_success # pyright: ignore[reportAttributeAccessIssue]
94
+ self._on_error_fn = on_error # pyright: ignore[reportAttributeAccessIssue]
95
+
96
+ # Decorator to attach an on-success handler (sync or async)
97
+ def on_success(self, fn: OnSuccessFn[TState, T]):
98
+ if self._on_success_fn is not None:
99
+ raise RuntimeError(
100
+ f"Duplicate on_success() decorator for mutation '{self.name}'. Only one is allowed."
101
+ )
102
+ self._on_success_fn = fn # pyright: ignore[reportAttributeAccessIssue]
103
+ return fn
104
+
105
+ # Decorator to attach an on-error handler (sync or async)
106
+ def on_error(self, fn: OnErrorFn[TState]):
107
+ if self._on_error_fn is not None:
108
+ raise RuntimeError(
109
+ f"Duplicate on_error() decorator for mutation '{self.name}'. Only one is allowed."
110
+ )
111
+ self._on_error_fn = fn # pyright: ignore[reportAttributeAccessIssue]
112
+ return fn
113
+
114
+ def __get__(self, obj: Any, objtype: Any = None) -> MutationResult[T, P]:
115
+ if obj is None:
116
+ return self # pyright: ignore[reportReturnType]
117
+
118
+ # Cache the result on the instance
119
+ cache_key = f"__mutation_{self.name}"
120
+ if not hasattr(obj, cache_key):
121
+ # Bind methods to state
122
+ bound_fn = bind_state(obj, self.fn)
123
+ bound_on_success = (
124
+ bind_state(obj, self._on_success_fn) if self._on_success_fn else None
125
+ )
126
+ bound_on_error = (
127
+ bind_state(obj, self._on_error_fn) if self._on_error_fn else None
128
+ )
129
+
130
+ result = MutationResult[T, P](
131
+ fn=bound_fn,
132
+ on_success=bound_on_success,
133
+ on_error=bound_on_error,
134
+ )
135
+ setattr(obj, cache_key, result)
136
+
137
+ return getattr(obj, cache_key)
138
+
139
+ @override
140
+ def initialize(self, state: State, name: str) -> MutationResult[T, P]:
141
+ # For compatibility with InitializableProperty, but mutations don't need special initialization
142
+ return self.__get__(state, state.__class__)
pulse/queries/query.py ADDED
@@ -0,0 +1,270 @@
1
+ import asyncio
2
+ import time
3
+ from collections.abc import Awaitable, Callable, Hashable
4
+ from dataclasses import dataclass
5
+ from typing import (
6
+ TYPE_CHECKING,
7
+ Any,
8
+ Generic,
9
+ Literal,
10
+ TypeAlias,
11
+ TypeVar,
12
+ cast,
13
+ override,
14
+ )
15
+
16
+ from pulse.helpers import (
17
+ MISSING,
18
+ Disposable,
19
+ call_flexible,
20
+ is_pytest,
21
+ later,
22
+ maybe_await,
23
+ )
24
+ from pulse.reactive import AsyncEffect, Computed, Signal
25
+
26
+ if TYPE_CHECKING:
27
+ from pulse.queries.query_observer import QueryResult
28
+
29
+ T = TypeVar("T")
30
+ QueryKey: TypeAlias = tuple[Hashable, ...]
31
+ QueryStatus: TypeAlias = Literal["loading", "success", "error"]
32
+ QueryFetchStatus: TypeAlias = Literal["idle", "fetching", "paused"]
33
+
34
+
35
+ class AsyncQueryEffect(AsyncEffect):
36
+ """
37
+ Specialized AsyncEffect for queries that synchronously sets loading state
38
+ when rescheduled/run.
39
+ """
40
+
41
+ query: "Query[Any]"
42
+
43
+ def __init__(
44
+ self,
45
+ fn: Callable[[], Awaitable[None]],
46
+ query: "Query[Any]",
47
+ name: str | None = None,
48
+ lazy: bool = False,
49
+ deps: list[Signal[Any] | Computed[Any]] | None = None,
50
+ ):
51
+ self.query = query
52
+ super().__init__(fn, name=name, lazy=lazy, deps=deps)
53
+
54
+ @override
55
+ def run(self) -> asyncio.Task[Any]:
56
+ # Immediately set loading state before running the effect
57
+ self.query.fetch_status.write("fetching")
58
+ return super().run()
59
+
60
+
61
+ @dataclass(slots=True)
62
+ class QueryConfig(Generic[T]):
63
+ retries: int
64
+ retry_delay: float
65
+ initial_data: T | Callable[[], T] | None
66
+ gc_time: float
67
+ on_dispose: Callable[[Any], None] | None
68
+
69
+
70
+ RETRY_DELAY_DEFAULT = 2.0 if not is_pytest() else 0.01
71
+
72
+
73
+ class Query(Generic[T], Disposable):
74
+ """
75
+ Represents a single query instance in a store.
76
+ Manages the async effect, data/status signals, and observer tracking.
77
+ """
78
+
79
+ key: QueryKey | None
80
+ fn: Callable[[], Awaitable[T]]
81
+ cfg: QueryConfig[T]
82
+
83
+ # Reactive signals for query state
84
+ data: Signal[T | None]
85
+ error: Signal[Exception | None]
86
+ last_updated: Signal[float]
87
+ status: Signal[QueryStatus]
88
+ fetch_status: Signal[QueryFetchStatus]
89
+ retries: Signal[int]
90
+ retry_reason: Signal[Exception | None]
91
+
92
+ _observers: "list[QueryResult[T]]"
93
+ _effect: AsyncEffect | None
94
+ _gc_handle: asyncio.TimerHandle | None
95
+
96
+ def __init__(
97
+ self,
98
+ key: QueryKey | None,
99
+ fn: Callable[[], Awaitable[T]],
100
+ retries: int = 3,
101
+ retry_delay: float = RETRY_DELAY_DEFAULT,
102
+ initial_data: T | None = MISSING,
103
+ gc_time: float = 300.0,
104
+ on_dispose: Callable[[Any], None] | None = None,
105
+ ):
106
+ self.key = key
107
+ self.fn = fn
108
+ self.cfg = QueryConfig(
109
+ retries=retries,
110
+ retry_delay=retry_delay,
111
+ initial_data=initial_data,
112
+ gc_time=gc_time,
113
+ on_dispose=on_dispose,
114
+ )
115
+
116
+ # Initialize reactive signals
117
+ self.data = Signal(
118
+ None if initial_data is MISSING else initial_data, name=f"query.data({key})"
119
+ )
120
+ self.error = Signal(None, name=f"query.error({key})")
121
+ self.last_updated = Signal(
122
+ time.time() if initial_data is not MISSING else 0.0,
123
+ name=f"query.last_updated({key})",
124
+ )
125
+ self.status = Signal(
126
+ "loading" if initial_data is MISSING else "success",
127
+ name=f"query.status({key})",
128
+ )
129
+ self.fetch_status = Signal("idle", name=f"query.fetch_status({key})")
130
+ self.retries = Signal(0, name=f"query.retries({key})")
131
+ self.retry_reason = Signal(None, name=f"query.retry_reason({key})")
132
+
133
+ self._observers = []
134
+ self._gc_handle = None
135
+ # Effect is created lazily on first observation
136
+ self._effect = None
137
+
138
+ def set_data(self, data: T):
139
+ self._set_success(data, manual=True)
140
+
141
+ def set_error(self, error: Exception):
142
+ self._set_error(error, manual=True)
143
+
144
+ def _set_success(self, data: T, manual: bool = False):
145
+ self.data.write(data)
146
+ self.last_updated.write(time.time())
147
+ self.error.write(None)
148
+ self.status.write("success")
149
+ if not manual:
150
+ self.fetch_status.write("idle")
151
+ self.retries.write(0)
152
+ self.retry_reason.write(None)
153
+
154
+ def _set_error(self, error: Exception, manual: bool = False):
155
+ self.error.write(error)
156
+ self.last_updated.write(time.time())
157
+ self.status.write("error")
158
+ if not manual:
159
+ self.fetch_status.write("idle")
160
+ # Don't reset retries on final error - preserve for debugging
161
+ # retry_reason is updated to the final error in _run
162
+
163
+ def _failed_retry(self, reason: Exception):
164
+ self.retries.write(self.retries.read() + 1)
165
+ self.retry_reason.write(reason)
166
+
167
+ @property
168
+ def effect(self) -> AsyncEffect:
169
+ """Lazy property that creates the query effect on first access."""
170
+ if self._effect is None:
171
+ self._effect = AsyncQueryEffect(
172
+ self._run,
173
+ query=self,
174
+ name=f"query_effect({self.key})",
175
+ deps=[] if self.key is not None else None,
176
+ )
177
+ return self._effect
178
+
179
+ async def _run(self):
180
+ # Reset retries at start of run
181
+ self.retries.write(0)
182
+ self.retry_reason.write(None)
183
+
184
+ while True:
185
+ try:
186
+ result = await self.fn()
187
+ self._set_success(result)
188
+ for obs in self._observers:
189
+ if obs._on_success: # pyright: ignore[reportPrivateUsage]
190
+ await maybe_await(call_flexible(obs._on_success, result)) # pyright: ignore[reportPrivateUsage]
191
+ return
192
+ except asyncio.CancelledError:
193
+ raise
194
+ except Exception as e:
195
+ current_retries = self.retries.read()
196
+ if current_retries < self.cfg.retries:
197
+ # Record failed retry attempt and retry
198
+ self._failed_retry(e)
199
+ # Wait before retrying
200
+ await asyncio.sleep(self.cfg.retry_delay)
201
+ else:
202
+ # All retries exhausted - update retry_reason to final error
203
+ self.retry_reason.write(e)
204
+ self._set_error(e)
205
+ for obs in self._observers:
206
+ if obs._on_error: # pyright: ignore[reportPrivateUsage]
207
+ await maybe_await(call_flexible(obs._on_error, e)) # pyright: ignore[reportPrivateUsage]
208
+ return
209
+
210
+ async def refetch(self, cancel_refetch: bool = True) -> T:
211
+ """
212
+ Reruns the query and returns the result.
213
+ If cancel_refetch is True (default), cancels any in-flight request and starts a new one.
214
+ If cancel_refetch is False, deduplicates requests if one is already in flight.
215
+ """
216
+ if cancel_refetch:
217
+ self.effect.cancel()
218
+ return await self.wait()
219
+
220
+ async def wait(self) -> T:
221
+ await self.effect.wait()
222
+ return cast(T, self.data.read())
223
+
224
+ def invalidate(self, cancel_refetch: bool = False):
225
+ """
226
+ Marks query as stale. If there are active observers, triggers a refetch.
227
+ """
228
+ should_schedule = not self.effect.is_scheduled or cancel_refetch
229
+ if should_schedule and len(self._observers) > 0:
230
+ self.effect.schedule()
231
+
232
+ def observe(
233
+ self,
234
+ observer: "QueryResult[T]",
235
+ ):
236
+ _ = self.effect # ensure effect is created
237
+ self._observers.append(observer)
238
+ self.cancel_gc()
239
+ if observer._gc_time > 0: # pyright: ignore[reportPrivateUsage]
240
+ self.cfg.gc_time = max(self.cfg.gc_time, observer._gc_time) # pyright: ignore[reportPrivateUsage]
241
+
242
+ def unobserve(self, observer: "QueryResult[T]"):
243
+ """Unregister an observer. Schedules GC if no observers remain."""
244
+ if observer in self._observers:
245
+ self._observers.remove(observer)
246
+ if len(self._observers) == 0:
247
+ self.schedule_gc()
248
+
249
+ def schedule_gc(self):
250
+ self.cancel_gc()
251
+ if self.cfg.gc_time > 0:
252
+ self._gc_handle = later(self.cfg.gc_time, self.dispose)
253
+ else:
254
+ self.dispose()
255
+
256
+ def cancel_gc(self):
257
+ if self._gc_handle:
258
+ self._gc_handle.cancel()
259
+ self._gc_handle = None
260
+
261
+ @override
262
+ def dispose(self):
263
+ """
264
+ Cleans up the query entry, removing it from the store.
265
+ """
266
+ if self._effect:
267
+ self._effect.dispose()
268
+
269
+ if self.cfg.on_dispose:
270
+ self.cfg.on_dispose(self)