diff --git a/ChangeLog b/ChangeLog index 3f4b867a14..d173d2c885 100644 --- a/ChangeLog +++ b/ChangeLog @@ -6,6 +6,9 @@ What's New in astroid 2.15.0? ============================= Release date: TBA +* ``Astroid`` now supports custom import hooks. + + Refs PyCQA/pylint#7306 What's New in astroid 2.14.2? diff --git a/astroid/interpreter/_import/spec.py b/astroid/interpreter/_import/spec.py index ecf330b09d..77e71016ac 100644 --- a/astroid/interpreter/_import/spec.py +++ b/astroid/interpreter/_import/spec.py @@ -12,6 +12,7 @@ import os import pathlib import sys +import types import zipimport from collections.abc import Iterator, Sequence from pathlib import Path @@ -23,9 +24,21 @@ from . import util if sys.version_info >= (3, 8): - from typing import Literal + from typing import Literal, Protocol else: - from typing_extensions import Literal + from typing_extensions import Literal, Protocol + + +# The MetaPathFinder protocol comes from typeshed, which says: +# Intentionally omits one deprecated and one optional method of `importlib.abc.MetaPathFinder` +class _MetaPathFinder(Protocol): + def find_spec( + self, + fullname: str, + path: Sequence[str] | None, + target: types.ModuleType | None = ..., + ) -> importlib.machinery.ModuleSpec | None: + ... # pragma: no cover class ModuleType(enum.Enum): @@ -43,6 +56,15 @@ class ModuleType(enum.Enum): PY_NAMESPACE = enum.auto() +_MetaPathFinderModuleTypes: dict[str, ModuleType] = { + # Finders created by setuptools editable installs + "_EditableFinder": ModuleType.PY_SOURCE, + "_EditableNamespaceFinder": ModuleType.PY_NAMESPACE, + # Finders create by six + "_SixMetaPathImporter": ModuleType.PY_SOURCE, +} + + class ModuleSpec(NamedTuple): """Defines a class similar to PEP 420's ModuleSpec. @@ -122,8 +144,10 @@ def find_module( try: spec = importlib.util.find_spec(modname) if ( - spec and spec.loader is importlib.machinery.FrozenImporter - ): # noqa: E501 # type: ignore[comparison-overlap] + spec + and spec.loader # type: ignore[comparison-overlap] # noqa: E501 + is importlib.machinery.FrozenImporter + ): # No need for BuiltinImporter; builtins handled above return ModuleSpec( name=modname, @@ -226,7 +250,6 @@ def __init__(self, path: Sequence[str]) -> None: super().__init__(path) for entry_path in path: if entry_path not in sys.path_importer_cache: - # pylint: disable=no-member try: sys.path_importer_cache[entry_path] = zipimport.zipimporter( # type: ignore[assignment] entry_path @@ -310,7 +333,6 @@ def _is_setuptools_namespace(location: pathlib.Path) -> bool: def _get_zipimporters() -> Iterator[tuple[str, zipimport.zipimporter]]: for filepath, importer in sys.path_importer_cache.items(): - # pylint: disable-next=no-member if isinstance(importer, zipimport.zipimporter): yield filepath, importer @@ -349,7 +371,7 @@ def _find_spec_with_path( module_parts: list[str], processed: list[str], submodule_path: Sequence[str] | None, -) -> tuple[Finder, ModuleSpec]: +) -> tuple[Finder | _MetaPathFinder, ModuleSpec]: for finder in _SPEC_FINDERS: finder_instance = finder(search_path) spec = finder_instance.find_module( @@ -359,6 +381,43 @@ def _find_spec_with_path( continue return finder_instance, spec + # Support for custom finders + for meta_finder in sys.meta_path: + # See if we support the customer import hook of the meta_finder + meta_finder_name = meta_finder.__class__.__name__ + if meta_finder_name not in _MetaPathFinderModuleTypes: + # Setuptools>62 creates its EditableFinders dynamically and have + # "type" as their __class__.__name__. We check __name__ as well + # to see if we can support the finder. + try: + meta_finder_name = meta_finder.__name__ + except AttributeError: + continue + if meta_finder_name not in _MetaPathFinderModuleTypes: + continue + + module_type = _MetaPathFinderModuleTypes[meta_finder_name] + + # Meta path finders are supposed to have a find_spec method since + # Python 3.4. However, some third-party finders do not implement it. + # PEP302 does not refer to find_spec as well. + # See: https://github.com/PyCQA/astroid/pull/1752/ + if not hasattr(meta_finder, "find_spec"): + continue + + spec = meta_finder.find_spec(modname, submodule_path) + if spec: + return ( + meta_finder, + ModuleSpec( + spec.name, + module_type, + spec.origin, + spec.origin, + spec.submodule_search_locations, + ), + ) + raise ImportError(f"No module named {'.'.join(module_parts)}") @@ -394,7 +453,7 @@ 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: + if modpath and isinstance(finder, Finder): submodule_path = finder.contribute_to_path(spec, processed) if spec.type == ModuleType.PKG_DIRECTORY: diff --git a/tests/unittest_modutils.py b/tests/unittest_modutils.py index ab1acaac37..9c058f2a21 100644 --- a/tests/unittest_modutils.py +++ b/tests/unittest_modutils.py @@ -445,11 +445,7 @@ 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: - pytest.raises( - ImportError, - modutils.file_info_from_modpath, - ["urllib3.packages.six.moves.http_client"], - ) + assert modutils.file_info_from_modpath(["urllib3.packages.six.moves.http_client"]) if __name__ == "__main__":