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