Skip to content

Commit a3d0be5

Browse files
authored
Fix PathEntryFinder / MetaPathFinder in editable_wheel (pypa#4278)
2 parents 150798d + 2b7ea60 commit a3d0be5

File tree

3 files changed

+78
-17
lines changed

3 files changed

+78
-17
lines changed

newsfragments/4278.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix finder template for lenient editable installs of implicit nested namespaces
2+
constructed by using ``package_dir`` to reorganise directory structure.

setuptools/command/editable_wheel.py

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,7 @@ def __init__(self, dist: Distribution, name: str):
505505
self.dist = dist
506506
self.name = name
507507

508-
def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]):
508+
def template_vars(self) -> Tuple[str, str, Dict[str, str], Dict[str, List[str]]]:
509509
src_root = self.dist.src_root or os.curdir
510510
top_level = chain(_find_packages(self.dist), _find_top_level_modules(self.dist))
511511
package_dir = self.dist.package_dir or {}
@@ -519,7 +519,7 @@ def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]
519519
)
520520

521521
legacy_namespaces = {
522-
pkg: find_package_path(pkg, roots, self.dist.src_root or "")
522+
cast(str, pkg): find_package_path(pkg, roots, self.dist.src_root or "")
523523
for pkg in self.dist.namespace_packages or []
524524
}
525525

@@ -530,11 +530,20 @@ def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]
530530

531531
name = f"__editable__.{self.name}.finder"
532532
finder = _normalization.safe_identifier(name)
533+
return finder, name, mapping, namespaces_
534+
535+
def get_implementation(self) -> Iterator[Tuple[str, bytes]]:
536+
finder, name, mapping, namespaces_ = self.template_vars()
537+
533538
content = bytes(_finder_template(name, mapping, namespaces_), "utf-8")
534-
wheel.writestr(f"{finder}.py", content)
539+
yield (f"{finder}.py", content)
535540

536541
content = _encode_pth(f"import {finder}; {finder}.install()")
537-
wheel.writestr(f"__editable__.{self.name}.pth", content)
542+
yield (f"__editable__.{self.name}.pth", content)
543+
544+
def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]):
545+
for file, content in self.get_implementation():
546+
wheel.writestr(file, content)
538547

539548
def __enter__(self):
540549
msg = "Editable install will be performed using a meta path finder.\n"
@@ -784,23 +793,22 @@ def _get_root(self):
784793

785794

786795
_FINDER_TEMPLATE = """\
796+
from __future__ import annotations
787797
import sys
788798
from importlib.machinery import ModuleSpec, PathFinder
789799
from importlib.machinery import all_suffixes as module_suffixes
790800
from importlib.util import spec_from_file_location
791801
from itertools import chain
792802
from pathlib import Path
793803
794-
MAPPING = {mapping!r}
795-
NAMESPACES = {namespaces!r}
804+
MAPPING: dict[str, str] = {mapping!r}
805+
NAMESPACES: dict[str, list[str]] = {namespaces!r}
796806
PATH_PLACEHOLDER = {name!r} + ".__path_hook__"
797807
798808
799809
class _EditableFinder: # MetaPathFinder
800810
@classmethod
801-
def find_spec(cls, fullname, path=None, target=None):
802-
extra_path = []
803-
811+
def find_spec(cls, fullname: str, _path=None, _target=None) -> ModuleSpec | None:
804812
# Top-level packages and modules (we know these exist in the FS)
805813
if fullname in MAPPING:
806814
pkg_path = MAPPING[fullname]
@@ -811,43 +819,50 @@ def find_spec(cls, fullname, path=None, target=None):
811819
# to the importlib.machinery implementation.
812820
parent, _, child = fullname.rpartition(".")
813821
if parent and parent in MAPPING:
814-
return PathFinder.find_spec(fullname, path=[MAPPING[parent], *extra_path])
822+
return PathFinder.find_spec(fullname, path=[MAPPING[parent]])
815823
816824
# Other levels of nesting should be handled automatically by importlib
817825
# using the parent path.
818826
return None
819827
820828
@classmethod
821-
def _find_spec(cls, fullname, candidate_path):
829+
def _find_spec(cls, fullname: str, candidate_path: Path) -> ModuleSpec | None:
822830
init = candidate_path / "__init__.py"
823831
candidates = (candidate_path.with_suffix(x) for x in module_suffixes())
824832
for candidate in chain([init], candidates):
825833
if candidate.exists():
826834
return spec_from_file_location(fullname, candidate)
835+
return None
827836
828837
829838
class _EditableNamespaceFinder: # PathEntryFinder
830839
@classmethod
831-
def _path_hook(cls, path):
840+
def _path_hook(cls, path) -> type[_EditableNamespaceFinder]:
832841
if path == PATH_PLACEHOLDER:
833842
return cls
834843
raise ImportError
835844
836845
@classmethod
837-
def _paths(cls, fullname):
838-
# Ensure __path__ is not empty for the spec to be considered a namespace.
839-
return NAMESPACES[fullname] or MAPPING.get(fullname) or [PATH_PLACEHOLDER]
846+
def _paths(cls, fullname: str) -> list[str]:
847+
paths = NAMESPACES[fullname]
848+
if not paths and fullname in MAPPING:
849+
paths = [MAPPING[fullname]]
850+
# Always add placeholder, for 2 reasons:
851+
# 1. __path__ cannot be empty for the spec to be considered namespace.
852+
# 2. In the case of nested namespaces, we need to force
853+
# import machinery to query _EditableNamespaceFinder again.
854+
return [*paths, PATH_PLACEHOLDER]
840855
841856
@classmethod
842-
def find_spec(cls, fullname, target=None):
857+
def find_spec(cls, fullname: str, _target=None) -> ModuleSpec | None:
843858
if fullname in NAMESPACES:
844859
spec = ModuleSpec(fullname, None, is_package=True)
845860
spec.submodule_search_locations = cls._paths(fullname)
846861
return spec
847862
return None
848863
849864
@classmethod
850-
def find_module(cls, fullname):
865+
def find_module(cls, _fullname) -> None:
851866
return None
852867
853868

setuptools/tests/test_editable_install.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from setuptools.command.editable_wheel import (
2525
_DebuggingTips,
2626
_LinkTree,
27+
_TopLevelFinder,
2728
_encode_pth,
2829
_find_virtual_namespaces,
2930
_find_namespaces,
@@ -530,6 +531,49 @@ def test_combine_namespaces(self, tmp_path):
530531
assert pkgA.a == 13
531532
assert mod2.b == 37
532533

534+
def test_combine_namespaces_nested(self, tmp_path):
535+
"""
536+
Users may attempt to combine namespace packages in a nested way via
537+
``package_dir`` as shown in pypa/setuptools#4248.
538+
"""
539+
540+
files = {
541+
"src": {"my_package": {"my_module.py": "a = 13"}},
542+
"src2": {"my_package2": {"my_module2.py": "b = 37"}},
543+
}
544+
545+
stack = jaraco.path.DirectoryStack()
546+
with stack.context(tmp_path):
547+
jaraco.path.build(files)
548+
attrs = {
549+
"script_name": "%PEP 517%",
550+
"package_dir": {
551+
"different_name": "src/my_package",
552+
"different_name.subpkg": "src2/my_package2",
553+
},
554+
"packages": ["different_name", "different_name.subpkg"],
555+
}
556+
dist = Distribution(attrs)
557+
finder = _TopLevelFinder(dist, str(uuid4()))
558+
code = next(v for k, v in finder.get_implementation() if k.endswith(".py"))
559+
560+
with contexts.save_paths(), contexts.save_sys_modules():
561+
for mod in attrs["packages"]:
562+
sys.modules.pop(mod, None)
563+
564+
self.install_finder(code)
565+
mod1 = import_module("different_name.my_module")
566+
mod2 = import_module("different_name.subpkg.my_module2")
567+
568+
expected = str((tmp_path / "src/my_package/my_module.py").resolve())
569+
assert str(Path(mod1.__file__).resolve()) == expected
570+
571+
expected = str((tmp_path / "src2/my_package2/my_module2.py").resolve())
572+
assert str(Path(mod2.__file__).resolve()) == expected
573+
574+
assert mod1.a == 13
575+
assert mod2.b == 37
576+
533577
def test_dynamic_path_computation(self, tmp_path):
534578
# Follows the example in PEP 420
535579
files = {

0 commit comments

Comments
 (0)