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/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__}")
|