pulse-framework 0.1.64__py3-none-any.whl → 0.1.65__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/render_session.py CHANGED
@@ -3,11 +3,10 @@ import logging
3
3
  import traceback
4
4
  import uuid
5
5
  from asyncio import iscoroutine
6
- from collections.abc import Callable
6
+ from collections.abc import Awaitable, Callable
7
7
  from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
8
8
 
9
9
  from pulse.context import PulseContext
10
- from pulse.helpers import create_future_on_loop, create_task, later
11
10
  from pulse.hooks.runtime import NotFoundInterrupt, RedirectInterrupt
12
11
  from pulse.messages import (
13
12
  ServerApiCallMessage,
@@ -29,13 +28,19 @@ from pulse.routing import (
29
28
  RouteTree,
30
29
  ensure_absolute_path,
31
30
  )
31
+ from pulse.scheduling import (
32
+ TaskRegistry,
33
+ TimerHandleLike,
34
+ TimerRegistry,
35
+ create_future,
36
+ )
32
37
  from pulse.state import State
33
38
  from pulse.transpiler.id import next_id
34
39
  from pulse.transpiler.nodes import Expr
35
40
 
36
41
  if TYPE_CHECKING:
37
42
  from pulse.channel import ChannelsManager
38
- from pulse.form import FormRegistry
43
+ from pulse.forms import FormRegistry
39
44
 
40
45
  logger = logging.getLogger(__file__)
41
46
 
@@ -93,7 +98,7 @@ class RouteMount:
93
98
  state: MountState
94
99
  pending_action: PendingAction | None
95
100
  queue: list[ServerMessage] | None
96
- queue_timeout: asyncio.TimerHandle | None
101
+ queue_timeout: TimerHandleLike | None
97
102
  render_batch_id: int
98
103
  render_batch_renders: int
99
104
 
@@ -124,6 +129,7 @@ class RouteMount:
124
129
  def _cancel_pending_timeout(self) -> None:
125
130
  if self.queue_timeout is not None:
126
131
  self.queue_timeout.cancel()
132
+ self.render.discard_timer(self.queue_timeout)
127
133
  self.queue_timeout = None
128
134
  self.pending_action = None
129
135
 
@@ -145,7 +151,9 @@ class RouteMount:
145
151
  )
146
152
  self._cancel_pending_timeout()
147
153
  self.pending_action = next_action
148
- self.queue_timeout = later(timeout, self._on_pending_timeout)
154
+ self.queue_timeout = self.render.schedule_later(
155
+ timeout, self._on_pending_timeout
156
+ )
149
157
  return
150
158
  self._cancel_pending_timeout()
151
159
  if self.state == "idle" and self.effect:
@@ -153,7 +161,9 @@ class RouteMount:
153
161
  self.state = "pending"
154
162
  self.queue = []
155
163
  self.pending_action = action
156
- self.queue_timeout = later(timeout, self._on_pending_timeout)
164
+ self.queue_timeout = self.render.schedule_later(
165
+ timeout, self._on_pending_timeout
166
+ )
157
167
 
158
168
  def activate(self, send_message: Callable[[ServerMessage], Any]) -> None:
159
169
  if self.state != "pending":
@@ -248,6 +258,8 @@ class RenderSession:
248
258
  _pending_api: dict[str, asyncio.Future[dict[str, Any]]]
249
259
  _pending_js_results: dict[str, asyncio.Future[Any]]
250
260
  _global_states: dict[str, State]
261
+ _tasks: TaskRegistry
262
+ _timers: TimerRegistry
251
263
 
252
264
  def __init__(
253
265
  self,
@@ -262,7 +274,7 @@ class RenderSession:
262
274
  render_loop_limit: int = 50,
263
275
  ) -> None:
264
276
  from pulse.channel import ChannelsManager
265
- from pulse.form import FormRegistry
277
+ from pulse.forms import FormRegistry
266
278
 
267
279
  self.id = id
268
280
  self.routes = routes
@@ -277,6 +289,8 @@ class RenderSession:
277
289
  self.forms = FormRegistry(self)
278
290
  self._pending_api = {}
279
291
  self._pending_js_results = {}
292
+ self._tasks = TaskRegistry(name=f"render:{id}")
293
+ self._timers = TimerRegistry(tasks=self._tasks, name=f"render:{id}")
280
294
  self.prerender_queue_timeout = prerender_queue_timeout
281
295
  self.detach_queue_timeout = detach_queue_timeout
282
296
  self.disconnect_queue_timeout = disconnect_queue_timeout
@@ -427,6 +441,7 @@ class RenderSession:
427
441
 
428
442
  if mount is None or mount.state == "idle":
429
443
  # Initial render must come from prerender
444
+ print(f"[DEBUG] Missing or idle route '{path}', reloading")
430
445
  self.send({"type": "reload"})
431
446
  return
432
447
 
@@ -546,9 +561,12 @@ class RenderSession:
546
561
 
547
562
  def close(self):
548
563
  self.forms.dispose()
564
+ self._tasks.cancel_all()
565
+ self._timers.cancel_all()
549
566
  for path in list(self.route_mounts.keys()):
550
567
  self.detach(path, timeout=0)
551
568
  self.route_mounts.clear()
569
+ self.query_store.dispose_all()
552
570
  for value in self._global_states.values():
553
571
  value.dispose()
554
572
  self._global_states.clear()
@@ -587,6 +605,28 @@ class RenderSession:
587
605
  with PulseContext.update(render=self):
588
606
  flush_effects()
589
607
 
608
+ def create_task(
609
+ self,
610
+ coroutine: Callable[[], Any] | Awaitable[Any],
611
+ *,
612
+ name: str | None = None,
613
+ on_done: Callable[[asyncio.Task[Any]], None] | None = None,
614
+ ) -> asyncio.Task[Any]:
615
+ """Create a tracked task tied to this render session."""
616
+ if callable(coroutine):
617
+ return self._tasks.create_task(coroutine(), name=name, on_done=on_done)
618
+ return self._tasks.create_task(coroutine, name=name, on_done=on_done)
619
+
620
+ def schedule_later(
621
+ self, delay: float, fn: Callable[..., Any], *args: Any, **kwargs: Any
622
+ ) -> TimerHandleLike:
623
+ """Schedule a tracked timer tied to this render session."""
624
+ return self._timers.later(delay, fn, *args, **kwargs)
625
+
626
+ def discard_timer(self, handle: TimerHandleLike | None) -> None:
627
+ """Remove a timer handle from the session registry."""
628
+ self._timers.discard(handle)
629
+
590
630
  def execute_callback(self, path: str, key: str, args: list[Any] | tuple[Any, ...]):
591
631
  mount = self.route_mounts[path]
592
632
  cb = mount.tree.callbacks[key]
@@ -598,9 +638,18 @@ class RenderSession:
598
638
  with PulseContext.update(render=self, route=mount.route):
599
639
  res = cb.fn(*args[: cb.n_args])
600
640
  if iscoroutine(res):
601
- create_task(
602
- res, on_done=lambda t: (e := t.exception()) and report(e, True)
603
- )
641
+
642
+ def _on_done(t: asyncio.Task[Any]) -> None:
643
+ if t.cancelled():
644
+ return
645
+ try:
646
+ exc = t.exception()
647
+ except asyncio.CancelledError:
648
+ return
649
+ if exc:
650
+ report(exc, True)
651
+
652
+ self.create_task(res, name=f"callback:{key}", on_done=_on_done)
604
653
  except Exception as e:
605
654
  report(e)
606
655
 
@@ -628,7 +677,7 @@ class RenderSession:
628
677
  api_path = url_or_path if url_or_path.startswith("/") else "/" + url_or_path
629
678
  url = f"{base}{api_path}"
630
679
  corr_id = uuid.uuid4().hex
631
- fut = create_future_on_loop()
680
+ fut = create_future()
632
681
  self._pending_api[corr_id] = fut
633
682
  headers = headers or {}
634
683
  headers["x-pulse-render-id"] = self.id
@@ -746,7 +795,7 @@ class RenderSession:
746
795
  if not future.done():
747
796
  future.set_exception(asyncio.TimeoutError())
748
797
 
749
- loop.call_later(timeout, _on_timeout)
798
+ self._timers.later(timeout, _on_timeout)
750
799
 
751
800
  return future
752
801
 
pulse/scheduling.py ADDED
@@ -0,0 +1,448 @@
1
+ import asyncio
2
+ import os
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import Any, ParamSpec, Protocol, TypeVar, override
5
+
6
+ from anyio import from_thread
7
+
8
+ T = TypeVar("T")
9
+ P = ParamSpec("P")
10
+
11
+
12
+ class TimerHandleLike(Protocol):
13
+ def cancel(self) -> None: ...
14
+ def cancelled(self) -> bool: ...
15
+ def when(self) -> float: ...
16
+
17
+
18
+ def is_pytest() -> bool:
19
+ """Detect if running inside pytest using environment variables."""
20
+ return bool(os.environ.get("PYTEST_CURRENT_TEST")) or (
21
+ "PYTEST_XDIST_TESTRUNUID" in os.environ
22
+ )
23
+
24
+
25
+ def _resolve_registries() -> tuple["TaskRegistry", "TimerRegistry"]:
26
+ from pulse.context import PulseContext
27
+
28
+ ctx = PulseContext.get()
29
+ if ctx.render is not None:
30
+ return ctx.render._tasks, ctx.render._timers # pyright: ignore[reportPrivateUsage]
31
+ return ctx.app._tasks, ctx.app._timers # pyright: ignore[reportPrivateUsage]
32
+
33
+
34
+ def call_soon(
35
+ fn: Callable[P, Any], *args: P.args, **kwargs: P.kwargs
36
+ ) -> TimerHandleLike | None:
37
+ """Schedule a callback to run ASAP on the main event loop from any thread."""
38
+ _, timer_registry = _resolve_registries()
39
+ return timer_registry.call_soon(fn, *args, **kwargs)
40
+
41
+
42
+ def create_task(
43
+ coroutine: Awaitable[T],
44
+ *,
45
+ name: str | None = None,
46
+ on_done: Callable[[asyncio.Task[T]], None] | None = None,
47
+ ) -> asyncio.Task[T]:
48
+ """Create a tracked task on the active session/app registry."""
49
+ task_registry, _ = _resolve_registries()
50
+ return task_registry.create_task(coroutine, name=name, on_done=on_done)
51
+
52
+
53
+ def create_future() -> asyncio.Future[Any]:
54
+ """Create an asyncio Future on the main event loop from any thread."""
55
+ task_registry, _ = _resolve_registries()
56
+ return task_registry.create_future()
57
+
58
+
59
+ def later(
60
+ delay: float, fn: Callable[P, Any], *args: P.args, **kwargs: P.kwargs
61
+ ) -> TimerHandleLike:
62
+ """
63
+ Schedule `fn(*args, **kwargs)` to run after `delay` seconds.
64
+ Works with sync or async functions. Returns a handle; call .cancel() to cancel.
65
+
66
+ The callback runs with no reactive scope to avoid accidentally capturing
67
+ reactive dependencies from the calling context. Other context vars (like
68
+ PulseContext) are preserved normally.
69
+ """
70
+
71
+ _, timer_registry = _resolve_registries()
72
+ return timer_registry.later(delay, fn, *args, **kwargs)
73
+
74
+
75
+ class RepeatHandle:
76
+ task: asyncio.Task[None] | None
77
+ cancelled: bool
78
+
79
+ def __init__(self) -> None:
80
+ self.task = None
81
+ self.cancelled = False
82
+
83
+ def cancel(self):
84
+ if self.cancelled:
85
+ return
86
+ self.cancelled = True
87
+ if self.task is not None and not self.task.done():
88
+ self.task.cancel()
89
+
90
+
91
+ def repeat(interval: float, fn: Callable[P, Any], *args: P.args, **kwargs: P.kwargs):
92
+ """
93
+ Repeatedly run `fn(*args, **kwargs)` every `interval` seconds.
94
+ Works with sync or async functions.
95
+ For async functions, waits for completion before starting the next delay.
96
+ Returns a handle with .cancel() to stop future runs.
97
+
98
+ The callback runs with no reactive scope to avoid accidentally capturing
99
+ reactive dependencies from the calling context. Other context vars (like
100
+ PulseContext) are preserved normally.
101
+
102
+ Optional kwargs:
103
+ - immediate: bool = False # run once immediately before the first interval
104
+ """
105
+
106
+ _, timer_registry = _resolve_registries()
107
+ return timer_registry.repeat(interval, fn, *args, **kwargs)
108
+
109
+
110
+ class TaskRegistry:
111
+ _tasks: set[asyncio.Task[Any]]
112
+ name: str | None
113
+
114
+ def __init__(self, name: str | None = None) -> None:
115
+ self._tasks = set()
116
+ self.name = name
117
+
118
+ def track(self, task: asyncio.Task[T]) -> asyncio.Task[T]:
119
+ self._tasks.add(task)
120
+ task.add_done_callback(self._tasks.discard)
121
+ return task
122
+
123
+ def create_task(
124
+ self,
125
+ coroutine: Awaitable[T],
126
+ *,
127
+ name: str | None = None,
128
+ on_done: Callable[[asyncio.Task[T]], None] | None = None,
129
+ ) -> asyncio.Task[T]:
130
+ """Create and schedule a coroutine task on the main loop from any thread."""
131
+ try:
132
+ asyncio.get_running_loop()
133
+ task = asyncio.ensure_future(coroutine)
134
+ if name is not None:
135
+ task.set_name(name)
136
+ if on_done:
137
+ task.add_done_callback(on_done)
138
+ except RuntimeError:
139
+
140
+ async def _runner():
141
+ asyncio.get_running_loop()
142
+ task = asyncio.ensure_future(coroutine)
143
+ if name is not None:
144
+ task.set_name(name)
145
+ if on_done:
146
+ task.add_done_callback(on_done)
147
+ return task
148
+
149
+ task = from_thread.run(_runner)
150
+
151
+ return self.track(task)
152
+
153
+ def create_future(self) -> asyncio.Future[Any]:
154
+ """Create an asyncio Future on the main event loop from any thread."""
155
+ try:
156
+ return asyncio.get_running_loop().create_future()
157
+ except RuntimeError:
158
+
159
+ async def _create():
160
+ return asyncio.get_running_loop().create_future()
161
+
162
+ return from_thread.run(_create)
163
+
164
+ def cancel_all(self) -> None:
165
+ for task in list(self._tasks):
166
+ if not task.done():
167
+ task.cancel()
168
+ self._tasks.clear()
169
+
170
+
171
+ class TimerRegistry:
172
+ _handles: set[TimerHandleLike]
173
+ _tasks: TaskRegistry
174
+ name: str | None
175
+
176
+ def __init__(self, *, tasks: TaskRegistry, name: str | None = None) -> None:
177
+ self._handles = set()
178
+ self._tasks = tasks
179
+ self.name = name
180
+
181
+ def track(self, handle: TimerHandleLike) -> TimerHandleLike:
182
+ self._handles.add(handle)
183
+ return handle
184
+
185
+ def discard(self, handle: TimerHandleLike | None) -> None:
186
+ if handle is None:
187
+ return
188
+ self._handles.discard(handle)
189
+
190
+ def later(
191
+ self,
192
+ delay: float,
193
+ fn: Callable[P, Any],
194
+ *args: P.args,
195
+ **kwargs: P.kwargs,
196
+ ) -> TimerHandleLike:
197
+ return self._schedule(delay, fn, args, dict(kwargs), untrack=True)
198
+
199
+ def call_soon(
200
+ self, fn: Callable[P, Any], *args: P.args, **kwargs: P.kwargs
201
+ ) -> TimerHandleLike | None:
202
+ def _schedule():
203
+ return self._schedule_soon(fn, args, dict(kwargs))
204
+
205
+ try:
206
+ asyncio.get_running_loop()
207
+ return _schedule()
208
+ except RuntimeError:
209
+
210
+ async def _runner():
211
+ return _schedule()
212
+
213
+ try:
214
+ return from_thread.run(_runner)
215
+ except RuntimeError:
216
+ if not is_pytest():
217
+ raise
218
+ return None
219
+
220
+ def repeat(
221
+ self, interval: float, fn: Callable[P, Any], *args: P.args, **kwargs: P.kwargs
222
+ ) -> RepeatHandle:
223
+ from pulse.reactive import Untrack
224
+
225
+ loop = asyncio.get_running_loop()
226
+ handle = RepeatHandle()
227
+
228
+ async def _runner():
229
+ nonlocal handle
230
+ try:
231
+ while not handle.cancelled:
232
+ # Start counting the next interval AFTER the previous execution completes
233
+ await asyncio.sleep(interval)
234
+ if handle.cancelled:
235
+ break
236
+ try:
237
+ with Untrack():
238
+ result = fn(*args, **kwargs)
239
+ if asyncio.iscoroutine(result):
240
+ await result
241
+ except asyncio.CancelledError:
242
+ # Propagate to outer handler to finish cleanly
243
+ raise
244
+ except Exception as exc:
245
+ # Surface exceptions via the loop's exception handler and continue
246
+ loop.call_exception_handler(
247
+ {
248
+ "message": "Unhandled exception in repeat() callback",
249
+ "exception": exc,
250
+ "context": {"callback": fn},
251
+ }
252
+ )
253
+ except asyncio.CancelledError:
254
+ # Swallow task cancellation to avoid noisy "exception was never retrieved"
255
+ pass
256
+
257
+ handle.task = self._tasks.create_task(_runner())
258
+ return handle
259
+
260
+ def cancel_all(self) -> None:
261
+ for handle in list(self._handles):
262
+ handle.cancel()
263
+ self._handles.clear()
264
+
265
+ def _schedule(
266
+ self,
267
+ delay: float,
268
+ fn: Callable[..., Any],
269
+ args: tuple[Any, ...],
270
+ kwargs: dict[str, Any],
271
+ *,
272
+ untrack: bool,
273
+ ) -> TimerHandleLike:
274
+ """
275
+ Schedule `fn(*args, **kwargs)` to run after `delay` seconds.
276
+ Works with sync or async functions. Returns a TimerHandle; call .cancel() to cancel.
277
+
278
+ The callback can run without a reactive scope to avoid accidentally capturing
279
+ reactive dependencies from the calling context. Other context vars (like
280
+ PulseContext) are preserved normally.
281
+ """
282
+ try:
283
+ loop = asyncio.get_running_loop()
284
+ except RuntimeError:
285
+ try:
286
+ loop = asyncio.get_event_loop()
287
+ except RuntimeError as exc:
288
+ raise RuntimeError("later() requires an event loop") from exc
289
+
290
+ tracked_box: list[TimerHandleLike] = []
291
+ _run = self._prepare_run(loop, tracked_box, fn, args, kwargs, untrack=untrack)
292
+
293
+ handle = loop.call_later(delay, _run)
294
+ tracked = _TrackedTimerHandle(handle, self)
295
+ tracked_box.append(tracked)
296
+ self._handles.add(tracked)
297
+ return tracked
298
+
299
+ def _schedule_soon(
300
+ self,
301
+ fn: Callable[..., Any],
302
+ args: tuple[Any, ...],
303
+ kwargs: dict[str, Any],
304
+ ) -> TimerHandleLike:
305
+ try:
306
+ loop = asyncio.get_running_loop()
307
+ except RuntimeError:
308
+ try:
309
+ loop = asyncio.get_event_loop()
310
+ except RuntimeError as exc:
311
+ raise RuntimeError("call_soon() requires an event loop") from exc
312
+
313
+ tracked_box: list[TimerHandleLike] = []
314
+ _run = self._prepare_run(loop, tracked_box, fn, args, kwargs, untrack=False)
315
+
316
+ handle = loop.call_soon(_run)
317
+ tracked = _TrackedHandle(handle, self, when=loop.time())
318
+ tracked_box.append(tracked)
319
+ self._handles.add(tracked)
320
+ return tracked
321
+
322
+ def _prepare_run(
323
+ self,
324
+ loop: asyncio.AbstractEventLoop,
325
+ tracked_box: list[TimerHandleLike],
326
+ fn: Callable[..., Any],
327
+ args: tuple[Any, ...],
328
+ kwargs: dict[str, Any],
329
+ *,
330
+ untrack: bool,
331
+ ) -> Callable[[], None]:
332
+ def _run():
333
+ from pulse.reactive import Untrack
334
+
335
+ try:
336
+ if untrack:
337
+ with Untrack():
338
+ res = fn(*args, **kwargs)
339
+ else:
340
+ res = fn(*args, **kwargs)
341
+ if asyncio.iscoroutine(res):
342
+ task = self._tasks.create_task(res)
343
+
344
+ def _log_task_exception(t: asyncio.Task[Any]):
345
+ try:
346
+ t.result()
347
+ except asyncio.CancelledError:
348
+ # Normal cancellation path
349
+ pass
350
+ except Exception as exc:
351
+ loop.call_exception_handler(
352
+ {
353
+ "message": "Unhandled exception in later() task",
354
+ "exception": exc,
355
+ "context": {"callback": fn},
356
+ }
357
+ )
358
+
359
+ task.add_done_callback(_log_task_exception)
360
+ except Exception as exc:
361
+ # Surface exceptions via the loop's exception handler and continue
362
+ loop.call_exception_handler(
363
+ {
364
+ "message": "Unhandled exception in later() callback",
365
+ "exception": exc,
366
+ "context": {"callback": fn},
367
+ }
368
+ )
369
+ finally:
370
+ self.discard(tracked_box[0] if tracked_box else None)
371
+
372
+ return _run
373
+
374
+
375
+ class _TrackedTimerHandle:
376
+ __slots__: tuple[str, ...] = ("_handle", "_registry")
377
+ _handle: asyncio.TimerHandle
378
+ _registry: "TimerRegistry"
379
+
380
+ def __init__(self, handle: asyncio.TimerHandle, registry: "TimerRegistry") -> None:
381
+ self._handle = handle
382
+ self._registry = registry
383
+
384
+ def cancel(self) -> None:
385
+ if not self._handle.cancelled():
386
+ self._handle.cancel()
387
+ self._registry.discard(self)
388
+
389
+ def cancelled(self) -> bool:
390
+ return self._handle.cancelled()
391
+
392
+ def when(self) -> float:
393
+ return self._handle.when()
394
+
395
+ def __getattr__(self, name: str):
396
+ return getattr(self._handle, name)
397
+
398
+ @override
399
+ def __hash__(self) -> int:
400
+ return hash(self._handle)
401
+
402
+ @override
403
+ def __eq__(self, other: object) -> bool:
404
+ if isinstance(other, _TrackedTimerHandle):
405
+ return self._handle is other._handle
406
+ return self._handle is other
407
+
408
+
409
+ class _TrackedHandle:
410
+ __slots__: tuple[str, ...] = ("_handle", "_registry", "_when")
411
+ _handle: asyncio.Handle
412
+ _registry: "TimerRegistry"
413
+ _when: float
414
+
415
+ def __init__(
416
+ self,
417
+ handle: asyncio.Handle,
418
+ registry: "TimerRegistry",
419
+ *,
420
+ when: float,
421
+ ) -> None:
422
+ self._handle = handle
423
+ self._registry = registry
424
+ self._when = when
425
+
426
+ def cancel(self) -> None:
427
+ if not self._handle.cancelled():
428
+ self._handle.cancel()
429
+ self._registry.discard(self)
430
+
431
+ def cancelled(self) -> bool:
432
+ return self._handle.cancelled()
433
+
434
+ def when(self) -> float:
435
+ return self._when
436
+
437
+ def __getattr__(self, name: str):
438
+ return getattr(self._handle, name)
439
+
440
+ @override
441
+ def __hash__(self) -> int:
442
+ return hash(self._handle)
443
+
444
+ @override
445
+ def __eq__(self, other: object) -> bool:
446
+ if isinstance(other, _TrackedHandle):
447
+ return self._handle is other._handle
448
+ return self._handle is other
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.64
3
+ Version: 0.1.65
4
4
  Summary: Pulse - Full-stack framework for building real-time React applications in Python
5
5
  Requires-Dist: websockets>=12.0
6
6
  Requires-Dist: fastapi>=0.128.0