Skip to content

Commit 3fc050f

Browse files
committed
begin vdom interface unification
1 parent 7bdcdfe commit 3fc050f

File tree

4 files changed

+397
-106
lines changed

4 files changed

+397
-106
lines changed

scripts/fix_vdom_constructor_usage.py

+361
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
from __future__ import annotations
2+
3+
import ast
4+
import re
5+
import sys
6+
from collections.abc import Sequence
7+
from keyword import kwlist
8+
from pathlib import Path
9+
from textwrap import dedent, indent
10+
from tokenize import COMMENT as COMMENT_TOKEN
11+
from tokenize import generate_tokens
12+
from typing import Iterator
13+
14+
from idom import html
15+
16+
17+
_CAMEL_CASE_SUB_PATTERN = re.compile(r"(?<!^)(?=[A-Z])")
18+
19+
20+
def update_vdom_constructor_usages(source: str, filename: str = "") -> None:
21+
tree = ast.parse(source)
22+
23+
changed: list[Sequence[ast.AST]] = []
24+
for parents, node in walk_with_parent(tree):
25+
if isinstance(node, ast.Call):
26+
func = node.func
27+
match func:
28+
case ast.Attribute():
29+
name = func.attr
30+
case ast.Name(ctx=ast.Load()):
31+
name = func.id
32+
case _:
33+
name = ""
34+
if hasattr(html, name):
35+
match node.args:
36+
case [ast.Dict(keys, values), *_]:
37+
new_kwargs = list(node.keywords)
38+
for k, v in zip(keys, values):
39+
if isinstance(k, ast.Constant) and isinstance(k.value, str):
40+
new_kwargs.append(
41+
ast.keyword(arg=conv_attr_name(k.value), value=v)
42+
)
43+
else:
44+
new_kwargs = [ast.keyword(arg=None, value=node.args[0])]
45+
break
46+
node.args = node.args[1:]
47+
node.keywords = new_kwargs
48+
changed.append((node, *parents))
49+
case [
50+
ast.Call(
51+
func=ast.Name(id="dict", ctx=ast.Load()),
52+
args=args,
53+
keywords=kwargs,
54+
),
55+
*_,
56+
]:
57+
new_kwargs = [
58+
*[ast.keyword(arg=None, value=a) for a in args],
59+
*node.keywords,
60+
]
61+
for kw in kwargs:
62+
if kw.arg is not None:
63+
new_kwargs.append(
64+
ast.keyword(
65+
arg=conv_attr_name(kw.arg), value=kw.value
66+
)
67+
)
68+
else:
69+
new_kwargs.append(kw)
70+
node.args = node.args[1:]
71+
node.keywords = new_kwargs
72+
changed.append((node, *parents))
73+
74+
case _:
75+
pass
76+
77+
if not changed:
78+
return
79+
80+
ast.fix_missing_locations(tree)
81+
82+
lines = source.split("\n")
83+
84+
# find closest parent nodes that should be re-written
85+
nodes_to_unparse: list[ast.AST] = []
86+
for node_lineage in changed:
87+
origin_node = node_lineage[0]
88+
for i in range(len(node_lineage) - 1):
89+
current_node, next_node = node_lineage[i : i + 2]
90+
if (
91+
not hasattr(next_node, "lineno")
92+
or next_node.lineno < origin_node.lineno
93+
or isinstance(next_node, (ast.ClassDef, ast.FunctionDef))
94+
):
95+
nodes_to_unparse.append(current_node)
96+
break
97+
else:
98+
raise RuntimeError("Failed to change code")
99+
100+
# check if an nodes to rewrite contain eachother, pick outermost nodes
101+
current_outermost_node, *sorted_nodes_to_unparse = list(
102+
sorted(nodes_to_unparse, key=lambda n: n.lineno)
103+
)
104+
outermost_nodes_to_unparse = [current_outermost_node]
105+
for node in sorted_nodes_to_unparse:
106+
if node.lineno > current_outermost_node.end_lineno:
107+
current_outermost_node = node
108+
outermost_nodes_to_unparse.append(node)
109+
110+
moved_comment_lines_from_end: list[int] = []
111+
# now actually rewrite these nodes (in reverse to avoid changes earlier in file)
112+
for node in reversed(outermost_nodes_to_unparse):
113+
# make a best effort to preserve any comments that we're going to overwrite
114+
comments = find_comments(lines[node.lineno - 1 : node.end_lineno])
115+
116+
# there may be some content just before and after the content we're re-writing
117+
before_replacement = lines[node.lineno - 1][: node.col_offset].strip()
118+
119+
if node.end_lineno is not None and node.end_col_offset is not None:
120+
after_replacement = lines[node.end_lineno - 1][
121+
node.end_col_offset :
122+
].strip()
123+
else:
124+
after_replacement = ""
125+
126+
replacement = indent(
127+
before_replacement
128+
+ "\n".join([*comments, ast.unparse(node)])
129+
+ after_replacement,
130+
" " * (node.col_offset - len(before_replacement)),
131+
)
132+
133+
if node.end_lineno:
134+
lines[node.lineno - 1 : node.end_lineno] = [replacement]
135+
else:
136+
lines[node.lineno - 1] = replacement
137+
138+
if comments:
139+
moved_comment_lines_from_end.append(len(lines) - node.lineno)
140+
141+
for lineno_from_end in sorted(list(set(moved_comment_lines_from_end))):
142+
print(f"Moved comments to {filename}:{len(lines) - lineno_from_end}")
143+
144+
return "\n".join(lines)
145+
146+
147+
def find_comments(lines: list[str]) -> list[str]:
148+
iter_lines = iter(lines)
149+
return [
150+
token
151+
for token_type, token, _, _, _ in generate_tokens(lambda: next(iter_lines))
152+
if token_type == COMMENT_TOKEN
153+
]
154+
155+
156+
def walk_with_parent(
157+
node: ast.AST, parents: tuple[ast.AST, ...] = ()
158+
) -> Iterator[tuple[tuple[ast.AST, ...], ast.AST]]:
159+
parents = (node,) + parents
160+
for child in ast.iter_child_nodes(node):
161+
yield parents, child
162+
yield from walk_with_parent(child, parents)
163+
164+
165+
def conv_attr_name(name: str) -> str:
166+
new_name = _CAMEL_CASE_SUB_PATTERN.sub("_", name).replace("-", "_").lower()
167+
return f"{new_name}_" if new_name in kwlist else new_name
168+
169+
170+
def run_tests():
171+
cases = [
172+
# simple conversions
173+
(
174+
'html.div({"className": "test"})',
175+
"html.div(class_name='test')",
176+
),
177+
(
178+
'html.div({class_name: "test", **other})',
179+
"html.div(**{class_name: 'test', **other})",
180+
),
181+
(
182+
'html.div(dict(other, className="test"))',
183+
"html.div(**other, class_name='test')",
184+
),
185+
(
186+
'html.div({"className": "outer"}, html.div({"className": "inner"}))',
187+
"html.div(html.div(class_name='inner'), class_name='outer')",
188+
),
189+
(
190+
'html.div({"className": "outer"}, html.div({"className": "inner"}))',
191+
"html.div(html.div(class_name='inner'), class_name='outer')",
192+
),
193+
(
194+
'["before", html.div({"className": "test"}), "after"]',
195+
"['before', html.div(class_name='test'), 'after']",
196+
),
197+
(
198+
"""
199+
html.div(
200+
{"className": "outer"},
201+
html.div({"className": "inner"}),
202+
html.div({"className": "inner"}),
203+
)
204+
""",
205+
"html.div(html.div(class_name='inner'), html.div(class_name='inner'), class_name='outer')",
206+
),
207+
(
208+
'html.div(dict(className="test"))',
209+
"html.div(class_name='test')",
210+
),
211+
# when to not attempt conversion
212+
(
213+
'html.div(ignore, {"className": "test"})',
214+
None,
215+
),
216+
# avoid unnecessary changes
217+
(
218+
"""
219+
def my_function():
220+
x = 1 # some comment
221+
return html.div({"className": "test"})
222+
""",
223+
"""
224+
def my_function():
225+
x = 1 # some comment
226+
return html.div(class_name='test')
227+
""",
228+
),
229+
(
230+
"""
231+
if condition:
232+
# some comment
233+
dom = html.div({"className": "test"})
234+
""",
235+
"""
236+
if condition:
237+
# some comment
238+
dom = html.div(class_name='test')
239+
""",
240+
),
241+
(
242+
"""
243+
[
244+
html.div({"className": "test"}),
245+
html.div({"className": "test"}),
246+
]
247+
""",
248+
"""
249+
[
250+
html.div(class_name='test'),
251+
html.div(class_name='test'),
252+
]
253+
""",
254+
),
255+
(
256+
"""
257+
@deco(
258+
html.div({"className": "test"}),
259+
html.div({"className": "test"}),
260+
)
261+
def func():
262+
# comment
263+
x = [
264+
1
265+
]
266+
""",
267+
"""
268+
@deco(
269+
html.div(class_name='test'),
270+
html.div(class_name='test'),
271+
)
272+
def func():
273+
# comment
274+
x = [
275+
1
276+
]
277+
""",
278+
),
279+
(
280+
"""
281+
@deco(html.div({"className": "test"}), html.div({"className": "test"}))
282+
def func():
283+
# comment
284+
x = [
285+
1
286+
]
287+
""",
288+
"""
289+
@deco(html.div(class_name='test'), html.div(class_name='test'))
290+
def func():
291+
# comment
292+
x = [
293+
1
294+
]
295+
""",
296+
),
297+
# best effort to preserve comments
298+
(
299+
"""
300+
x = 1
301+
html.div(
302+
# comment 1
303+
{"className": "outer"},
304+
# comment 2
305+
html.div({"className": "inner"}),
306+
)
307+
""",
308+
"""
309+
x = 1
310+
# comment 1
311+
# comment 2
312+
html.div(html.div(class_name='inner'), class_name='outer')
313+
""",
314+
),
315+
]
316+
317+
success = True
318+
319+
for source, expected in cases:
320+
actual = update_vdom_constructor_usages(dedent(source).strip(), "test.py")
321+
if isinstance(expected, str):
322+
expected = dedent(expected).strip()
323+
if actual != expected:
324+
if not success:
325+
print("\n" + "-" * 20)
326+
print(
327+
dedent(
328+
f"""
329+
330+
{actual}
331+
332+
▲ actual ▲
333+
▼ expected ▼
334+
335+
{expected}
336+
337+
"""
338+
)
339+
)
340+
success = False
341+
342+
return success
343+
344+
345+
if __name__ == "__main__":
346+
argv = sys.argv[1:]
347+
348+
if not argv:
349+
print("Running tests...")
350+
result = run_tests()
351+
print("Success" if result else "Failed")
352+
sys.exit(0 if result else 0)
353+
354+
for pattern in argv:
355+
for file in Path.cwd().glob(pattern):
356+
result = update_vdom_constructor_usages(
357+
source=file.read_text(),
358+
filename=str(file),
359+
)
360+
if result is not None:
361+
file.write_text(result)

src/idom/core/types.py

+7-4
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,13 @@ class ComponentType(Protocol):
6262
This is used to see if two component instances share the same definition.
6363
"""
6464

65-
def render(self) -> VdomDict | ComponentType | str | None:
65+
def render(self) -> RenderResult:
6666
"""Render the component's view model."""
6767

6868

69+
RenderResult = Union["VdomDict", ComponentType, str, None]
70+
71+
6972
_Render = TypeVar("_Render", covariant=True)
7073
_Event = TypeVar("_Event", contravariant=True)
7174

@@ -208,9 +211,9 @@ class VdomDictConstructor(Protocol):
208211

209212
def __call__(
210213
self,
211-
*attributes_and_children: VdomAttributesAndChildren,
212-
key: str = ...,
213-
event_handlers: Optional[EventHandlerMapping] = ...,
214+
*children: VdomChild,
215+
key: Key | None = None,
216+
**attributes: Any,
214217
) -> VdomDict:
215218
...
216219

0 commit comments

Comments
 (0)