Skip to content

Commit daaf3ab

Browse files
committed
Ensure namespaces from ImportFinder handle additions to path
According to the PEP 420, namespace packages need to gracefully handle later additions to path. - Use a `PathEntryFinder` + an arbitrary placeholder entry on `sys.path` to force `PathFinder` to create a namespace spec. - Since `_NamespacePath` and `_NamespaceLoader` are private classes (or just documented for comparison purposes), there is no other way to implement this behaviour directly [^1]. [^1]: Reimplementing _NamespacePath + a custom logic to maintain namespace portions don't have a corresponding path entry also seems to have the same end result.
2 parents d9c4a41 + 4687243 commit daaf3ab

File tree

3 files changed

+154
-64
lines changed

3 files changed

+154
-64
lines changed

setuptools/command/editable_wheel.py

Lines changed: 75 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from itertools import chain
1919
from pathlib import Path
2020
from tempfile import TemporaryDirectory
21-
from typing import Dict, Iterable, Iterator, List, Mapping, Set, Union, TypeVar
21+
from typing import Dict, Iterable, Iterator, List, Mapping, Union, Tuple, TypeVar
2222

2323
from setuptools import Command, namespaces
2424
from setuptools.discovery import find_package_path
@@ -247,10 +247,15 @@ def __call__(self, unpacked_wheel_dir: Path):
247247
top_level = chain(_find_packages(self.dist), _find_top_level_modules(self.dist))
248248
package_dir = self.dist.package_dir or {}
249249
roots = _find_package_roots(top_level, package_dir, src_root)
250-
namespaces_ = set(_find_mapped_namespaces(roots))
251250

252-
finder = _make_identifier(f"__editable__.{self.name}.finder")
253-
content = _finder_template(roots, namespaces_)
251+
namespaces_: Dict[str, List[str]] = dict(chain(
252+
_find_namespaces(self.dist.packages, roots),
253+
((ns, []) for ns in _find_virtual_namespaces(roots)),
254+
))
255+
256+
name = f"__editable__.{self.name}.finder"
257+
finder = _make_identifier(name)
258+
content = _finder_template(name, roots, namespaces_)
254259
Path(unpacked_wheel_dir, f"{finder}.py").write_text(content, encoding="utf-8")
255260

256261
pth = f"__editable__.{self.name}.pth"
@@ -398,9 +403,9 @@ def _absolute_root(path: _Path) -> str:
398403
return str(parent.resolve() / path_.name)
399404

400405

401-
def _find_mapped_namespaces(pkg_roots: Dict[str, str]) -> Iterator[str]:
402-
"""By carefully designing ``package_dir``, it is possible to implement
403-
PEP 420 compatible namespaces without creating extra folders.
406+
def _find_virtual_namespaces(pkg_roots: Dict[str, str]) -> Iterator[str]:
407+
"""By carefully designing ``package_dir``, it is possible to implement the logical
408+
structure of PEP 420 in a package without the corresponding directories.
404409
This function will try to find this kind of namespaces.
405410
"""
406411
for pkg in pkg_roots:
@@ -409,11 +414,20 @@ def _find_mapped_namespaces(pkg_roots: Dict[str, str]) -> Iterator[str]:
409414
parts = pkg.split(".")
410415
for i in range(len(parts) - 1, 0, -1):
411416
partial_name = ".".join(parts[:i])
412-
path = find_package_path(partial_name, pkg_roots, "")
413-
if not Path(path, "__init__.py").exists():
417+
path = Path(find_package_path(partial_name, pkg_roots, ""))
418+
if not path.exists():
414419
yield partial_name
415420

416421

422+
def _find_namespaces(
423+
packages: List[str], pkg_roots: Dict[str, str]
424+
) -> Iterator[Tuple[str, List[str]]]:
425+
for pkg in packages:
426+
path = find_package_path(pkg, pkg_roots, "")
427+
if Path(path).exists() and not Path(path, "__init__.py").exists():
428+
yield (pkg, [path])
429+
430+
417431
def _remove_nested(pkg_roots: Dict[str, str]) -> Dict[str, str]:
418432
output = dict(pkg_roots.copy())
419433

@@ -491,59 +505,81 @@ def _get_root(self):
491505

492506
_FINDER_TEMPLATE = """\
493507
import sys
494-
from importlib.machinery import all_suffixes as module_suffixes
495508
from importlib.machinery import ModuleSpec
509+
from importlib.machinery import all_suffixes as module_suffixes
496510
from importlib.util import spec_from_file_location
497511
from itertools import chain
498512
from pathlib import Path
499513
500-
class __EditableFinder:
501-
MAPPING = {mapping!r}
502-
NAMESPACES = {namespaces!r}
514+
MAPPING = {mapping!r}
515+
NAMESPACES = {namespaces!r}
516+
PATH_PLACEHOLDER = {name!r} + ".__path_hook__"
503517
504-
@classmethod
505-
def find_spec(cls, fullname, path, target=None):
506-
if fullname in cls.NAMESPACES:
507-
return cls._namespace_spec(fullname)
508518
509-
for pkg, pkg_path in reversed(list(cls.MAPPING.items())):
519+
class _EditableFinder: # MetaPathFinder
520+
@classmethod
521+
def find_spec(cls, fullname, path=None, target=None):
522+
for pkg, pkg_path in reversed(list(MAPPING.items())):
510523
if fullname.startswith(pkg):
511-
return cls._find_spec(fullname, pkg, pkg_path)
524+
rest = fullname.replace(pkg, "").strip(".").split(".")
525+
return cls._find_spec(fullname, Path(pkg_path, *rest))
512526
513527
return None
514528
515529
@classmethod
516-
def _namespace_spec(cls, name):
517-
# Since `cls` is appended to the path, this will only trigger
518-
# when no other package is installed in the same namespace.
519-
return ModuleSpec(name, None, is_package=True)
520-
# ^-- PEP 451 mentions setting loader to None for namespaces.
521-
522-
@classmethod
523-
def _find_spec(cls, fullname, parent, parent_path):
524-
rest = fullname.replace(parent, "").strip(".").split(".")
525-
candidate_path = Path(parent_path, *rest)
526-
530+
def _find_spec(cls, fullname, candidate_path):
527531
init = candidate_path / "__init__.py"
528532
candidates = (candidate_path.with_suffix(x) for x in module_suffixes())
529533
for candidate in chain([init], candidates):
530534
if candidate.exists():
531-
spec = spec_from_file_location(fullname, candidate)
532-
return spec
535+
return spec_from_file_location(fullname, candidate)
533536
534-
if candidate_path.exists():
535-
return cls._namespace_spec(fullname)
536537
538+
class _EditableNamespaceFinder: # PathEntryFinder
539+
@classmethod
540+
def _path_hook(cls, path):
541+
if path == PATH_PLACEHOLDER:
542+
return cls
543+
raise ImportError
544+
545+
@classmethod
546+
def _paths(cls, fullname):
547+
# Ensure __path__ is not empty for the spec to be considered a namespace.
548+
return NAMESPACES[fullname] or MAPPING.get(fullname) or [PATH_PLACEHOLDER]
549+
550+
@classmethod
551+
def find_spec(cls, fullname, target=None):
552+
if fullname in NAMESPACES:
553+
spec = ModuleSpec(fullname, None, is_package=True)
554+
spec.submodule_search_locations = cls._paths(fullname)
555+
return spec
556+
return None
557+
558+
@classmethod
559+
def find_module(cls, fullname):
537560
return None
538561
539562
540563
def install():
541-
if not any(finder == __EditableFinder for finder in sys.meta_path):
542-
sys.meta_path.append(__EditableFinder)
564+
if not any(finder == _EditableFinder for finder in sys.meta_path):
565+
sys.meta_path.append(_EditableFinder)
566+
567+
if not NAMESPACES:
568+
return
569+
570+
if not any(hook == _EditableNamespaceFinder._path_hook for hook in sys.path_hooks):
571+
# PathEntryFinder is needed to create NamespaceSpec without private APIS
572+
sys.path_hooks.append(_EditableNamespaceFinder._path_hook)
573+
if PATH_PLACEHOLDER not in sys.path:
574+
sys.path.append(PATH_PLACEHOLDER) # Used just to trigger the path hook
543575
"""
544576

545577

546-
def _finder_template(mapping: Mapping[str, str], namespaces: Set[str]):
547-
"""Create a string containing the code for a ``MetaPathFinder``."""
578+
def _finder_template(
579+
name: str, mapping: Mapping[str, str], namespaces: Dict[str, List[str]]
580+
) -> str:
581+
"""Create a string containing the code for the``MetaPathFinder`` and
582+
``PathEntryFinder``.
583+
"""
548584
mapping = dict(sorted(mapping.items(), key=lambda p: p[0]))
549-
return _FINDER_TEMPLATE.format(mapping=mapping, namespaces=namespaces)
585+
return _FINDER_TEMPLATE.format(name=name, mapping=mapping, namespaces=namespaces)

setuptools/tests/contexts.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,13 @@ def session_locked_tmp_dir(request, tmp_path_factory, name):
127127

128128
@contextlib.contextmanager
129129
def save_paths():
130-
"""Make sure initial ``sys.path`` and ``sys.meta_path`` are preserved"""
131-
prev_paths = sys.path[:], sys.meta_path[:]
130+
"""Make sure ``sys.path``, ``sys.meta_path`` and ``sys.path_hooks`` are preserved"""
131+
prev = sys.path[:], sys.meta_path[:], sys.path_hooks[:]
132132

133133
try:
134134
yield
135135
finally:
136-
sys.path, sys.meta_path = prev_paths
136+
sys.path, sys.meta_path, sys.path_hooks = prev
137137

138138

139139
@contextlib.contextmanager

setuptools/tests/test_editable_install.py

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from importlib import import_module
88
from pathlib import Path
99
from textwrap import dedent
10+
from uuid import uuid4
1011

1112
import jaraco.envs
1213
import jaraco.path
@@ -19,7 +20,8 @@
1920
from setuptools._importlib import resources as importlib_resources
2021
from setuptools.command.editable_wheel import (
2122
_LinkTree,
22-
_find_mapped_namespaces,
23+
_find_virtual_namespaces,
24+
_find_namespaces,
2325
_find_package_roots,
2426
_finder_template,
2527
)
@@ -129,7 +131,7 @@ def test_editable_with_pyproject(tmp_path, venv, files, editable_mode):
129131
assert subprocess.check_output(cmd).strip() == b"3.14159.post0 foobar 42"
130132

131133

132-
def test_editable_with_flat_layout(tmp_path, venv, monkeypatch, editable_mode):
134+
def test_editable_with_flat_layout(tmp_path, venv, editable_mode):
133135
files = {
134136
"mypkg": {
135137
"pyproject.toml": dedent("""\
@@ -163,9 +165,7 @@ def test_editable_with_flat_layout(tmp_path, venv, monkeypatch, editable_mode):
163165
class TestLegacyNamespaces:
164166
"""Ported from test_develop"""
165167

166-
def test_namespace_package_importable(
167-
self, venv, tmp_path, monkeypatch, editable_mode
168-
):
168+
def test_namespace_package_importable(self, venv, tmp_path, editable_mode):
169169
"""
170170
Installing two packages sharing the same namespace, one installed
171171
naturally using pip or `--single-version-externally-managed`
@@ -184,9 +184,7 @@ def test_namespace_package_importable(
184184

185185

186186
class TestPep420Namespaces:
187-
def test_namespace_package_importable(
188-
self, venv, tmp_path, monkeypatch, editable_mode
189-
):
187+
def test_namespace_package_importable(self, venv, tmp_path, editable_mode):
190188
"""
191189
Installing two packages sharing the same namespace, one installed
192190
normally using pip and the other installed in editable mode
@@ -200,9 +198,7 @@ def test_namespace_package_importable(
200198
venv.run(["python", "-m", "pip", "install", "-e", str(pkg_B), *opts])
201199
venv.run(["python", "-c", "import myns.n.pkgA; import myns.n.pkgB"])
202200

203-
def test_namespace_created_via_package_dir(
204-
self, venv, tmp_path, monkeypatch, editable_mode
205-
):
201+
def test_namespace_created_via_package_dir(self, venv, tmp_path, editable_mode):
206202
"""Currently users can create a namespace by tweaking `package_dir`"""
207203
files = {
208204
"pkgA": {
@@ -305,7 +301,7 @@ def test_packages(self, tmp_path):
305301
"pkg1": str(tmp_path / "src1/pkg1"),
306302
"mod2": str(tmp_path / "src2/mod2")
307303
}
308-
template = _finder_template(mapping, {})
304+
template = _finder_template(str(uuid4()), mapping, {})
309305

310306
with contexts.save_paths(), contexts.save_sys_modules():
311307
for mod in ("pkg1", "pkg1.subpkg", "pkg1.subpkg.mod1", "mod2"):
@@ -326,9 +322,9 @@ def test_namespace(self, tmp_path):
326322
jaraco.path.build(files, prefix=tmp_path)
327323

328324
mapping = {"ns.othername": str(tmp_path / "pkg")}
329-
namespaces = {"ns"}
325+
namespaces = {"ns": []}
330326

331-
template = _finder_template(mapping, namespaces)
327+
template = _finder_template(str(uuid4()), mapping, namespaces)
332328
with contexts.save_paths(), contexts.save_sys_modules():
333329
for mod in ("ns", "ns.othername"):
334330
sys.modules.pop(mod, None)
@@ -344,7 +340,7 @@ def test_namespace(self, tmp_path):
344340
# Make sure resources can also be found
345341
assert text.read_text(encoding="utf-8") == "abc"
346342

347-
def test_combine_namespaces(self, tmp_path, monkeypatch):
343+
def test_combine_namespaces(self, tmp_path):
348344
files = {
349345
"src1": {"ns": {"pkg1": {"__init__.py": "a = 13"}}},
350346
"src2": {"ns": {"mod2.py": "b = 37"}},
@@ -355,7 +351,8 @@ def test_combine_namespaces(self, tmp_path, monkeypatch):
355351
"ns.pkgA": str(tmp_path / "src1/ns/pkg1"),
356352
"ns": str(tmp_path / "src2/ns"),
357353
}
358-
template = _finder_template(mapping, {})
354+
namespaces_ = {"ns": [str(tmp_path / "src1"), str(tmp_path / "src2")]}
355+
template = _finder_template(str(uuid4()), mapping, namespaces_)
359356

360357
with contexts.save_paths(), contexts.save_sys_modules():
361358
for mod in ("ns", "ns.pkgA", "ns.mod2"):
@@ -370,6 +367,42 @@ def test_combine_namespaces(self, tmp_path, monkeypatch):
370367
assert pkgA.a == 13
371368
assert mod2.b == 37
372369

370+
def test_dynamic_path_computation(self, tmp_path):
371+
# Follows the example in PEP 420
372+
files = {
373+
"project1": {"parent": {"child": {"one.py": "x = 1"}}},
374+
"project2": {"parent": {"child": {"two.py": "x = 2"}}},
375+
"project3": {"parent": {"child": {"three.py": "x = 3"}}},
376+
}
377+
jaraco.path.build(files, prefix=tmp_path)
378+
mapping = {}
379+
namespaces_ = {"parent": [str(tmp_path / "project1/parent")]}
380+
template = _finder_template(str(uuid4()), mapping, namespaces_)
381+
382+
mods = (f"parent.child.{name}" for name in ("one", "two", "three"))
383+
with contexts.save_paths(), contexts.save_sys_modules():
384+
for mod in ("parent", "parent.child", "parent.child", *mods):
385+
sys.modules.pop(mod, None)
386+
387+
self.install_finder(template)
388+
389+
one = import_module("parent.child.one")
390+
assert one.x == 1
391+
392+
with pytest.raises(ImportError):
393+
import_module("parent.child.two")
394+
395+
sys.path.append(str(tmp_path / "project2"))
396+
two = import_module("parent.child.two")
397+
assert two.x == 2
398+
399+
with pytest.raises(ImportError):
400+
import_module("parent.child.three")
401+
402+
sys.path.append(str(tmp_path / "project3"))
403+
three = import_module("parent.child.three")
404+
assert three.x == 3
405+
373406

374407
def test_pkg_roots(tmp_path):
375408
"""This test focus in getting a particular implementation detail right.
@@ -381,22 +414,43 @@ def test_pkg_roots(tmp_path):
381414
"d": {"__init__.py": "d = 1", "e": {"__init__.py": "de = 1"}},
382415
"f": {"g": {"h": {"__init__.py": "fgh = 1"}}},
383416
"other": {"__init__.py": "abc = 1"},
384-
"another": {"__init__.py": "abcxy = 1"},
417+
"another": {"__init__.py": "abcxyz = 1"},
418+
"yet_another": {"__init__.py": "mnopq = 1"},
385419
}
386420
jaraco.path.build(files, prefix=tmp_path)
387-
package_dir = {"a.b.c": "other", "a.b.c.x.y": "another"}
388-
packages = ["a", "a.b", "a.b.c", "a.b.c.x.y", "d", "d.e", "f", "f.g", "f.g.h"]
421+
package_dir = {
422+
"a.b.c": "other",
423+
"a.b.c.x.y.z": "another",
424+
"m.n.o.p.q": "yet_another"
425+
}
426+
packages = [
427+
"a",
428+
"a.b",
429+
"a.b.c",
430+
"a.b.c.x.y",
431+
"a.b.c.x.y.z",
432+
"d",
433+
"d.e",
434+
"f",
435+
"f.g",
436+
"f.g.h",
437+
"m.n.o.p.q",
438+
]
389439
roots = _find_package_roots(packages, package_dir, tmp_path)
390440
assert roots == {
391441
"a": str(tmp_path / "a"),
392442
"a.b.c": str(tmp_path / "other"),
393-
"a.b.c.x.y": str(tmp_path / "another"),
443+
"a.b.c.x.y.z": str(tmp_path / "another"),
394444
"d": str(tmp_path / "d"),
395445
"f": str(tmp_path / "f"),
446+
"m.n.o.p.q": str(tmp_path / "yet_another"),
396447
}
397448

398-
namespaces = set(_find_mapped_namespaces(roots))
399-
assert namespaces == {"a.b.c.x"}
449+
ns = set(dict(_find_namespaces(packages, roots)))
450+
assert ns == {"f", "f.g"}
451+
452+
ns = set(_find_virtual_namespaces(roots))
453+
assert ns == {"a.b.c.x", "a.b.c.x.y", "m", "m.n", "m.n.o", "m.n.o.p"}
400454

401455

402456
class TestOverallBehaviour:

0 commit comments

Comments
 (0)