Skip to content

Commit 6e72df4

Browse files
committed
Add support for type comments (PEP-484#type-comments)
Implementation: Detect type comments by their `# type: ` prefix, recursively unasyncify the type declaration part as new token stream, update the otherwise unchanged token value.
1 parent 8a24452 commit 6e72df4

File tree

4 files changed

+95
-1
lines changed

4 files changed

+95
-1
lines changed

src/unasync/__init__.py

+48-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
# -*- encoding: utf8 -*-
12
"""Top-level package for unasync."""
23

34
from __future__ import print_function
45

56
import collections
67
import errno
8+
import io
79
import os
810
import sys
911
import tokenize as std_tokenize
@@ -34,6 +36,22 @@
3436
"StopAsyncIteration": "StopIteration",
3537
}
3638

39+
_TYPE_COMMENT_PREFIX = "# type: "
40+
41+
42+
if sys.version_info[0] == 2: # PY2
43+
44+
def isidentifier(s):
45+
return all([c.isalnum() or c == "_" for c in s])
46+
47+
StringIO = io.BytesIO
48+
else: # PY3
49+
50+
def isidentifier(s):
51+
return s.isidentifier()
52+
53+
StringIO = io.StringIO
54+
3755

3856
class Rule:
3957
"""A single set of rules for 'unasync'ing file(s)"""
@@ -95,6 +113,31 @@ def _unasync_tokens(self, tokens):
95113
elif toknum == std_tokenize.STRING:
96114
left_quote, name, right_quote = tokval[0], tokval[1:-1], tokval[-1]
97115
tokval = left_quote + self._unasync_name(name) + right_quote
116+
elif toknum == std_tokenize.COMMENT and tokval.startswith(
117+
_TYPE_COMMENT_PREFIX
118+
):
119+
type_decl, suffix = tokval[len(_TYPE_COMMENT_PREFIX) :], ""
120+
if "#" in type_decl:
121+
type_decl, suffix = type_decl.split("#", 1)
122+
suffix = "#" + suffix
123+
type_decl_stripped = type_decl.strip()
124+
125+
# Do not process `type: ignore` or `type: ignore[…]` as these aren't actual identifiers
126+
is_type_ignore = type_decl_stripped == "ignore"
127+
is_type_ignore |= type_decl_stripped.startswith(
128+
"ignore"
129+
) and not isidentifier(type_decl_stripped[0:7])
130+
if not is_type_ignore:
131+
# Preserve trailing whitespace since the tokenizer won't
132+
trailing_space_len = len(type_decl) - len(type_decl.rstrip())
133+
if trailing_space_len > 0:
134+
suffix = type_decl[-trailing_space_len:] + suffix
135+
type_decl = type_decl[:-trailing_space_len]
136+
type_decl = _untokenize(
137+
self._unasync_tokens(_tokenize(StringIO(type_decl)))
138+
)
139+
140+
tokval = _TYPE_COMMENT_PREFIX + type_decl + suffix
98141
if used_space is None:
99142
used_space = space
100143
yield (used_space, tokval)
@@ -133,7 +176,11 @@ def _get_tokens(f):
133176
type_, string, start, end, line = tok
134177
yield Token(type_, string, start, end, line)
135178
else: # PY3
136-
for tok in std_tokenize.tokenize(f.readline):
179+
if isinstance(f, io.TextIOBase):
180+
gen = std_tokenize.generate_tokens(f.readline)
181+
else:
182+
gen = std_tokenize.tokenize(f.readline)
183+
for tok in gen:
137184
if tok.type == std_tokenize.ENCODING:
138185
continue
139186
yield tok

tests/data/async/typing.py

+22
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,25 @@
33
typing.AsyncIterable[bytes]
44
typing.AsyncIterator[bytes]
55
typing.AsyncGenerator[bytes]
6+
7+
# A typed function that takes the first item of an (a)sync iterator and returns it
8+
async def func1(a: typing.AsyncIterable[int]) -> str:
9+
it: typing.AsyncIterator[int] = a.__aiter__()
10+
b: int = await it.__anext__()
11+
return str(b)
12+
13+
14+
# Same as the above but using old-style typings (Python 2.7 – 3.5 compatible)
15+
async def func2(a): # type: (typing.AsyncIterable[int]) -> str
16+
it = a.__aiter__() # type: typing.AsyncIterator[int]
17+
b = await it.__anext__() # type: int
18+
return str(b)
19+
20+
21+
# And some funky edge cases to at least cover the relevant at all in this test
22+
a: int = 5
23+
b: str = a # type: ignore # This the actual comment and the type declaration silences the warning that would otherwise happen
24+
c: str = a # type: ignore2 # This the actual comment and this declares two different types that are both wrong
25+
26+
# And some genuine trailing whitespace (uww…)
27+
z = a # type: int

tests/data/sync/typing.py

+22
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,25 @@
33
typing.Iterable[bytes]
44
typing.Iterator[bytes]
55
typing.Generator[bytes]
6+
7+
# A typed function that takes the first item of an (a)sync iterator and returns it
8+
def func1(a: typing.Iterable[int]) -> str:
9+
it: typing.Iterator[int] = a.__iter__()
10+
b: int = it.__next__()
11+
return str(b)
12+
13+
14+
# Same as the above but using old-style typings (Python 2.7 – 3.5 compatible)
15+
def func2(a): # type: (typing.Iterable[int]) -> str
16+
it = a.__iter__() # type: typing.Iterator[int]
17+
b = it.__next__() # type: int
18+
return str(b)
19+
20+
21+
# And some funky edge cases to at least cover the relevant at all in this test
22+
a: int = 5
23+
b: str = a # type: ignore # This the actual comment and the type declaration silences the warning that would otherwise happen
24+
c: str = a # type: ignore2 # This the actual comment and this declares two different types that are both wrong
25+
26+
# And some genuine trailing whitespace (uww…)
27+
z = a # type: int

tests/test_unasync.py

+3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
SYNC_DIR = os.path.join(TEST_DIR, "sync")
1616
TEST_FILES = sorted([f for f in os.listdir(ASYNC_DIR) if f.endswith(".py")])
1717

18+
if sys.version_info[0] == 2:
19+
TEST_FILES.remove("typing.py")
20+
1821

1922
def list_files(startpath):
2023
output = ""

0 commit comments

Comments
 (0)