diff --git a/astroid/interpreter/_import/util.py b/astroid/interpreter/_import/util.py index 47623c94d8..3de921b889 100644 --- a/astroid/interpreter/_import/util.py +++ b/astroid/interpreter/_import/util.py @@ -2,8 +2,12 @@ # For details: https://github.com/PyCQA/astroid/blob/main/LICENSE # Copyright (c) https://github.com/PyCQA/astroid/blob/main/CONTRIBUTORS.txt +from __future__ import annotations + +import pathlib import sys from functools import lru_cache +from importlib._bootstrap_external import _NamespacePath from importlib.util import _find_spec_from_path @@ -18,18 +22,46 @@ def is_namespace(modname: str) -> bool: # That's unacceptable here, so we fallback to _find_spec_from_path(), which does # not, but requires instead that each single parent ('astroid', 'nodes', etc.) # be specced from left to right. - components = modname.split(".") - for i in range(1, len(components) + 1): - working_modname = ".".join(components[:i]) + processed_components = [] + last_submodule_search_locations: _NamespacePath | None = None + for component in modname.split("."): + processed_components.append(component) + working_modname = ".".join(processed_components) try: - # Search under the highest package name - # Only relevant if package not already on sys.path - # See https://github.com/python/cpython/issues/89754 for reasoning - # Otherwise can raise bare KeyError: https://github.com/python/cpython/issues/93334 - found_spec = _find_spec_from_path(working_modname, components[0]) + # Both the modname and the path are built iteratively, with the + # path (e.g. ['a', 'a/b', 'a/b/c']) lagging the modname by one + found_spec = _find_spec_from_path( + working_modname, path=last_submodule_search_locations + ) except ValueError: # executed .pth files may not have __spec__ return True + except KeyError: + # Intermediate steps might raise KeyErrors + # https://github.com/python/cpython/issues/93334 + # TODO: update if fixed in importlib + # For tree a > b > c.py + # >>> from importlib.machinery import PathFinder + # >>> PathFinder.find_spec('a.b', ['a']) + # KeyError: 'a' + + # Repair last_submodule_search_locations + if last_submodule_search_locations: + # TODO: py38: remove except + try: + # pylint: disable=unsubscriptable-object + last_item = last_submodule_search_locations[-1] + except TypeError: + last_item = last_submodule_search_locations._recalculate()[-1] + # e.g. for failure example above, add 'a/b' and keep going + # so that find_spec('a.b.c', path=['a', 'a/b']) succeeds + assumed_location = pathlib.Path(last_item) / component + last_submodule_search_locations.append(str(assumed_location)) + continue + + # Update last_submodule_search_locations + if found_spec and found_spec.submodule_search_locations: + last_submodule_search_locations = found_spec.submodule_search_locations if found_spec is None: return False diff --git a/tests/unittest_manager.py b/tests/unittest_manager.py index d098ca1343..922b78a9bf 100644 --- a/tests/unittest_manager.py +++ b/tests/unittest_manager.py @@ -125,6 +125,9 @@ def test_submodule_homonym_with_non_module(self) -> None: util.is_namespace("tests.testdata.python3.data.parent_of_homonym.doc") ) + def test_module_is_not_namespace(self) -> None: + self.assertFalse(util.is_namespace("tests.testdata.python3.data.all")) + def test_implicit_namespace_package(self) -> None: data_dir = os.path.dirname(resources.find("data/namespace_pep_420")) contribute = os.path.join(data_dir, "contribute_to_namespace")