Skip to content

Do not cache module lookup results that may become invalid in future #19044

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 38 additions & 21 deletions mypy/modulefinder.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,13 +320,21 @@ def find_module(self, id: str, *, fast_path: bool = False) -> ModuleSearchResult
use_typeshed = self._typeshed_has_version(id)
elif top_level in self.stdlib_py_versions:
use_typeshed = self._typeshed_has_version(top_level)
self.results[id] = self._find_module(id, use_typeshed)
if (
not (fast_path or (self.options is not None and self.options.fast_module_lookup))
and self.results[id] is ModuleNotFoundReason.NOT_FOUND
and self._can_find_module_in_parent_dir(id)
):
self.results[id] = ModuleNotFoundReason.WRONG_WORKING_DIRECTORY
result, should_cache = self._find_module(id, use_typeshed)
if should_cache:
if (
not (
fast_path or (self.options is not None and self.options.fast_module_lookup)
)
and result is ModuleNotFoundReason.NOT_FOUND
and self._can_find_module_in_parent_dir(id)
):
self.results[id] = ModuleNotFoundReason.WRONG_WORKING_DIRECTORY
else:
self.results[id] = result
return self.results[id]
else:
return result
return self.results[id]

def _typeshed_has_version(self, module: str) -> bool:
Expand Down Expand Up @@ -384,11 +392,16 @@ def _can_find_module_in_parent_dir(self, id: str) -> bool:
while any(is_init_file(file) for file in os.listdir(working_dir)):
working_dir = os.path.dirname(working_dir)
parent_search.search_paths = SearchPaths((working_dir,), (), (), ())
if not isinstance(parent_search._find_module(id, False), ModuleNotFoundReason):
if not isinstance(parent_search._find_module(id, False)[0], ModuleNotFoundReason):
return True
return False

def _find_module(self, id: str, use_typeshed: bool) -> ModuleSearchResult:
def _find_module(self, id: str, use_typeshed: bool) -> tuple[ModuleSearchResult, bool]:
"""Try to find a module in all available sources.

Returns:
``(result, can_be_cached)`` pair.
"""
fscache = self.fscache

# Fast path for any modules in the current source set.
Expand Down Expand Up @@ -424,7 +437,7 @@ def _find_module(self, id: str, use_typeshed: bool) -> ModuleSearchResult:
else None
)
if p:
return p
return p, True

# If we're looking for a module like 'foo.bar.baz', it's likely that most of the
# many elements of lib_path don't even have a subdirectory 'foo/bar'. Discover
Expand All @@ -444,6 +457,9 @@ def _find_module(self, id: str, use_typeshed: bool) -> ModuleSearchResult:
for component in (components[0], components[0] + "-stubs")
for package_dir in self.find_lib_path_dirs(component, self.search_paths.package_path)
}
# Caching FOUND_WITHOUT_TYPE_HINTS is not always safe. That causes issues with
# typed subpackages in namespace packages.
can_cache_any_result = True
for pkg_dir in self.search_paths.package_path:
if pkg_dir not in candidate_package_dirs:
continue
Expand Down Expand Up @@ -475,6 +491,7 @@ def _find_module(self, id: str, use_typeshed: bool) -> ModuleSearchResult:
if isinstance(non_stub_match, ModuleNotFoundReason):
if non_stub_match is ModuleNotFoundReason.FOUND_WITHOUT_TYPE_HINTS:
found_possible_third_party_missing_type_hints = True
can_cache_any_result = False
else:
third_party_inline_dirs.append(non_stub_match)
self._update_ns_ancestors(components, non_stub_match)
Expand Down Expand Up @@ -513,7 +530,7 @@ def _find_module(self, id: str, use_typeshed: bool) -> ModuleSearchResult:
if verify and not verify_module(fscache, id, path_stubs, dir_prefix):
near_misses.append((path_stubs, dir_prefix))
else:
return path_stubs
return path_stubs, True

# Prefer package over module, i.e. baz/__init__.py* over baz.py*.
for extension in PYTHON_EXTENSIONS:
Expand All @@ -523,7 +540,7 @@ def _find_module(self, id: str, use_typeshed: bool) -> ModuleSearchResult:
if verify and not verify_module(fscache, id, path, dir_prefix):
near_misses.append((path, dir_prefix))
continue
return path
return path, True

# In namespace mode, register a potential namespace package
if self.options and self.options.namespace_packages:
Expand All @@ -541,7 +558,7 @@ def _find_module(self, id: str, use_typeshed: bool) -> ModuleSearchResult:
if verify and not verify_module(fscache, id, path, dir_prefix):
near_misses.append((path, dir_prefix))
continue
return path
return path, True

# In namespace mode, re-check those entries that had 'verify'.
# Assume search path entries xxx, yyy and zzz, and we're
Expand Down Expand Up @@ -570,35 +587,35 @@ def _find_module(self, id: str, use_typeshed: bool) -> ModuleSearchResult:
for path, dir_prefix in near_misses
]
index = levels.index(max(levels))
return near_misses[index][0]
return near_misses[index][0], True

# Finally, we may be asked to produce an ancestor for an
# installed package with a py.typed marker that is a
# subpackage of a namespace package. We only fess up to these
# if we would otherwise return "not found".
ancestor = self.ns_ancestors.get(id)
if ancestor is not None:
return ancestor
return ancestor, True

approved_dist_name = stub_distribution_name(id)
if approved_dist_name:
if len(components) == 1:
return ModuleNotFoundReason.APPROVED_STUBS_NOT_INSTALLED
return ModuleNotFoundReason.APPROVED_STUBS_NOT_INSTALLED, True
# If we're a missing submodule of an already installed approved stubs, we don't want to
# error with APPROVED_STUBS_NOT_INSTALLED, but rather want to return NOT_FOUND.
for i in range(1, len(components)):
parent_id = ".".join(components[:i])
if stub_distribution_name(parent_id) == approved_dist_name:
break
else:
return ModuleNotFoundReason.APPROVED_STUBS_NOT_INSTALLED
return ModuleNotFoundReason.APPROVED_STUBS_NOT_INSTALLED, True
if self.find_module(parent_id) is ModuleNotFoundReason.APPROVED_STUBS_NOT_INSTALLED:
return ModuleNotFoundReason.APPROVED_STUBS_NOT_INSTALLED
return ModuleNotFoundReason.NOT_FOUND
return ModuleNotFoundReason.APPROVED_STUBS_NOT_INSTALLED, True
return ModuleNotFoundReason.NOT_FOUND, True

if found_possible_third_party_missing_type_hints:
return ModuleNotFoundReason.FOUND_WITHOUT_TYPE_HINTS
return ModuleNotFoundReason.NOT_FOUND
return ModuleNotFoundReason.FOUND_WITHOUT_TYPE_HINTS, can_cache_any_result
return ModuleNotFoundReason.NOT_FOUND, True

def find_modules_recursive(self, module: str) -> list[BuildSource]:
module_path = self.find_module(module, fast_path=True)
Expand Down
7 changes: 5 additions & 2 deletions mypy/test/testpep561.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,11 @@ def test_pep561(testcase: DataDrivenTestCase) -> None:
output.append(line[len(test_temp_dir + os.sep) :].rstrip("\r\n"))
else:
# Normalize paths so that the output is the same on Windows and Linux/macOS.
line = line.replace(test_temp_dir + os.sep, test_temp_dir + "/")
output.append(line.rstrip("\r\n"))
# Yes, this is naive: replace all slashes preceding first colon, if any.
path, *rest = line.split(":", maxsplit=1)
if rest:
path = path.replace(os.sep, "/")
output.append(":".join([path, *rest]).rstrip("\r\n"))
iter_count = "" if i == 0 else f" on iteration {i + 1}"
expected = testcase.output if i == 0 else testcase.output2.get(i + 1, [])

Expand Down
11 changes: 11 additions & 0 deletions test-data/packages/typedpkg_ns_nested/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[project]
name = 'typedpkg_namespace.nested'
version = '0.1'
description = 'Two namespace packages, one of them typed'

[tool.hatch.build]
include = ["**/*.py", "**/*.pyi", "**/py.typed"]

[build-system]
requires = ["hatchling==1.18"]
build-backend = "hatchling.build"
Empty file.
Empty file.
Empty file.
20 changes: 20 additions & 0 deletions test-data/unit/pep561.test
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,23 @@ from typedpkg_ns.a.bbb import bf
[file dummy.py.2]
[out]
[out2]

[case testTypedNamespaceSubpackage]
# pkgs: typedpkg_ns_nested
import our
[file our/__init__.py]
import our.bar
import our.foo
[file our/bar.py]
from typedpkg_ns.b import Something
[file our/foo.py]
import typedpkg_ns.a

[file dummy.py.2]

[out]
our/bar.py:1: error: Skipping analyzing "typedpkg_ns.b": module is installed, but missing library stubs or py.typed marker
our/bar.py:1: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
[out2]
our/bar.py:1: error: Skipping analyzing "typedpkg_ns.b": module is installed, but missing library stubs or py.typed marker
our/bar.py:1: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports