Skip to content

Commit ad120b1

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

File tree

5 files changed

+421
-114
lines changed

5 files changed

+421
-114
lines changed

scripts/fix_vdom_constructor_usage.py

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

0 commit comments

Comments
 (0)