Skip to content

Commit 18b9626

Browse files
committed
fix: Fix type annotations parsing
Instead of writing our own function, we rely on `ast.unparse` on Python 3.9, and on `astunparse.unparse` on previous Python versions. Issue 92: #92 PR 96: #96
1 parent beddb31 commit 18b9626

File tree

7 files changed

+587
-50
lines changed

7 files changed

+587
-50
lines changed

Diff for: duties.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from jinja2.sandbox import SandboxedEnvironment
1616
from pip._internal.commands.show import search_packages_info # noqa: WPS436 (no other way?)
1717

18-
PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py"))
18+
PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "scripts", "duties.py"))
1919
PY_SRC_LIST = tuple(str(_) for _ in PY_SRC_PATHS)
2020
PY_SRC = " ".join(PY_SRC_LIST)
2121
TESTING = os.environ.get("TESTING", "0") in {"1", "true"}

Diff for: pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ include = [
2020

2121
[tool.poetry.dependencies]
2222
python = "^3.6"
23+
astunparse = {version = "^1.6.3", python = "<3.9"}
2324
cached-property = {version = "^1.5.2", python = "<3.8"}
2425
dataclasses = {version = ">=0.7,<0.9", python = "3.6"}
2526
docstring_parser = {version = "^0.7.3", optional = true}

Diff for: scripts/get_annotations.py

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env python
2+
"""Scan Python files to retrieve real-world type annotations."""
3+
4+
import ast
5+
import glob
6+
import re
7+
import sys
8+
from multiprocessing import Pool, cpu_count
9+
from pathlib import Path
10+
from typing import List
11+
12+
try:
13+
from ast import unparse # type: ignore
14+
except ImportError:
15+
from astunparse import unparse as _unparse
16+
17+
unparse = lambda node: _unparse(node).rstrip("\n").replace("(", "").replace(")", "")
18+
19+
regex = re.compile(r"\w+")
20+
21+
22+
def scan_file(filepath: str) -> set:
23+
"""
24+
Scan a Python file and return a set of annotations.
25+
26+
Since parsing `Optional[typing.List]` and `Optional[typing.Dict]` is the same,
27+
we're not interested in keeping the actual names.
28+
Therefore we replace every word with "a".
29+
It has two benefits:
30+
31+
- we can get rid of syntaxically equivalent annotations (duplicates)
32+
- the resulting annotations takes less bytes
33+
34+
Arguments:
35+
filepath: The path to the Python file to scan.
36+
37+
Returns:
38+
A set of annotations.
39+
"""
40+
annotations: set = set()
41+
path = Path(filepath)
42+
if not path.suffix == ".py":
43+
return annotations
44+
try:
45+
code = ast.parse(path.read_text())
46+
except:
47+
return annotations
48+
for node in ast.walk(code):
49+
if hasattr(node, "annotation"):
50+
try:
51+
unparsed = unparse(node.annotation) # type: ignore
52+
annotations.add(regex.sub("a", unparsed))
53+
except:
54+
continue
55+
return annotations
56+
57+
58+
def main(directories: List[str]) -> int:
59+
"""
60+
Scan Python files in a list of directories.
61+
62+
First, all the files are stored in a list,
63+
then the scanning is done in parallel with a multiprocessing pool.
64+
65+
Arguments:
66+
directories: A list of directories to scan.
67+
68+
Returns:
69+
An exit code.
70+
"""
71+
if not directories:
72+
return 1
73+
all_files = []
74+
for directory in directories:
75+
all_files.extend(glob.glob(directory.rstrip("/") + "/**/*.py", recursive=True))
76+
n_files = len(all_files)
77+
with Pool(cpu_count() - 1) as pool:
78+
sets = pool.map(scan_file, all_files)
79+
annotations: set = set().union(*sets)
80+
print("a: " + "\na: ".join(sorted(annotations)))
81+
return 0
82+
83+
84+
if __name__ == "__main__":
85+
sys.exit(main(sys.argv[1:]))

Diff for: src/pytkdocs/parsers/attributes.py

+12-27
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,14 @@
44
import inspect
55
from functools import lru_cache
66
from textwrap import dedent
7-
from typing import Union, get_type_hints
8-
9-
RECURSIVE_NODES = (ast.If, ast.IfExp, ast.Try, ast.With)
7+
from typing import get_type_hints
108

9+
try:
10+
from ast import unparse # type: ignore
11+
except ImportError:
12+
from astunparse import unparse
1113

12-
def node_to_annotation(node) -> Union[str, object]:
13-
if isinstance(node, ast.AnnAssign):
14-
if isinstance(node.annotation, ast.Name):
15-
return node.annotation.id
16-
elif isinstance(node.annotation, (ast.Constant, ast.Str)):
17-
return node.annotation.s
18-
elif isinstance(node.annotation, ast.Subscript):
19-
value_id = node.annotation.value.id # type: ignore
20-
if hasattr(node.annotation.slice, "value"):
21-
value = node.annotation.slice.value # type: ignore
22-
else:
23-
value = node.annotation.slice
24-
return f"{value_id}[{node_to_annotation(value)}]"
25-
else:
26-
return inspect.Signature.empty
27-
elif isinstance(node, ast.Subscript):
28-
return f"{node.value.id}[{node_to_annotation(node.slice.value)}]" # type: ignore
29-
elif isinstance(node, ast.Tuple):
30-
annotations = [node_to_annotation(n) for n in node.elts]
31-
return ", ".join(a for a in annotations if a is not inspect.Signature.empty) # type: ignore
32-
elif isinstance(node, ast.Name):
33-
return node.id
34-
return inspect.Signature.empty
14+
RECURSIVE_NODES = (ast.If, ast.IfExp, ast.Try, ast.With)
3515

3616

3717
def get_nodes(obj):
@@ -144,6 +124,11 @@ def pick_target(target):
144124
return isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id == "self"
145125

146126

127+
def unparse_annotation(node):
128+
code = unparse(node).rstrip("\n")
129+
return code.replace("(", "").replace(")", "")
130+
131+
147132
@lru_cache()
148133
def get_instance_attributes(func):
149134
nodes = get_nodes(func)
@@ -157,7 +142,7 @@ def get_instance_attributes(func):
157142
if isinstance(assignment, ast.AnnAssign):
158143
if pick_target(assignment.target):
159144
names = [assignment.target.attr]
160-
annotation = node_to_annotation(assignment)
145+
annotation = unparse_annotation(assignment.annotation)
161146
else:
162147
names = [target.attr for target in assignment.targets if pick_target(target)]
163148

Diff for: tests/fixtures/parsing/annotations.py

-10
This file was deleted.

0 commit comments

Comments
 (0)