diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..e6805180 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.file binary +*.zip binary diff --git a/importlib_resources/__init__.py b/importlib_resources/__init__.py index 36cf5e1c..a0683e1f 100644 --- a/importlib_resources/__init__.py +++ b/importlib_resources/__init__.py @@ -12,6 +12,8 @@ from typing import Iterator, Union from typing.io import BinaryIO +from . import abc as resources_abc + Package = Union[types.ModuleType, str] if sys.version_info >= (3, 6): @@ -47,27 +49,33 @@ def open(package: Package, file_name: FileName) -> BinaryIO: """Return a file-like object opened for binary-reading of the resource.""" file_name = _normalize_path(file_name) package = _get_package(package) - # Using pathlib doesn't work well here due to the lack of 'strict' argument - # for pathlib.Path.resolve() prior to Python 3.6. - absolute_package_path = os.path.abspath(package.__spec__.origin) - package_path = os.path.dirname(absolute_package_path) - full_path = os.path.join(package_path, file_name) - try: - return builtins.open(full_path, 'rb') - except IOError: - # Just assume the loader is a resource loader; all the relevant - # importlib.machinery loaders are and an AttributeError for get_data() - # will make it clear what is needed from the loader. - loader = typing.cast(importlib.abc.ResourceLoader, + if hasattr(package.__spec__.loader, 'open_resource'): + reader = typing.cast(resources_abc.ResourceReader, package.__spec__.loader) + return reader.open_resource(file_name) + else: + # Using pathlib doesn't work well here due to the lack of 'strict' + # argument for pathlib.Path.resolve() prior to Python 3.6. + absolute_package_path = os.path.abspath(package.__spec__.origin) + package_path = os.path.dirname(absolute_package_path) + full_path = os.path.join(package_path, file_name) try: - data = loader.get_data(full_path) + return builtins.open(full_path, 'rb') except IOError: - package_name = package.__spec__.name - message = '{!r} resource not found in {!r}'.format(file_name, package_name) - raise FileNotFoundError(message) - else: - return io.BytesIO(data) + # Just assume the loader is a resource loader; all the relevant + # importlib.machinery loaders are and an AttributeError for + # get_data() will make it clear what is needed from the loader. + loader = typing.cast(importlib.abc.ResourceLoader, + package.__spec__.loader) + try: + data = loader.get_data(full_path) + except IOError: + package_name = package.__spec__.name + message = '{!r} resource not found in {!r}'.format(file_name, + package_name) + raise FileNotFoundError(message) + else: + return io.BytesIO(data) def read(package: Package, file_name: FileName, encoding: str = 'utf-8', @@ -98,14 +106,24 @@ def path(package: Package, file_name: FileName) -> Iterator[pathlib.Path]: raised if the file was deleted prior to the context manager exiting). """ - normalized_path = _normalize_path(file_name) + file_name = _normalize_path(file_name) package = _get_package(package) + if hasattr(package.__spec__.loader, 'resource_path'): + reader = typing.cast(resources_abc.ResourceReader, + package.__spec__.loader) + try: + yield pathlib.Path(reader.resource_path(file_name)) + return + except FileNotFoundError: + pass + # Fall-through for both the lack of resource_path() *and* if resource_path() + # raises FileNotFoundError. package_directory = pathlib.Path(package.__spec__.origin).parent - file_path = package_directory / normalized_path + file_path = package_directory / file_name if file_path.exists(): yield file_path else: - with open(package, normalized_path) as file: + with open(package, file_name) as file: data = file.read() # Not using tempfile.NamedTemporaryFile as it leads to deeper 'try' # blocks due to the need to close the temporary file to work on diff --git a/importlib_resources/abc.py b/importlib_resources/abc.py new file mode 100644 index 00000000..8975cbf0 --- /dev/null +++ b/importlib_resources/abc.py @@ -0,0 +1,25 @@ +import abc +from typing.io import BinaryIO + + +class ResourceReader(abc.ABC): + + """Abstract base class for loaders to provide resource reading support.""" + + @abc.abstractmethod + def open_resource(self, path: str) -> BinaryIO: + """Return an opened, file-like object for binary reading of the resource. + + The 'path' argument is expected to represent only a file name. + If the resource cannot be found, FileNotFoundError is raised. + """ + raise FileNotFoundError + + def resource_path(self, path: str) -> str: + """Return the file system path to the specified resource. + + The 'path' argument is expected to represent only a file name. + If the resource does not exist on the file system, raise + FileNotFoundError. + """ + raise FileNotFoundError diff --git a/importlib_resources/tests/util.py b/importlib_resources/tests/util.py index 0340b45b..6b00fcb7 100644 --- a/importlib_resources/tests/util.py +++ b/importlib_resources/tests/util.py @@ -1,12 +1,43 @@ import abc import importlib +import importlib.machinery +import io import pathlib import sys +import types import unittest +from .. import abc as resources_abc from . import data +def create_package(*, file, path): + class Reader(resources_abc.ResourceReader): + def open_resource(self, path): + self._path = path + if isinstance(file, Exception): + raise file + else: + return file + + def resource_path(self, path_): + self._path = path_ + if isinstance(path, Exception): + raise path + else: + return path + + name = 'testingpackage' + spec = importlib.machinery.ModuleSpec(name, Reader(), + origin='does-not-exist', + is_package=True) + # Unforunately importlib.util.module_from_spec() was not introduced until + # Python 3.5. + module = types.ModuleType(name) + module.__spec__ = spec + return module + + class CommonTests(abc.ABC): @abc.abstractmethod @@ -54,6 +85,25 @@ def test_non_package(self): with self.assertRaises(TypeError): self.execute(__spec__.name, 'utf-8.file') + def test_resource_opener(self): + data = io.BytesIO(b'Hello, world!') + package = create_package(file=data, path=FileNotFoundError()) + self.execute(package, 'utf-8.file') + self.assertEqual(package.__spec__.loader._path, 'utf-8.file') + + def test_resource_path(self): + data = io.BytesIO(b'Hello, world!') + path = __file__ + package = create_package(file=data, path=path) + self.execute(package, 'utf-8.file') + self.assertEqual(package.__spec__.loader._path, 'utf-8.file') + + def test_useless_loader(self): + package = create_package(file=FileNotFoundError(), + path=FileNotFoundError()) + with self.assertRaises(FileNotFoundError): + self.execute(package, 'utf-8.file') + class ZipSetup: