Skip to content

Commit 19c72bf

Browse files
committed
ENH: support importlib.resources for editable wheels
1 parent 1f07e39 commit 19c72bf

File tree

8 files changed

+243
-4
lines changed

8 files changed

+243
-4
lines changed

mesonpy/_editable.py

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,163 @@
2323
from types import ModuleType
2424
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
2525
NodeBase = Dict[str, Union[Node, str]]
26+
PathStr = Union[str, os.PathLike[str]]
2627
else:
2728
NodeBase = dict
2829

2930

31+
if sys.version_info >= (3, 12):
32+
from importlib.resources.abc import Traversable, TraversableResources
33+
elif sys.version_info >= (3, 9):
34+
from importlib.abc import Traversable, TraversableResources
35+
else:
36+
class Traversable:
37+
pass
38+
class TraversableResources:
39+
pass
40+
41+
3042
MARKER = 'MESONPY_EDITABLE_SKIP'
3143
VERBOSE = 'MESONPY_EDITABLE_VERBOSE'
3244

45+
46+
class MesonpyOrphan(Traversable):
47+
def __init__(self, name: str):
48+
self._name = name
49+
50+
@property
51+
def name(self) -> str:
52+
return self._name
53+
54+
def is_dir(self) -> bool:
55+
return False
56+
57+
def is_file(self) -> bool:
58+
return False
59+
60+
def iterdir(self) -> Iterator[Traversable]:
61+
raise FileNotFoundError()
62+
63+
def open(self, *args, **kwargs): # type: ignore
64+
raise FileNotFoundError()
65+
66+
def joinpath(self, *descendants: PathStr) -> Traversable:
67+
if not descendants:
68+
return self
69+
name = os.fspath(descendants[-1]).split('/')[-1]
70+
return MesonpyOrphan(name)
71+
72+
def __truediv__(self, child: PathStr) -> Traversable:
73+
return self.joinpath(child)
74+
75+
def read_bytes(self) -> bytes:
76+
raise FileNotFoundError()
77+
78+
def read_text(self, encoding: Optional[str] = None) -> str:
79+
raise FileNotFoundError()
80+
81+
82+
class MesonpyTraversable(Traversable):
83+
def __init__(self, name: str, tree: Node):
84+
self._name = name
85+
self._tree = tree
86+
87+
@property
88+
def name(self) -> str:
89+
return self._name
90+
91+
def is_dir(self) -> bool:
92+
return True
93+
94+
def is_file(self) -> bool:
95+
return False
96+
97+
def iterdir(self) -> Iterator[Traversable]:
98+
for name, node in self._tree.items():
99+
yield MesonpyTraversable(name, node) if isinstance(node, dict) else pathlib.Path(node) # type: ignore
100+
101+
def open(self, *args, **kwargs): # type: ignore
102+
raise IsADirectoryError()
103+
104+
@staticmethod
105+
def _flatten(names: Tuple[PathStr, ...]) -> Iterator[str]:
106+
for name in names:
107+
yield from os.fspath(name).split('/')
108+
109+
def joinpath(self, *descendants: PathStr) -> Traversable:
110+
if not descendants:
111+
return self
112+
names = self._flatten(descendants)
113+
name = next(names)
114+
node = self._tree.get(name)
115+
if isinstance(node, dict):
116+
return MesonpyTraversable(name, node).joinpath(*names)
117+
if isinstance(node, str):
118+
return pathlib.Path(node).joinpath(*names)
119+
return MesonpyOrphan(name).joinpath(*names)
120+
121+
def __truediv__(self, child: PathStr) -> Traversable:
122+
return self.joinpath(child)
123+
124+
def read_bytes(self) -> bytes:
125+
raise IsADirectoryError()
126+
127+
def read_text(self, encoding: Optional[str] = None) -> str:
128+
raise IsADirectoryError()
129+
130+
131+
class MesonpyReader(TraversableResources):
132+
def __init__(self, name: str, tree: Node):
133+
self._name = name
134+
self._tree = tree
135+
136+
def files(self) -> Traversable:
137+
return MesonpyTraversable(self._name, self._tree)
138+
139+
140+
class ExtensionFileLoader(importlib.machinery.ExtensionFileLoader):
141+
def __init__(self, name: str, path: str, tree: Node):
142+
super().__init__(name, path)
143+
self._tree = tree
144+
145+
def get_resource_reader(self, name: str) -> TraversableResources:
146+
return MesonpyReader(name, self._tree)
147+
148+
149+
class SourceFileLoader(importlib.machinery.SourceFileLoader):
150+
def __init__(self, name: str, path: str, tree: Node):
151+
super().__init__(name, path)
152+
self._tree = tree
153+
154+
def get_resource_reader(self, name: str) -> TraversableResources:
155+
return MesonpyReader(name, self._tree)
156+
157+
158+
class SourcelessFileLoader(importlib.machinery.SourcelessFileLoader):
159+
def __init__(self, name: str, path: str, tree: Node):
160+
super().__init__(name, path)
161+
self._tree = tree
162+
163+
def get_resource_reader(self, name: str) -> TraversableResources:
164+
return MesonpyReader(name, self._tree)
165+
166+
33167
LOADERS = [
34-
(importlib.machinery.ExtensionFileLoader, tuple(importlib.machinery.EXTENSION_SUFFIXES)),
35-
(importlib.machinery.SourceFileLoader, tuple(importlib.machinery.SOURCE_SUFFIXES)),
36-
(importlib.machinery.SourcelessFileLoader, tuple(importlib.machinery.BYTECODE_SUFFIXES)),
168+
(ExtensionFileLoader, tuple(importlib.machinery.EXTENSION_SUFFIXES)),
169+
(SourceFileLoader, tuple(importlib.machinery.SOURCE_SUFFIXES)),
170+
(SourcelessFileLoader, tuple(importlib.machinery.BYTECODE_SUFFIXES)),
37171
]
38172

39173

174+
def build_module_spec(cls: type, name: str, path: str, tree: Optional[Node]) -> importlib.machinery.ModuleSpec:
175+
loader = cls(name, path, tree)
176+
spec = importlib.machinery.ModuleSpec(name, loader, origin=path)
177+
spec.has_location = True
178+
if loader.is_package(name):
179+
spec.submodule_search_locations = []
180+
return spec
181+
182+
40183
class Node(NodeBase):
41184
"""Tree structure to store a virtual filesystem view."""
42185

tests/packages/simple/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# SPDX-FileCopyrightText: 2021 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
from .test import data # noqa: F401

tests/packages/simple/data.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ABC
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# SPDX-FileCopyrightText: 2021 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT

tests/packages/simple/meson.build

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# SPDX-FileCopyrightText: 2021 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
project('simple', version: '1.0.0')
6+
7+
py = import('python').find_installation()
8+
9+
py.install_sources(
10+
'__init__.py',
11+
'test.py',
12+
'data.txt',
13+
subdir: 'simple')

tests/packages/simple/pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# SPDX-FileCopyrightText: 2021 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
[build-system]
6+
build-backend = 'mesonpy'
7+
requires = ['meson-python']

tests/packages/simple/test.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# SPDX-FileCopyrightText: 2021 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
import pathlib
6+
7+
8+
def data():
9+
with pathlib.Path(__file__).parent.joinpath('data.txt').open() as f:
10+
return f.read().rstrip()

tests/test_editable.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
# SPDX-License-Identifier: MIT
44

55
import os
6+
import pathlib
7+
import sys
8+
9+
import pytest
610

711
import mesonpy
812

@@ -94,8 +98,37 @@ def test_mesonpy_meta_finder(package_complex, tmp_build_path):
9498
# remove finder from the meta path
9599
del sys.meta_path[0]
96100

101+
102+
def test_mesonpy_traversable():
103+
tree = _editable.Node()
104+
tree[('package', '__init__.py')] = '/tmp/src/package/__init__.py'
105+
tree[('package', 'src.py')] = '/tmp/src/package/src.py'
106+
tree[('package', 'data.txt')] = '/tmp/src/package/data.txt'
107+
tree[('package', 'nested', '__init__.py')] = '/tmp/src/package/nested/__init__.py'
108+
tree[('package', 'nested', 'some.py')] = '/tmp/src/package/nested/some.py'
109+
tree[('package', 'nested', 'generated.txt')] = '/tmp/build/generated.txt'
110+
traversable = _editable.MesonpyTraversable('package', tree['package'])
111+
assert {x.name for x in traversable.iterdir()} == {'__init__.py', 'src.py', 'data.txt', 'nested'}
112+
nested = traversable / 'nested'
113+
assert nested.is_dir()
114+
assert {x.name for x in nested.iterdir()} == {'__init__.py', 'some.py', 'generated.txt'}
115+
generated = traversable.joinpath('nested', 'generated.txt')
116+
assert isinstance(generated, pathlib.Path)
117+
assert generated == pathlib.Path('/tmp/build/generated.txt')
118+
bad = traversable / 'bad'
119+
assert not bad.is_file()
120+
assert not bad.is_dir()
121+
with pytest.raises(FileNotFoundError):
122+
bad.open()
123+
124+
125+
def test_resources(tmp_path):
126+
# build a package in a temporary directory
127+
package_path = pathlib.Path(__file__).parent / 'packages' / 'simple'
128+
mesonpy.Project(package_path, tmp_path)
129+
97130
# point the meta finder to the build directory
98-
finder = _editable.MesonpyMetaFinder({'simple'}, os.fspath(tmp_path / 'build'), ['ninja'], False)
131+
finder = _editable.MesonpyMetaFinder({'simple'}, os.fspath(tmp_path / 'build'), ['ninja'])
99132

100133
# verify that we can look up resources
101134
spec = finder.find_spec('simple')
@@ -106,6 +139,30 @@ def test_mesonpy_meta_finder(package_complex, tmp_build_path):
106139
text = f.read().rstrip()
107140
assert text == 'ABC'
108141

142+
143+
@pytest.mark.skipif(sys.version_info < (3, 9), reason='importlib.resources not available')
144+
def test_importlib_resources(tmp_path):
145+
# build a package in a temporary directory
146+
package_path = pathlib.Path(__file__).parent / 'packages' / 'simple'
147+
mesonpy.Project(package_path, tmp_path)
148+
149+
# point the meta finder to the build directory
150+
finder = _editable.MesonpyMetaFinder({'simple'}, os.fspath(tmp_path / 'build'), ['ninja'])
151+
152+
try:
153+
# install the finder in the meta path
154+
sys.meta_path.insert(0, finder)
155+
# verify that we can import the modules
156+
import simple
157+
assert simple.__spec__.origin == os.fspath(package_path / '__init__.py')
158+
assert simple.__file__ == os.fspath(package_path / '__init__.py')
159+
assert simple.data() == 'ABC'
160+
# load resources via importlib
161+
import importlib.resources
162+
with importlib.resources.files(simple).joinpath('data.txt').open() as f:
163+
text = f.read().rstrip()
164+
assert text == 'ABC'
165+
assert importlib.resources.files(simple).joinpath('data.txt').read_text().rstrip() == 'ABC'
109166
finally:
110167
# remove finder from the meta path
111168
del sys.meta_path[0]

0 commit comments

Comments
 (0)