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/shell.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""The HTML page that loads the bundle.
|
|
2
|
+
|
|
3
|
+
Built from the bundle's Vite manifest so asset names can be hashed and
|
|
4
|
+
cached for a year. The theme's CSS variables are rendered here, before the
|
|
5
|
+
bundle loads, so there is no flash of the default theme.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
|
|
17
|
+
from millwork.protocol import PROTOCOL_VERSION
|
|
18
|
+
from millwork.theme import Theme, under
|
|
19
|
+
|
|
20
|
+
_PREFIX = re.compile(r"^(/[A-Za-z0-9._~-]+)*/?$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_prefix(value: str) -> str:
|
|
24
|
+
"""A path prefix as the server may be told it: plain path segments, or empty.
|
|
25
|
+
Anything else is refused up front, since the prefix is written into HTML,
|
|
26
|
+
CSS and cookie paths."""
|
|
27
|
+
if not _PREFIX.match(value) or any(seg in (".", "..") for seg in value.split("/")):
|
|
28
|
+
raise ValueError(f"the path prefix must be a plain path such as /sales, got {value!r}")
|
|
29
|
+
return value.rstrip("/")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def root_of(scope: Mapping[str, Any]) -> str:
|
|
33
|
+
"""The path prefix the app runs under, from the ASGI scope and nothing else:
|
|
34
|
+
the server is told it (``--root-path``) or a mount sets it. Never a request
|
|
35
|
+
header, which a client could write. Empty at the root, else ``/prefix``."""
|
|
36
|
+
return check_prefix(str(scope.get("root_path") or ""))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
TEMPLATE = """<!doctype html>
|
|
40
|
+
<html lang="{lang}" data-density="{density}" data-scrollbars="{scrollbars}">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="utf-8">
|
|
43
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
44
|
+
<title>{title}</title>
|
|
45
|
+
{head}
|
|
46
|
+
<script{nonce}>window.__mw = {boot};</script>
|
|
47
|
+
<script{nonce}>{mode_script}</script>
|
|
48
|
+
{styles}
|
|
49
|
+
{scripts}
|
|
50
|
+
</head>
|
|
51
|
+
<body><div id="root"></div></body>
|
|
52
|
+
</html>
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Runs before the stylesheet applies: the viewer's stored choice, else the theme's
|
|
57
|
+
# mode, decides the dark class, so the first paint is already the right scheme.
|
|
58
|
+
MODE_SCRIPT = (
|
|
59
|
+
'(function(){var m=null;try{m=localStorage.getItem("mw:mode")}catch(e){}'
|
|
60
|
+
'm=m||(window.__mw.theme||{}).mode||"system";'
|
|
61
|
+
'if(m==="dark"||(m==="system"&&matchMedia("(prefers-color-scheme: dark)").matches))'
|
|
62
|
+
'document.documentElement.classList.add("dark")})();'
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
PLAIN_TEMPLATE = """<!doctype html>
|
|
67
|
+
<html lang="{lang}">
|
|
68
|
+
<head>
|
|
69
|
+
<meta charset="utf-8">
|
|
70
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
71
|
+
<title>{title}</title>
|
|
72
|
+
<script{nonce}>window.__mw = {{}};</script>
|
|
73
|
+
<script{nonce}>{mode_script}</script>
|
|
74
|
+
<style{nonce}>{css}</style>
|
|
75
|
+
</head>
|
|
76
|
+
<body>
|
|
77
|
+
<main><h1>{title}</h1><p>{text}</p><p><a href="{home_href}">{home}</a></p></main>
|
|
78
|
+
</body>
|
|
79
|
+
</html>
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
# The look of the page above: the theme's surface, ink and font, so even a
|
|
83
|
+
# failure before the bundle runs is in the app's colours. The stock tokens
|
|
84
|
+
# come first; a theme's own blocks follow and win.
|
|
85
|
+
PLAIN_DEFAULTS = (
|
|
86
|
+
":root{--background:#fff;--foreground:#0a0a0a;--muted-foreground:#737373;--primary:#171717}"
|
|
87
|
+
".dark{--background:#0a0a0a;--foreground:#fafafa;--muted-foreground:#a1a1a1;--primary:#e5e5e5}"
|
|
88
|
+
)
|
|
89
|
+
PLAIN_CSS = (
|
|
90
|
+
"html{{background:var(--background);color:var(--foreground);"
|
|
91
|
+
"font-family:var(--font-sans,system-ui,sans-serif)}}"
|
|
92
|
+
"body{{margin:0;min-height:100dvh;display:flex;align-items:center;justify-content:center}}"
|
|
93
|
+
"main{{max-width:24rem;padding:1.5rem}}"
|
|
94
|
+
"h1{{font-size:1.5rem;font-weight:600;letter-spacing:-0.02em;margin:0 0 .5rem}}"
|
|
95
|
+
"p{{margin:0 0 1rem;color:var(--muted-foreground);line-height:1.5}}"
|
|
96
|
+
"a{{color:var(--primary);text-decoration:underline;text-underline-offset:4px}}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def plain(*, title: str, text: str, theme: Theme, nonce: str | None = None, base: str = "") -> str:
|
|
101
|
+
"""An HTML page without the bundle, for a failure before it can run: not found
|
|
102
|
+
without a built bundle, an exception in an app's own route."""
|
|
103
|
+
return PLAIN_TEMPLATE.format(
|
|
104
|
+
lang=html.escape(theme.locale),
|
|
105
|
+
title=html.escape(title),
|
|
106
|
+
text=html.escape(text),
|
|
107
|
+
home=html.escape(theme.text("home")),
|
|
108
|
+
home_href=html.escape(f"{base}/"),
|
|
109
|
+
nonce=f' nonce="{html.escape(nonce)}"' if nonce else "",
|
|
110
|
+
mode_script=MODE_SCRIPT,
|
|
111
|
+
css=PLAIN_DEFAULTS + theme.css(base) + PLAIN_CSS.format(),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Shell:
|
|
116
|
+
def __init__(self, bundle_dir: Path, prefix: str) -> None:
|
|
117
|
+
self.bundle_dir = bundle_dir
|
|
118
|
+
self.prefix = prefix
|
|
119
|
+
self._manifest: dict[str, dict[str, object]] | None = None
|
|
120
|
+
self._stamp = 0.0
|
|
121
|
+
self._load()
|
|
122
|
+
|
|
123
|
+
def _load(self) -> None:
|
|
124
|
+
# Re-read when the file changes: a bundle rebuilt under a running process
|
|
125
|
+
# has new hashed names, and a page must name the files that exist now.
|
|
126
|
+
manifest = self.bundle_dir / ".vite" / "manifest.json"
|
|
127
|
+
try:
|
|
128
|
+
stamp = manifest.stat().st_mtime
|
|
129
|
+
except OSError:
|
|
130
|
+
self._manifest, self._stamp = None, 0.0
|
|
131
|
+
return
|
|
132
|
+
if stamp != self._stamp or self._manifest is None:
|
|
133
|
+
self._manifest = json.loads(manifest.read_text(encoding="utf-8"))
|
|
134
|
+
self._stamp = stamp
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def available(self) -> bool:
|
|
138
|
+
self._load()
|
|
139
|
+
return self._manifest is not None
|
|
140
|
+
|
|
141
|
+
def _entry(self) -> dict[str, object]:
|
|
142
|
+
self._load()
|
|
143
|
+
assert self._manifest is not None
|
|
144
|
+
for item in self._manifest.values():
|
|
145
|
+
if item.get("isEntry"):
|
|
146
|
+
return item
|
|
147
|
+
raise RuntimeError("the bundle manifest has no entry")
|
|
148
|
+
|
|
149
|
+
def render(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
title: str,
|
|
153
|
+
path: str,
|
|
154
|
+
lang: str = "en",
|
|
155
|
+
theme_css: str = "",
|
|
156
|
+
head: str = "",
|
|
157
|
+
login: dict[str, object] | None = None,
|
|
158
|
+
theme: Theme | None = None,
|
|
159
|
+
user: dict[str, object] | None = None,
|
|
160
|
+
nonce: str | None = None,
|
|
161
|
+
keep_alive: bool = False,
|
|
162
|
+
components: dict[str, str] | None = None,
|
|
163
|
+
base: str = "",
|
|
164
|
+
) -> str:
|
|
165
|
+
"""``base`` is the path prefix the app runs under: every URL the page
|
|
166
|
+
carries is under it, while ``path`` stays the app's own."""
|
|
167
|
+
entry = self._entry()
|
|
168
|
+
if theme is not None:
|
|
169
|
+
theme_css = theme.css(base) + theme_css
|
|
170
|
+
head = theme.head() + head
|
|
171
|
+
lang = theme.locale
|
|
172
|
+
# Always a link: without one the browser asks the host's root for
|
|
173
|
+
# /favicon.ico, which an app under a prefix never sees.
|
|
174
|
+
icon = under(base, theme.favicon) if theme.favicon else f"{base}/favicon.ico"
|
|
175
|
+
head += f'<link rel="icon" href="{html.escape(icon or "")}">'
|
|
176
|
+
asset = html.escape(f"{base}{self.prefix}/assets/")
|
|
177
|
+
scripts = f'<script type="module" src="{asset}{_basename(str(entry["file"]))}"></script>'
|
|
178
|
+
css_files = cast(list[str], entry.get("css", []))
|
|
179
|
+
styles = "".join(
|
|
180
|
+
f'<link rel="stylesheet" href="{asset}{_basename(css)}">' for css in css_files
|
|
181
|
+
)
|
|
182
|
+
# After the bundle's stylesheet: both declare :root tokens and the later wins.
|
|
183
|
+
styles = styles + f'<style id="mw-theme">{theme_css}</style>'
|
|
184
|
+
payload: dict[str, object] = {
|
|
185
|
+
"path": path,
|
|
186
|
+
"base": base,
|
|
187
|
+
"protocol": PROTOCOL_VERSION,
|
|
188
|
+
"ws": f"{base}{self.prefix}/ws",
|
|
189
|
+
}
|
|
190
|
+
if keep_alive:
|
|
191
|
+
payload["keep_alive"] = True
|
|
192
|
+
if components:
|
|
193
|
+
payload["components"] = components
|
|
194
|
+
if theme is not None:
|
|
195
|
+
payload["theme"] = theme.boot()
|
|
196
|
+
if user is not None:
|
|
197
|
+
payload["user"] = user
|
|
198
|
+
if login is not None:
|
|
199
|
+
payload["login"] = login
|
|
200
|
+
boot = _script_safe(json.dumps(payload))
|
|
201
|
+
return TEMPLATE.format(
|
|
202
|
+
lang=html.escape(lang),
|
|
203
|
+
density=theme.density if theme else "comfortable",
|
|
204
|
+
scrollbars=theme.scrollbars if theme else "thin",
|
|
205
|
+
title=html.escape(title),
|
|
206
|
+
head=head,
|
|
207
|
+
boot=boot,
|
|
208
|
+
nonce=f' nonce="{html.escape(nonce)}"' if nonce else "",
|
|
209
|
+
mode_script=MODE_SCRIPT,
|
|
210
|
+
styles=styles,
|
|
211
|
+
scripts=scripts,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _basename(file: str) -> str:
|
|
216
|
+
return file.split("/")[-1]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _script_safe(text: str) -> str:
|
|
220
|
+
"""JSON inside a <script>: nothing in it may close the element or open a comment."""
|
|
221
|
+
return (
|
|
222
|
+
text.replace("</", "<\\/")
|
|
223
|
+
.replace("<!--", "<\\!--")
|
|
224
|
+
.replace("\u2028", "\\u2028")
|
|
225
|
+
.replace("\u2029", "\\u2029")
|
|
226
|
+
)
|
millwork/tasks.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""Tasks: long-running work owned by the session, reported through ``progress()``.
|
|
2
|
+
|
|
3
|
+
A ``Task`` is an asyncio task wrapped in an object that
|
|
4
|
+
is also state: a page renders it like any other value. Sync callables run
|
|
5
|
+
in a thread with the session context copied in, so ``progress()`` works
|
|
6
|
+
there too; reports marshal onto the loop and schedule a coalesced render.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import inspect
|
|
15
|
+
import logging
|
|
16
|
+
import secrets
|
|
17
|
+
import threading
|
|
18
|
+
from collections.abc import Callable, Iterator
|
|
19
|
+
from contextvars import ContextVar, copy_context
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from millwork.session import Session
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger("millwork.tasks")
|
|
28
|
+
|
|
29
|
+
Status = Literal["pending", "running", "done", "failed", "cancelled"]
|
|
30
|
+
StepStatus = Literal["pending", "running", "done", "failed", "cancelled", "skipped"]
|
|
31
|
+
MAX_STEP_LINES = 200
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Cancelled(Exception):
|
|
35
|
+
"""Raised inside sync work by ``progress().check()`` after ``task.cancel()``."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TaskError(BaseModel):
|
|
39
|
+
model_config = ConfigDict(extra="forbid")
|
|
40
|
+
|
|
41
|
+
type: str
|
|
42
|
+
message: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Step(BaseModel):
|
|
46
|
+
model_config = ConfigDict(extra="forbid")
|
|
47
|
+
|
|
48
|
+
id: str
|
|
49
|
+
label: str
|
|
50
|
+
status: StepStatus = "pending"
|
|
51
|
+
note: str | None = None
|
|
52
|
+
lines: list[str] = Field(default_factory=list)
|
|
53
|
+
children: list[Step] = Field(default_factory=list)
|
|
54
|
+
started_at: dt.datetime | None = None
|
|
55
|
+
finished_at: dt.datetime | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _now() -> dt.datetime:
|
|
59
|
+
return dt.datetime.now(dt.UTC)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Task[T]:
|
|
63
|
+
"""What ``s.task()`` returns. Serialises to a snapshot the page can render."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, session: Session[Any], label: str, key: str | None, quiet: bool) -> None:
|
|
66
|
+
self.id = secrets.token_urlsafe(8)
|
|
67
|
+
self.key = key
|
|
68
|
+
self.label = label
|
|
69
|
+
self.status: Status = "pending"
|
|
70
|
+
self.progress: float | None = None
|
|
71
|
+
self.message: str | None = None
|
|
72
|
+
self.steps: list[Step] = []
|
|
73
|
+
self.started_at: dt.datetime | None = None
|
|
74
|
+
self.finished_at: dt.datetime | None = None
|
|
75
|
+
self.error: TaskError | None = None
|
|
76
|
+
self.quiet = quiet
|
|
77
|
+
|
|
78
|
+
self._session = session
|
|
79
|
+
self._result: T | None = None
|
|
80
|
+
self._future: asyncio.Future[T] = session._loop.create_future()
|
|
81
|
+
self._observed = False
|
|
82
|
+
self._cancel_flag = threading.Event()
|
|
83
|
+
self._asyncio_task: asyncio.Task[Any] | None = None
|
|
84
|
+
self._delay_handle: asyncio.TimerHandle | None = None
|
|
85
|
+
self._on_done: Callable[..., Any] | None = None
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------- reading
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def done(self) -> bool:
|
|
91
|
+
return self.status in ("done", "failed", "cancelled")
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def ok(self) -> bool:
|
|
95
|
+
return self.status == "done"
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def result(self) -> T:
|
|
99
|
+
if not self.ok:
|
|
100
|
+
raise RuntimeError(f"task {self.label!r} is {self.status}")
|
|
101
|
+
return self._result # type: ignore[return-value]
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def cancelled(self) -> bool:
|
|
105
|
+
return self._cancel_flag.is_set()
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def future(self) -> asyncio.Future[T]:
|
|
109
|
+
self._observed = True
|
|
110
|
+
return self._future
|
|
111
|
+
|
|
112
|
+
def __await__(self) -> Any:
|
|
113
|
+
"""Awaiting from a handler lets renders through (the session releases its lock)."""
|
|
114
|
+
self._observed = True
|
|
115
|
+
return self._session.wait_for_task(self).__await__()
|
|
116
|
+
|
|
117
|
+
async def _in_thread(self, fn: Callable[[], Any]) -> Any:
|
|
118
|
+
"""Run the work in a thread. In debug mode, state that changed while no
|
|
119
|
+
handler ran on the loop was changed by the thread, which is the one thing
|
|
120
|
+
a sync task may not do."""
|
|
121
|
+
session = self._session
|
|
122
|
+
watch = session.app.debug
|
|
123
|
+
before = _snapshot(session.state) if watch else None
|
|
124
|
+
runs = session._handler_runs
|
|
125
|
+
result = await asyncio.to_thread(fn)
|
|
126
|
+
reporter = _reporter.get()
|
|
127
|
+
if reporter is not None:
|
|
128
|
+
await reporter.drain()
|
|
129
|
+
if watch and before is not None and runs == session._handler_runs:
|
|
130
|
+
if _snapshot(session.state) != before:
|
|
131
|
+
session.warn(
|
|
132
|
+
f"thread-state:{self.label}",
|
|
133
|
+
f"task {self.label} changed session state from its thread; report "
|
|
134
|
+
"through progress() and return a result instead",
|
|
135
|
+
)
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
def __millwork_wire__(self) -> dict[str, Any]:
|
|
139
|
+
return {
|
|
140
|
+
"id": self.id,
|
|
141
|
+
"key": self.key,
|
|
142
|
+
"label": self.label,
|
|
143
|
+
"status": self.status,
|
|
144
|
+
"progress": self.progress,
|
|
145
|
+
"message": self.message,
|
|
146
|
+
"steps": [s.model_dump(mode="json") for s in self.steps],
|
|
147
|
+
"started_at": self.started_at.isoformat() if self.started_at else None,
|
|
148
|
+
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
|
|
149
|
+
"error": self.error.model_dump() if self.error else None,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------- control
|
|
153
|
+
|
|
154
|
+
def cancel(self) -> None:
|
|
155
|
+
"""Stop the work; what it already wrote stays."""
|
|
156
|
+
if self.done:
|
|
157
|
+
return
|
|
158
|
+
self._cancel_flag.set()
|
|
159
|
+
if self._delay_handle is not None:
|
|
160
|
+
self._delay_handle.cancel()
|
|
161
|
+
self._delay_handle = None
|
|
162
|
+
self._finish("cancelled")
|
|
163
|
+
return
|
|
164
|
+
if self._asyncio_task is not None:
|
|
165
|
+
self._asyncio_task.cancel()
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------- internals
|
|
168
|
+
|
|
169
|
+
def _start(
|
|
170
|
+
self, work: Any, args: tuple[Any, ...], kwargs: dict[str, Any], timeout: float | None
|
|
171
|
+
) -> None:
|
|
172
|
+
self.status = "running"
|
|
173
|
+
self.started_at = _now()
|
|
174
|
+
self._asyncio_task = self._session._loop.create_task(self._run(work, args, kwargs, timeout))
|
|
175
|
+
self._session.refresh()
|
|
176
|
+
|
|
177
|
+
async def _run( # noqa: ASYNC109
|
|
178
|
+
self, work: Any, args: tuple[Any, ...], kwargs: dict[str, Any], timeout: float | None
|
|
179
|
+
) -> None:
|
|
180
|
+
from millwork import context
|
|
181
|
+
|
|
182
|
+
token = context.bind(self._session)
|
|
183
|
+
reporter_token = _reporter.set(Reporter(self))
|
|
184
|
+
try:
|
|
185
|
+
coro: Any
|
|
186
|
+
if inspect.iscoroutine(work):
|
|
187
|
+
coro = work
|
|
188
|
+
elif inspect.iscoroutinefunction(work):
|
|
189
|
+
coro = work(*args, **kwargs)
|
|
190
|
+
elif callable(work):
|
|
191
|
+
coro = self._in_thread(_in_context(copy_context(), work, *args, **kwargs))
|
|
192
|
+
else:
|
|
193
|
+
raise TypeError(
|
|
194
|
+
f"task work is a coroutine or a callable, not {type(work).__name__}"
|
|
195
|
+
)
|
|
196
|
+
result = await asyncio.wait_for(coro, timeout) if timeout else await coro
|
|
197
|
+
except asyncio.CancelledError:
|
|
198
|
+
self._finish("cancelled")
|
|
199
|
+
return
|
|
200
|
+
except Cancelled:
|
|
201
|
+
self._finish("cancelled")
|
|
202
|
+
return
|
|
203
|
+
except TimeoutError:
|
|
204
|
+
# A worker thread cannot be interrupted: the flag makes its next check() stop it.
|
|
205
|
+
self._cancel_flag.set()
|
|
206
|
+
self._fail(TimeoutError(f"task {self.label!r} exceeded {timeout} s"))
|
|
207
|
+
return
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
self._fail(exc)
|
|
210
|
+
return
|
|
211
|
+
finally:
|
|
212
|
+
_reporter.reset(reporter_token)
|
|
213
|
+
context.unbind(token)
|
|
214
|
+
self._result = result
|
|
215
|
+
self._finish("done")
|
|
216
|
+
|
|
217
|
+
def _fail(self, exc: BaseException) -> None:
|
|
218
|
+
log.exception("task %s (%s) failed", self.label, self.id, exc_info=exc)
|
|
219
|
+
self.error = TaskError(type=type(exc).__name__, message=str(exc) or type(exc).__name__)
|
|
220
|
+
self._finish("failed", exc)
|
|
221
|
+
|
|
222
|
+
def _finish(self, status: Status, exc: BaseException | None = None) -> None:
|
|
223
|
+
if self.done:
|
|
224
|
+
return
|
|
225
|
+
self.status = status
|
|
226
|
+
self.finished_at = _now()
|
|
227
|
+
for step in _walk_steps(self.steps):
|
|
228
|
+
if step.status == "running":
|
|
229
|
+
step.status = (
|
|
230
|
+
"cancelled"
|
|
231
|
+
if status == "cancelled"
|
|
232
|
+
else "failed"
|
|
233
|
+
if status == "failed"
|
|
234
|
+
else "done"
|
|
235
|
+
)
|
|
236
|
+
step.finished_at = self.finished_at
|
|
237
|
+
if not self._future.done():
|
|
238
|
+
if status == "done":
|
|
239
|
+
self._future.set_result(self._result) # type: ignore[arg-type]
|
|
240
|
+
elif status == "cancelled":
|
|
241
|
+
self._future.cancel()
|
|
242
|
+
else:
|
|
243
|
+
self._future.set_exception(
|
|
244
|
+
exc or RuntimeError(self.error.message if self.error else "failed")
|
|
245
|
+
)
|
|
246
|
+
# The toast is the report for a failure nobody awaits; mark the
|
|
247
|
+
# exception seen so the loop does not log it again at collection.
|
|
248
|
+
self._future.exception()
|
|
249
|
+
if status == "failed" and not self._observed and not self.quiet:
|
|
250
|
+
# An unobserved failure is never silent.
|
|
251
|
+
self._session.toast(
|
|
252
|
+
f"{self.label} failed: {self.error.message if self.error else ''}",
|
|
253
|
+
tone="destructive",
|
|
254
|
+
)
|
|
255
|
+
self._session._task_finished(self)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _in_context(ctx: Any, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Callable[[], Any]:
|
|
259
|
+
return lambda: ctx.run(fn, *args, **kwargs)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _walk_steps(steps: list[Step]) -> Iterator[Step]:
|
|
263
|
+
for s in steps:
|
|
264
|
+
yield s
|
|
265
|
+
yield from _walk_steps(s.children)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ------------------------------------------------------------------ the reporter
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class Reporter:
|
|
272
|
+
"""What ``progress()`` returns inside a task. Every report schedules a render."""
|
|
273
|
+
|
|
274
|
+
def __init__(self, task: Task[Any]) -> None:
|
|
275
|
+
self._task = task
|
|
276
|
+
self._loop = task._session._loop
|
|
277
|
+
# Reports handed over from a worker thread and not yet applied on the
|
|
278
|
+
# loop. The loop may resume the task before it runs them, so the task
|
|
279
|
+
# drains them before it finishes.
|
|
280
|
+
self._queued = 0
|
|
281
|
+
self._lock = threading.Lock()
|
|
282
|
+
|
|
283
|
+
def _touch(self, fn: Callable[[], None]) -> None:
|
|
284
|
+
"""Run a mutation on the loop thread, then render. Safe from a worker thread.
|
|
285
|
+
|
|
286
|
+
A report that arrives after the task ended (a thread running on past a
|
|
287
|
+
timeout or a cancel) changes nothing.
|
|
288
|
+
"""
|
|
289
|
+
try:
|
|
290
|
+
on_loop = asyncio.get_running_loop() is self._loop
|
|
291
|
+
except RuntimeError:
|
|
292
|
+
on_loop = False
|
|
293
|
+
if on_loop:
|
|
294
|
+
if self._task.done:
|
|
295
|
+
return
|
|
296
|
+
fn()
|
|
297
|
+
self._task._session.refresh()
|
|
298
|
+
else:
|
|
299
|
+
with self._lock:
|
|
300
|
+
self._queued += 1
|
|
301
|
+
self._loop.call_soon_threadsafe(self._handed_over, fn)
|
|
302
|
+
|
|
303
|
+
def _handed_over(self, fn: Callable[[], None]) -> None:
|
|
304
|
+
with self._lock:
|
|
305
|
+
self._queued -= 1
|
|
306
|
+
self._touch(fn)
|
|
307
|
+
|
|
308
|
+
async def drain(self) -> None:
|
|
309
|
+
"""Wait until every report a thread handed over has been applied."""
|
|
310
|
+
while True:
|
|
311
|
+
with self._lock:
|
|
312
|
+
pending = self._queued
|
|
313
|
+
if not pending:
|
|
314
|
+
return
|
|
315
|
+
await asyncio.sleep(0)
|
|
316
|
+
|
|
317
|
+
def set(self, value: float | None = None, message: str | None = None) -> None:
|
|
318
|
+
def apply() -> None:
|
|
319
|
+
if value is not None:
|
|
320
|
+
self._task.progress = max(0.0, min(1.0, float(value)))
|
|
321
|
+
if message is not None:
|
|
322
|
+
self._task.message = message
|
|
323
|
+
|
|
324
|
+
self._touch(apply)
|
|
325
|
+
|
|
326
|
+
def log(self, line: str) -> None:
|
|
327
|
+
stack = _steps.get()
|
|
328
|
+
current = stack[-1] if stack else None
|
|
329
|
+
|
|
330
|
+
def apply() -> None:
|
|
331
|
+
if current is not None:
|
|
332
|
+
current.lines.append(line)
|
|
333
|
+
del current.lines[:-MAX_STEP_LINES]
|
|
334
|
+
else:
|
|
335
|
+
self._task.message = line
|
|
336
|
+
|
|
337
|
+
self._touch(apply)
|
|
338
|
+
|
|
339
|
+
def check(self) -> None:
|
|
340
|
+
"""Raise ``Cancelled`` after ``task.cancel()``; sync work calls this at sensible points."""
|
|
341
|
+
if self._task.cancelled:
|
|
342
|
+
raise Cancelled(self._task.label)
|
|
343
|
+
|
|
344
|
+
def plan(self, labels: list[str]) -> None:
|
|
345
|
+
"""Declare steps ahead; ``step(label)`` then opens the one with that label."""
|
|
346
|
+
stack = _steps.get()
|
|
347
|
+
parent = stack[-1].children if stack else self._task.steps
|
|
348
|
+
|
|
349
|
+
def apply() -> None:
|
|
350
|
+
for label in labels:
|
|
351
|
+
if not any(s.label == label for s in parent):
|
|
352
|
+
parent.append(Step(id=secrets.token_urlsafe(6), label=label))
|
|
353
|
+
|
|
354
|
+
self._touch(apply)
|
|
355
|
+
|
|
356
|
+
@contextlib.contextmanager
|
|
357
|
+
def step(self, label: str) -> Iterator[StepHandle]:
|
|
358
|
+
# The open steps live in a context variable, not on the reporter: work run
|
|
359
|
+
# in parallel under one step (asyncio.gather, a thread) each sees its own
|
|
360
|
+
# branch, so sibling steps never nest into each other.
|
|
361
|
+
stack = _steps.get()
|
|
362
|
+
parent = stack[-1].children if stack else self._task.steps
|
|
363
|
+
step = Step(id=secrets.token_urlsafe(6), label=label)
|
|
364
|
+
|
|
365
|
+
def open_() -> None:
|
|
366
|
+
# Mutations are applied in order on the loop, so a planned placeholder
|
|
367
|
+
# with this label (if any) is visible here and is taken over.
|
|
368
|
+
planned = next((s for s in parent if s.label == label and s.status == "pending"), None)
|
|
369
|
+
if planned is not None:
|
|
370
|
+
parent[parent.index(planned)] = step
|
|
371
|
+
else:
|
|
372
|
+
parent.append(step)
|
|
373
|
+
step.status = "running"
|
|
374
|
+
step.started_at = _now()
|
|
375
|
+
|
|
376
|
+
self._touch(open_)
|
|
377
|
+
token = _steps.set((*stack, step))
|
|
378
|
+
try:
|
|
379
|
+
yield StepHandle(self, step)
|
|
380
|
+
except BaseException as exc:
|
|
381
|
+
cancelled = isinstance(exc, Cancelled | asyncio.CancelledError)
|
|
382
|
+
self._close(step, "cancelled" if cancelled else "failed")
|
|
383
|
+
raise
|
|
384
|
+
else:
|
|
385
|
+
self._close(step, "done")
|
|
386
|
+
finally:
|
|
387
|
+
_steps.reset(token)
|
|
388
|
+
|
|
389
|
+
def _close(self, step: Step, status: StepStatus) -> None:
|
|
390
|
+
def apply() -> None:
|
|
391
|
+
step.status = status
|
|
392
|
+
step.finished_at = _now()
|
|
393
|
+
|
|
394
|
+
self._touch(apply)
|
|
395
|
+
|
|
396
|
+
def callback(self) -> Callable[..., None]:
|
|
397
|
+
"""A ``(value, message)`` callable for code that already takes a progress callback."""
|
|
398
|
+
|
|
399
|
+
def report(value: float | None = None, message: str | None = None) -> None:
|
|
400
|
+
self.set(value, message)
|
|
401
|
+
|
|
402
|
+
return report
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class StepHandle:
|
|
406
|
+
def __init__(self, reporter: Reporter, step: Step) -> None:
|
|
407
|
+
self._reporter = reporter
|
|
408
|
+
self._step = step
|
|
409
|
+
|
|
410
|
+
def note(self, text: str) -> None:
|
|
411
|
+
step = self._step
|
|
412
|
+
|
|
413
|
+
def apply() -> None:
|
|
414
|
+
step.note = text
|
|
415
|
+
|
|
416
|
+
self._reporter._touch(apply)
|
|
417
|
+
|
|
418
|
+
def log(self, line: str) -> None:
|
|
419
|
+
self._reporter.log(line)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class _NoReporter:
|
|
423
|
+
"""Outside a task every call does nothing, so library code reports unconditionally."""
|
|
424
|
+
|
|
425
|
+
def set(self, value: float | None = None, message: str | None = None) -> None:
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
def log(self, line: str) -> None:
|
|
429
|
+
pass
|
|
430
|
+
|
|
431
|
+
def check(self) -> None:
|
|
432
|
+
pass
|
|
433
|
+
|
|
434
|
+
def plan(self, labels: list[str]) -> None:
|
|
435
|
+
pass
|
|
436
|
+
|
|
437
|
+
@contextlib.contextmanager
|
|
438
|
+
def step(self, label: str) -> Iterator[Any]:
|
|
439
|
+
yield _NoStep()
|
|
440
|
+
|
|
441
|
+
def callback(self) -> Callable[..., None]:
|
|
442
|
+
def report(value: float | None = None, message: str | None = None) -> None:
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
return report
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class _NoStep:
|
|
449
|
+
def note(self, text: str) -> None:
|
|
450
|
+
pass
|
|
451
|
+
|
|
452
|
+
def log(self, line: str) -> None:
|
|
453
|
+
pass
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
_reporter: ContextVar[Reporter | None] = ContextVar("millwork_progress", default=None)
|
|
457
|
+
_steps: ContextVar[tuple[Step, ...]] = ContextVar("millwork_steps", default=())
|
|
458
|
+
_none = _NoReporter()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def progress() -> Reporter | _NoReporter:
|
|
462
|
+
"""The current task's reporter, or a reporter that does nothing."""
|
|
463
|
+
return _reporter.get() or _none
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _snapshot(state: Any) -> Any:
|
|
467
|
+
"""A comparable picture of the session state, or None when it cannot be taken."""
|
|
468
|
+
try:
|
|
469
|
+
if hasattr(state, "model_dump"):
|
|
470
|
+
return repr(state.model_dump(mode="python"))
|
|
471
|
+
return repr(state)
|
|
472
|
+
except Exception:
|
|
473
|
+
return None
|