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/__init__.py +16 -10
- pulse/app.py +30 -11
- pulse/channel.py +3 -3
- pulse/{form.py → forms.py} +2 -2
- pulse/helpers.py +9 -212
- pulse/proxy.py +10 -3
- pulse/queries/client.py +5 -1
- pulse/queries/effect.py +2 -1
- pulse/queries/infinite_query.py +54 -17
- pulse/queries/query.py +58 -44
- pulse/queries/store.py +10 -2
- pulse/reactive.py +18 -7
- pulse/render_session.py +61 -12
- pulse/scheduling.py +448 -0
- {pulse_framework-0.1.64.dist-info → pulse_framework-0.1.65.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.64.dist-info → pulse_framework-0.1.65.dist-info}/RECORD +18 -17
- {pulse_framework-0.1.64.dist-info → pulse_framework-0.1.65.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.64.dist-info → pulse_framework-0.1.65.dist-info}/entry_points.txt +0 -0
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.
|
|
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:
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
602
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|