Repository: python/mypy
Pull request: Disallow undesirable implicit reexport with ImportFrom
Fixes #12689

We always hid e.g. `import concurrent`, but looks like mypy
never hid `from concurrent import futures`. It's possible this fix is
pretty breaking for users, let's see what primer thinks.

I last touched this logic in #11707, which fixed cases involving
implicitly reexported symbols that shared the name of a module

Problem statement:
`from module import submodule` implicit reexport is allowed
**Bug Report**
`mypy --no-implicit-reexport` doesn't catch reexports written in the form of `from module import submodule`.

**To Reproduce**

```python
# pkg/lib.py

# implicit re-exports
import requests
import requests as demands
import requests.status_codes as requests_status_codes
from requests import Request

# implicit submodule re-export, but mypy doesn't care
from requests import status_codes
```

```python
# pkg/shell.py

from pkg.lib import requests, demands, requests_status_codes, Request, status_codes
```

**Expected Behavior**
I would expect each of those reexports to trigger an error.

**Actual Behavior**
`from requests import status_codes` is ignored.

```bash
❯ mypy --no-implicit-reexport pkg
pkg/shell.py:1: error: Module "pkg.lib" does not explicitly export attribute "requests"; implicit reexport disabled
pkg/shell.py:1: error: Module "pkg.lib" does not explicitly export attribute "demands"; implicit reexport disabled
pkg/shell.py:1: error: Module "pkg.lib" does not explicitly export attribute "requests_status_codes"; implicit reexport disabled
pkg/shell.py:1: error: Module "pkg.lib" does not explicitly export attribute "Request"; implicit reexport disabled
Found 4 errors in 1 file (checked 3 source files)
```

**Your Environment**

- Mypy version used: 0.942
- Mypy command-line flags: `--no-implicit-reexports`
- Mypy configuration options from `mypy.ini` (and other config files): None
- Python version used: 3.9.10
- Operating system and version: Arch Linux latest

Hints from issue discussion:

Commit message: Disallow undesirable implicit reexport with ImportFrom

Fixes #12689

We always hid e.g. `import concurrent`, but looks like mypy
never hid `from concurrent import futures`. It's possible this fix is
pretty breaking for users, let's see what primer thinks.

I last touched this logic in #11707, which fixed cases involving
implicitly reexported symbols that shared the name of a module

Patch to review:
diff --git a/mypy/semanal.py b/mypy/semanal.py
index 985d5217cc08..b22a368ead60 100644
--- a/mypy/semanal.py
+++ b/mypy/semanal.py
@@ -1894,10 +1894,14 @@ def process_imported_symbol(self,
                                 fullname: str,
                                 module_public: bool,
                                 context: ImportBase) -> None:
-        module_hidden = not module_public and not (
-            # `from package import module` should work regardless of whether package
-            # re-exports module
-            isinstance(node.node, MypyFile) and fullname in self.modules
+        module_hidden = not module_public and (
+            # `from package import submodule` should work regardless of whether package
+            # re-exports submodule, so we shouldn't hide it
+            not isinstance(node.node, MypyFile)
+            or fullname not in self.modules
+            # but given `from somewhere import random_unrelated_module` we should hide
+            # random_unrelated_module
+            or not fullname.startswith(self.cur_mod_id)
         )
 
         if isinstance(node.node, PlaceholderNode):
diff --git a/test-data/unit/check-modules.test b/test-data/unit/check-modules.test
index 67767a9114e1..0e97e8fa39b6 100644
--- a/test-data/unit/check-modules.test
+++ b/test-data/unit/check-modules.test
@@ -1929,6 +1929,19 @@ from package import mod as mod
 from package import mod as internal_detail
 [builtins fixtures/module.pyi]
 
+[case testNoReExportUnrelatedModule]
+from mod2 import unrelated  # E: Module "mod2" has no attribute "unrelated"
+
+[file mod1/__init__.pyi]
+
+[file mod1/unrelated.pyi]
+x: int
+
+[file mod2.pyi]
+from mod1 import unrelated
+
+[builtins fixtures/module.pyi]
+
 [case testNoReExportChildStubs]
 import mod
 from mod import C, D  # E: Module "mod" has no attribute "C"


Code review comments:
diff_hunk: @@ -1894,10 +1894,14 @@ def process_imported_symbol(self,
                                 fullname: str,
                                 module_public: bool,
                                 context: ImportBase) -> None:
-        module_hidden = not module_public and not (
-            # `from package import module` should work regardless of whether package
-            # re-exports module
-            isinstance(node.node, MypyFile) and fullname in self.modules
+        module_hidden = not module_public and (
+            # `from package import submodule` should work regardless of whether package
+            # re-exports submodule, so we shouldn't hide it
+            not isinstance(node.node, MypyFile)
+            or fullname not in self.modules
+            # but given `from somewhere import random_unrelated_module` we should hide
+            # random_unrelated_module
+            or not fullname.startswith(self.cur_mod_id)
path: mypy/semanal.py
text: @user1:
What if there's a partial prefix, e.g. `foo.ba` vs `foo.bar`?

@author:
Thanks, was a little sloppy. Fixed and added a test.