Repository: python/mypy (Python)

Issue:
mypy 0.990 shows no errors when passing source directory as absolute path
**Bug Report**

I noticed that after upgrading to 0.990, mypy in djangorestframework-stubs CI no longer finds any errors.

It seems this happens when the source folder is passed as a full path, instead of relative path. `$PWD` is the full path of the current working directory:

```
% mypy --config-file $PWD/mypy.ini --show-traceback --hide-error-context ./drf_source/tests |tail -n1
Found 569 errors in 48 files (checked 83 source files)
% mypy --config-file $PWD/mypy.ini --show-traceback --hide-error-context $PWD/drf_source/tests |tail -n1
Success: no issues found in 83 source files
```

~~Maybe, just maybe, this is related to #13768?~~ EDIT: seems not to be, various `--follow-imports=` options have no effect.

**To Reproduce**

1. Check out https://github.com/intgr/djangorestframework-stubs branch `update-to-mypy-0.990`
2. Install dependencies from `requirements.txt` in a virtualenv
3. Upgrade to mypy 0.990
4. Run `python scripts/typecheck_tests.py`, which populates the `drf_source` directory
5. Try running the commands above

**Your Environment**

- Mypy version used: 0.990
- Mypy configuration options from `mypy.ini` (and other config files): https://github.com/typeddjango/djangorestframework-stubs/blob/06a2f2f9f6c05a07f03bc1bd7f1b54ecfd7a3c43/mypy.ini
- Python version used: 3.10.8



Hints:
I can also reproduce this when I comment out mypy plugins in `mypy.ini`
Also reproduced this in another project of mine.
Does it reproduce with `--no-namespace-packages`?
Yes. Adding `--no-namespace-packages` does not solve the issue, still getting "no issues found".
Also do you have `$PWD` in your `PYTHONPATH` maybe? What was the last mypy version that didn't have this issue? Around 0.970 mypy started interpreting everything in `PYTHONPATH` as third party packages and silencing them. You can try `--no-silence-site-packages`
This issue does not occur with 0.982

```
% mypy --version
mypy 0.982 (compiled: yes)
% mypy --config-file $PWD/mypy.ini --show-traceback --hide-error-context --no-namespace-packages $PWD/drf_source/tests |tail -n1
.../djangorestframework-stubs/mypy.ini: [mypy]: disable_error_code: Invalid error code(s): empty-body
Found 575 errors in 49 files (checked 83 source files)
```

PYTHONPATH is unset in the shell I'm testing this from. The `typecheck_tests.py` script does indeed add $PWD to PYTHONPATH, but it doesn't seem to affect this issue.

Hm, probably the easiest way is to bisect mypy commits. Since the problem looks really bizzare.
Adding `--no-silence-site-packages` with mypy 0.990 and using `$PWD` ~~only~~ mostly prints errors from site-packages modules, not the project itself.

Are you unable to reproduce this in any of your own projects?

I can give bisecting a try.
> Are you unable to reproduce this in any of your own projects?

No, but I also don't have many.

> I can give bisecting a try.

That would be great, since I will not have much time today/tomorrow.
Git bisected it to:

> 00ca6bf127fcdca00fbe6641454d3c0535b00f4f is the first bad commit

Which is #13768 as I initially suspected.

@JukkaL could you please take a look?
I was wrong before. `--no-silence-site-packages` *does* have an effect on the output but it's really weird.

* `mypy --no-silence-site-packages $PWD/drf_source/tests` 142 errors in 18 files (checked 83 source files) -- most of these are site-pacakges errors, but 6 are from project itself!
* `mypy --no-silence-site-packages ./drf_source/tests` 142 errors in 18 files (checked 83 source files), same as above
* `mypy $PWD/drf_source/tests` no issues found in 83 source files
* `mypy ./drf_source/tests` 569 errors in 48 files (checked 83 source files) -- expected behavior

The behavior of `--no-silence-site-packages` is also reproducible in mypy 0.982. But the `$PWD` vs `.` behavior does not reproduce there.
Just to confirm I'm experiencing the same issue with the latest mypy. But the issue is not reproducible in every environment it seems. I run into the same issue as described by @intgr on my local machine, but our CI script can report errors correctly using the same version of mypy.
I will investigate. This might be caused by absolute paths treated differently from relative paths, but we'll see.
In case it is useful, `-p` is also incorrectly omitting errors, matching the absolute path behavior (perhaps `-p` resolves to an absolute path?). I can confirm relative paths work correctly.
I think this is the cause to https://github.com/microsoft/vscode-python/issues/20182

EDIT: see my next comment, this issue here does not seem to cause the linting problem.

The issue is about mypy not find any error when run by the linter but finding errors when the exact same command is running in a terminal. Notice the relative paths.
Linter inside vscode: 
```
> ./.venv/bin/python ~/.vscode-oss/extensions/ms-python.python-2022.16.1-universal/pythonFiles/linter.py -m mypy --follow-imports=silent --ignore-missing-imports --show-column-numbers --no-pretty ./rootsnakes/app.py
cwd: .
##########Linting Output - mypy##########

Success: no issues found in 1 source file
```

same code in terminal:
```
./.venv/bin/python ~/.vscode-oss/extensions/ms-python.python-2022.16.1-universal/pythonFiles/linter.py -m mypy --follow-imports=silent --ignore-missing-imports --show-column-numbers --no-pretty ./lol.py
lol.py:1:1: error: Missing return statement  [return]
Found 1 error in 1 file (checked 1 source file)
```

See the linked issue for a more detailed description. 
~~also maybe~~ some of the issues reported here are definitely related to https://github.com/python/mypy/issues/14054

EDIT: through some experimentation i found that the vs-code linting issue is definitely caused by #14054 and not by this issue here. Some package managers automatically add the current working directory to the path, e.g. running `poetry install`.  **It seems very likely that this could also break numerous CI setups.** @trivialfis @intgr  Here i cover in detail how to get reproduce the issue https://github.com/microsoft/vscode-python/issues/20182#issuecomment-1310018181 in case anyone get here with the same issue as me.
It seems that both issues are caused by the same changes in https://github.com/python/mypy/pull/13768

Patch:
diff --git a/mypy/build.py b/mypy/build.py
--- a/mypy/build.py
+++ b/mypy/build.py
@@ -1940,7 +1940,7 @@ def __init__(
                 raise
             if follow_imports == "silent":
                 self.ignore_all = True
-        elif path and is_silent_import_module(manager, path):
+        elif path and is_silent_import_module(manager, path) and not root_source:
             self.ignore_all = True
         self.path = path
         if path:
@@ -2629,7 +2629,7 @@ def find_module_and_diagnose(
                 else:
                     skipping_module(manager, caller_line, caller_state, id, result)
             raise ModuleNotFound
-        if is_silent_import_module(manager, result):
+        if is_silent_import_module(manager, result) and not root_source:
             follow_imports = "silent"
         return (result, follow_imports)
     else:
@@ -3024,7 +3024,11 @@ def load_graph(
     for bs in sources:
         try:
             st = State(
-                id=bs.module, path=bs.path, source=bs.text, manager=manager, root_source=True
+                id=bs.module,
+                path=bs.path,
+                source=bs.text,
+                manager=manager,
+                root_source=not bs.followed,
             )
         except ModuleNotFound:
             continue
diff --git a/mypy/dmypy_server.py b/mypy/dmypy_server.py
--- a/mypy/dmypy_server.py
+++ b/mypy/dmypy_server.py
@@ -592,7 +592,7 @@ def fine_grained_increment_follow_imports(self, sources: list[BuildSource]) -> l
         sources.extend(new_files)
 
         # Process changes directly reachable from roots.
-        messages = fine_grained_manager.update(changed, [])
+        messages = fine_grained_manager.update(changed, [], followed=True)
 
         # Follow deps from changed modules (still within graph).
         worklist = changed[:]
@@ -609,13 +609,13 @@ def fine_grained_increment_follow_imports(self, sources: list[BuildSource]) -> l
                 sources2, graph, seen, changed_paths
             )
             self.update_sources(new_files)
-            messages = fine_grained_manager.update(changed, [])
+            messages = fine_grained_manager.update(changed, [], followed=True)
             worklist.extend(changed)
 
         t2 = time.time()
 
         def refresh_file(module: str, path: str) -> list[str]:
-            return fine_grained_manager.update([(module, path)], [])
+            return fine_grained_manager.update([(module, path)], [], followed=True)
 
         for module_id, state in list(graph.items()):
             new_messages = refresh_suppressed_submodules(
@@ -632,10 +632,10 @@ def refresh_file(module: str, path: str) -> list[str]:
             new_unsuppressed = self.find_added_suppressed(graph, seen, manager.search_paths)
             if not new_unsuppressed:
                 break
-            new_files = [BuildSource(mod[1], mod[0]) for mod in new_unsuppressed]
+            new_files = [BuildSource(mod[1], mod[0], followed=True) for mod in new_unsuppressed]
             sources.extend(new_files)
             self.update_sources(new_files)
-            messages = fine_grained_manager.update(new_unsuppressed, [])
+            messages = fine_grained_manager.update(new_unsuppressed, [], followed=True)
 
             for module_id, path in new_unsuppressed:
                 new_messages = refresh_suppressed_submodules(
@@ -717,7 +717,7 @@ def find_reachable_changed_modules(
                 for dep in state.dependencies:
                     if dep not in seen:
                         seen.add(dep)
-                        worklist.append(BuildSource(graph[dep].path, graph[dep].id))
+                        worklist.append(BuildSource(graph[dep].path, graph[dep].id, followed=True))
         return changed, new_files
 
     def direct_imports(
@@ -725,7 +725,7 @@ def direct_imports(
     ) -> list[BuildSource]:
         """Return the direct imports of module not included in seen."""
         state = graph[module[0]]
-        return [BuildSource(graph[dep].path, dep) for dep in state.dependencies]
+        return [BuildSource(graph[dep].path, dep, followed=True) for dep in state.dependencies]
 
     def find_added_suppressed(
         self, graph: mypy.build.Graph, seen: set[str], search_paths: SearchPaths
diff --git a/mypy/modulefinder.py b/mypy/modulefinder.py
--- a/mypy/modulefinder.py
+++ b/mypy/modulefinder.py
@@ -115,15 +115,19 @@ def __init__(
         module: str | None,
         text: str | None = None,
         base_dir: str | None = None,
+        followed: bool = False,
     ) -> None:
         self.path = path  # File where it's found (e.g. 'xxx/yyy/foo/bar.py')
         self.module = module or "__main__"  # Module name (e.g. 'foo.bar')
         self.text = text  # Source code, if initially supplied, else None
         self.base_dir = base_dir  # Directory where the package is rooted (e.g. 'xxx/yyy')
+        self.followed = followed  # Was this found by following imports?
 
     def __repr__(self) -> str:
-        return "BuildSource(path={!r}, module={!r}, has_text={}, base_dir={!r})".format(
-            self.path, self.module, self.text is not None, self.base_dir
+        return (
+            "BuildSource(path={!r}, module={!r}, has_text={}, base_dir={!r}, followed={})".format(
+                self.path, self.module, self.text is not None, self.base_dir, self.followed
+            )
         )
 
 
diff --git a/mypy/server/update.py b/mypy/server/update.py
--- a/mypy/server/update.py
+++ b/mypy/server/update.py
@@ -203,7 +203,10 @@ def __init__(self, result: BuildResult) -> None:
         self.processed_targets: list[str] = []
 
     def update(
-        self, changed_modules: list[tuple[str, str]], removed_modules: list[tuple[str, str]]
+        self,
+        changed_modules: list[tuple[str, str]],
+        removed_modules: list[tuple[str, str]],
+        followed: bool = False,
     ) -> list[str]:
         """Update previous build result by processing changed modules.
 
@@ -219,6 +222,7 @@ def update(
                 Assume this is correct; it's not validated here.
             removed_modules: Modules that have been deleted since the previous update
                 or removed from the build.
+            followed: If True, the modules were found through following imports
 
         Returns:
             A list of errors.
@@ -256,7 +260,9 @@ def update(
             self.blocking_error = None
 
         while True:
-            result = self.update_one(changed_modules, initial_set, removed_set, blocking_error)
+            result = self.update_one(
+                changed_modules, initial_set, removed_set, blocking_error, followed
+            )
             changed_modules, (next_id, next_path), blocker_messages = result
 
             if blocker_messages is not None:
@@ -329,6 +335,7 @@ def update_one(
         initial_set: set[str],
         removed_set: set[str],
         blocking_error: str | None,
+        followed: bool,
     ) -> tuple[list[tuple[str, str]], tuple[str, str], list[str] | None]:
         """Process a module from the list of changed modules.
 
@@ -355,7 +362,7 @@ def update_one(
             )
             return changed_modules, (next_id, next_path), None
 
-        result = self.update_module(next_id, next_path, next_id in removed_set)
+        result = self.update_module(next_id, next_path, next_id in removed_set, followed)
         remaining, (next_id, next_path), blocker_messages = result
         changed_modules = [(id, path) for id, path in changed_modules if id != next_id]
         changed_modules = dedupe_modules(remaining + changed_modules)
@@ -368,7 +375,7 @@ def update_one(
         return changed_modules, (next_id, next_path), blocker_messages
 
     def update_module(
-        self, module: str, path: str, force_removed: bool
+        self, module: str, path: str, force_removed: bool, followed: bool
     ) -> tuple[list[tuple[str, str]], tuple[str, str], list[str] | None]:
         """Update a single modified module.
 
@@ -380,6 +387,7 @@ def update_module(
             path: File system path of the module
             force_removed: If True, consider module removed from the build even if path
                 exists (used for removing an existing file from the build)
+            followed: Was this found via import following?
 
         Returns:
             Tuple with these items:
@@ -417,7 +425,7 @@ def update_module(
         manager.errors.reset()
         self.processed_targets.append(module)
         result = update_module_isolated(
-            module, path, manager, previous_modules, graph, force_removed
+            module, path, manager, previous_modules, graph, force_removed, followed
         )
         if isinstance(result, BlockedUpdate):
             # Blocking error -- just give up
@@ -552,6 +560,7 @@ def update_module_isolated(
     previous_modules: dict[str, str],
     graph: Graph,
     force_removed: bool,
+    followed: bool,
 ) -> UpdateResult:
     """Build a new version of one changed module only.
 
@@ -575,7 +584,7 @@ def update_module_isolated(
         delete_module(module, path, graph, manager)
         return NormalUpdate(module, path, [], None)
 
-    sources = get_sources(manager.fscache, previous_modules, [(module, path)])
+    sources = get_sources(manager.fscache, previous_modules, [(module, path)], followed)
 
     if module in manager.missing_modules:
         manager.missing_modules.remove(module)
@@ -728,12 +737,15 @@ def get_module_to_path_map(graph: Graph) -> dict[str, str]:
 
 
 def get_sources(
-    fscache: FileSystemCache, modules: dict[str, str], changed_modules: list[tuple[str, str]]
+    fscache: FileSystemCache,
+    modules: dict[str, str],
+    changed_modules: list[tuple[str, str]],
+    followed: bool,
 ) -> list[BuildSource]:
     sources = []
     for id, path in changed_modules:
         if fscache.isfile(path):
-            sources.append(BuildSource(path, id, None))
+            sources.append(BuildSource(path, id, None, followed=followed))
     return sources
 
 


Test patch:
diff --git a/mypy/test/testcmdline.py b/mypy/test/testcmdline.py
--- a/mypy/test/testcmdline.py
+++ b/mypy/test/testcmdline.py
@@ -69,12 +69,10 @@ def test_python_cmdline(testcase: DataDrivenTestCase, step: int) -> None:
     env["PYTHONPATH"] = PREFIX
     if os.path.isdir(extra_path):
         env["PYTHONPATH"] += os.pathsep + extra_path
+    cwd = os.path.join(test_temp_dir, custom_cwd or "")
+    args = [arg.replace("$CWD", os.path.abspath(cwd)) for arg in args]
     process = subprocess.Popen(
-        fixed + args,
-        stdout=subprocess.PIPE,
-        stderr=subprocess.PIPE,
-        cwd=os.path.join(test_temp_dir, custom_cwd or ""),
-        env=env,
+        fixed + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, env=env
     )
     outb, errb = process.communicate()
     result = process.returncode
diff --git a/test-data/unit/cmdline.test b/test-data/unit/cmdline.test
--- a/test-data/unit/cmdline.test
+++ b/test-data/unit/cmdline.test
@@ -1505,3 +1505,68 @@ def f():
 [out]
 a.py:2: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs
 == Return code: 0
+
+[case testCustomTypeshedDirFilePassedExplicitly]
+# cmd: mypy --custom-typeshed-dir dir m.py dir/stdlib/foo.pyi
+[file m.py]
+1()
+[file dir/stdlib/abc.pyi]
+1()  # Errors are not reported from typeshed by default
+[file dir/stdlib/builtins.pyi]
+class object: pass
+class str(object): pass
+class int(object): pass
+[file dir/stdlib/sys.pyi]
+[file dir/stdlib/types.pyi]
+[file dir/stdlib/typing.pyi]
+[file dir/stdlib/mypy_extensions.pyi]
+[file dir/stdlib/typing_extensions.pyi]
+[file dir/stdlib/foo.pyi]
+1()  # Errors are reported if the file was explicitly passed on the command line
+[file dir/stdlib/VERSIONS]
+[out]
+dir/stdlib/foo.pyi:1: error: "int" not callable
+m.py:1: error: "int" not callable
+
+[case testFileInPythonPathPassedExplicitly1]
+# cmd: mypy $CWD/pypath/foo.py
+[file pypath/foo.py]
+1()
+[out]
+pypath/foo.py:1: error: "int" not callable
+
+[case testFileInPythonPathPassedExplicitly2]
+# cmd: mypy pypath/foo.py
+[file pypath/foo.py]
+1()
+[out]
+pypath/foo.py:1: error: "int" not callable
+
+[case testFileInPythonPathPassedExplicitly3]
+# cmd: mypy -p foo
+# cwd: pypath
+[file pypath/foo/__init__.py]
+1()
+[file pypath/foo/m.py]
+1()
+[out]
+foo/m.py:1: error: "int" not callable
+foo/__init__.py:1: error: "int" not callable
+
+[case testFileInPythonPathPassedExplicitly4]
+# cmd: mypy -m foo
+# cwd: pypath
+[file pypath/foo.py]
+1()
+[out]
+foo.py:1: error: "int" not callable
+
+[case testFileInPythonPathPassedExplicitly5]
+# cmd: mypy -m foo.m
+# cwd: pypath
+[file pypath/foo/__init__.py]
+1()  # TODO: Maybe this should generate errors as well? But how would we decide?
+[file pypath/foo/m.py]
+1()
+[out]
+foo/m.py:1: error: "int" not callable