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/cli.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""``millwork run module:app`` and friends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import getpass
|
|
8
|
+
import importlib
|
|
9
|
+
import importlib.util
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import secrets
|
|
13
|
+
import socket
|
|
14
|
+
import stat
|
|
15
|
+
import sys
|
|
16
|
+
import types
|
|
17
|
+
from typing import Any, cast
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_app(target: str, debug: bool = False, proxied: bool = False) -> object:
|
|
21
|
+
"""Import ``module:attribute`` or ``path/to/file.py:attribute``; the attribute
|
|
22
|
+
defaults to ``app``.
|
|
23
|
+
|
|
24
|
+
A module path is imported from the current directory. A file path is
|
|
25
|
+
loaded from wherever it is, with its own directory importable so the app
|
|
26
|
+
can import its neighbours.
|
|
27
|
+
|
|
28
|
+
``--debug`` switches a millwork App into debug mode (page errors in the
|
|
29
|
+
tab, the theme file watched) as well as raising the log level, and
|
|
30
|
+
``--proxied`` tells the App a reverse proxy is in front of it.
|
|
31
|
+
"""
|
|
32
|
+
module_name, attr = split_target(target)
|
|
33
|
+
if module_name.endswith(".py") or os.sep in module_name:
|
|
34
|
+
module = _import_file(module_name)
|
|
35
|
+
else:
|
|
36
|
+
sys.path.insert(0, os.getcwd())
|
|
37
|
+
module = importlib.import_module(module_name)
|
|
38
|
+
app = getattr(module, attr or "app")
|
|
39
|
+
if debug and hasattr(app, "debug"):
|
|
40
|
+
app.debug = True
|
|
41
|
+
if proxied and hasattr(app, "proxied"):
|
|
42
|
+
app.proxied = True
|
|
43
|
+
return getattr(app, "asgi", app)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def split_target(target: str) -> tuple[str, str]:
|
|
47
|
+
"""``module:attribute`` or ``path:attribute``; a Windows drive letter is not a split."""
|
|
48
|
+
module_name, sep, attr = target.rpartition(":")
|
|
49
|
+
if sep and module_name and attr.isidentifier():
|
|
50
|
+
return module_name, attr
|
|
51
|
+
return target, ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _import_file(path: str) -> types.ModuleType:
|
|
55
|
+
file = os.path.abspath(path)
|
|
56
|
+
if not os.path.isfile(file):
|
|
57
|
+
raise SystemExit(f"millwork run: no such file: {path}")
|
|
58
|
+
name = os.path.splitext(os.path.basename(file))[0]
|
|
59
|
+
sys.path.insert(0, os.path.dirname(file))
|
|
60
|
+
spec = importlib.util.spec_from_file_location(name, file)
|
|
61
|
+
if spec is None or spec.loader is None:
|
|
62
|
+
raise SystemExit(f"millwork run: cannot import {path}")
|
|
63
|
+
module = importlib.util.module_from_spec(spec)
|
|
64
|
+
sys.modules[name] = module
|
|
65
|
+
spec.loader.exec_module(module)
|
|
66
|
+
return module
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def asgi_factory() -> object:
|
|
70
|
+
"""uvicorn's reloader needs an import string; this wraps ``load_app``."""
|
|
71
|
+
return load_app(
|
|
72
|
+
os.environ["MILLWORK_TARGET"],
|
|
73
|
+
os.environ.get("MILLWORK_DEBUG") == "1",
|
|
74
|
+
os.environ.get("MILLWORK_PROXIED") == "1",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main(argv: list[str] | None = None) -> int:
|
|
79
|
+
from millwork import __version__
|
|
80
|
+
|
|
81
|
+
parser = argparse.ArgumentParser(prog="millwork")
|
|
82
|
+
parser.add_argument("--version", action="version", version=f"millwork {__version__}")
|
|
83
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
84
|
+
run = sub.add_parser("run", help="serve an app")
|
|
85
|
+
run.add_argument(
|
|
86
|
+
"target", help="the App as module:attribute or file.py:attribute, for example app:app"
|
|
87
|
+
)
|
|
88
|
+
run.add_argument("--host", default=None, help="default 127.0.0.1")
|
|
89
|
+
run.add_argument("--port", type=int, default=None, help="default 8000")
|
|
90
|
+
run.add_argument(
|
|
91
|
+
"--uds",
|
|
92
|
+
default=None,
|
|
93
|
+
metavar="PATH",
|
|
94
|
+
help="listen on a unix socket at this path instead of a host and port; the socket "
|
|
95
|
+
"is created for its owner and group only",
|
|
96
|
+
)
|
|
97
|
+
run.add_argument("--debug", action="store_true")
|
|
98
|
+
run.add_argument(
|
|
99
|
+
"--root-path",
|
|
100
|
+
default="",
|
|
101
|
+
help="the path prefix the app is served under, such as /sales",
|
|
102
|
+
)
|
|
103
|
+
run.add_argument("--reload", action="store_true", help="restart the process on Python changes")
|
|
104
|
+
run.add_argument(
|
|
105
|
+
"--proxied",
|
|
106
|
+
action="store_true",
|
|
107
|
+
help="a reverse proxy is in front: its forwarded address and scheme and its "
|
|
108
|
+
"identity headers are believed; implied by --uds",
|
|
109
|
+
)
|
|
110
|
+
docs = sub.add_parser("docs", help="serve the manual, with its live examples")
|
|
111
|
+
docs.add_argument("--host", default=None, help="default 127.0.0.1")
|
|
112
|
+
docs.add_argument("--port", type=int, default=None, help="default 8008")
|
|
113
|
+
docs.add_argument("--uds", default=None, metavar="PATH", help="listen on a unix socket")
|
|
114
|
+
docs.add_argument("--proxied", action="store_true", help="as for run; implied by --uds")
|
|
115
|
+
docs.add_argument("--theme", default=None, help="a theme file to show the manual in")
|
|
116
|
+
docs.add_argument("--root-path", default="", help="the path prefix the manual is served under")
|
|
117
|
+
sub.add_parser("secret", help="print a signing secret for App(secret=...)")
|
|
118
|
+
hp = sub.add_parser("hash-password", help="hash a password for a Password provider")
|
|
119
|
+
hp.add_argument("--user", default="", help="prefix the line with the user name")
|
|
120
|
+
args = parser.parse_args(argv)
|
|
121
|
+
if getattr(args, "root_path", ""):
|
|
122
|
+
from millwork.shell import check_prefix
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
args.root_path = check_prefix(args.root_path)
|
|
126
|
+
except ValueError as exc:
|
|
127
|
+
print(f"--root-path: {exc}", file=sys.stderr)
|
|
128
|
+
return 2
|
|
129
|
+
|
|
130
|
+
if args.command == "docs":
|
|
131
|
+
from millwork.manual import make_app
|
|
132
|
+
|
|
133
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
134
|
+
manual = make_app(theme=args.theme)
|
|
135
|
+
manual.proxied = args.proxied or bool(args.uds)
|
|
136
|
+
return serve(
|
|
137
|
+
manual.asgi,
|
|
138
|
+
host=args.host,
|
|
139
|
+
port=args.port,
|
|
140
|
+
default_port=8008,
|
|
141
|
+
uds=args.uds,
|
|
142
|
+
root_path=args.root_path,
|
|
143
|
+
debug=False,
|
|
144
|
+
)
|
|
145
|
+
if args.command == "secret":
|
|
146
|
+
print(secrets.token_urlsafe(48))
|
|
147
|
+
return 0
|
|
148
|
+
if args.command == "hash-password":
|
|
149
|
+
from millwork.auth import hash_password
|
|
150
|
+
|
|
151
|
+
pw = getpass.getpass("Password: ")
|
|
152
|
+
again = getpass.getpass("Again: ")
|
|
153
|
+
if pw != again:
|
|
154
|
+
print("the passwords differ", file=sys.stderr)
|
|
155
|
+
return 1
|
|
156
|
+
line = hash_password(pw)
|
|
157
|
+
print(f"{args.user}:{line}" if args.user else line)
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
logging.basicConfig(
|
|
161
|
+
level=logging.DEBUG if args.debug else logging.INFO,
|
|
162
|
+
format="%(levelname)s %(name)s: %(message)s",
|
|
163
|
+
)
|
|
164
|
+
# A unix socket is only ever reached through a proxy.
|
|
165
|
+
proxied = args.proxied or bool(args.uds)
|
|
166
|
+
if args.reload:
|
|
167
|
+
os.environ["MILLWORK_TARGET"] = args.target
|
|
168
|
+
os.environ["MILLWORK_DEBUG"] = "1" if args.debug else "0"
|
|
169
|
+
os.environ["MILLWORK_PROXIED"] = "1" if proxied else "0"
|
|
170
|
+
return serve(
|
|
171
|
+
"millwork.cli:asgi_factory",
|
|
172
|
+
factory=True,
|
|
173
|
+
reload=True,
|
|
174
|
+
host=args.host,
|
|
175
|
+
port=args.port,
|
|
176
|
+
uds=args.uds,
|
|
177
|
+
root_path=args.root_path,
|
|
178
|
+
debug=args.debug,
|
|
179
|
+
)
|
|
180
|
+
return serve(
|
|
181
|
+
load_app(args.target, args.debug, proxied),
|
|
182
|
+
host=args.host,
|
|
183
|
+
port=args.port,
|
|
184
|
+
uds=args.uds,
|
|
185
|
+
root_path=args.root_path,
|
|
186
|
+
debug=args.debug,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# The largest message a tab may send on the socket. Uploads travel in frames of
|
|
191
|
+
# a quarter of this, so the bound costs nothing and caps every event's payload.
|
|
192
|
+
WS_MAX_SIZE = 1024 * 1024
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def serve(
|
|
196
|
+
app: object,
|
|
197
|
+
*,
|
|
198
|
+
host: str | None,
|
|
199
|
+
port: int | None,
|
|
200
|
+
uds: str | None,
|
|
201
|
+
root_path: str,
|
|
202
|
+
debug: bool,
|
|
203
|
+
reload: bool = False,
|
|
204
|
+
factory: bool = False,
|
|
205
|
+
default_port: int = 8000,
|
|
206
|
+
) -> int:
|
|
207
|
+
"""Run uvicorn around the app. millwork handles proxy headers itself, so
|
|
208
|
+
uvicorn's own handling is off. A unix socket is bound here rather than by
|
|
209
|
+
uvicorn, so that it is never open to every local user: uvicorn would make
|
|
210
|
+
it world-writable."""
|
|
211
|
+
import uvicorn
|
|
212
|
+
|
|
213
|
+
if uds and (host is not None or port is not None):
|
|
214
|
+
print("--uds replaces --host and --port; give one or the other", file=sys.stderr)
|
|
215
|
+
return 2
|
|
216
|
+
config = uvicorn.Config(
|
|
217
|
+
cast(Any, app),
|
|
218
|
+
host=host or "127.0.0.1",
|
|
219
|
+
port=port or default_port,
|
|
220
|
+
uds=uds,
|
|
221
|
+
proxy_headers=False,
|
|
222
|
+
ws_max_size=WS_MAX_SIZE,
|
|
223
|
+
root_path=root_path,
|
|
224
|
+
log_level="debug" if debug else "info",
|
|
225
|
+
reload=reload,
|
|
226
|
+
reload_dirs=[os.getcwd()] if reload else None,
|
|
227
|
+
factory=factory,
|
|
228
|
+
)
|
|
229
|
+
server = uvicorn.Server(config)
|
|
230
|
+
sock: socket.socket | None = None
|
|
231
|
+
try:
|
|
232
|
+
if uds:
|
|
233
|
+
sock = bind_unix_socket(uds, config.backlog, inheritable=reload)
|
|
234
|
+
# uvicorn says where it listens only for sockets it bound itself.
|
|
235
|
+
logging.getLogger("millwork").info("listening on unix socket %s", uds)
|
|
236
|
+
except OSError as exc:
|
|
237
|
+
print(f"--uds: {exc}", file=sys.stderr)
|
|
238
|
+
return 2
|
|
239
|
+
try:
|
|
240
|
+
if reload:
|
|
241
|
+
from uvicorn.supervisors import ChangeReload
|
|
242
|
+
|
|
243
|
+
sockets = [sock if sock is not None else config.bind_socket()]
|
|
244
|
+
ChangeReload(config, target=server.run, sockets=sockets).run()
|
|
245
|
+
elif sock is not None:
|
|
246
|
+
server.run(sockets=[sock])
|
|
247
|
+
else:
|
|
248
|
+
server.run()
|
|
249
|
+
except KeyboardInterrupt:
|
|
250
|
+
pass
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _unix_family() -> int:
|
|
255
|
+
"""The address family for a unix socket, which Windows lacks."""
|
|
256
|
+
family = getattr(socket, "AF_UNIX", None)
|
|
257
|
+
if family is None:
|
|
258
|
+
raise OSError("unix sockets are not available on this platform")
|
|
259
|
+
return int(family)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _answers(path: str) -> bool:
|
|
263
|
+
"""Whether a process listens on the socket. A connect on a unix socket
|
|
264
|
+
answers at once: refused means nobody, and anything else means someone,
|
|
265
|
+
including a listener too busy to accept."""
|
|
266
|
+
probe = socket.socket(_unix_family(), socket.SOCK_STREAM)
|
|
267
|
+
probe.setblocking(False)
|
|
268
|
+
try:
|
|
269
|
+
probe.connect(path)
|
|
270
|
+
except (ConnectionRefusedError, FileNotFoundError):
|
|
271
|
+
return False
|
|
272
|
+
except OSError:
|
|
273
|
+
return True
|
|
274
|
+
else:
|
|
275
|
+
return True
|
|
276
|
+
finally:
|
|
277
|
+
probe.close()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def bind_unix_socket(path: str, backlog: int, *, inheritable: bool) -> socket.socket:
|
|
281
|
+
"""A listening unix socket for the process's owner and group only: mode 0660
|
|
282
|
+
whatever the umask, created under that mask so it is at no point open to
|
|
283
|
+
everyone. Like a port, the path is refused while another process answers
|
|
284
|
+
on it; a socket file nobody answers on was left by an earlier process and
|
|
285
|
+
is replaced; anything else at the path stops the start. The file stays at
|
|
286
|
+
exit, where the next start replaces it."""
|
|
287
|
+
try:
|
|
288
|
+
st = os.lstat(path)
|
|
289
|
+
except FileNotFoundError:
|
|
290
|
+
pass
|
|
291
|
+
else:
|
|
292
|
+
if not stat.S_ISSOCK(st.st_mode):
|
|
293
|
+
raise OSError(f"{path} exists and is not a socket file")
|
|
294
|
+
if _answers(path):
|
|
295
|
+
raise OSError(f"{path} is in use by another process")
|
|
296
|
+
with contextlib.suppress(FileNotFoundError):
|
|
297
|
+
os.remove(path)
|
|
298
|
+
sock = socket.socket(_unix_family(), socket.SOCK_STREAM)
|
|
299
|
+
mask = os.umask(0o117)
|
|
300
|
+
try:
|
|
301
|
+
sock.bind(path)
|
|
302
|
+
except FileNotFoundError:
|
|
303
|
+
sock.close()
|
|
304
|
+
raise OSError(f"{path}: the directory does not exist") from None
|
|
305
|
+
except OSError:
|
|
306
|
+
sock.close()
|
|
307
|
+
raise
|
|
308
|
+
finally:
|
|
309
|
+
os.umask(mask)
|
|
310
|
+
sock.set_inheritable(inheritable)
|
|
311
|
+
sock.listen(backlog)
|
|
312
|
+
return sock
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
if __name__ == "__main__":
|
|
316
|
+
raise SystemExit(main())
|
millwork/context.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""The current session, reachable from anywhere on the loop or in a task thread.
|
|
2
|
+
|
|
3
|
+
Handlers receive the session explicitly. Code deeper down (a progress report
|
|
4
|
+
from inside a workflow, a library that wants to toast) uses ``session()``.
|
|
5
|
+
The variable is set per handler and per task; ``asyncio.to_thread`` copies
|
|
6
|
+
the context, so it is visible from a sync task's thread too.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from contextvars import ContextVar
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from millwork.session import Session
|
|
16
|
+
|
|
17
|
+
_session: ContextVar[Session[Any] | None] = ContextVar("millwork_session", default=None)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NoSession(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def session() -> Session[Any]:
|
|
25
|
+
"""The session the current handler or task belongs to."""
|
|
26
|
+
s = _session.get()
|
|
27
|
+
if s is None:
|
|
28
|
+
raise NoSession("no millwork session in this context")
|
|
29
|
+
return s
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def current() -> Session[Any] | None:
|
|
33
|
+
return _session.get()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def bind(s: Session[Any] | None) -> object:
|
|
37
|
+
"""Set the current session; returns the token for ``unbind``."""
|
|
38
|
+
return _session.set(s)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def unbind(token: object) -> None:
|
|
42
|
+
_session.reset(token) # type: ignore[arg-type]
|