Skip to content

Commit 600807e

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 95267ba commit 600807e

File tree

3 files changed

+98
-2
lines changed

3 files changed

+98
-2
lines changed

src/unasync/__init__.py

Lines changed: 52 additions & 2 deletions
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
@@ -143,7 +190,10 @@ def _tokenize(f):
143190
last_end = (1, 0)
144191
for tok in _get_tokens(f):
145192
if last_end[0] < tok.start[0]:
146-
yield ("", std_tokenize.STRING, " \\\n")
193+
# Somehow Python 3.5 and below produce the ENDMARKER in a way that
194+
# causes superfluous continuation lines to be generated
195+
if tok.type != std_tokenize.ENDMARKER:
196+
yield ("", std_tokenize.STRING, " \\\n")
147197
last_end = (tok.start[0], 0)
148198

149199
space = ""

tests/data/async/typing.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,26 @@
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 (mainly for Python 2.7 – 3.5 compatibility)
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 is the actual comment and the type declaration silences the warning that would otherwise happen
24+
c: str = a # type: ignore2 # This is the actual comment and the declaration declares another type, both of which are wrong
25+
26+
# fmt: off
27+
# And some genuine trailing whitespace (uww…)
28+
z = a # type: int

tests/data/sync/typing.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,26 @@
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 (mainly for Python 2.7 – 3.5 compatibility)
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 is the actual comment and the type declaration silences the warning that would otherwise happen
24+
c: str = a # type: ignore2 # This is the actual comment and the declaration declares another type, both of which are wrong
25+
26+
# fmt: off
27+
# And some genuine trailing whitespace (uww…)
28+
z = a # type: int

0 commit comments

Comments
 (0)