Skip to content

Commit 9f53917

Browse files
authored
Quick and dirty variant of incremental mode (#2972)
* "Quick and dirty" variant of incremental mode. This uses the cache whenever the cache file exists and is newer than the source. The difference with regular incremental mode is that regular incremental mode forces a re-check whenever any dependency is stale, and it forces re-checks for all modules in an SCC. In quick-and-dirty mode, by contrast, if some modules in an SCC have a new-enough cache file, they are loaded from the cache even if some other module in the same SCC or one of its dependencies was stale. This is much quicker but obviously not always correct. The hope is that it speeds up checking single files when there are large SCCs (or large dependency graphs) around. A typical workflow would be: 1. mypy -i (slow, but warms the cache) 2. edit some file (or a few) 3. mypy --quick-and-dirty (re-checks only the file(s) you edited) 4. repeat steps 2-4 any number of times (maybe for different files) 5. mypy -i (re-check everything -- this could be part of CI) 6. if any errors left, go to step 2
1 parent 9a1fbde commit 9f53917

File tree

7 files changed

+409
-43
lines changed

7 files changed

+409
-43
lines changed

mypy/build.py

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,9 @@ def find_cache_meta(id: str, path: str, manager: BuildManager) -> Optional[Cache
787787
# Ignore cache if (relevant) options aren't the same.
788788
cached_options = m.options
789789
current_options = manager.options.clone_for_module(id).select_options_affecting_cache()
790+
if manager.options.quick_and_dirty:
791+
# In quick_and_dirty mode allow non-quick_and_dirty cache files.
792+
cached_options['quick_and_dirty'] = True
790793
if cached_options != current_options:
791794
manager.trace('Metadata abandoned for {}: options differ'.format(id))
792795
return None
@@ -1318,10 +1321,12 @@ def load_tree(self) -> None:
13181321
self.manager.modules[self.id] = self.tree
13191322

13201323
def fix_cross_refs(self) -> None:
1321-
fixup_module_pass_one(self.tree, self.manager.modules)
1324+
fixup_module_pass_one(self.tree, self.manager.modules,
1325+
self.manager.options.quick_and_dirty)
13221326

13231327
def calculate_mros(self) -> None:
1324-
fixup_module_pass_two(self.tree, self.manager.modules)
1328+
fixup_module_pass_two(self.tree, self.manager.modules,
1329+
self.manager.options.quick_and_dirty)
13251330

13261331
def fix_suppressed_dependencies(self, graph: Graph) -> None:
13271332
"""Corrects whether dependencies are considered stale in silent mode.
@@ -1513,7 +1518,14 @@ def valid_references(self) -> Set[str]:
15131518
return valid_refs
15141519

15151520
def write_cache(self) -> None:
1516-
if self.path and self.options.incremental and not self.manager.errors.is_errors():
1521+
ok = self.path and self.options.incremental
1522+
if ok:
1523+
if self.manager.options.quick_and_dirty:
1524+
is_errors = self.manager.errors.is_errors_for_file(self.path)
1525+
else:
1526+
is_errors = self.manager.errors.is_errors()
1527+
ok = not is_errors
1528+
if ok:
15171529
dep_prios = [self.priorities.get(dep, PRI_HIGH) for dep in self.dependencies]
15181530
new_interface_hash = write_cache(
15191531
self.id, self.path, self.tree,
@@ -1702,7 +1714,8 @@ def process_graph(graph: Graph, manager: BuildManager) -> None:
17021714
deps.update(graph[id].dependencies)
17031715
deps -= ascc
17041716
stale_deps = {id for id in deps if not graph[id].is_interface_fresh()}
1705-
fresh = fresh and not stale_deps
1717+
if not manager.options.quick_and_dirty:
1718+
fresh = fresh and not stale_deps
17061719
undeps = set()
17071720
if fresh:
17081721
# Check if any dependencies that were suppressed according
@@ -1717,7 +1730,7 @@ def process_graph(graph: Graph, manager: BuildManager) -> None:
17171730
# All cache files are fresh. Check that no dependency's
17181731
# cache file is newer than any scc node's cache file.
17191732
oldest_in_scc = min(graph[id].meta.data_mtime for id in scc)
1720-
viable = {id for id in deps if not graph[id].is_interface_fresh()}
1733+
viable = {id for id in stale_deps if graph[id].meta is not None}
17211734
newest_in_deps = 0 if not viable else max(graph[dep].meta.data_mtime for dep in viable)
17221735
if manager.options.verbosity >= 3: # Dump all mtimes for extreme debugging.
17231736
all_ids = sorted(ascc | viable, key=lambda id: graph[id].meta.data_mtime)
@@ -1735,7 +1748,9 @@ def process_graph(graph: Graph, manager: BuildManager) -> None:
17351748
manager.trace(" %5s %.0f %s" % (key, graph[id].meta.data_mtime, id))
17361749
# If equal, give the benefit of the doubt, due to 1-sec time granularity
17371750
# (on some platforms).
1738-
if oldest_in_scc < newest_in_deps:
1751+
if manager.options.quick_and_dirty and stale_deps:
1752+
fresh_msg = "fresh(ish)"
1753+
elif oldest_in_scc < newest_in_deps:
17391754
fresh = False
17401755
fresh_msg = "out of date by %.0f seconds" % (newest_in_deps - oldest_in_scc)
17411756
else:
@@ -1753,7 +1768,7 @@ def process_graph(graph: Graph, manager: BuildManager) -> None:
17531768

17541769
scc_str = " ".join(scc)
17551770
if fresh:
1756-
manager.log("Queuing fresh SCC (%s)" % scc_str)
1771+
manager.log("Queuing %s SCC (%s)" % (fresh_msg, scc_str))
17571772
fresh_scc_queue.append(scc)
17581773
else:
17591774
if len(fresh_scc_queue) > 0:
@@ -1775,7 +1790,7 @@ def process_graph(graph: Graph, manager: BuildManager) -> None:
17751790
manager.log("Processing SCC singleton (%s) as %s" % (scc_str, fresh_msg))
17761791
else:
17771792
manager.log("Processing SCC of size %d (%s) as %s" % (size, scc_str, fresh_msg))
1778-
process_stale_scc(graph, scc)
1793+
process_stale_scc(graph, scc, manager)
17791794

17801795
sccs_left = len(fresh_scc_queue)
17811796
if sccs_left:
@@ -1842,26 +1857,46 @@ def process_fresh_scc(graph: Graph, scc: List[str]) -> None:
18421857
graph[id].calculate_mros()
18431858

18441859

1845-
def process_stale_scc(graph: Graph, scc: List[str]) -> None:
1846-
"""Process the modules in one SCC from source code."""
1847-
for id in scc:
1860+
def process_stale_scc(graph: Graph, scc: List[str], manager: BuildManager) -> None:
1861+
"""Process the modules in one SCC from source code.
1862+
1863+
Exception: If quick_and_dirty is set, use the cache for fresh modules.
1864+
"""
1865+
if manager.options.quick_and_dirty:
1866+
fresh = [id for id in scc if graph[id].is_fresh()]
1867+
fresh_set = set(fresh) # To avoid running into O(N**2)
1868+
stale = [id for id in scc if id not in fresh_set]
1869+
if fresh:
1870+
manager.log(" Fresh ids: %s" % (", ".join(fresh)))
1871+
if stale:
1872+
manager.log(" Stale ids: %s" % (", ".join(stale)))
1873+
else:
1874+
fresh = []
1875+
stale = scc
1876+
for id in fresh:
1877+
graph[id].load_tree()
1878+
for id in stale:
18481879
# We may already have parsed the module, or not.
18491880
# If the former, parse_file() is a no-op.
18501881
graph[id].parse_file()
18511882
graph[id].fix_suppressed_dependencies(graph)
1852-
for id in scc:
1883+
for id in fresh:
1884+
graph[id].fix_cross_refs()
1885+
for id in stale:
18531886
graph[id].semantic_analysis()
1854-
for id in scc:
1887+
for id in stale:
18551888
graph[id].semantic_analysis_pass_three()
1856-
for id in scc:
1889+
for id in fresh:
1890+
graph[id].calculate_mros()
1891+
for id in stale:
18571892
graph[id].type_check_first_pass()
18581893
more = True
18591894
while more:
18601895
more = False
1861-
for id in scc:
1896+
for id in stale:
18621897
if graph[id].type_check_second_pass():
18631898
more = True
1864-
for id in scc:
1899+
for id in stale:
18651900
graph[id].finish_passes()
18661901
graph[id].write_cache()
18671902
graph[id].mark_as_rechecked()

mypy/errors.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ class Errors:
7676
# Current error context: nested import context/stack, as a list of (path, line) pairs.
7777
import_ctx = None # type: List[Tuple[str, int]]
7878

79+
# Set of files with errors.
80+
error_files = None # type: Set[str]
81+
7982
# Path name prefix that is removed from all paths, if set.
8083
ignore_prefix = None # type: str
8184

@@ -110,6 +113,7 @@ def __init__(self, show_error_context: bool = False,
110113
show_column_numbers: bool = False) -> None:
111114
self.error_info = []
112115
self.import_ctx = []
116+
self.error_files = set()
113117
self.type_name = [None]
114118
self.function_or_member = [None]
115119
self.ignored_lines = OrderedDict()
@@ -230,6 +234,7 @@ def add_error_info(self, info: ErrorInfo) -> None:
230234
return
231235
self.only_once_messages.add(info.message)
232236
self.error_info.append(info)
237+
self.error_files.add(file)
233238

234239
def generate_unused_ignore_notes(self) -> None:
235240
for file, ignored_lines in self.ignored_lines.items():
@@ -257,6 +262,10 @@ def is_blockers(self) -> bool:
257262
"""Are the any errors that are blockers?"""
258263
return any(err for err in self.error_info if err.blocker)
259264

265+
def is_errors_for_file(self, file: str) -> bool:
266+
"""Are there any errors for the given file?"""
267+
return file in self.error_files
268+
260269
def raise_error(self) -> None:
261270
"""Raise a CompileError with the generated messages.
262271

mypy/fixup.py

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
from mypy.visitor import NodeVisitor
1717

1818

19-
def fixup_module_pass_one(tree: MypyFile, modules: Dict[str, MypyFile]) -> None:
20-
node_fixer = NodeFixer(modules)
19+
def fixup_module_pass_one(tree: MypyFile, modules: Dict[str, MypyFile],
20+
quick_and_dirty: bool) -> None:
21+
node_fixer = NodeFixer(modules, quick_and_dirty)
2122
node_fixer.visit_symbol_table(tree.names)
2223

2324

24-
def fixup_module_pass_two(tree: MypyFile, modules: Dict[str, MypyFile]) -> None:
25+
def fixup_module_pass_two(tree: MypyFile, modules: Dict[str, MypyFile],
26+
quick_and_dirty: bool) -> None:
2527
compute_all_mros(tree.names, modules)
2628

2729

@@ -38,11 +40,10 @@ def compute_all_mros(symtab: SymbolTable, modules: Dict[str, MypyFile]) -> None:
3840
class NodeFixer(NodeVisitor[None]):
3941
current_info = None # type: Optional[TypeInfo]
4042

41-
def __init__(self, modules: Dict[str, MypyFile], type_fixer: 'TypeFixer' = None) -> None:
43+
def __init__(self, modules: Dict[str, MypyFile], quick_and_dirty: bool) -> None:
4244
self.modules = modules
43-
if type_fixer is None:
44-
type_fixer = TypeFixer(self.modules)
45-
self.type_fixer = type_fixer
45+
self.quick_and_dirty = quick_and_dirty
46+
self.type_fixer = TypeFixer(self.modules, quick_and_dirty)
4647

4748
# NOTE: This method isn't (yet) part of the NodeVisitor API.
4849
def visit_type_info(self, info: TypeInfo) -> None:
@@ -76,10 +77,13 @@ def visit_symbol_table(self, symtab: SymbolTable) -> None:
7677
if cross_ref in self.modules:
7778
value.node = self.modules[cross_ref]
7879
else:
79-
stnode = lookup_qualified_stnode(self.modules, cross_ref)
80-
assert stnode is not None, "Could not find cross-ref %s" % (cross_ref,)
81-
value.node = stnode.node
82-
value.type_override = stnode.type_override
80+
stnode = lookup_qualified_stnode(self.modules, cross_ref,
81+
self.quick_and_dirty)
82+
if stnode is not None:
83+
value.node = stnode.node
84+
value.type_override = stnode.type_override
85+
elif not self.quick_and_dirty:
86+
assert stnode is not None, "Could not find cross-ref %s" % (cross_ref,)
8387
else:
8488
if isinstance(value.node, TypeInfo):
8589
# TypeInfo has no accept(). TODO: Add it?
@@ -132,16 +136,17 @@ def visit_var(self, v: Var) -> None:
132136

133137

134138
class TypeFixer(TypeVisitor[None]):
135-
def __init__(self, modules: Dict[str, MypyFile]) -> None:
139+
def __init__(self, modules: Dict[str, MypyFile], quick_and_dirty: bool) -> None:
136140
self.modules = modules
141+
self.quick_and_dirty = quick_and_dirty
137142

138143
def visit_instance(self, inst: Instance) -> None:
139144
# TODO: Combine Instances that are exactly the same?
140145
type_ref = inst.type_ref
141146
if type_ref is None:
142147
return # We've already been here.
143148
del inst.type_ref
144-
node = lookup_qualified(self.modules, type_ref)
149+
node = lookup_qualified(self.modules, type_ref, self.quick_and_dirty)
145150
if isinstance(node, TypeInfo):
146151
inst.type = node
147152
# TODO: Is this needed or redundant?
@@ -230,29 +235,40 @@ def visit_type_type(self, t: TypeType) -> None:
230235
t.item.accept(self)
231236

232237

233-
def lookup_qualified(modules: Dict[str, MypyFile], name: str) -> SymbolNode:
234-
stnode = lookup_qualified_stnode(modules, name)
238+
def lookup_qualified(modules: Dict[str, MypyFile], name: str,
239+
quick_and_dirty: bool) -> SymbolNode:
240+
stnode = lookup_qualified_stnode(modules, name, quick_and_dirty)
235241
if stnode is None:
236242
return None
237243
else:
238244
return stnode.node
239245

240246

241-
def lookup_qualified_stnode(modules: Dict[str, MypyFile], name: str) -> SymbolTableNode:
247+
def lookup_qualified_stnode(modules: Dict[str, MypyFile], name: str,
248+
quick_and_dirty: bool) -> Optional[SymbolTableNode]:
242249
head = name
243250
rest = []
244251
while True:
245-
assert '.' in head, "Cannot find %s" % (name,)
252+
if '.' not in head:
253+
if not quick_and_dirty:
254+
assert '.' in head, "Cannot find %s" % (name,)
255+
return None
246256
head, tail = head.rsplit('.', 1)
247257
rest.append(tail)
248258
mod = modules.get(head)
249259
if mod is not None:
250260
break
251261
names = mod.names
252262
while True:
253-
assert rest, "Cannot find %s" % (name,)
263+
if not rest:
264+
if not quick_and_dirty:
265+
assert rest, "Cannot find %s" % (name,)
266+
return None
254267
key = rest.pop()
255-
assert key in names, "Cannot find %s for %s" % (key, name)
268+
if key not in names:
269+
return None
270+
elif not quick_and_dirty:
271+
assert key in names, "Cannot find %s for %s" % (key, name)
256272
stnode = names[key]
257273
if not rest:
258274
return stnode

mypy/main.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,10 @@ def add_invertible_flag(flag: str,
239239
add_invertible_flag('--no-fast-parser', default=True, dest='fast_parser',
240240
help="disable the fast parser (not recommended)")
241241
parser.add_argument('-i', '--incremental', action='store_true',
242-
help="enable experimental module cache")
242+
help="enable module cache")
243+
parser.add_argument('--quick-and-dirty', action='store_true',
244+
help="use cache even if dependencies out of date "
245+
"(implies --incremental)")
243246
parser.add_argument('--cache-dir', action='store', metavar='DIR',
244247
help="store module cache info in the given folder in incremental mode "
245248
"(defaults to '{}')".format(defaults.CACHE_DIR))
@@ -405,6 +408,10 @@ def add_invertible_flag(flag: str,
405408
report_dir = val
406409
options.report_dirs[report_type] = report_dir
407410

411+
# Let quick_and_dirty imply incremental.
412+
if options.quick_and_dirty:
413+
options.incremental = True
414+
408415
# Set target.
409416
if special_opts.modules:
410417
options.build_type = BuildType.MODULE

mypy/options.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class Options:
3131
"strict_boolean",
3232
}
3333

34-
OPTIONS_AFFECTING_CACHE = PER_MODULE_OPTIONS | {"strict_optional"}
34+
OPTIONS_AFFECTING_CACHE = PER_MODULE_OPTIONS | {"strict_optional", "quick_and_dirty"}
3535

3636
def __init__(self) -> None:
3737
# -- build options --
@@ -101,6 +101,15 @@ def __init__(self) -> None:
101101
# Write junit.xml to given file
102102
self.junit_xml = None # type: Optional[str]
103103

104+
# Fast parser is on by default
105+
self.fast_parser = True
106+
107+
# Caching options
108+
self.incremental = False
109+
self.cache_dir = defaults.CACHE_DIR
110+
self.debug_cache = False
111+
self.quick_and_dirty = False
112+
104113
# Per-module options (raw)
105114
self.per_module_options = {} # type: Dict[Pattern[str], Dict[str, object]]
106115

@@ -119,10 +128,6 @@ def __init__(self) -> None:
119128
self.use_builtins_fixtures = False
120129

121130
# -- experimental options --
122-
self.fast_parser = True
123-
self.incremental = False
124-
self.cache_dir = defaults.CACHE_DIR
125-
self.debug_cache = False
126131
self.shadow_file = None # type: Optional[Tuple[str, str]]
127132
self.show_column_numbers = False # type: bool
128133
self.dump_graph = False

mypy/test/testcheck.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ def run_case_once(self, testcase: DataDrivenTestCase, incremental: int = 0) -> N
149149
os.utime(target, times=(new_time, new_time))
150150

151151
# Parse options after moving files (in case mypy.ini is being moved).
152-
options = self.parse_options(original_program_text, testcase)
152+
options = self.parse_options(original_program_text, testcase, incremental)
153153
options.use_builtins_fixtures = True
154154
options.show_traceback = True
155155
if 'optional' in testcase.file:
@@ -305,9 +305,14 @@ def parse_module(self, program_text: str, incremental: int = 0) -> List[Tuple[st
305305
else:
306306
return [('__main__', 'main', program_text)]
307307

308-
def parse_options(self, program_text: str, testcase: DataDrivenTestCase) -> Options:
308+
def parse_options(self, program_text: str, testcase: DataDrivenTestCase,
309+
incremental: int) -> Options:
309310
options = Options()
310311
flags = re.search('# flags: (.*)$', program_text, flags=re.MULTILINE)
312+
if incremental == 2:
313+
flags2 = re.search('# flags2: (.*)$', program_text, flags=re.MULTILINE)
314+
if flags2:
315+
flags = flags2
311316

312317
flag_list = None
313318
if flags:

0 commit comments

Comments
 (0)