Skip to content

Commit a29105f

Browse files
committed
Improve .pyi generation
1 parent 12edb57 commit a29105f

File tree

2 files changed

+112
-32
lines changed

2 files changed

+112
-32
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ jobs:
3737
run: |
3838
sudo apt-get install -y eatmydata
3939
sudo eatmydata apt-get install -y gettext librsvg2-bin mingw-w64
40-
pip install requests sh click setuptools cpp-coveralls "Sphinx<4" sphinx-rtd-theme recommonmark sphinx-autoapi sphinxcontrib-svg2pdfconverter polib pyyaml astroid
40+
pip install requests sh click setuptools cpp-coveralls "Sphinx<4" sphinx-rtd-theme recommonmark sphinx-autoapi sphinxcontrib-svg2pdfconverter polib pyyaml astroid isort
4141
- name: Versions
4242
run: |
4343
gcc --version

tools/extract_pyi.py

Lines changed: 111 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,91 @@
22
#
33
# SPDX-License-Identifier: MIT
44

5+
import ast
56
import os
7+
import re
68
import sys
7-
import astroid
89
import traceback
910

10-
top_level = sys.argv[1].strip("/")
11-
stub_directory = sys.argv[2]
11+
import isort
12+
13+
14+
IMPORTS_IGNORE = frozenset({'int', 'float', 'bool', 'str', 'bytes', 'tuple', 'list', 'set', 'dict', 'bytearray', 'file', 'buffer'})
15+
IMPORTS_TYPING = frozenset({'Any', 'Optional', 'Union', 'Tuple', 'List', 'Sequence'})
16+
IMPORTS_TYPESHED = frozenset({'ReadableBuffer', 'WritableBuffer'})
17+
18+
19+
def is_any(node):
20+
node_type = type(node)
21+
if node is None:
22+
return True
23+
if node_type == ast.Name and node.id == "Any":
24+
return True
25+
if (node_type == ast.Attribute and type(node.value) == ast.Name
26+
and node.value.id == "typing" and node.attr == "Any"):
27+
return True
28+
return False
29+
30+
31+
def report_missing_annotations(tree):
32+
for node in ast.walk(tree):
33+
node_type = type(node)
34+
if node_type == ast.AnnAssign:
35+
if is_any(node.annotation):
36+
print(f"Missing attribute type on line {node.lineno}")
37+
elif node_type == ast.arg:
38+
if is_any(node.annotation) and node.arg != "self":
39+
print(f"Missing argument type: {node.arg} on line {node.lineno}")
40+
elif node_type == ast.FunctionDef:
41+
if is_any(node.returns) and node.name != "__init__":
42+
print(f"Missing return type: {node.name} on line {node.lineno}")
43+
44+
45+
def extract_imports(tree):
46+
modules = set()
47+
typing = set()
48+
typeshed = set()
49+
50+
def collect_annotations(anno_tree):
51+
if anno_tree is None:
52+
return
53+
for node in ast.walk(anno_tree):
54+
node_type = type(node)
55+
if node_type == ast.Name:
56+
if node.id in IMPORTS_IGNORE:
57+
continue
58+
elif node.id in IMPORTS_TYPING:
59+
typing.add(node.id)
60+
elif node.id in IMPORTS_TYPESHED:
61+
typeshed.add(node.id)
62+
elif not node.id[0].isupper():
63+
modules.add(node.id)
64+
65+
for node in ast.walk(tree):
66+
node_type = type(node)
67+
if (node_type == ast.AnnAssign) or (node_type == ast.arg):
68+
collect_annotations(node.annotation)
69+
elif node_type == ast.FunctionDef:
70+
collect_annotations(node.returns)
71+
72+
return {
73+
"modules": sorted(modules),
74+
"typing": sorted(typing),
75+
"typeshed": sorted(typeshed),
76+
}
77+
1278

1379
def convert_folder(top_level, stub_directory):
1480
ok = 0
1581
total = 0
1682
filenames = sorted(os.listdir(top_level))
1783
pyi_lines = []
84+
1885
for filename in filenames:
1986
full_path = os.path.join(top_level, filename)
2087
file_lines = []
2188
if os.path.isdir(full_path):
22-
mok, mtotal = convert_folder(full_path, os.path.join(stub_directory, filename))
89+
(mok, mtotal) = convert_folder(full_path, os.path.join(stub_directory, filename))
2390
ok += mok
2491
total += mtotal
2592
elif filename.endswith(".c"):
@@ -44,44 +111,57 @@ def convert_folder(top_level, stub_directory):
44111
pyi_lines.extend(file_lines)
45112

46113
if not pyi_lines:
47-
return ok, total
114+
return (ok, total)
48115

49116
stub_filename = os.path.join(stub_directory, "__init__.pyi")
50117
print(stub_filename)
51118
stub_contents = "".join(pyi_lines)
52-
os.makedirs(stub_directory, exist_ok=True)
53-
with open(stub_filename, "w") as f:
54-
f.write(stub_contents)
55119

56120
# Validate that the module is a parseable stub.
57121
total += 1
58122
try:
59-
tree = astroid.parse(stub_contents)
60-
for i in tree.body:
61-
if 'name' in i.__dict__:
62-
print(i.__dict__['name'])
63-
for j in i.body:
64-
if isinstance(j, astroid.scoped_nodes.FunctionDef):
65-
if None in j.args.__dict__['annotations']:
66-
print(f"Missing parameter type: {j.__dict__['name']} on line {j.__dict__['lineno']}\n")
67-
if j.returns:
68-
if 'Any' in j.returns.__dict__.values():
69-
print(f"Missing return type: {j.__dict__['name']} on line {j.__dict__['lineno']}")
70-
elif isinstance(j, astroid.node_classes.AnnAssign):
71-
if 'name' in j.__dict__['annotation'].__dict__:
72-
if j.__dict__['annotation'].__dict__['name'] == 'Any':
73-
print(f"missing attribute type on line {j.__dict__['lineno']}")
74-
123+
tree = ast.parse(stub_contents)
124+
imports = extract_imports(tree)
125+
report_missing_annotations(tree)
75126
ok += 1
76-
except astroid.exceptions.AstroidSyntaxError as e:
77-
e = e.__cause__
127+
except SyntaxError as e:
78128
traceback.print_exception(type(e), e, e.__traceback__)
129+
return (ok, total)
130+
131+
# Add import statements
132+
import_lines = ["from __future__ import annotations"]
133+
import_lines.extend(f"import {m}" for m in imports["modules"])
134+
import_lines.append("from typing import " + ", ".join(imports["typing"]))
135+
import_lines.append("from _typeshed import " + ", ".join(imports["typeshed"]))
136+
import_body = "\n".join(import_lines)
137+
m = re.match(r'(\s*""".*?""")', stub_contents, flags=re.DOTALL)
138+
if m:
139+
stub_contents = m.group(1) + "\n\n" + import_body + "\n\n" + stub_contents[m.end():]
140+
else:
141+
stub_contents = import_body + "\n\n" + stub_contents
142+
stub_contents = isort.code(stub_contents)
143+
144+
# Adjust blank lines
145+
stub_contents = re.sub(r"\n+class", "\n\n\nclass", stub_contents)
146+
stub_contents = re.sub(r"\n+def", "\n\n\ndef", stub_contents)
147+
stub_contents = re.sub(r"\n+^(\s+)def", lambda m: f"\n\n{m.group(1)}def", stub_contents, flags=re.M)
148+
stub_contents = stub_contents.strip() + "\n"
149+
150+
os.makedirs(stub_directory, exist_ok=True)
151+
with open(stub_filename, "w") as f:
152+
f.write(stub_contents)
153+
79154
print()
80-
return ok, total
155+
return (ok, total)
156+
157+
158+
if __name__ == "__main__":
159+
top_level = sys.argv[1].strip("/")
160+
stub_directory = sys.argv[2]
81161

82-
ok, total = convert_folder(top_level, stub_directory)
162+
(ok, total) = convert_folder(top_level, stub_directory)
83163

84-
print(f"{ok} ok out of {total}")
164+
print(f"Parsing .pyi files: {total - ok} failed, {ok} passed")
85165

86-
if ok != total:
87-
sys.exit(total - ok)
166+
if ok != total:
167+
sys.exit(total - ok)

0 commit comments

Comments
 (0)