pulse-framework 0.1.41__py3-none-any.whl → 0.1.43__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 CHANGED
@@ -72,8 +72,6 @@ from pulse.css import (
72
72
  # Decorators
73
73
  from pulse.decorators import computed as computed
74
74
  from pulse.decorators import effect as effect
75
- from pulse.decorators import mutation as mutation
76
- from pulse.decorators import query as query
77
75
 
78
76
  # Environment
79
77
  from pulse.env import PulseEnv as PulseEnv
@@ -161,6 +159,11 @@ from pulse.hooks.core import (
161
159
  # Hooks - Effects
162
160
  from pulse.hooks.effects import EffectsHookState as EffectsHookState
163
161
  from pulse.hooks.effects import effects as effects
162
+
163
+ # Hooks - Init
164
+ from pulse.hooks.init import (
165
+ init as init,
166
+ )
164
167
  from pulse.hooks.runtime import (
165
168
  GLOBAL_STATES as GLOBAL_STATES,
166
169
  )
@@ -1344,7 +1347,18 @@ from pulse.middleware import (
1344
1347
 
1345
1348
  # Plugin
1346
1349
  from pulse.plugin import Plugin as Plugin
1347
- from pulse.queries.query import QueryStatus as QueryStatus
1350
+ from pulse.queries.client import QueryClient as QueryClient
1351
+ from pulse.queries.client import QueryFilter as QueryFilter
1352
+ from pulse.queries.client import queries as queries
1353
+ from pulse.queries.common import ActionError as ActionError
1354
+ from pulse.queries.common import ActionResult as ActionResult
1355
+ from pulse.queries.common import ActionSuccess as ActionSuccess
1356
+ from pulse.queries.common import QueryKey as QueryKey
1357
+ from pulse.queries.common import QueryStatus as QueryStatus
1358
+ from pulse.queries.infinite_query import infinite_query as infinite_query
1359
+ from pulse.queries.mutation import mutation as mutation
1360
+ from pulse.queries.query import QueryResult as QueryResult
1361
+ from pulse.queries.query import query as query
1348
1362
 
1349
1363
  # React component registry
1350
1364
  from pulse.react_component import (
pulse/context.py CHANGED
@@ -2,7 +2,7 @@
2
2
  from contextvars import ContextVar, Token
3
3
  from dataclasses import dataclass
4
4
  from types import TracebackType
5
- from typing import TYPE_CHECKING
5
+ from typing import TYPE_CHECKING, Literal
6
6
 
7
7
  from pulse.routing import RouteContext
8
8
 
@@ -58,10 +58,11 @@ class PulseContext:
58
58
  exc_type: type[BaseException] | None = None,
59
59
  exc_val: BaseException | None = None,
60
60
  exc_tb: TracebackType | None = None,
61
- ):
61
+ ) -> Literal[False]:
62
62
  if self._token is not None:
63
63
  PULSE_CONTEXT.reset(self._token)
64
64
  self._token = None
65
+ return False
65
66
 
66
67
 
67
68
  PULSE_CONTEXT: ContextVar["PulseContext | None"] = ContextVar(
pulse/decorators.py CHANGED
@@ -2,16 +2,8 @@
2
2
 
3
3
  import inspect
4
4
  from collections.abc import Awaitable, Callable
5
- from typing import Any, Concatenate, ParamSpec, Protocol, TypeVar, overload
5
+ from typing import Any, ParamSpec, Protocol, TypeVar, overload
6
6
 
7
- from pulse.helpers import MISSING
8
- from pulse.queries.common import OnErrorFn, OnSuccessFn
9
- from pulse.queries.mutation import MutationProperty
10
- from pulse.queries.query import RETRY_DELAY_DEFAULT
11
- from pulse.queries.query_observer import (
12
- QueryProperty,
13
- QueryPropertyWithInitial,
14
- )
15
7
  from pulse.reactive import (
16
8
  AsyncEffect,
17
9
  AsyncEffectFn,
@@ -89,6 +81,7 @@ def effect(
89
81
  lazy: bool = False,
90
82
  on_error: Callable[[Exception], None] | None = None,
91
83
  deps: list[Signal[Any] | Computed[Any]] | None = None,
84
+ interval: float | None = None,
92
85
  ) -> Effect: ...
93
86
 
94
87
 
@@ -101,6 +94,7 @@ def effect(
101
94
  lazy: bool = False,
102
95
  on_error: Callable[[Exception], None] | None = None,
103
96
  deps: list[Signal[Any] | Computed[Any]] | None = None,
97
+ interval: float | None = None,
104
98
  ) -> AsyncEffect: ...
105
99
  # In practice this overload returns a StateEffect, but it gets converted into an
106
100
  # Effect at state instantiation.
@@ -117,6 +111,7 @@ def effect(
117
111
  lazy: bool = False,
118
112
  on_error: Callable[[Exception], None] | None = None,
119
113
  deps: list[Signal[Any] | Computed[Any]] | None = None,
114
+ interval: float | None = None,
120
115
  ) -> EffectBuilder: ...
121
116
 
122
117
 
@@ -128,6 +123,7 @@ def effect(
128
123
  lazy: bool = False,
129
124
  on_error: Callable[[Exception], None] | None = None,
130
125
  deps: list[Signal[Any] | Computed[Any]] | None = None,
126
+ interval: float | None = None,
131
127
  ):
132
128
  # The type checker is not happy if I don't specify the `/` here.
133
129
  def decorator(func: Callable[..., Any], /):
@@ -146,6 +142,7 @@ def effect(
146
142
  lazy=lazy,
147
143
  on_error=on_error,
148
144
  deps=deps,
145
+ interval=interval,
149
146
  )
150
147
 
151
148
  if len(params) > 0:
@@ -161,6 +158,7 @@ def effect(
161
158
  lazy=lazy,
162
159
  on_error=on_error,
163
160
  deps=deps,
161
+ interval=interval,
164
162
  )
165
163
  return Effect(
166
164
  func, # type: ignore[arg-type]
@@ -169,169 +167,7 @@ def effect(
169
167
  lazy=lazy,
170
168
  on_error=on_error,
171
169
  deps=deps,
172
- )
173
-
174
- if fn:
175
- return decorator(fn)
176
- return decorator
177
-
178
-
179
- # -----------------
180
- # Query decorator
181
- # -----------------
182
-
183
-
184
- # With initial (narrowed return type) - more specific overloads first
185
- @overload
186
- def query(
187
- fn: Callable[[TState], Awaitable[T]],
188
- *,
189
- key: Callable[[TState], tuple[Any, ...]] | None = None,
190
- stale_time: float = 0.0,
191
- gc_time: float | None = 300.0,
192
- keep_previous_data: bool = False,
193
- retries: int = 3,
194
- retry_delay: float | None = None,
195
- initial: T | Callable[[TState], T],
196
- on_success: OnSuccessFn[TState, T] | None = None,
197
- on_error: OnErrorFn[TState] | None = None,
198
- ) -> QueryPropertyWithInitial[T, TState]: ...
199
-
200
-
201
- @overload
202
- def query(
203
- fn: None = None,
204
- *,
205
- key: Callable[[TState], tuple[Any, ...]] | None = None,
206
- stale_time: float = 0.0,
207
- gc_time: float | None = 300.0,
208
- keep_previous_data: bool = False,
209
- retries: int = 3,
210
- retry_delay: float | None = None,
211
- initial: T | Callable[[TState], T],
212
- on_success: OnSuccessFn[TState, T] | None = None,
213
- on_error: OnErrorFn[TState] | None = None,
214
- ) -> Callable[
215
- [Callable[[TState], Awaitable[T]]], QueryPropertyWithInitial[T, TState]
216
- ]: ...
217
-
218
-
219
- @overload
220
- def query(
221
- fn: Callable[[TState], Awaitable[T]],
222
- *,
223
- key: Callable[[TState], tuple[Any, ...]] | None = None,
224
- stale_time: float = 0.0,
225
- gc_time: float | None = 300.0,
226
- keep_previous_data: bool = False,
227
- retries: int = 3,
228
- retry_delay: float | None = None,
229
- initial: T | Callable[[TState], T] | None = ...,
230
- on_success: OnSuccessFn[TState, T] | None = None,
231
- on_error: OnErrorFn[TState] | None = None,
232
- ) -> QueryProperty[T, TState]: ...
233
-
234
-
235
- @overload
236
- def query(
237
- fn: None = None,
238
- *,
239
- key: Callable[[TState], tuple[Any, ...]] | None = None,
240
- stale_time: float = 0.0,
241
- gc_time: float | None = 300.0,
242
- keep_previous_data: bool = False,
243
- retries: int = 3,
244
- retry_delay: float | None = None,
245
- initial: T | Callable[[TState], T] | None = ...,
246
- on_success: OnSuccessFn[TState, T] | None = None,
247
- on_error: OnErrorFn[TState] | None = None,
248
- ) -> Callable[[Callable[[TState], Awaitable[T]]], QueryProperty[T, TState]]: ...
249
-
250
-
251
- def query(
252
- fn: Callable[[TState], Awaitable[T]] | None = None,
253
- *,
254
- key: Callable[[TState], tuple[Any, ...]] | None = None,
255
- stale_time: float = 0.0,
256
- gc_time: float | None = 300.0,
257
- keep_previous_data: bool = False,
258
- retries: int = 3,
259
- retry_delay: float | None = None,
260
- initial: T | Callable[[TState], T] | None = MISSING,
261
- on_success: OnSuccessFn[TState, T] | None = None,
262
- on_error: OnErrorFn[TState] | None = None,
263
- ):
264
- def decorator(
265
- func: Callable[[TState], Awaitable[T]], /
266
- ) -> QueryProperty[T, TState] | QueryPropertyWithInitial[T, TState]:
267
- sig = inspect.signature(func)
268
- params = list(sig.parameters.values())
269
- # Only state-method form supported for now (single 'self')
270
- if not (len(params) == 1 and params[0].name == "self"):
271
- raise TypeError("@query currently only supports state methods (self)")
272
-
273
- prop_cls = QueryPropertyWithInitial if initial is not None else QueryProperty
274
-
275
- return prop_cls(
276
- func.__name__,
277
- func,
278
- key=key,
279
- stale_time=stale_time,
280
- gc_time=gc_time if gc_time is not None else 300.0,
281
- keep_previous_data=keep_previous_data,
282
- retries=retries,
283
- retry_delay=RETRY_DELAY_DEFAULT if retry_delay is None else retry_delay,
284
- initial=initial,
285
- on_success=on_success,
286
- on_error=on_error,
287
- )
288
-
289
- if fn:
290
- return decorator(fn)
291
- return decorator
292
-
293
-
294
- # -----------------
295
- # Mutation decorator
296
- # -----------------
297
- @overload
298
- def mutation(
299
- fn: Callable[Concatenate[TState, P], Awaitable[T]],
300
- *,
301
- on_success: OnSuccessFn[TState, T] | None = None,
302
- on_error: OnErrorFn[TState] | None = None,
303
- ) -> MutationProperty[T, TState, P]: ...
304
-
305
-
306
- @overload
307
- def mutation(
308
- fn: None = None,
309
- *,
310
- on_success: OnSuccessFn[TState, T] | None = None,
311
- on_error: OnErrorFn[TState] | None = None,
312
- ) -> Callable[
313
- [Callable[Concatenate[TState, P], Awaitable[T]]], MutationProperty[T, TState, P]
314
- ]: ...
315
-
316
-
317
- def mutation(
318
- fn: Callable[Concatenate[TState, P], Awaitable[T]] | None = None,
319
- *,
320
- on_success: OnSuccessFn[TState, T] | None = None,
321
- on_error: OnErrorFn[TState] | None = None,
322
- ):
323
- def decorator(func: Callable[Concatenate[TState, P], Awaitable[T]], /):
324
- sig = inspect.signature(func)
325
- params = list(sig.parameters.values())
326
-
327
- if len(params) == 0 or params[0].name != "self":
328
- raise TypeError("@mutation method must have 'self' as first argument")
329
-
330
- return MutationProperty(
331
- func.__name__,
332
- func,
333
- on_success=on_success,
334
- on_error=on_error,
170
+ interval=interval,
335
171
  )
336
172
 
337
173
  if fn:
pulse/helpers.py CHANGED
@@ -212,31 +212,39 @@ def later(
212
212
  """
213
213
  Schedule `fn(*args, **kwargs)` to run after `delay` seconds.
214
214
  Works with sync or async functions. Returns a TimerHandle; call .cancel() to cancel.
215
+
216
+ The callback runs with no reactive scope to avoid accidentally capturing
217
+ reactive dependencies from the calling context. Other context vars (like
218
+ PulseContext) are preserved normally.
215
219
  """
220
+
221
+ from pulse.reactive import Untrack
222
+
216
223
  loop = asyncio.get_running_loop()
217
224
 
218
225
  def _run():
219
226
  try:
220
- res = fn(*args, **kwargs)
221
- if asyncio.iscoroutine(res):
222
- task = loop.create_task(res)
223
-
224
- def _log_task_exception(t: asyncio.Task[Any]):
225
- try:
226
- t.result()
227
- except asyncio.CancelledError:
228
- # Normal cancellation path
229
- pass
230
- except Exception as exc:
231
- loop.call_exception_handler(
232
- {
233
- "message": "Unhandled exception in later() task",
234
- "exception": exc,
235
- "context": {"callback": fn},
236
- }
237
- )
238
-
239
- task.add_done_callback(_log_task_exception)
227
+ with Untrack():
228
+ res = fn(*args, **kwargs)
229
+ if asyncio.iscoroutine(res):
230
+ task = loop.create_task(res)
231
+
232
+ def _log_task_exception(t: asyncio.Task[Any]):
233
+ try:
234
+ t.result()
235
+ except asyncio.CancelledError:
236
+ # Normal cancellation path
237
+ pass
238
+ except Exception as exc:
239
+ loop.call_exception_handler(
240
+ {
241
+ "message": "Unhandled exception in later() task",
242
+ "exception": exc,
243
+ "context": {"callback": fn},
244
+ }
245
+ )
246
+
247
+ task.add_done_callback(_log_task_exception)
240
248
  except Exception as exc:
241
249
  # Surface exceptions via the loop's exception handler and continue
242
250
  loop.call_exception_handler(
@@ -273,9 +281,16 @@ def repeat(interval: float, fn: Callable[P, Any], *args: P.args, **kwargs: P.kwa
273
281
  For async functions, waits for completion before starting the next delay.
274
282
  Returns a handle with .cancel() to stop future runs.
275
283
 
284
+ The callback runs with no reactive scope to avoid accidentally capturing
285
+ reactive dependencies from the calling context. Other context vars (like
286
+ PulseContext) are preserved normally.
287
+
276
288
  Optional kwargs:
277
289
  - immediate: bool = False # run once immediately before the first interval
278
290
  """
291
+
292
+ from pulse.reactive import Untrack
293
+
279
294
  loop = asyncio.get_running_loop()
280
295
  handle = RepeatHandle()
281
296
 
@@ -288,9 +303,10 @@ def repeat(interval: float, fn: Callable[P, Any], *args: P.args, **kwargs: P.kwa
288
303
  if handle.cancelled:
289
304
  break
290
305
  try:
291
- result = fn(*args, **kwargs)
292
- if asyncio.iscoroutine(result):
293
- await result
306
+ with Untrack():
307
+ result = fn(*args, **kwargs)
308
+ if asyncio.iscoroutine(result):
309
+ await result
294
310
  except asyncio.CancelledError:
295
311
  # Propagate to outer handler to finish cleanly
296
312
  raise
pulse/hooks/core.py CHANGED
@@ -3,7 +3,7 @@ import logging
3
3
  from collections.abc import Callable, Mapping
4
4
  from contextvars import ContextVar, Token
5
5
  from dataclasses import dataclass
6
- from typing import Any, Generic, TypeVar, override
6
+ from typing import Any, Generic, Literal, TypeVar, override
7
7
 
8
8
  from pulse.helpers import Disposable, call_flexible
9
9
 
@@ -40,10 +40,7 @@ class HookMetadata:
40
40
  class HookState(Disposable):
41
41
  """Base class returned by hook factories."""
42
42
 
43
- render_cycle: int
44
-
45
- def __init__(self) -> None:
46
- self.render_cycle = 0
43
+ render_cycle: int = 0
47
44
 
48
45
  def on_render_start(self, render_cycle: int) -> None:
49
46
  self.render_cycle = render_cycle
@@ -177,13 +174,14 @@ class HookContext:
177
174
  exc_type: type[BaseException] | None,
178
175
  exc_val: BaseException | None,
179
176
  exc_tb: Any,
180
- ):
177
+ ) -> Literal[False]:
181
178
  if self._token is not None:
182
179
  HOOK_CONTEXT.reset(self._token)
183
180
  self._token = None
184
181
 
185
182
  for namespace in self.namespaces.values():
186
183
  namespace.on_render_end(self.render_cycle)
184
+ return False
187
185
 
188
186
  def namespace_for(self, hook: Hook[T]) -> HookNamespace[T]:
189
187
  namespace = self.namespaces.get(hook.name)