Skip to content

Commit

Permalink
Update submodule_path after finding an editable install (#2033)
Browse files Browse the repository at this point in the history
  • Loading branch information
noah-weingarden committed Feb 26, 2023
1 parent 27352c2 commit fe57762
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 2 deletions.
14 changes: 12 additions & 2 deletions astroid/interpreter/_import/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions tests/test_modutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from subpackage import hello
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hello = 1

0 comments on commit fe57762

Please sign in to comment.