diff --git a/astroid/interpreter/_import/spec.py b/astroid/interpreter/_import/spec.py index 77e71016ac..f000468e92 100644 --- a/astroid/interpreter/_import/spec.py +++ b/astroid/interpreter/_import/spec.py @@ -64,6 +64,11 @@ class ModuleType(enum.Enum): "_SixMetaPathImporter": ModuleType.PY_SOURCE, } +_EditableFinderClasses: set[str] = { + "_EditableFinder", + "_EditableNamespaceFinder", +} + class ModuleSpec(NamedTuple): """Defines a class similar to PEP 420's ModuleSpec. @@ -453,8 +458,13 @@ def find_spec(modpath: list[str], path: Sequence[str] | None = None) -> ModuleSp _path, modname, module_parts, processed, submodule_path or path ) processed.append(modname) - if modpath and isinstance(finder, Finder): - submodule_path = finder.contribute_to_path(spec, processed) + if modpath: + if isinstance(finder, Finder): + submodule_path = finder.contribute_to_path(spec, processed) + # If modname is a package from an editable install, update submodule_path + # so that the next module in the path will be found inside of it using importlib. + elif finder.__name__ in _EditableFinderClasses: + submodule_path = spec.submodule_search_locations if spec.type == ModuleType.PKG_DIRECTORY: spec = spec._replace(submodule_search_locations=submodule_path) diff --git a/tests/test_modutils.py b/tests/test_modutils.py index 8058b13223..0c8bee8880 100644 --- a/tests/test_modutils.py +++ b/tests/test_modutils.py @@ -550,3 +550,15 @@ def test_is_module_name_part_of_extension_package_whitelist_success(self) -> Non @pytest.mark.skipif(not HAS_URLLIB3, reason="This test requires urllib3.") def test_file_info_from_modpath__SixMetaPathImporter() -> None: assert modutils.file_info_from_modpath(["urllib3.packages.six.moves.http_client"]) + + +def test_find_setuptools_pep660_editable_install(): + """Find the spec for a package installed via setuptools PEP 660 import hooks.""" + # pylint: disable-next=import-outside-toplevel + from tests.testdata.python3.data.import_setuptools_pep660.__editable___example_0_1_0_finder import ( + _EditableFinder, + ) + + with unittest.mock.patch.object(sys, "meta_path", new=[_EditableFinder]): + assert spec.find_spec(["example"]) + assert spec.find_spec(["example", "subpackage"]) diff --git a/tests/testdata/python3/data/import_setuptools_pep660/__editable___example_0_1_0_finder.py b/tests/testdata/python3/data/import_setuptools_pep660/__editable___example_0_1_0_finder.py new file mode 100644 index 0000000000..7e324f4114 --- /dev/null +++ b/tests/testdata/python3/data/import_setuptools_pep660/__editable___example_0_1_0_finder.py @@ -0,0 +1,72 @@ +"""This file contains Finders automatically generated by setuptools for a package installed +in editable mode via custom import hooks. It's generated here: +https://github.com/pypa/setuptools/blob/c34b82735c1a9c8707bea00705ae2f621bf4c24d/setuptools/command/editable_wheel.py#L732-L801 +""" +import sys +from importlib.machinery import ModuleSpec +from importlib.machinery import all_suffixes as module_suffixes +from importlib.util import spec_from_file_location +from itertools import chain +from pathlib import Path + +MAPPING = {"example": Path(__file__).parent.resolve() / "example"} +NAMESPACES = {} +PATH_PLACEHOLDER = "__editable__.example-0.1.0.finder" + ".__path_hook__" + + +class _EditableFinder: # MetaPathFinder + @classmethod + def find_spec(cls, fullname, path=None, target=None): + for pkg, pkg_path in reversed(list(MAPPING.items())): + if fullname == pkg or fullname.startswith(f"{pkg}."): + rest = fullname.replace(pkg, "", 1).strip(".").split(".") + return cls._find_spec(fullname, Path(pkg_path, *rest)) + + return None + + @classmethod + def _find_spec(cls, fullname, candidate_path): + init = candidate_path / "__init__.py" + candidates = (candidate_path.with_suffix(x) for x in module_suffixes()) + for candidate in chain([init], candidates): + if candidate.exists(): + return spec_from_file_location(fullname, candidate) + + +class _EditableNamespaceFinder: # PathEntryFinder + @classmethod + def _path_hook(cls, path): + if path == PATH_PLACEHOLDER: + return cls + raise ImportError + + @classmethod + def _paths(cls, fullname): + # Ensure __path__ is not empty for the spec to be considered a namespace. + return NAMESPACES[fullname] or MAPPING.get(fullname) or [PATH_PLACEHOLDER] + + @classmethod + def find_spec(cls, fullname, target=None): + if fullname in NAMESPACES: + spec = ModuleSpec(fullname, None, is_package=True) + spec.submodule_search_locations = cls._paths(fullname) + return spec + return None + + @classmethod + def find_module(cls, fullname): + return None + + +def install(): + if not any(finder == _EditableFinder for finder in sys.meta_path): + sys.meta_path.append(_EditableFinder) + + if not NAMESPACES: + return + + if not any(hook == _EditableNamespaceFinder._path_hook for hook in sys.path_hooks): + # PathEntryFinder is needed to create NamespaceSpec without private APIS + sys.path_hooks.append(_EditableNamespaceFinder._path_hook) + if PATH_PLACEHOLDER not in sys.path: + sys.path.append(PATH_PLACEHOLDER) # Used just to trigger the path hook diff --git a/tests/testdata/python3/data/import_setuptools_pep660/example/__init__.py b/tests/testdata/python3/data/import_setuptools_pep660/example/__init__.py new file mode 100644 index 0000000000..643085bc41 --- /dev/null +++ b/tests/testdata/python3/data/import_setuptools_pep660/example/__init__.py @@ -0,0 +1 @@ +from subpackage import hello diff --git a/tests/testdata/python3/data/import_setuptools_pep660/example/subpackage/__init__.py b/tests/testdata/python3/data/import_setuptools_pep660/example/subpackage/__init__.py new file mode 100644 index 0000000000..d7501694bb --- /dev/null +++ b/tests/testdata/python3/data/import_setuptools_pep660/example/subpackage/__init__.py @@ -0,0 +1 @@ +hello = 1