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/__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,
|
|
@@ -109,8 +107,8 @@ PulseMode = Literal["subdomains", "single-server"]
|
|
|
109
107
|
"""Deployment mode for the application.
|
|
110
108
|
|
|
111
109
|
Values:
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
"single-server": Python and React served from the same origin (default).
|
|
111
|
+
"subdomains": Python API on a subdomain (e.g., api.example.com).
|
|
114
112
|
"""
|
|
115
113
|
|
|
116
114
|
|
|
@@ -120,12 +118,12 @@ class ConnectionStatusConfig:
|
|
|
120
118
|
Configuration for connection status message delays.
|
|
121
119
|
|
|
122
120
|
Attributes:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
121
|
+
initial_connecting_delay: Delay in seconds before showing "Connecting..." message
|
|
122
|
+
on initial connection attempt. Default: 2.0
|
|
123
|
+
initial_error_delay: Additional delay in seconds before showing error message
|
|
124
|
+
on initial connection attempt (after connecting message). Default: 8.0
|
|
125
|
+
reconnect_error_delay: Delay in seconds before showing error message when
|
|
126
|
+
reconnecting after losing connection. Default: 8.0
|
|
129
127
|
"""
|
|
130
128
|
|
|
131
129
|
initial_connecting_delay: float = 2.0
|
|
@@ -173,15 +171,15 @@ class App:
|
|
|
173
171
|
import pulse as ps
|
|
174
172
|
|
|
175
173
|
app = ps.App(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
174
|
+
routes=[
|
|
175
|
+
ps.Route("/", render=home),
|
|
176
|
+
ps.Route("/users/:id", render=user_detail),
|
|
177
|
+
],
|
|
178
|
+
session_timeout=120.0,
|
|
181
179
|
)
|
|
182
180
|
|
|
183
181
|
if __name__ == "__main__":
|
|
184
|
-
|
|
182
|
+
app.run(port=8000)
|
|
185
183
|
```
|
|
186
184
|
"""
|
|
187
185
|
|
|
@@ -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
|
|
@@ -540,10 +544,21 @@ class App:
|
|
|
540
544
|
paths = [ensure_absolute_path(path) for path in paths]
|
|
541
545
|
payload["paths"] = paths
|
|
542
546
|
route_info = payload.get("routeInfo")
|
|
547
|
+
debug = os.environ.get("PULSE_DEBUG_RENDER")
|
|
548
|
+
if debug:
|
|
549
|
+
logger.info(
|
|
550
|
+
"[PulseDebug][prerender] session=%s header_render_id=%s payload_render_id=%s paths=%s route_info=%s",
|
|
551
|
+
session.sid,
|
|
552
|
+
request.headers.get("x-pulse-render-id"),
|
|
553
|
+
payload.get("renderId"),
|
|
554
|
+
paths,
|
|
555
|
+
route_info,
|
|
556
|
+
)
|
|
543
557
|
|
|
544
558
|
client_addr: str | None = get_client_address(request)
|
|
545
559
|
# Reuse render session from header (set by middleware) or create new one
|
|
546
560
|
render = PulseContext.get().render
|
|
561
|
+
reused = render is not None
|
|
547
562
|
if render is not None:
|
|
548
563
|
render_id = render.id
|
|
549
564
|
else:
|
|
@@ -552,9 +567,19 @@ class App:
|
|
|
552
567
|
render = self.create_render(
|
|
553
568
|
render_id, session, client_address=client_addr
|
|
554
569
|
)
|
|
570
|
+
if debug:
|
|
571
|
+
logger.info(
|
|
572
|
+
"[PulseDebug][prerender] session=%s render=%s reused=%s connected=%s",
|
|
573
|
+
session.sid,
|
|
574
|
+
render_id,
|
|
575
|
+
reused,
|
|
576
|
+
render.connected,
|
|
577
|
+
)
|
|
578
|
+
print(f"Prerendering for RenderSession {render_id}")
|
|
555
579
|
|
|
556
580
|
# Schedule cleanup timeout (will cancel/reschedule on activity)
|
|
557
|
-
|
|
581
|
+
if not render.connected:
|
|
582
|
+
self._schedule_render_cleanup(render_id)
|
|
558
583
|
|
|
559
584
|
def _normalize_prerender_result(
|
|
560
585
|
captured: ServerInitMessage | ServerNavigateToMessage,
|
|
@@ -659,10 +684,11 @@ class App:
|
|
|
659
684
|
+ "Use 'pulse run' CLI command or set the environment variable."
|
|
660
685
|
)
|
|
661
686
|
|
|
662
|
-
|
|
687
|
+
self._proxy = ReactProxy(
|
|
663
688
|
react_server_address=react_server_address,
|
|
664
689
|
server_address=server_address,
|
|
665
690
|
)
|
|
691
|
+
proxy_handler = self._proxy
|
|
666
692
|
|
|
667
693
|
# In dev mode, proxy WebSocket connections to React Router (e.g. Vite HMR)
|
|
668
694
|
# Socket.IO handles /socket.io/ at ASGI level before reaching FastAPI
|
|
@@ -693,6 +719,13 @@ class App:
|
|
|
693
719
|
if cookie is None:
|
|
694
720
|
raise ConnectionRefusedError("Socket connect missing cookie")
|
|
695
721
|
session = await self.get_or_create_session(cookie)
|
|
722
|
+
debug = os.environ.get("PULSE_DEBUG_RENDER")
|
|
723
|
+
if debug:
|
|
724
|
+
logger.info(
|
|
725
|
+
"[PulseDebug][connect] session=%s render_id=%s",
|
|
726
|
+
session.sid,
|
|
727
|
+
rid,
|
|
728
|
+
)
|
|
696
729
|
|
|
697
730
|
if not rid:
|
|
698
731
|
# Still refuse connections without a renderId
|
|
@@ -703,6 +736,13 @@ class App:
|
|
|
703
736
|
# Allow reconnects where the provided renderId no longer exists by creating a new RenderSession
|
|
704
737
|
render = self.render_sessions.get(rid)
|
|
705
738
|
if render is None:
|
|
739
|
+
# The client will try to attach to a non-existing RouteMount, which will cause a reload down the line
|
|
740
|
+
if debug:
|
|
741
|
+
logger.info(
|
|
742
|
+
"[PulseDebug][connect] render_missing session=%s render_id=%s creating=true",
|
|
743
|
+
session.sid,
|
|
744
|
+
rid,
|
|
745
|
+
)
|
|
706
746
|
render = self.create_render(
|
|
707
747
|
rid, session, client_address=get_client_address_socketio(environ)
|
|
708
748
|
)
|
|
@@ -713,12 +753,21 @@ class App:
|
|
|
713
753
|
f"Socket connect session mismatch render={render.id} "
|
|
714
754
|
+ f"owner={owner} session={session.sid}"
|
|
715
755
|
)
|
|
756
|
+
if debug:
|
|
757
|
+
logger.info(
|
|
758
|
+
"[PulseDebug][connect] render_found session=%s render_id=%s owner=%s connected=%s",
|
|
759
|
+
session.sid,
|
|
760
|
+
render.id,
|
|
761
|
+
owner,
|
|
762
|
+
render.connected,
|
|
763
|
+
)
|
|
764
|
+
print(f"Connected to RenderSession {render.id}")
|
|
716
765
|
|
|
717
766
|
def on_message(message: ServerMessage):
|
|
718
767
|
payload = serialize(message)
|
|
719
768
|
# `serialize` returns a tuple, which socket.io will mistake for multiple arguments
|
|
720
769
|
payload = list(payload)
|
|
721
|
-
create_task(self.sio.emit("message", list(payload), to=sid))
|
|
770
|
+
self._tasks.create_task(self.sio.emit("message", list(payload), to=sid))
|
|
722
771
|
|
|
723
772
|
render.connect(on_message)
|
|
724
773
|
# Map socket sid to renderId for message routing
|
|
@@ -790,8 +839,10 @@ class App:
|
|
|
790
839
|
def _cancel_render_cleanup(self, rid: str):
|
|
791
840
|
"""Cancel any pending cleanup task for a render session."""
|
|
792
841
|
cleanup_handle = self._render_cleanups.pop(rid, None)
|
|
793
|
-
if cleanup_handle
|
|
794
|
-
cleanup_handle.
|
|
842
|
+
if cleanup_handle:
|
|
843
|
+
if not cleanup_handle.cancelled():
|
|
844
|
+
cleanup_handle.cancel()
|
|
845
|
+
self._timers.discard(cleanup_handle)
|
|
795
846
|
|
|
796
847
|
def _schedule_render_cleanup(self, rid: str):
|
|
797
848
|
"""Schedule cleanup of a RenderSession after the configured timeout."""
|
|
@@ -817,12 +868,26 @@ class App:
|
|
|
817
868
|
)
|
|
818
869
|
self.close_render(rid)
|
|
819
870
|
|
|
820
|
-
handle = later(self.session_timeout, _cleanup)
|
|
871
|
+
handle = self._timers.later(self.session_timeout, _cleanup)
|
|
821
872
|
self._render_cleanups[rid] = handle
|
|
822
873
|
|
|
823
874
|
async def _handle_pulse_message(
|
|
824
875
|
self, render: RenderSession, session: UserSession, msg: ClientPulseMessage
|
|
825
876
|
) -> None:
|
|
877
|
+
if os.environ.get("PULSE_DEBUG_RENDER") and msg["type"] in (
|
|
878
|
+
"attach",
|
|
879
|
+
"update",
|
|
880
|
+
"detach",
|
|
881
|
+
):
|
|
882
|
+
logger.info(
|
|
883
|
+
"[PulseDebug][client-message] session=%s render=%s type=%s path=%s route_info=%s",
|
|
884
|
+
session.sid,
|
|
885
|
+
render.id,
|
|
886
|
+
msg["type"],
|
|
887
|
+
msg.get("path"),
|
|
888
|
+
msg.get("routeInfo"),
|
|
889
|
+
)
|
|
890
|
+
|
|
826
891
|
async def _next() -> Ok[None]:
|
|
827
892
|
if msg["type"] == "attach":
|
|
828
893
|
render.attach(msg["path"], msg["routeInfo"])
|
|
@@ -1023,7 +1088,7 @@ class App:
|
|
|
1023
1088
|
self._user_to_render[session.sid].remove(rid)
|
|
1024
1089
|
|
|
1025
1090
|
if len(self._user_to_render[session.sid]) == 0:
|
|
1026
|
-
later(60, self.close_session_if_inactive, sid)
|
|
1091
|
+
self._timers.later(60, self.close_session_if_inactive, sid)
|
|
1027
1092
|
|
|
1028
1093
|
def close_session(self, sid: str):
|
|
1029
1094
|
session = self.user_sessions.pop(sid, None)
|
|
@@ -1053,6 +1118,15 @@ class App:
|
|
|
1053
1118
|
for sid in list(self.user_sessions.keys()):
|
|
1054
1119
|
self.close_session(sid)
|
|
1055
1120
|
|
|
1121
|
+
# Cancel any remaining app-level tasks/timers
|
|
1122
|
+
self._tasks.cancel_all()
|
|
1123
|
+
self._timers.cancel_all()
|
|
1124
|
+
if self._proxy is not None:
|
|
1125
|
+
try:
|
|
1126
|
+
await self._proxy.close()
|
|
1127
|
+
except Exception:
|
|
1128
|
+
logger.exception("Error during ReactProxy.close()")
|
|
1129
|
+
|
|
1056
1130
|
# Update status
|
|
1057
1131
|
self.status = AppStatus.stopped
|
|
1058
1132
|
# Call plugin on_shutdown hooks before closing
|
|
@@ -1082,5 +1156,8 @@ class App:
|
|
|
1082
1156
|
return # no active render for this user session
|
|
1083
1157
|
|
|
1084
1158
|
# We don't want to wait for this to resolve
|
|
1085
|
-
|
|
1159
|
+
render.create_task(
|
|
1160
|
+
render.call_api(f"{self.api_prefix}/set-cookies", method="GET"),
|
|
1161
|
+
name="cookies.refresh",
|
|
1162
|
+
)
|
|
1086
1163
|
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",
|
|
@@ -53,15 +53,11 @@ export async function loader(args: LoaderFunctionArgs) {
|
|
|
53
53
|
return data(prerenderData, { headers });
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
// Client loader: re-prerender on navigation while reusing
|
|
56
|
+
// Client loader: re-prerender on navigation while reusing directives
|
|
57
57
|
export async function clientLoader(args: ClientLoaderFunctionArgs) {
|
|
58
58
|
const url = new URL(args.request.url);
|
|
59
59
|
const matches = matchRoutes(rrPulseRouteTree, url.pathname) ?? [];
|
|
60
60
|
const paths = matches.map(m => m.route.uniquePath);
|
|
61
|
-
const renderId =
|
|
62
|
-
typeof window !== "undefined" && typeof sessionStorage !== "undefined"
|
|
63
|
-
? (sessionStorage.getItem("__PULSE_RENDER_ID") ?? undefined)
|
|
64
|
-
: undefined;
|
|
65
61
|
const directives =
|
|
66
62
|
typeof window !== "undefined" && typeof sessionStorage !== "undefined"
|
|
67
63
|
? (JSON.parse(sessionStorage.getItem("__PULSE_DIRECTIVES") ?? "{}"))
|
|
@@ -76,7 +72,7 @@ export async function clientLoader(args: ClientLoaderFunctionArgs) {
|
|
|
76
72
|
method: "POST",
|
|
77
73
|
headers,
|
|
78
74
|
credentials: "include",
|
|
79
|
-
body: JSON.stringify({ paths, routeInfo: extractServerRouteInfo(args)
|
|
75
|
+
body: JSON.stringify({ paths, routeInfo: extractServerRouteInfo(args) }),
|
|
80
76
|
});
|
|
81
77
|
if (!res.ok) throw new Error("Failed to prerender batch:" + res.status);
|
|
82
78
|
const body = await res.json();
|
|
@@ -92,7 +88,6 @@ export async function clientLoader(args: ClientLoaderFunctionArgs) {
|
|
|
92
88
|
export default function PulseLayout() {
|
|
93
89
|
const data = useLoaderData<typeof loader>();
|
|
94
90
|
if (typeof window !== "undefined" && typeof sessionStorage !== "undefined") {
|
|
95
|
-
sessionStorage.setItem("__PULSE_RENDER_ID", data.renderId);
|
|
96
91
|
sessionStorage.setItem("__PULSE_DIRECTIVES", JSON.stringify(data.directives));
|
|
97
92
|
}
|
|
98
93
|
return (
|
|
@@ -101,6 +96,6 @@ export default function PulseLayout() {
|
|
|
101
96
|
</PulseProvider>
|
|
102
97
|
);
|
|
103
98
|
}
|
|
104
|
-
// Persist
|
|
99
|
+
// Persist directives in sessionStorage for reuse in clientLoader is handled within the component
|
|
105
100
|
"""
|
|
106
101
|
)
|
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
|
)
|