pulse-framework 0.1.64__py3-none-any.whl → 0.1.66a1__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 +103 -26
- pulse/channel.py +3 -3
- pulse/codegen/templates/layout.py +3 -8
- 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 +135 -12
- pulse/scheduling.py +448 -0
- {pulse_framework-0.1.64.dist-info → pulse_framework-0.1.66a1.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.64.dist-info → pulse_framework-0.1.66a1.dist-info}/RECORD +19 -18
- {pulse_framework-0.1.64.dist-info → pulse_framework-0.1.66a1.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.64.dist-info → pulse_framework-0.1.66a1.dist-info}/entry_points.txt +0 -0
pulse/render_session.py
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import logging
|
|
3
|
+
import os
|
|
3
4
|
import traceback
|
|
4
5
|
import uuid
|
|
5
6
|
from asyncio import iscoroutine
|
|
6
|
-
from collections.abc import Callable
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
7
8
|
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
|
|
8
9
|
|
|
9
10
|
from pulse.context import PulseContext
|
|
10
|
-
from pulse.helpers import create_future_on_loop, create_task, later
|
|
11
11
|
from pulse.hooks.runtime import NotFoundInterrupt, RedirectInterrupt
|
|
12
12
|
from pulse.messages import (
|
|
13
13
|
ServerApiCallMessage,
|
|
@@ -29,13 +29,19 @@ from pulse.routing import (
|
|
|
29
29
|
RouteTree,
|
|
30
30
|
ensure_absolute_path,
|
|
31
31
|
)
|
|
32
|
+
from pulse.scheduling import (
|
|
33
|
+
TaskRegistry,
|
|
34
|
+
TimerHandleLike,
|
|
35
|
+
TimerRegistry,
|
|
36
|
+
create_future,
|
|
37
|
+
)
|
|
32
38
|
from pulse.state import State
|
|
33
39
|
from pulse.transpiler.id import next_id
|
|
34
40
|
from pulse.transpiler.nodes import Expr
|
|
35
41
|
|
|
36
42
|
if TYPE_CHECKING:
|
|
37
43
|
from pulse.channel import ChannelsManager
|
|
38
|
-
from pulse.
|
|
44
|
+
from pulse.forms import FormRegistry
|
|
39
45
|
|
|
40
46
|
logger = logging.getLogger(__file__)
|
|
41
47
|
|
|
@@ -93,7 +99,7 @@ class RouteMount:
|
|
|
93
99
|
state: MountState
|
|
94
100
|
pending_action: PendingAction | None
|
|
95
101
|
queue: list[ServerMessage] | None
|
|
96
|
-
queue_timeout:
|
|
102
|
+
queue_timeout: TimerHandleLike | None
|
|
97
103
|
render_batch_id: int
|
|
98
104
|
render_batch_renders: int
|
|
99
105
|
|
|
@@ -124,6 +130,7 @@ class RouteMount:
|
|
|
124
130
|
def _cancel_pending_timeout(self) -> None:
|
|
125
131
|
if self.queue_timeout is not None:
|
|
126
132
|
self.queue_timeout.cancel()
|
|
133
|
+
self.render.discard_timer(self.queue_timeout)
|
|
127
134
|
self.queue_timeout = None
|
|
128
135
|
self.pending_action = None
|
|
129
136
|
|
|
@@ -131,6 +138,14 @@ class RouteMount:
|
|
|
131
138
|
if self.state != "pending":
|
|
132
139
|
return
|
|
133
140
|
action = self.pending_action
|
|
141
|
+
if os.environ.get("PULSE_DEBUG_RENDER"):
|
|
142
|
+
logger.info(
|
|
143
|
+
"[PulseDebug][mount-timeout] render=%s path=%s action=%s state=%s",
|
|
144
|
+
self.render.id,
|
|
145
|
+
self.path,
|
|
146
|
+
action,
|
|
147
|
+
self.state,
|
|
148
|
+
)
|
|
134
149
|
self.pending_action = None
|
|
135
150
|
if action == "dispose":
|
|
136
151
|
self.render.dispose_mount(self.path, self)
|
|
@@ -138,6 +153,15 @@ class RouteMount:
|
|
|
138
153
|
self.to_idle()
|
|
139
154
|
|
|
140
155
|
def start_pending(self, timeout: float, *, action: PendingAction = "idle") -> None:
|
|
156
|
+
if os.environ.get("PULSE_DEBUG_RENDER"):
|
|
157
|
+
logger.info(
|
|
158
|
+
"[PulseDebug][mount-pending] render=%s path=%s state=%s action=%s timeout=%s",
|
|
159
|
+
self.render.id,
|
|
160
|
+
self.path,
|
|
161
|
+
self.state,
|
|
162
|
+
action,
|
|
163
|
+
timeout,
|
|
164
|
+
)
|
|
141
165
|
if self.state == "pending":
|
|
142
166
|
prev_action = self.pending_action
|
|
143
167
|
next_action: PendingAction = (
|
|
@@ -145,7 +169,9 @@ class RouteMount:
|
|
|
145
169
|
)
|
|
146
170
|
self._cancel_pending_timeout()
|
|
147
171
|
self.pending_action = next_action
|
|
148
|
-
self.queue_timeout =
|
|
172
|
+
self.queue_timeout = self.render.schedule_later(
|
|
173
|
+
timeout, self._on_pending_timeout
|
|
174
|
+
)
|
|
149
175
|
return
|
|
150
176
|
self._cancel_pending_timeout()
|
|
151
177
|
if self.state == "idle" and self.effect:
|
|
@@ -153,9 +179,19 @@ class RouteMount:
|
|
|
153
179
|
self.state = "pending"
|
|
154
180
|
self.queue = []
|
|
155
181
|
self.pending_action = action
|
|
156
|
-
self.queue_timeout =
|
|
182
|
+
self.queue_timeout = self.render.schedule_later(
|
|
183
|
+
timeout, self._on_pending_timeout
|
|
184
|
+
)
|
|
157
185
|
|
|
158
186
|
def activate(self, send_message: Callable[[ServerMessage], Any]) -> None:
|
|
187
|
+
if os.environ.get("PULSE_DEBUG_RENDER"):
|
|
188
|
+
logger.info(
|
|
189
|
+
"[PulseDebug][mount-activate] render=%s path=%s state=%s queued=%s",
|
|
190
|
+
self.render.id,
|
|
191
|
+
self.path,
|
|
192
|
+
self.state,
|
|
193
|
+
0 if not self.queue else len(self.queue),
|
|
194
|
+
)
|
|
159
195
|
if self.state != "pending":
|
|
160
196
|
return
|
|
161
197
|
self._cancel_pending_timeout()
|
|
@@ -182,6 +218,12 @@ class RouteMount:
|
|
|
182
218
|
def to_idle(self) -> None:
|
|
183
219
|
if self.state != "pending":
|
|
184
220
|
return
|
|
221
|
+
if os.environ.get("PULSE_DEBUG_RENDER"):
|
|
222
|
+
logger.info(
|
|
223
|
+
"[PulseDebug][mount-idle] render=%s path=%s",
|
|
224
|
+
self.render.id,
|
|
225
|
+
self.path,
|
|
226
|
+
)
|
|
185
227
|
self.state = "idle"
|
|
186
228
|
self.queue = None
|
|
187
229
|
self._cancel_pending_timeout()
|
|
@@ -248,6 +290,8 @@ class RenderSession:
|
|
|
248
290
|
_pending_api: dict[str, asyncio.Future[dict[str, Any]]]
|
|
249
291
|
_pending_js_results: dict[str, asyncio.Future[Any]]
|
|
250
292
|
_global_states: dict[str, State]
|
|
293
|
+
_tasks: TaskRegistry
|
|
294
|
+
_timers: TimerRegistry
|
|
251
295
|
|
|
252
296
|
def __init__(
|
|
253
297
|
self,
|
|
@@ -262,7 +306,7 @@ class RenderSession:
|
|
|
262
306
|
render_loop_limit: int = 50,
|
|
263
307
|
) -> None:
|
|
264
308
|
from pulse.channel import ChannelsManager
|
|
265
|
-
from pulse.
|
|
309
|
+
from pulse.forms import FormRegistry
|
|
266
310
|
|
|
267
311
|
self.id = id
|
|
268
312
|
self.routes = routes
|
|
@@ -277,6 +321,8 @@ class RenderSession:
|
|
|
277
321
|
self.forms = FormRegistry(self)
|
|
278
322
|
self._pending_api = {}
|
|
279
323
|
self._pending_js_results = {}
|
|
324
|
+
self._tasks = TaskRegistry(name=f"render:{id}")
|
|
325
|
+
self._timers = TimerRegistry(tasks=self._tasks, name=f"render:{id}")
|
|
280
326
|
self.prerender_queue_timeout = prerender_queue_timeout
|
|
281
327
|
self.detach_queue_timeout = detach_queue_timeout
|
|
282
328
|
self.disconnect_queue_timeout = disconnect_queue_timeout
|
|
@@ -303,11 +349,15 @@ class RenderSession:
|
|
|
303
349
|
|
|
304
350
|
def connect(self, send_message: Callable[[ServerMessage], Any]):
|
|
305
351
|
"""WebSocket connected. Set sender, don't auto-flush (attach does that)."""
|
|
352
|
+
if os.environ.get("PULSE_DEBUG_RENDER"):
|
|
353
|
+
logger.info("[PulseDebug][render-connect] render=%s", self.id)
|
|
306
354
|
self._send_message = send_message
|
|
307
355
|
self.connected = True
|
|
308
356
|
|
|
309
357
|
def disconnect(self):
|
|
310
358
|
"""WebSocket disconnected. Start queuing briefly before pausing."""
|
|
359
|
+
if os.environ.get("PULSE_DEBUG_RENDER"):
|
|
360
|
+
logger.info("[PulseDebug][render-disconnect] render=%s", self.id)
|
|
311
361
|
self._send_message = None
|
|
312
362
|
self.connected = False
|
|
313
363
|
|
|
@@ -381,6 +431,13 @@ class RenderSession:
|
|
|
381
431
|
- Creates mounts in PENDING state and starts queue
|
|
382
432
|
"""
|
|
383
433
|
normalized = [ensure_absolute_path(path) for path in paths]
|
|
434
|
+
if os.environ.get("PULSE_DEBUG_RENDER"):
|
|
435
|
+
logger.info(
|
|
436
|
+
"[PulseDebug][prerender] render=%s paths=%s route_info=%s",
|
|
437
|
+
self.id,
|
|
438
|
+
normalized,
|
|
439
|
+
route_info,
|
|
440
|
+
)
|
|
384
441
|
|
|
385
442
|
results: dict[str, ServerInitMessage | ServerNavigateToMessage] = {}
|
|
386
443
|
|
|
@@ -388,6 +445,15 @@ class RenderSession:
|
|
|
388
445
|
route = self.routes.find(path)
|
|
389
446
|
info = route_info or route.default_route_info()
|
|
390
447
|
mount = self.route_mounts.get(path)
|
|
448
|
+
if os.environ.get("PULSE_DEBUG_RENDER"):
|
|
449
|
+
route_label = repr(route)
|
|
450
|
+
logger.info(
|
|
451
|
+
"[PulseDebug][prerender] render=%s path=%s mount_state=%s route=%s",
|
|
452
|
+
self.id,
|
|
453
|
+
path,
|
|
454
|
+
mount.state if mount else None,
|
|
455
|
+
route_label,
|
|
456
|
+
)
|
|
391
457
|
|
|
392
458
|
if mount is None:
|
|
393
459
|
mount = RouteMount(self, path, route, info)
|
|
@@ -426,13 +492,29 @@ class RenderSession:
|
|
|
426
492
|
mount = self.route_mounts.get(path)
|
|
427
493
|
|
|
428
494
|
if mount is None or mount.state == "idle":
|
|
495
|
+
if os.environ.get("PULSE_DEBUG_RENDER"):
|
|
496
|
+
logger.info(
|
|
497
|
+
"[PulseDebug][attach] render=%s path=%s mount_state=%s mounts=%s route_info=%s",
|
|
498
|
+
self.id,
|
|
499
|
+
path,
|
|
500
|
+
mount.state if mount else None,
|
|
501
|
+
{key: value.state for key, value in self.route_mounts.items()},
|
|
502
|
+
route_info,
|
|
503
|
+
)
|
|
429
504
|
# Initial render must come from prerender
|
|
505
|
+
print(f"[DEBUG] Missing or idle route '{path}', reloading")
|
|
430
506
|
self.send({"type": "reload"})
|
|
431
507
|
return
|
|
432
508
|
|
|
433
509
|
# Update route info for active and pending mounts
|
|
434
510
|
mount.update_route(route_info)
|
|
435
511
|
if mount.state == "pending" and self._send_message:
|
|
512
|
+
if os.environ.get("PULSE_DEBUG_RENDER"):
|
|
513
|
+
logger.info(
|
|
514
|
+
"[PulseDebug][attach] render=%s path=%s activating=true",
|
|
515
|
+
self.id,
|
|
516
|
+
path,
|
|
517
|
+
)
|
|
436
518
|
mount.activate(self._send_message)
|
|
437
519
|
|
|
438
520
|
def update_route(self, path: str, route_info: RouteInfo):
|
|
@@ -448,6 +530,13 @@ class RenderSession:
|
|
|
448
530
|
current = self.route_mounts.get(path)
|
|
449
531
|
if current is not mount:
|
|
450
532
|
return
|
|
533
|
+
if os.environ.get("PULSE_DEBUG_RENDER"):
|
|
534
|
+
logger.info(
|
|
535
|
+
"[PulseDebug][mount-dispose] render=%s path=%s state=%s",
|
|
536
|
+
self.id,
|
|
537
|
+
path,
|
|
538
|
+
mount.state,
|
|
539
|
+
)
|
|
451
540
|
try:
|
|
452
541
|
self.route_mounts.pop(path, None)
|
|
453
542
|
mount.dispose()
|
|
@@ -546,9 +635,12 @@ class RenderSession:
|
|
|
546
635
|
|
|
547
636
|
def close(self):
|
|
548
637
|
self.forms.dispose()
|
|
638
|
+
self._tasks.cancel_all()
|
|
639
|
+
self._timers.cancel_all()
|
|
549
640
|
for path in list(self.route_mounts.keys()):
|
|
550
641
|
self.detach(path, timeout=0)
|
|
551
642
|
self.route_mounts.clear()
|
|
643
|
+
self.query_store.dispose_all()
|
|
552
644
|
for value in self._global_states.values():
|
|
553
645
|
value.dispose()
|
|
554
646
|
self._global_states.clear()
|
|
@@ -587,6 +679,28 @@ class RenderSession:
|
|
|
587
679
|
with PulseContext.update(render=self):
|
|
588
680
|
flush_effects()
|
|
589
681
|
|
|
682
|
+
def create_task(
|
|
683
|
+
self,
|
|
684
|
+
coroutine: Callable[[], Any] | Awaitable[Any],
|
|
685
|
+
*,
|
|
686
|
+
name: str | None = None,
|
|
687
|
+
on_done: Callable[[asyncio.Task[Any]], None] | None = None,
|
|
688
|
+
) -> asyncio.Task[Any]:
|
|
689
|
+
"""Create a tracked task tied to this render session."""
|
|
690
|
+
if callable(coroutine):
|
|
691
|
+
return self._tasks.create_task(coroutine(), name=name, on_done=on_done)
|
|
692
|
+
return self._tasks.create_task(coroutine, name=name, on_done=on_done)
|
|
693
|
+
|
|
694
|
+
def schedule_later(
|
|
695
|
+
self, delay: float, fn: Callable[..., Any], *args: Any, **kwargs: Any
|
|
696
|
+
) -> TimerHandleLike:
|
|
697
|
+
"""Schedule a tracked timer tied to this render session."""
|
|
698
|
+
return self._timers.later(delay, fn, *args, **kwargs)
|
|
699
|
+
|
|
700
|
+
def discard_timer(self, handle: TimerHandleLike | None) -> None:
|
|
701
|
+
"""Remove a timer handle from the session registry."""
|
|
702
|
+
self._timers.discard(handle)
|
|
703
|
+
|
|
590
704
|
def execute_callback(self, path: str, key: str, args: list[Any] | tuple[Any, ...]):
|
|
591
705
|
mount = self.route_mounts[path]
|
|
592
706
|
cb = mount.tree.callbacks[key]
|
|
@@ -598,9 +712,18 @@ class RenderSession:
|
|
|
598
712
|
with PulseContext.update(render=self, route=mount.route):
|
|
599
713
|
res = cb.fn(*args[: cb.n_args])
|
|
600
714
|
if iscoroutine(res):
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
715
|
+
|
|
716
|
+
def _on_done(t: asyncio.Task[Any]) -> None:
|
|
717
|
+
if t.cancelled():
|
|
718
|
+
return
|
|
719
|
+
try:
|
|
720
|
+
exc = t.exception()
|
|
721
|
+
except asyncio.CancelledError:
|
|
722
|
+
return
|
|
723
|
+
if exc:
|
|
724
|
+
report(exc, True)
|
|
725
|
+
|
|
726
|
+
self.create_task(res, name=f"callback:{key}", on_done=_on_done)
|
|
604
727
|
except Exception as e:
|
|
605
728
|
report(e)
|
|
606
729
|
|
|
@@ -628,7 +751,7 @@ class RenderSession:
|
|
|
628
751
|
api_path = url_or_path if url_or_path.startswith("/") else "/" + url_or_path
|
|
629
752
|
url = f"{base}{api_path}"
|
|
630
753
|
corr_id = uuid.uuid4().hex
|
|
631
|
-
fut =
|
|
754
|
+
fut = create_future()
|
|
632
755
|
self._pending_api[corr_id] = fut
|
|
633
756
|
headers = headers or {}
|
|
634
757
|
headers["x-pulse-render-id"] = self.id
|
|
@@ -746,7 +869,7 @@ class RenderSession:
|
|
|
746
869
|
if not future.done():
|
|
747
870
|
future.set_exception(asyncio.TimeoutError())
|
|
748
871
|
|
|
749
|
-
|
|
872
|
+
self._timers.later(timeout, _on_timeout)
|
|
750
873
|
|
|
751
874
|
return future
|
|
752
875
|
|