Skip to content

Commit 10d87bf

Browse files
authored
Merge pull request #321 from kurtmckee/add-readtext-errors-parameter-cpython-issue-127012
Add a `Traversable.read_text()` `errors` parameter
2 parents fa27acb + 9a872e5 commit 10d87bf

File tree

6 files changed

+161
-14
lines changed

6 files changed

+161
-14
lines changed

importlib_resources/_functional.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import warnings
44

55
from ._common import as_file, files
6+
from .abc import TraversalError
67

78
_MISSING = object()
89

@@ -41,7 +42,10 @@ def is_resource(anchor, *path_names):
4142
4243
Otherwise returns ``False``.
4344
"""
44-
return _get_resource(anchor, path_names).is_file()
45+
try:
46+
return _get_resource(anchor, path_names).is_file()
47+
except TraversalError:
48+
return False
4549

4650

4751
def contents(anchor, *path_names):

importlib_resources/abc.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,13 @@ def read_bytes(self) -> bytes:
9393
with self.open('rb') as strm:
9494
return strm.read()
9595

96-
def read_text(self, encoding: Optional[str] = None) -> str:
96+
def read_text(
97+
self, encoding: Optional[str] = None, errors: Optional[str] = None
98+
) -> str:
9799
"""
98100
Read contents of self as text
99101
"""
100-
with self.open(encoding=encoding) as strm:
102+
with self.open(encoding=encoding, errors=errors) as strm:
101103
return strm.read()
102104

103105
@abc.abstractmethod

importlib_resources/tests/test_functional.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@
77
from . import util
88
from .compat.py39 import warnings_helper
99

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

1511
class StringAnchorMixin:
1612
anchor01 = 'data01'
@@ -27,7 +23,7 @@ def anchor02(self):
2723
return importlib.import_module('data02')
2824

2925

30-
class FunctionalAPIBase(util.DiskSetup):
26+
class FunctionalAPIBase:
3127
def setUp(self):
3228
super().setUp()
3329
self.load_fixture('data02')
@@ -76,7 +72,7 @@ def test_read_text(self):
7672
# fail with PermissionError rather than IsADirectoryError
7773
with self.assertRaises(OSError):
7874
resources.read_text(self.anchor01)
79-
with self.assertRaises(OSError):
75+
with self.assertRaises((OSError, resources.abc.TraversalError)):
8076
resources.read_text(self.anchor01, 'no-such-file')
8177
with self.assertRaises(UnicodeDecodeError):
8278
resources.read_text(self.anchor01, 'utf-16.file')
@@ -124,7 +120,7 @@ def test_open_text(self):
124120
# fail with PermissionError rather than IsADirectoryError
125121
with self.assertRaises(OSError):
126122
resources.open_text(self.anchor01)
127-
with self.assertRaises(OSError):
123+
with self.assertRaises((OSError, resources.abc.TraversalError)):
128124
resources.open_text(self.anchor01, 'no-such-file')
129125
with resources.open_text(self.anchor01, 'utf-16.file') as f:
130126
with self.assertRaises(UnicodeDecodeError):
@@ -192,7 +188,7 @@ def test_contents(self):
192188

193189
for path_parts in self._gen_resourcetxt_path_parts():
194190
with (
195-
self.assertRaises(OSError),
191+
self.assertRaises((OSError, resources.abc.TraversalError)),
196192
warnings_helper.check_warnings((
197193
".*contents.*",
198194
DeprecationWarning,
@@ -244,17 +240,28 @@ def test_text_errors(self):
244240
)
245241

246242

247-
class FunctionalAPITest_StringAnchor(
243+
class FunctionalAPITest_StringAnchor_Disk(
248244
StringAnchorMixin,
249245
FunctionalAPIBase,
246+
util.DiskSetup,
250247
unittest.TestCase,
251248
):
252249
pass
253250

254251

255-
class FunctionalAPITest_ModuleAnchor(
252+
class FunctionalAPITest_ModuleAnchor_Disk(
256253
ModuleAnchorMixin,
257254
FunctionalAPIBase,
255+
util.DiskSetup,
256+
unittest.TestCase,
257+
):
258+
pass
259+
260+
261+
class FunctionalAPITest_StringAnchor_Memory(
262+
StringAnchorMixin,
263+
FunctionalAPIBase,
264+
util.MemorySetup,
258265
unittest.TestCase,
259266
):
260267
pass
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import unittest
2+
3+
from .util import MemorySetup, Traversable
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+
assert not overridden_methods

importlib_resources/tests/util.py

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import abc
22
import contextlib
3+
import functools
34
import importlib
45
import io
56
import pathlib
67
import sys
78
import types
89
from importlib.machinery import ModuleSpec
910

10-
from ..abc import ResourceReader
11+
from ..abc import ResourceReader, Traversable, TraversableResources
1112
from . import _path
1213
from . import zip as zip_
1314
from .compat.py39 import import_helper, os_helper
@@ -200,5 +201,108 @@ def tree_on_path(self, spec):
200201
self.fixtures.enter_context(import_helper.DirsOnSysPath(temp_dir))
201202

202203

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

newsfragments/321.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Updated ``Traversable.read_text()`` to reflect the ``errors`` parameter (python/cpython#127012).

0 commit comments

Comments
 (0)