Repository: python/mypy

Issue:
Always write the `-i` cache
I've noticed that many people initially run plain `mypy` and then start using `mypy -i` but find that the first such run is not any faster than a plain run. This is understandable because it needs to warm the cache first.

We could alleviate this issue by just always writing the cache (perhaps allowing this feature to be turned off for special scenarios but having it on by default). This might cost a little bit of extra run time -- IIRC I once measured the cost of writing the cache and it was in the 5-10% range.


Discussion:
It would then be cleaner to simply default to the `-i` behavior entirely and only allow a non-cache variant as an option.
I'm torn about that. Historically `-i` mode has had a lot of instabilities around edge cases, which would make this problematic -- but some of that is because it's not on by default, so maybe enabling it by default is better because it'll get more testing. Also, at Dropbox it suffered from not flushing the cache when the mypy version changes, but that's been fixed recently (#2992).

Calling for additional opinions?
Writing the cache always seems reasonable to me, since the performance overhead is pretty minor but the potential speedup from being able to use `-i` is significant. Turning it off manually could be useful at least for CI jobs that won't ever run mypy twice. 

I feel like turning `-i` on by default is a little risky, since it has historically been unstable and we don't have very good CI around it yet. Regressions to `-i` can still slip through quite easily. I'd suggest we wait until we have better testing around it.

Resolving patch:
diff --git a/mypy/build.py b/mypy/build.py
--- a/mypy/build.py
+++ b/mypy/build.py
@@ -860,8 +860,7 @@ def write_cache(id: str, path: str, tree: MypyFile,
 
     # Make sure directory for cache files exists
     parent = os.path.dirname(data_json)
-    if not os.path.isdir(parent):
-        os.makedirs(parent)
+    os.makedirs(parent, exist_ok=True)
     assert os.path.dirname(meta_json) == parent
 
     # Construct temp file names
@@ -877,6 +876,20 @@ def write_cache(id: str, path: str, tree: MypyFile,
         data_str = json.dumps(data, sort_keys=True)
     interface_hash = compute_hash(data_str)
 
+    # Obtain and set up metadata
+    try:
+        st = manager.get_stat(path)
+    except OSError as err:
+        manager.log("Cannot get stat for {}: {}".format(path, err))
+        # Remove apparently-invalid cache files.
+        for filename in [data_json, meta_json]:
+            try:
+                os.remove(filename)
+            except OSError:
+                pass
+        # Still return the interface hash we computed.
+        return interface_hash
+
     # Write data cache file, if applicable
     if old_interface_hash == interface_hash:
         # If the interface is unchanged, the cached data is guaranteed
@@ -891,8 +904,6 @@ def write_cache(id: str, path: str, tree: MypyFile,
         os.replace(data_json_tmp, data_json)
         manager.trace("Interface for {} has changed".format(id))
 
-    # Obtain and set up metadata
-    st = manager.get_stat(path)  # TODO: Handle errors
     mtime = st.st_mtime
     size = st.st_size
     options = manager.options.clone_for_module(id)
@@ -1524,26 +1535,26 @@ def valid_references(self) -> Set[str]:
         return valid_refs
 
     def write_cache(self) -> None:
-        ok = self.path and self.options.incremental
-        if ok:
-            if self.manager.options.quick_and_dirty:
-                is_errors = self.manager.errors.is_errors_for_file(self.path)
-            else:
-                is_errors = self.manager.errors.is_errors()
-            ok = not is_errors
-        if ok:
-            dep_prios = [self.priorities.get(dep, PRI_HIGH) for dep in self.dependencies]
-            new_interface_hash = write_cache(
-                self.id, self.path, self.tree,
-                list(self.dependencies), list(self.suppressed), list(self.child_modules),
-                dep_prios, self.interface_hash,
-                self.manager)
-            if new_interface_hash == self.interface_hash:
-                self.manager.log("Cached module {} has same interface".format(self.id))
-            else:
-                self.manager.log("Cached module {} has changed interface".format(self.id))
-                self.mark_interface_stale()
-                self.interface_hash = new_interface_hash
+        if not self.path or self.options.cache_dir == os.devnull:
+            return
+        if self.manager.options.quick_and_dirty:
+            is_errors = self.manager.errors.is_errors_for_file(self.path)
+        else:
+            is_errors = self.manager.errors.is_errors()
+        if is_errors:
+            return
+        dep_prios = [self.priorities.get(dep, PRI_HIGH) for dep in self.dependencies]
+        new_interface_hash = write_cache(
+            self.id, self.path, self.tree,
+            list(self.dependencies), list(self.suppressed), list(self.child_modules),
+            dep_prios, self.interface_hash,
+            self.manager)
+        if new_interface_hash == self.interface_hash:
+            self.manager.log("Cached module {} has same interface".format(self.id))
+        else:
+            self.manager.log("Cached module {} has changed interface".format(self.id))
+            self.mark_interface_stale()
+            self.interface_hash = new_interface_hash
 
 
 def dispatch(sources: List[BuildSource], manager: BuildManager) -> Graph:


Test patch: