/
mypy_task.py
198 lines (158 loc) · 6.54 KB
/
mypy_task.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
# coding=utf-8
from __future__ import division
from __future__ import print_function
from __future__ import absolute_import
import hashlib
import os
import shlex
import tempfile
import traceback
from collections import defaultdict
from subprocess import Popen, PIPE
from typing import Optional, Tuple, List, Dict
from mypytools.config import config
STRICT_OPTIONAL_DIRS = [os.path.join(config['root_dir'], d['path']) for d in config['src_dirs'] if d.get('strict_optional')]
# From https://stackoverflow.com/a/377028
def which(program):
# type: (str) -> Optional[str]
def is_exe(fpath):
return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
fpath, fname = os.path.split(program)
if fpath:
if is_exe(program):
return program
else:
for path in os.environ["PATH"].split(os.pathsep):
path = path.strip('"')
exe_file = os.path.join(path, program)
if is_exe(exe_file):
return exe_file
return None
class MypyTask(object):
def __init__(self, filename, include_error_context=True):
# type: (str, bool) -> None
self.filename = filename
self._proc = None # type: Optional[Popen]
self.include_error_context = include_error_context
def _should_use_strict_optional(self, path):
# type: (str) -> bool
for strict_path in STRICT_OPTIONAL_DIRS:
if path.startswith(strict_path):
return True
return False
def _get_file_hash(self):
# type: () -> str
with open(self.filename, 'rb') as f:
return hashlib.md5(f.read()).hexdigest()
def execute(self):
# type: () -> Tuple[int, str, str, str, str]
mypy_path = os.pathsep.join(os.path.join(config['root_dir'], path) for path in config.get('mypy_path', []))
mypy_exec = which('mypy')
python_exec = which('python')
if mypy_exec is None:
print("Couldn't find mypy executable. Is it installed and in your PATH?")
raise RuntimeError('Mypy executable missing.')
if python_exec is None:
print("Couldn't find python executable. Is it in your PATH?")
raise RuntimeError('Python executable missing.')
flags = config.get('global_flags', [])
flags.append('--python-executable={}'.format(python_exec))
if self._should_use_strict_optional(self.filename):
flags.append('--strict-optional')
cmd = shlex.split("{} {} {}".format(mypy_exec, ' '.join(flags), self.filename))
out = ''
err = ''
context = ''
try:
before_file_hash = self._get_file_hash()
after_file_hash = ''
exit_code = 0
while before_file_hash != after_file_hash:
before_file_hash = after_file_hash
self._proc = Popen(cmd, stdout=PIPE, stderr=PIPE, env={'MYPY_PATH': mypy_path})
out, err = self._proc.communicate()
exit_code = self._proc.wait()
# This still has an ABA problem, but ¯\_(ツ)_/¯
after_file_hash = self._get_file_hash()
if exit_code == 0:
return 0, out, err, context, before_file_hash
if self.include_error_context:
context = self._find_context(out)
return exit_code, out, err, context, before_file_hash
except Exception:
traceback.print_exc()
return -1, out, err, context, ''
finally:
self._proc = None
def _find_context(self, errors):
# type: (str) -> str
error_list = errors.split('\n')
errors_by_path = defaultdict(list) # type: Dict[str, List[Tuple[int, str]]]
for error in error_list:
error_parts = error.split(':')
if len(error_parts) != 4:
continue
path, line, _, message = error_parts
errors_by_path[path].append((int(line), message))
results = []
for path in errors_by_path:
results.extend(self._get_context_for_path(path, errors_by_path[path]))
return '\n'.join(results)
def _get_context_for_path(self, path, parsed_errors):
# type: (str, List[Tuple[int, str]]) -> List[str]
result = []
template = """
\033[91m\033[1mError\033[0m: {}
\033[93m\033[1m{}\033[0m
{}
>>> {}
{}
"""
target_context_lines_before = 2
target_context_lines_after = 2
# We seed the context lines with an empty string to get the
# proper final padding before and after.
context_lines_before = ['']
context_lines_after = ['']
with open(path, 'r') as f:
lines = f.readlines()
# Insert a blank line so that 1-indexed line numbers from errors match.
lines.insert(0, '')
for line, message in parsed_errors:
begin_line = max(0, line - target_context_lines_before)
end_line = min(len(lines), line + target_context_lines_after + 1)
for num, l in enumerate(lines[begin_line:line]):
context_lines_before.append('{} {}'.format(begin_line + num, l.rstrip('\n')))
context_line = '{} {}'.format(line, lines[line].rstrip('\n'))
for num, l in enumerate(lines[line + 1:end_line]):
context_lines_after.append('{} {}'.format(line + num + 1, l.rstrip('\n')))
error_lines = [
'\n'.join(context_lines_before),
context_line,
'\n'.join(context_lines_after),
]
# Insert the location.
error_lines.insert(0, '{}:{}'.format(path, line))
# Insert the error message.
error_lines.insert(1, message)
result.append(template.format(*error_lines))
return result
def interrupt(self):
# type: () -> None
if self._proc is None:
return
try:
# There's a race between interrupting the stored process and
# the process exiting. If the process exits first then killing
# it will throw an OSError, so just swallow that and keep going.
self._proc.kill()
except OSError:
pass
def __eq__(self, other):
# type: (object) -> bool
if not isinstance(other, MypyTask):
raise NotImplemented
return self.filename == other.filename
def __hash__(self):
# type: () -> int
return self.filename.__hash__()