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/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]