This repository has been archived by the owner on Jun 19, 2020. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 17
/
flake8_mypy.py
382 lines (316 loc) · 11.2 KB
/
flake8_mypy.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
#!/usr/bin/env python3
import ast
from collections import namedtuple
from functools import partial
import itertools
import logging
import os
from pathlib import Path
import re
from tempfile import NamedTemporaryFile, TemporaryDirectory
import time
import traceback
from typing import (
Any,
Iterator,
List,
Optional,
Pattern,
Tuple,
Type,
TYPE_CHECKING,
Union,
)
import attr
import mypy.api
if TYPE_CHECKING:
import flake8.options.manager.OptionManager # noqa
__version__ = '17.8.0'
noqa = re.compile(r'# noqa\b', re.I).search
Error = namedtuple('Error', 'lineno col message type vars')
def make_arguments(**kwargs: Union[str, bool]) -> List[str]:
result = []
for k, v in kwargs.items():
k = k.replace('_', '-')
if v is True:
result.append('--' + k)
elif v is False:
continue
else:
result.append('--{}={}'.format(k, v))
return result
def calculate_mypypath() -> List[str]:
"""Return MYPYPATH so that stubs have precedence over local sources."""
typeshed_root = None
count = 0
started = time.time()
for parent in itertools.chain(
# Look in current script's parents, useful for zipapps.
Path(__file__).parents,
# Look around site-packages, useful for virtualenvs.
Path(mypy.api.__file__).parents,
# Look in global paths, useful for globally installed.
Path(os.__file__).parents,
):
count += 1
candidate = parent / 'lib' / 'mypy' / 'typeshed'
if candidate.is_dir():
typeshed_root = candidate
break
# Also check the non-installed path, useful for `setup.py develop`.
candidate = parent / 'typeshed'
if candidate.is_dir():
typeshed_root = candidate
break
LOG.debug(
'Checked %d paths in %.2fs looking for typeshed. Found %s',
count,
time.time() - started,
typeshed_root,
)
if not typeshed_root:
return []
stdlib_dirs = ('3.7', '3.6', '3.5', '3.4', '3.3', '3.2', '3', '2and3')
stdlib_stubs = [
typeshed_root / 'stdlib' / stdlib_dir
for stdlib_dir in stdlib_dirs
]
third_party_dirs = ('3.7', '3.6', '3', '2and3')
third_party_stubs = [
typeshed_root / 'third_party' / tp_dir
for tp_dir in third_party_dirs
]
return [
str(p) for p in stdlib_stubs + third_party_stubs
]
# invalid_types.py:5: error: Missing return statement
MYPY_ERROR_TEMPLATE = r"""
^
.* # whatever at the beginning
{filename}: # this needs to be provided in run()
(?P<lineno>\d+) # necessary for the match
(:(?P<column>\d+))? # optional but useful column info
:[ ] # ends the preamble
((?P<class>error|warning|note):)? # optional class
[ ](?P<message>.*) # the rest
$"""
LOG = logging.getLogger('flake8.mypy')
DEFAULT_ARGUMENTS = make_arguments(
platform='linux',
# flake8-mypy expects the two following for sensible formatting
show_column_numbers=True,
show_error_context=False,
# suppress error messages from unrelated files
follow_imports='skip',
# since we're ignoring imports, writing .mypy_cache doesn't make any sense
cache_dir=os.devnull,
# suppress errors about unsatisfied imports
ignore_missing_imports=True,
# allow untyped calls as a consequence of the options above
disallow_untyped_calls=False,
# allow returning Any as a consequence of the options above
warn_return_any=False,
# treat Optional per PEP 484
strict_optional=True,
# ensure all execution paths are returning
warn_no_return=True,
# lint-style cleanliness for typing needs to be disabled; returns more errors
# than the full run.
warn_redundant_casts=False,
warn_unused_ignores=False,
# The following are off by default. Flip them on if you feel
# adventurous.
disallow_untyped_defs=False,
check_untyped_defs=False,
)
_Flake8Error = Tuple[int, int, str, Type['MypyChecker']]
@attr.s(hash=False)
class MypyChecker:
name = 'flake8-mypy'
version = __version__
tree = attr.ib(default=None)
filename = attr.ib(default='(none)')
lines = attr.ib(default=[]) # type: List[int]
options = attr.ib(default=None)
visitor = attr.ib(default=attr.Factory(lambda: TypingVisitor))
def run(self) -> Iterator[_Flake8Error]:
if not self.lines:
return # empty file, no need checking.
visitor = self.visitor()
visitor.visit(self.tree)
if not visitor.should_type_check:
return # typing not used in the module
if not self.options.mypy_config and 'MYPYPATH' not in os.environ:
os.environ['MYPYPATH'] = ':'.join(calculate_mypypath())
# Always put the file in a separate temporary directory to avoid
# unexpected clashes with other .py and .pyi files in the same original
# directory.
with TemporaryDirectory(prefix='flake8mypy_') as d:
file = NamedTemporaryFile(
'w',
encoding='utf8',
prefix='tmpmypy_',
suffix='.py',
dir=d,
delete=False,
)
try:
self.filename = file.name
for line in self.lines:
file.write(line)
file.close()
yield from self._run()
finally:
os.remove(file.name)
def _run(self) -> Iterator[_Flake8Error]:
mypy_cmdline = self.build_mypy_cmdline(self.filename, self.options.mypy_config)
mypy_re = self.build_mypy_re(self.filename)
last_t499 = 0
try:
stdout, stderr, returncode = mypy.api.run(mypy_cmdline)
except Exception as exc:
# Pokémon exception handling to guard against mypy's internal errors
last_t499 += 1
yield self.adapt_error(T498(last_t499, 0, vars=(type(exc), str(exc))))
for line in traceback.format_exc().splitlines():
last_t499 += 1
yield self.adapt_error(T499(last_t499, 0, vars=(line,)))
else:
# FIXME: should we make any decision based on `returncode`?
for line in stdout.splitlines():
try:
e = self.make_error(line, mypy_re)
except ValueError:
# unmatched line
last_t499 += 1
yield self.adapt_error(T499(last_t499, 0, vars=(line,)))
continue
if self.omit_error(e):
continue
yield self.adapt_error(e)
for line in stderr.splitlines():
last_t499 += 1
yield self.adapt_error(T499(last_t499, 0, vars=(line,)))
@classmethod
def adapt_error(cls, e: Any) -> _Flake8Error:
"""Adapts the extended error namedtuple to be compatible with Flake8."""
return e._replace(message=e.message.format(*e.vars))[:4]
def omit_error(self, e: Error) -> bool:
"""Returns True if error should be ignored."""
if (
e.vars and
e.vars[0] == 'No parent module -- cannot perform relative import'
):
return True
return bool(noqa(self.lines[e.lineno - 1]))
@classmethod
def add_options(cls, parser: 'flake8.options.manager.OptionManager') -> None:
parser.add_option(
'--mypy-config',
parse_from_config=True,
help="path to a custom mypy configuration file",
)
def make_error(self, line: str, regex: Pattern) -> Error:
m = regex.match(line)
if not m:
raise ValueError("unmatched line")
lineno = int(m.group('lineno'))
column = int(m.group('column') or 0)
message = m.group('message').strip()
if m.group('class') == 'note':
return T400(lineno, column, vars=(message,))
return T484(lineno, column, vars=(message,))
def build_mypy_cmdline(
self, filename: str, mypy_config: Optional[str]
) -> List[str]:
if mypy_config:
return ['--config-file=' + mypy_config, filename]
return DEFAULT_ARGUMENTS + [filename]
def build_mypy_re(self, filename: Union[str, Path]) -> Pattern:
filename = Path(filename)
if filename.is_absolute():
prefix = Path('.').absolute()
try:
filename = filename.relative_to(prefix)
except ValueError:
pass # not relative to the cwd
re_filename = re.escape(str(filename))
if re_filename.startswith(r'\./'):
re_filename = re_filename[3:]
return re.compile(
MYPY_ERROR_TEMPLATE.format(filename=re_filename),
re.VERBOSE,
)
@attr.s
class TypingVisitor(ast.NodeVisitor):
"""Used to determine if the file is using annotations at all."""
should_type_check = attr.ib(default=False)
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
if node.returns:
self.should_type_check = True
return
for arg in itertools.chain(node.args.args, node.args.kwonlyargs):
if arg.annotation:
self.should_type_check = True
return
va = node.args.vararg
kw = node.args.kwarg
if (va and va.annotation) or (kw and kw.annotation):
self.should_type_check = True
def visit_Import(self, node: ast.Import) -> None:
for name in node.names:
if (
isinstance(name, ast.alias) and
name.name == 'typing' or
name.name.startswith('typing.')
):
self.should_type_check = True
break
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
if (
node.level == 0 and
node.module == 'typing' or
node.module and node.module.startswith('typing.')
):
self.should_type_check = True
def generic_visit(self, node: ast.AST) -> None:
"""Called if no explicit visitor function exists for a node."""
for _field, value in ast.iter_fields(node):
if self.should_type_check:
break
if isinstance(value, list):
for item in value:
if self.should_type_check:
break
if isinstance(item, ast.AST):
self.visit(item)
elif isinstance(value, ast.AST):
self.visit(value)
# Generic mypy error
T484 = partial(
Error,
message="T484 {}",
type=MypyChecker,
vars=(),
)
# Generic mypy note
T400 = partial(
Error,
message="T400 note: {}",
type=MypyChecker,
vars=(),
)
# Internal mypy error (summary)
T498 = partial(
Error,
message="T498 Internal mypy error '{}': {}",
type=MypyChecker,
vars=(),
)
# Internal mypy error (traceback, stderr, unmatched line)
T499 = partial(
Error,
message="T499 {}",
type=MypyChecker,
vars=(),
)