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 +21 -0
- ourui-0.1.0/PKG-INFO +87 -0
- ourui-0.1.0/README.md +44 -0
- ourui-0.1.0/ourui/__init__.py +6 -0
- ourui-0.1.0/ourui/analysis/__init__.py +337 -0
- ourui-0.1.0/ourui/analysis/components.py +165 -0
- ourui-0.1.0/ourui/cli.py +109 -0
- ourui-0.1.0/ourui/emit/__init__.py +4 -0
- ourui-0.1.0/ourui/emit/html.py +235 -0
- ourui-0.1.0/ourui/emit/js.py +73 -0
- ourui-0.1.0/ourui/lowering/__init__.py +105 -0
- ourui-0.1.0/ourui/lowering/intent.py +4 -0
- ourui-0.1.0/ourui/lowering/layout.py +89 -0
- ourui-0.1.0/ourui/lowering/render.py +137 -0
- ourui-0.1.0/ourui/lsp/__init__.py +6 -0
- ourui-0.1.0/ourui/lsp/completions.py +150 -0
- ourui-0.1.0/ourui/lsp/protocol.py +58 -0
- ourui-0.1.0/ourui/lsp/server.py +114 -0
- ourui-0.1.0/ourui/node.py +67 -0
- ourui-0.1.0/ourui/parse/__init__.py +69 -0
- ourui-0.1.0/ourui/pipeline.py +97 -0
- ourui-0.1.0/ourui/runtime/__init__.py +432 -0
- ourui-0.1.0/ourui/runtime/hmr.py +50 -0
- ourui-0.1.0/ourui/runtime/invoke.py +101 -0
- ourui-0.1.0/ourui/runtime/session.py +157 -0
- ourui-0.1.0/ourui/serialize/__init__.py +8 -0
- ourui-0.1.0/ourui/theme.py +162 -0
- ourui-0.1.0/ourui/ui.py +80 -0
- ourui-0.1.0/ourui.egg-info/PKG-INFO +87 -0
- ourui-0.1.0/ourui.egg-info/SOURCES.txt +33 -0
- ourui-0.1.0/ourui.egg-info/dependency_links.txt +1 -0
- ourui-0.1.0/ourui.egg-info/entry_points.txt +2 -0
- ourui-0.1.0/ourui.egg-info/top_level.txt +1 -0
- ourui-0.1.0/pyproject.toml +37 -0
- ourui-0.1.0/setup.cfg +4 -0
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,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
|