Skip to content

Commit d377bf7

Browse files
dnicolodirgommers
authored andcommitted
ENH: set __path__ for module loaded in editable installation
Set the __path__ module attribute to a placeholder path. Because how editable installs are implemented, this cannot correspond to the filesystem path for the package. And add a sys.path_hook that recognizes this paths and returns a path loader that implements iter_modules(). This allows pkgutil.iter_packages() to work properly. Fixes #557. Fixes #568.
1 parent c67578c commit d377bf7

File tree

7 files changed

+167
-34
lines changed

7 files changed

+167
-34
lines changed

mesonpy/_editable.py

Lines changed: 72 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import importlib.abc
1111
import importlib.machinery
1212
import importlib.util
13+
import inspect
1314
import json
1415
import os
1516
import pathlib
@@ -182,7 +183,7 @@ def build_module_spec(cls: type, name: str, path: str, tree: Optional[Node]) ->
182183
spec = importlib.machinery.ModuleSpec(name, loader, origin=path)
183184
spec.has_location = True
184185
if loader.is_package(name):
185-
spec.submodule_search_locations = []
186+
spec.submodule_search_locations = [os.path.join(__file__, name)]
186187
return spec
187188

188189

@@ -253,6 +254,35 @@ def collect(install_plan: Dict[str, Dict[str, Any]]) -> Node:
253254
tree[path.parts[1:]] = src
254255
return tree
255256

257+
def find_spec(fullname: str, tree: Node) -> Optional[importlib.machinery.ModuleSpec]:
258+
namespace = False
259+
parts = fullname.split('.')
260+
261+
# look for a package
262+
package = tree.get(tuple(parts))
263+
if isinstance(package, Node):
264+
for loader, suffix in LOADERS:
265+
src = package.get('__init__' + suffix)
266+
if isinstance(src, str):
267+
return build_module_spec(loader, fullname, src, package)
268+
else:
269+
namespace = True
270+
271+
# look for a module
272+
for loader, suffix in LOADERS:
273+
src = tree.get((*parts[:-1], parts[-1] + suffix))
274+
if isinstance(src, str):
275+
return build_module_spec(loader, fullname, src, None)
276+
277+
# namespace
278+
if namespace:
279+
spec = importlib.machinery.ModuleSpec(fullname, None, is_package=True)
280+
assert isinstance(spec.submodule_search_locations, list) # make mypy happy
281+
spec.submodule_search_locations.append(os.path.join(__file__, fullname))
282+
return spec
283+
284+
return None
285+
256286

257287
class MesonpyMetaFinder(importlib.abc.MetaPathFinder):
258288
def __init__(self, names: Set[str], path: str, cmd: List[str], verbose: bool = False):
@@ -271,40 +301,12 @@ def find_spec(
271301
path: Optional[Sequence[Union[bytes, str]]] = None,
272302
target: Optional[ModuleType] = None
273303
) -> Optional[importlib.machinery.ModuleSpec]:
274-
275-
if fullname.split('.', maxsplit=1)[0] not in self._top_level_modules:
304+
if fullname.split('.', 1)[0] not in self._top_level_modules:
276305
return None
277-
278306
if self._build_path in os.environ.get(MARKER, '').split(os.pathsep):
279307
return None
280-
281-
namespace = False
282308
tree = self._rebuild()
283-
parts = fullname.split('.')
284-
285-
# look for a package
286-
package = tree.get(tuple(parts))
287-
if isinstance(package, Node):
288-
for loader, suffix in LOADERS:
289-
src = package.get('__init__' + suffix)
290-
if isinstance(src, str):
291-
return build_module_spec(loader, fullname, src, package)
292-
else:
293-
namespace = True
294-
295-
# look for a module
296-
for loader, suffix in LOADERS:
297-
src = tree.get((*parts[:-1], parts[-1] + suffix))
298-
if isinstance(src, str):
299-
return build_module_spec(loader, fullname, src, None)
300-
301-
# namespace
302-
if namespace:
303-
spec = importlib.machinery.ModuleSpec(fullname, None)
304-
spec.submodule_search_locations = []
305-
return spec
306-
307-
return None
309+
return find_spec(fullname, tree)
308310

309311
@functools.lru_cache(maxsize=1)
310312
def _rebuild(self) -> Node:
@@ -327,6 +329,44 @@ def _rebuild(self) -> Node:
327329
install_plan = json.load(f)
328330
return collect(install_plan)
329331

332+
def _path_hook(self, path: str) -> MesonpyPathFinder:
333+
if os.altsep:
334+
path.replace(os.altsep, os.sep)
335+
path, _, key = path.rpartition(os.sep)
336+
if path == __file__:
337+
tree = self._rebuild()
338+
node = tree.get(tuple(key.split('.')))
339+
if isinstance(node, Node):
340+
return MesonpyPathFinder(node)
341+
raise ImportError
342+
343+
344+
class MesonpyPathFinder(importlib.abc.PathEntryFinder):
345+
def __init__(self, tree: Node):
346+
self._tree = tree
347+
348+
def find_spec(self, fullname: str, target: Optional[ModuleType] = None) -> Optional[importlib.machinery.ModuleSpec]:
349+
return find_spec(fullname, self._tree)
350+
351+
def iter_modules(self, prefix: str) -> Iterator[Tuple[str, bool]]:
352+
yielded = set()
353+
for name, node in self._tree.items():
354+
modname = inspect.getmodulename(name)
355+
if modname == '__init__' or modname in yielded:
356+
continue
357+
if isinstance(node, Node):
358+
modname = name
359+
for _, suffix in LOADERS:
360+
src = node.get('__init__' + suffix)
361+
if isinstance(src, str):
362+
yielded.add(modname)
363+
yield prefix + modname, True
364+
elif modname and '.' not in modname:
365+
yielded.add(modname)
366+
yield prefix + modname, False
367+
330368

331369
def install(names: Set[str], path: str, cmd: List[str], verbose: bool) -> None:
332-
sys.meta_path.insert(0, MesonpyMetaFinder(names, path, cmd, verbose))
370+
finder = MesonpyMetaFinder(names, path, cmd, verbose)
371+
sys.meta_path.insert(0, finder)
372+
sys.path_hooks.insert(0, finder._path_hook)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# SPDX-FileCopyrightText: 2023 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
def answer():
6+
return 42
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# SPDX-FileCopyrightText: 2024 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
py.extension_module(
6+
'baz',
7+
'baz.pyx',
8+
install: true,
9+
subdir: 'complex/more',
10+
)

tests/packages/complex/foo.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# SPDX-FileCopyrightText: 2024 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
def foo():
6+
return True

tests/packages/complex/meson.build

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,36 @@ endif
1515

1616
py = import('python').find_installation()
1717

18-
install_subdir('complex', install_dir: py.get_install_dir(pure: false))
18+
py.install_sources(
19+
'move.py',
20+
subdir: 'complex/more',
21+
pure: false,
22+
)
1923

20-
py.extension_module('test', 'test.pyx', install: true, subdir: 'complex')
24+
install_data(
25+
'foo.py',
26+
rename: 'bar.py',
27+
install_dir: py.get_install_dir(pure: false) / 'complex',
28+
)
29+
30+
install_subdir(
31+
'complex',
32+
install_dir: py.get_install_dir(pure: false),
33+
exclude_files: ['more/meson.build', 'more/baz.pyx'],
34+
)
35+
36+
py.extension_module(
37+
'test',
38+
'test.pyx',
39+
install: true,
40+
subdir: 'complex',
41+
)
42+
43+
py.extension_module(
44+
'baz',
45+
'complex/more/baz.pyx',
46+
install: true,
47+
subdir: 'complex/more',
48+
)
49+
50+
subdir('complex/more')

tests/packages/complex/move.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# SPDX-FileCopyrightText: 2024 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
def test():
6+
return True

tests/test_editable.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import os
66
import pathlib
7+
import pkgutil
78
import sys
89

910
import pytest
@@ -191,3 +192,37 @@ def test_editble_reentrant(venv, editable_imports_itself_during_build):
191192
assert venv.python('-c', 'import plat; print(plat.data())').strip() == 'DEF'
192193
finally:
193194
path.write_text(code)
195+
196+
197+
def test_editable_pkgutils_walk_packages(package_complex, tmp_path):
198+
# build a package in a temporary directory
199+
mesonpy.Project(package_complex, tmp_path)
200+
201+
finder = _editable.MesonpyMetaFinder({'complex'}, os.fspath(tmp_path), ['ninja'])
202+
203+
try:
204+
# install editable hooks
205+
sys.meta_path.insert(0, finder)
206+
sys.path_hooks.insert(0, finder._path_hook)
207+
208+
import complex
209+
packages = {m.name for m in pkgutil.walk_packages(complex.__path__, complex.__name__ + '.')}
210+
assert packages == {
211+
'complex.bar',
212+
'complex.more',
213+
'complex.more.baz',
214+
'complex.more.move',
215+
'complex.test',
216+
}
217+
218+
from complex import namespace
219+
packages = {m.name for m in pkgutil.walk_packages(namespace.__path__, namespace.__name__ + '.')}
220+
assert packages == {
221+
'complex.namespace.bar',
222+
'complex.namespace.foo',
223+
}
224+
225+
finally:
226+
# remove hooks
227+
del sys.meta_path[0]
228+
del sys.path_hooks[0]

0 commit comments

Comments
 (0)