ourui 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.
- ourui/__init__.py +6 -0
- ourui/analysis/__init__.py +337 -0
- ourui/analysis/components.py +165 -0
- ourui/cli.py +109 -0
- ourui/emit/__init__.py +4 -0
- ourui/emit/html.py +235 -0
- ourui/emit/js.py +73 -0
- ourui/lowering/__init__.py +105 -0
- ourui/lowering/intent.py +4 -0
- ourui/lowering/layout.py +89 -0
- ourui/lowering/render.py +137 -0
- ourui/lsp/__init__.py +6 -0
- ourui/lsp/completions.py +150 -0
- ourui/lsp/protocol.py +58 -0
- ourui/lsp/server.py +114 -0
- ourui/node.py +67 -0
- ourui/parse/__init__.py +69 -0
- ourui/pipeline.py +97 -0
- ourui/runtime/__init__.py +432 -0
- ourui/runtime/hmr.py +50 -0
- ourui/runtime/invoke.py +101 -0
- ourui/runtime/session.py +157 -0
- ourui/serialize/__init__.py +8 -0
- ourui/theme.py +162 -0
- ourui/ui.py +80 -0
- ourui-0.1.0.dist-info/METADATA +87 -0
- ourui-0.1.0.dist-info/RECORD +31 -0
- ourui-0.1.0.dist-info/WHEEL +5 -0
- ourui-0.1.0.dist-info/entry_points.txt +2 -0
- ourui-0.1.0.dist-info/licenses/LICENSE +21 -0
- ourui-0.1.0.dist-info/top_level.txt +1 -0
ourui/__init__.py
ADDED
|
@@ -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
|
ourui/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ourui.lsp.server import run_stdio_server
|
|
8
|
+
from ourui.pipeline import dump_json, emit_html
|
|
9
|
+
from ourui.runtime import serve
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: list[str] | None = None) -> int:
|
|
13
|
+
parser = argparse.ArgumentParser(prog="ourui", description="OurUI compiler CLI")
|
|
14
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
15
|
+
|
|
16
|
+
dump_p = sub.add_parser(
|
|
17
|
+
"dump",
|
|
18
|
+
help="Dump Semantic Graph, Dependency Graph, IIR, LTR, and RTR as JSON",
|
|
19
|
+
)
|
|
20
|
+
dump_p.add_argument("source", type=Path, help="Path to a Python OurUI module")
|
|
21
|
+
dump_p.add_argument("-o", "--output", type=Path, default=None, help="Write JSON to file instead of stdout")
|
|
22
|
+
|
|
23
|
+
emit_p = sub.add_parser(
|
|
24
|
+
"emit",
|
|
25
|
+
help="Emit HTML from HostNode RTR (does not consume Python AST in emitter)",
|
|
26
|
+
)
|
|
27
|
+
emit_p.add_argument("source", type=Path, help="Path to a Python OurUI module")
|
|
28
|
+
emit_p.add_argument("-o", "--output", type=Path, default=None, help="Write HTML to file instead of stdout")
|
|
29
|
+
emit_p.add_argument("--title", default=None, help="HTML document title")
|
|
30
|
+
|
|
31
|
+
serve_p = sub.add_parser(
|
|
32
|
+
"serve",
|
|
33
|
+
help="Serve app: GET / emits HTML; POST /__ourui/call/<handler> runs @server fns",
|
|
34
|
+
)
|
|
35
|
+
serve_p.add_argument("source", type=Path, help="Path to a Python OurUI module")
|
|
36
|
+
serve_p.add_argument("--host", default="127.0.0.1")
|
|
37
|
+
serve_p.add_argument("--port", type=int, default=8765)
|
|
38
|
+
serve_p.add_argument("--title", default=None)
|
|
39
|
+
serve_p.add_argument(
|
|
40
|
+
"--prod",
|
|
41
|
+
action="store_true",
|
|
42
|
+
help="Production mode: no HMR, session State, safe errors, /__ourui/health",
|
|
43
|
+
)
|
|
44
|
+
serve_p.add_argument(
|
|
45
|
+
"--workers",
|
|
46
|
+
type=int,
|
|
47
|
+
default=1,
|
|
48
|
+
help="Worker processes (requires --prod; uses file session store when >1)",
|
|
49
|
+
)
|
|
50
|
+
serve_p.add_argument(
|
|
51
|
+
"--session-dir",
|
|
52
|
+
type=Path,
|
|
53
|
+
default=None,
|
|
54
|
+
help="Directory for file-backed sessions (or set OURUI_SESSION_DIR)",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
sub.add_parser(
|
|
58
|
+
"lsp",
|
|
59
|
+
help="Start the OurUI Language Server (stdio JSON-RPC for editors)",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
args = parser.parse_args(argv)
|
|
63
|
+
|
|
64
|
+
if args.command == "dump":
|
|
65
|
+
if not args.source.exists():
|
|
66
|
+
print(f"error: file not found: {args.source}", file=sys.stderr)
|
|
67
|
+
return 1
|
|
68
|
+
text = dump_json(args.source)
|
|
69
|
+
if args.output:
|
|
70
|
+
args.output.write_text(text, encoding="utf-8")
|
|
71
|
+
else:
|
|
72
|
+
sys.stdout.write(text)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
if args.command == "emit":
|
|
76
|
+
if not args.source.exists():
|
|
77
|
+
print(f"error: file not found: {args.source}", file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
text = emit_html(args.source, title=args.title)
|
|
80
|
+
if args.output:
|
|
81
|
+
args.output.write_text(text, encoding="utf-8")
|
|
82
|
+
else:
|
|
83
|
+
sys.stdout.write(text)
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
if args.command == "serve":
|
|
87
|
+
if not args.source.exists():
|
|
88
|
+
print(f"error: file not found: {args.source}", file=sys.stderr)
|
|
89
|
+
return 1
|
|
90
|
+
serve(
|
|
91
|
+
args.source,
|
|
92
|
+
host=args.host,
|
|
93
|
+
port=args.port,
|
|
94
|
+
title=args.title,
|
|
95
|
+
prod=args.prod,
|
|
96
|
+
workers=args.workers,
|
|
97
|
+
session_dir=args.session_dir,
|
|
98
|
+
)
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
if args.command == "lsp":
|
|
102
|
+
run_stdio_server()
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
return 2
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
raise SystemExit(main())
|
ourui/emit/__init__.py
ADDED