ourui 0.1.0__tar.gz

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.
ourui-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OurUI contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ourui-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: ourui
3
+ Version: 0.1.0
4
+ Summary: Python-first UI compiler — write intent in Python, emit HTML/CSS/JS
5
+ Author: OurUI contributors
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 OurUI contributors
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Keywords: ui,compiler,python,html,ourui
29
+ Classifier: Development Status :: 4 - Beta
30
+ Classifier: Environment :: Console
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Programming Language :: Python :: 3.11
35
+ Classifier: Programming Language :: Python :: 3.12
36
+ Classifier: Programming Language :: Python :: 3.13
37
+ Classifier: Topic :: Software Development :: Code Generators
38
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
39
+ Requires-Python: >=3.11
40
+ Description-Content-Type: text/markdown
41
+ License-File: LICENSE
42
+ Dynamic: license-file
43
+
44
+ # ourui 0.1.0
45
+
46
+ Python package for the **OurUI** compiler and runtime.
47
+
48
+ **Developer writes intent. Compiler writes implementation. Host receives primitives.**
49
+
50
+ ## Install
51
+
52
+ From a clone of the repository:
53
+
54
+ ```bash
55
+ python3 -m venv .venv
56
+ source .venv/bin/activate
57
+ pip install -e packages/ourui
58
+ ```
59
+
60
+ Build distributable artifacts (wheel + sdist):
61
+
62
+ ```bash
63
+ pip install build
64
+ python -m build packages/ourui
65
+ # artifacts under packages/ourui/dist/
66
+ ```
67
+
68
+ ## Quick commands
69
+
70
+ ```bash
71
+ ourui dump path/to/app.py
72
+ ourui emit path/to/app.py
73
+ ourui serve path/to/app.py
74
+ ourui serve path/to/app.py --prod
75
+ ourui lsp
76
+ ```
77
+
78
+ ## Documentation
79
+
80
+ - User guide: [`docs/user/`](../../docs/user/README.md) (from repo root)
81
+ - Changelog: [`CHANGELOG.md`](../../CHANGELOG.md)
82
+ - License: MIT ([`LICENSE`](LICENSE))
83
+
84
+ ## Requirements
85
+
86
+ - Python 3.11+
87
+ - No runtime dependencies (stdlib only)
ourui-0.1.0/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # ourui 0.1.0
2
+
3
+ Python package for the **OurUI** compiler and runtime.
4
+
5
+ **Developer writes intent. Compiler writes implementation. Host receives primitives.**
6
+
7
+ ## Install
8
+
9
+ From a clone of the repository:
10
+
11
+ ```bash
12
+ python3 -m venv .venv
13
+ source .venv/bin/activate
14
+ pip install -e packages/ourui
15
+ ```
16
+
17
+ Build distributable artifacts (wheel + sdist):
18
+
19
+ ```bash
20
+ pip install build
21
+ python -m build packages/ourui
22
+ # artifacts under packages/ourui/dist/
23
+ ```
24
+
25
+ ## Quick commands
26
+
27
+ ```bash
28
+ ourui dump path/to/app.py
29
+ ourui emit path/to/app.py
30
+ ourui serve path/to/app.py
31
+ ourui serve path/to/app.py --prod
32
+ ourui lsp
33
+ ```
34
+
35
+ ## Documentation
36
+
37
+ - User guide: [`docs/user/`](../../docs/user/README.md) (from repo root)
38
+ - Changelog: [`CHANGELOG.md`](../../CHANGELOG.md)
39
+ - License: MIT ([`LICENSE`](LICENSE))
40
+
41
+ ## Requirements
42
+
43
+ - Python 3.11+
44
+ - No runtime dependencies (stdlib only)
@@ -0,0 +1,6 @@
1
+ """OurUI authoring surface and compiler package."""
2
+
3
+ from ourui.ui import Component, State, component, server, ui
4
+
5
+ __all__ = ["ui", "server", "State", "Component", "component"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,337 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ourui.analysis.components import (
9
+ collect_components,
10
+ component_call_name,
11
+ expand_component_call,
12
+ )
13
+ from ourui.node import THEME_ATTR_KEYS, Node
14
+ from ourui.parse import call_kind, literal_value, parse_file, span_for
15
+ from ourui.theme import apply_theme_overrides, default_tokens, theme_kwargs_to_overrides
16
+
17
+
18
+ @dataclass
19
+ class SemanticGraph:
20
+ nodes: dict[str, Node] = field(default_factory=dict)
21
+ roots: list[str] = field(default_factory=list)
22
+ edges: list[dict[str, str]] = field(default_factory=list)
23
+ handlers: dict[str, dict[str, Any]] = field(default_factory=dict)
24
+ states: dict[str, dict[str, Any]] = field(default_factory=dict)
25
+ components: dict[str, dict[str, Any]] = field(default_factory=dict)
26
+ routes: dict[str, str] = field(default_factory=dict)
27
+ tokens: dict[str, dict[str, str]] = field(default_factory=dict)
28
+
29
+ def to_dict(self) -> dict[str, Any]:
30
+ return {
31
+ "nodes": {nid: n.to_dict() for nid, n in sorted(self.nodes.items())},
32
+ "roots": list(self.roots),
33
+ "routes": {k: self.routes[k] for k in sorted(self.routes)},
34
+ "edges": sorted(self.edges, key=lambda e: (e["from"], e["to"], e["kind"])),
35
+ "handlers": {k: self.handlers[k] for k in sorted(self.handlers)},
36
+ "states": {k: self.states[k] for k in sorted(self.states)},
37
+ "components": {k: self.components[k] for k in sorted(self.components)},
38
+ "tokens": {
39
+ "light": {k: self.tokens.get("light", {})[k] for k in sorted(self.tokens.get("light", {}))},
40
+ "dark": {k: self.tokens.get("dark", {})[k] for k in sorted(self.tokens.get("dark", {}))},
41
+ },
42
+ }
43
+
44
+
45
+ @dataclass
46
+ class DependencyGraph:
47
+ edges: list[dict[str, str]] = field(default_factory=list)
48
+
49
+ def to_dict(self) -> dict[str, Any]:
50
+ return {
51
+ "edges": sorted(self.edges, key=lambda e: (e["from"], e["to"], e["kind"])),
52
+ }
53
+
54
+
55
+ def _is_server_decorator(dec: ast.AST) -> bool:
56
+ if isinstance(dec, ast.Name) and dec.id == "server":
57
+ return True
58
+ if isinstance(dec, ast.Attribute) and dec.attr == "server":
59
+ return True
60
+ return False
61
+
62
+
63
+ def _is_state_call(call: ast.Call) -> bool:
64
+ if isinstance(call.func, ast.Name) and call.func.id == "State":
65
+ return True
66
+ if isinstance(call.func, ast.Attribute) and call.func.attr == "State":
67
+ return True
68
+ return False
69
+
70
+
71
+ def _is_theme_call(call: ast.Call) -> bool:
72
+ if isinstance(call.func, ast.Attribute) and call.func.attr == "Theme":
73
+ if isinstance(call.func.value, ast.Name) and call.func.value.id == "ui":
74
+ return True
75
+ if isinstance(call.func, ast.Name) and call.func.id == "Theme":
76
+ return True
77
+ return False
78
+
79
+
80
+ class _GraphBuilder:
81
+ def __init__(self, path: str, components: dict) -> None:
82
+ self.path = path
83
+ self.counter = 0
84
+ self.graph = SemanticGraph(tokens=default_tokens())
85
+ self.dep = DependencyGraph()
86
+ self._theme_nodes: dict[str, str] = {}
87
+ self._page_route_by_node: dict[str, str] = {}
88
+ self.components = components
89
+ for name, cdef in components.items():
90
+ self.graph.components[name] = {
91
+ "name": name,
92
+ "style": cdef.style,
93
+ "params": list(cdef.params),
94
+ "line": cdef.span_line,
95
+ }
96
+
97
+ def next_id(self, prefix: str = "n") -> str:
98
+ self.counter += 1
99
+ return f"{prefix}{self.counter:04d}"
100
+
101
+ def theme_node(self, token: str, at: ast.AST) -> str:
102
+ if token in self._theme_nodes:
103
+ return self._theme_nodes[token]
104
+ nid = self.next_id("theme")
105
+ node = Node(
106
+ id=nid,
107
+ kind="ThemeToken",
108
+ span=span_for(self.path, at),
109
+ attributes={"token": token},
110
+ provenance=["analyze:theme_token"],
111
+ ).with_hash()
112
+ self.graph.nodes[nid] = node
113
+ self._theme_nodes[token] = nid
114
+ return nid
115
+
116
+ def register_handler(self, name: str, *, kind: str, at: ast.AST) -> None:
117
+ self.graph.handlers[name] = {
118
+ "name": name,
119
+ "kind": kind,
120
+ "span": span_for(self.path, at).to_dict(),
121
+ }
122
+
123
+ def register_state(self, name: str, call: ast.Call) -> None:
124
+ initial: Any = None
125
+ if call.args:
126
+ initial = literal_value(call.args[0])
127
+ self.graph.states[name] = {
128
+ "name": name,
129
+ "initial": initial,
130
+ "span": span_for(self.path, call).to_dict(),
131
+ }
132
+
133
+ def register_theme(self, call: ast.Call) -> None:
134
+ attrs: dict[str, Any] = {}
135
+ for kw in call.keywords:
136
+ if kw.arg is None:
137
+ continue
138
+ attrs[kw.arg] = literal_value(kw.value)
139
+ light, dark = theme_kwargs_to_overrides(attrs)
140
+ self.graph.tokens = apply_theme_overrides(self.graph.tokens, light=light, dark=dark)
141
+
142
+ def _maybe_state_ref(self, node: ast.AST) -> dict[str, str] | None:
143
+ if isinstance(node, ast.Name) and node.id in self.graph.states:
144
+ return {"__state__": node.id}
145
+ return None
146
+
147
+ def _resolve_call(self, call: ast.Call, trail: list[str]) -> tuple[ast.Call, list[str]]:
148
+ """Expand user components until a ui.* call remains."""
149
+ while component_call_name(call, self.components):
150
+ name = component_call_name(call, self.components)
151
+ assert name is not None
152
+ expanded = expand_component_call(call, self.components)
153
+ if expanded is None:
154
+ break
155
+ trail = [*trail, name]
156
+ call = expanded
157
+ return call, trail
158
+
159
+ def build_call(
160
+ self,
161
+ call: ast.Call,
162
+ parent_id: str | None = None,
163
+ expansion_trail: list[str] | None = None,
164
+ ) -> str | None:
165
+ trail = list(expansion_trail or [])
166
+ call, trail = self._resolve_call(call, trail)
167
+
168
+ kind = call_kind(call)
169
+ if kind is None:
170
+ return None
171
+
172
+ nid = self.next_id()
173
+ attrs: dict[str, Any] = {}
174
+ child_ids: list[str] = []
175
+
176
+ for arg in call.args:
177
+ if isinstance(arg, ast.Call):
178
+ cid = self.build_call(arg, parent_id=nid, expansion_trail=trail)
179
+ if cid:
180
+ child_ids.append(cid)
181
+ continue
182
+ if isinstance(arg, ast.List):
183
+ for elt in arg.elts:
184
+ if isinstance(elt, ast.Call):
185
+ cid = self.build_call(elt, parent_id=nid, expansion_trail=trail)
186
+ if cid:
187
+ child_ids.append(cid)
188
+ continue
189
+ state_ref = self._maybe_state_ref(arg)
190
+ if state_ref and kind in {"Button", "Text", "Card"} and "text" not in attrs:
191
+ attrs["text"] = state_ref
192
+ continue
193
+ val = literal_value(arg)
194
+ if isinstance(val, str):
195
+ if kind in {"Button", "Text", "Card"} and "text" not in attrs:
196
+ attrs["text"] = val
197
+ elif "title" not in attrs:
198
+ attrs["title"] = val
199
+ else:
200
+ attrs.setdefault("_args", []).append(val)
201
+ else:
202
+ attrs.setdefault("_args", []).append(val)
203
+
204
+ for kw in call.keywords:
205
+ if kw.arg is None:
206
+ continue
207
+ if kw.arg == "children":
208
+ val_node = kw.value
209
+ if isinstance(val_node, (ast.List, ast.Tuple)):
210
+ for elt in val_node.elts:
211
+ if isinstance(elt, ast.Call):
212
+ cid = self.build_call(elt, parent_id=nid, expansion_trail=trail)
213
+ if cid:
214
+ child_ids.append(cid)
215
+ elif isinstance(val_node, ast.Call):
216
+ cid = self.build_call(val_node, parent_id=nid, expansion_trail=trail)
217
+ if cid:
218
+ child_ids.append(cid)
219
+ continue
220
+ if kw.arg == "route" and kind == "Page":
221
+ route_val = literal_value(kw.value)
222
+ if isinstance(route_val, str):
223
+ self._page_route_by_node[nid] = route_val
224
+ continue
225
+ if kw.arg == "on_click":
226
+ if isinstance(kw.value, ast.Name):
227
+ attrs["on_click"] = {"__handler__": kw.value.id}
228
+ elif isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
229
+ attrs["on_click"] = {"__handler__": kw.value.value}
230
+ else:
231
+ attrs["on_click"] = literal_value(kw.value)
232
+ continue
233
+ if kw.arg in {"text", "title", "subtitle", "bind"}:
234
+ state_ref = self._maybe_state_ref(kw.value)
235
+ if state_ref:
236
+ if kw.arg == "bind":
237
+ attrs["text"] = state_ref
238
+ else:
239
+ attrs[kw.arg] = state_ref
240
+ continue
241
+ if isinstance(kw.value, ast.Call):
242
+ cid = self.build_call(kw.value, parent_id=nid, expansion_trail=trail)
243
+ if cid:
244
+ attrs[kw.arg] = {"__node__": cid}
245
+ child_ids.append(cid)
246
+ continue
247
+ attrs[kw.arg] = literal_value(kw.value)
248
+
249
+ provenance = ["parse:ui_call", "analyze:semantic_graph", *[f"expand:{n}" for n in trail]]
250
+ node = Node(
251
+ id=nid,
252
+ kind=kind,
253
+ span=span_for(self.path, call),
254
+ attributes=attrs,
255
+ children=child_ids,
256
+ provenance=provenance,
257
+ metadata={"expanded_from": list(trail)} if trail else {},
258
+ ).with_hash()
259
+ self.graph.nodes[nid] = node
260
+
261
+ if parent_id:
262
+ self.graph.edges.append({"from": parent_id, "to": nid, "kind": "contains"})
263
+ else:
264
+ self.graph.roots.append(nid)
265
+ if kind == "Page" and nid in self._page_route_by_node:
266
+ route_path = self._page_route_by_node[nid]
267
+ if route_path in self.graph.routes:
268
+ raise ValueError(f"Duplicate route: {route_path!r}")
269
+ self.graph.routes[route_path] = nid
270
+
271
+ for key, value in attrs.items():
272
+ if key in THEME_ATTR_KEYS and isinstance(value, str):
273
+ tid = self.theme_node(value, call)
274
+ self.dep.edges.append({"from": nid, "to": tid, "kind": "uses_theme"})
275
+ self.graph.edges.append({"from": nid, "to": tid, "kind": "uses_theme"})
276
+ if key == "on_click" and isinstance(value, dict) and "__handler__" in value:
277
+ handler = value["__handler__"]
278
+ self.dep.edges.append({"from": nid, "to": handler, "kind": "on_click"})
279
+ self.graph.edges.append({"from": nid, "to": handler, "kind": "calls_handler"})
280
+ if isinstance(value, dict) and "__state__" in value:
281
+ state_name = value["__state__"]
282
+ self.dep.edges.append({"from": nid, "to": state_name, "kind": "reads_state"})
283
+ self.graph.edges.append({"from": nid, "to": state_name, "kind": "reads_state"})
284
+
285
+ return nid
286
+
287
+ def visit_module(self, module: ast.Module) -> None:
288
+ for stmt in module.body:
289
+ if isinstance(stmt, ast.FunctionDef):
290
+ if any(_is_server_decorator(d) for d in stmt.decorator_list):
291
+ self.register_handler(stmt.name, kind="server", at=stmt)
292
+ elif stmt.name not in self.components:
293
+ self.register_handler(stmt.name, kind="client", at=stmt)
294
+ elif isinstance(stmt, ast.Assign):
295
+ if isinstance(stmt.value, ast.Call) and _is_state_call(stmt.value):
296
+ for target in stmt.targets:
297
+ if isinstance(target, ast.Name):
298
+ self.register_state(target.id, stmt.value)
299
+ elif isinstance(stmt.value, ast.Call) and _is_theme_call(stmt.value):
300
+ self.register_theme(stmt.value)
301
+ elif isinstance(stmt.value, ast.Call):
302
+ self.build_call(stmt.value)
303
+ elif isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
304
+ if _is_theme_call(stmt.value):
305
+ self.register_theme(stmt.value)
306
+ else:
307
+ self.build_call(stmt.value)
308
+ self._finalize_routes()
309
+
310
+ def _finalize_routes(self) -> None:
311
+ page_roots = [nid for nid in self.graph.roots if self.graph.nodes[nid].kind == "Page"]
312
+ unrouted = [nid for nid in page_roots if nid not in self._page_route_by_node]
313
+ if len(page_roots) == 1 and not self.graph.routes:
314
+ self.graph.routes["/"] = page_roots[0]
315
+ elif unrouted:
316
+ raise ValueError(
317
+ "Multiple ui.Page definitions require route= on each page "
318
+ f"(missing route on {len(unrouted)} page(s))"
319
+ )
320
+
321
+
322
+ def _display_path(path: Path) -> str:
323
+ path = path.resolve()
324
+ try:
325
+ return path.relative_to(Path.cwd().resolve()).as_posix()
326
+ except ValueError:
327
+ return path.as_posix()
328
+
329
+
330
+ def build_semantic_graph(path: str | Path) -> tuple[SemanticGraph, DependencyGraph]:
331
+ path = Path(path)
332
+ module = parse_file(path)
333
+ components = collect_components(module)
334
+ builder = _GraphBuilder(_display_path(path), components)
335
+ builder.visit_module(module)
336
+ builder.graph.roots = sorted(set(builder.graph.roots))
337
+ return builder.graph, builder.dep
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import copy
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class ComponentDef:
11
+ name: str
12
+ params: list[str]
13
+ template: ast.Call
14
+ style: str # "function" | "class"
15
+ span_line: int = 0
16
+
17
+
18
+ def _is_ui_call(call: ast.Call) -> bool:
19
+ func = call.func
20
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
21
+ return func.value.id == "ui"
22
+ return False
23
+
24
+
25
+ def _inherits_component(bases: list[ast.expr]) -> bool:
26
+ for base in bases:
27
+ if isinstance(base, ast.Name) and base.id == "Component":
28
+ return True
29
+ if isinstance(base, ast.Attribute) and base.attr == "Component":
30
+ return True
31
+ return False
32
+
33
+
34
+ def _return_call(fn: ast.FunctionDef) -> ast.Call | None:
35
+ body = [s for s in fn.body if not isinstance(s, (ast.Pass, ast.Expr))]
36
+ # allow docstring Expr as first
37
+ stmts = list(fn.body)
38
+ if stmts and isinstance(stmts[0], ast.Expr) and isinstance(stmts[0].value, ast.Constant):
39
+ stmts = stmts[1:]
40
+ if len(stmts) != 1 or not isinstance(stmts[0], ast.Return):
41
+ return None
42
+ value = stmts[0].value
43
+ if isinstance(value, ast.Call):
44
+ return value
45
+ return None
46
+
47
+
48
+ def collect_components(module: ast.Module) -> dict[str, ComponentDef]:
49
+ components: dict[str, ComponentDef] = {}
50
+
51
+ for stmt in module.body:
52
+ if isinstance(stmt, ast.FunctionDef):
53
+ # skip @server handlers
54
+ if any(
55
+ (isinstance(d, ast.Name) and d.id == "server")
56
+ or (isinstance(d, ast.Attribute) and d.attr == "server")
57
+ for d in stmt.decorator_list
58
+ ):
59
+ continue
60
+ ret = _return_call(stmt)
61
+ if ret is None:
62
+ continue
63
+ # Must be ui.* or another component name (resolved later)
64
+ params = [a.arg for a in stmt.args.args]
65
+ components[stmt.name] = ComponentDef(
66
+ name=stmt.name,
67
+ params=params,
68
+ template=ret,
69
+ style="function",
70
+ span_line=stmt.lineno,
71
+ )
72
+ elif isinstance(stmt, ast.ClassDef) and _inherits_component(stmt.bases):
73
+ init_params: list[str] = []
74
+ build_fn: ast.FunctionDef | None = None
75
+ for item in stmt.body:
76
+ if isinstance(item, ast.FunctionDef) and item.name == "__init__":
77
+ # skip self
78
+ init_params = [a.arg for a in item.args.args[1:]]
79
+ if isinstance(item, ast.FunctionDef) and item.name == "build":
80
+ build_fn = item
81
+ if build_fn is None:
82
+ continue
83
+ ret = _return_call(build_fn)
84
+ if ret is None:
85
+ continue
86
+ # Prefer __init__ params; else build params without self
87
+ params = init_params or [a.arg for a in build_fn.args.args[1:]]
88
+ components[stmt.name] = ComponentDef(
89
+ name=stmt.name,
90
+ params=params,
91
+ template=ret,
92
+ style="class",
93
+ span_line=stmt.lineno,
94
+ )
95
+
96
+ return components
97
+
98
+
99
+ class _Subst(ast.NodeTransformer):
100
+ def __init__(self, mapping: dict[str, ast.AST], *, class_style: bool) -> None:
101
+ self.mapping = mapping
102
+ self.class_style = class_style
103
+
104
+ def visit_Name(self, node: ast.Name) -> ast.AST:
105
+ if node.id in self.mapping:
106
+ return copy.deepcopy(self.mapping[node.id])
107
+ return node
108
+
109
+ def visit_Attribute(self, node: ast.Attribute) -> ast.AST:
110
+ self.generic_visit(node)
111
+ if (
112
+ self.class_style
113
+ and isinstance(node.value, ast.Name)
114
+ and node.value.id == "self"
115
+ and node.attr in self.mapping
116
+ ):
117
+ return copy.deepcopy(self.mapping[node.attr])
118
+ return node
119
+
120
+
121
+ def bind_call_args(call: ast.Call, params: list[str]) -> dict[str, ast.AST]:
122
+ mapping: dict[str, ast.AST] = {}
123
+ for i, arg in enumerate(call.args):
124
+ if i < len(params):
125
+ mapping[params[i]] = arg
126
+ for kw in call.keywords:
127
+ if kw.arg and kw.arg in params:
128
+ mapping[kw.arg] = kw.value
129
+ return mapping
130
+
131
+
132
+ def expand_component_call(
133
+ call: ast.Call,
134
+ components: dict[str, ComponentDef],
135
+ *,
136
+ depth: int = 0,
137
+ ) -> ast.Call | None:
138
+ """If call is a user component, return expanded ui.* Call (fully expanded)."""
139
+ if depth > 16:
140
+ raise RecursionError("component expansion exceeded depth limit")
141
+ if not isinstance(call.func, ast.Name):
142
+ return None
143
+ name = call.func.id
144
+ if name not in components:
145
+ return None
146
+ cdef = components[name]
147
+ mapping = bind_call_args(call, cdef.params)
148
+ template = copy.deepcopy(cdef.template)
149
+ substituted = _Subst(mapping, class_style=cdef.style == "class").visit(template)
150
+ ast.fix_missing_locations(substituted)
151
+ if not isinstance(substituted, ast.Call):
152
+ return None
153
+ # Recurse if still a component
154
+ while isinstance(substituted.func, ast.Name) and substituted.func.id in components:
155
+ nested = expand_component_call(substituted, components, depth=depth + 1)
156
+ if nested is None:
157
+ break
158
+ substituted = nested
159
+ return substituted
160
+
161
+
162
+ def component_call_name(call: ast.Call, components: dict[str, ComponentDef]) -> str | None:
163
+ if isinstance(call.func, ast.Name) and call.func.id in components:
164
+ return call.func.id
165
+ return None