millwork 0.1.0__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.
- millwork/__init__.py +45 -0
- millwork/_bundle/dist/.vite/manifest.json +92 -0
- millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
- millwork/_bundle/dist/favicon.svg +15 -0
- millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- millwork/_bundle/dist/icons.svg +24 -0
- millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
- millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
- millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
- millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
- millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
- millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
- millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
- millwork/app.py +726 -0
- millwork/auth.py +877 -0
- millwork/chat.py +397 -0
- millwork/cli.py +316 -0
- millwork/context.py +42 -0
- millwork/files.py +450 -0
- millwork/forms.py +349 -0
- millwork/manual/__init__.py +449 -0
- millwork/manual/components/Hello.jsx +24 -0
- millwork/manual/components/Voice.jsx +46 -0
- millwork/manual/pages/app/auth.md +188 -0
- millwork/manual/pages/app/pages.md +200 -0
- millwork/manual/pages/app/running.md +242 -0
- millwork/manual/pages/app/state.md +151 -0
- millwork/manual/pages/app/testing.md +147 -0
- millwork/manual/pages/app/theming.md +122 -0
- millwork/manual/pages/components/charts.md +263 -0
- millwork/manual/pages/components/chat.md +280 -0
- millwork/manual/pages/components/custom.md +148 -0
- millwork/manual/pages/components/data.md +112 -0
- millwork/manual/pages/components/feedback.md +212 -0
- millwork/manual/pages/components/files.md +194 -0
- millwork/manual/pages/components/form.md +135 -0
- millwork/manual/pages/components/inputs.md +360 -0
- millwork/manual/pages/components/layout.md +154 -0
- millwork/manual/pages/components/tasks.md +140 -0
- millwork/manual/pages/components/text.md +145 -0
- millwork/manual/pages/index.md +67 -0
- millwork/manual/public/fonts/InterVariable.woff2 +0 -0
- millwork/manual/public/fonts/LICENSE.txt +2 -0
- millwork/manual/public/mark.svg +15 -0
- millwork/manual/public/theme.json +97 -0
- millwork/nodes.py +300 -0
- millwork/protocol.py +238 -0
- millwork/proxy.py +122 -0
- millwork/py.typed +0 -0
- millwork/session.py +1422 -0
- millwork/shell.py +226 -0
- millwork/tasks.py +473 -0
- millwork/testing.py +336 -0
- millwork/theme.py +338 -0
- millwork/ui/__init__.py +725 -0
- millwork/ui/charts.py +214 -0
- millwork/ui/chat.py +97 -0
- millwork/ui/files.py +101 -0
- millwork/ui/form.py +102 -0
- millwork/ui/table.py +145 -0
- millwork/ws.py +220 -0
- millwork-0.1.0.dist-info/METADATA +131 -0
- millwork-0.1.0.dist-info/RECORD +70 -0
- millwork-0.1.0.dist-info/WHEEL +4 -0
- millwork-0.1.0.dist-info/entry_points.txt +2 -0
- millwork-0.1.0.dist-info/licenses/LICENSE +202 -0
millwork/session.py
ADDED
|
@@ -0,0 +1,1422 @@
|
|
|
1
|
+
"""A session: one browser tab's state, tree, and connection.
|
|
2
|
+
|
|
3
|
+
The session owns the state object, renders the page from it, diffs against
|
|
4
|
+
the last tree it sent, and talks to the tab through whatever ``send``
|
|
5
|
+
callable the websocket attached. It outlives the socket for a grace
|
|
6
|
+
period so a reload or a dropped connection keeps its state.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import hmac
|
|
14
|
+
import inspect
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
import secrets
|
|
18
|
+
import time
|
|
19
|
+
from collections import deque
|
|
20
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
23
|
+
|
|
24
|
+
from millwork import context
|
|
25
|
+
from millwork.auth import Forbidden, NotFound
|
|
26
|
+
from millwork.chat import ChatTurn, Thread, UIMessage
|
|
27
|
+
from millwork.files import FileRef, Reject, SessionFiles, content_to_file
|
|
28
|
+
from millwork.forms import FormIssues, resolve_files, validate
|
|
29
|
+
from millwork.nodes import Component, Tree, build, diff
|
|
30
|
+
from millwork.protocol import (
|
|
31
|
+
Answer,
|
|
32
|
+
Command,
|
|
33
|
+
Event,
|
|
34
|
+
Head,
|
|
35
|
+
Mount,
|
|
36
|
+
Patch,
|
|
37
|
+
Reply,
|
|
38
|
+
Request,
|
|
39
|
+
ServerError,
|
|
40
|
+
SessionInfo,
|
|
41
|
+
Stream,
|
|
42
|
+
pack,
|
|
43
|
+
pack_frame,
|
|
44
|
+
)
|
|
45
|
+
from millwork.tasks import Cancelled, Task
|
|
46
|
+
from millwork.ui import Button, Dialog, Text, as_options
|
|
47
|
+
from millwork.ui.chat import Chat
|
|
48
|
+
from millwork.ui.files import Download, FileUpload
|
|
49
|
+
from millwork.ui.form import Form
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from millwork.app import App, PageDef
|
|
53
|
+
|
|
54
|
+
log = logging.getLogger("millwork.session")
|
|
55
|
+
|
|
56
|
+
Send = Callable[[str], Awaitable[None]]
|
|
57
|
+
SendBytes = Callable[[bytes], Awaitable[None]]
|
|
58
|
+
Close = Callable[[], Awaitable[None]]
|
|
59
|
+
|
|
60
|
+
RENDER_COALESCE_SECONDS = 0.05
|
|
61
|
+
# The replay window: what a tab that drops and comes back is caught up with.
|
|
62
|
+
# Bounded in messages and in bytes, since one message can carry a whole chat;
|
|
63
|
+
# a tab that fell behind the window is mounted afresh instead.
|
|
64
|
+
OUTBOX_KEEP = 500
|
|
65
|
+
OUTBOX_KEEP_BYTES = 4 * 1024 * 1024
|
|
66
|
+
# Messages from the tab still being handled; further ones are dropped until
|
|
67
|
+
# the backlog clears, so a flood cannot grow the task set without bound.
|
|
68
|
+
MAX_INFLIGHT = 64
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Session[S]:
|
|
72
|
+
def __init__(self, app: App, page: PageDef, params: dict[str, str], path: str) -> None:
|
|
73
|
+
self.id = secrets.token_urlsafe(16)
|
|
74
|
+
self.resume_token = secrets.token_urlsafe(24)
|
|
75
|
+
self.app = app
|
|
76
|
+
self.page = page
|
|
77
|
+
self.params = params
|
|
78
|
+
self.path = path
|
|
79
|
+
self.user: Any = None
|
|
80
|
+
# The path prefix the app runs under, as the tab's socket arrived under it,
|
|
81
|
+
# for anything the session renders into a browser URL.
|
|
82
|
+
self.base = ""
|
|
83
|
+
# Who opened it, for the per-client cap: the user's id, or the address.
|
|
84
|
+
self.client: str | None = None
|
|
85
|
+
self.created = time.monotonic()
|
|
86
|
+
# When its tab last went away, for the caps: the session longest without
|
|
87
|
+
# a tab makes room first. A session being connected or caught up still
|
|
88
|
+
# has its tab, even though nothing can be sent to it yet.
|
|
89
|
+
self.detached_at = self.created
|
|
90
|
+
self.connecting = False
|
|
91
|
+
self.end_reason: str | None = None
|
|
92
|
+
self.state: S = page.make_state()
|
|
93
|
+
self.tree: Tree | None = None
|
|
94
|
+
self.closed = False
|
|
95
|
+
self.files = SessionFiles(self)
|
|
96
|
+
|
|
97
|
+
self._send: Send | None = None
|
|
98
|
+
self._close_socket: Close | None = None
|
|
99
|
+
self._seq = 0
|
|
100
|
+
self._outbox: deque[tuple[int, str]] = deque()
|
|
101
|
+
self._outbox_bytes = 0
|
|
102
|
+
self._render_scheduled = False
|
|
103
|
+
self._render_handle: asyncio.TimerHandle | None = None
|
|
104
|
+
self._lock = asyncio.Lock()
|
|
105
|
+
self._lock_holder: asyncio.Task[Any] | None = None
|
|
106
|
+
self._grace: asyncio.TimerHandle | None = None
|
|
107
|
+
self._loop = asyncio.get_running_loop()
|
|
108
|
+
self.tasks: list[Task[Any]] = []
|
|
109
|
+
self._asks: dict[str, _Ask] = {}
|
|
110
|
+
self._toast_actions: dict[str, Callable[..., Any]] = {}
|
|
111
|
+
# Requests in flight by id, and the byte streams uploads feed into.
|
|
112
|
+
self._requests: dict[str, asyncio.Task[Any]] = {}
|
|
113
|
+
self._inbound: dict[str, _Inbound] = {}
|
|
114
|
+
# The handler request in flight per node and name: a newer one cancels it.
|
|
115
|
+
self._queries: dict[tuple[str, str], asyncio.Task[Any]] = {}
|
|
116
|
+
self._send_bytes: SendBytes | None = None
|
|
117
|
+
# The socket being attached, before it has caught up: unnumbered messages
|
|
118
|
+
# (a request's head or reply) may go to it straight away, since they take
|
|
119
|
+
# no part in the replay order.
|
|
120
|
+
self._catching_up: Send | None = None
|
|
121
|
+
self._file_slots = asyncio.Semaphore(FILE_SLOTS)
|
|
122
|
+
self._busy = False
|
|
123
|
+
# Development warnings: each is said once per session; counters tell a
|
|
124
|
+
# handler that changed nothing, and a thread that changed state, apart.
|
|
125
|
+
self._warned: set[str] = set()
|
|
126
|
+
self._slow_builds = 0
|
|
127
|
+
self._effects = 0
|
|
128
|
+
self._handler_runs = 0
|
|
129
|
+
self._noop_watch: tuple[str, int] | None = None
|
|
130
|
+
self._spawned: set[asyncio.Task[Any]] = set()
|
|
131
|
+
self._threads: dict[str, Thread] = {}
|
|
132
|
+
|
|
133
|
+
# ------------------------------------------------------------- connection
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def connected(self) -> bool:
|
|
137
|
+
return self._send is not None
|
|
138
|
+
|
|
139
|
+
def text(self, key: str, **vars: Any) -> str:
|
|
140
|
+
"""A framework message in the theme's words, for toasts and errors."""
|
|
141
|
+
return self.app.theme.text(key, **vars)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def has_tab(self) -> bool:
|
|
145
|
+
return self.connected or self.connecting or self._catching_up is not None
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def inflight(self) -> int:
|
|
149
|
+
"""Messages from the tab whose handling has not finished."""
|
|
150
|
+
return sum(1 for t in self._spawned if not t.done())
|
|
151
|
+
|
|
152
|
+
def owns_resume(self, token: str | None) -> bool:
|
|
153
|
+
return token is not None and hmac.compare_digest(self.resume_token, token)
|
|
154
|
+
|
|
155
|
+
async def attach(
|
|
156
|
+
self,
|
|
157
|
+
send: Send,
|
|
158
|
+
last_seq: int,
|
|
159
|
+
close: Close | None = None,
|
|
160
|
+
send_bytes: SendBytes | None = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""A tab connected (or reconnected). Replay what it missed, or mount.
|
|
163
|
+
|
|
164
|
+
A second attachment replaces the first: the earlier socket is closed
|
|
165
|
+
so a duplicated tab cannot keep dispatching into the session.
|
|
166
|
+
"""
|
|
167
|
+
if self.closed:
|
|
168
|
+
return
|
|
169
|
+
if self._grace is not None:
|
|
170
|
+
self._grace.cancel()
|
|
171
|
+
self._grace = None
|
|
172
|
+
previous = self._close_socket
|
|
173
|
+
# Nothing goes to either socket while the switch is made. The new one is
|
|
174
|
+
# published only once it has caught up, so a message sent meanwhile lands
|
|
175
|
+
# in the outbox and is replayed in order instead of racing the replay.
|
|
176
|
+
self._send = None
|
|
177
|
+
self._close_socket = None
|
|
178
|
+
self._send_bytes = send_bytes
|
|
179
|
+
self._catching_up = send
|
|
180
|
+
self._abort_requests("the socket changed")
|
|
181
|
+
if previous is not None:
|
|
182
|
+
try:
|
|
183
|
+
await previous()
|
|
184
|
+
except Exception:
|
|
185
|
+
log.debug("closing the previous socket of %s failed", self.id)
|
|
186
|
+
try:
|
|
187
|
+
info = SessionInfo(
|
|
188
|
+
id=self.id, resume=self.resume_token, components=self.app.component_versions
|
|
189
|
+
)
|
|
190
|
+
await send(pack(self._seq, info))
|
|
191
|
+
if self.tree is not None and self._can_replay(last_seq):
|
|
192
|
+
caught_up = last_seq
|
|
193
|
+
while True:
|
|
194
|
+
pending = [(seq, text) for seq, text in self._outbox if seq > caught_up]
|
|
195
|
+
if not pending:
|
|
196
|
+
break
|
|
197
|
+
for seq, text in pending:
|
|
198
|
+
await send(text)
|
|
199
|
+
caught_up = seq
|
|
200
|
+
self._send, self._close_socket = send, close
|
|
201
|
+
self._catching_up = None
|
|
202
|
+
return
|
|
203
|
+
except Exception:
|
|
204
|
+
log.debug("the socket of %s failed before it caught up; detached", self.id)
|
|
205
|
+
self._catching_up = None
|
|
206
|
+
self.detach()
|
|
207
|
+
return
|
|
208
|
+
self._send, self._close_socket = send, close
|
|
209
|
+
self._catching_up = None
|
|
210
|
+
await self._mount()
|
|
211
|
+
|
|
212
|
+
def _can_replay(self, last_seq: int) -> bool:
|
|
213
|
+
if last_seq <= 0 or not self._outbox:
|
|
214
|
+
return False
|
|
215
|
+
first_seq = self._outbox[0][0]
|
|
216
|
+
return first_seq <= last_seq + 1 <= self._seq + 1
|
|
217
|
+
|
|
218
|
+
def detach(self, send: Send | None = None) -> None:
|
|
219
|
+
"""A socket went away. Only the current one detaches the session."""
|
|
220
|
+
if send is not None and self._send is not send:
|
|
221
|
+
return
|
|
222
|
+
self._send = None
|
|
223
|
+
self._close_socket = None
|
|
224
|
+
self._send_bytes = None
|
|
225
|
+
self._catching_up = None
|
|
226
|
+
self.connecting = False
|
|
227
|
+
self.detached_at = time.monotonic()
|
|
228
|
+
self._abort_requests("the socket went away")
|
|
229
|
+
if self.closed:
|
|
230
|
+
return
|
|
231
|
+
self._grace = self._loop.call_later(self.app.session_grace, self._grace_expired)
|
|
232
|
+
|
|
233
|
+
def _grace_expired(self) -> None:
|
|
234
|
+
self._grace = None
|
|
235
|
+
if not self.connected and not self.closed:
|
|
236
|
+
self._loop.create_task(self.close())
|
|
237
|
+
|
|
238
|
+
async def end(self, message: str) -> None:
|
|
239
|
+
"""Close the session and tell its tab why, so the tab stops instead of
|
|
240
|
+
reconnecting: signed out elsewhere, or made room for a newer session."""
|
|
241
|
+
if not self.closed:
|
|
242
|
+
self.end_reason = message
|
|
243
|
+
with contextlib.suppress(Exception):
|
|
244
|
+
await self._send_now(ServerError(message=message, fatal=True))
|
|
245
|
+
close = self._close_socket
|
|
246
|
+
if close is not None:
|
|
247
|
+
with contextlib.suppress(Exception):
|
|
248
|
+
await close()
|
|
249
|
+
await self.close()
|
|
250
|
+
|
|
251
|
+
async def close(self) -> None:
|
|
252
|
+
if self.closed:
|
|
253
|
+
return
|
|
254
|
+
self.closed = True
|
|
255
|
+
if self._render_handle is not None:
|
|
256
|
+
self._render_handle.cancel()
|
|
257
|
+
if self._grace is not None:
|
|
258
|
+
self._grace.cancel()
|
|
259
|
+
self._grace = None
|
|
260
|
+
self.app.sessions.pop(self.id, None)
|
|
261
|
+
self._abort_requests("the session closed")
|
|
262
|
+
for task in list(self.tasks):
|
|
263
|
+
task.cancel()
|
|
264
|
+
for spawned in list(self._spawned):
|
|
265
|
+
spawned.cancel()
|
|
266
|
+
for ask in list(self._asks.values()):
|
|
267
|
+
if not ask.future.done():
|
|
268
|
+
ask.future.cancel()
|
|
269
|
+
if self.page.on_close is not None:
|
|
270
|
+
try:
|
|
271
|
+
await _call(self.page.on_close, self)
|
|
272
|
+
except Exception:
|
|
273
|
+
log.exception("close hook failed for session %s", self.id)
|
|
274
|
+
self.app.files.discard(self.id)
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------------- sending
|
|
277
|
+
|
|
278
|
+
async def _send_now(self, msg: Any) -> None:
|
|
279
|
+
"""Number, remember for replay, and send if a tab is attached."""
|
|
280
|
+
self._effects += 1
|
|
281
|
+
self._seq += 1
|
|
282
|
+
text = pack(self._seq, msg)
|
|
283
|
+
self._outbox.append((self._seq, text))
|
|
284
|
+
self._outbox_bytes += len(text)
|
|
285
|
+
while len(self._outbox) > OUTBOX_KEEP or (
|
|
286
|
+
self._outbox_bytes > OUTBOX_KEEP_BYTES and len(self._outbox) > 1
|
|
287
|
+
):
|
|
288
|
+
self._outbox_bytes -= len(self._outbox.popleft()[1])
|
|
289
|
+
await self._deliver(text)
|
|
290
|
+
|
|
291
|
+
async def _send_direct(self, msg: Any) -> None:
|
|
292
|
+
"""Send without entering the replay window (the session info on attach)."""
|
|
293
|
+
await self._deliver(pack(self._seq, msg))
|
|
294
|
+
|
|
295
|
+
async def _send_unnumbered(self, msg: Any) -> None:
|
|
296
|
+
"""A message outside the sequence: never replayed, never deduplicated, and
|
|
297
|
+
sent even to a socket that is still catching up on the replay."""
|
|
298
|
+
send = self._send or self._catching_up
|
|
299
|
+
if send is None:
|
|
300
|
+
return
|
|
301
|
+
try:
|
|
302
|
+
await send(pack(0, msg))
|
|
303
|
+
except Exception:
|
|
304
|
+
log.debug("unnumbered send failed for session %s", self.id)
|
|
305
|
+
|
|
306
|
+
async def _deliver(self, text: str) -> None:
|
|
307
|
+
if self._send is None:
|
|
308
|
+
return
|
|
309
|
+
try:
|
|
310
|
+
await self._send(text)
|
|
311
|
+
except Exception:
|
|
312
|
+
# The socket is dead whatever the endpoint later learns: detach now
|
|
313
|
+
# so the grace timer runs and the session cannot leak.
|
|
314
|
+
log.debug("send failed for session %s; detached", self.id)
|
|
315
|
+
self.detach(self._send)
|
|
316
|
+
|
|
317
|
+
def command(self, name: str, **args: Any) -> None:
|
|
318
|
+
"""Fire-and-forget instruction to the tab: toast, navigate, clipboard."""
|
|
319
|
+
self._loop.create_task(self._send_now(Command(name=name, args=args)))
|
|
320
|
+
|
|
321
|
+
def toast(
|
|
322
|
+
self,
|
|
323
|
+
message: str,
|
|
324
|
+
tone: str = "default",
|
|
325
|
+
title: str | None = None,
|
|
326
|
+
action: tuple[str, Callable[..., Any]] | None = None,
|
|
327
|
+
) -> None:
|
|
328
|
+
"""A toast; ``action=(label, handler)`` adds a button that runs the handler once."""
|
|
329
|
+
args: dict[str, Any] = {"message": message, "tone": tone, "title": title}
|
|
330
|
+
if action is not None:
|
|
331
|
+
label, handler = action
|
|
332
|
+
token = secrets.token_urlsafe(8)
|
|
333
|
+
self._toast_actions[token] = handler
|
|
334
|
+
while len(self._toast_actions) > 20:
|
|
335
|
+
self._toast_actions.pop(next(iter(self._toast_actions)))
|
|
336
|
+
args["action"] = {"id": token, "label": label}
|
|
337
|
+
self.command("toast", **args)
|
|
338
|
+
|
|
339
|
+
def navigate(self, path: str) -> None:
|
|
340
|
+
"""Go to another page on this session: its connect runs, the tab's history updates."""
|
|
341
|
+
self._loop.create_task(self.navigate_to(path))
|
|
342
|
+
|
|
343
|
+
def clipboard(self, text: str) -> None:
|
|
344
|
+
self.command("clipboard", text=text)
|
|
345
|
+
|
|
346
|
+
def logout(self) -> None:
|
|
347
|
+
"""Clear the sign-in cookie and send the tab to the login page."""
|
|
348
|
+
self.command("logout")
|
|
349
|
+
|
|
350
|
+
def reset(self, key: str) -> None:
|
|
351
|
+
self.command("reset", key=key)
|
|
352
|
+
|
|
353
|
+
def download(self, filename: str, content: Any, mime: str | None = None) -> FileRef:
|
|
354
|
+
"""Send a file to the tab without a button: the tab fetches it over the socket."""
|
|
355
|
+
ref = content_to_file(self, content, filename, mime)
|
|
356
|
+
self.command("download", id=ref.id, filename=filename)
|
|
357
|
+
return ref
|
|
358
|
+
|
|
359
|
+
# ------------------------------------------------------------- chat
|
|
360
|
+
|
|
361
|
+
def chat(self, key: str) -> Thread:
|
|
362
|
+
"""The messages of the Chat with this key; created on first use."""
|
|
363
|
+
thread = self._threads.get(key)
|
|
364
|
+
if thread is None:
|
|
365
|
+
thread = self._threads[key] = Thread()
|
|
366
|
+
return thread
|
|
367
|
+
|
|
368
|
+
def drop_chat(self, key: str) -> None:
|
|
369
|
+
"""Forget the thread under this key, unless a reply is running in it: the
|
|
370
|
+
Chat with that key starts empty next time. An app whose threads live in
|
|
371
|
+
a store keeps only the open one in the session this way."""
|
|
372
|
+
thread = self._threads.get(key)
|
|
373
|
+
if thread is not None and not thread.busy:
|
|
374
|
+
del self._threads[key]
|
|
375
|
+
|
|
376
|
+
async def stream(self, node: str, chunk: dict[str, Any]) -> None:
|
|
377
|
+
"""One chunk of the AI SDK UI message stream to a Chat node. Never touches the tree."""
|
|
378
|
+
await self._send_now(Stream(node=node, chunk=chunk))
|
|
379
|
+
|
|
380
|
+
async def _chat_message(self, event: Event, component: Chat) -> None:
|
|
381
|
+
"""A user turn: record the message, run the handler as this thread's turn."""
|
|
382
|
+
if component.key is None:
|
|
383
|
+
log.warning("a Chat needs a key to keep its messages")
|
|
384
|
+
return
|
|
385
|
+
thread = self.chat(component.key)
|
|
386
|
+
thread.limit = component.max_messages
|
|
387
|
+
if thread.busy:
|
|
388
|
+
self.toast(self.text("wait_for_reply"), tone="warning")
|
|
389
|
+
return
|
|
390
|
+
payload = event.payload if isinstance(event.payload, dict) else {}
|
|
391
|
+
text = str(payload.get("text") or "").strip()
|
|
392
|
+
if len(text) > component.max_chars:
|
|
393
|
+
self.toast(self.text("message_too_long"), tone="warning")
|
|
394
|
+
return
|
|
395
|
+
ids = [d.get("id") for d in payload.get("files") or [] if isinstance(d, dict)]
|
|
396
|
+
files = [
|
|
397
|
+
ref
|
|
398
|
+
for i in ids
|
|
399
|
+
if isinstance(i, str)
|
|
400
|
+
and (ref := self.files.get(i)) is not None
|
|
401
|
+
and ref.node == event.node
|
|
402
|
+
]
|
|
403
|
+
if component.attachments is not None:
|
|
404
|
+
files = files[: component.attachments.max_files]
|
|
405
|
+
command = payload.get("command")
|
|
406
|
+
if not text and not files:
|
|
407
|
+
return
|
|
408
|
+
parts: list[dict[str, Any]] = []
|
|
409
|
+
if text:
|
|
410
|
+
parts.append({"type": "text", "text": text})
|
|
411
|
+
for ref in files:
|
|
412
|
+
parts.append(
|
|
413
|
+
{
|
|
414
|
+
"type": "file",
|
|
415
|
+
"mediaType": ref.mime,
|
|
416
|
+
"url": "",
|
|
417
|
+
"filename": ref.name,
|
|
418
|
+
"fileId": ref.id,
|
|
419
|
+
}
|
|
420
|
+
)
|
|
421
|
+
metadata: dict[str, Any] = {"at": time.time()}
|
|
422
|
+
if isinstance(command, str):
|
|
423
|
+
metadata["command"] = command
|
|
424
|
+
message = UIMessage(
|
|
425
|
+
id=secrets.token_urlsafe(8), role="user", parts=parts, metadata=metadata
|
|
426
|
+
)
|
|
427
|
+
thread.append(message)
|
|
428
|
+
self._chat_run(event.node, thread, message)
|
|
429
|
+
|
|
430
|
+
def _chat_run(self, node_id: str, thread: Thread, message: UIMessage) -> None:
|
|
431
|
+
"""Run ``on_message`` for a user message the thread holds: a new one, or one
|
|
432
|
+
sent again after an edit or a regenerate."""
|
|
433
|
+
files = [
|
|
434
|
+
ref
|
|
435
|
+
for p in message.parts
|
|
436
|
+
if p.get("type") == "file"
|
|
437
|
+
and isinstance(p.get("fileId"), str)
|
|
438
|
+
and (ref := self.files.get(p["fileId"])) is not None
|
|
439
|
+
]
|
|
440
|
+
command = (message.metadata or {}).get("command")
|
|
441
|
+
turn = ChatTurn(
|
|
442
|
+
self, node_id, thread, message, files, command if isinstance(command, str) else None
|
|
443
|
+
)
|
|
444
|
+
handler = self.tree.handler(node_id, "message") if self.tree else None
|
|
445
|
+
if handler is None:
|
|
446
|
+
self.refresh()
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
async def run() -> None:
|
|
450
|
+
# The lock is not held while a reply streams: tool steps, tasks and
|
|
451
|
+
# inline components render meanwhile; the composer is disabled by `busy`.
|
|
452
|
+
token = context.bind(self)
|
|
453
|
+
try:
|
|
454
|
+
await _call(handler, self, turn)
|
|
455
|
+
except asyncio.CancelledError:
|
|
456
|
+
pass
|
|
457
|
+
except Exception:
|
|
458
|
+
log.exception("chat handler failed")
|
|
459
|
+
self.toast(self.text("reply_failed"), tone="destructive")
|
|
460
|
+
finally:
|
|
461
|
+
context.unbind(token)
|
|
462
|
+
thread.turn_task = None
|
|
463
|
+
if thread.inflight is not None:
|
|
464
|
+
thread.append(thread.inflight)
|
|
465
|
+
thread.inflight = None
|
|
466
|
+
self.refresh()
|
|
467
|
+
|
|
468
|
+
self.refresh()
|
|
469
|
+
thread.turn_task = self.spawn(run(), "chat turn")
|
|
470
|
+
|
|
471
|
+
async def _chat_stop(self, event: Event, component: Chat) -> None:
|
|
472
|
+
thread = self.chat(component.key) if component.key else None
|
|
473
|
+
if thread is not None and thread.turn_task is not None:
|
|
474
|
+
thread.turn_task.cancel()
|
|
475
|
+
handler = self.tree.handler(event.node, "stop") if self.tree else None
|
|
476
|
+
if handler is not None:
|
|
477
|
+
await self._run_handler(handler, self, None)
|
|
478
|
+
|
|
479
|
+
def _chat_target(
|
|
480
|
+
self, event: Event, component: Chat, role: str
|
|
481
|
+
) -> tuple[Thread, int, dict[str, Any]] | None:
|
|
482
|
+
"""The thread and the index of the message an action names, when it is
|
|
483
|
+
one of this chat's with the expected role and the thread is free."""
|
|
484
|
+
if component.key is None:
|
|
485
|
+
return None
|
|
486
|
+
thread = self.chat(component.key)
|
|
487
|
+
payload = event.payload if isinstance(event.payload, dict) else {}
|
|
488
|
+
message_id = payload.get("id")
|
|
489
|
+
index = thread.find(message_id) if isinstance(message_id, str) else None
|
|
490
|
+
if index is None or thread.messages[index].role != role:
|
|
491
|
+
return None
|
|
492
|
+
return thread, index, payload
|
|
493
|
+
|
|
494
|
+
async def _chat_feedback(self, event: Event, component: Chat) -> None:
|
|
495
|
+
"""Thumbs up or down on an assistant message, with a comment; kept in the
|
|
496
|
+
message's metadata so it shows after a reload, and given to ``on_feedback``."""
|
|
497
|
+
target = self._chat_target(event, component, "assistant")
|
|
498
|
+
if target is None:
|
|
499
|
+
return
|
|
500
|
+
thread, index, payload = target
|
|
501
|
+
value = payload.get("value")
|
|
502
|
+
if value not in ("up", "down", None):
|
|
503
|
+
return
|
|
504
|
+
comment = str(payload.get("comment") or "").strip()[:2000] or None
|
|
505
|
+
message = thread.messages[index]
|
|
506
|
+
metadata = dict(message.metadata or {})
|
|
507
|
+
if value is None:
|
|
508
|
+
metadata.pop("feedback", None)
|
|
509
|
+
else:
|
|
510
|
+
metadata["feedback"] = {"value": value, "comment": comment}
|
|
511
|
+
message.metadata = metadata or None
|
|
512
|
+
handler = self.tree.handler(event.node, "feedback") if self.tree else None
|
|
513
|
+
if handler is not None:
|
|
514
|
+
await self._run_handler(handler, self, message, value, comment)
|
|
515
|
+
else:
|
|
516
|
+
self.refresh()
|
|
517
|
+
|
|
518
|
+
async def _chat_action(self, event: Event, component: Chat) -> None:
|
|
519
|
+
target = self._chat_target(event, component, "assistant")
|
|
520
|
+
if target is None:
|
|
521
|
+
return
|
|
522
|
+
thread, index, payload = target
|
|
523
|
+
action = payload.get("action")
|
|
524
|
+
if not any(a.id == action for a in component.actions):
|
|
525
|
+
return
|
|
526
|
+
handler = self.tree.handler(event.node, "action") if self.tree else None
|
|
527
|
+
if handler is not None:
|
|
528
|
+
await self._run_handler(handler, self, thread.messages[index], action)
|
|
529
|
+
|
|
530
|
+
async def _chat_regenerate(self, event: Event, component: Chat) -> None:
|
|
531
|
+
"""Answer a user message again: the assistant message named and everything
|
|
532
|
+
after it go, and ``on_message`` runs for the user message before it."""
|
|
533
|
+
target = self._chat_target(event, component, "assistant")
|
|
534
|
+
if target is None or not component.regenerate:
|
|
535
|
+
return
|
|
536
|
+
thread, index, _ = target
|
|
537
|
+
if thread.busy:
|
|
538
|
+
self.toast(self.text("wait_for_reply"), tone="warning")
|
|
539
|
+
return
|
|
540
|
+
user = next(
|
|
541
|
+
(i for i in range(index - 1, -1, -1) if thread.messages[i].role == "user"), None
|
|
542
|
+
)
|
|
543
|
+
if user is None:
|
|
544
|
+
return
|
|
545
|
+
thread.truncate(index)
|
|
546
|
+
self._chat_run(event.node, thread, thread.messages[user])
|
|
547
|
+
|
|
548
|
+
async def _chat_edit(self, event: Event, component: Chat) -> None:
|
|
549
|
+
"""A user message with new text: what followed it goes, and it is answered again."""
|
|
550
|
+
target = self._chat_target(event, component, "user")
|
|
551
|
+
if target is None or not component.edit:
|
|
552
|
+
return
|
|
553
|
+
thread, index, payload = target
|
|
554
|
+
if thread.busy:
|
|
555
|
+
self.toast(self.text("wait_for_reply"), tone="warning")
|
|
556
|
+
return
|
|
557
|
+
text = str(payload.get("text") or "").strip()
|
|
558
|
+
if len(text) > component.max_chars:
|
|
559
|
+
self.toast(self.text("message_too_long"), tone="warning")
|
|
560
|
+
return
|
|
561
|
+
message = thread.messages[index]
|
|
562
|
+
files = [p for p in message.parts if p.get("type") == "file"]
|
|
563
|
+
if not text and not files:
|
|
564
|
+
return
|
|
565
|
+
message.parts = ([{"type": "text", "text": text}] if text else []) + files
|
|
566
|
+
thread.truncate(index + 1)
|
|
567
|
+
self._chat_run(event.node, thread, message)
|
|
568
|
+
|
|
569
|
+
def spawn(self, coro: Any, what: str = "work") -> asyncio.Task[Any]:
|
|
570
|
+
"""Run a coroutine for this session: tracked, cancelled on close, failures logged."""
|
|
571
|
+
task = self._loop.create_task(coro)
|
|
572
|
+
self._spawned.add(task)
|
|
573
|
+
|
|
574
|
+
def done(t: asyncio.Task[Any]) -> None:
|
|
575
|
+
self._spawned.discard(t)
|
|
576
|
+
if t.cancelled():
|
|
577
|
+
return
|
|
578
|
+
exc = t.exception()
|
|
579
|
+
if exc is not None:
|
|
580
|
+
log.error("%s failed for session %s", what, self.id, exc_info=exc)
|
|
581
|
+
|
|
582
|
+
task.add_done_callback(done)
|
|
583
|
+
return task
|
|
584
|
+
|
|
585
|
+
# ------------------------------------------------------------- tasks
|
|
586
|
+
|
|
587
|
+
def task(
|
|
588
|
+
self,
|
|
589
|
+
work: Any,
|
|
590
|
+
*args: Any,
|
|
591
|
+
label: str | None = None,
|
|
592
|
+
key: str | None = None,
|
|
593
|
+
replace: bool = False,
|
|
594
|
+
delay: float | None = None,
|
|
595
|
+
timeout: float | None = None,
|
|
596
|
+
on_done: Callable[..., Any] | None = None,
|
|
597
|
+
quiet: bool = False,
|
|
598
|
+
**kwargs: Any,
|
|
599
|
+
) -> Task[Any]:
|
|
600
|
+
"""Start work the session owns."""
|
|
601
|
+
self._effects += 1
|
|
602
|
+
if key is not None:
|
|
603
|
+
for existing in self.tasks:
|
|
604
|
+
if existing.key == key and not existing.done:
|
|
605
|
+
if not replace:
|
|
606
|
+
return existing
|
|
607
|
+
existing.cancel()
|
|
608
|
+
name = label or getattr(work, "__name__", None) or "task"
|
|
609
|
+
task: Task[Any] = Task(self, name, key, quiet)
|
|
610
|
+
task._on_done = on_done
|
|
611
|
+
if on_done is not None:
|
|
612
|
+
task._observed = True
|
|
613
|
+
self.tasks.append(task)
|
|
614
|
+
if delay:
|
|
615
|
+
task._delay_handle = self._loop.call_later(
|
|
616
|
+
delay, lambda: task._start(work, args, kwargs, timeout)
|
|
617
|
+
)
|
|
618
|
+
self.refresh()
|
|
619
|
+
else:
|
|
620
|
+
task._start(work, args, kwargs, timeout)
|
|
621
|
+
return task
|
|
622
|
+
|
|
623
|
+
def later(self, delay: float, work: Any, *args: Any, **kwargs: Any) -> Task[Any]:
|
|
624
|
+
return self.task(work, *args, delay=delay, **kwargs)
|
|
625
|
+
|
|
626
|
+
def thread(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Task[Any]:
|
|
627
|
+
return self.task(fn, *args, **kwargs)
|
|
628
|
+
|
|
629
|
+
def _task_finished(self, task: Task[Any]) -> None:
|
|
630
|
+
"""A task reached a terminal status: run ``on_done`` as a handler, then render."""
|
|
631
|
+
on_done = getattr(task, "_on_done", None)
|
|
632
|
+
if on_done is not None:
|
|
633
|
+
self._loop.create_task(self._run_handler(on_done, self, task))
|
|
634
|
+
if len(self.tasks) > 50:
|
|
635
|
+
self.tasks = [t for t in self.tasks if not t.done][-50:] + [
|
|
636
|
+
t for t in self.tasks if t.done
|
|
637
|
+
][-10:]
|
|
638
|
+
self.refresh()
|
|
639
|
+
|
|
640
|
+
async def _run_handler(self, fn: Callable[..., Any], *args: Any) -> None:
|
|
641
|
+
async with self._hold():
|
|
642
|
+
token = context.bind(self)
|
|
643
|
+
self._handler_runs += 1
|
|
644
|
+
try:
|
|
645
|
+
await _call(fn, *args)
|
|
646
|
+
except Exception:
|
|
647
|
+
log.exception("handler %s failed", getattr(fn, "__name__", fn))
|
|
648
|
+
self.toast(self.text("something_went_wrong"), tone="destructive")
|
|
649
|
+
finally:
|
|
650
|
+
context.unbind(token)
|
|
651
|
+
self.refresh()
|
|
652
|
+
|
|
653
|
+
@contextlib.asynccontextmanager
|
|
654
|
+
async def _hold(self) -> AsyncIterator[None]:
|
|
655
|
+
"""The one-handler-at-a-time lock, remembering who holds it so ``ask`` can let go."""
|
|
656
|
+
await self._lock.acquire()
|
|
657
|
+
self._lock_holder = asyncio.current_task()
|
|
658
|
+
try:
|
|
659
|
+
yield
|
|
660
|
+
finally:
|
|
661
|
+
self._lock_holder = None
|
|
662
|
+
self._lock.release()
|
|
663
|
+
|
|
664
|
+
# ------------------------------------------------------------- ask
|
|
665
|
+
|
|
666
|
+
async def ask( # noqa: ASYNC109
|
|
667
|
+
self, component: Component, timeout: float | None = None
|
|
668
|
+
) -> Any:
|
|
669
|
+
"""Render a component, wait for its submit or close, return the answer or None.
|
|
670
|
+
|
|
671
|
+
The handler lock is released while waiting, so other handlers run.
|
|
672
|
+
A ``Form`` inside answers with its validated model; a ``Dialog``'s
|
|
673
|
+
close, a cancel, or a timeout answers ``None``. The session closing
|
|
674
|
+
cancels the handler itself, as it does any other work.
|
|
675
|
+
"""
|
|
676
|
+
request = secrets.token_urlsafe(8)
|
|
677
|
+
component = _askable(component)
|
|
678
|
+
ask = _Ask(request=request, component=component, future=self._loop.create_future())
|
|
679
|
+
self._asks[request] = ask
|
|
680
|
+
self.refresh()
|
|
681
|
+
try:
|
|
682
|
+
async with self._unlocked():
|
|
683
|
+
if timeout:
|
|
684
|
+
return await asyncio.wait_for(ask.future, timeout)
|
|
685
|
+
return await ask.future
|
|
686
|
+
except TimeoutError:
|
|
687
|
+
return None
|
|
688
|
+
finally:
|
|
689
|
+
self._asks.pop(request, None)
|
|
690
|
+
self.refresh()
|
|
691
|
+
|
|
692
|
+
@contextlib.asynccontextmanager
|
|
693
|
+
async def _unlocked(self) -> AsyncIterator[None]:
|
|
694
|
+
"""Let go of the handler lock while waiting on the user or a task, then take it back."""
|
|
695
|
+
held = self._lock_holder is asyncio.current_task() and self._lock.locked()
|
|
696
|
+
if held:
|
|
697
|
+
self._lock_holder = None
|
|
698
|
+
self._lock.release()
|
|
699
|
+
try:
|
|
700
|
+
yield
|
|
701
|
+
finally:
|
|
702
|
+
if held:
|
|
703
|
+
await self._lock.acquire()
|
|
704
|
+
self._lock_holder = asyncio.current_task()
|
|
705
|
+
|
|
706
|
+
async def wait_for_task(self, task: Task[Any]) -> Any:
|
|
707
|
+
"""``await task`` from a handler: renders keep flowing while it runs.
|
|
708
|
+
|
|
709
|
+
A cancelled task raises ``millwork.Cancelled`` (an ordinary exception the
|
|
710
|
+
handler can catch), never asyncio's cancellation of the handler itself.
|
|
711
|
+
"""
|
|
712
|
+
async with self._unlocked():
|
|
713
|
+
try:
|
|
714
|
+
return await asyncio.shield(task.future)
|
|
715
|
+
except asyncio.CancelledError:
|
|
716
|
+
if task.status == "cancelled":
|
|
717
|
+
raise Cancelled(task.label) from None
|
|
718
|
+
raise
|
|
719
|
+
|
|
720
|
+
async def confirm(
|
|
721
|
+
self,
|
|
722
|
+
title: str,
|
|
723
|
+
description: str | None = None,
|
|
724
|
+
ok: str = "OK",
|
|
725
|
+
cancel: str = "Cancel",
|
|
726
|
+
destructive: bool = False,
|
|
727
|
+
) -> bool:
|
|
728
|
+
buttons = _ConfirmButtons(
|
|
729
|
+
children=[
|
|
730
|
+
Button(cancel, key="cancel", variant="outline", on_click=_noop),
|
|
731
|
+
Button(
|
|
732
|
+
ok,
|
|
733
|
+
key="ok",
|
|
734
|
+
variant="destructive" if destructive else "primary",
|
|
735
|
+
on_click=_noop,
|
|
736
|
+
),
|
|
737
|
+
]
|
|
738
|
+
)
|
|
739
|
+
dialog = Dialog(
|
|
740
|
+
children=[Text(description)] if description else [],
|
|
741
|
+
title=title,
|
|
742
|
+
size="sm",
|
|
743
|
+
footer=buttons,
|
|
744
|
+
)
|
|
745
|
+
answer = await self.ask(dialog)
|
|
746
|
+
return answer is True
|
|
747
|
+
|
|
748
|
+
def _ask_root(self, request: str) -> str:
|
|
749
|
+
return f"{self.tree.root if self.tree else 'root'}/@ask:{request}"
|
|
750
|
+
|
|
751
|
+
async def _answer_ask(self, event: Event, component: Component | None) -> bool:
|
|
752
|
+
"""An event inside an open ask: resolve it, or let it through to its own handler."""
|
|
753
|
+
for request, ask in self._asks.items():
|
|
754
|
+
prefix = self._ask_root(request)
|
|
755
|
+
if event.node != prefix and not event.node.startswith(prefix + "/"):
|
|
756
|
+
continue
|
|
757
|
+
if ask.future.done():
|
|
758
|
+
return True
|
|
759
|
+
if event.name == "submit" and isinstance(component, Form):
|
|
760
|
+
draft = resolve_files(event.payload, self._form_file(event.node))
|
|
761
|
+
instance, issues = validate(component.model, draft)
|
|
762
|
+
if issues:
|
|
763
|
+
self.command(
|
|
764
|
+
"form_issues", node=event.node, issues=[i.model_dump() for i in issues]
|
|
765
|
+
)
|
|
766
|
+
return True
|
|
767
|
+
ask.future.set_result(instance)
|
|
768
|
+
return True
|
|
769
|
+
if event.name in ("close", "cancel"):
|
|
770
|
+
ask.future.set_result(None)
|
|
771
|
+
return True
|
|
772
|
+
if event.name == "click" and event.node.endswith("/ok"):
|
|
773
|
+
ask.future.set_result(True)
|
|
774
|
+
return True
|
|
775
|
+
if event.name == "click" and event.node.endswith("/cancel"):
|
|
776
|
+
ask.future.set_result(None)
|
|
777
|
+
return True
|
|
778
|
+
return False
|
|
779
|
+
return False
|
|
780
|
+
|
|
781
|
+
# ------------------------------------------------------------- navigation
|
|
782
|
+
|
|
783
|
+
async def navigate_to(self, path: str) -> None:
|
|
784
|
+
"""Switch this session to another page. State is per page; files and identity stay."""
|
|
785
|
+
# A fragment names a heading: the tab scrolls to it after the mount.
|
|
786
|
+
path, _, fragment = path.partition("#")
|
|
787
|
+
resolved = self.app.resolve(path) or self.app.fallback("not_found", path)
|
|
788
|
+
page, params = resolved
|
|
789
|
+
if self.app.auth is not None and not page.public and self.user is None:
|
|
790
|
+
await self._send_now(ServerError(message=self.text("sign_in_hint"), fatal=True))
|
|
791
|
+
return
|
|
792
|
+
# Under the handler lock: no render runs against the new page before its
|
|
793
|
+
# connect has finished, and no handler runs across the switch. The switch
|
|
794
|
+
# is provisional until connect succeeds: a page that fails to start leaves
|
|
795
|
+
# the tab on the page it had, still working.
|
|
796
|
+
async with self._hold():
|
|
797
|
+
previous = (self.page, self.params, self.path, self.state, self.tree)
|
|
798
|
+
self.page, self.params, self.path = page, params, path
|
|
799
|
+
self.state = page.make_state()
|
|
800
|
+
self.tree = None
|
|
801
|
+
self._slow_builds = 0
|
|
802
|
+
try:
|
|
803
|
+
await self.connect_or_fall_back()
|
|
804
|
+
except Exception:
|
|
805
|
+
log.exception("connect failed for %s", path)
|
|
806
|
+
self.page, self.params, self.path, self.state, self.tree = previous
|
|
807
|
+
await self._send_now(ServerError(message=self.text("page_failed"), fatal=False))
|
|
808
|
+
return
|
|
809
|
+
for ask in list(self._asks.values()):
|
|
810
|
+
if not ask.future.done():
|
|
811
|
+
ask.future.set_result(None)
|
|
812
|
+
self._asks.clear()
|
|
813
|
+
args: dict[str, Any] = {"path": path, "title": self.page.title}
|
|
814
|
+
if fragment:
|
|
815
|
+
args["hash"] = fragment
|
|
816
|
+
await self._send_now(Command(name="navigate", args=args))
|
|
817
|
+
await self._mount()
|
|
818
|
+
|
|
819
|
+
# ------------------------------------------------------------- rendering
|
|
820
|
+
|
|
821
|
+
def refresh(self) -> None:
|
|
822
|
+
"""Render soon. Coalesced: many calls in one tick are one patch."""
|
|
823
|
+
if self._render_scheduled or self.closed:
|
|
824
|
+
return
|
|
825
|
+
self._render_scheduled = True
|
|
826
|
+
self._render_handle = self._loop.call_later(RENDER_COALESCE_SECONDS, self._flush)
|
|
827
|
+
|
|
828
|
+
def _flush(self) -> None:
|
|
829
|
+
self._render_scheduled = False
|
|
830
|
+
self._render_handle = None
|
|
831
|
+
self._loop.create_task(self._render())
|
|
832
|
+
|
|
833
|
+
async def _mount(self) -> None:
|
|
834
|
+
if self.closed:
|
|
835
|
+
return
|
|
836
|
+
tree = self._build()
|
|
837
|
+
if tree is None:
|
|
838
|
+
return
|
|
839
|
+
self.tree = tree
|
|
840
|
+
await self._send_now(Mount(root=tree.root, nodes=tree.nodes, title=self.page.title))
|
|
841
|
+
|
|
842
|
+
async def _render(self) -> None:
|
|
843
|
+
"""Render under the handler lock, so a page never sees half-mutated state."""
|
|
844
|
+
if self.closed:
|
|
845
|
+
return
|
|
846
|
+
async with self._hold():
|
|
847
|
+
if self.closed:
|
|
848
|
+
return
|
|
849
|
+
if self.tree is None:
|
|
850
|
+
await self._mount()
|
|
851
|
+
return
|
|
852
|
+
tree = self._build()
|
|
853
|
+
if tree is None:
|
|
854
|
+
return
|
|
855
|
+
ops = diff(self.tree, tree)
|
|
856
|
+
self._check_keyless(self.tree, tree)
|
|
857
|
+
self.tree = tree
|
|
858
|
+
if ops:
|
|
859
|
+
await self._send_now(Patch(ops=ops))
|
|
860
|
+
self._signal_activity()
|
|
861
|
+
watch, self._noop_watch = self._noop_watch, None
|
|
862
|
+
if watch is not None and not ops and self._effects == watch[1]:
|
|
863
|
+
self.warn(
|
|
864
|
+
f"noop:{watch[0]}",
|
|
865
|
+
f"{watch[0]} changed nothing: no state, no command, no task. "
|
|
866
|
+
"A forgotten await, or a handler that should not be one?",
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
def _check_keyless(self, old: Tree, new: Tree) -> None:
|
|
870
|
+
"""A run of keyless same-type siblings whose length changed is a data list
|
|
871
|
+
without keys: every row after the change is redrawn instead of moved."""
|
|
872
|
+
for node_id, kind in new.keyless_runs.items():
|
|
873
|
+
before = old.nodes.get(node_id)
|
|
874
|
+
if before is not None and len(before.children) != len(new.nodes[node_id].children):
|
|
875
|
+
self.warn(
|
|
876
|
+
f"keyless:{node_id}",
|
|
877
|
+
f"{node_id} is a list of {kind} rows without keys; give each a key so "
|
|
878
|
+
"a change moves rows instead of redrawing them",
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
def warn(self, key: str, message: str) -> None:
|
|
882
|
+
"""A development warning: one log line per session and key, and in debug mode a
|
|
883
|
+
toast as well, so the author sees it where it happened."""
|
|
884
|
+
if key in self._warned:
|
|
885
|
+
return
|
|
886
|
+
self._warned.add(key)
|
|
887
|
+
log.warning("%s: %s", self.page.path, message)
|
|
888
|
+
if self.app.debug:
|
|
889
|
+
self.command("toast", message=message, tone="warning", title="millwork")
|
|
890
|
+
|
|
891
|
+
def _signal_activity(self) -> None:
|
|
892
|
+
"""Tell the tab when work starts and ends, so it can keep a host that scales
|
|
893
|
+
to zero awake while a task or a reply is running."""
|
|
894
|
+
busy = any(not t.done for t in self.tasks) or any(th.busy for th in self._threads.values())
|
|
895
|
+
if busy != self._busy:
|
|
896
|
+
self._busy = busy
|
|
897
|
+
self.command("activity", busy=busy)
|
|
898
|
+
|
|
899
|
+
def _build(self) -> Tree | None:
|
|
900
|
+
# Bound for the render and the build: wire values (a FileRef, a task)
|
|
901
|
+
# read the current session while the tree is walked.
|
|
902
|
+
token = context.bind(self)
|
|
903
|
+
try:
|
|
904
|
+
started = time.perf_counter()
|
|
905
|
+
root = self.page.render(self)
|
|
906
|
+
took = (time.perf_counter() - started) * 1000
|
|
907
|
+
# Two slow builds in a row: a page that does I/O is slow every time,
|
|
908
|
+
# while one wall-clock hiccup (a collection, a busy process) is not.
|
|
909
|
+
self._slow_builds = self._slow_builds + 1 if took > SLOW_PAGE_MS else 0
|
|
910
|
+
if self._slow_builds >= 2:
|
|
911
|
+
self.warn(
|
|
912
|
+
"slow-page",
|
|
913
|
+
f"the page function took {took:.0f} ms; it should build the tree and "
|
|
914
|
+
"nothing else, so move I/O to connect or a task",
|
|
915
|
+
)
|
|
916
|
+
if not isinstance(root, Component):
|
|
917
|
+
raise TypeError(
|
|
918
|
+
f"page {self.page.path} returned {type(root).__name__}, not a component"
|
|
919
|
+
)
|
|
920
|
+
for request, ask in self._asks.items():
|
|
921
|
+
root.children.append(ask.component.model_copy(update={"key": f"@ask:{request}"}))
|
|
922
|
+
return build(root)
|
|
923
|
+
except Exception:
|
|
924
|
+
log.exception("page %s failed to render", self.page.path)
|
|
925
|
+
self._loop.create_task(
|
|
926
|
+
self._send_now(ServerError(message=self.text("render_failed"), fatal=False))
|
|
927
|
+
)
|
|
928
|
+
return None
|
|
929
|
+
finally:
|
|
930
|
+
context.unbind(token)
|
|
931
|
+
|
|
932
|
+
# ------------------------------------------------------------- events
|
|
933
|
+
|
|
934
|
+
async def _toast_action(self, token: str) -> None:
|
|
935
|
+
"""The button on a toast was pressed: run its handler once, as a handler."""
|
|
936
|
+
handler = self._toast_actions.pop(token, None)
|
|
937
|
+
if handler is None:
|
|
938
|
+
log.debug("toast action %s unknown or already used", token)
|
|
939
|
+
return
|
|
940
|
+
async with self._hold():
|
|
941
|
+
ctx = context.bind(self)
|
|
942
|
+
try:
|
|
943
|
+
await _call(handler, self, None)
|
|
944
|
+
except Exception:
|
|
945
|
+
log.exception("toast action failed")
|
|
946
|
+
self.toast(self.text("something_went_wrong"), tone="destructive")
|
|
947
|
+
finally:
|
|
948
|
+
context.unbind(ctx)
|
|
949
|
+
self.refresh()
|
|
950
|
+
|
|
951
|
+
async def dispatch(self, event: Event) -> None:
|
|
952
|
+
"""Run the handler bound to this event, then render.
|
|
953
|
+
|
|
954
|
+
Everything the tab sends is untrusted: the node must exist in the
|
|
955
|
+
current tree, a disabled component takes no events, an upload refers
|
|
956
|
+
only to files uploaded for that node, a form submit is validated
|
|
957
|
+
before its handler sees it.
|
|
958
|
+
"""
|
|
959
|
+
if self.tree is None or self.closed:
|
|
960
|
+
return
|
|
961
|
+
if event.node.startswith("toast:") and event.name == "action":
|
|
962
|
+
await self._toast_action(event.node[6:])
|
|
963
|
+
return
|
|
964
|
+
component = self.tree.components.get(event.node)
|
|
965
|
+
if component is None:
|
|
966
|
+
log.debug("event %s for unknown node %s", event.name, event.node)
|
|
967
|
+
return
|
|
968
|
+
if self._asks and await self._answer_ask(event, component):
|
|
969
|
+
return
|
|
970
|
+
if getattr(component, "disabled", False):
|
|
971
|
+
log.debug("event %s on disabled node %s dropped", event.name, event.node)
|
|
972
|
+
return
|
|
973
|
+
if (
|
|
974
|
+
isinstance(component, Form)
|
|
975
|
+
and component.read_only
|
|
976
|
+
and event.name in ("submit", "cancel")
|
|
977
|
+
):
|
|
978
|
+
return
|
|
979
|
+
payload = event.payload
|
|
980
|
+
|
|
981
|
+
if isinstance(component, Chat):
|
|
982
|
+
chat_events = {
|
|
983
|
+
"message": self._chat_message,
|
|
984
|
+
"stop": self._chat_stop,
|
|
985
|
+
"feedback": self._chat_feedback,
|
|
986
|
+
"action": self._chat_action,
|
|
987
|
+
"regenerate": self._chat_regenerate,
|
|
988
|
+
"edit": self._chat_edit,
|
|
989
|
+
}
|
|
990
|
+
if event.name in chat_events:
|
|
991
|
+
await chat_events[event.name](event, component)
|
|
992
|
+
return
|
|
993
|
+
|
|
994
|
+
if event.name == "download" and isinstance(component, Download):
|
|
995
|
+
token = context.bind(self)
|
|
996
|
+
try:
|
|
997
|
+
self.download(component.filename, component.content, component.mime)
|
|
998
|
+
except Exception:
|
|
999
|
+
log.exception("download %s failed", component.filename)
|
|
1000
|
+
self.toast(self.text("download_failed"), tone="destructive")
|
|
1001
|
+
finally:
|
|
1002
|
+
context.unbind(token)
|
|
1003
|
+
return
|
|
1004
|
+
|
|
1005
|
+
handler = self.tree.handler(event.node, event.name)
|
|
1006
|
+
if handler is None:
|
|
1007
|
+
log.warning("no handler for %s on %s", event.name, event.node)
|
|
1008
|
+
return
|
|
1009
|
+
|
|
1010
|
+
if event.name == "upload" and isinstance(component, FileUpload):
|
|
1011
|
+
# The tab echoes the upload route's answers; the session's own records are
|
|
1012
|
+
# the truth, and only files uploaded for this node count.
|
|
1013
|
+
ids = [d.get("id") for d in payload or [] if isinstance(d, dict)]
|
|
1014
|
+
payload = [
|
|
1015
|
+
ref
|
|
1016
|
+
for i in ids
|
|
1017
|
+
if isinstance(i, str)
|
|
1018
|
+
and (ref := self.files.get(i)) is not None
|
|
1019
|
+
and ref.node == event.node
|
|
1020
|
+
][: component.max_files if component.multiple else 1]
|
|
1021
|
+
if not payload:
|
|
1022
|
+
return
|
|
1023
|
+
|
|
1024
|
+
if event.name == "submit" and isinstance(component, Form):
|
|
1025
|
+
draft = resolve_files(payload, self._form_file(event.node))
|
|
1026
|
+
instance, issues = validate(component.model, draft)
|
|
1027
|
+
if issues:
|
|
1028
|
+
self.command(
|
|
1029
|
+
"form_issues", node=event.node, issues=[i.model_dump() for i in issues]
|
|
1030
|
+
)
|
|
1031
|
+
return
|
|
1032
|
+
payload = instance
|
|
1033
|
+
|
|
1034
|
+
question = _confirmation(component, event)
|
|
1035
|
+
rejected = False
|
|
1036
|
+
async with self._hold():
|
|
1037
|
+
token = context.bind(self)
|
|
1038
|
+
try:
|
|
1039
|
+
if question is not None:
|
|
1040
|
+
title, destructive = question
|
|
1041
|
+
if not await self.confirm(title, destructive=destructive):
|
|
1042
|
+
return
|
|
1043
|
+
self._handler_runs += 1
|
|
1044
|
+
before = self._effects
|
|
1045
|
+
await _call(handler, self, payload)
|
|
1046
|
+
self._noop_watch = (f"the {event.name} handler on {event.node}", before)
|
|
1047
|
+
except FormIssues as exc:
|
|
1048
|
+
rejected = True
|
|
1049
|
+
self.command(
|
|
1050
|
+
"form_issues", node=event.node, issues=[i.model_dump() for i in exc.issues]
|
|
1051
|
+
)
|
|
1052
|
+
except Exception:
|
|
1053
|
+
log.exception("handler %s on %s failed", event.name, event.node)
|
|
1054
|
+
self.toast(self.text("something_went_wrong"), tone="destructive")
|
|
1055
|
+
finally:
|
|
1056
|
+
context.unbind(token)
|
|
1057
|
+
if event.name == "submit" and isinstance(component, Form) and not rejected:
|
|
1058
|
+
# The empty issue list is the form's acknowledgement: the draft was accepted.
|
|
1059
|
+
self.command("form_issues", node=event.node, issues=[])
|
|
1060
|
+
self.refresh()
|
|
1061
|
+
|
|
1062
|
+
def _form_file(self, node: str) -> Callable[[str], FileRef | None]:
|
|
1063
|
+
"""A lookup for the files a form's draft refers to: uploaded for that form, or none."""
|
|
1064
|
+
|
|
1065
|
+
def lookup(file_id: str) -> FileRef | None:
|
|
1066
|
+
ref = self.files.get(file_id)
|
|
1067
|
+
return ref if ref is not None and ref.node == node else None
|
|
1068
|
+
|
|
1069
|
+
return lookup
|
|
1070
|
+
|
|
1071
|
+
# --------------------------------------------------------- requests
|
|
1072
|
+
|
|
1073
|
+
def start_request(self, msg: Request) -> None:
|
|
1074
|
+
"""Begin answering a ``request``: registered here, synchronously, so that a
|
|
1075
|
+
``cancel`` or binary frames right behind it on the socket find it."""
|
|
1076
|
+
if self.closed:
|
|
1077
|
+
return
|
|
1078
|
+
if msg.request in self._requests:
|
|
1079
|
+
self._send_later(Reply(request=msg.request, error="duplicate request"))
|
|
1080
|
+
return
|
|
1081
|
+
if len(self._requests) >= MAX_REQUESTS:
|
|
1082
|
+
self._send_later(Reply(request=msg.request, error="too many requests"))
|
|
1083
|
+
return
|
|
1084
|
+
if msg.name == "upload":
|
|
1085
|
+
self._inbound[msg.request] = _Inbound(msg.size or 0)
|
|
1086
|
+
task = self.spawn(self._request(msg), f"request {msg.name} on {msg.node}")
|
|
1087
|
+
self._requests[msg.request] = task
|
|
1088
|
+
|
|
1089
|
+
def _send_later(self, msg: Any) -> None:
|
|
1090
|
+
self._loop.create_task(self._send_unnumbered(msg))
|
|
1091
|
+
|
|
1092
|
+
async def feed(self, request: str, data: bytes) -> None:
|
|
1093
|
+
"""A binary frame for an upload in flight. Unknown or finished: dropped."""
|
|
1094
|
+
stream = self._inbound.get(request)
|
|
1095
|
+
if stream is None:
|
|
1096
|
+
log.debug("frame for unknown request %s dropped", request)
|
|
1097
|
+
return
|
|
1098
|
+
await stream.put(data)
|
|
1099
|
+
|
|
1100
|
+
def cancel_request(self, request: str) -> None:
|
|
1101
|
+
task = self._requests.get(request)
|
|
1102
|
+
if task is not None:
|
|
1103
|
+
task.cancel()
|
|
1104
|
+
|
|
1105
|
+
def _abort_requests(self, why: str) -> None:
|
|
1106
|
+
"""Every request belongs to one socket connection: when it goes, so do they.
|
|
1107
|
+
Their "cancelled" replies may still reach a socket attached later; the tab
|
|
1108
|
+
ignores replies to requests it never made."""
|
|
1109
|
+
for stream in self._inbound.values():
|
|
1110
|
+
stream.fail(why)
|
|
1111
|
+
for task in list(self._requests.values()):
|
|
1112
|
+
task.cancel()
|
|
1113
|
+
|
|
1114
|
+
async def _request(self, msg: Request) -> None:
|
|
1115
|
+
"""Answer a ``request`` with a ``reply``, over the socket, without a render.
|
|
1116
|
+
|
|
1117
|
+
``file`` streams one of the session's files to the tab; ``upload`` stores the
|
|
1118
|
+
bytes that follow the message; any other name is a handler on the node,
|
|
1119
|
+
run outside the handler lock (a search never waits on a click), whose
|
|
1120
|
+
return value is the payload. A newer handler request on the same node and
|
|
1121
|
+
name cancels the one in flight.
|
|
1122
|
+
"""
|
|
1123
|
+
try:
|
|
1124
|
+
if msg.name == "file":
|
|
1125
|
+
await self._serve_file(msg)
|
|
1126
|
+
elif msg.name == "upload":
|
|
1127
|
+
await self._receive_upload(msg)
|
|
1128
|
+
else:
|
|
1129
|
+
await self._ask_handler(msg)
|
|
1130
|
+
except asyncio.CancelledError:
|
|
1131
|
+
# Cancelled by the tab, a newer request, or the socket going away: the
|
|
1132
|
+
# reply, if it still has somewhere to go, says so. The cancellation
|
|
1133
|
+
# itself goes on to whoever awaits this task.
|
|
1134
|
+
await self._reply(msg.request, error="cancelled")
|
|
1135
|
+
raise
|
|
1136
|
+
finally:
|
|
1137
|
+
self._requests.pop(msg.request, None)
|
|
1138
|
+
stream = self._inbound.pop(msg.request, None)
|
|
1139
|
+
if stream is not None:
|
|
1140
|
+
stream.fail("done")
|
|
1141
|
+
|
|
1142
|
+
async def _reply(self, request: str, payload: Any = None, error: str | None = None) -> None:
|
|
1143
|
+
await self._send_unnumbered(Reply(request=request, payload=payload, error=error))
|
|
1144
|
+
|
|
1145
|
+
async def _ask_handler(self, msg: Request) -> None:
|
|
1146
|
+
tree = self.tree
|
|
1147
|
+
component = tree.components.get(msg.node) if tree is not None else None
|
|
1148
|
+
handler = tree.handler(msg.node, msg.name) if tree is not None and component else None
|
|
1149
|
+
if handler is None or getattr(component, "disabled", False) or msg.size is not None:
|
|
1150
|
+
await self._reply(msg.request, error="unknown request")
|
|
1151
|
+
return
|
|
1152
|
+
key = (msg.node, msg.name)
|
|
1153
|
+
previous = self._queries.pop(key, None)
|
|
1154
|
+
if previous is not None:
|
|
1155
|
+
previous.cancel()
|
|
1156
|
+
self._queries[key] = self._requests[msg.request]
|
|
1157
|
+
token = context.bind(self)
|
|
1158
|
+
try:
|
|
1159
|
+
result = await _call(handler, self, msg.payload)
|
|
1160
|
+
if isinstance(result, list):
|
|
1161
|
+
result = [
|
|
1162
|
+
o.model_dump(exclude_none=True, exclude_defaults=True)
|
|
1163
|
+
for o in as_options(result)
|
|
1164
|
+
]
|
|
1165
|
+
await self._reply(msg.request, payload=result)
|
|
1166
|
+
except asyncio.CancelledError:
|
|
1167
|
+
raise
|
|
1168
|
+
except Exception:
|
|
1169
|
+
log.exception("request %s on %s failed", msg.name, msg.node)
|
|
1170
|
+
await self._reply(msg.request, error="The request failed.")
|
|
1171
|
+
finally:
|
|
1172
|
+
context.unbind(token)
|
|
1173
|
+
if self._queries.get(key) is self._requests.get(msg.request):
|
|
1174
|
+
self._queries.pop(key, None)
|
|
1175
|
+
|
|
1176
|
+
async def _serve_file(self, msg: Request) -> None:
|
|
1177
|
+
"""One of the session's files, as a ``head`` and binary frames, then the reply.
|
|
1178
|
+
|
|
1179
|
+
A few at a time per session, so a tab cannot hold an open file per request.
|
|
1180
|
+
"""
|
|
1181
|
+
file_id = msg.payload.get("id") if isinstance(msg.payload, dict) else None
|
|
1182
|
+
ref = self.files.get(file_id) if isinstance(file_id, str) else None
|
|
1183
|
+
if ref is None or not ref.path.is_file():
|
|
1184
|
+
await self._reply(msg.request, error="no such file")
|
|
1185
|
+
return
|
|
1186
|
+
async with self._file_slots:
|
|
1187
|
+
head = {"name": ref.name, "mime": ref.mime, "size": ref.size}
|
|
1188
|
+
await self._send_unnumbered(Head(request=msg.request, payload=head))
|
|
1189
|
+
sent = 0
|
|
1190
|
+
with ref.path.open("rb") as fh:
|
|
1191
|
+
while True:
|
|
1192
|
+
chunk = await asyncio.to_thread(fh.read, STREAM_CHUNK)
|
|
1193
|
+
if not chunk:
|
|
1194
|
+
break
|
|
1195
|
+
send_bytes = self._send_bytes
|
|
1196
|
+
if send_bytes is None:
|
|
1197
|
+
return
|
|
1198
|
+
frame = pack_frame(msg.request, chunk)
|
|
1199
|
+
try:
|
|
1200
|
+
await send_bytes(frame)
|
|
1201
|
+
except Exception:
|
|
1202
|
+
log.debug("file stream to %s failed; detached", self.id)
|
|
1203
|
+
self.detach(self._send)
|
|
1204
|
+
return
|
|
1205
|
+
sent += len(chunk)
|
|
1206
|
+
await self._reply(msg.request, payload={"size": sent})
|
|
1207
|
+
|
|
1208
|
+
def upload_spec(self, node_id: str, field: str | None) -> tuple[list[str] | None, int]:
|
|
1209
|
+
"""What an upload for this node may be: ``(accept, max_bytes)``. Raises ``Reject``."""
|
|
1210
|
+
from millwork.forms import field_spec, file_fields
|
|
1211
|
+
|
|
1212
|
+
component = self.tree.components.get(node_id) if self.tree is not None else None
|
|
1213
|
+
accept: list[str] | None
|
|
1214
|
+
max_mb: int | None
|
|
1215
|
+
if isinstance(component, FileUpload) and not component.disabled:
|
|
1216
|
+
accept, max_mb = component.accept, component.max_size_mb
|
|
1217
|
+
elif isinstance(component, Chat) and component.attachments and not component.disabled:
|
|
1218
|
+
accept, max_mb = component.attachments.accept, component.attachments.max_size_mb
|
|
1219
|
+
elif isinstance(component, Form) and not (component.disabled or component.read_only):
|
|
1220
|
+
# The pointer of a file field in the draft; a list index is dropped to find
|
|
1221
|
+
# it in the spec, which knows list items without an index.
|
|
1222
|
+
pointer = re.sub(r"/\d+(?=/|$)", "", field or "")
|
|
1223
|
+
spec = file_fields(field_spec(component.model)).get(pointer)
|
|
1224
|
+
if spec is None:
|
|
1225
|
+
raise Reject("no file field at this pointer")
|
|
1226
|
+
accept, max_mb = spec.get("accept"), spec.get("max_size_mb")
|
|
1227
|
+
else:
|
|
1228
|
+
raise Reject("no upload component for this node")
|
|
1229
|
+
return accept, (max_mb * 1024 * 1024 if max_mb else self.app.files.default_max_bytes)
|
|
1230
|
+
|
|
1231
|
+
async def _receive_upload(self, msg: Request) -> None:
|
|
1232
|
+
"""Store the bytes that follow an ``upload`` request as a session file.
|
|
1233
|
+
|
|
1234
|
+
Frames are taken off the stream on the loop and each write goes to a
|
|
1235
|
+
thread, so an upload that stalls holds a queue, never a thread.
|
|
1236
|
+
"""
|
|
1237
|
+
stream = self._inbound.get(msg.request)
|
|
1238
|
+
payload = msg.payload if isinstance(msg.payload, dict) else {}
|
|
1239
|
+
writer = None
|
|
1240
|
+
try:
|
|
1241
|
+
if stream is None or msg.size is None:
|
|
1242
|
+
raise Reject("an upload needs a size")
|
|
1243
|
+
field = payload.get("field")
|
|
1244
|
+
accept, max_bytes = self.upload_spec(
|
|
1245
|
+
msg.node, field if isinstance(field, str) else None
|
|
1246
|
+
)
|
|
1247
|
+
if msg.size > max_bytes:
|
|
1248
|
+
raise Reject(f"the file is larger than {max_bytes // (1024 * 1024)} MB")
|
|
1249
|
+
name = payload.get("name")
|
|
1250
|
+
mime = payload.get("mime")
|
|
1251
|
+
writer = self.app.files.begin(
|
|
1252
|
+
self,
|
|
1253
|
+
name if isinstance(name, str) and name else "file",
|
|
1254
|
+
mime=mime if isinstance(mime, str) else None,
|
|
1255
|
+
max_bytes=max_bytes,
|
|
1256
|
+
accept=accept,
|
|
1257
|
+
node=msg.node,
|
|
1258
|
+
)
|
|
1259
|
+
while True:
|
|
1260
|
+
chunk = await stream.get()
|
|
1261
|
+
if chunk is None:
|
|
1262
|
+
break
|
|
1263
|
+
await asyncio.to_thread(writer.write, chunk)
|
|
1264
|
+
ref = await asyncio.to_thread(writer.finish)
|
|
1265
|
+
except Reject as exc:
|
|
1266
|
+
if writer is not None:
|
|
1267
|
+
writer.abort()
|
|
1268
|
+
await self._reply(msg.request, error=exc.reason)
|
|
1269
|
+
return
|
|
1270
|
+
except asyncio.CancelledError:
|
|
1271
|
+
if writer is not None:
|
|
1272
|
+
writer.abort()
|
|
1273
|
+
raise
|
|
1274
|
+
except Exception:
|
|
1275
|
+
log.exception("upload on %s failed", msg.node)
|
|
1276
|
+
if writer is not None:
|
|
1277
|
+
writer.abort()
|
|
1278
|
+
await self._reply(msg.request, error="The upload failed.")
|
|
1279
|
+
return
|
|
1280
|
+
await self._reply(msg.request, payload=ref.__millwork_wire__())
|
|
1281
|
+
|
|
1282
|
+
async def answer(self, msg: Answer) -> None:
|
|
1283
|
+
"""The tab answered an ``ask``. Asks arrive with tasks; until then, log."""
|
|
1284
|
+
log.warning("answer for unknown request %s", msg.request)
|
|
1285
|
+
|
|
1286
|
+
async def connect(self) -> None:
|
|
1287
|
+
"""Run the page's connect hook, in the session context."""
|
|
1288
|
+
if self.page.on_connect is None:
|
|
1289
|
+
return
|
|
1290
|
+
token = context.bind(self)
|
|
1291
|
+
try:
|
|
1292
|
+
await _call(self.page.on_connect, self, **self.params)
|
|
1293
|
+
finally:
|
|
1294
|
+
context.unbind(token)
|
|
1295
|
+
|
|
1296
|
+
async def connect_or_fall_back(self) -> None:
|
|
1297
|
+
"""Connect, and when the hook refuses with NotFound or Forbidden, land on the
|
|
1298
|
+
app's page for that instead, under the path that was asked for."""
|
|
1299
|
+
try:
|
|
1300
|
+
await self.connect()
|
|
1301
|
+
except (Forbidden, NotFound) as exc:
|
|
1302
|
+
kind = "forbidden" if isinstance(exc, Forbidden) else "not_found"
|
|
1303
|
+
self.page, self.params = self.app.fallback(kind, self.path, exc.message)
|
|
1304
|
+
self.state = self.page.make_state()
|
|
1305
|
+
await self.connect()
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def _confirmation(component: Component, event: Event) -> tuple[str, bool] | None:
|
|
1309
|
+
"""The question to ask before this event's handler runs, if the component wants one.
|
|
1310
|
+
|
|
1311
|
+
``Button.confirm`` carries its own text; a destructive menu item asks
|
|
1312
|
+
with its label, so an irreversible row action is never one slip away.
|
|
1313
|
+
"""
|
|
1314
|
+
if isinstance(component, Button) and event.name == "click" and component.confirm:
|
|
1315
|
+
return component.confirm, component.variant == "destructive"
|
|
1316
|
+
if event.name == "action":
|
|
1317
|
+
payload = event.payload
|
|
1318
|
+
action = payload[0] if isinstance(payload, list | tuple) and payload else payload
|
|
1319
|
+
items = getattr(component, "row_actions", None) or getattr(component, "actions", None)
|
|
1320
|
+
for item in items or []:
|
|
1321
|
+
if item.id == action and item.destructive:
|
|
1322
|
+
return f"{item.label}?", True
|
|
1323
|
+
return None
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
async def _call(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
|
1327
|
+
result = fn(*args, **kwargs)
|
|
1328
|
+
if inspect.isawaitable(result):
|
|
1329
|
+
result = await result
|
|
1330
|
+
return result
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
@dataclass
|
|
1334
|
+
class _Ask:
|
|
1335
|
+
request: str
|
|
1336
|
+
component: Component
|
|
1337
|
+
future: asyncio.Future[Any]
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
class _ConfirmButtons(Component):
|
|
1341
|
+
"""The footer of a confirm dialog: a row the session recognises by its button keys."""
|
|
1342
|
+
|
|
1343
|
+
type: ClassVar[str] = "Row"
|
|
1344
|
+
gap: int = 2
|
|
1345
|
+
justify: str = "end"
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
async def _noop(*args: Any, **kwargs: Any) -> None:
|
|
1349
|
+
return None
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _askable(component: Component) -> Component:
|
|
1353
|
+
"""A copy whose Dialog opens and closes, and whose Forms submit, into the ask."""
|
|
1354
|
+
update: dict[str, Any] = {}
|
|
1355
|
+
if isinstance(component, Dialog):
|
|
1356
|
+
update["open"] = True
|
|
1357
|
+
if component.on_close is None:
|
|
1358
|
+
update["on_close"] = _noop
|
|
1359
|
+
if isinstance(component, Form) and component.on_submit is None:
|
|
1360
|
+
update["on_submit"] = _noop
|
|
1361
|
+
for name in ("footer", "action"):
|
|
1362
|
+
slot = getattr(component, name, None)
|
|
1363
|
+
if isinstance(slot, Component):
|
|
1364
|
+
update[name] = _askable(slot)
|
|
1365
|
+
children = [_askable(c) if isinstance(c, Component) else c for c in component.children]
|
|
1366
|
+
update["children"] = children
|
|
1367
|
+
return component.model_copy(update=update)
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
STREAM_CHUNK = 256 * 1024
|
|
1371
|
+
# A page function slower than this is doing more than building a tree.
|
|
1372
|
+
SLOW_PAGE_MS = 20
|
|
1373
|
+
# Requests in flight per session, of any kind, and files streaming out at once.
|
|
1374
|
+
MAX_REQUESTS = 32
|
|
1375
|
+
FILE_SLOTS = 4
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
class _Inbound:
|
|
1379
|
+
"""The byte stream of one upload: frames go in from the socket's receive loop,
|
|
1380
|
+
the upload task takes them out. Bounded, so a slow disk holds the receive
|
|
1381
|
+
loop back instead of buffering a whole file in memory. Failing it wakes
|
|
1382
|
+
both sides: a blocked putter, and a getter, which sees the failure."""
|
|
1383
|
+
|
|
1384
|
+
def __init__(self, size: int) -> None:
|
|
1385
|
+
self.size = size
|
|
1386
|
+
self.received = 0
|
|
1387
|
+
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=16)
|
|
1388
|
+
self.failed: str | None = None
|
|
1389
|
+
if size == 0:
|
|
1390
|
+
self._queue.put_nowait(None)
|
|
1391
|
+
|
|
1392
|
+
async def put(self, data: bytes) -> None:
|
|
1393
|
+
if self.failed is not None:
|
|
1394
|
+
return
|
|
1395
|
+
self.received += len(data)
|
|
1396
|
+
if self.received > self.size:
|
|
1397
|
+
self.fail("more bytes than announced")
|
|
1398
|
+
return
|
|
1399
|
+
await self._queue.put(data)
|
|
1400
|
+
if self.failed is not None:
|
|
1401
|
+
return
|
|
1402
|
+
if self.received == self.size:
|
|
1403
|
+
await self._queue.put(None)
|
|
1404
|
+
|
|
1405
|
+
async def get(self) -> bytes | None:
|
|
1406
|
+
"""The next chunk, or None at the end. Raises ``Reject`` once failed."""
|
|
1407
|
+
if self.failed is not None:
|
|
1408
|
+
raise Reject(f"the upload was interrupted: {self.failed}")
|
|
1409
|
+
item = await self._queue.get()
|
|
1410
|
+
if self.failed is not None:
|
|
1411
|
+
raise Reject(f"the upload was interrupted: {self.failed}")
|
|
1412
|
+
return item
|
|
1413
|
+
|
|
1414
|
+
def fail(self, why: str) -> None:
|
|
1415
|
+
if self.failed is not None:
|
|
1416
|
+
return
|
|
1417
|
+
self.failed = why
|
|
1418
|
+
# Drop what is queued: it wakes a putter blocked on a full queue, and makes
|
|
1419
|
+
# room for the end marker that wakes a waiting getter.
|
|
1420
|
+
while not self._queue.empty():
|
|
1421
|
+
self._queue.get_nowait()
|
|
1422
|
+
self._queue.put_nowait(None)
|