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 support for resources in namespace packages #196

Merged
merged 3 commits into from
Oct 23, 2020
Merged
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: 6 additions & 0 deletions importlib_resources/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ def _zip_reader(spec):
with suppress(AttributeError):
return readers.ZipReader(spec.loader, spec.name)

def _namespace_reader(spec):
with suppress(AttributeError, ValueError):
return readers.NamespaceReader(spec.submodule_search_locations)

def _available_reader(spec):
with suppress(AttributeError):
return spec.loader.get_resource_reader(spec.name)
Expand All @@ -106,6 +110,8 @@ def _native_reader(spec):
_native_reader(self.spec) or
# local ZipReader if a zip module
_zip_reader(self.spec) or
# local NamespaceReader if a namespace module
_namespace_reader(self.spec) or
# local FileReader
readers.FileReader(self)
)
Expand Down
58 changes: 28 additions & 30 deletions importlib_resources/_py3.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,27 +31,30 @@ def open_binary(package: Package, resource: Resource) -> BinaryIO:
return reader.open_resource(resource)
# 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 or 'non-existent file')
package_path = os.path.dirname(absolute_package_path)
full_path = os.path.join(package_path, resource)
try:
return open(full_path, mode='rb')
except OSError:
# 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 = cast(ResourceLoader, package.__spec__.loader)
data = None
if hasattr(package.__spec__.loader, 'get_data'):
with suppress(OSError):
data = loader.get_data(full_path)
if data is None:
package_name = package.__spec__.name
message = '{!r} resource not found in {!r}'.format(
resource, package_name)
raise FileNotFoundError(message)
return BytesIO(data)
spec = package.__spec__
if package.__spec__.submodule_search_locations is not None:
paths = package.__spec__.submodule_search_locations
elif package.__spec__.origin is not None:
paths = [os.path.dirname(os.path.abspath(package.__spec__.origin))]

for package_path in paths:
full_path = os.path.join(package_path, resource)
try:
return open(full_path, mode='rb')
except OSError:
# 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 = cast(ResourceLoader, package.__spec__.loader)
data = None
if hasattr(package.__spec__.loader, 'get_data'):
with suppress(OSError):
data = loader.get_data(full_path)
if data is not None:
return BytesIO(data)

raise FileNotFoundError('{!r} resource not found in {!r}'.format(
resource, package.__spec__.name))


def open_text(package: Package,
Expand Down Expand Up @@ -144,12 +147,7 @@ def contents(package: Package) -> Iterable[str]:
reader = _common.get_resource_reader(package)
if reader is not None:
return reader.contents()
# Is the package a namespace package? By definition, namespace packages
# cannot have resources.
namespace = (
package.__spec__.origin is None or
package.__spec__.origin == 'namespace'
)
if namespace or not package.__spec__.has_location:
return ()
return list(item.name for item in _common.from_package(package).iterdir())
transversable = _common.from_package(package)
if transversable.is_dir():
return list(item.name for item in transversable.iterdir())
return []
84 changes: 83 additions & 1 deletion importlib_resources/readers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import itertools
import os.path

from . import abc

from ._compat import Path, ZipPath
from ._compat import suppress, Path, ZipPath


class FileReader(abc.TraversableResources):
Expand Down Expand Up @@ -39,3 +42,82 @@ def is_resource(self, path):

def files(self):
return ZipPath(self.archive, self.prefix)


class MultiplexedPath(abc.Traversable):
"""
Given a series of Traversable objects, implement a merged
version of the interface across all objects. Useful for
namespace packages which may be multihomed at a single
name.
"""
def __init__(self, *paths):
self._paths = list(map(Path, set(paths)))
if not self._paths:
message = 'MultiplexedPath must contain at least one path'
raise FileNotFoundError(message)
if any(not path.is_dir() for path in self._paths):
raise NotADirectoryError(
'MultiplexedPath only supports directories')

def iterdir(self):
visited = []
for path in self._paths:
for file in path.iterdir():
if file.name in visited:
continue
visited.append(file.name)
yield file

def read_bytes(self):
return self.open(mode='rb').read()

def read_text(self, *args, **kwargs):
return self.open(mode='r', *args, **kwargs).read()

def is_dir(self):
return True

def is_file(self):
return False

def joinpath(self, child):
# first try to find child in current paths
for file in self.iterdir():
if file.name == child:
return file
# if it does not exist, construct it with the first path
return Path(os.path.join(self._paths[0], child))

__truediv__ = joinpath

def open(self, *args, **kwargs):
for path in self._paths[:-1]:
with suppress(Exception):
return path.open(*args, **kwargs)
return self._paths[-1].open(*args, **kwargs)

def name(self):
return os.path.basename(self._paths[0])

def __repr__(self):
return 'MultiplexedPath({})'.format(
', '.join("'{}'".format(path) for path in self._paths))


class NamespaceReader(abc.TraversableResources):
def __init__(self, namespace_path):
if 'NamespacePath' not in str(namespace_path):
raise ValueError('Invalid path')
self.path = MultiplexedPath(*list(namespace_path))

def resource_path(self, resource):
"""
Return the file system path to prevent
`resources.path()` from creating a temporary
copy.
"""
return str(self.path.joinpath(resource))

def files(self):
return self.path
Empty file.
Empty file.
Empty file.
Empty file.
Binary file not shown.
Binary file not shown.
1 change: 1 addition & 0 deletions importlib_resources/tests/namespacedata01/utf-8.file
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello, UTF-8 world!
11 changes: 11 additions & 0 deletions importlib_resources/tests/test_open.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import sys
import unittest

import importlib_resources as resources
Expand Down Expand Up @@ -65,6 +66,16 @@ def setUp(self):
self.data = data01


@unittest.skipUnless(
sys.version_info[0] >= 3,
'namespace packages not available on Python 2'
)
class OpenDiskNamespaceTests(OpenTests, unittest.TestCase):
def setUp(self):
from . import namespacedata01
self.data = namespacedata01


class OpenZipTests(OpenTests, util.ZipSetup, unittest.TestCase):
pass

Expand Down
65 changes: 35 additions & 30 deletions importlib_resources/tests/test_resource.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os.path
import sys
import unittest
import importlib_resources as resources
Expand Down Expand Up @@ -140,36 +141,6 @@ def test_unrelated_contents(self):
{'__init__.py', 'resource2.txt'})


@unittest.skipIf(sys.version_info < (3,), 'No namespace packages in Python 2')
class NamespaceTest(unittest.TestCase):
def test_namespaces_cannot_have_resources(self):
contents = resources.contents(
'importlib_resources.tests.data03.namespace')
self.assertFalse(list(contents))
# Even though there is a file in the namespace directory, it is not
# considered a resource, since namespace packages can't have them.
self.assertFalse(resources.is_resource(
'importlib_resources.tests.data03.namespace',
'resource1.txt'))
# We should get an exception if we try to read it or open it.
self.assertRaises(
FileNotFoundError,
resources.open_text,
'importlib_resources.tests.data03.namespace', 'resource1.txt')
self.assertRaises(
FileNotFoundError,
resources.open_binary,
'importlib_resources.tests.data03.namespace', 'resource1.txt')
self.assertRaises(
FileNotFoundError,
resources.read_text,
'importlib_resources.tests.data03.namespace', 'resource1.txt')
self.assertRaises(
FileNotFoundError,
resources.read_binary,
'importlib_resources.tests.data03.namespace', 'resource1.txt')


class DeletingZipsTest(unittest.TestCase):
"""Having accessed resources in a zip file should not keep an open
reference to the zip.
Expand Down Expand Up @@ -245,5 +216,39 @@ def test_read_text_does_not_keep_open(self):
del c


@unittest.skipUnless(
sys.version_info[0] >= 3,
'namespace packages not available on Python 2'
)
class ResourceFromNamespaceTest01(unittest.TestCase):
@classmethod
def setUpClass(cls):
sys.path.append(os.path.abspath(os.path.join(__file__, '..')))

def test_is_submodule_resource(self):
self.assertTrue(
resources.is_resource(import_module('namespacedata01'), 'binary.file'))

def test_read_submodule_resource_by_name(self):
self.assertTrue(
resources.is_resource('namespacedata01', 'binary.file'))

def test_submodule_contents(self):
contents = set(resources.contents(import_module('namespacedata01')))
try:
contents.remove('__pycache__')
except KeyError:
pass
self.assertEqual(contents, {'binary.file', 'utf-8.file', 'utf-16.file'})

def test_submodule_contents_by_name(self):
contents = set(resources.contents('namespacedata01'))
try:
contents.remove('__pycache__')
except KeyError:
pass
self.assertEqual(contents, {'binary.file', 'utf-8.file', 'utf-16.file'})


if __name__ == '__main__':
unittest.main()