Skip to content

Commit 0f15ae6

Browse files
committed
This adds an in-memory finder, loader, and traversable implementation, which allows the `Traversable` protocol and concrete methods to be tested. This additional infrastructure demonstrates python/cpython#127012, but also highlights that the `Traversable.joinpath()` concrete method raises `TraversalError` which is not getting caught in several places.
1 parent 9dc0b75 commit 0f15ae6

File tree

3 files changed

+153
-8
lines changed

3 files changed

+153
-8
lines changed

importlib_resources/tests/test_functional.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,6 @@
88

99
from . import util
1010

11-
# Since the functional API forwards to Traversable, we only test
12-
# filesystem resources here -- not zip files, namespace packages etc.
13-
# We do test for two kinds of Anchor, though.
14-
1511

1612
class StringAnchorMixin:
1713
anchor01 = 'data01'
@@ -28,7 +24,7 @@ def anchor02(self):
2824
return importlib.import_module('data02')
2925

3026

31-
class FunctionalAPIBase(util.DiskSetup):
27+
class FunctionalAPIBase:
3228
def setUp(self):
3329
super().setUp()
3430
self.load_fixture('data02')
@@ -245,17 +241,28 @@ def test_text_errors(self):
245241
)
246242

247243

248-
class FunctionalAPITest_StringAnchor(
244+
class FunctionalAPITest_StringAnchor_Disk(
249245
StringAnchorMixin,
250246
FunctionalAPIBase,
247+
util.DiskSetup,
251248
unittest.TestCase,
252249
):
253250
pass
254251

255252

256-
class FunctionalAPITest_ModuleAnchor(
253+
class FunctionalAPITest_ModuleAnchor_Disk(
257254
ModuleAnchorMixin,
258255
FunctionalAPIBase,
256+
util.DiskSetup,
257+
unittest.TestCase,
258+
):
259+
pass
260+
261+
262+
class FunctionalAPITest_StringAnchor_Memory(
263+
StringAnchorMixin,
264+
FunctionalAPIBase,
265+
util.MemorySetup,
259266
unittest.TestCase,
260267
):
261268
pass
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import unittest
2+
3+
from .util import Traversable, MemorySetup
4+
5+
6+
class TestMemoryTraversableImplementation(unittest.TestCase):
7+
def test_concrete_methods_are_not_overridden(self):
8+
"""`MemoryTraversable` must not override `Traversable` concrete methods.
9+
10+
This test is not an attempt to enforce a particular `Traversable` protocol;
11+
it merely catches changes in the `Traversable` abstract/concrete methods
12+
that have not been mirrored in the `MemoryTraversable` subclass.
13+
"""
14+
15+
traversable_concrete_methods = {
16+
method
17+
for method, value in Traversable.__dict__.items()
18+
if callable(value) and method not in Traversable.__abstractmethods__
19+
}
20+
memory_traversable_concrete_methods = {
21+
method
22+
for method, value in MemorySetup.MemoryTraversable.__dict__.items()
23+
if callable(value) and not method.startswith("__")
24+
}
25+
overridden_methods = (
26+
memory_traversable_concrete_methods & traversable_concrete_methods
27+
)
28+
29+
if overridden_methods:
30+
raise AssertionError(
31+
"MemorySetup.MemoryTraversable overrides Traversable concrete methods, "
32+
"which may mask problems in the Traversable protocol. "
33+
"Please remove the following methods in MemoryTraversable: "
34+
+ ", ".join(overridden_methods)
35+
)

importlib_resources/tests/util.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import abc
2+
import functools
23
import importlib
34
import io
45
import sys
56
import types
67
import pathlib
78
import contextlib
89

9-
from ..abc import ResourceReader
10+
from ..abc import ResourceReader, TraversableResources, Traversable
1011
from .compat.py39 import import_helper, os_helper
1112
from . import zip as zip_
1213
from . import _path
@@ -202,5 +203,107 @@ def tree_on_path(self, spec):
202203
self.fixtures.enter_context(import_helper.DirsOnSysPath(temp_dir))
203204

204205

206+
class MemorySetup(ModuleSetup):
207+
"""Support loading a module in memory."""
208+
209+
MODULE = 'data01'
210+
211+
def load_fixture(self, module):
212+
self.fixtures.enter_context(self.augment_sys_metapath(module))
213+
return importlib.import_module(module)
214+
215+
@contextlib.contextmanager
216+
def augment_sys_metapath(self, module):
217+
finder_instance = self.MemoryFinder(module)
218+
sys.meta_path.append(finder_instance)
219+
yield
220+
sys.meta_path.remove(finder_instance)
221+
222+
class MemoryFinder(importlib.abc.MetaPathFinder):
223+
def __init__(self, module):
224+
self._module = module
225+
226+
def find_spec(self, fullname, path, target=None):
227+
if fullname != self._module:
228+
return None
229+
230+
return importlib.machinery.ModuleSpec(
231+
name=fullname,
232+
loader=MemorySetup.MemoryLoader(self._module),
233+
is_package=True,
234+
)
235+
236+
class MemoryLoader(importlib.abc.Loader):
237+
def __init__(self, module):
238+
self._module = module
239+
240+
def exec_module(self, module):
241+
pass
242+
243+
def get_resource_reader(self, fullname):
244+
return MemorySetup.MemoryTraversableResources(self._module, fullname)
245+
246+
class MemoryTraversableResources(TraversableResources):
247+
def __init__(self, module, fullname):
248+
self._module = module
249+
self._fullname = fullname
250+
251+
def files(self):
252+
return MemorySetup.MemoryTraversable(self._module, self._fullname)
253+
254+
class MemoryTraversable(Traversable):
255+
"""Implement only the abstract methods of `Traversable`.
256+
257+
Besides `.__init__()`, no other methods may be implemented or overridden.
258+
This is critical for validating the concrete `Traversable` implementations.
259+
"""
260+
261+
def __init__(self, module, fullname):
262+
self._module = module
263+
self._fullname = fullname
264+
265+
def iterdir(self):
266+
path = pathlib.PurePosixPath(self._fullname)
267+
directory = functools.reduce(lambda d, p: d[p], path.parts, fixtures)
268+
if not isinstance(directory, dict):
269+
# Filesystem openers raise OSError, and that exception is mirrored here.
270+
raise OSError(f"{self._fullname} is not a directory")
271+
for path in directory:
272+
yield MemorySetup.MemoryTraversable(
273+
self._module, f"{self._fullname}/{path}"
274+
)
275+
276+
def is_dir(self) -> bool:
277+
path = pathlib.PurePosixPath(self._fullname)
278+
# Fully traverse the `fixtures` dictionary.
279+
# This should be wrapped in a `try/except KeyError`
280+
# but it is not currently needed, and lowers the code coverage numbers.
281+
directory = functools.reduce(lambda d, p: d[p], path.parts, fixtures)
282+
return isinstance(directory, dict)
283+
284+
def is_file(self) -> bool:
285+
path = pathlib.PurePosixPath(self._fullname)
286+
directory = functools.reduce(lambda d, p: d[p], path.parts, fixtures)
287+
return not isinstance(directory, dict)
288+
289+
def open(self, mode='r', encoding=None, errors=None, *_, **__):
290+
path = pathlib.PurePosixPath(self._fullname)
291+
contents = functools.reduce(lambda d, p: d[p], path.parts, fixtures)
292+
if isinstance(contents, dict):
293+
# Filesystem openers raise OSError when attempting to open a directory,
294+
# and that exception is mirrored here.
295+
raise OSError(f"{self._fullname} is a directory")
296+
if isinstance(contents, str):
297+
contents = contents.encode("utf-8")
298+
result = io.BytesIO(contents)
299+
if "b" in mode:
300+
return result
301+
return io.TextIOWrapper(result, encoding=encoding, errors=errors)
302+
303+
@property
304+
def name(self):
305+
return pathlib.PurePosixPath(self._fullname).name
306+
307+
205308
class CommonTests(DiskSetup, CommonTestsBase):
206309
pass

0 commit comments

Comments
 (0)