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