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.
Files changed (70) hide show
  1. millwork/__init__.py +45 -0
  2. millwork/_bundle/dist/.vite/manifest.json +92 -0
  3. millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
  4. millwork/_bundle/dist/favicon.svg +15 -0
  5. millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  6. millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  7. millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  8. millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  9. millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  10. millwork/_bundle/dist/icons.svg +24 -0
  11. millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
  12. millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
  13. millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
  14. millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
  15. millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
  16. millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
  17. millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
  18. millwork/app.py +726 -0
  19. millwork/auth.py +877 -0
  20. millwork/chat.py +397 -0
  21. millwork/cli.py +316 -0
  22. millwork/context.py +42 -0
  23. millwork/files.py +450 -0
  24. millwork/forms.py +349 -0
  25. millwork/manual/__init__.py +449 -0
  26. millwork/manual/components/Hello.jsx +24 -0
  27. millwork/manual/components/Voice.jsx +46 -0
  28. millwork/manual/pages/app/auth.md +188 -0
  29. millwork/manual/pages/app/pages.md +200 -0
  30. millwork/manual/pages/app/running.md +242 -0
  31. millwork/manual/pages/app/state.md +151 -0
  32. millwork/manual/pages/app/testing.md +147 -0
  33. millwork/manual/pages/app/theming.md +122 -0
  34. millwork/manual/pages/components/charts.md +263 -0
  35. millwork/manual/pages/components/chat.md +280 -0
  36. millwork/manual/pages/components/custom.md +148 -0
  37. millwork/manual/pages/components/data.md +112 -0
  38. millwork/manual/pages/components/feedback.md +212 -0
  39. millwork/manual/pages/components/files.md +194 -0
  40. millwork/manual/pages/components/form.md +135 -0
  41. millwork/manual/pages/components/inputs.md +360 -0
  42. millwork/manual/pages/components/layout.md +154 -0
  43. millwork/manual/pages/components/tasks.md +140 -0
  44. millwork/manual/pages/components/text.md +145 -0
  45. millwork/manual/pages/index.md +67 -0
  46. millwork/manual/public/fonts/InterVariable.woff2 +0 -0
  47. millwork/manual/public/fonts/LICENSE.txt +2 -0
  48. millwork/manual/public/mark.svg +15 -0
  49. millwork/manual/public/theme.json +97 -0
  50. millwork/nodes.py +300 -0
  51. millwork/protocol.py +238 -0
  52. millwork/proxy.py +122 -0
  53. millwork/py.typed +0 -0
  54. millwork/session.py +1422 -0
  55. millwork/shell.py +226 -0
  56. millwork/tasks.py +473 -0
  57. millwork/testing.py +336 -0
  58. millwork/theme.py +338 -0
  59. millwork/ui/__init__.py +725 -0
  60. millwork/ui/charts.py +214 -0
  61. millwork/ui/chat.py +97 -0
  62. millwork/ui/files.py +101 -0
  63. millwork/ui/form.py +102 -0
  64. millwork/ui/table.py +145 -0
  65. millwork/ws.py +220 -0
  66. millwork-0.1.0.dist-info/METADATA +131 -0
  67. millwork-0.1.0.dist-info/RECORD +70 -0
  68. millwork-0.1.0.dist-info/WHEEL +4 -0
  69. millwork-0.1.0.dist-info/entry_points.txt +2 -0
  70. 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