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
@@ -0,0 +1,145 @@
1
+ ---
2
+ title: Text
3
+ order: 1
4
+ ---
5
+
6
+ # Text
7
+
8
+ Words on a page: plain text with a variant and a tone, Markdown, code, and
9
+ the small pieces that label things.
10
+
11
+ ## Text
12
+
13
+ `ui.Text` is a string with a `variant`: `body`, `muted`, `caption`, `label`,
14
+ `title`, `heading`, `subheading` or `mono`. `tone` colours it, `truncate`
15
+ keeps it to one line, `align` sets the alignment.
16
+
17
+ ```python live
18
+ ui.Stack(
19
+ ui.Text("Heading", variant="heading"),
20
+ ui.Text("Title", variant="title"),
21
+ ui.Text("Subheading", variant="subheading"),
22
+ ui.Text("Body text, the default."),
23
+ ui.Text("Muted text for secondary information.", variant="muted"),
24
+ ui.Text("A caption.", variant="caption"),
25
+ ui.Text("A label", variant="label"),
26
+ ui.Text("mono: for identifiers and paths", variant="mono"),
27
+ ui.Row(
28
+ ui.Text("success", tone="success"),
29
+ ui.Text("warning", tone="warning"),
30
+ ui.Text("destructive", tone="destructive"),
31
+ ui.Text("primary", tone="primary"),
32
+ gap=4,
33
+ ),
34
+ ui.Text(
35
+ "A long line that is cut with an ellipsis when it does not fit the width it is given, however long it goes on.",
36
+ truncate=True,
37
+ ),
38
+ gap=2,
39
+ )
40
+ ```
41
+
42
+ ## Markdown
43
+
44
+ `ui.Markdown` renders GitHub-flavoured Markdown as real elements: headings,
45
+ lists, tables, links and code. `tight=True` removes the outer spacing for use
46
+ inside a card or a table cell.
47
+
48
+ ```python live
49
+ ui.Markdown("""
50
+ ### A heading inside Markdown
51
+
52
+ Text with **bold**, *italic*, `code` and a [link](https://example.com).
53
+
54
+ - a list
55
+ - with items
56
+
57
+ | Column | Value |
58
+ | --- | --- |
59
+ | one | 1 |
60
+ | two | 2 |
61
+ """)
62
+ ```
63
+
64
+ ## Code
65
+
66
+ `ui.Code` shows a block of code with a `language` for highlighting, an
67
+ optional `title`, a copy button (`copyable`, on by default), `wrap` for long
68
+ lines and `max_height` to scroll past.
69
+
70
+ ```python live
71
+ ui.Code(
72
+ 'def greet(name: str) -> str:\n return f"Hello, {name}."\n',
73
+ language="python",
74
+ title="greet.py",
75
+ )
76
+ ```
77
+
78
+ ## Badge
79
+
80
+ `ui.Badge` is a small label with a `tone` and an optional `icon`.
81
+
82
+ ```python live
83
+ ui.Row(
84
+ ui.Badge("default"),
85
+ ui.Badge("muted", tone="muted"),
86
+ ui.Badge("primary", tone="primary"),
87
+ ui.Badge("accent", tone="accent"),
88
+ ui.Badge("success", tone="success", icon="check"),
89
+ ui.Badge("warning", tone="warning"),
90
+ ui.Badge("destructive", tone="destructive"),
91
+ gap=2,
92
+ wrap=True,
93
+ )
94
+ ```
95
+
96
+ ## Kbd
97
+
98
+ `ui.Kbd` shows keys as keycaps.
99
+
100
+ ```python live
101
+ ui.Row(
102
+ ui.Text("Save with"),
103
+ ui.Kbd(keys=["Ctrl", "S"]),
104
+ ui.Text("or press"),
105
+ ui.Kbd(keys=["Enter"]),
106
+ gap=2,
107
+ )
108
+ ```
109
+
110
+ ## Link
111
+
112
+ `ui.Link` is a hyperlink. A path inside the app navigates over the session
113
+ without a reload; `target="_blank"` opens a new tab.
114
+
115
+ ```python live
116
+ ui.Row(
117
+ ui.Link(label="Welcome, inside the app", href="/"),
118
+ ui.Link(
119
+ label="An outside site", href="https://example.com", target="_blank", icon="external-link"
120
+ ),
121
+ gap=6,
122
+ )
123
+ ```
124
+
125
+ ## Stat
126
+
127
+ `ui.Stat` is a number with a `label`, an optional `hint`, a `tone` and an
128
+ `icon`, for telemetry and summaries.
129
+
130
+ ```python live
131
+ ui.Grid(
132
+ ui.Stat(label="Tokens", value="12,480", hint="this session", icon="hash"),
133
+ ui.Stat(label="Cost", value="€0.42", tone="success", icon="zap"),
134
+ ui.Stat(label="Latency", value="1.9 s", hint="p95", tone="warning", icon="clock"),
135
+ cols={"sm": 1, "md": 3},
136
+ )
137
+ ```
138
+
139
+ ## Tooltip
140
+
141
+ `ui.Tooltip` wraps one child and shows `content` on hover or focus.
142
+
143
+ ```python live
144
+ ui.Tooltip(ui.Button("Hover me", variant="outline"), content="More about this button.")
145
+ ```
@@ -0,0 +1,67 @@
1
+ ---
2
+ title: Welcome
3
+ order: 0
4
+ ---
5
+
6
+ # millwork
7
+
8
+ millwork is a Python framework for data tools and AI applications. The browser
9
+ runs one prebuilt React bundle; Python describes the page and handles its
10
+ events. The project is Python only: the bundle is prebuilt, and Python owns
11
+ every piece of state.
12
+
13
+ ```python
14
+ import millwork as mw
15
+ from millwork import ui
16
+
17
+ app = mw.App("hello")
18
+
19
+
20
+ @app.page("/")
21
+ def home(s: mw.Session) -> ui.Component:
22
+ return ui.Page(ui.Text("Hello."))
23
+ ```
24
+
25
+ Run it with `millwork run app.py` and open the address it prints.
26
+
27
+ ## How this manual works
28
+
29
+ This manual is itself a millwork app. `millwork docs` serves it; its pages
30
+ are Markdown files inside the package, and the sidebar, the search box and
31
+ the header are catalogue components. Every example runs: the component you
32
+ see is produced by the Python shown beneath it, executed by the server that
33
+ serves these pages. Try the button:
34
+
35
+ ```python live
36
+ ui.Button("Say hello", icon="sparkles", on_click=lambda s, _: s.toast("Hello from Python."))
37
+ ```
38
+
39
+ An example may hold state. `state(Model)` returns an instance of your model
40
+ that lives for the session, so a handler can change it and the page re-renders:
41
+
42
+ ```python live
43
+ from pydantic import BaseModel
44
+
45
+
46
+ class Counter(BaseModel):
47
+ n: int = 0
48
+
49
+
50
+ def example(s):
51
+ st = state(Counter)
52
+ return ui.Row(
53
+ ui.Button("Add one", on_click=lambda s, _: setattr(st, "n", st.n + 1)),
54
+ ui.Text(f"Clicked {st.n} times.", variant="muted"),
55
+ )
56
+ ```
57
+
58
+ A block ends in an expression, which is the component, or defines
59
+ `example(s)`, a page function over the session. `mw` and `ui` are in scope;
60
+ everything else is imported, as in an app.
61
+
62
+ ## Where to go next
63
+
64
+ The App chapters say how an app is put together: pages and routing, state
65
+ and handlers, signing in, theming, testing, running and deploying. The
66
+ Components chapters walk through the catalogue, one group at a time, each
67
+ component in its states with the Python that makes it.
@@ -0,0 +1,2 @@
1
+ Inter, by Rasmus Andersson, is used under the SIL Open Font License 1.1.
2
+ https://github.com/rsms/inter
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
2
+ <title>millwork</title>
3
+ <defs>
4
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
5
+ <stop offset="0" stop-color="#1fb3d3"/>
6
+ <stop offset=".55" stop-color="#0a7ea4"/>
7
+ <stop offset="1" stop-color="#6d5ae0"/>
8
+ </linearGradient>
9
+ </defs>
10
+ <g fill="url(#g)">
11
+ <path id="b" d="M24 23C17 18 15 9 19 5c5-4 13 3 13 10 0 5-3 7-8 8Z"/>
12
+ <use href="#b" transform="rotate(120 24 24)"/>
13
+ <use href="#b" transform="rotate(240 24 24)"/>
14
+ </g>
15
+ </svg>
@@ -0,0 +1,97 @@
1
+ {
2
+ "mode": "system",
3
+ "logo": "/public/mark.svg",
4
+ "favicon": "/public/mark.svg",
5
+ "fonts": [
6
+ {
7
+ "family": "Inter",
8
+ "src": "/public/fonts/InterVariable.woff2",
9
+ "weights": "100 900"
10
+ }
11
+ ],
12
+ "density": "comfortable",
13
+ "tokens": {
14
+ "light": {
15
+ "background": "#ffffff",
16
+ "foreground": "#16232e",
17
+ "card": "#ffffff",
18
+ "card-foreground": "#16232e",
19
+ "popover": "#ffffff",
20
+ "popover-foreground": "#16232e",
21
+ "primary": "#0a7ea4",
22
+ "primary-foreground": "#ffffff",
23
+ "secondary": "#f6f8f9",
24
+ "secondary-foreground": "#3d4c58",
25
+ "muted": "#f6f8f9",
26
+ "muted-foreground": "#5a6a76",
27
+ "accent": "#e9f5f9",
28
+ "accent-foreground": "#08637f",
29
+ "border": "#e4e9ed",
30
+ "input": "#cfd8de",
31
+ "ring": "#0a7ea4",
32
+ "destructive": "#b83232",
33
+ "destructive-foreground": "#ffffff",
34
+ "success": "#08637f",
35
+ "success-foreground": "#ffffff",
36
+ "warning": "#8a5a0a",
37
+ "warning-foreground": "#ffffff",
38
+ "info": "#6d5ae0",
39
+ "info-foreground": "#ffffff",
40
+ "sidebar": "#f6f8f9",
41
+ "sidebar-foreground": "#16232e",
42
+ "sidebar-primary": "#0a7ea4",
43
+ "sidebar-primary-foreground": "#ffffff",
44
+ "sidebar-accent": "#eaeff2",
45
+ "sidebar-accent-foreground": "#16232e",
46
+ "sidebar-border": "#e4e9ed",
47
+ "sidebar-ring": "#0a7ea4",
48
+ "chart-1": "#0a7ea4",
49
+ "chart-2": "#eb6834",
50
+ "chart-3": "#6d5ae0",
51
+ "chart-4": "#eda100",
52
+ "chart-5": "#d55181",
53
+ "radius": "0.5rem",
54
+ "font-sans": "'Inter', ui-sans-serif, system-ui, sans-serif"
55
+ },
56
+ "dark": {
57
+ "background": "#0f1519",
58
+ "foreground": "#e6edf2",
59
+ "card": "#171f25",
60
+ "card-foreground": "#e6edf2",
61
+ "popover": "#171f25",
62
+ "popover-foreground": "#e6edf2",
63
+ "primary": "#3fb0d8",
64
+ "primary-foreground": "#0f1519",
65
+ "secondary": "#171f25",
66
+ "secondary-foreground": "#c3ced6",
67
+ "muted": "#171f25",
68
+ "muted-foreground": "#93a3ae",
69
+ "accent": "#12303a",
70
+ "accent-foreground": "#72c8e6",
71
+ "border": "#26323b",
72
+ "input": "#364550",
73
+ "ring": "#3fb0d8",
74
+ "destructive": "#f08080",
75
+ "destructive-foreground": "#0f1519",
76
+ "success": "#72c8e6",
77
+ "success-foreground": "#0f1519",
78
+ "warning": "#f0c060",
79
+ "warning-foreground": "#0f1519",
80
+ "info": "#9a8cf0",
81
+ "info-foreground": "#0f1519",
82
+ "sidebar": "#141b20",
83
+ "sidebar-foreground": "#e6edf2",
84
+ "sidebar-primary": "#3fb0d8",
85
+ "sidebar-primary-foreground": "#0f1519",
86
+ "sidebar-accent": "#202a31",
87
+ "sidebar-accent-foreground": "#e6edf2",
88
+ "sidebar-border": "#26323b",
89
+ "sidebar-ring": "#3fb0d8",
90
+ "chart-1": "#2a9ac4",
91
+ "chart-2": "#d95926",
92
+ "chart-3": "#9085e9",
93
+ "chart-4": "#c98500",
94
+ "chart-5": "#d55181"
95
+ }
96
+ }
97
+ }
millwork/nodes.py ADDED
@@ -0,0 +1,300 @@
1
+ """Components, the tree they become, and the diff between two trees.
2
+
3
+ A page function returns a ``Component``. ``build`` walks it into a ``Tree``:
4
+ a flat table of wire ``Node``s with stable ids, plus the handlers those nodes
5
+ bound. ``diff`` turns the previous and the next ``Tree`` into patch ops.
6
+
7
+ Identity: a node's id is its parent's id, a slash, and either its ``key`` or
8
+ its position among the parent's children, counting ``None`` children so a
9
+ conditional sibling does not shift the ones after it. The position carries
10
+ the component type, so a different component in the same place is a
11
+ remove and an add, never a prop update on the wrong kind of node.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import datetime as dt
17
+ import enum
18
+ from collections.abc import Callable, Iterable, Mapping
19
+ from pathlib import PurePath
20
+ from typing import Any, ClassVar, Protocol, runtime_checkable
21
+
22
+ from pydantic import BaseModel, ConfigDict
23
+ from pydantic_core import to_jsonable_python
24
+
25
+ from millwork.protocol import AddOp, ChildrenOp, Node, PatchOp, RemoveOp, SetOp, UnsetOp
26
+
27
+ Handler = Callable[..., Any]
28
+ ROOT_ID = "root"
29
+
30
+
31
+ @runtime_checkable
32
+ class Wireable(Protocol):
33
+ """Anything that knows how to appear in props: tasks, file references."""
34
+
35
+ def __millwork_wire__(self) -> Any: ...
36
+
37
+
38
+ class Component(BaseModel):
39
+ """Base of every catalogue component. Subclasses set ``type``.
40
+
41
+ Children are positional: ``Card(Text("a"), Button("b"), title="t")``. A
42
+ bare string child becomes a ``Text``; ``None`` is skipped but keeps its
43
+ position. Props named ``on_*`` are handlers and never reach the wire;
44
+ the wire sees ``on_*: true`` so the bundle knows which events to emit.
45
+ """
46
+
47
+ model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
48
+
49
+ type: ClassVar[str] = "component"
50
+ key: str | None = None
51
+ hidden: bool = False
52
+ cls: str | None = None
53
+ children: list[Any] = []
54
+
55
+ def __init__(self, *children: Any, **props: Any) -> None:
56
+ if children and "children" in props:
57
+ raise TypeError("children are positional or keyword, not both")
58
+ if children:
59
+ props["children"] = list(children)
60
+ if "children" in props:
61
+ props["children"] = _flatten(props["children"])
62
+ super().__init__(**props)
63
+
64
+ # Fields that never reach the wire as they are (a callable evaluated at
65
+ # render, say); ``wire_extra`` supplies what goes instead.
66
+ wire_exclude: ClassVar[frozenset[str]] = frozenset()
67
+
68
+ def wire_extra(self) -> dict[str, Any]:
69
+ """Extra props computed at render time. Override in components that need it."""
70
+ return {}
71
+
72
+ def wire_type(self) -> str:
73
+ """The node type on the wire; a custom component names its own."""
74
+ return self.type
75
+
76
+
77
+ class Text(Component):
78
+ type: ClassVar[str] = "Text"
79
+ content: str = ""
80
+ variant: str = "body"
81
+ truncate: bool = False
82
+ align: str | None = None
83
+ tone: str | None = None
84
+
85
+ def __init__(self, content: str = "", **props: Any) -> None:
86
+ super().__init__(content=content, **props)
87
+
88
+
89
+ class Tree:
90
+ """A built page: flat nodes, the handlers they bound, and the root id."""
91
+
92
+ def __init__(self) -> None:
93
+ self.root: str = ROOT_ID
94
+ self.nodes: dict[str, Node] = {}
95
+ self.handlers: dict[tuple[str, str], Handler] = {}
96
+ self.callables: dict[tuple[str, str], Callable[..., Any]] = {}
97
+ self.components: dict[str, Component] = {}
98
+ # Nodes whose children include a run of keyless siblings of one type, and
99
+ # that type: a list built from data, if the run grows or shrinks later.
100
+ self.keyless_runs: dict[str, str] = {}
101
+
102
+ def handler(self, node_id: str, event: str) -> Handler | None:
103
+ return self.handlers.get((node_id, event))
104
+
105
+
106
+ def _flatten(children: Any) -> list[Any]:
107
+ """Lists and tuples among children are spliced in, so a helper may return a list."""
108
+ out: list[Any] = []
109
+ for child in children:
110
+ if isinstance(child, list | tuple):
111
+ out.extend(_flatten(child))
112
+ else:
113
+ out.append(child)
114
+ return out
115
+
116
+
117
+ def build(root: Component, root_id: str = ROOT_ID) -> Tree:
118
+ tree = Tree()
119
+ tree.root = root_id
120
+ _walk(root, root_id, tree)
121
+ return tree
122
+
123
+
124
+ def _walk(component: Component, node_id: str, tree: Tree) -> None:
125
+ props: dict[str, Any] = {}
126
+ for name, value in component:
127
+ if name in ("children", "key") or name in component.wire_exclude:
128
+ continue
129
+ if callable(value) and not isinstance(value, type):
130
+ if name.startswith("on_"):
131
+ tree.handlers[(node_id, name[3:])] = value
132
+ props[name] = True
133
+ else:
134
+ tree.callables[(node_id, name)] = value
135
+ props[name] = {"$callable": True}
136
+ continue
137
+ if isinstance(value, Component):
138
+ slot_id = f"{node_id}/@{name}"
139
+ _walk(value, slot_id, tree)
140
+ props[name] = {"$node": slot_id}
141
+ continue
142
+ if isinstance(value, list) and value and all(isinstance(v, Component) for v in value):
143
+ items: list[Any] = []
144
+ for index, item in enumerate(value):
145
+ slot_id = f"{node_id}/@{name}.{item.key or index}"
146
+ if slot_id in tree.nodes:
147
+ raise ValueError(f"duplicate key {item.key!r} in {name} of {node_id}")
148
+ _walk(item, slot_id, tree)
149
+ items.append({"$node": slot_id})
150
+ props[name] = items
151
+ continue
152
+ if value is None and name not in component.model_fields_set:
153
+ # Absent on the wire: a prop the page never set. An explicit ``value=None``
154
+ # is sent as null and means "controlled, and empty".
155
+ continue
156
+ props[name] = wire_value(value)
157
+ for name, value in component.wire_extra().items():
158
+ if isinstance(value, Component):
159
+ slot_id = f"{node_id}/@{name}"
160
+ _walk(value, slot_id, tree)
161
+ props[name] = {"$node": slot_id}
162
+ elif (
163
+ isinstance(value, dict)
164
+ and value
165
+ and all(isinstance(v, Component) for v in value.values())
166
+ ):
167
+ slots: dict[str, Any] = {}
168
+ for key, child in value.items():
169
+ slot_id = f"{node_id}/@{name}.{key}"
170
+ _walk(child, slot_id, tree)
171
+ slots[str(key)] = {"$node": slot_id}
172
+ props[name] = slots
173
+ else:
174
+ props[name] = wire_value(value)
175
+ for event, handler in getattr(component, "events", {}).items():
176
+ tree.handlers[(node_id, event)] = handler
177
+ props[f"on_{event}"] = True
178
+ if props.get("hidden") is False:
179
+ del props["hidden"]
180
+ children_ids: list[str] = []
181
+ for index, child in enumerate(component.children):
182
+ if child is None:
183
+ continue
184
+ if isinstance(child, str):
185
+ child = Text(child)
186
+ if not isinstance(child, Component):
187
+ raise TypeError(
188
+ f"{type(component).__name__} child {index} is {type(child).__name__}, "
189
+ "not a component or a string"
190
+ )
191
+ child_id = (
192
+ f"{node_id}/{child.key}" if child.key else f"{node_id}/{index}:{child.wire_type()}"
193
+ )
194
+ if child_id in tree.nodes:
195
+ raise ValueError(f"duplicate key {child.key!r} under {node_id}")
196
+ children_ids.append(child_id)
197
+ _walk(child, child_id, tree)
198
+ _check_keys(component, node_id, tree)
199
+ tree.nodes[node_id] = Node(
200
+ id=node_id, type=component.wire_type(), props=props, children=children_ids
201
+ )
202
+ tree.components[node_id] = component
203
+
204
+
205
+ def wire_value(value: Any) -> Any:
206
+ """Turn a prop value into JSON-ready data."""
207
+ if value is None or isinstance(value, bool | int | float | str):
208
+ return value
209
+ if isinstance(value, Wireable):
210
+ return value.__millwork_wire__()
211
+ if isinstance(value, BaseModel):
212
+ return value.model_dump(mode="json")
213
+ if isinstance(value, enum.Enum):
214
+ return value.value
215
+ if isinstance(value, dt.datetime | dt.date | dt.time):
216
+ return value.isoformat()
217
+ if isinstance(value, PurePath):
218
+ return str(value)
219
+ if isinstance(value, Mapping):
220
+ return {str(k): wire_value(v) for k, v in value.items()}
221
+ if isinstance(value, Iterable) and not isinstance(value, bytes | bytearray):
222
+ return [wire_value(v) for v in value]
223
+ try:
224
+ return to_jsonable_python(value)
225
+ except Exception as exc:
226
+ raise TypeError(f"cannot send a {type(value).__name__} as a prop") from exc
227
+
228
+
229
+ def diff(old: Tree | None, new: Tree) -> list[PatchOp]:
230
+ """Patch ops that take the client from ``old`` to ``new``.
231
+
232
+ Adds come first so references resolve, then updates, then removes.
233
+ """
234
+ if old is None:
235
+ raise ValueError("diff needs a previous tree; use a mount for the first render")
236
+ ops: list[PatchOp] = []
237
+ for node_id, node in new.nodes.items():
238
+ if node_id not in old.nodes:
239
+ ops.append(AddOp(node=node))
240
+ for node_id, node in new.nodes.items():
241
+ before = old.nodes.get(node_id)
242
+ if before is None:
243
+ continue
244
+ if before.type != node.type:
245
+ ops.append(RemoveOp(id=node_id))
246
+ ops.append(AddOp(node=node))
247
+ continue
248
+ changed = {k: v for k, v in node.props.items() if before.props.get(k, _MISSING) != v}
249
+ if changed:
250
+ ops.append(SetOp(id=node_id, props=changed))
251
+ gone = [k for k in before.props if k not in node.props]
252
+ if gone:
253
+ ops.append(UnsetOp(id=node_id, keys=gone))
254
+ if before.children != node.children:
255
+ ops.append(ChildrenOp(id=node_id, children=node.children))
256
+ for node_id in old.nodes:
257
+ if node_id not in new.nodes:
258
+ ops.append(RemoveOp(id=node_id))
259
+ return ops
260
+
261
+
262
+ def apply(nodes: dict[str, Node], ops: list[PatchOp]) -> dict[str, Node]:
263
+ """Apply patch ops to a node table, the way the bundle does. For tests."""
264
+ out = {k: v.model_copy(deep=True) for k, v in nodes.items()}
265
+ for op in ops:
266
+ if isinstance(op, AddOp):
267
+ out[op.node.id] = op.node.model_copy(deep=True)
268
+ elif isinstance(op, RemoveOp):
269
+ out.pop(op.id, None)
270
+ elif isinstance(op, SetOp):
271
+ out[op.id].props.update(op.props)
272
+ elif isinstance(op, UnsetOp):
273
+ for k in op.keys:
274
+ out[op.id].props.pop(k, None)
275
+ elif isinstance(op, ChildrenOp):
276
+ out[op.id].children = list(op.children)
277
+ return out
278
+
279
+
280
+ _MISSING = object()
281
+
282
+
283
+ # A run of this many keyless siblings of one type may be a list built from data,
284
+ # where a reorder would re-render every row instead of moving them. It is one
285
+ # only when its length changes between renders; a static layout never does.
286
+ KEYLESS_RUN = 4
287
+
288
+
289
+ def _check_keys(component: Component, node_id: str, tree: Tree) -> None:
290
+ run: list[Component] = []
291
+ for child in component.children:
292
+ if isinstance(child, Component) and child.key is None:
293
+ if run and type(child) is not type(run[-1]):
294
+ run = []
295
+ run.append(child)
296
+ if len(run) == KEYLESS_RUN:
297
+ tree.keyless_runs[node_id] = child.wire_type()
298
+ return
299
+ else:
300
+ run = []