Repository: python/mypy

Issue:
`no-redef` when `import`-importing a module to satisfy a protocol


**Bug Report**

modules-as-protocols triggers "no-redef" when using `import`-imports, but is fine with `from`-imports

(I'm testing out this unreleased feature on the primary branch)

```python
# main.py
import os
from typing import Protocol

class P(Protocol):
    def f(self) -> str: ...

t: P
if os.environ.get('ENV') == 'prod':
    import t as t  # Name "t" already defined on line 7  [no-redef]
else:
    import u as t  # Name "t" already defined on line 7  [no-redef]

```

```python
# t.py and u.py
def f() -> str:
    return ''
```

the errors triggered are inline above

oddly enough, a `from` import seems to work fine (so there's an easy workaround if the module isn't top-level):

```bash
mkdir y
touch y/__init__.py
mv {t,u}.py y
```

```python
import os
from typing import Protocol

class P(Protocol):
    def f(self) -> str: ...

t: P
if os.environ.get('ENV') == 'prod':
    from y import t as t  # no errors!
else:
    from y import u as t  # no errors!
```

**Your Environment**

<!-- Include as many relevant details about the environment you experienced the bug in -->

- Mypy version used: `mypy 0.990+dev.dc5c299aa190949f2300b163ccc10257a779006d (compiled: no)`
- Mypy command-line flags: none
- Mypy configuration options from `mypy.ini` (and other config files): none
- Python version used: 3.10.6

<!-- You can freely edit this text, please remove all the lines you believe are unnecessary. -->

Circular import of name defined in same module, reported as an error.
I have a project, which I've reduced to a minimum which shows the same problem.  It has 9 modules, excerpts are:
```
debugging.py:

from typing import *
if TYPE_CHECKING:
	from .instr_common import InstrCtx
	from .instruct import InstrTab
----------
instr_common.py:

from __future__ import annotations
from .common import *
from . import debugging

class InstrCtx(NamedTuple):
	tab : InstrTab
----------------
instruct.py:

from __future__ import annotations
from .instr_merge import *
```

The other modules, with the above, form an SCC of imports, so all 9 are analyzed together.

**Expected Behavior**
`solver\debugging.py:12:2: error: Module "solver.instruct" has no attribute "InstrTab"`
The InstrTab import is an error because the name does not exist in the imported module.
The class def of `InstrCtx` should be OK, since the import from `common` is importing the same thing.

**Actual Behavior**
```
solver\debugging.py:11:2: error: Module "solver.instr_common" has no attribute "InstrCtx"
solver\debugging.py:12:2: error: Module "solver.instruct" has no attribute "InstrTab"
solver\instr_common.py:12:1: error: Name "InstrCtx" already defined (possibly by an import)
```

**Discussion**
In some cases, a circular import would be a programming error, as the runtime results could vary depending on which of the modules in the SCC is imported first.
In my case, the import in `solver.debugging` is guarded by `TYPE_CHECKING`, and so it has no effect at runtime.  mypy makes a placeholder for `solver.debugging.InstrCtx`, then later this is imported into `solver.instr_common` as a `Var` object.
Now if the placeholder resulted from a guarded import, then mypy should not consider that `InstrCtx` is imported at runtime, and so it should be ignored.
In effect, the import of `solver.debugging.InstrCtx` is private.  It is not visible to other modules, and is visible within `solver.debugging` only for type analysis purposes.

I propose adding the following to `semanal.py` to make imported names non-public when MYPY-guarded:
```
in SemanticAnalyzer.visit_import:
			if as_id is not None:
				base_id = id
				imported_id = as_id
				module_public = use_implicit_reexport or id.split(".")[-1] == as_id
			else:
				base_id = id.split('.')[0]
				imported_id = base_id
				module_public = use_implicit_reexport
--->			if i.is_mypy_only: module_public = False

in SemanticAnalyzer.visit_import_all:
					module_public = self.is_stub_file or self.options.implicit_reexport
--->					if i.is_mypy_only: module_public = False

in SemanticAnalyzer.visit_import_from:
			module_public = use_implicit_reexport or (as_id is not None and id == as_id)
--->			if imp.is_mypy_only: module_public = False
```
Attached is a zip file with:
- All the project modules.
- The config file.
- `log.txt` from mypy run before making changes.
- `log2.txt` from mypy run after making changes.
[Exp3.zip](https://github.com/python/mypy/files/8477796/Exp3.zip)

**Your Environment**

- Mypy version used: 0.931
- Mypy command-line flags: -v
- Mypy configuration options: see attached
- Python version used: 3.7
- Operating system and version: Windows 10




Discussion:
Thanks for the report!

Note mainly for self: looks like the difference is between `add_symbol_table_node` https://github.com/python/mypy/blob/ddd917767aa8135f3b1aeef47b0bb0616a4b63fb/mypy/semanal.py#L5457
and `process_imported_symbol` https://github.com/python/mypy/blob/ddd917767aa8135f3b1aeef47b0bb0616a4b63fb/mypy/semanal.py#L2309 . It's not obvious to me why `process_import_over_existing_name` isn't called for normal imports, but just changing that should probably fix


Resolving patch:
diff --git a/mypy/checker.py b/mypy/checker.py
--- a/mypy/checker.py
+++ b/mypy/checker.py
@@ -2528,8 +2528,8 @@ def visit_import_from(self, node: ImportFrom) -> None:
     def visit_import_all(self, node: ImportAll) -> None:
         self.check_import(node)
 
-    def visit_import(self, s: Import) -> None:
-        pass
+    def visit_import(self, node: Import) -> None:
+        self.check_import(node)
 
     def check_import(self, node: ImportBase) -> None:
         for assign in node.assignments:
diff --git a/mypy/semanal.py b/mypy/semanal.py
--- a/mypy/semanal.py
+++ b/mypy/semanal.py
@@ -2235,13 +2235,33 @@ def visit_import(self, i: Import) -> None:
                 base_id = id.split(".")[0]
                 imported_id = base_id
                 module_public = use_implicit_reexport
-            self.add_module_symbol(
-                base_id,
-                imported_id,
-                context=i,
-                module_public=module_public,
-                module_hidden=not module_public,
-            )
+
+            if base_id in self.modules:
+                node = self.modules[base_id]
+                if self.is_func_scope():
+                    kind = LDEF
+                elif self.type is not None:
+                    kind = MDEF
+                else:
+                    kind = GDEF
+                symbol = SymbolTableNode(
+                    kind, node, module_public=module_public, module_hidden=not module_public
+                )
+                self.add_imported_symbol(
+                    imported_id,
+                    symbol,
+                    context=i,
+                    module_public=module_public,
+                    module_hidden=not module_public,
+                )
+            else:
+                self.add_unknown_imported_symbol(
+                    imported_id,
+                    context=i,
+                    target_name=base_id,
+                    module_public=module_public,
+                    module_hidden=not module_public,
+                )
 
     def visit_import_from(self, imp: ImportFrom) -> None:
         self.statement = imp
@@ -2367,19 +2387,6 @@ def process_imported_symbol(
                     module_hidden=module_hidden,
                     becomes_typeinfo=True,
                 )
-        existing_symbol = self.globals.get(imported_id)
-        if (
-            existing_symbol
-            and not isinstance(existing_symbol.node, PlaceholderNode)
-            and not isinstance(node.node, PlaceholderNode)
-        ):
-            # Import can redefine a variable. They get special treatment.
-            if self.process_import_over_existing_name(imported_id, existing_symbol, node, context):
-                return
-        if existing_symbol and isinstance(node.node, PlaceholderNode):
-            # Imports are special, some redefinitions are allowed, so wait until
-            # we know what is the new symbol node.
-            return
         # NOTE: we take the original node even for final `Var`s. This is to support
         # a common pattern when constants are re-exported (same applies to import *).
         self.add_imported_symbol(
@@ -2495,14 +2502,9 @@ def visit_import_all(self, i: ImportAll) -> None:
                     if isinstance(node.node, MypyFile):
                         # Star import of submodule from a package, add it as a dependency.
                         self.imports.add(node.node.fullname)
-                    existing_symbol = self.lookup_current_scope(name)
-                    if existing_symbol and not isinstance(node.node, PlaceholderNode):
-                        # Import can redefine a variable. They get special treatment.
-                        if self.process_import_over_existing_name(name, existing_symbol, node, i):
-                            continue
                     # `from x import *` always reexports symbols
                     self.add_imported_symbol(
-                        name, node, i, module_public=True, module_hidden=False
+                        name, node, context=i, module_public=True, module_hidden=False
                     )
 
         else:
@@ -5577,24 +5579,6 @@ def add_local(self, node: Var | FuncDef | OverloadedFuncDef, context: Context) -
         node._fullname = name
         self.add_symbol(name, node, context)
 
-    def add_module_symbol(
-        self, id: str, as_id: str, context: Context, module_public: bool, module_hidden: bool
-    ) -> None:
-        """Add symbol that is a reference to a module object."""
-        if id in self.modules:
-            node = self.modules[id]
-            self.add_symbol(
-                as_id, node, context, module_public=module_public, module_hidden=module_hidden
-            )
-        else:
-            self.add_unknown_imported_symbol(
-                as_id,
-                context,
-                target_name=id,
-                module_public=module_public,
-                module_hidden=module_hidden,
-            )
-
     def _get_node_for_class_scoped_import(
         self, name: str, symbol_node: SymbolNode | None, context: Context
     ) -> SymbolNode | None:
@@ -5641,13 +5625,23 @@ def add_imported_symbol(
         self,
         name: str,
         node: SymbolTableNode,
-        context: Context,
+        context: ImportBase,
         module_public: bool,
         module_hidden: bool,
     ) -> None:
         """Add an alias to an existing symbol through import."""
         assert not module_hidden or not module_public
 
+        existing_symbol = self.lookup_current_scope(name)
+        if (
+            existing_symbol
+            and not isinstance(existing_symbol.node, PlaceholderNode)
+            and not isinstance(node.node, PlaceholderNode)
+        ):
+            # Import can redefine a variable. They get special treatment.
+            if self.process_import_over_existing_name(name, existing_symbol, node, context):
+                return
+
         symbol_node: SymbolNode | None = node.node
 
         if self.is_class_scope():


Test patch:
diff --git a/test-data/unit/check-classes.test b/test-data/unit/check-classes.test
--- a/test-data/unit/check-classes.test
+++ b/test-data/unit/check-classes.test
@@ -7414,8 +7414,7 @@ class Foo:
     def meth1(self, a: str) -> str: ...  # E: Name "meth1" already defined on line 5
 
     def meth2(self, a: str) -> str: ...
-    from mod1 import meth2  # E: Unsupported class scoped import \
-                            # E: Name "meth2" already defined on line 8
+    from mod1 import meth2  # E: Incompatible import of "meth2" (imported name has type "Callable[[int], int]", local name has type "Callable[[Foo, str], str]")
 
 class Bar:
     from mod1 import foo  # E: Unsupported class scoped import
diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test
--- a/test-data/unit/check-incremental.test
+++ b/test-data/unit/check-incremental.test
@@ -1025,10 +1025,7 @@ import a.b
 
 [file a/b.py]
 
-[rechecked b]
-[stale]
-[out2]
-tmp/b.py:4: error: Name "a" already defined on line 3
+[stale b]
 
 [case testIncrementalSilentImportsAndImportsInClass]
 # flags: --ignore-missing-imports
diff --git a/test-data/unit/check-modules.test b/test-data/unit/check-modules.test
--- a/test-data/unit/check-modules.test
+++ b/test-data/unit/check-modules.test
@@ -651,10 +651,29 @@ try:
     from m import f, g # E: Incompatible import of "g" (imported name has type "Callable[[Any, Any], Any]", local name has type "Callable[[Any], Any]")
 except:
     pass
+
+import m as f  # E: Incompatible import of "f" (imported name has type "object", local name has type "Callable[[Any], Any]")
+
 [file m.py]
 def f(x): pass
 def g(x, y): pass
 
+[case testRedefineTypeViaImport]
+from typing import Type
+import mod
+
+X: Type[mod.A]
+Y: Type[mod.B]
+from mod import B as X
+from mod import A as Y  # E: Incompatible import of "Y" (imported name has type "Type[A]", local name has type "Type[B]")
+
+import mod as X  # E: Incompatible import of "X" (imported name has type "object", local name has type "Type[A]")
+
+[file mod.py]
+class A: ...
+class B(A): ...
+
+
 [case testImportVariableAndAssignNone]
 try:
     from m import x
diff --git a/test-data/unit/check-protocols.test b/test-data/unit/check-protocols.test
--- a/test-data/unit/check-protocols.test
+++ b/test-data/unit/check-protocols.test
@@ -3787,3 +3787,59 @@ from typing_extensions import Final
 
 a: Final = 1
 [builtins fixtures/module.pyi]
+
+
+[case testModuleAsProtocolRedefinitionTopLevel]
+from typing import Protocol
+
+class P(Protocol):
+    def f(self) -> str: ...
+
+cond: bool
+t: P
+if cond:
+    import mod1 as t
+else:
+    import mod2 as t
+
+import badmod as t  # E: Incompatible import of "t" (imported name has type Module, local name has type "P")
+
+[file mod1.py]
+def f() -> str: ...
+
+[file mod2.py]
+def f() -> str: ...
+
+[file badmod.py]
+def nothing() -> int: ...
+[builtins fixtures/module.pyi]
+
+[case testModuleAsProtocolRedefinitionImportFrom]
+from typing import Protocol
+
+class P(Protocol):
+    def f(self) -> str: ...
+
+cond: bool
+t: P
+if cond:
+    from package import mod1 as t
+else:
+    from package import mod2 as t
+
+from package import badmod as t  # E: Incompatible import of "t" (imported name has type Module, local name has type "P")
+
+package: int = 10
+
+import package.mod1 as t
+import package.mod1  # E: Incompatible import of "package" (imported name has type Module, local name has type "int")
+
+[file package/mod1.py]
+def f() -> str: ...
+
+[file package/mod2.py]
+def f() -> str: ...
+
+[file package/badmod.py]
+def nothing() -> int: ...
+[builtins fixtures/module.pyi]
diff --git a/test-data/unit/check-redefine.test b/test-data/unit/check-redefine.test
--- a/test-data/unit/check-redefine.test
+++ b/test-data/unit/check-redefine.test
@@ -285,7 +285,7 @@ def f() -> None:
     import typing as m
     m = 1 # E: Incompatible types in assignment (expression has type "int", variable has type Module)
     n = 1
-    import typing as n # E: Name "n" already defined on line 5
+    import typing as n  # E: Incompatible import of "n" (imported name has type Module, local name has type "int")
 [builtins fixtures/module.pyi]
 
 [case testRedefineLocalWithTypeAnnotation]