Repository: python/mypy

Issue:
Silent imports is too silent
We've encountered a few cases where users were baffled when they found out that a certain error (to them obvious) was _not_ reported, and the reason was that some essential file was excluded due to use of the --silent-imports (-s) flag.

It seems to be particularly baffling when there's a base class that comes from a silenced import. See e.g. #1363 -- once we fix that, there remains the problem that Base has type Any and so in Derived, self has effectively type Any (as do all other instances of Derived).

One possibility would be to have an option that's just like --silent-imports but which reports an error at every suppressed import (possibly displaying a different message depending on whether the module was found or not). This option might also add an additional message when a base class is undefined due to a missing import.

Another approach might be to treat this as a new reporting option.



Discussion:

Resolving patch:
diff --git a/mypy/build.py b/mypy/build.py
--- a/mypy/build.py
+++ b/mypy/build.py
@@ -57,6 +57,7 @@
 DUMP_TYPE_STATS = 'dump-type-stats'
 DUMP_INFER_STATS = 'dump-infer-stats'
 SILENT_IMPORTS = 'silent-imports'  # Silence imports of .py files
+ALMOST_SILENT = 'almost-silent'  # If SILENT_IMPORTS: report silenced imports as errors
 INCREMENTAL = 'incremental'      # Incremental mode: use the cache
 FAST_PARSER = 'fast-parser'      # Use experimental fast parser
 # Disallow calling untyped functions from typed ones
@@ -941,7 +942,7 @@ def __init__(self,
                  manager: BuildManager,
                  caller_state: 'State' = None,
                  caller_line: int = 0,
-                 is_ancestor: bool = False,
+                 ancestor_for: 'State' = None,
                  ) -> None:
         assert id or path or source is not None, "Neither id, path nor source given"
         self.manager = manager
@@ -971,13 +972,18 @@ def __init__(self,
             if path:
                 # In silent mode, don't import .py files, except from stubs.
                 if (SILENT_IMPORTS in manager.flags and
-                        path.endswith('.py') and (caller_state or is_ancestor)):
+                        path.endswith('.py') and (caller_state or ancestor_for)):
                     # (Never silence builtins, even if it's a .py file;
                     # this can happen in tests!)
                     if (id != 'builtins' and
                         not ((caller_state and
                               caller_state.tree and
                               caller_state.tree.is_stub))):
+                        if ALMOST_SILENT in manager.flags:
+                            if ancestor_for:
+                                self.skipping_ancestor(id, path, ancestor_for)
+                            else:
+                                self.skipping_module(id, path)
                         path = None
                         manager.missing_modules.add(id)
                         raise ModuleNotFound
@@ -986,10 +992,12 @@ def __init__(self,
                 # misspelled module name, missing stub, module not in
                 # search path or the module has not been installed.
                 if caller_state:
-                    if not (SILENT_IMPORTS in manager.flags or
-                            (caller_state.tree is not None and
-                             (caller_line in caller_state.tree.ignored_lines or
-                              'import' in caller_state.tree.weak_opts))):
+                    suppress_message = ((SILENT_IMPORTS in manager.flags and
+                                        ALMOST_SILENT not in manager.flags) or
+                                        (caller_state.tree is not None and
+                                         (caller_line in caller_state.tree.ignored_lines or
+                                          'import' in caller_state.tree.weak_opts)))
+                    if not suppress_message:
                         save_import_context = manager.errors.import_context()
                         manager.errors.set_import_context(caller_state.import_context)
                         manager.module_not_found(caller_state.xpath, caller_line, id)
@@ -1015,6 +1023,36 @@ def __init__(self,
             # Parse the file (and then some) to get the dependencies.
             self.parse_file()
 
+    def skipping_ancestor(self, id: str, path: str, ancestor_for: 'State') -> None:
+        # TODO: Read the path (the __init__.py file) and return
+        # immediately if it's empty or only contains comments.
+        # But beware, some package may be the ancestor of many modules,
+        # so we'd need to cache the decision.
+        manager = self.manager
+        manager.errors.set_import_context([])
+        manager.errors.set_file(ancestor_for.xpath)
+        manager.errors.report(-1, "Ancestor package '%s' silently ignored" % (id,),
+                              severity='note', only_once=True)
+        manager.errors.report(-1, "(Using --silent-imports, submodule passed on command line)",
+                              severity='note', only_once=True)
+        manager.errors.report(-1, "(This note brought to you by --almost-silent)",
+                              severity='note', only_once=True)
+
+    def skipping_module(self, id: str, path: str) -> None:
+        assert self.caller_state, (id, path)
+        manager = self.manager
+        save_import_context = manager.errors.import_context()
+        manager.errors.set_import_context(self.caller_state.import_context)
+        manager.errors.set_file(self.caller_state.xpath)
+        line = self.caller_line
+        manager.errors.report(line, "Import of '%s' silently ignored" % (id,),
+                              severity='note')
+        manager.errors.report(line, "(Using --silent-imports, module not passed on command line)",
+                              severity='note', only_once=True)
+        manager.errors.report(line, "(This note courtesy of --almost-silent)",
+                              severity='note', only_once=True)
+        manager.errors.set_import_context(save_import_context)
+
     def add_ancestors(self) -> None:
         # All parent packages are new ancestors.
         ancestors = []
@@ -1215,7 +1253,7 @@ def load_graph(sources: List[BuildSource], manager: BuildManager) -> Graph:
                         # TODO: Why not 'if dep not in st.dependencies' ?
                         # Ancestors don't have import context.
                         newst = State(id=dep, path=None, source=None, manager=manager,
-                                      is_ancestor=True)
+                                      ancestor_for=st)
                     else:
                         newst = State(id=dep, path=None, source=None, manager=manager,
                                       caller_state=st, caller_line=st.dep_line_map.get(dep, 1))
diff --git a/mypy/main.py b/mypy/main.py
--- a/mypy/main.py
+++ b/mypy/main.py
@@ -130,8 +130,12 @@ def parse_version(v):
                         help='use Python x.y')
     parser.add_argument('--py2', dest='python_version', action='store_const',
                         const=defaults.PYTHON2_VERSION, help="use Python 2 mode")
-    parser.add_argument('-s', '--silent-imports', '--silent', action='store_true',
+    parser.add_argument('-s', '--silent-imports', action='store_true',
                         help="don't follow imports to .py files")
+    parser.add_argument('--silent', action='store_true',
+                        help="deprecated name for --silent-imports")
+    parser.add_argument('--almost-silent', action='store_true',
+                        help="like --silent-imports but reports the imports as errors")
     parser.add_argument('--disallow-untyped-calls', action='store_true',
                         help="disallow calling functions without type annotations"
                         " from functions with type annotations")
@@ -208,8 +212,11 @@ def parse_version(v):
     if args.inferstats:
         options.build_flags.append(build.DUMP_INFER_STATS)
 
-    if args.silent_imports:
+    if args.silent_imports or args.silent:
         options.build_flags.append(build.SILENT_IMPORTS)
+    if args.almost_silent:
+        options.build_flags.append(build.SILENT_IMPORTS)
+        options.build_flags.append(build.ALMOST_SILENT)
 
     if args.disallow_untyped_calls:
         options.build_flags.append(build.DISALLOW_UNTYPED_CALLS)


Test patch:
diff --git a/mypy/test/data/check-modules.test b/mypy/test/data/check-modules.test
--- a/mypy/test/data/check-modules.test
+++ b/mypy/test/data/check-modules.test
@@ -699,6 +699,29 @@ class C(B):
 [out]
 tmp/bar.py:1: error: Module has no attribute 'B'
 
+[case testImportSuppressedWhileAlmostSilent]
+# cmd: mypy -m main
+[file main.py]
+# flags: silent-imports almost-silent
+import mod
+[file mod.py]
+[builtins fixtures/module.py]
+[out]
+tmp/main.py:2: note: Import of 'mod' silently ignored
+tmp/main.py:2: note: (Using --silent-imports, module not passed on command line)
+tmp/main.py:2: note: (This note courtesy of --almost-silent)
+
+[case testAncestorSuppressedWhileAlmostSilent]
+# cmd: mypy -m foo.bar
+[file foo/bar.py]
+# flags: silent-imports almost-silent
+[file foo/__init__.py]
+[builtins fixtures/module.py]
+[out]
+tmp/foo/bar.py: note: Ancestor package 'foo' silently ignored
+tmp/foo/bar.py: note: (Using --silent-imports, submodule passed on command line)
+tmp/foo/bar.py: note: (This note brought to you by --almost-silent)
+
 [case testStubImportNonStubWhileSilent]
 # cmd: mypy -m main
 [file main.py]