pulse-framework 0.1.63__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 +164 -54
- pulse/queries/protocol.py +9 -0
- pulse/queries/query.py +164 -81
- 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.63.dist-info → pulse_framework-0.1.65.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.63.dist-info → pulse_framework-0.1.65.dist-info}/RECORD +19 -18
- {pulse_framework-0.1.63.dist-info → pulse_framework-0.1.65.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.63.dist-info → pulse_framework-0.1.65.dist-info}/entry_points.txt +0 -0
pulse/__init__.py
CHANGED
|
@@ -1134,16 +1134,16 @@ from pulse.env import env as env
|
|
|
1134
1134
|
from pulse.env import mode as mode
|
|
1135
1135
|
|
|
1136
1136
|
# Forms
|
|
1137
|
-
from pulse.
|
|
1137
|
+
from pulse.forms import (
|
|
1138
1138
|
Form as Form,
|
|
1139
1139
|
)
|
|
1140
|
-
from pulse.
|
|
1140
|
+
from pulse.forms import (
|
|
1141
1141
|
FormData as FormData,
|
|
1142
1142
|
)
|
|
1143
|
-
from pulse.
|
|
1143
|
+
from pulse.forms import (
|
|
1144
1144
|
FormValue as FormValue,
|
|
1145
1145
|
)
|
|
1146
|
-
from pulse.
|
|
1146
|
+
from pulse.forms import (
|
|
1147
1147
|
ManualForm as ManualForm,
|
|
1148
1148
|
)
|
|
1149
1149
|
|
|
@@ -1151,12 +1151,6 @@ from pulse.form import (
|
|
|
1151
1151
|
from pulse.helpers import (
|
|
1152
1152
|
CSSProperties as CSSProperties,
|
|
1153
1153
|
)
|
|
1154
|
-
from pulse.helpers import (
|
|
1155
|
-
later as later,
|
|
1156
|
-
)
|
|
1157
|
-
from pulse.helpers import (
|
|
1158
|
-
repeat as repeat,
|
|
1159
|
-
)
|
|
1160
1154
|
|
|
1161
1155
|
# Hooks - Core
|
|
1162
1156
|
from pulse.hooks.core import (
|
|
@@ -1414,6 +1408,18 @@ from pulse.requirements import require as require
|
|
|
1414
1408
|
from pulse.routing import Layout as Layout
|
|
1415
1409
|
from pulse.routing import Route as Route
|
|
1416
1410
|
from pulse.routing import RouteInfo as RouteInfo
|
|
1411
|
+
from pulse.scheduling import (
|
|
1412
|
+
TaskRegistry as TaskRegistry,
|
|
1413
|
+
)
|
|
1414
|
+
from pulse.scheduling import (
|
|
1415
|
+
TimerRegistry as TimerRegistry,
|
|
1416
|
+
)
|
|
1417
|
+
from pulse.scheduling import (
|
|
1418
|
+
later as later,
|
|
1419
|
+
)
|
|
1420
|
+
from pulse.scheduling import (
|
|
1421
|
+
repeat as repeat,
|
|
1422
|
+
)
|
|
1417
1423
|
from pulse.serializer import deserialize as deserialize
|
|
1418
1424
|
|
|
1419
1425
|
# Serializer
|
pulse/app.py
CHANGED
|
@@ -5,7 +5,6 @@ This module provides the main App class that users instantiate in their main.py
|
|
|
5
5
|
to define routes and configure their Pulse application.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
import asyncio
|
|
9
8
|
import logging
|
|
10
9
|
import os
|
|
11
10
|
from collections import defaultdict
|
|
@@ -40,11 +39,9 @@ from pulse.env import (
|
|
|
40
39
|
)
|
|
41
40
|
from pulse.env import env as envvars
|
|
42
41
|
from pulse.helpers import (
|
|
43
|
-
create_task,
|
|
44
42
|
find_available_port,
|
|
45
43
|
get_client_address,
|
|
46
44
|
get_client_address_socketio,
|
|
47
|
-
later,
|
|
48
45
|
)
|
|
49
46
|
from pulse.hooks.core import hooks
|
|
50
47
|
from pulse.messages import (
|
|
@@ -74,6 +71,7 @@ from pulse.proxy import ReactProxy
|
|
|
74
71
|
from pulse.render_session import RenderSession
|
|
75
72
|
from pulse.request import PulseRequest
|
|
76
73
|
from pulse.routing import Layout, Route, RouteTree, ensure_absolute_path
|
|
74
|
+
from pulse.scheduling import TaskRegistry, TimerHandleLike, TimerRegistry
|
|
77
75
|
from pulse.serializer import Serialized, deserialize, serialize
|
|
78
76
|
from pulse.user_session import (
|
|
79
77
|
CookieSessionStore,
|
|
@@ -209,7 +207,10 @@ class App:
|
|
|
209
207
|
_render_to_user: dict[str, str]
|
|
210
208
|
_sessions_in_request: dict[str, int]
|
|
211
209
|
_socket_to_render: dict[str, str]
|
|
212
|
-
_render_cleanups: dict[str,
|
|
210
|
+
_render_cleanups: dict[str, TimerHandleLike]
|
|
211
|
+
_tasks: TaskRegistry
|
|
212
|
+
_timers: TimerRegistry
|
|
213
|
+
_proxy: ReactProxy | None
|
|
213
214
|
session_timeout: float
|
|
214
215
|
connection_status: ConnectionStatusConfig
|
|
215
216
|
render_loop_limit: int
|
|
@@ -283,6 +284,9 @@ class App:
|
|
|
283
284
|
self._socket_to_render = {}
|
|
284
285
|
# Map render_id -> cleanup timer handle for timeout-based expiry
|
|
285
286
|
self._render_cleanups = {}
|
|
287
|
+
self._tasks = TaskRegistry(name="app")
|
|
288
|
+
self._timers = TimerRegistry(tasks=self._tasks, name="app")
|
|
289
|
+
self._proxy = None
|
|
286
290
|
self.session_timeout = session_timeout
|
|
287
291
|
self.detach_queue_timeout = detach_queue_timeout
|
|
288
292
|
self.disconnect_queue_timeout = disconnect_queue_timeout
|
|
@@ -659,10 +663,11 @@ class App:
|
|
|
659
663
|
+ "Use 'pulse run' CLI command or set the environment variable."
|
|
660
664
|
)
|
|
661
665
|
|
|
662
|
-
|
|
666
|
+
self._proxy = ReactProxy(
|
|
663
667
|
react_server_address=react_server_address,
|
|
664
668
|
server_address=server_address,
|
|
665
669
|
)
|
|
670
|
+
proxy_handler = self._proxy
|
|
666
671
|
|
|
667
672
|
# In dev mode, proxy WebSocket connections to React Router (e.g. Vite HMR)
|
|
668
673
|
# Socket.IO handles /socket.io/ at ASGI level before reaching FastAPI
|
|
@@ -718,7 +723,7 @@ class App:
|
|
|
718
723
|
payload = serialize(message)
|
|
719
724
|
# `serialize` returns a tuple, which socket.io will mistake for multiple arguments
|
|
720
725
|
payload = list(payload)
|
|
721
|
-
create_task(self.sio.emit("message", list(payload), to=sid))
|
|
726
|
+
self._tasks.create_task(self.sio.emit("message", list(payload), to=sid))
|
|
722
727
|
|
|
723
728
|
render.connect(on_message)
|
|
724
729
|
# Map socket sid to renderId for message routing
|
|
@@ -790,8 +795,10 @@ class App:
|
|
|
790
795
|
def _cancel_render_cleanup(self, rid: str):
|
|
791
796
|
"""Cancel any pending cleanup task for a render session."""
|
|
792
797
|
cleanup_handle = self._render_cleanups.pop(rid, None)
|
|
793
|
-
if cleanup_handle
|
|
794
|
-
cleanup_handle.
|
|
798
|
+
if cleanup_handle:
|
|
799
|
+
if not cleanup_handle.cancelled():
|
|
800
|
+
cleanup_handle.cancel()
|
|
801
|
+
self._timers.discard(cleanup_handle)
|
|
795
802
|
|
|
796
803
|
def _schedule_render_cleanup(self, rid: str):
|
|
797
804
|
"""Schedule cleanup of a RenderSession after the configured timeout."""
|
|
@@ -817,7 +824,7 @@ class App:
|
|
|
817
824
|
)
|
|
818
825
|
self.close_render(rid)
|
|
819
826
|
|
|
820
|
-
handle = later(self.session_timeout, _cleanup)
|
|
827
|
+
handle = self._timers.later(self.session_timeout, _cleanup)
|
|
821
828
|
self._render_cleanups[rid] = handle
|
|
822
829
|
|
|
823
830
|
async def _handle_pulse_message(
|
|
@@ -1023,7 +1030,7 @@ class App:
|
|
|
1023
1030
|
self._user_to_render[session.sid].remove(rid)
|
|
1024
1031
|
|
|
1025
1032
|
if len(self._user_to_render[session.sid]) == 0:
|
|
1026
|
-
later(60, self.close_session_if_inactive, sid)
|
|
1033
|
+
self._timers.later(60, self.close_session_if_inactive, sid)
|
|
1027
1034
|
|
|
1028
1035
|
def close_session(self, sid: str):
|
|
1029
1036
|
session = self.user_sessions.pop(sid, None)
|
|
@@ -1053,6 +1060,15 @@ class App:
|
|
|
1053
1060
|
for sid in list(self.user_sessions.keys()):
|
|
1054
1061
|
self.close_session(sid)
|
|
1055
1062
|
|
|
1063
|
+
# Cancel any remaining app-level tasks/timers
|
|
1064
|
+
self._tasks.cancel_all()
|
|
1065
|
+
self._timers.cancel_all()
|
|
1066
|
+
if self._proxy is not None:
|
|
1067
|
+
try:
|
|
1068
|
+
await self._proxy.close()
|
|
1069
|
+
except Exception:
|
|
1070
|
+
logger.exception("Error during ReactProxy.close()")
|
|
1071
|
+
|
|
1056
1072
|
# Update status
|
|
1057
1073
|
self.status = AppStatus.stopped
|
|
1058
1074
|
# Call plugin on_shutdown hooks before closing
|
|
@@ -1082,5 +1098,8 @@ class App:
|
|
|
1082
1098
|
return # no active render for this user session
|
|
1083
1099
|
|
|
1084
1100
|
# We don't want to wait for this to resolve
|
|
1085
|
-
|
|
1101
|
+
render.create_task(
|
|
1102
|
+
render.call_api(f"{self.api_prefix}/set-cookies", method="GET"),
|
|
1103
|
+
name="cookies.refresh",
|
|
1104
|
+
)
|
|
1086
1105
|
sess.scheduled_cookie_refresh = True
|
pulse/channel.py
CHANGED
|
@@ -7,7 +7,6 @@ from dataclasses import dataclass
|
|
|
7
7
|
from typing import TYPE_CHECKING, Any, cast
|
|
8
8
|
|
|
9
9
|
from pulse.context import PulseContext
|
|
10
|
-
from pulse.helpers import create_future_on_loop
|
|
11
10
|
from pulse.messages import (
|
|
12
11
|
ClientChannelRequestMessage,
|
|
13
12
|
ClientChannelResponseMessage,
|
|
@@ -15,6 +14,7 @@ from pulse.messages import (
|
|
|
15
14
|
ServerChannelRequestMessage,
|
|
16
15
|
ServerChannelResponseMessage,
|
|
17
16
|
)
|
|
17
|
+
from pulse.scheduling import create_future
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
20
|
from pulse.render_session import RenderSession
|
|
@@ -203,7 +203,7 @@ class ChannelsManager:
|
|
|
203
203
|
msg=msg,
|
|
204
204
|
)
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
render.create_task(_invoke(), name=f"channel:{channel_id}:{event}")
|
|
207
207
|
|
|
208
208
|
# ------------------------------------------------------------------
|
|
209
209
|
def register_pending(
|
|
@@ -494,7 +494,7 @@ class Channel:
|
|
|
494
494
|
|
|
495
495
|
self._ensure_open()
|
|
496
496
|
request_id = uuid.uuid4().hex
|
|
497
|
-
fut =
|
|
497
|
+
fut = create_future()
|
|
498
498
|
self._manager.register_pending(request_id, fut, self.id)
|
|
499
499
|
msg = ServerChannelRequestMessage(
|
|
500
500
|
type="channel_message",
|
pulse/{form.py → forms.py}
RENAMED
|
@@ -244,7 +244,7 @@ def Form(
|
|
|
244
244
|
key: str,
|
|
245
245
|
onSubmit: EventHandler1[FormData] | None = None,
|
|
246
246
|
**props: Unpack[PulseFormProps], # pyright: ignore[reportGeneralTypeIssues]
|
|
247
|
-
)
|
|
247
|
+
):
|
|
248
248
|
"""Server-registered HTML form component.
|
|
249
249
|
|
|
250
250
|
Automatically wires up form submission to a Python handler. Uses
|
|
@@ -435,7 +435,7 @@ class ManualForm(Disposable):
|
|
|
435
435
|
*children: Node,
|
|
436
436
|
key: str | None = None,
|
|
437
437
|
**props: Unpack[PulseFormProps],
|
|
438
|
-
)
|
|
438
|
+
):
|
|
439
439
|
"""Render as a form element with children.
|
|
440
440
|
|
|
441
441
|
Args:
|
pulse/helpers.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import asyncio
|
|
2
1
|
import inspect
|
|
3
2
|
import linecache
|
|
4
3
|
import os
|
|
@@ -17,7 +16,6 @@ from typing import (
|
|
|
17
16
|
)
|
|
18
17
|
from urllib.parse import urlsplit
|
|
19
18
|
|
|
20
|
-
from anyio import from_thread
|
|
21
19
|
from fastapi import Request
|
|
22
20
|
|
|
23
21
|
from pulse.env import env
|
|
@@ -89,7 +87,15 @@ P = ParamSpec("P")
|
|
|
89
87
|
CSSProperties = dict[str, Any]
|
|
90
88
|
|
|
91
89
|
|
|
92
|
-
|
|
90
|
+
class Missing:
|
|
91
|
+
__slots__: tuple[str, ...] = ()
|
|
92
|
+
|
|
93
|
+
@override
|
|
94
|
+
def __repr__(self) -> str:
|
|
95
|
+
return "MISSING"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
MISSING = Missing()
|
|
93
99
|
|
|
94
100
|
|
|
95
101
|
class File(TypedDict):
|
|
@@ -130,7 +136,6 @@ def data(**attrs: Any):
|
|
|
130
136
|
return {f"data-{k}": v for k, v in attrs.items()}
|
|
131
137
|
|
|
132
138
|
|
|
133
|
-
# --- Async scheduling helpers (work from loop or sync threads) ---
|
|
134
139
|
class Disposable(ABC):
|
|
135
140
|
__disposed__: bool = False
|
|
136
141
|
|
|
@@ -158,214 +163,6 @@ class Disposable(ABC):
|
|
|
158
163
|
cls.dispose = wrapped_dispose
|
|
159
164
|
|
|
160
165
|
|
|
161
|
-
def is_pytest() -> bool:
|
|
162
|
-
"""Detect if running inside pytest using environment variables."""
|
|
163
|
-
return bool(os.environ.get("PYTEST_CURRENT_TEST")) or (
|
|
164
|
-
"PYTEST_XDIST_TESTRUNUID" in os.environ
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def schedule_on_loop(callback: Callable[[], None]) -> None:
|
|
169
|
-
"""Schedule a callback to run ASAP on the main event loop from any thread."""
|
|
170
|
-
try:
|
|
171
|
-
loop = asyncio.get_running_loop()
|
|
172
|
-
loop.call_soon_threadsafe(callback)
|
|
173
|
-
except RuntimeError:
|
|
174
|
-
|
|
175
|
-
async def _runner():
|
|
176
|
-
loop = asyncio.get_running_loop()
|
|
177
|
-
loop.call_soon(callback)
|
|
178
|
-
|
|
179
|
-
try:
|
|
180
|
-
from_thread.run(_runner)
|
|
181
|
-
except RuntimeError:
|
|
182
|
-
if not is_pytest():
|
|
183
|
-
raise
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def create_task(
|
|
187
|
-
coroutine: Awaitable[T],
|
|
188
|
-
*,
|
|
189
|
-
name: str | None = None,
|
|
190
|
-
on_done: Callable[[asyncio.Task[T]], None] | None = None,
|
|
191
|
-
) -> asyncio.Task[T]:
|
|
192
|
-
"""Create and schedule a coroutine task on the main loop from any thread.
|
|
193
|
-
|
|
194
|
-
- factory should create a fresh coroutine each call
|
|
195
|
-
- optional on_done is attached on the created task within the loop
|
|
196
|
-
"""
|
|
197
|
-
|
|
198
|
-
try:
|
|
199
|
-
asyncio.get_running_loop()
|
|
200
|
-
# ensure_future accepts Awaitable and returns a Task when given a coroutine
|
|
201
|
-
task = asyncio.ensure_future(coroutine)
|
|
202
|
-
if name is not None:
|
|
203
|
-
task.set_name(name)
|
|
204
|
-
if on_done:
|
|
205
|
-
task.add_done_callback(on_done)
|
|
206
|
-
return task
|
|
207
|
-
except RuntimeError:
|
|
208
|
-
|
|
209
|
-
async def _runner():
|
|
210
|
-
asyncio.get_running_loop()
|
|
211
|
-
# ensure_future accepts Awaitable and returns a Task when given a coroutine
|
|
212
|
-
task = asyncio.ensure_future(coroutine)
|
|
213
|
-
if name is not None:
|
|
214
|
-
task.set_name(name)
|
|
215
|
-
if on_done:
|
|
216
|
-
task.add_done_callback(on_done)
|
|
217
|
-
return task
|
|
218
|
-
|
|
219
|
-
try:
|
|
220
|
-
return from_thread.run(_runner)
|
|
221
|
-
except RuntimeError:
|
|
222
|
-
if is_pytest():
|
|
223
|
-
return None # pyright: ignore[reportReturnType]
|
|
224
|
-
raise
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def create_future_on_loop() -> asyncio.Future[Any]:
|
|
228
|
-
"""Create an asyncio Future on the main event loop from any thread."""
|
|
229
|
-
try:
|
|
230
|
-
return asyncio.get_running_loop().create_future()
|
|
231
|
-
except RuntimeError:
|
|
232
|
-
from anyio import from_thread
|
|
233
|
-
|
|
234
|
-
async def _create():
|
|
235
|
-
loop = asyncio.get_running_loop()
|
|
236
|
-
return loop.create_future()
|
|
237
|
-
|
|
238
|
-
return from_thread.run(_create)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def later(
|
|
242
|
-
delay: float, fn: Callable[P, Any], *args: P.args, **kwargs: P.kwargs
|
|
243
|
-
) -> asyncio.TimerHandle:
|
|
244
|
-
"""
|
|
245
|
-
Schedule `fn(*args, **kwargs)` to run after `delay` seconds.
|
|
246
|
-
Works with sync or async functions. Returns a TimerHandle; call .cancel() to cancel.
|
|
247
|
-
|
|
248
|
-
The callback runs with no reactive scope to avoid accidentally capturing
|
|
249
|
-
reactive dependencies from the calling context. Other context vars (like
|
|
250
|
-
PulseContext) are preserved normally.
|
|
251
|
-
"""
|
|
252
|
-
|
|
253
|
-
from pulse.reactive import Untrack
|
|
254
|
-
|
|
255
|
-
try:
|
|
256
|
-
loop = asyncio.get_running_loop()
|
|
257
|
-
except RuntimeError:
|
|
258
|
-
try:
|
|
259
|
-
loop = asyncio.get_event_loop()
|
|
260
|
-
except RuntimeError as exc:
|
|
261
|
-
raise RuntimeError("later() requires an event loop") from exc
|
|
262
|
-
|
|
263
|
-
def _run():
|
|
264
|
-
try:
|
|
265
|
-
with Untrack():
|
|
266
|
-
res = fn(*args, **kwargs)
|
|
267
|
-
if asyncio.iscoroutine(res):
|
|
268
|
-
task = loop.create_task(res)
|
|
269
|
-
|
|
270
|
-
def _log_task_exception(t: asyncio.Task[Any]):
|
|
271
|
-
try:
|
|
272
|
-
t.result()
|
|
273
|
-
except asyncio.CancelledError:
|
|
274
|
-
# Normal cancellation path
|
|
275
|
-
pass
|
|
276
|
-
except Exception as exc:
|
|
277
|
-
loop.call_exception_handler(
|
|
278
|
-
{
|
|
279
|
-
"message": "Unhandled exception in later() task",
|
|
280
|
-
"exception": exc,
|
|
281
|
-
"context": {"callback": fn},
|
|
282
|
-
}
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
task.add_done_callback(_log_task_exception)
|
|
286
|
-
except Exception as exc:
|
|
287
|
-
# Surface exceptions via the loop's exception handler and continue
|
|
288
|
-
loop.call_exception_handler(
|
|
289
|
-
{
|
|
290
|
-
"message": "Unhandled exception in later() callback",
|
|
291
|
-
"exception": exc,
|
|
292
|
-
"context": {"callback": fn},
|
|
293
|
-
}
|
|
294
|
-
)
|
|
295
|
-
|
|
296
|
-
return loop.call_later(delay, _run)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
class RepeatHandle:
|
|
300
|
-
task: asyncio.Task[None] | None
|
|
301
|
-
cancelled: bool
|
|
302
|
-
|
|
303
|
-
def __init__(self) -> None:
|
|
304
|
-
self.task = None
|
|
305
|
-
self.cancelled = False
|
|
306
|
-
|
|
307
|
-
def cancel(self):
|
|
308
|
-
if self.cancelled:
|
|
309
|
-
return
|
|
310
|
-
self.cancelled = True
|
|
311
|
-
if self.task is not None and not self.task.done():
|
|
312
|
-
self.task.cancel()
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
def repeat(interval: float, fn: Callable[P, Any], *args: P.args, **kwargs: P.kwargs):
|
|
316
|
-
"""
|
|
317
|
-
Repeatedly run `fn(*args, **kwargs)` every `interval` seconds.
|
|
318
|
-
Works with sync or async functions.
|
|
319
|
-
For async functions, waits for completion before starting the next delay.
|
|
320
|
-
Returns a handle with .cancel() to stop future runs.
|
|
321
|
-
|
|
322
|
-
The callback runs with no reactive scope to avoid accidentally capturing
|
|
323
|
-
reactive dependencies from the calling context. Other context vars (like
|
|
324
|
-
PulseContext) are preserved normally.
|
|
325
|
-
|
|
326
|
-
Optional kwargs:
|
|
327
|
-
- immediate: bool = False # run once immediately before the first interval
|
|
328
|
-
"""
|
|
329
|
-
|
|
330
|
-
from pulse.reactive import Untrack
|
|
331
|
-
|
|
332
|
-
loop = asyncio.get_running_loop()
|
|
333
|
-
handle = RepeatHandle()
|
|
334
|
-
|
|
335
|
-
async def _runner():
|
|
336
|
-
nonlocal handle
|
|
337
|
-
try:
|
|
338
|
-
while not handle.cancelled:
|
|
339
|
-
# Start counting the next interval AFTER the previous execution completes
|
|
340
|
-
await asyncio.sleep(interval)
|
|
341
|
-
if handle.cancelled:
|
|
342
|
-
break
|
|
343
|
-
try:
|
|
344
|
-
with Untrack():
|
|
345
|
-
result = fn(*args, **kwargs)
|
|
346
|
-
if asyncio.iscoroutine(result):
|
|
347
|
-
await result
|
|
348
|
-
except asyncio.CancelledError:
|
|
349
|
-
# Propagate to outer handler to finish cleanly
|
|
350
|
-
raise
|
|
351
|
-
except Exception as exc:
|
|
352
|
-
# Surface exceptions via the loop's exception handler and continue
|
|
353
|
-
loop.call_exception_handler(
|
|
354
|
-
{
|
|
355
|
-
"message": "Unhandled exception in repeat() callback",
|
|
356
|
-
"exception": exc,
|
|
357
|
-
"context": {"callback": fn},
|
|
358
|
-
}
|
|
359
|
-
)
|
|
360
|
-
except asyncio.CancelledError:
|
|
361
|
-
# Swallow task cancellation to avoid noisy "exception was never retrieved"
|
|
362
|
-
pass
|
|
363
|
-
|
|
364
|
-
handle.task = loop.create_task(_runner())
|
|
365
|
-
|
|
366
|
-
return handle
|
|
367
|
-
|
|
368
|
-
|
|
369
166
|
def get_client_address(request: Request) -> str | None:
|
|
370
167
|
"""Best-effort client origin/address from an HTTP request.
|
|
371
168
|
|
pulse/proxy.py
CHANGED
|
@@ -9,7 +9,6 @@ from typing import cast
|
|
|
9
9
|
import httpx
|
|
10
10
|
import websockets
|
|
11
11
|
from fastapi.responses import StreamingResponse
|
|
12
|
-
from starlette.background import BackgroundTask
|
|
13
12
|
from starlette.requests import Request
|
|
14
13
|
from starlette.responses import PlainTextResponse, Response
|
|
15
14
|
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
@@ -223,9 +222,17 @@ class ReactProxy:
|
|
|
223
222
|
v = self.rewrite_url(v)
|
|
224
223
|
response_headers[k] = v
|
|
225
224
|
|
|
225
|
+
async def _iter():
|
|
226
|
+
try:
|
|
227
|
+
async for chunk in r.aiter_raw():
|
|
228
|
+
if await request.is_disconnected():
|
|
229
|
+
break
|
|
230
|
+
yield chunk
|
|
231
|
+
finally:
|
|
232
|
+
await r.aclose()
|
|
233
|
+
|
|
226
234
|
return StreamingResponse(
|
|
227
|
-
|
|
228
|
-
background=BackgroundTask(r.aclose),
|
|
235
|
+
_iter(),
|
|
229
236
|
status_code=r.status_code,
|
|
230
237
|
headers=response_headers,
|
|
231
238
|
)
|
pulse/queries/client.py
CHANGED
|
@@ -3,6 +3,7 @@ from collections.abc import Callable
|
|
|
3
3
|
from typing import Any, TypeVar, overload
|
|
4
4
|
|
|
5
5
|
from pulse.context import PulseContext
|
|
6
|
+
from pulse.helpers import MISSING
|
|
6
7
|
from pulse.queries.common import ActionResult, QueryKey
|
|
7
8
|
from pulse.queries.infinite_query import InfiniteQuery, Page
|
|
8
9
|
from pulse.queries.query import KeyedQuery
|
|
@@ -203,7 +204,10 @@ class QueryClient:
|
|
|
203
204
|
query = self.get(key)
|
|
204
205
|
if query is None:
|
|
205
206
|
return None
|
|
206
|
-
|
|
207
|
+
value = query.data.read()
|
|
208
|
+
if value is MISSING:
|
|
209
|
+
return None
|
|
210
|
+
return value
|
|
207
211
|
|
|
208
212
|
def get_infinite_data(self, key: QueryKey) -> list[Page[Any, Any]] | None:
|
|
209
213
|
"""Get the pages for an infinite query by key.
|
pulse/queries/effect.py
CHANGED
|
@@ -7,6 +7,7 @@ from typing import (
|
|
|
7
7
|
override,
|
|
8
8
|
)
|
|
9
9
|
|
|
10
|
+
from pulse.helpers import MISSING
|
|
10
11
|
from pulse.reactive import AsyncEffect, Computed, Signal
|
|
11
12
|
|
|
12
13
|
|
|
@@ -49,7 +50,7 @@ class AsyncQueryEffect(AsyncEffect):
|
|
|
49
50
|
# For unkeyed queries on re-run (dependency changed), reset data/status
|
|
50
51
|
# to behave like keyed queries when key changes (new Query with data=None)
|
|
51
52
|
if self._is_unkeyed and self.runs > 0:
|
|
52
|
-
self.fetcher.data.write(
|
|
53
|
+
self.fetcher.data.write(MISSING)
|
|
53
54
|
self.fetcher.status.write("loading")
|
|
54
55
|
|
|
55
56
|
return super().run()
|