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/protocol.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""The wire protocol between a session on the server and a tab in the browser.
|
|
2
|
+
|
|
3
|
+
Every message is JSON inside an envelope ``{"seq": n, "msg": {...}}``. The
|
|
4
|
+
server numbers what it sends so a reconnecting tab can say how far it got;
|
|
5
|
+
the client numbers its own messages the same way for logging. ``msg.type``
|
|
6
|
+
discriminates the payload.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Annotated, Any, Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
|
14
|
+
|
|
15
|
+
PROTOCOL_VERSION = 3
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Wire(BaseModel):
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Node(Wire):
|
|
23
|
+
"""One node of a page. Children are ids, so a page is a flat table."""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
type: str
|
|
27
|
+
props: dict[str, Any] = Field(default_factory=dict)
|
|
28
|
+
children: list[str] = Field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------- server -> client
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Mount(Wire):
|
|
35
|
+
type: Literal["mount"] = "mount"
|
|
36
|
+
root: str
|
|
37
|
+
nodes: dict[str, Node]
|
|
38
|
+
title: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SetOp(Wire):
|
|
42
|
+
op: Literal["set"] = "set"
|
|
43
|
+
id: str
|
|
44
|
+
props: dict[str, Any]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UnsetOp(Wire):
|
|
48
|
+
op: Literal["unset"] = "unset"
|
|
49
|
+
id: str
|
|
50
|
+
keys: list[str]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ChildrenOp(Wire):
|
|
54
|
+
op: Literal["children"] = "children"
|
|
55
|
+
id: str
|
|
56
|
+
children: list[str]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AddOp(Wire):
|
|
60
|
+
op: Literal["add"] = "add"
|
|
61
|
+
node: Node
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RemoveOp(Wire):
|
|
65
|
+
op: Literal["remove"] = "remove"
|
|
66
|
+
id: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
PatchOp = Annotated[SetOp | UnsetOp | ChildrenOp | AddOp | RemoveOp, Field(discriminator="op")]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Patch(Wire):
|
|
73
|
+
type: Literal["patch"] = "patch"
|
|
74
|
+
ops: list[PatchOp]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Stream(Wire):
|
|
78
|
+
"""A chunk of the chat stream addressed to a Chat node. Never touches the tree."""
|
|
79
|
+
|
|
80
|
+
type: Literal["stream"] = "stream"
|
|
81
|
+
node: str
|
|
82
|
+
chunk: dict[str, Any]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Command(Wire):
|
|
86
|
+
type: Literal["command"] = "command"
|
|
87
|
+
name: str
|
|
88
|
+
args: dict[str, Any] = Field(default_factory=dict)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SessionInfo(Wire):
|
|
92
|
+
type: Literal["session"] = "session"
|
|
93
|
+
id: str
|
|
94
|
+
resume: str
|
|
95
|
+
protocol: int = PROTOCOL_VERSION
|
|
96
|
+
# Custom component versions, so a tab that outlived a deploy fetches fresh sources.
|
|
97
|
+
components: dict[str, str] = Field(default_factory=dict)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Ask(Wire):
|
|
101
|
+
type: Literal["ask"] = "ask"
|
|
102
|
+
request: str
|
|
103
|
+
node: str
|
|
104
|
+
timeout: float | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ServerError(Wire):
|
|
108
|
+
type: Literal["error"] = "error"
|
|
109
|
+
message: str
|
|
110
|
+
fatal: bool = False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Head(Wire):
|
|
114
|
+
"""A byte stream for a request starts: its metadata. Binary frames follow, then
|
|
115
|
+
the ``reply``."""
|
|
116
|
+
|
|
117
|
+
type: Literal["head"] = "head"
|
|
118
|
+
request: str
|
|
119
|
+
payload: Any = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Reply(Wire):
|
|
123
|
+
"""The end of a request: what it produced, or why it failed. Unnumbered: a
|
|
124
|
+
request lives within one socket connection and is never replayed."""
|
|
125
|
+
|
|
126
|
+
type: Literal["reply"] = "reply"
|
|
127
|
+
request: str
|
|
128
|
+
payload: Any = None
|
|
129
|
+
error: str | None = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
ServerMessage = Annotated[
|
|
133
|
+
Mount | Patch | Stream | Command | SessionInfo | Ask | ServerError | Head | Reply,
|
|
134
|
+
Field(discriminator="type"),
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------- client -> server
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Hello(Wire):
|
|
142
|
+
type: Literal["hello"] = "hello"
|
|
143
|
+
path: str
|
|
144
|
+
session: str | None = None
|
|
145
|
+
resume: str | None = None
|
|
146
|
+
seq: int = 0
|
|
147
|
+
protocol: int = PROTOCOL_VERSION
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Event(Wire):
|
|
151
|
+
type: Literal["event"] = "event"
|
|
152
|
+
node: str
|
|
153
|
+
name: str
|
|
154
|
+
payload: Any = None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Answer(Wire):
|
|
158
|
+
type: Literal["answer"] = "answer"
|
|
159
|
+
request: str
|
|
160
|
+
payload: Any = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Navigate(Wire):
|
|
164
|
+
type: Literal["navigate"] = "navigate"
|
|
165
|
+
path: str
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# Printable ASCII, short: it goes into every binary frame's header as is.
|
|
169
|
+
RequestId = Annotated[str, Field(min_length=1, max_length=32, pattern=r"^[\x21-\x7e]+$")]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Request(Wire):
|
|
173
|
+
"""Something the session answers over the socket without a render: a handler's
|
|
174
|
+
return value (a Combobox ``search``), a file (``file``), or an upload (``upload``,
|
|
175
|
+
where ``size`` bytes follow as binary frames). The ``reply`` carries ``request``."""
|
|
176
|
+
|
|
177
|
+
type: Literal["request"] = "request"
|
|
178
|
+
node: str
|
|
179
|
+
name: str
|
|
180
|
+
request: RequestId
|
|
181
|
+
payload: Any = None
|
|
182
|
+
size: int | None = Field(default=None, ge=0)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Cancel(Wire):
|
|
186
|
+
type: Literal["cancel"] = "cancel"
|
|
187
|
+
request: RequestId
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
ClientMessage = Annotated[
|
|
191
|
+
Hello | Event | Answer | Navigate | Request | Cancel, Field(discriminator="type")
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class Envelope(Wire):
|
|
196
|
+
seq: int
|
|
197
|
+
msg: ServerMessage | ClientMessage
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
server_message = TypeAdapter(ServerMessage)
|
|
201
|
+
client_message = TypeAdapter(ClientMessage)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def pack(seq: int, msg: BaseModel) -> str:
|
|
205
|
+
"""Serialise one message for the wire."""
|
|
206
|
+
return Envelope(seq=seq, msg=msg).model_dump_json() # type: ignore[arg-type]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def unpack_client(text: str) -> tuple[int, Hello | Event | Answer | Navigate | Request | Cancel]:
|
|
210
|
+
"""Parse one message from the browser. Raises ``pydantic.ValidationError``."""
|
|
211
|
+
env = Envelope.model_validate_json(text)
|
|
212
|
+
msg = client_message.validate_python(env.msg.model_dump())
|
|
213
|
+
return env.seq, msg
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------- binary frames
|
|
217
|
+
|
|
218
|
+
# A chunk of a request's byte stream, either direction: one byte with the length
|
|
219
|
+
# of the request id, the id in ASCII, then the bytes. Ids are short and ASCII by
|
|
220
|
+
# construction (the tab makes them), so the header is at most 33 bytes.
|
|
221
|
+
MAX_REQUEST_ID = 32
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def pack_frame(request: str, data: bytes) -> bytes:
|
|
225
|
+
rid = request.encode("ascii")
|
|
226
|
+
if not 0 < len(rid) <= MAX_REQUEST_ID:
|
|
227
|
+
raise ValueError("request id must be 1 to 32 ASCII characters")
|
|
228
|
+
return bytes([len(rid)]) + rid + data
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def unpack_frame(frame: bytes) -> tuple[str, bytes]:
|
|
232
|
+
"""Raises ``ValueError`` for a frame that is not ours."""
|
|
233
|
+
if not frame:
|
|
234
|
+
raise ValueError("empty frame")
|
|
235
|
+
n = frame[0]
|
|
236
|
+
if not 0 < n <= MAX_REQUEST_ID or len(frame) < 1 + n:
|
|
237
|
+
raise ValueError("bad frame header")
|
|
238
|
+
return frame[1 : 1 + n].decode("ascii"), bytes(frame[1 + n :])
|
millwork/proxy.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""A reverse proxy in front of the app.
|
|
2
|
+
|
|
3
|
+
With ``App(proxied=True)``, or ``millwork run --proxied``, the process is
|
|
4
|
+
behind a reverse proxy and believes what that proxy says: the visitor's
|
|
5
|
+
address and scheme from ``X-Forwarded-For`` and ``X-Forwarded-Proto``, and
|
|
6
|
+
the visitor's identity from the headers an ``auth.Headers`` provider names.
|
|
7
|
+
Whoever can reach the process can then send those headers, so a proxied
|
|
8
|
+
app listens where only its proxy connects: a unix socket, a loopback port,
|
|
9
|
+
a container network.
|
|
10
|
+
|
|
11
|
+
Without the switch the headers are ignored, which suits a process that
|
|
12
|
+
browsers reach directly, as in development. The middleware records the
|
|
13
|
+
switch in the scope as ``millwork.proxied`` for the auth layer.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from collections.abc import Awaitable, Callable, MutableMapping
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger("millwork.proxy")
|
|
23
|
+
|
|
24
|
+
Scope = MutableMapping[str, Any]
|
|
25
|
+
Receive = Callable[[], Awaitable[Any]]
|
|
26
|
+
Send = Callable[[Any], Awaitable[None]]
|
|
27
|
+
ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Proxied:
|
|
31
|
+
"""ASGI middleware: see through the proxy when the app says there is one.
|
|
32
|
+
|
|
33
|
+
``proxied`` is read on every connection, so the switch can be set after
|
|
34
|
+
the app was built, as the CLI does."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, app: ASGIApp, proxied: Callable[[], bool]) -> None:
|
|
37
|
+
self.app = app
|
|
38
|
+
self.proxied = proxied
|
|
39
|
+
self._bad_root: str | None = None
|
|
40
|
+
|
|
41
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
42
|
+
if scope["type"] in ("http", "websocket"):
|
|
43
|
+
if not self._root_ok(scope):
|
|
44
|
+
await _refuse(scope, send)
|
|
45
|
+
return
|
|
46
|
+
trusted = bool(self.proxied())
|
|
47
|
+
scope["millwork.proxied"] = trusted
|
|
48
|
+
if trusted:
|
|
49
|
+
_see_through(scope)
|
|
50
|
+
await self.app(scope, receive, send)
|
|
51
|
+
|
|
52
|
+
def _root_ok(self, scope: Scope) -> bool:
|
|
53
|
+
"""The prefix the server was given must be plain path segments; one that
|
|
54
|
+
is not would fail inside every handler, the error page included."""
|
|
55
|
+
from millwork.shell import check_prefix
|
|
56
|
+
|
|
57
|
+
root = scope.get("root_path", "")
|
|
58
|
+
if root == self._bad_root:
|
|
59
|
+
return False
|
|
60
|
+
try:
|
|
61
|
+
check_prefix(root)
|
|
62
|
+
except ValueError as exc:
|
|
63
|
+
self._bad_root = root
|
|
64
|
+
log.error("every request refused: %s", exc)
|
|
65
|
+
return False
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _see_through(scope: Scope) -> None:
|
|
70
|
+
"""The scheme and the visitor's address as the proxy forwarded them. A proxy
|
|
71
|
+
appends the address it saw to ``X-Forwarded-For``, so the last one is the
|
|
72
|
+
proxy's word and anything before it is whatever the browser sent."""
|
|
73
|
+
proto: bytes | None = None
|
|
74
|
+
forwarded: list[bytes] = []
|
|
75
|
+
for name, value in scope["headers"]:
|
|
76
|
+
if name == b"x-forwarded-proto":
|
|
77
|
+
proto = value
|
|
78
|
+
elif name == b"x-forwarded-for":
|
|
79
|
+
forwarded.append(value)
|
|
80
|
+
if proto is not None:
|
|
81
|
+
scheme = proto.decode("latin-1").strip().lower()
|
|
82
|
+
if scheme in ("http", "https", "ws", "wss"):
|
|
83
|
+
if scope["type"] == "websocket":
|
|
84
|
+
scheme = scheme.replace("http", "ws")
|
|
85
|
+
scope["scheme"] = scheme
|
|
86
|
+
if forwarded:
|
|
87
|
+
last = b", ".join(forwarded).decode("latin-1").split(",")[-1].strip()
|
|
88
|
+
host, port = _host_port(last)
|
|
89
|
+
if host:
|
|
90
|
+
scope["client"] = (host, port)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _host_port(value: str) -> tuple[str, int]:
|
|
94
|
+
"""``host``, ``host:port`` or ``[v6]:port``; anything else is kept as given."""
|
|
95
|
+
if value.startswith("["):
|
|
96
|
+
end = value.find("]")
|
|
97
|
+
if end == -1:
|
|
98
|
+
return value, 0
|
|
99
|
+
rest = value[end + 1 :]
|
|
100
|
+
if rest[:1] == ":" and rest[1:].isdigit():
|
|
101
|
+
return value[1:end], int(rest[1:])
|
|
102
|
+
return value[1:end], 0
|
|
103
|
+
if value.count(":") == 1:
|
|
104
|
+
host, port = value.rsplit(":", 1)
|
|
105
|
+
if port.isdigit():
|
|
106
|
+
return host, int(port)
|
|
107
|
+
return value, 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _refuse(scope: Scope, send: Send) -> None:
|
|
111
|
+
if scope["type"] == "websocket":
|
|
112
|
+
await send({"type": "websocket.close", "code": 1011})
|
|
113
|
+
return
|
|
114
|
+
body = b"millwork: the server's root path is not a valid prefix; see the log"
|
|
115
|
+
await send(
|
|
116
|
+
{
|
|
117
|
+
"type": "http.response.start",
|
|
118
|
+
"status": 500,
|
|
119
|
+
"headers": [(b"content-type", b"text/plain; charset=utf-8")],
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
await send({"type": "http.response.body", "body": body})
|
millwork/py.typed
ADDED
|
File without changes
|