Repository: python/mypy

Issue:
Mypy gets confused between Process and process
Repro (on OS X only):

```
mypy --py2 -c 'from multiprocessing import Process'
```

This gives the following mysterious error (and no others):

```
typeshed/stdlib/2.7/multiprocessing/__init__.pyi:5: error: Name 'Process' already defined
```

Using `-v` I found the reason is that mypy believes that Process is a submodule of multiprocessing, because `os.stat('.../Process.pyi')` succeeds -- the filesystem is case-insensitive, so it returns stat() info for `.../process.pyi`. :-(

I'll try to figure out what the right idiom is to handle this.



Discussion:
Wouldn't this also be an issue on Windows, which is also case-insensitive?

Yes, Windows should have the same problem. In either case I expect the
solution is to list the directory and double-check whether the file is
exactly there rather than just believing os.path.exists() or similar.


Resolving patch:
diff --git a/mypy/build.py b/mypy/build.py
--- a/mypy/build.py
+++ b/mypy/build.py
@@ -522,10 +522,51 @@ def remove_cwd_prefix_from_path(p: str) -> str:
 # in the last component.
 find_module_dir_cache = {}  # type: Dict[Tuple[str, Tuple[str, ...]], List[str]]
 
+# Cache directory listings.  We assume that while one os.listdir()
+# call may be more expensive than one os.stat() call, a small number
+# of os.stat() calls is quickly more expensive than caching the
+# os.listdir() outcome, and the advantage of the latter is that it
+# gives us the case-correct filename on Windows and Mac.
+find_module_listdir_cache = {}  # type: Dict[str, Optional[List[str]]]
+
 
 def find_module_clear_caches():
     find_module_cache.clear()
     find_module_dir_cache.clear()
+    find_module_listdir_cache.clear()
+
+
+def list_dir(path: str) -> Optional[List[str]]:
+    """Return a cached directory listing.
+
+    Returns None if the path doesn't exist or isn't a directory.
+    """
+    if path in find_module_listdir_cache:
+        return find_module_listdir_cache[path]
+    try:
+        res = os.listdir(path)
+    except OSError:
+        res = None
+    find_module_listdir_cache[path] = res
+    return res
+
+
+def is_file(path: str) -> bool:
+    """Return whether path exists and is a file.
+
+    On case-insensitive filesystems (like Mac or Windows) this returns
+    False if the case of the path's last component does not exactly
+    match the case found in the filesystem.
+    """
+    head, tail = os.path.split(path)
+    if not tail:
+        return False
+    names = list_dir(head)
+    if not names:
+        return False
+    if tail not in names:
+        return False
+    return os.path.isfile(path)
 
 
 def find_module(id: str, lib_path: Iterable[str]) -> str:
@@ -560,9 +601,9 @@ def find():
             base_path = base_dir + seplast  # so e.g. '/usr/lib/python3.4/foo/bar/baz'
             for extension in PYTHON_EXTENSIONS:
                 path = base_path + extension
-                if not os.path.isfile(path):
+                if not is_file(path):
                     path = base_path + sepinit + extension
-                if os.path.isfile(path) and verify_module(id, path):
+                if is_file(path) and verify_module(id, path):
                     return path
         return None
 
@@ -1401,7 +1442,7 @@ def process_graph(graph: Graph, manager: BuildManager) -> None:
         else:
             fresh_msg = "stale due to deps (%s)" % " ".join(sorted(stale_deps))
         if len(scc) == 1:
-            manager.log("Processing SCC sigleton (%s) as %s" % (" ".join(scc), fresh_msg))
+            manager.log("Processing SCC singleton (%s) as %s" % (" ".join(scc), fresh_msg))
         else:
             manager.log("Processing SCC of size %d (%s) as %s" %
                         (len(scc), " ".join(scc), fresh_msg))


Test patch:
diff --git a/mypy/test/testcheck.py b/mypy/test/testcheck.py
--- a/mypy/test/testcheck.py
+++ b/mypy/test/testcheck.py
@@ -9,7 +9,7 @@
 
 from mypy import build
 import mypy.myunit  # for mutable globals (ick!)
-from mypy.build import BuildSource
+from mypy.build import BuildSource, find_module_clear_caches
 from mypy.myunit import Suite, AssertionFailure
 from mypy.test.config import test_temp_dir, test_data_prefix
 from mypy.test.data import parse_test_cases, DataDrivenTestCase
@@ -86,6 +86,7 @@ def clear_cache(self) -> None:
             shutil.rmtree(dn)
 
     def run_test_once(self, testcase: DataDrivenTestCase, incremental=0) -> None:
+        find_module_clear_caches()
         pyversion = testcase_pyversion(testcase.file, testcase.name)
         program_text = '\n'.join(testcase.input)
         module_name, program_name, program_text = self.parse_options(program_text)