Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a Traversable.read_text() errors parameter #321

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 files, as_file
from .abc import TraversalError


_MISSING = object()
Expand Down Expand Up @@ -42,7 +43,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 @@ -80,11 +80,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
2 changes: 0 additions & 2 deletions importlib_resources/tests/test_files.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import os
import pathlib
import py_compile
import shutil
import textwrap
import unittest
import warnings
Expand Down
47 changes: 30 additions & 17 deletions importlib_resources/tests/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@

from . import util

# 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 @@ -28,7 +24,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 @@ -77,7 +73,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 @@ -125,7 +121,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 @@ -182,17 +178,23 @@ def test_contents(self):
set(c),
{'utf-8.file', 'utf-16.file', 'binary.file', 'subdirectory'},
)
with self.assertRaises(OSError), warnings_helper.check_warnings((
".*contents.*",
DeprecationWarning,
)):
with (
self.assertRaises(OSError),
warnings_helper.check_warnings((
".*contents.*",
DeprecationWarning,
)),
):
list(resources.contents(self.anchor01, 'utf-8.file'))

for path_parts in self._gen_resourcetxt_path_parts():
with self.assertRaises(OSError), warnings_helper.check_warnings((
".*contents.*",
DeprecationWarning,
)):
with (
self.assertRaises((OSError, resources.abc.TraversalError)),
warnings_helper.check_warnings((
".*contents.*",
DeprecationWarning,
)),
):
list(resources.contents(self.anchor01, *path_parts))
with warnings_helper.check_warnings((".*contents.*", DeprecationWarning)):
c = resources.contents(self.anchor01, 'subdirectory')
Expand Down Expand Up @@ -239,17 +241,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
35 changes: 35 additions & 0 deletions importlib_resources/tests/test_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import unittest

from .util import Traversable, MemorySetup


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
)

if overridden_methods:
raise AssertionError(
"MemorySetup.MemoryTraversable overrides Traversable concrete methods, "
"which may mask problems in the Traversable protocol. "
"Please remove the following methods in MemoryTraversable: "
+ ", ".join(overridden_methods)
)
105 changes: 104 additions & 1 deletion importlib_resources/tests/util.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import abc
import functools
import importlib
import io
import sys
import types
import pathlib
import contextlib

from ..abc import ResourceReader
from ..abc import ResourceReader, TraversableResources, Traversable
from .compat.py39 import import_helper, os_helper
from . import zip as zip_
from . import _path
Expand Down Expand Up @@ -202,5 +203,107 @@ 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 iterdir(self):
path = pathlib.PurePosixPath(self._fullname)
directory = functools.reduce(lambda d, p: d[p], path.parts, fixtures)
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:
path = pathlib.PurePosixPath(self._fullname)
# 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.
directory = functools.reduce(lambda d, p: d[p], path.parts, fixtures)
return isinstance(directory, dict)

def is_file(self) -> bool:
path = pathlib.PurePosixPath(self._fullname)
directory = functools.reduce(lambda d, p: d[p], path.parts, fixtures)
return not isinstance(directory, dict)

def open(self, mode='r', encoding=None, errors=None, *_, **__):
path = pathlib.PurePosixPath(self._fullname)
contents = functools.reduce(lambda d, p: d[p], path.parts, fixtures)
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
Loading