Repository: python/mypy
Pull request: Fix adding a class in a new module and using it
We add a field to AnyType indicating---if it was created because of a
failed import---what the full name of the imported name is (that is,
the name in the importing module).

An eventual better solution might be to *always* generate dependencies to the locally imported name of variables. That would fix #4512 as well.

Problem statement:
Reexported imports not tracked properly in fine-grained incremental mode
We fail to propagate changes when the target of an import changes.

Here, we swap what module `a` imports `foo` from, but `m.f` doesn't get rechecked.
```
[case testImportSwap]
from a import foo
def f() -> int:
    return foo()

[file a.py]
from b1 import foo
[file a.py.2]
from b2 import foo

[file b1.py]
def foo() -> int:
    return 12
[file b2.py]
def foo() -> str:
    return 'uhoh'
[out]
==
main:3: error: Incompatible return value type (got "str", expected "int")
```

Same thing if we switch from `a` importing `foo` to just defining it.
```
[case testImportSwap2]
from b import foo
def f() -> int:
    return foo()

[file a.py]
from b import foo
[file a.py.2]
def foo() -> str:
    return 'uhoh'

[file b.py]
def foo() -> int:
    return 12
[out]
==
main:3: error: Incompatible return value type (got "str", expected "int")
```

Same problem without using `from` in `m`.
```
[case testImportSwap3]
import a
def f() -> int:
    return a.foo()
[file a.py]
from b1 import foo
[file a.py.2]
from b2 import foo

[file b1.py]
def foo() -> int:
    return 12
[file b2.py]
def foo() -> str:
    return 'uhoh'
[out]
==
main:3: error: Incompatible return value type (got "str", expected "int")
```

Hints from issue discussion:

Commit message: Fix adding a class in a new module and using it

We add a field to AnyType indicating---if it was created because of a
failed import---what the full name of the imported name is (that is,
the name in the importing module).

Patch to review:
diff --git a/mypy/semanal.py b/mypy/semanal.py
index bc9db0c34efb..ca93352c384f 100644
--- a/mypy/semanal.py
+++ b/mypy/semanal.py
@@ -1698,7 +1698,7 @@ def add_unknown_symbol(self, name: str, context: Context, is_import: bool = Fals
             var._fullname = self.qualified_name(name)
         var.is_ready = True
         if is_import:
-            any_type = AnyType(TypeOfAny.from_unimported_type)
+            any_type = AnyType(TypeOfAny.from_unimported_type, missing_import_name=var._fullname)
         else:
             any_type = AnyType(TypeOfAny.from_error)
         var.type = any_type
diff --git a/mypy/server/deps.py b/mypy/server/deps.py
index 91ef49e3a546..efd3a58da871 100644
--- a/mypy/server/deps.py
+++ b/mypy/server/deps.py
@@ -645,6 +645,8 @@ def visit_instance(self, typ: Instance) -> List[str]:
         return triggers
 
     def visit_any(self, typ: AnyType) -> List[str]:
+        if typ.missing_import_name is not None:
+            return [make_trigger(typ.missing_import_name)]
         return []
 
     def visit_none_type(self, typ: NoneTyp) -> List[str]:
diff --git a/mypy/typeanal.py b/mypy/typeanal.py
index 6bafaae0fb11..8b31f86514ea 100644
--- a/mypy/typeanal.py
+++ b/mypy/typeanal.py
@@ -315,7 +315,8 @@ def visit_unbound_type(self, t: UnboundType) -> Type:
                     # context. This is slightly problematic as it allows using the type 'Any'
                     # as a base class -- however, this will fail soon at runtime so the problem
                     # is pretty minor.
-                    return AnyType(TypeOfAny.from_unimported_type)
+                    return AnyType(TypeOfAny.from_unimported_type,
+                                   missing_import_name=sym.node.type.missing_import_name)
                 # Allow unbound type variables when defining an alias
                 if not (self.aliasing and sym.kind == TVAR and
                         (not self.tvar_scope or self.tvar_scope.get_binding(sym) is None)):
diff --git a/mypy/types.py b/mypy/types.py
index b4ad8e68c8f4..96b5849ffee2 100644
--- a/mypy/types.py
+++ b/mypy/types.py
@@ -294,6 +294,7 @@ class AnyType(Type):
     def __init__(self,
                  type_of_any: TypeOfAny,
                  source_any: Optional['AnyType'] = None,
+                 missing_import_name: Optional[str] = None,
                  line: int = -1,
                  column: int = -1) -> None:
         super().__init__(line, column)
@@ -304,6 +305,14 @@ def __init__(self,
         if source_any and source_any.source_any:
             self.source_any = source_any.source_any
 
+        if source_any is None:
+            self.missing_import_name = missing_import_name
+        else:
+            self.missing_import_name = source_any.missing_import_name
+
+        # Only unimported type anys and anys from other anys should have an import name
+        assert (missing_import_name is None or
+                type_of_any in (TypeOfAny.from_unimported_type, TypeOfAny.from_another_any))
         # Only Anys that come from another Any can have source_any.
         assert type_of_any != TypeOfAny.from_another_any or source_any is not None
         # We should not have chains of Anys.
@@ -321,6 +330,7 @@ def copy_modified(self,
         if original_any is _dummy:
             original_any = self.source_any
         return AnyType(type_of_any=type_of_any, source_any=original_any,
+                       missing_import_name=self.missing_import_name,
                        line=self.line, column=self.column)
 
     def __hash__(self) -> int:
@@ -331,14 +341,16 @@ def __eq__(self, other: object) -> bool:
 
     def serialize(self) -> JsonDict:
         return {'.class': 'AnyType', 'type_of_any': self.type_of_any.name,
-                'source_any': self.source_any.serialize() if self.source_any is not None else None}
+                'source_any': self.source_any.serialize() if self.source_any is not None else None,
+                'missing_import_name': self.missing_import_name}
 
     @classmethod
     def deserialize(cls, data: JsonDict) -> 'AnyType':
         assert data['.class'] == 'AnyType'
         source = data['source_any']
         return AnyType(TypeOfAny[data['type_of_any']],
-                       AnyType.deserialize(source) if source is not None else None)
+                       AnyType.deserialize(source) if source is not None else None,
+                       data['missing_import_name'])
 
 
 class UninhabitedType(Type):
diff --git a/test-data/unit/deps.test b/test-data/unit/deps.test
index 6245794c3c45..51d423c55688 100644
--- a/test-data/unit/deps.test
+++ b/test-data/unit/deps.test
@@ -603,3 +603,11 @@ from a import *
 x = 0
 [out]
 <a[wildcard]> -> m
+
+[case testMissingModuleClass]
+from b import A  # type: ignore
+def f(x: A) -> None:
+    x.foo()
+[out]
+<m.A> -> <m.f>, m.f
+<b.A> -> m
diff --git a/test-data/unit/fine-grained-modules.test b/test-data/unit/fine-grained-modules.test
index 8ab0085216f3..ac87c39d92f5 100644
--- a/test-data/unit/fine-grained-modules.test
+++ b/test-data/unit/fine-grained-modules.test
@@ -1362,3 +1362,45 @@ def f() -> None:
 a.py:2: error: "int" not callable
 a.py:3: error: "str" not callable
 ==
+
+[case testAddAndUseClass1]
+[file a.py]
+[file a.py.2]
+from b import Foo
+def bar(f: Foo) -> None:
+    f.foo(12)
+[file b.py.2]
+class Foo:
+    def foo(self, s: str) -> None: pass
+[out]
+==
+a.py:3: error: Argument 1 to "foo" of "Foo" has incompatible type "int"; expected "str"
+
+[case testAddAndUseClass2]
+[file a.py]
+[file a.py.3]
+from b import Foo
+def bar(f: Foo) -> None:
+    f.foo(12)
+[file b.py.2]
+class Foo:
+    def foo(self, s: str) -> None: pass
+[out]
+==
+==
+a.py:3: error: Argument 1 to "foo" of "Foo" has incompatible type "int"; expected "str"
+
+[case testAddAndUseClass3]
+# flags: --ignore-missing-imports
+[file a.py]
+[file a.py.2]
+from b import Foo
+def bar(f: Foo) -> None:
+    f.foo(12)
+[file b.py.3]
+class Foo:
+    def foo(self, s: str) -> None: pass
+[out]
+==
+==
+a.py:3: error: Argument 1 to "foo" of "Foo" has incompatible type "int"; expected "str"


Code review comments:
diff_hunk: @@ -603,3 +603,11 @@ from a import *
 x = 0
 [out]
 <a[wildcard]> -> m
+
+[case testMissingModuleClass]
path: test-data/unit/deps.test
text: @user1:
Try with an import from a package as well (such as `from p.b import A`) and using `from p import *`.