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/forms.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Forms from Pydantic models: the field spec the bundle renders, and validation.
|
|
2
|
+
|
|
3
|
+
The spec is derived from the model's JSON Schema, so nested models, lists,
|
|
4
|
+
literals, enums, optionals and discriminated unions all come out the same
|
|
5
|
+
way. ``json_schema_extra`` carries widget hints: ``widget``, ``choices``,
|
|
6
|
+
``group``, ``order``, ``hidden``, ``sortable``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
15
|
+
|
|
16
|
+
Widget = str # input, textarea, password, number, slider, switch, checkbox, select, radio,
|
|
17
|
+
# toggle, checkboxes, multiselect, date, datetime, fieldset, list, union, file, hidden
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Issue(BaseModel):
|
|
21
|
+
model_config = ConfigDict(extra="forbid")
|
|
22
|
+
|
|
23
|
+
pointer: str
|
|
24
|
+
message: str
|
|
25
|
+
code: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FormIssues(Exception):
|
|
29
|
+
"""Raised by a submit handler to reject a valid document on the app's grounds."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, issues: list[Issue] | list[tuple[str, str]]) -> None:
|
|
32
|
+
self.issues = [
|
|
33
|
+
i if isinstance(i, Issue) else Issue(pointer=i[0], message=i[1]) for i in issues
|
|
34
|
+
]
|
|
35
|
+
super().__init__(f"{len(self.issues)} issue(s)")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def pointer_of(loc: tuple[Any, ...], tags: frozenset[str] = frozenset()) -> str:
|
|
39
|
+
"""A pydantic error ``loc`` as a JSON pointer: ``("a", 0, "b")`` is ``/a/0/b``.
|
|
40
|
+
|
|
41
|
+
A discriminated union adds its tag to the loc (``("ops", 0, "bucket",
|
|
42
|
+
"source")``); tags known from the model are dropped so the pointer lands
|
|
43
|
+
on the field the user sees.
|
|
44
|
+
"""
|
|
45
|
+
parts: list[str] = []
|
|
46
|
+
for i, part in enumerate(loc):
|
|
47
|
+
if isinstance(part, int):
|
|
48
|
+
parts.append(str(part))
|
|
49
|
+
elif isinstance(part, str):
|
|
50
|
+
if part.startswith("function-after") or part.startswith("tagged-union"):
|
|
51
|
+
continue
|
|
52
|
+
if i > 0 and part in tags:
|
|
53
|
+
continue
|
|
54
|
+
parts.append(part.replace("~", "~0").replace("/", "~1"))
|
|
55
|
+
return "/" + "/".join(parts)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def union_tags(model: type[BaseModel]) -> frozenset[str]:
|
|
59
|
+
"""Every discriminator value in the model's forms, for ``pointer_of``."""
|
|
60
|
+
tags: set[str] = set()
|
|
61
|
+
|
|
62
|
+
def walk(fields: list[dict[str, Any]]) -> None:
|
|
63
|
+
for f in fields:
|
|
64
|
+
for v in f.get("variants", []):
|
|
65
|
+
if v.get("tag") is not None:
|
|
66
|
+
tags.add(str(v["tag"]))
|
|
67
|
+
walk(v["fields"])
|
|
68
|
+
if "fields" in f:
|
|
69
|
+
walk(f["fields"])
|
|
70
|
+
item = f.get("item")
|
|
71
|
+
if item:
|
|
72
|
+
walk([item])
|
|
73
|
+
|
|
74
|
+
walk(field_spec(model)["fields"])
|
|
75
|
+
return frozenset(tags)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate(model: type[BaseModel], draft: Any) -> tuple[BaseModel | None, list[Issue]]:
|
|
79
|
+
"""Validate a draft. Returns the instance, or the issues by pointer."""
|
|
80
|
+
try:
|
|
81
|
+
return model.model_validate(draft), []
|
|
82
|
+
except ValidationError as exc:
|
|
83
|
+
issues: list[Issue] = []
|
|
84
|
+
seen: set[str] = set()
|
|
85
|
+
tags = union_tags(model)
|
|
86
|
+
for err in exc.errors():
|
|
87
|
+
pointer = pointer_of(tuple(err["loc"]), tags)
|
|
88
|
+
if pointer in seen:
|
|
89
|
+
continue
|
|
90
|
+
seen.add(pointer)
|
|
91
|
+
issues.append(Issue(pointer=pointer, message=err["msg"], code=err.get("type")))
|
|
92
|
+
return None, issues
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------- files in a draft
|
|
96
|
+
|
|
97
|
+
# What a file looks like on the wire, and in a schema (where the server-only
|
|
98
|
+
# fields are present too). The schema's title may be the field's own.
|
|
99
|
+
_FILE_KEYS = frozenset({"id", "name", "size", "mime"})
|
|
100
|
+
_FILE_SCHEMA_KEYS = _FILE_KEYS | {"path", "session"}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _is_file_ref(prop: Mapping[str, Any]) -> bool:
|
|
104
|
+
return _FILE_SCHEMA_KEYS <= set(prop.get("properties", {}))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def file_fields(spec: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
|
|
108
|
+
"""The file fields of a form spec by pointer, list items without their index."""
|
|
109
|
+
out: dict[str, dict[str, Any]] = {}
|
|
110
|
+
|
|
111
|
+
def walk(fields: list[dict[str, Any]]) -> None:
|
|
112
|
+
for f in fields:
|
|
113
|
+
if f["widget"] in ("file", "files"):
|
|
114
|
+
out[f["pointer"]] = f
|
|
115
|
+
walk(f.get("fields", []))
|
|
116
|
+
item = f.get("item")
|
|
117
|
+
if item:
|
|
118
|
+
walk(item.get("fields", []))
|
|
119
|
+
for v in f.get("variants", []):
|
|
120
|
+
walk(v["fields"])
|
|
121
|
+
|
|
122
|
+
walk(list(spec.get("fields", [])))
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def resolve_files(draft: Any, lookup: Callable[[str], Any]) -> Any:
|
|
127
|
+
"""Replace the tab's file dicts in a draft by what ``lookup`` returns for their id.
|
|
128
|
+
|
|
129
|
+
A dict that looks like a file on the wire (``id``, ``name``, ``size``,
|
|
130
|
+
``mime``) is only ever an upload echoed back; the session's own record
|
|
131
|
+
replaces it, and an id the session does not know becomes ``None`` so
|
|
132
|
+
validation reports the field.
|
|
133
|
+
"""
|
|
134
|
+
if isinstance(draft, dict):
|
|
135
|
+
if _FILE_KEYS <= set(draft) and isinstance(draft.get("id"), str):
|
|
136
|
+
return lookup(draft["id"])
|
|
137
|
+
return {k: resolve_files(v, lookup) for k, v in draft.items()}
|
|
138
|
+
if isinstance(draft, list):
|
|
139
|
+
return [resolve_files(v, lookup) for v in draft]
|
|
140
|
+
return draft
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------- schema -> fields
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def humanize(name: str) -> str:
|
|
147
|
+
return name.replace("_", " ").strip().capitalize()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def field_spec(model: type[BaseModel]) -> dict[str, Any]:
|
|
151
|
+
"""The whole form as a spec: ``{"fields": [...], "title": ...}``."""
|
|
152
|
+
schema = model.model_json_schema()
|
|
153
|
+
walker = _Walker(schema.get("$defs", {}))
|
|
154
|
+
return {"title": schema.get("title"), "fields": walker.fields(schema, "")}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _Walker:
|
|
158
|
+
def __init__(self, defs: Mapping[str, Any]) -> None:
|
|
159
|
+
self.defs = defs
|
|
160
|
+
|
|
161
|
+
def resolve(self, node: Mapping[str, Any]) -> dict[str, Any]:
|
|
162
|
+
"""Follow ``$ref`` and merge the referenced schema under the local keys."""
|
|
163
|
+
if "$ref" in node:
|
|
164
|
+
name = node["$ref"].split("/")[-1]
|
|
165
|
+
target = dict(self.defs[name])
|
|
166
|
+
local = {k: v for k, v in node.items() if k != "$ref"}
|
|
167
|
+
target.update(local)
|
|
168
|
+
return target
|
|
169
|
+
return dict(node)
|
|
170
|
+
|
|
171
|
+
def fields(self, obj: Mapping[str, Any], base: str) -> list[dict[str, Any]]:
|
|
172
|
+
obj = self.resolve(obj)
|
|
173
|
+
required = set(obj.get("required", []))
|
|
174
|
+
out: list[dict[str, Any]] = []
|
|
175
|
+
for name, prop in obj.get("properties", {}).items():
|
|
176
|
+
spec = self.field(name, prop, f"{base}/{name}", name in required)
|
|
177
|
+
if spec is not None:
|
|
178
|
+
out.append(spec)
|
|
179
|
+
out.sort(key=lambda f: f.get("order", 0))
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
def field(
|
|
183
|
+
self, name: str, prop: Mapping[str, Any], pointer: str, required: bool
|
|
184
|
+
) -> dict[str, Any] | None:
|
|
185
|
+
prop = self.resolve(prop)
|
|
186
|
+
if prop.get("hidden"):
|
|
187
|
+
return None
|
|
188
|
+
spec: dict[str, Any] = {
|
|
189
|
+
"name": name,
|
|
190
|
+
"pointer": pointer,
|
|
191
|
+
"label": prop.get("title") or humanize(name),
|
|
192
|
+
"help": prop.get("description"),
|
|
193
|
+
"placeholder": (prop.get("examples") or [None])[0],
|
|
194
|
+
"required": required and "default" not in prop,
|
|
195
|
+
"group": prop.get("group"),
|
|
196
|
+
"order": prop.get("order", 0),
|
|
197
|
+
"widget": "input",
|
|
198
|
+
}
|
|
199
|
+
if "default" in prop:
|
|
200
|
+
spec["default"] = prop["default"]
|
|
201
|
+
self.classify(prop, spec, name)
|
|
202
|
+
if prop.get("widget"):
|
|
203
|
+
spec["widget"] = prop["widget"]
|
|
204
|
+
if prop.get("choices"):
|
|
205
|
+
spec["options"] = [self.option(c) for c in prop["choices"]]
|
|
206
|
+
if spec["widget"] == "input":
|
|
207
|
+
spec["widget"] = "select"
|
|
208
|
+
return spec
|
|
209
|
+
|
|
210
|
+
def option(self, c: Any) -> dict[str, Any]:
|
|
211
|
+
if isinstance(c, Mapping):
|
|
212
|
+
return {"value": c["value"], "label": c.get("label", str(c["value"]))}
|
|
213
|
+
if isinstance(c, tuple | list) and len(c) == 2:
|
|
214
|
+
return {"value": c[0], "label": str(c[1])}
|
|
215
|
+
return {"value": c, "label": str(c)}
|
|
216
|
+
|
|
217
|
+
def classify(self, prop: Mapping[str, Any], spec: dict[str, Any], name: str) -> None:
|
|
218
|
+
# Optional[X] is anyOf [X, null]; unwrap and mark clearable.
|
|
219
|
+
if "anyOf" in prop and "discriminator" not in prop:
|
|
220
|
+
branches = [self.resolve(b) for b in prop["anyOf"]]
|
|
221
|
+
non_null = [b for b in branches if b.get("type") != "null"]
|
|
222
|
+
if len(non_null) == 1 and len(branches) == 2:
|
|
223
|
+
spec["clearable"] = True
|
|
224
|
+
merged = dict(non_null[0])
|
|
225
|
+
for k in (
|
|
226
|
+
"title",
|
|
227
|
+
"description",
|
|
228
|
+
"default",
|
|
229
|
+
"examples",
|
|
230
|
+
"widget",
|
|
231
|
+
"choices",
|
|
232
|
+
"accept",
|
|
233
|
+
"max_size_mb",
|
|
234
|
+
"max_files",
|
|
235
|
+
):
|
|
236
|
+
if k in prop:
|
|
237
|
+
merged[k] = prop[k]
|
|
238
|
+
self.classify(merged, spec, name)
|
|
239
|
+
return
|
|
240
|
+
if "oneOf" in prop or ("anyOf" in prop and "discriminator" in prop):
|
|
241
|
+
self.union(prop, spec)
|
|
242
|
+
return
|
|
243
|
+
if "enum" in prop:
|
|
244
|
+
spec["widget"] = "select"
|
|
245
|
+
spec["options"] = [self.option(v) for v in prop["enum"]]
|
|
246
|
+
return
|
|
247
|
+
if "const" in prop:
|
|
248
|
+
spec["widget"] = "hidden"
|
|
249
|
+
spec["default"] = prop["const"]
|
|
250
|
+
return
|
|
251
|
+
t = prop.get("type")
|
|
252
|
+
fmt = prop.get("format")
|
|
253
|
+
if t == "string":
|
|
254
|
+
if fmt == "password" or prop.get("writeOnly"):
|
|
255
|
+
spec["widget"] = "password"
|
|
256
|
+
elif fmt == "date":
|
|
257
|
+
spec["widget"] = "date"
|
|
258
|
+
# Pydantic puts no bounds for dates in the schema; the hints do.
|
|
259
|
+
for k in ("min_date", "max_date"):
|
|
260
|
+
if k in prop:
|
|
261
|
+
spec[k] = prop[k]
|
|
262
|
+
elif fmt == "date-time":
|
|
263
|
+
spec["widget"] = "datetime"
|
|
264
|
+
elif (prop.get("maxLength") or 0) > 200:
|
|
265
|
+
spec["widget"] = "textarea"
|
|
266
|
+
else:
|
|
267
|
+
spec["widget"] = "input"
|
|
268
|
+
if "maxLength" in prop:
|
|
269
|
+
spec["max_length"] = prop["maxLength"]
|
|
270
|
+
if "pattern" in prop:
|
|
271
|
+
spec["pattern"] = prop["pattern"]
|
|
272
|
+
elif t in ("integer", "number"):
|
|
273
|
+
spec["widget"] = "number"
|
|
274
|
+
spec["integer"] = t == "integer"
|
|
275
|
+
for src, dst in (
|
|
276
|
+
("minimum", "min"),
|
|
277
|
+
("maximum", "max"),
|
|
278
|
+
("exclusiveMinimum", "min"),
|
|
279
|
+
("exclusiveMaximum", "max"),
|
|
280
|
+
("multipleOf", "step"),
|
|
281
|
+
):
|
|
282
|
+
if src in prop:
|
|
283
|
+
spec[dst] = prop[src]
|
|
284
|
+
elif t == "boolean":
|
|
285
|
+
spec["widget"] = "switch"
|
|
286
|
+
elif t == "array":
|
|
287
|
+
items = self.resolve(prop.get("items", {}))
|
|
288
|
+
if "enum" in items:
|
|
289
|
+
spec["widget"] = "checkboxes"
|
|
290
|
+
spec["options"] = [self.option(v) for v in items["enum"]]
|
|
291
|
+
if "maxItems" in prop:
|
|
292
|
+
spec["max_items"] = prop["maxItems"]
|
|
293
|
+
if prop.get("sortable"):
|
|
294
|
+
spec["sortable"] = True
|
|
295
|
+
elif _is_file_ref(items):
|
|
296
|
+
spec["widget"] = "files"
|
|
297
|
+
for k in ("accept", "max_size_mb", "max_files"):
|
|
298
|
+
if k in prop:
|
|
299
|
+
spec[k] = prop[k]
|
|
300
|
+
elif (
|
|
301
|
+
items.get("type") == "object"
|
|
302
|
+
or "properties" in items
|
|
303
|
+
or "oneOf" in items
|
|
304
|
+
or "discriminator" in items
|
|
305
|
+
):
|
|
306
|
+
spec["widget"] = "list"
|
|
307
|
+
# Fields of an item carry the list's pointer without an index
|
|
308
|
+
# (``/items/title``): labels, file fields and the tab's draft
|
|
309
|
+
# pointers (which add the index) all find them there.
|
|
310
|
+
item_spec: dict[str, Any] = {"widget": "fieldset", "pointer": spec["pointer"]}
|
|
311
|
+
self.classify(items, item_spec, name)
|
|
312
|
+
spec["item"] = item_spec
|
|
313
|
+
spec["item_label"] = items.get("title") or humanize(name)
|
|
314
|
+
else:
|
|
315
|
+
spec["widget"] = "tags"
|
|
316
|
+
item_spec = {"widget": "input"}
|
|
317
|
+
self.classify(items, item_spec, name)
|
|
318
|
+
spec["item"] = item_spec
|
|
319
|
+
elif _is_file_ref(prop):
|
|
320
|
+
spec["widget"] = "file"
|
|
321
|
+
for k in ("accept", "max_size_mb"):
|
|
322
|
+
if k in prop:
|
|
323
|
+
spec[k] = prop[k]
|
|
324
|
+
elif t == "object" or "properties" in prop:
|
|
325
|
+
spec["widget"] = "fieldset"
|
|
326
|
+
spec["fields"] = self.fields(prop, spec.get("pointer", ""))
|
|
327
|
+
spec["title"] = prop.get("title")
|
|
328
|
+
else:
|
|
329
|
+
spec["widget"] = "input"
|
|
330
|
+
|
|
331
|
+
def union(self, prop: Mapping[str, Any], spec: dict[str, Any]) -> None:
|
|
332
|
+
branches = [self.resolve(b) for b in prop.get("oneOf") or prop.get("anyOf") or []]
|
|
333
|
+
disc = prop.get("discriminator", {})
|
|
334
|
+
key = disc.get("propertyName") if isinstance(disc, Mapping) else None
|
|
335
|
+
variants: list[dict[str, Any]] = []
|
|
336
|
+
for b in branches:
|
|
337
|
+
tag = None
|
|
338
|
+
props = b.get("properties", {})
|
|
339
|
+
if key and key in props:
|
|
340
|
+
tag_schema = self.resolve(props[key])
|
|
341
|
+
tag = tag_schema.get("const") or (tag_schema.get("enum") or [None])[0]
|
|
342
|
+
pointer = spec.get("pointer", "")
|
|
343
|
+
fields = [f for f in self.fields(b, pointer) if f["name"] != key]
|
|
344
|
+
variants.append(
|
|
345
|
+
{"tag": tag, "label": b.get("title") or humanize(str(tag)), "fields": fields}
|
|
346
|
+
)
|
|
347
|
+
spec["widget"] = "union"
|
|
348
|
+
spec["discriminator"] = key
|
|
349
|
+
spec["variants"] = variants
|