Skip to content

Commit 63920b1

Browse files
authored
Integrate fine-grained incremental checking with dmypy (#4447)
Fine-grained incremental mode is enabled through the `--experimental` dmypy flag. As a mostly unrelated change, also print daemon tracebacks to log to make debugging crashes easier.
1 parent 942f7d4 commit 63920b1

File tree

4 files changed

+220
-12
lines changed

4 files changed

+220
-12
lines changed

mypy/dmypy_server.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313
import socket
1414
import sys
1515
import time
16+
import traceback
1617

17-
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
18+
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
1819

1920
import mypy.build
2021
import mypy.errors
2122
import mypy.main
23+
import mypy.server.update
2224
from mypy.dmypy_util import STATUS_FILE, receive
2325
from mypy.gclogger import GcLogger
2426

@@ -82,6 +84,12 @@ class Server:
8284
def __init__(self, flags: List[str]) -> None:
8385
"""Initialize the server with the desired mypy flags."""
8486
self.saved_cache = {} # type: mypy.build.SavedCache
87+
if '--experimental' in flags:
88+
self.fine_grained = True
89+
self.fine_grained_initialized = False
90+
flags.remove('--experimental')
91+
else:
92+
self.fine_grained = False
8593
sources, options = mypy.main.process_options(['-i'] + flags, False)
8694
if sources:
8795
sys.exit("dmypy: start/restart does not accept sources")
@@ -94,6 +102,10 @@ def __init__(self, flags: List[str]) -> None:
94102
self.options = options
95103
if os.path.isfile(STATUS_FILE):
96104
os.unlink(STATUS_FILE)
105+
if self.fine_grained:
106+
options.incremental = True
107+
options.show_traceback = True
108+
options.cache_dir = os.devnull
97109

98110
def serve(self) -> None:
99111
"""Serve requests, synchronously (no thread or fork)."""
@@ -128,6 +140,9 @@ def serve(self) -> None:
128140
os.unlink(STATUS_FILE)
129141
finally:
130142
os.unlink(self.sockname)
143+
exc_info = sys.exc_info()
144+
if exc_info[0]:
145+
traceback.print_exception(*exc_info) # type: ignore
131146

132147
def create_listening_socket(self) -> socket.socket:
133148
"""Create the socket and set it up for listening."""
@@ -190,6 +205,14 @@ def cmd_recheck(self) -> Dict[str, object]:
190205

191206
def check(self, sources: List[mypy.build.BuildSource],
192207
alt_lib_path: Optional[str] = None) -> Dict[str, Any]:
208+
if self.fine_grained:
209+
return self.check_fine_grained(sources)
210+
else:
211+
return self.check_default(sources, alt_lib_path)
212+
213+
def check_default(self, sources: List[mypy.build.BuildSource],
214+
alt_lib_path: Optional[str] = None) -> Dict[str, Any]:
215+
"""Check using the default (per-file) incremental mode."""
193216
self.last_manager = None
194217
with GcLogger() as gc_result:
195218
try:
@@ -212,6 +235,73 @@ def check(self, sources: List[mypy.build.BuildSource],
212235
response.update(self.last_manager.stats_summary())
213236
return response
214237

238+
def check_fine_grained(self, sources: List[mypy.build.BuildSource]) -> Dict[str, Any]:
239+
"""Check using fine-grained incremental mode."""
240+
if not self.fine_grained_initialized:
241+
return self.initialize_fine_grained(sources)
242+
else:
243+
return self.fine_grained_increment(sources)
244+
245+
def initialize_fine_grained(self, sources: List[mypy.build.BuildSource]) -> Dict[str, Any]:
246+
self.file_modified = {} # type: Dict[str, float]
247+
for source in sources:
248+
assert source.path
249+
self.file_modified[source.path] = os.stat(source.path).st_mtime
250+
try:
251+
# TODO: alt_lib_path
252+
result = mypy.build.build(sources=sources,
253+
options=self.options)
254+
except mypy.errors.CompileError as e:
255+
output = ''.join(s + '\n' for s in e.messages)
256+
if e.use_stdout:
257+
out, err = output, ''
258+
else:
259+
out, err = '', output
260+
return {'out': out, 'err': err, 'status': 2}
261+
messages = result.errors
262+
manager = result.manager
263+
graph = result.graph
264+
self.fine_grained_manager = mypy.server.update.FineGrainedBuildManager(manager, graph)
265+
status = 1 if messages else 0
266+
self.previous_messages = messages[:]
267+
self.fine_grained_initialized = True
268+
self.previous_sources = sources
269+
return {'out': ''.join(s + '\n' for s in messages), 'err': '', 'status': status}
270+
271+
def fine_grained_increment(self, sources: List[mypy.build.BuildSource]) -> Dict[str, Any]:
272+
changed = self.find_changed(sources)
273+
if not changed:
274+
# Nothing changed -- just produce the same result as before.
275+
messages = self.previous_messages
276+
else:
277+
messages = self.fine_grained_manager.update(changed)
278+
status = 1 if messages else 0
279+
self.previous_messages = messages[:]
280+
self.previous_sources = sources
281+
return {'out': ''.join(s + '\n' for s in messages), 'err': '', 'status': status}
282+
283+
def find_changed(self, sources: List[mypy.build.BuildSource]) -> List[Tuple[str, str]]:
284+
changed = []
285+
for source in sources:
286+
path = source.path
287+
assert path
288+
mtime = os.stat(path).st_mtime
289+
if path not in self.file_modified or self.file_modified[path] != mtime:
290+
self.file_modified[path] = mtime
291+
changed.append((source.module, path))
292+
modules = {source.module for source in sources}
293+
omitted = [source for source in self.previous_sources if source.module not in modules]
294+
for source in omitted:
295+
path = source.path
296+
assert path
297+
# Note that a file could be removed from the list of root sources but still continue
298+
# to exist on the file system.
299+
if not os.path.isfile(path):
300+
changed.append((source.module, path))
301+
if source.path in self.file_modified:
302+
del self.file_modified[source.path]
303+
return changed
304+
215305
def cmd_hang(self) -> Dict[str, object]:
216306
"""Hang for 100 seconds, as a debug hack."""
217307
time.sleep(100)

mypy/server/update.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -643,7 +643,7 @@ def update_dependencies(new_modules: Mapping[str, Optional[MypyFile]],
643643
for id, node in new_modules.items():
644644
if node is None:
645645
continue
646-
if '/typeshed/' in node.path:
646+
if '/typeshed/' in node.path or node.path.startswith('typeshed/'):
647647
# We don't track changes to typeshed -- the assumption is that they are only changed
648648
# as part of mypy updates, which will invalidate everything anyway.
649649
#

mypy/test/testdmypy.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
'check-enum.test',
2929
'check-incremental.test',
3030
'check-newtype.test',
31+
'check-dmypy-fine-grained.test',
3132
]
3233
else:
3334
dmypy_files = [] # type: List[str]
@@ -72,16 +73,8 @@ def run_case_once(self, testcase: DataDrivenTestCase, incremental_step: int) ->
7273
assert incremental_step >= 1
7374
build.find_module_clear_caches()
7475
original_program_text = '\n'.join(testcase.input)
75-
module_data = self.parse_module(original_program_text, incremental_step)
7676

77-
if incremental_step == 1:
78-
# In run 1, copy program text to program file.
79-
for module_name, program_path, program_text in module_data:
80-
if module_name == '__main__':
81-
with open(program_path, 'w') as f:
82-
f.write(program_text)
83-
break
84-
elif incremental_step > 1:
77+
if incremental_step > 1:
8578
# In runs 2+, copy *.[num] files to * files.
8679
for dn, dirs, files in os.walk(os.curdir):
8780
for file in files:
@@ -101,10 +94,23 @@ def run_case_once(self, testcase: DataDrivenTestCase, incremental_step: int) ->
10194
# Use retries to work around potential flakiness on Windows (AppVeyor).
10295
retry_on_error(lambda: os.remove(path))
10396

97+
module_data = self.parse_module(original_program_text, incremental_step)
98+
99+
if incremental_step == 1:
100+
# In run 1, copy program text to program file.
101+
for module_name, program_path, program_text in module_data:
102+
if module_name == '__main__':
103+
with open(program_path, 'w') as f:
104+
f.write(program_text)
105+
break
106+
104107
# Parse options after moving files (in case mypy.ini is being moved).
105108
options = self.parse_options(original_program_text, testcase, incremental_step)
106109
if incremental_step == 1:
107-
self.server = dmypy_server.Server([]) # TODO: Fix ugly API
110+
server_options = [] # type: List[str]
111+
if 'fine-grained' in testcase.file:
112+
server_options.append('--experimental')
113+
self.server = dmypy_server.Server(server_options) # TODO: Fix ugly API
108114
self.server.options = options
109115

110116
assert self.server is not None # Set in step 1 and survives into next steps
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
[case testCleanFineGrainedIncremental]
2+
# cmd: mypy -m a b
3+
[file b.py]
4+
import a
5+
x = a.f()
6+
7+
[file a.py]
8+
def f() -> int:
9+
return 1
10+
11+
[file a.py.2]
12+
def f() -> str:
13+
return ''
14+
[out1]
15+
[out2]
16+
17+
[case testErrorFineGrainedIncremental]
18+
# cmd: mypy -m a b
19+
[file b.py]
20+
import a
21+
x = a.f()
22+
x = 1
23+
24+
[file a.py]
25+
def f() -> int:
26+
return 1
27+
28+
[file a.py.2]
29+
def f() -> str:
30+
return ''
31+
[out1]
32+
[out2]
33+
tmp/b.py:3: error: Incompatible types in assignment (expression has type "int", variable has type "str")
34+
35+
[case testAddFileFineGrainedIncremental]
36+
# cmd: mypy -m a
37+
# cmd2: mypy -m a b
38+
[file a.py]
39+
import b
40+
b.f(1)
41+
[file b.py.2]
42+
def f(x: str) -> None: pass
43+
[out1]
44+
tmp/a.py:1: error: Cannot find module named 'b'
45+
tmp/a.py:1: note: (Perhaps setting MYPYPATH or using the "--ignore-missing-imports" flag would help)
46+
[out2]
47+
tmp/a.py:2: error: Argument 1 to "f" has incompatible type "int"; expected "str"
48+
49+
[case testDeleteFileFineGrainedIncremental]
50+
# cmd: mypy -m a b
51+
# cmd2: mypy -m a
52+
[file a.py]
53+
import b
54+
b.f(1)
55+
[file b.py]
56+
def f(x: str) -> None: pass
57+
[delete b.py.2]
58+
[out1]
59+
tmp/a.py:2: error: Argument 1 to "f" has incompatible type "int"; expected "str"
60+
[out2]
61+
tmp/a.py:1: error: Cannot find module named 'b'
62+
tmp/a.py:1: note: (Perhaps setting MYPYPATH or using the "--ignore-missing-imports" flag would help)
63+
64+
[case testInitialErrorFineGrainedIncremental]
65+
# cmd: mypy -m a b
66+
[file b.py]
67+
import a
68+
x = a.f()
69+
x = ''
70+
71+
[file a.py]
72+
def f() -> int:
73+
return 1
74+
75+
[file a.py.2]
76+
def f() -> str:
77+
return ''
78+
[out1]
79+
tmp/b.py:3: error: Incompatible types in assignment (expression has type "str", variable has type "int")
80+
[out2]
81+
82+
[case testInitialBlockerFineGrainedIncremental]
83+
# cmd: mypy -m a b
84+
[file a.py]
85+
1 1
86+
[file b.py]
87+
def f() -> int:
88+
return ''
89+
[file a.py.2]
90+
x = 1
91+
[file b.py.3]
92+
def f() -> int:
93+
return 0
94+
[out1]
95+
tmp/a.py:1: error: invalid syntax
96+
[out2]
97+
tmp/b.py:2: error: Incompatible return value type (got "str", expected "int")
98+
[out3]
99+
100+
[case testNoOpUpdateFineGrainedIncremental]
101+
# cmd: mypy -m a
102+
[file a.py]
103+
1()
104+
[file b.py.2]
105+
# Note: this file is not part of the build
106+
[file a.py.3]
107+
x = 1
108+
[out1]
109+
tmp/a.py:1: error: "int" not callable
110+
[out2]
111+
tmp/a.py:1: error: "int" not callable
112+
[out3]

0 commit comments

Comments
 (0)