Skip to content

Commit

Permalink
Merge pull request #321 from kurtmckee/add-readtext-errors-parameter-…
Browse files Browse the repository at this point in the history
…cpython-issue-127012

Add a `Traversable.read_text()` `errors` parameter
  • Loading branch information
jaraco authored Jan 3, 2025
2 parents fa27acb + 9a872e5 commit 10d87bf
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 14 deletions.
6 changes: 5 additions & 1 deletion importlib_resources/_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import warnings

from ._common import as_file, files
from .abc import TraversalError

_MISSING = object()

Expand Down Expand Up @@ -41,7 +42,10 @@ def is_resource(anchor, *path_names):
Otherwise returns ``False``.
"""
return _get_resource(anchor, path_names).is_file()
try:
return _get_resource(anchor, path_names).is_file()
except TraversalError:
return False


def contents(anchor, *path_names):
Expand Down
6 changes: 4 additions & 2 deletions importlib_resources/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,13 @@ def read_bytes(self) -> bytes:
with self.open('rb') as strm:
return strm.read()

def read_text(self, encoding: Optional[str] = None) -> str:
def read_text(
self, encoding: Optional[str] = None, errors: Optional[str] = None
) -> str:
"""
Read contents of self as text
"""
with self.open(encoding=encoding) as strm:
with self.open(encoding=encoding, errors=errors) as strm:
return strm.read()

@abc.abstractmethod
Expand Down
27 changes: 17 additions & 10 deletions importlib_resources/tests/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@
from . import util
from .compat.py39 import warnings_helper

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


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


class FunctionalAPIBase(util.DiskSetup):
class FunctionalAPIBase:
def setUp(self):
super().setUp()
self.load_fixture('data02')
Expand Down Expand Up @@ -76,7 +72,7 @@ def test_read_text(self):
# fail with PermissionError rather than IsADirectoryError
with self.assertRaises(OSError):
resources.read_text(self.anchor01)
with self.assertRaises(OSError):
with self.assertRaises((OSError, resources.abc.TraversalError)):
resources.read_text(self.anchor01, 'no-such-file')
with self.assertRaises(UnicodeDecodeError):
resources.read_text(self.anchor01, 'utf-16.file')
Expand Down Expand Up @@ -124,7 +120,7 @@ def test_open_text(self):
# fail with PermissionError rather than IsADirectoryError
with self.assertRaises(OSError):
resources.open_text(self.anchor01)
with self.assertRaises(OSError):
with self.assertRaises((OSError, resources.abc.TraversalError)):
resources.open_text(self.anchor01, 'no-such-file')
with resources.open_text(self.anchor01, 'utf-16.file') as f:
with self.assertRaises(UnicodeDecodeError):
Expand Down Expand Up @@ -192,7 +188,7 @@ def test_contents(self):

for path_parts in self._gen_resourcetxt_path_parts():
with (
self.assertRaises(OSError),
self.assertRaises((OSError, resources.abc.TraversalError)),
warnings_helper.check_warnings((
".*contents.*",
DeprecationWarning,
Expand Down Expand Up @@ -244,17 +240,28 @@ def test_text_errors(self):
)


class FunctionalAPITest_StringAnchor(
class FunctionalAPITest_StringAnchor_Disk(
StringAnchorMixin,
FunctionalAPIBase,
util.DiskSetup,
unittest.TestCase,
):
pass


class FunctionalAPITest_ModuleAnchor(
class FunctionalAPITest_ModuleAnchor_Disk(
ModuleAnchorMixin,
FunctionalAPIBase,
util.DiskSetup,
unittest.TestCase,
):
pass


class FunctionalAPITest_StringAnchor_Memory(
StringAnchorMixin,
FunctionalAPIBase,
util.MemorySetup,
unittest.TestCase,
):
pass
29 changes: 29 additions & 0 deletions importlib_resources/tests/test_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import unittest

from .util import MemorySetup, Traversable


class TestMemoryTraversableImplementation(unittest.TestCase):
def test_concrete_methods_are_not_overridden(self):
"""`MemoryTraversable` must not override `Traversable` concrete methods.
This test is not an attempt to enforce a particular `Traversable` protocol;
it merely catches changes in the `Traversable` abstract/concrete methods
that have not been mirrored in the `MemoryTraversable` subclass.
"""

traversable_concrete_methods = {
method
for method, value in Traversable.__dict__.items()
if callable(value) and method not in Traversable.__abstractmethods__
}
memory_traversable_concrete_methods = {
method
for method, value in MemorySetup.MemoryTraversable.__dict__.items()
if callable(value) and not method.startswith("__")
}
overridden_methods = (
memory_traversable_concrete_methods & traversable_concrete_methods
)

assert not overridden_methods
106 changes: 105 additions & 1 deletion importlib_resources/tests/util.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import abc
import contextlib
import functools
import importlib
import io
import pathlib
import sys
import types
from importlib.machinery import ModuleSpec

from ..abc import ResourceReader
from ..abc import ResourceReader, Traversable, TraversableResources
from . import _path
from . import zip as zip_
from .compat.py39 import import_helper, os_helper
Expand Down Expand Up @@ -200,5 +201,108 @@ def tree_on_path(self, spec):
self.fixtures.enter_context(import_helper.DirsOnSysPath(temp_dir))


class MemorySetup(ModuleSetup):
"""Support loading a module in memory."""

MODULE = 'data01'

def load_fixture(self, module):
self.fixtures.enter_context(self.augment_sys_metapath(module))
return importlib.import_module(module)

@contextlib.contextmanager
def augment_sys_metapath(self, module):
finder_instance = self.MemoryFinder(module)
sys.meta_path.append(finder_instance)
yield
sys.meta_path.remove(finder_instance)

class MemoryFinder(importlib.abc.MetaPathFinder):
def __init__(self, module):
self._module = module

def find_spec(self, fullname, path, target=None):
if fullname != self._module:
return None

return importlib.machinery.ModuleSpec(
name=fullname,
loader=MemorySetup.MemoryLoader(self._module),
is_package=True,
)

class MemoryLoader(importlib.abc.Loader):
def __init__(self, module):
self._module = module

def exec_module(self, module):
pass

def get_resource_reader(self, fullname):
return MemorySetup.MemoryTraversableResources(self._module, fullname)

class MemoryTraversableResources(TraversableResources):
def __init__(self, module, fullname):
self._module = module
self._fullname = fullname

def files(self):
return MemorySetup.MemoryTraversable(self._module, self._fullname)

class MemoryTraversable(Traversable):
"""Implement only the abstract methods of `Traversable`.
Besides `.__init__()`, no other methods may be implemented or overridden.
This is critical for validating the concrete `Traversable` implementations.
"""

def __init__(self, module, fullname):
self._module = module
self._fullname = fullname

def _resolve(self):
"""
Fully traverse the `fixtures` dictionary.
This should be wrapped in a `try/except KeyError`
but it is not currently needed and lowers the code coverage numbers.
"""
path = pathlib.PurePosixPath(self._fullname)
return functools.reduce(lambda d, p: d[p], path.parts, fixtures)

def iterdir(self):
directory = self._resolve()
if not isinstance(directory, dict):
# Filesystem openers raise OSError, and that exception is mirrored here.
raise OSError(f"{self._fullname} is not a directory")
for path in directory:
yield MemorySetup.MemoryTraversable(
self._module, f"{self._fullname}/{path}"
)

def is_dir(self) -> bool:
return isinstance(self._resolve(), dict)

def is_file(self) -> bool:
return not self.is_dir()

def open(self, mode='r', encoding=None, errors=None, *_, **__):
contents = self._resolve()
if isinstance(contents, dict):
# Filesystem openers raise OSError when attempting to open a directory,
# and that exception is mirrored here.
raise OSError(f"{self._fullname} is a directory")
if isinstance(contents, str):
contents = contents.encode("utf-8")
result = io.BytesIO(contents)
if "b" in mode:
return result
return io.TextIOWrapper(result, encoding=encoding, errors=errors)

@property
def name(self):
return pathlib.PurePosixPath(self._fullname).name


class CommonTests(DiskSetup, CommonTestsBase):
pass
1 change: 1 addition & 0 deletions newsfragments/321.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Updated ``Traversable.read_text()`` to reflect the ``errors`` parameter (python/cpython#127012).

0 comments on commit 10d87bf

Please sign in to comment.