Skip to content

Commit 598bcf7

Browse files
committed
turn script into cli app
1 parent ad120b1 commit 598bcf7

File tree

6 files changed

+416
-1
lines changed

6 files changed

+416
-1
lines changed

requirements/pkg-deps.txt

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ fastjsonschema >=2.14.5
66
requests >=2
77
colorlog >=6
88
asgiref >=3
9-
lxml >= 4
9+
lxml >=4
10+
typer >=8, <9

src/idom/__main__.py

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import click
2+
3+
from idom import __version__
4+
from idom._console.update_html_usages import update_html_usages
5+
6+
7+
app = click.Group(
8+
commands=[
9+
update_html_usages,
10+
]
11+
)
12+
13+
if __name__ == "__main__":
14+
app()

src/idom/_console/__init__.py

Whitespace-only changes.
+207
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
from __future__ import annotations
2+
3+
import ast
4+
import re
5+
from collections.abc import Sequence
6+
from glob import iglob
7+
from keyword import kwlist
8+
from pathlib import Path
9+
from textwrap import indent
10+
from tokenize import COMMENT as COMMENT_TOKEN
11+
from tokenize import generate_tokens
12+
from typing import Iterator
13+
14+
import click
15+
16+
from idom import html
17+
18+
19+
CAMEL_CASE_SUB_PATTERN = re.compile(r"(?<!^)(?=[A-Z])")
20+
21+
22+
@click.command()
23+
@click.argument("patterns", nargs=-1)
24+
def update_html_usages(patterns: list[str]) -> None:
25+
"""Rewrite files matching the given glob patterns to the new html element API.
26+
27+
The old API required users to pass a dictionary of attributes to html element
28+
constructor functions. For example:
29+
30+
>>> html.div({"className": "x"}, "y")
31+
{"tagName": "div", "attributes": {"className": "x"}, "children": ["y"]}
32+
33+
The latest API though allows for attributes to be passed as snake_cased keyword
34+
arguments instead. The above example would be rewritten as:
35+
36+
>>> html.div("y", class_name="x")
37+
{"tagName": "div", "attributes": {"class_name": "x"}, "children": ["y"]}
38+
39+
All snake_case attributes are converted to camelCase by the client where necessary.
40+
41+
----- Notes -----
42+
43+
While this command does it's best to preserve as much of the original code as
44+
possible, there are inevitably some limitations in doing this. As a result, we
45+
recommend running your code formatter like Black against your code after executing
46+
this command.
47+
48+
Additionally, We are unable to perserve the location of comments that lie within any
49+
rewritten code. This command will place the comments in the code it plans to rewrite
50+
just above its changes. As such it requires manual intervention to put those
51+
comments back in their original location.
52+
"""
53+
for pat in patterns:
54+
for file in map(Path, iglob(pat)):
55+
result = generate_rewrite(file=file, source=file.read_text())
56+
if result is not None:
57+
file.write_text(result)
58+
59+
60+
def generate_rewrite(file: Path, source: str) -> None:
61+
tree = ast.parse(source)
62+
63+
changed: list[Sequence[ast.AST]] = []
64+
for parents, node in walk_with_parent(tree):
65+
if isinstance(node, ast.Call):
66+
func = node.func
67+
match func:
68+
case ast.Attribute():
69+
name = func.attr
70+
case ast.Name(ctx=ast.Load()):
71+
name = func.id
72+
case _:
73+
name = ""
74+
if hasattr(html, name):
75+
match node.args:
76+
case [ast.Dict(keys, values), *_]:
77+
new_kwargs = list(node.keywords)
78+
for k, v in zip(keys, values):
79+
if isinstance(k, ast.Constant) and isinstance(k.value, str):
80+
new_kwargs.append(
81+
ast.keyword(arg=conv_attr_name(k.value), value=v)
82+
)
83+
else:
84+
new_kwargs = [ast.keyword(arg=None, value=node.args[0])]
85+
break
86+
node.args = node.args[1:]
87+
node.keywords = new_kwargs
88+
changed.append((node, *parents))
89+
case [
90+
ast.Call(
91+
func=ast.Name(id="dict", ctx=ast.Load()),
92+
args=args,
93+
keywords=kwargs,
94+
),
95+
*_,
96+
]:
97+
new_kwargs = [
98+
*[ast.keyword(arg=None, value=a) for a in args],
99+
*node.keywords,
100+
]
101+
for kw in kwargs:
102+
if kw.arg is not None:
103+
new_kwargs.append(
104+
ast.keyword(
105+
arg=conv_attr_name(kw.arg), value=kw.value
106+
)
107+
)
108+
else:
109+
new_kwargs.append(kw)
110+
node.args = node.args[1:]
111+
node.keywords = new_kwargs
112+
changed.append((node, *parents))
113+
114+
case _:
115+
pass
116+
117+
if not changed:
118+
return
119+
120+
ast.fix_missing_locations(tree)
121+
122+
lines = source.split("\n")
123+
124+
# find closest parent nodes that should be re-written
125+
nodes_to_unparse: list[ast.AST] = []
126+
for node_lineage in changed:
127+
origin_node = node_lineage[0]
128+
for i in range(len(node_lineage) - 1):
129+
current_node, next_node = node_lineage[i : i + 2]
130+
if (
131+
not hasattr(next_node, "lineno")
132+
or next_node.lineno < origin_node.lineno
133+
or isinstance(next_node, (ast.ClassDef, ast.FunctionDef))
134+
):
135+
nodes_to_unparse.append(current_node)
136+
break
137+
else:
138+
raise RuntimeError("Failed to change code")
139+
140+
# check if an nodes to rewrite contain eachother, pick outermost nodes
141+
current_outermost_node, *sorted_nodes_to_unparse = list(
142+
sorted(nodes_to_unparse, key=lambda n: n.lineno)
143+
)
144+
outermost_nodes_to_unparse = [current_outermost_node]
145+
for node in sorted_nodes_to_unparse:
146+
if node.lineno > current_outermost_node.end_lineno:
147+
current_outermost_node = node
148+
outermost_nodes_to_unparse.append(node)
149+
150+
moved_comment_lines_from_end: list[int] = []
151+
# now actually rewrite these nodes (in reverse to avoid changes earlier in file)
152+
for node in reversed(outermost_nodes_to_unparse):
153+
# make a best effort to preserve any comments that we're going to overwrite
154+
comments = find_comments(lines[node.lineno - 1 : node.end_lineno])
155+
156+
# there may be some content just before and after the content we're re-writing
157+
before_replacement = lines[node.lineno - 1][: node.col_offset].lstrip()
158+
159+
if node.end_lineno is not None and node.end_col_offset is not None:
160+
after_replacement = lines[node.end_lineno - 1][
161+
node.end_col_offset :
162+
].strip()
163+
else:
164+
after_replacement = ""
165+
166+
replacement = indent(
167+
before_replacement
168+
+ "\n".join([*comments, ast.unparse(node)])
169+
+ after_replacement,
170+
" " * (node.col_offset - len(before_replacement)),
171+
)
172+
173+
if node.end_lineno:
174+
lines[node.lineno - 1 : node.end_lineno] = [replacement]
175+
else:
176+
lines[node.lineno - 1] = replacement
177+
178+
if comments:
179+
moved_comment_lines_from_end.append(len(lines) - node.lineno)
180+
181+
for lineno_from_end in sorted(list(set(moved_comment_lines_from_end))):
182+
click.echo(f"Moved comments to {file}:{len(lines) - lineno_from_end}")
183+
184+
return "\n".join(lines)
185+
186+
187+
def find_comments(lines: list[str]) -> list[str]:
188+
iter_lines = iter(lines)
189+
return [
190+
token
191+
for token_type, token, _, _, _ in generate_tokens(lambda: next(iter_lines))
192+
if token_type == COMMENT_TOKEN
193+
]
194+
195+
196+
def walk_with_parent(
197+
node: ast.AST, parents: tuple[ast.AST, ...] = ()
198+
) -> Iterator[tuple[tuple[ast.AST, ...], ast.AST]]:
199+
parents = (node,) + parents
200+
for child in ast.iter_child_nodes(node):
201+
yield parents, child
202+
yield from walk_with_parent(child, parents)
203+
204+
205+
def conv_attr_name(name: str) -> str:
206+
new_name = CAMEL_CASE_SUB_PATTERN.sub("_", name).replace("-", "_").lower()
207+
return f"{new_name}_" if new_name in kwlist else new_name

tests/test__console/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)