Skip to content

Commit 52c0a63

Browse files
authored
Work towards following imports in mypy daemon (#8559)
This implements some basic use cases, such as adding files, removing files, and basic package support. The implementation is separate from what is used for coarse-grained incremental mode, since it seemed too difficult (and risky) to try to merge them. Many special cases are not handled correctly yet, and code is in need of some cleanup. Some functionality is still missing. Following imports is only enabled in tests for now. I'll enable the feature generally after it's more fully implemented.
1 parent 60a7e08 commit 52c0a63

File tree

5 files changed

+924
-68
lines changed

5 files changed

+924
-68
lines changed

mypy/dmypy_server.py

Lines changed: 241 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,19 @@
1616
import traceback
1717
from contextlib import redirect_stderr, redirect_stdout
1818

19-
from typing import AbstractSet, Any, Callable, Dict, List, Optional, Sequence, Tuple
19+
from typing import AbstractSet, Any, Callable, Dict, List, Optional, Sequence, Tuple, Set
2020
from typing_extensions import Final
2121

2222
import mypy.build
2323
import mypy.errors
2424
import mypy.main
2525
from mypy.find_sources import create_source_list, InvalidSourceList
26-
from mypy.server.update import FineGrainedBuildManager
26+
from mypy.server.update import FineGrainedBuildManager, refresh_suppressed_submodules
2727
from mypy.dmypy_util import receive
2828
from mypy.ipc import IPCServer
2929
from mypy.fscache import FileSystemCache
3030
from mypy.fswatcher import FileSystemWatcher, FileData
31-
from mypy.modulefinder import BuildSource, compute_search_paths
31+
from mypy.modulefinder import BuildSource, compute_search_paths, FindModuleCache, SearchPaths
3232
from mypy.options import Options
3333
from mypy.suggestions import SuggestionFailure, SuggestionEngine
3434
from mypy.typestate import reset_global_state
@@ -361,8 +361,12 @@ def cmd_recheck(self,
361361
t1 = time.time()
362362
manager = self.fine_grained_manager.manager
363363
manager.log("fine-grained increment: cmd_recheck: {:.3f}s".format(t1 - t0))
364-
res = self.fine_grained_increment(sources, is_tty, terminal_width,
365-
remove, update)
364+
if not self.following_imports():
365+
messages = self.fine_grained_increment(sources, remove, update)
366+
else:
367+
assert remove is None and update is None
368+
messages = self.fine_grained_increment_follow_imports(sources)
369+
res = self.increment_output(messages, sources, is_tty, terminal_width)
366370
self.fscache.flush()
367371
self.update_stats(res)
368372
return res
@@ -377,7 +381,11 @@ def check(self, sources: List[BuildSource],
377381
if not self.fine_grained_manager:
378382
res = self.initialize_fine_grained(sources, is_tty, terminal_width)
379383
else:
380-
res = self.fine_grained_increment(sources, is_tty, terminal_width)
384+
if not self.following_imports():
385+
messages = self.fine_grained_increment(sources)
386+
else:
387+
messages = self.fine_grained_increment_follow_imports(sources)
388+
res = self.increment_output(messages, sources, is_tty, terminal_width)
381389
self.fscache.flush()
382390
self.update_stats(res)
383391
return res
@@ -389,6 +397,11 @@ def update_stats(self, res: Dict[str, Any]) -> None:
389397
res['stats'] = manager.stats
390398
manager.stats = {}
391399

400+
def following_imports(self) -> bool:
401+
"""Are we following imports?"""
402+
# TODO: What about silent?
403+
return self.options.follow_imports == 'normal'
404+
392405
def initialize_fine_grained(self, sources: List[BuildSource],
393406
is_tty: bool, terminal_width: int) -> Dict[str, Any]:
394407
self.fswatcher = FileSystemWatcher(self.fscache)
@@ -408,6 +421,11 @@ def initialize_fine_grained(self, sources: List[BuildSource],
408421
return {'out': out, 'err': err, 'status': 2}
409422
messages = result.errors
410423
self.fine_grained_manager = FineGrainedBuildManager(result)
424+
425+
if self.following_imports():
426+
sources = find_all_sources_in_build(self.fine_grained_manager.graph)
427+
self.update_sources(sources)
428+
411429
self.previous_sources = sources
412430

413431
# If we are using the fine-grained cache, build hasn't actually done
@@ -436,13 +454,19 @@ def initialize_fine_grained(self, sources: List[BuildSource],
436454
t3 = time.time()
437455
# Run an update
438456
messages = self.fine_grained_manager.update(changed, removed)
457+
458+
if self.following_imports():
459+
# We need to do another update to any new files found by following imports.
460+
messages = self.fine_grained_increment_follow_imports(sources)
461+
439462
t4 = time.time()
440463
self.fine_grained_manager.manager.add_stats(
441464
update_sources_time=t1 - t0,
442465
build_time=t2 - t1,
443466
find_changes_time=t3 - t2,
444467
fg_update_time=t4 - t3,
445468
files_changed=len(removed) + len(changed))
469+
446470
else:
447471
# Stores the initial state of sources as a side effect.
448472
self.fswatcher.find_changed()
@@ -457,11 +481,19 @@ def initialize_fine_grained(self, sources: List[BuildSource],
457481

458482
def fine_grained_increment(self,
459483
sources: List[BuildSource],
460-
is_tty: bool,
461-
terminal_width: int,
462484
remove: Optional[List[str]] = None,
463485
update: Optional[List[str]] = None,
464-
) -> Dict[str, Any]:
486+
) -> List[str]:
487+
"""Perform a fine-grained type checking increment.
488+
489+
If remove and update are None, determine changed paths by using
490+
fswatcher. Otherwise, assume that only these files have changes.
491+
492+
Args:
493+
sources: sources passed on the command line
494+
remove: paths of files that have been removed
495+
update: paths of files that have been changed or created
496+
"""
465497
assert self.fine_grained_manager is not None
466498
manager = self.fine_grained_manager.manager
467499

@@ -486,8 +518,180 @@ def fine_grained_increment(self,
486518
fg_update_time=t2 - t1,
487519
files_changed=len(removed) + len(changed))
488520

489-
status = 1 if messages else 0
490521
self.previous_sources = sources
522+
return messages
523+
524+
def fine_grained_increment_follow_imports(self, sources: List[BuildSource]) -> List[str]:
525+
"""Like fine_grained_increment, but follow imports."""
526+
527+
# TODO:
528+
# - file events
529+
# - search path updates
530+
# - logging
531+
532+
changed_paths = self.fswatcher.find_changed()
533+
534+
assert self.fine_grained_manager is not None
535+
fine_grained_manager = self.fine_grained_manager
536+
graph = fine_grained_manager.graph
537+
manager = fine_grained_manager.manager
538+
539+
orig_modules = list(graph.keys())
540+
541+
# TODO: Are the differences from fine_grained_increment(), such as
542+
# updating sources after finding changed, necessary?
543+
self.update_sources(sources)
544+
545+
sources_set = {source.module for source in sources}
546+
547+
# Find changed modules reachable from roots (or in roots) already in graph.
548+
seen = set() # type: Set[str]
549+
changed, removed, new_files = self.follow_imports(
550+
sources, graph, seen, changed_paths, sources_set
551+
)
552+
sources.extend(new_files)
553+
554+
# Process changes directly reachable from roots.
555+
messages = fine_grained_manager.update(changed, removed)
556+
557+
# Follow deps from changed modules (still within graph).
558+
worklist = changed[:]
559+
while worklist:
560+
module = worklist.pop()
561+
if module[0] not in graph:
562+
continue
563+
sources2 = self.direct_imports(module, graph)
564+
changed, removed, new_files = self.follow_imports(
565+
sources2, graph, seen, changed_paths, sources_set
566+
)
567+
sources.extend(new_files)
568+
self.update_sources(new_files)
569+
messages = fine_grained_manager.update(changed, removed)
570+
# TODO: Removed?
571+
worklist.extend(changed)
572+
573+
for module_id, state in graph.items():
574+
refresh_suppressed_submodules(module_id, state.path, fine_grained_manager.deps, graph,
575+
self.fscache)
576+
577+
# There may be new files that became available, currently treated as
578+
# suppressed imports. Process them.
579+
seen_suppressed = set() # type: Set[str]
580+
while True:
581+
# TODO: Merge seen and seen_suppressed?
582+
new_unsuppressed, seen_suppressed = self.find_added_suppressed(
583+
graph, seen_suppressed, manager.search_paths
584+
)
585+
if not new_unsuppressed:
586+
break
587+
new_files = [BuildSource(mod[1], mod[0]) for mod in new_unsuppressed]
588+
sources.extend(new_files)
589+
self.update_sources(new_files)
590+
messages = fine_grained_manager.update(new_unsuppressed, [])
591+
592+
for module_id, path in new_unsuppressed:
593+
refresh_suppressed_submodules(module_id, path, fine_grained_manager.deps, graph,
594+
self.fscache)
595+
596+
# Find all original modules in graph that were not reached -- they are deleted.
597+
to_delete = []
598+
for module_id in orig_modules:
599+
if module_id not in graph:
600+
continue
601+
if module_id not in seen and module_id not in seen_suppressed:
602+
module_path = graph[module_id].path
603+
assert module_path is not None
604+
to_delete.append((module_id, module_path))
605+
if to_delete:
606+
messages = fine_grained_manager.update([], to_delete)
607+
608+
fix_module_deps(graph)
609+
610+
# Store current file state as side effect
611+
self.fswatcher.find_changed()
612+
613+
self.previous_sources = find_all_sources_in_build(graph)
614+
self.update_sources(self.previous_sources)
615+
return messages
616+
617+
def follow_imports(self,
618+
sources: List[BuildSource],
619+
graph: mypy.build.Graph,
620+
seen: Set[str],
621+
changed_paths: AbstractSet[str],
622+
sources_set: Set[str]) -> Tuple[List[Tuple[str, str]],
623+
List[Tuple[str, str]],
624+
List[BuildSource]]:
625+
"""Follow imports within graph from given sources.
626+
627+
Args:
628+
sources: roots of modules to search
629+
graph: module graph to use for the search
630+
seen: modules we've seen before that won't be visited (mutated here!)
631+
changed_paths: which paths have changed (stop search here and return any found)
632+
sources_set: set of sources (TODO: relationship with seen)
633+
634+
Return (reachable changed modules, removed modules, updated file list).
635+
"""
636+
changed = []
637+
new_files = []
638+
worklist = sources[:]
639+
seen.update(source.module for source in worklist)
640+
while worklist:
641+
nxt = worklist.pop()
642+
if nxt.module not in sources_set:
643+
sources_set.add(nxt.module)
644+
new_files.append(nxt)
645+
if nxt.path in changed_paths:
646+
assert nxt.path is not None # TODO
647+
changed.append((nxt.module, nxt.path))
648+
elif nxt.module in graph:
649+
state = graph[nxt.module]
650+
for dep in state.dependencies:
651+
if dep not in seen:
652+
seen.add(dep)
653+
worklist.append(BuildSource(graph[dep].path,
654+
graph[dep].id))
655+
return changed, [], new_files
656+
657+
def direct_imports(self,
658+
module: Tuple[str, str],
659+
graph: mypy.build.Graph) -> List[BuildSource]:
660+
"""Return the direct imports of module not included in seen."""
661+
state = graph[module[0]]
662+
return [BuildSource(graph[dep].path, dep)
663+
for dep in state.dependencies]
664+
665+
def find_added_suppressed(self,
666+
graph: mypy.build.Graph,
667+
seen: Set[str],
668+
search_paths: SearchPaths) -> Tuple[List[Tuple[str, str]], Set[str]]:
669+
"""Find suppressed modules that have been added (and not included in seen)."""
670+
all_suppressed = set()
671+
for module, state in graph.items():
672+
all_suppressed |= state.suppressed_set
673+
674+
# TODO: Namespace packages
675+
# TODO: Handle seen?
676+
677+
finder = FindModuleCache(search_paths, self.fscache, self.options)
678+
679+
found = []
680+
681+
for module in all_suppressed:
682+
result = finder.find_module(module)
683+
if isinstance(result, str) and module not in seen:
684+
found.append((module, result))
685+
seen.add(module)
686+
687+
return found, seen
688+
689+
def increment_output(self,
690+
messages: List[str],
691+
sources: List[BuildSource],
692+
is_tty: bool,
693+
terminal_width: int) -> Dict[str, Any]:
694+
status = 1 if messages else 0
491695
messages = self.pretty_messages(messages, len(sources), is_tty, terminal_width)
492696
return {'out': ''.join(s + '\n' for s in messages), 'err': '', 'status': status}
493697

@@ -622,3 +826,30 @@ def get_meminfo() -> Dict[str, Any]:
622826
factor = 1024 # Linux
623827
res['memory_maxrss_mib'] = rusage.ru_maxrss * factor / MiB
624828
return res
829+
830+
831+
def find_all_sources_in_build(graph: mypy.build.Graph) -> List[BuildSource]:
832+
result = []
833+
for module, state in graph.items():
834+
result.append(BuildSource(state.path, module))
835+
return result
836+
837+
838+
def fix_module_deps(graph: mypy.build.Graph) -> None:
839+
"""After an incremental update, update module dependencies to reflect the new state.
840+
841+
This can make some suppressed dependencies non-suppressed, and vice versa (if modules
842+
have been added to or removed from the build).
843+
"""
844+
for module, state in graph.items():
845+
new_suppressed = []
846+
new_dependencies = []
847+
for dep in state.dependencies + state.suppressed:
848+
if dep in graph:
849+
new_dependencies.append(dep)
850+
else:
851+
new_suppressed.append(dep)
852+
state.dependencies = new_dependencies
853+
state.dependencies_set = set(new_dependencies)
854+
state.suppressed = new_suppressed
855+
state.suppressed_set = set(new_suppressed)

mypy/modulefinder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ class BuildSource:
7070
"""A single source file."""
7171

7272
def __init__(self, path: Optional[str], module: Optional[str],
73-
text: Optional[str], base_dir: Optional[str] = None) -> None:
73+
text: Optional[str] = None, base_dir: Optional[str] = None) -> None:
7474
self.path = path # File where it's found (e.g. 'xxx/yyy/foo/bar.py')
7575
self.module = module or '__main__' # Module name (e.g. 'foo.bar')
7676
self.text = text # Source code, if initially supplied, else None

0 commit comments

Comments
 (0)