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/files.py ADDED
@@ -0,0 +1,450 @@
1
+ """Files: uploads streamed into a per-session directory, fetched over the socket.
2
+
3
+ A ``FileRef`` is what an upload becomes and what a page
4
+ shows; on the wire it carries the id, name, size and type, never the path. The
5
+ bytes travel over the session's own socket, so a file has no URL.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import codecs
11
+ import contextlib
12
+ import logging
13
+ import mimetypes
14
+ import os
15
+ import re
16
+ import secrets
17
+ import shutil
18
+ import tempfile
19
+ import threading
20
+ import time
21
+ from collections.abc import Iterable
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ from pydantic import BaseModel, ConfigDict, Field
26
+
27
+ if TYPE_CHECKING:
28
+ from millwork.session import Session
29
+
30
+ log = logging.getLogger("millwork.files")
31
+
32
+
33
+ # Served inline (the browser may render it); everything else is an attachment.
34
+
35
+
36
+ class FileRef(BaseModel):
37
+ """A file the session holds. ``path`` stays on the server."""
38
+
39
+ model_config = ConfigDict(extra="forbid")
40
+
41
+ id: str
42
+ name: str
43
+ size: int
44
+ mime: str
45
+ path: Path = Field(exclude=True)
46
+ session: str = Field(exclude=True)
47
+ node: str | None = Field(default=None, exclude=True)
48
+
49
+ def __millwork_wire__(self) -> dict[str, Any]:
50
+ # The tab fetches the bytes over its socket by id; a file has no URL.
51
+ return self.model_dump(mode="json")
52
+
53
+ @property
54
+ def kind(self) -> str:
55
+ major = self.mime.split("/")[0]
56
+ if major in ("image", "audio", "video"):
57
+ return major
58
+ if self.mime == "application/pdf":
59
+ return "pdf"
60
+ if major == "text" or self.mime in ("application/json",):
61
+ return "text"
62
+ return "file"
63
+
64
+
65
+ class Reject(Exception):
66
+ def __init__(self, reason: str, status: int = 400) -> None:
67
+ self.reason = reason
68
+ self.status = status
69
+ super().__init__(reason)
70
+
71
+
72
+ # Magic bytes for the formats worth checking; anything else is accepted by extension.
73
+ _MAGIC: dict[str, tuple[bytes, ...]] = {
74
+ "pdf": (b"%PDF",),
75
+ "png": (b"\x89PNG",),
76
+ "jpg": (b"\xff\xd8\xff",),
77
+ "jpeg": (b"\xff\xd8\xff",),
78
+ "gif": (b"GIF87a", b"GIF89a"),
79
+ "webp": (b"RIFF",),
80
+ "zip": (b"PK\x03\x04",),
81
+ "docx": (b"PK\x03\x04",),
82
+ "xlsx": (b"PK\x03\x04",),
83
+ "pptx": (b"PK\x03\x04",),
84
+ }
85
+ _TEXT = {"txt", "md", "csv", "tsv", "json", "xml", "yaml", "yml", "html", "py"}
86
+
87
+ _SAFE_NAME = re.compile(r"[^\w.\- ()\[\]]+")
88
+ # Device names Windows refuses as file names, whatever the extension.
89
+ _RESERVED = {
90
+ "CON",
91
+ "PRN",
92
+ "AUX",
93
+ "NUL",
94
+ *(f"COM{i}" for i in range(10)),
95
+ *(f"LPT{i}" for i in range(10)),
96
+ }
97
+
98
+
99
+ def safe_name(name: str) -> str:
100
+ base = Path(name).name or "file"
101
+ # A trailing dot or space is dropped by Windows, so the name would differ.
102
+ cleaned = _SAFE_NAME.sub("_", base).rstrip(". ") or "file"
103
+ if cleaned.split(".", 1)[0].upper() in _RESERVED:
104
+ cleaned = "_" + cleaned
105
+ if len(cleaned) <= 200:
106
+ return cleaned
107
+ # A long name is cut in the stem, never in the extension: the extension
108
+ # decides the content check, the accept list and the MIME type.
109
+ suffix = Path(cleaned).suffix[:20]
110
+ return cleaned[: 200 - len(suffix)] + suffix
111
+
112
+
113
+ def check_content(name: str, head: bytes) -> None:
114
+ ext = Path(name).suffix.lower().lstrip(".")
115
+ if not head:
116
+ raise Reject("the file is empty")
117
+ if ext in _MAGIC:
118
+ if not any(head.startswith(m) for m in _MAGIC[ext]):
119
+ raise Reject(f"the file is not a valid .{ext}")
120
+ if ext == "webp" and b"WEBP" not in head[:16]:
121
+ raise Reject("the file is not a valid .webp")
122
+ elif ext in _TEXT:
123
+ # The head may end inside a multibyte character: only invalid bytes count.
124
+ try:
125
+ codecs.getincrementaldecoder("utf-8")().decode(head, final=False)
126
+ except UnicodeDecodeError as exc:
127
+ raise Reject("the file is not UTF-8 text") from exc
128
+
129
+
130
+ def _owner() -> str:
131
+ """What tells one user's store from another's in a shared temp directory."""
132
+ if hasattr(os, "getuid"):
133
+ return str(os.getuid())
134
+ import getpass
135
+
136
+ return _SAFE_NAME.sub("_", getpass.getuser())
137
+
138
+
139
+ class Files:
140
+ """Configuration and the store. One per app."""
141
+
142
+ def __init__(
143
+ self,
144
+ root: Path | str | None = None,
145
+ *,
146
+ max_session_mb: int = 200,
147
+ default_max_mb: int = 50,
148
+ max_total_mb: int = 2000,
149
+ ) -> None:
150
+ self.root = (
151
+ Path(root) if root else Path(tempfile.gettempdir()) / f"millwork-files-{_owner()}"
152
+ )
153
+ self.max_session_mb = max_session_mb
154
+ self.max_total_mb = max_total_mb
155
+ self.max_session_bytes = max_session_mb * 1024 * 1024
156
+ self.default_max_bytes = default_max_mb * 1024 * 1024
157
+ self.max_total_bytes = max_total_mb * 1024 * 1024
158
+ # Bytes on disk per session, counted as they are written so that uploads
159
+ # in flight hold their space against both quotas. Writers run in worker
160
+ # threads, so the check and the count move together under one lock.
161
+ self._usage: dict[str, int] = {}
162
+ self._lock = threading.Lock()
163
+
164
+ def used(self, session_id: str | None = None) -> int:
165
+ """Bytes held by one session, or by every session together."""
166
+ with self._lock:
167
+ if session_id is not None:
168
+ return self._usage.get(session_id, 0)
169
+ return sum(self._usage.values())
170
+
171
+ def _take(self, session: Session[Any], size: int) -> None:
172
+ """Hold ``size`` more bytes for the session, or refuse: against its quota,
173
+ the process's, and a session that has closed meanwhile (its space is gone)."""
174
+ with self._lock:
175
+ if session.closed:
176
+ raise Reject("the session closed", 410)
177
+ held = self._usage.get(session.id, 0)
178
+ if held + size > self.max_session_bytes:
179
+ raise Reject("the session's file quota is used up", 413)
180
+ if sum(self._usage.values()) + size > self.max_total_bytes:
181
+ raise Reject("the server's file space is used up", 507)
182
+ self._usage[session.id] = held + size
183
+
184
+ def _release(self, session_id: str, size: int) -> None:
185
+ with self._lock:
186
+ left = self._usage.get(session_id, 0) - size
187
+ if left > 0:
188
+ self._usage[session_id] = left
189
+ else:
190
+ self._usage.pop(session_id, None)
191
+
192
+ # ------------------------------------------------------------- directories
193
+
194
+ def session_dir(self, session_id: str) -> Path:
195
+ return self.root / session_id
196
+
197
+ def _ensure_root(self) -> None:
198
+ """The root is ours alone: created private, and never a path someone else owns.
199
+
200
+ Windows has no file mode or owner id to check; its temp directory is
201
+ per user already, and a root given explicitly is the app's own."""
202
+ self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
203
+ if not hasattr(os, "getuid"):
204
+ return
205
+ st = self.root.stat()
206
+ if st.st_uid != os.getuid():
207
+ raise RuntimeError(f"files root {self.root} is owned by another user")
208
+ if st.st_mode & 0o077:
209
+ os.chmod(self.root, 0o700)
210
+
211
+ def discard(self, session_id: str) -> None:
212
+ with self._lock:
213
+ self._usage.pop(session_id, None)
214
+ shutil.rmtree(self.session_dir(session_id), ignore_errors=True)
215
+
216
+ def sweep(self, older_than: float) -> int:
217
+ """Remove session directories untouched for ``older_than`` seconds. Startup only."""
218
+ if not self.root.is_dir():
219
+ return 0
220
+ cutoff = time.time() - older_than
221
+ removed = 0
222
+ for d in self.root.iterdir():
223
+ try:
224
+ if d.is_dir() and d.stat().st_mtime < cutoff:
225
+ shutil.rmtree(d, ignore_errors=True)
226
+ removed += 1
227
+ except OSError:
228
+ continue
229
+ return removed
230
+
231
+ # ------------------------------------------------------------- storing
232
+
233
+ def store(
234
+ self,
235
+ session: Session[Any],
236
+ name: str,
237
+ chunks: Iterable[bytes],
238
+ *,
239
+ mime: str | None = None,
240
+ max_bytes: int | None = None,
241
+ accept: list[str] | None = None,
242
+ check: bool = True,
243
+ node: str | None = None,
244
+ ) -> FileRef:
245
+ """Write a file into the session's directory from an iterable of chunks."""
246
+ writer = self.begin(
247
+ session, name, mime=mime, max_bytes=max_bytes, accept=accept, check=check, node=node
248
+ )
249
+ try:
250
+ for chunk in chunks:
251
+ writer.write(chunk)
252
+ return writer.finish()
253
+ except BaseException:
254
+ writer.abort()
255
+ raise
256
+
257
+ def begin(
258
+ self,
259
+ session: Session[Any],
260
+ name: str,
261
+ *,
262
+ mime: str | None = None,
263
+ max_bytes: int | None = None,
264
+ accept: list[str] | None = None,
265
+ check: bool = True,
266
+ node: str | None = None,
267
+ ) -> FileWriter:
268
+ """Start a file in the session's directory; ``write`` chunks, then ``finish``.
269
+
270
+ The extension is checked here, the size, the quota and the content as the
271
+ chunks come. ``mime`` is only used when the extension says nothing; what a
272
+ browser declares is never trusted.
273
+ """
274
+ name = safe_name(name)
275
+ ext = Path(name).suffix.lower()
276
+ if accept:
277
+ allowed = {a.lower() if a.startswith(".") else "." + a.lower() for a in accept}
278
+ if ext not in allowed:
279
+ raise Reject(
280
+ f"{ext or 'this type'} is not accepted; expected {', '.join(sorted(allowed))}"
281
+ )
282
+ file_id = secrets.token_urlsafe(12)
283
+ self._ensure_root()
284
+ directory = self.session_dir(session.id)
285
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
286
+ return FileWriter(
287
+ self,
288
+ session,
289
+ file_id=file_id,
290
+ name=name,
291
+ path=directory / f"{file_id}{ext}",
292
+ mime=mime,
293
+ limit=max_bytes or self.default_max_bytes,
294
+ check=check,
295
+ node=node,
296
+ )
297
+
298
+
299
+ class FileWriter:
300
+ """One file being written: validates as it goes, registers on ``finish``.
301
+
302
+ ``write`` and ``finish`` are plain blocking calls, short enough for a thread
303
+ per chunk; nothing waits inside them.
304
+ """
305
+
306
+ def __init__(
307
+ self,
308
+ files: Files,
309
+ session: Session[Any],
310
+ *,
311
+ file_id: str,
312
+ name: str,
313
+ path: Path,
314
+ mime: str | None,
315
+ limit: int,
316
+ check: bool,
317
+ node: str | None,
318
+ ) -> None:
319
+ self._files = files
320
+ self._session = session
321
+ self.id = file_id
322
+ self.name = name
323
+ self.path = path
324
+ self._mime = mime
325
+ self._limit = limit
326
+ self._node = node
327
+ self._size = 0
328
+ # Bytes counted against the quotas so far: exactly what abort gives back.
329
+ self._reserved = 0
330
+ self._head = b""
331
+ self._checked = not check
332
+ self._fh: Any = path.open("wb")
333
+ self._done = False
334
+
335
+ def write(self, chunk: bytes) -> None:
336
+ if self._done:
337
+ raise Reject("the upload is over")
338
+ if not chunk:
339
+ return
340
+ try:
341
+ if len(self._head) < 64:
342
+ self._head += chunk[: 64 - len(self._head)]
343
+ if self._size + len(chunk) > self._limit:
344
+ raise Reject(f"the file is larger than {self._limit // (1024 * 1024)} MB", 413)
345
+ self._files._take(self._session, len(chunk))
346
+ self._reserved += len(chunk)
347
+ self._size += len(chunk)
348
+ if not self._checked and len(self._head) >= 16:
349
+ # Enough bytes to know the format: reject before the rest streams in.
350
+ check_content(self.name, self._head)
351
+ self._checked = True
352
+ self._fh.write(chunk)
353
+ except BaseException:
354
+ self.abort()
355
+ raise
356
+
357
+ def finish(self) -> FileRef:
358
+ if self._done:
359
+ raise Reject("the upload is over")
360
+ try:
361
+ if not self._checked:
362
+ check_content(self.name, self._head)
363
+ self._fh.close()
364
+ except BaseException:
365
+ self.abort()
366
+ raise
367
+ self._done = True
368
+ guessed = mimetypes.guess_type(self.name)[0]
369
+ ref = FileRef(
370
+ id=self.id,
371
+ name=self.name,
372
+ size=self._size,
373
+ mime=guessed or self._mime or "application/octet-stream",
374
+ path=self.path,
375
+ session=self._session.id,
376
+ node=self._node,
377
+ )
378
+ self._session.files.register(ref)
379
+ return ref
380
+
381
+ def abort(self) -> None:
382
+ """Nothing half-written is left behind. Safe to call more than once."""
383
+ if self._done:
384
+ return
385
+ self._done = True
386
+ self._files._release(self._session.id, self._reserved)
387
+ self._reserved = 0
388
+ with contextlib.suppress(Exception):
389
+ self._fh.close()
390
+ self.path.unlink(missing_ok=True)
391
+
392
+
393
+ class SessionFiles:
394
+ """The files one session holds."""
395
+
396
+ def __init__(self, session: Session[Any]) -> None:
397
+ self._session = session
398
+ self._refs: dict[str, FileRef] = {}
399
+
400
+ def register(self, ref: FileRef) -> None:
401
+ self._refs[ref.id] = ref
402
+
403
+ def get(self, file_id: str) -> FileRef | None:
404
+ return self._refs.get(file_id)
405
+
406
+ def all(self) -> list[FileRef]:
407
+ return list(self._refs.values())
408
+
409
+ def remove(self, file_id: str) -> bool:
410
+ """Forget a file and delete it from disk; its quota is freed. False if unknown."""
411
+ ref = self._refs.pop(file_id, None)
412
+ if ref is None:
413
+ return False
414
+ self._session.app.files._release(ref.session, ref.size)
415
+ ref.path.unlink(missing_ok=True)
416
+ return True
417
+
418
+ def add(self, content: bytes | str | Path, *, name: str, mime: str | None = None) -> FileRef:
419
+ """Make a session file from server-side data or an existing path."""
420
+ if isinstance(content, Path):
421
+ data: Iterable[bytes] = _read_chunks(content)
422
+ elif isinstance(content, str):
423
+ data = [content.encode("utf-8")]
424
+ else:
425
+ data = [content]
426
+ files = self._session.app.files
427
+ return files.store(
428
+ self._session, name, data, mime=mime, max_bytes=files.max_session_bytes, check=False
429
+ )
430
+
431
+
432
+ def _read_chunks(path: Path, size: int = 1 << 16) -> Iterable[bytes]:
433
+ with path.open("rb") as fh:
434
+ while chunk := fh.read(size):
435
+ yield chunk
436
+
437
+
438
+ def content_to_file(session: Session[Any], content: Any, name: str, mime: str | None) -> FileRef:
439
+ """What ``Download`` and ``s.download`` accept: a FileRef, bytes, text or a callable."""
440
+ if callable(content):
441
+ content = content()
442
+ if isinstance(content, FileRef):
443
+ return content
444
+ if isinstance(content, bytes | bytearray):
445
+ return session.files.add(bytes(content), name=name, mime=mime)
446
+ if isinstance(content, str):
447
+ return session.files.add(content, name=name, mime=mime or "text/plain")
448
+ if isinstance(content, Path):
449
+ return session.files.add(content, name=name, mime=mime)
450
+ raise TypeError(f"cannot download a {type(content).__name__}")