bpo-42129: Add support for resources in namespaces (GH-24670)

* Unify behavior in ResourceReaderDefaultsTests and align with the behavior found in importlib_resources.
* Equip NamespaceLoader with a NamespaceReader.
* Apply changes from importlib_resources 5.0.4
This commit is contained in:
Jason R. Coombs 2021-03-04 13:43:00 -05:00 committed by GitHub
parent fbf75b9997
commit 6714825414
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1315 additions and 916 deletions

View file

@ -0,0 +1,82 @@
from contextlib import suppress
from . import abc
class SpecLoaderAdapter:
"""
Adapt a package spec to adapt the underlying loader.
"""
def __init__(self, spec, adapter=lambda spec: spec.loader):
self.spec = spec
self.loader = adapter(spec)
def __getattr__(self, name):
return getattr(self.spec, name)
class TraversableResourcesLoader:
"""
Adapt a loader to provide TraversableResources.
"""
def __init__(self, spec):
self.spec = spec
def get_resource_reader(self, name):
return DegenerateFiles(self.spec)._native()
class DegenerateFiles:
"""
Adapter for an existing or non-existant resource reader
to provide a degenerate .files().
"""
class Path(abc.Traversable):
def iterdir(self):
return iter(())
def is_dir(self):
return False
is_file = exists = is_dir # type: ignore
def joinpath(self, other):
return DegenerateFiles.Path()
def name(self):
return ''
def open(self):
raise ValueError()
def __init__(self, spec):
self.spec = spec
@property
def _reader(self):
with suppress(AttributeError):
return self.spec.loader.get_resource_reader(self.spec.name)
def _native(self):
"""
Return the native reader if it supports files().
"""
reader = self._reader
return reader if hasattr(reader, 'files') else self
def __getattr__(self, attr):
return getattr(self._reader, attr)
def files(self):
return DegenerateFiles.Path()
def wrap_spec(package):
"""
Construct a package spec with traversable compatibility
on the spec/loader/reader.
"""
return SpecLoaderAdapter(package.__spec__, TraversableResourcesLoader)

View file

@ -1259,6 +1259,10 @@ class _NamespaceLoader:
# Warning implemented in _load_module_shim(). # Warning implemented in _load_module_shim().
return _bootstrap._load_module_shim(self, fullname) return _bootstrap._load_module_shim(self, fullname)
def get_resource_reader(self, module):
from importlib.readers import NamespaceReader
return NamespaceReader(self._path)
# Finders ##################################################################### # Finders #####################################################################

View file

@ -9,6 +9,8 @@ import importlib
from typing import Union, Any, Optional from typing import Union, Any, Optional
from .abc import ResourceReader from .abc import ResourceReader
from ._adapters import wrap_spec
Package = Union[types.ModuleType, str] Package = Union[types.ModuleType, str]
@ -43,18 +45,15 @@ def get_resource_reader(package):
# zipimport.zipimporter does not support weak references, resulting in a # zipimport.zipimporter does not support weak references, resulting in a
# TypeError. That seems terrible. # TypeError. That seems terrible.
spec = package.__spec__ spec = package.__spec__
reader = getattr(spec.loader, 'get_resource_reader', None) reader = getattr(spec.loader, 'get_resource_reader', None) # type: ignore
if reader is None: if reader is None:
return None return None
return reader(spec.name) return reader(spec.name) # type: ignore
def resolve(cand): def resolve(cand):
# type: (Package) -> types.ModuleType # type: (Package) -> types.ModuleType
return ( return cand if isinstance(cand, types.ModuleType) else importlib.import_module(cand)
cand if isinstance(cand, types.ModuleType)
else importlib.import_module(cand)
)
def get_package(package): def get_package(package):
@ -64,7 +63,7 @@ def get_package(package):
Raise an exception if the resolved module is not a package. Raise an exception if the resolved module is not a package.
""" """
resolved = resolve(package) resolved = resolve(package)
if resolved.__spec__.submodule_search_locations is None: if wrap_spec(resolved).submodule_search_locations is None:
raise TypeError('{!r} is not a package'.format(package)) raise TypeError('{!r} is not a package'.format(package))
return resolved return resolved
@ -74,7 +73,7 @@ def from_package(package):
Return a Traversable object for the given package. Return a Traversable object for the given package.
""" """
spec = package.__spec__ spec = wrap_spec(package)
reader = spec.loader.get_resource_reader(spec.name) reader = spec.loader.get_resource_reader(spec.name)
return reader.files() return reader.files()

View file

@ -14,6 +14,7 @@ except ImportError:
from ._abc import Loader from ._abc import Loader
import abc import abc
import warnings import warnings
from typing import BinaryIO, Iterable, Text
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
@ -297,49 +298,45 @@ _register(SourceLoader, machinery.SourceFileLoader)
class ResourceReader(metaclass=abc.ABCMeta): class ResourceReader(metaclass=abc.ABCMeta):
"""Abstract base class for loaders to provide resource reading support."""
"""Abstract base class to provide resource-reading support.
Loaders that support resource reading are expected to implement
the ``get_resource_reader(fullname)`` method and have it either return None
or an object compatible with this ABC.
"""
@abc.abstractmethod @abc.abstractmethod
def open_resource(self, resource): def open_resource(self, resource: Text) -> BinaryIO:
"""Return an opened, file-like object for binary reading. """Return an opened, file-like object for binary reading.
The 'resource' argument is expected to represent only a file name The 'resource' argument is expected to represent only a file name.
and thus not contain any subdirectory components.
If the resource cannot be found, FileNotFoundError is raised. If the resource cannot be found, FileNotFoundError is raised.
""" """
# This deliberately raises FileNotFoundError instead of
# NotImplementedError so that if this method is accidentally called,
# it'll still do the right thing.
raise FileNotFoundError raise FileNotFoundError
@abc.abstractmethod @abc.abstractmethod
def resource_path(self, resource): def resource_path(self, resource: Text) -> Text:
"""Return the file system path to the specified resource. """Return the file system path to the specified resource.
The 'resource' argument is expected to represent only a file name The 'resource' argument is expected to represent only a file name.
and thus not contain any subdirectory components.
If the resource does not exist on the file system, raise If the resource does not exist on the file system, raise
FileNotFoundError. FileNotFoundError.
""" """
# This deliberately raises FileNotFoundError instead of
# NotImplementedError so that if this method is accidentally called,
# it'll still do the right thing.
raise FileNotFoundError raise FileNotFoundError
@abc.abstractmethod @abc.abstractmethod
def is_resource(self, name): def is_resource(self, path: Text) -> bool:
"""Return True if the named 'name' is consider a resource.""" """Return True if the named 'path' is a resource.
Files are resources, directories are not.
"""
raise FileNotFoundError raise FileNotFoundError
@abc.abstractmethod @abc.abstractmethod
def contents(self): def contents(self) -> Iterable[str]:
"""Return an iterable of strings over the contents of the package.""" """Return an iterable of entries in `package`."""
return [] raise FileNotFoundError
_register(ResourceReader, machinery.SourceFileLoader)
@runtime_checkable @runtime_checkable
@ -355,26 +352,28 @@ class Traversable(Protocol):
Yield Traversable objects in self Yield Traversable objects in self
""" """
@abc.abstractmethod
def read_bytes(self): def read_bytes(self):
""" """
Read contents of self as bytes Read contents of self as bytes
""" """
with self.open('rb') as strm:
return strm.read()
@abc.abstractmethod
def read_text(self, encoding=None): def read_text(self, encoding=None):
""" """
Read contents of self as bytes Read contents of self as text
""" """
with self.open(encoding=encoding) as strm:
return strm.read()
@abc.abstractmethod @abc.abstractmethod
def is_dir(self): def is_dir(self) -> bool:
""" """
Return True if self is a dir Return True if self is a dir
""" """
@abc.abstractmethod @abc.abstractmethod
def is_file(self): def is_file(self) -> bool:
""" """
Return True if self is a file Return True if self is a file
""" """
@ -385,11 +384,11 @@ class Traversable(Protocol):
Return Traversable child in self Return Traversable child in self
""" """
@abc.abstractmethod
def __truediv__(self, child): def __truediv__(self, child):
""" """
Return Traversable child in self Return Traversable child in self
""" """
return self.joinpath(child)
@abc.abstractmethod @abc.abstractmethod
def open(self, mode='r', *args, **kwargs): def open(self, mode='r', *args, **kwargs):
@ -402,14 +401,18 @@ class Traversable(Protocol):
""" """
@abc.abstractproperty @abc.abstractproperty
def name(self): def name(self) -> str:
# type: () -> str
""" """
The base name of this object without any parent references. The base name of this object without any parent references.
""" """
class TraversableResources(ResourceReader): class TraversableResources(ResourceReader):
"""
The required interface for providing traversable
resources.
"""
@abc.abstractmethod @abc.abstractmethod
def files(self): def files(self):
"""Return a Traversable object for the loaded package.""" """Return a Traversable object for the loaded package."""

View file

@ -1,8 +1,13 @@
import collections
import zipfile import zipfile
import pathlib import pathlib
from . import abc from . import abc
def remove_duplicates(items):
return iter(collections.OrderedDict.fromkeys(items))
class FileReader(abc.TraversableResources): class FileReader(abc.TraversableResources):
def __init__(self, loader): def __init__(self, loader):
self.path = pathlib.Path(loader.path).parent self.path = pathlib.Path(loader.path).parent
@ -39,3 +44,80 @@ class ZipReader(abc.TraversableResources):
def files(self): def files(self):
return zipfile.Path(self.archive, self.prefix) return zipfile.Path(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(pathlib.Path, remove_duplicates(paths)))
if not self._paths:
message = 'MultiplexedPath must contain at least one path'
raise FileNotFoundError(message)
if not all(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):
raise FileNotFoundError(f'{self} is not a file')
def read_text(self, *args, **kwargs):
raise FileNotFoundError(f'{self} is not a file')
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 self._paths[0] / child
__truediv__ = joinpath
def open(self, *args, **kwargs):
raise FileNotFoundError('{} is not a file'.format(self))
def name(self):
return self._paths[0].name
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

View file

@ -3,8 +3,10 @@ import io
from . import _common from . import _common
from ._common import as_file, files from ._common import as_file, files
from .abc import ResourceReader
from contextlib import suppress from contextlib import suppress
from importlib.abc import ResourceLoader from importlib.abc import ResourceLoader
from importlib.machinery import ModuleSpec
from io import BytesIO, TextIOWrapper from io import BytesIO, TextIOWrapper
from pathlib import Path from pathlib import Path
from types import ModuleType from types import ModuleType
@ -18,6 +20,7 @@ from functools import singledispatch
__all__ = [ __all__ = [
'Package', 'Package',
'Resource', 'Resource',
'ResourceReader',
'as_file', 'as_file',
'contents', 'contents',
'files', 'files',
@ -27,7 +30,7 @@ __all__ = [
'path', 'path',
'read_binary', 'read_binary',
'read_text', 'read_text',
] ]
Package = Union[str, ModuleType] Package = Union[str, ModuleType]
@ -41,36 +44,45 @@ def open_binary(package: Package, resource: Resource) -> BinaryIO:
reader = _common.get_resource_reader(package) reader = _common.get_resource_reader(package)
if reader is not None: if reader is not None:
return reader.open_resource(resource) return reader.open_resource(resource)
absolute_package_path = os.path.abspath( spec = cast(ModuleSpec, package.__spec__)
package.__spec__.origin or 'non-existent file') # Using pathlib doesn't work well here due to the lack of 'strict'
package_path = os.path.dirname(absolute_package_path) # argument for pathlib.Path.resolve() prior to Python 3.6.
full_path = os.path.join(package_path, resource) if spec.submodule_search_locations is not None:
try: paths = spec.submodule_search_locations
return open(full_path, mode='rb') elif spec.origin is not None:
except OSError: paths = [os.path.dirname(os.path.abspath(spec.origin))]
# Just assume the loader is a resource loader; all the relevant
# importlib.machinery loaders are and an AttributeError for for package_path in paths:
# get_data() will make it clear what is needed from the loader. full_path = os.path.join(package_path, resource)
loader = cast(ResourceLoader, package.__spec__.loader) try:
data = None return open(full_path, mode='rb')
if hasattr(package.__spec__.loader, 'get_data'): except OSError:
with suppress(OSError): # Just assume the loader is a resource loader; all the relevant
data = loader.get_data(full_path) # importlib.machinery loaders are and an AttributeError for
if data is None: # get_data() will make it clear what is needed from the loader.
package_name = package.__spec__.name loader = cast(ResourceLoader, spec.loader)
message = '{!r} resource not found in {!r}'.format( data = None
resource, package_name) if hasattr(spec.loader, 'get_data'):
raise FileNotFoundError(message) with suppress(OSError):
return BytesIO(data) data = loader.get_data(full_path)
if data is not None:
return BytesIO(data)
raise FileNotFoundError(
'{!r} resource not found in {!r}'.format(resource, spec.name)
)
def open_text(package: Package, def open_text(
resource: Resource, package: Package,
encoding: str = 'utf-8', resource: Resource,
errors: str = 'strict') -> TextIO: encoding: str = 'utf-8',
errors: str = 'strict',
) -> TextIO:
"""Return a file-like object opened for text reading of the resource.""" """Return a file-like object opened for text reading of the resource."""
return TextIOWrapper( return TextIOWrapper(
open_binary(package, resource), encoding=encoding, errors=errors) open_binary(package, resource), encoding=encoding, errors=errors
)
def read_binary(package: Package, resource: Resource) -> bytes: def read_binary(package: Package, resource: Resource) -> bytes:
@ -79,10 +91,12 @@ def read_binary(package: Package, resource: Resource) -> bytes:
return fp.read() return fp.read()
def read_text(package: Package, def read_text(
resource: Resource, package: Package,
encoding: str = 'utf-8', resource: Resource,
errors: str = 'strict') -> str: encoding: str = 'utf-8',
errors: str = 'strict',
) -> str:
"""Return the decoded string of the resource. """Return the decoded string of the resource.
The decoding-related arguments have the same semantics as those of The decoding-related arguments have the same semantics as those of
@ -93,8 +107,9 @@ def read_text(package: Package,
def path( def path(
package: Package, resource: Resource, package: Package,
) -> 'ContextManager[Path]': resource: Resource,
) -> 'ContextManager[Path]':
"""A context manager providing a file path object to the resource. """A context manager providing a file path object to the resource.
If the resource does not already exist on its own on the file system, If the resource does not already exist on its own on the file system,
@ -106,15 +121,17 @@ def path(
reader = _common.get_resource_reader(_common.get_package(package)) reader = _common.get_resource_reader(_common.get_package(package))
return ( return (
_path_from_reader(reader, _common.normalize_path(resource)) _path_from_reader(reader, _common.normalize_path(resource))
if reader else if reader
_common.as_file( else _common.as_file(
_common.files(package).joinpath(_common.normalize_path(resource))) _common.files(package).joinpath(_common.normalize_path(resource))
) )
)
def _path_from_reader(reader, resource): def _path_from_reader(reader, resource):
return _path_from_resource_path(reader, resource) or \ return _path_from_resource_path(reader, resource) or _path_from_open_resource(
_path_from_open_resource(reader, resource) reader, resource
)
def _path_from_resource_path(reader, resource): def _path_from_resource_path(reader, resource):
@ -154,15 +171,10 @@ def contents(package: Package) -> Iterable[str]:
reader = _common.get_resource_reader(package) reader = _common.get_resource_reader(package)
if reader is not None: if reader is not None:
return _ensure_sequence(reader.contents()) return _ensure_sequence(reader.contents())
# Is the package a namespace package? By definition, namespace packages transversable = _common.from_package(package)
# cannot have resources. if transversable.is_dir():
namespace = ( return list(item.name for item in transversable.iterdir())
package.__spec__.origin is None or return []
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())
@singledispatch @singledispatch

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
Hello, UTF-8 world!

View file

@ -338,7 +338,9 @@ class ResourceReaderDefaultsTests(ABCTestHarness):
self.ins.is_resource('dummy_file') self.ins.is_resource('dummy_file')
def test_contents(self): def test_contents(self):
self.assertEqual([], list(self.ins.contents())) with self.assertRaises(FileNotFoundError):
self.ins.contents()
(Frozen_RRDefaultTests, (Frozen_RRDefaultTests,
Source_RRDefaultsTests Source_RRDefaultsTests

View file

@ -21,7 +21,7 @@ class FilesTests:
@unittest.skipUnless( @unittest.skipUnless(
hasattr(typing, 'runtime_checkable'), hasattr(typing, 'runtime_checkable'),
"Only suitable when typing supports runtime_checkable", "Only suitable when typing supports runtime_checkable",
) )
def test_traversable(self): def test_traversable(self):
assert isinstance(resources.files(self.data), Traversable) assert isinstance(resources.files(self.data), Traversable)

View file

@ -29,34 +29,32 @@ class OpenTests:
self.assertEqual(result, 'Hello, UTF-8 world!\n') self.assertEqual(result, 'Hello, UTF-8 world!\n')
def test_open_text_given_encoding(self): def test_open_text_given_encoding(self):
with resources.open_text( with resources.open_text(self.data, 'utf-16.file', 'utf-16', 'strict') as fp:
self.data, 'utf-16.file', 'utf-16', 'strict') as fp:
result = fp.read() result = fp.read()
self.assertEqual(result, 'Hello, UTF-16 world!\n') self.assertEqual(result, 'Hello, UTF-16 world!\n')
def test_open_text_with_errors(self): def test_open_text_with_errors(self):
# Raises UnicodeError without the 'errors' argument. # Raises UnicodeError without the 'errors' argument.
with resources.open_text( with resources.open_text(self.data, 'utf-16.file', 'utf-8', 'strict') as fp:
self.data, 'utf-16.file', 'utf-8', 'strict') as fp:
self.assertRaises(UnicodeError, fp.read) self.assertRaises(UnicodeError, fp.read)
with resources.open_text( with resources.open_text(self.data, 'utf-16.file', 'utf-8', 'ignore') as fp:
self.data, 'utf-16.file', 'utf-8', 'ignore') as fp:
result = fp.read() result = fp.read()
self.assertEqual( self.assertEqual(
result, result,
'H\x00e\x00l\x00l\x00o\x00,\x00 ' 'H\x00e\x00l\x00l\x00o\x00,\x00 '
'\x00U\x00T\x00F\x00-\x001\x006\x00 ' '\x00U\x00T\x00F\x00-\x001\x006\x00 '
'\x00w\x00o\x00r\x00l\x00d\x00!\x00\n\x00') '\x00w\x00o\x00r\x00l\x00d\x00!\x00\n\x00',
)
def test_open_binary_FileNotFoundError(self): def test_open_binary_FileNotFoundError(self):
self.assertRaises( self.assertRaises(
FileNotFoundError, FileNotFoundError, resources.open_binary, self.data, 'does-not-exist'
resources.open_binary, self.data, 'does-not-exist') )
def test_open_text_FileNotFoundError(self): def test_open_text_FileNotFoundError(self):
self.assertRaises( self.assertRaises(
FileNotFoundError, FileNotFoundError, resources.open_text, self.data, 'does-not-exist'
resources.open_text, self.data, 'does-not-exist') )
class OpenDiskTests(OpenTests, unittest.TestCase): class OpenDiskTests(OpenTests, unittest.TestCase):
@ -64,6 +62,13 @@ class OpenDiskTests(OpenTests, unittest.TestCase):
self.data = data01 self.data = data01
class OpenDiskNamespaceTests(OpenTests, unittest.TestCase):
def setUp(self):
from . import namespacedata01
self.data = namespacedata01
class OpenZipTests(OpenTests, util.ZipSetup, unittest.TestCase): class OpenZipTests(OpenTests, util.ZipSetup, unittest.TestCase):
pass pass

View file

@ -1,3 +1,4 @@
import io
import unittest import unittest
from importlib import resources from importlib import resources
@ -37,6 +38,17 @@ class PathDiskTests(PathTests, unittest.TestCase):
assert 'data' in str(path) assert 'data' in str(path)
class PathMemoryTests(PathTests, unittest.TestCase):
def setUp(self):
file = io.BytesIO(b'Hello, UTF-8 world!\n')
self.addCleanup(file.close)
self.data = util.create_package(
file=file, path=FileNotFoundError("package exists only in memory")
)
self.data.__spec__.origin = None
self.data.__spec__.has_location = False
class PathZipTests(PathTests, util.ZipSetup, unittest.TestCase): class PathZipTests(PathTests, util.ZipSetup, unittest.TestCase):
def test_remove_in_context_manager(self): def test_remove_in_context_manager(self):
# It is not an error if the file that was temporarily stashed on the # It is not an error if the file that was temporarily stashed on the

View file

@ -25,20 +25,19 @@ class ReadTests:
self.assertEqual(result, 'Hello, UTF-8 world!\n') self.assertEqual(result, 'Hello, UTF-8 world!\n')
def test_read_text_given_encoding(self): def test_read_text_given_encoding(self):
result = resources.read_text( result = resources.read_text(self.data, 'utf-16.file', encoding='utf-16')
self.data, 'utf-16.file', encoding='utf-16')
self.assertEqual(result, 'Hello, UTF-16 world!\n') self.assertEqual(result, 'Hello, UTF-16 world!\n')
def test_read_text_with_errors(self): def test_read_text_with_errors(self):
# Raises UnicodeError without the 'errors' argument. # Raises UnicodeError without the 'errors' argument.
self.assertRaises( self.assertRaises(UnicodeError, resources.read_text, self.data, 'utf-16.file')
UnicodeError, resources.read_text, self.data, 'utf-16.file')
result = resources.read_text(self.data, 'utf-16.file', errors='ignore') result = resources.read_text(self.data, 'utf-16.file', errors='ignore')
self.assertEqual( self.assertEqual(
result, result,
'H\x00e\x00l\x00l\x00o\x00,\x00 ' 'H\x00e\x00l\x00l\x00o\x00,\x00 '
'\x00U\x00T\x00F\x00-\x001\x006\x00 ' '\x00U\x00T\x00F\x00-\x001\x006\x00 '
'\x00w\x00o\x00r\x00l\x00d\x00!\x00\n\x00') '\x00w\x00o\x00r\x00l\x00d\x00!\x00\n\x00',
)
class ReadDiskTests(ReadTests, unittest.TestCase): class ReadDiskTests(ReadTests, unittest.TestCase):
@ -48,13 +47,11 @@ class ReadDiskTests(ReadTests, unittest.TestCase):
class ReadZipTests(ReadTests, util.ZipSetup, unittest.TestCase): class ReadZipTests(ReadTests, util.ZipSetup, unittest.TestCase):
def test_read_submodule_resource(self): def test_read_submodule_resource(self):
submodule = import_module('ziptestdata.subdirectory') submodule = import_module('ziptestdata.subdirectory')
result = resources.read_binary( result = resources.read_binary(submodule, 'binary.file')
submodule, 'binary.file')
self.assertEqual(result, b'\0\1\2\3') self.assertEqual(result, b'\0\1\2\3')
def test_read_submodule_resource_by_name(self): def test_read_submodule_resource_by_name(self):
result = resources.read_binary( result = resources.read_binary('ziptestdata.subdirectory', 'binary.file')
'ziptestdata.subdirectory', 'binary.file')
self.assertEqual(result, b'\0\1\2\3') self.assertEqual(result, b'\0\1\2\3')

View file

@ -0,0 +1,123 @@
import os.path
import sys
import pathlib
import unittest
from importlib import import_module
from importlib.readers import MultiplexedPath, NamespaceReader
class MultiplexedPathTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
path = pathlib.Path(__file__).parent / 'namespacedata01'
cls.folder = str(path)
def test_init_no_paths(self):
with self.assertRaises(FileNotFoundError):
MultiplexedPath()
def test_init_file(self):
with self.assertRaises(NotADirectoryError):
MultiplexedPath(os.path.join(self.folder, 'binary.file'))
def test_iterdir(self):
contents = {path.name for path in MultiplexedPath(self.folder).iterdir()}
try:
contents.remove('__pycache__')
except (KeyError, ValueError):
pass
self.assertEqual(contents, {'binary.file', 'utf-16.file', 'utf-8.file'})
def test_iterdir_duplicate(self):
data01 = os.path.abspath(os.path.join(__file__, '..', 'data01'))
contents = {
path.name for path in MultiplexedPath(self.folder, data01).iterdir()
}
for remove in ('__pycache__', '__init__.pyc'):
try:
contents.remove(remove)
except (KeyError, ValueError):
pass
self.assertEqual(
contents,
{'__init__.py', 'binary.file', 'subdirectory', 'utf-16.file', 'utf-8.file'},
)
def test_is_dir(self):
self.assertEqual(MultiplexedPath(self.folder).is_dir(), True)
def test_is_file(self):
self.assertEqual(MultiplexedPath(self.folder).is_file(), False)
def test_open_file(self):
path = MultiplexedPath(self.folder)
with self.assertRaises(FileNotFoundError):
path.read_bytes()
with self.assertRaises(FileNotFoundError):
path.read_text()
with self.assertRaises(FileNotFoundError):
path.open()
def test_join_path(self):
print('test_join_path')
prefix = os.path.abspath(os.path.join(__file__, '..'))
data01 = os.path.join(prefix, 'data01')
path = MultiplexedPath(self.folder, data01)
self.assertEqual(
str(path.joinpath('binary.file'))[len(prefix) + 1 :],
os.path.join('namespacedata01', 'binary.file'),
)
self.assertEqual(
str(path.joinpath('subdirectory'))[len(prefix) + 1 :],
os.path.join('data01', 'subdirectory'),
)
self.assertEqual(
str(path.joinpath('imaginary'))[len(prefix) + 1 :],
os.path.join('namespacedata01', 'imaginary'),
)
def test_repr(self):
self.assertEqual(
repr(MultiplexedPath(self.folder)),
"MultiplexedPath('{}')".format(self.folder),
)
class NamespaceReaderTest(unittest.TestCase):
site_dir = str(pathlib.Path(__file__).parent)
@classmethod
def setUpClass(cls):
sys.path.append(cls.site_dir)
@classmethod
def tearDownClass(cls):
sys.path.remove(cls.site_dir)
def test_init_error(self):
with self.assertRaises(ValueError):
NamespaceReader(['path1', 'path2'])
def test_resource_path(self):
namespacedata01 = import_module('namespacedata01')
reader = NamespaceReader(namespacedata01.__spec__.submodule_search_locations)
root = os.path.abspath(os.path.join(__file__, '..', 'namespacedata01'))
self.assertEqual(
reader.resource_path('binary.file'), os.path.join(root, 'binary.file')
)
self.assertEqual(
reader.resource_path('imaginary'), os.path.join(root, 'imaginary')
)
def test_files(self):
namespacedata01 = import_module('namespacedata01')
reader = NamespaceReader(namespacedata01.__spec__.submodule_search_locations)
root = os.path.abspath(os.path.join(__file__, '..', 'namespacedata01'))
self.assertIsInstance(reader.files(), MultiplexedPath)
self.assertEqual(repr(reader.files()), "MultiplexedPath('{}')".format(root))
if __name__ == '__main__':
unittest.main()

View file

@ -27,20 +27,21 @@ class ResourceTests:
def test_contents(self): def test_contents(self):
contents = set(resources.contents(self.data)) contents = set(resources.contents(self.data))
# There may be cruft in the directory listing of the data directory. # There may be cruft in the directory listing of the data directory.
# Under Python 3 we could have a __pycache__ directory, and under # It could have a __pycache__ directory,
# Python 2 we could have .pyc files. These are both artifacts of the # an artifact of the
# test suite importing these modules and writing these caches. They # test suite importing these modules, which
# aren't germane to this test, so just filter them out. # are not germane to this test, so just filter them out.
contents.discard('__pycache__') contents.discard('__pycache__')
contents.discard('__init__.pyc') self.assertEqual(
contents.discard('__init__.pyo') contents,
self.assertEqual(contents, { {
'__init__.py', '__init__.py',
'subdirectory', 'subdirectory',
'utf-8.file', 'utf-8.file',
'binary.file', 'binary.file',
'utf-16.file', 'utf-16.file',
}) },
)
class ResourceDiskTests(ResourceTests, unittest.TestCase): class ResourceDiskTests(ResourceTests, unittest.TestCase):
@ -55,27 +56,26 @@ class ResourceZipTests(ResourceTests, util.ZipSetup, unittest.TestCase):
class ResourceLoaderTests(unittest.TestCase): class ResourceLoaderTests(unittest.TestCase):
def test_resource_contents(self): def test_resource_contents(self):
package = util.create_package( package = util.create_package(
file=data01, path=data01.__file__, contents=['A', 'B', 'C']) file=data01, path=data01.__file__, contents=['A', 'B', 'C']
self.assertEqual( )
set(resources.contents(package)), self.assertEqual(set(resources.contents(package)), {'A', 'B', 'C'})
{'A', 'B', 'C'})
def test_resource_is_resource(self): def test_resource_is_resource(self):
package = util.create_package( package = util.create_package(
file=data01, path=data01.__file__, file=data01, path=data01.__file__, contents=['A', 'B', 'C', 'D/E', 'D/F']
contents=['A', 'B', 'C', 'D/E', 'D/F']) )
self.assertTrue(resources.is_resource(package, 'B')) self.assertTrue(resources.is_resource(package, 'B'))
def test_resource_directory_is_not_resource(self): def test_resource_directory_is_not_resource(self):
package = util.create_package( package = util.create_package(
file=data01, path=data01.__file__, file=data01, path=data01.__file__, contents=['A', 'B', 'C', 'D/E', 'D/F']
contents=['A', 'B', 'C', 'D/E', 'D/F']) )
self.assertFalse(resources.is_resource(package, 'D')) self.assertFalse(resources.is_resource(package, 'D'))
def test_resource_missing_is_not_resource(self): def test_resource_missing_is_not_resource(self):
package = util.create_package( package = util.create_package(
file=data01, path=data01.__file__, file=data01, path=data01.__file__, contents=['A', 'B', 'C', 'D/E', 'D/F']
contents=['A', 'B', 'C', 'D/E', 'D/F']) )
self.assertFalse(resources.is_resource(package, 'Z')) self.assertFalse(resources.is_resource(package, 'Z'))
@ -86,90 +86,63 @@ class ResourceCornerCaseTests(unittest.TestCase):
# 2. Are not on the file system # 2. Are not on the file system
# 3. Are not in a zip file # 3. Are not in a zip file
module = util.create_package( module = util.create_package(
file=data01, path=data01.__file__, contents=['A', 'B', 'C']) file=data01, path=data01.__file__, contents=['A', 'B', 'C']
)
# Give the module a dummy loader. # Give the module a dummy loader.
module.__loader__ = object() module.__loader__ = object()
# Give the module a dummy origin. # Give the module a dummy origin.
module.__file__ = '/path/which/shall/not/be/named' module.__file__ = '/path/which/shall/not/be/named'
if sys.version_info >= (3,): module.__spec__.loader = module.__loader__
module.__spec__.loader = module.__loader__ module.__spec__.origin = module.__file__
module.__spec__.origin = module.__file__
self.assertFalse(resources.is_resource(module, 'A')) self.assertFalse(resources.is_resource(module, 'A'))
class ResourceFromZipsTest(util.ZipSetupBase, unittest.TestCase): class ResourceFromZipsTest01(util.ZipSetupBase, unittest.TestCase):
ZIP_MODULE = zipdata02 # type: ignore ZIP_MODULE = zipdata01 # type: ignore
def test_unrelated_contents(self):
# https://gitlab.com/python-devs/importlib_resources/issues/44
#
# Here we have a zip file with two unrelated subpackages. The bug
# reports that getting the contents of a resource returns unrelated
# files.
self.assertEqual(
set(resources.contents('ziptestdata.one')),
{'__init__.py', 'resource1.txt'})
self.assertEqual(
set(resources.contents('ziptestdata.two')),
{'__init__.py', 'resource2.txt'})
class SubdirectoryResourceFromZipsTest(util.ZipSetupBase, unittest.TestCase):
ZIP_MODULE = zipdata01 # type: ignore
def test_is_submodule_resource(self): def test_is_submodule_resource(self):
submodule = import_module('ziptestdata.subdirectory') submodule = import_module('ziptestdata.subdirectory')
self.assertTrue( self.assertTrue(resources.is_resource(submodule, 'binary.file'))
resources.is_resource(submodule, 'binary.file'))
def test_read_submodule_resource_by_name(self): def test_read_submodule_resource_by_name(self):
self.assertTrue( self.assertTrue(
resources.is_resource('ziptestdata.subdirectory', 'binary.file')) resources.is_resource('ziptestdata.subdirectory', 'binary.file')
)
def test_submodule_contents(self): def test_submodule_contents(self):
submodule = import_module('ziptestdata.subdirectory') submodule = import_module('ziptestdata.subdirectory')
self.assertEqual( self.assertEqual(
set(resources.contents(submodule)), set(resources.contents(submodule)), {'__init__.py', 'binary.file'}
{'__init__.py', 'binary.file'}) )
def test_submodule_contents_by_name(self): def test_submodule_contents_by_name(self):
self.assertEqual( self.assertEqual(
set(resources.contents('ziptestdata.subdirectory')), set(resources.contents('ziptestdata.subdirectory')),
{'__init__.py', 'binary.file'}) {'__init__.py', 'binary.file'},
)
class NamespaceTest(unittest.TestCase): class ResourceFromZipsTest02(util.ZipSetupBase, unittest.TestCase):
def test_namespaces_cannot_have_resources(self): ZIP_MODULE = zipdata02 # type: ignore
contents = resources.contents('test.test_importlib.data03.namespace')
self.assertFalse(list(contents)) def test_unrelated_contents(self):
# Even though there is a file in the namespace directory, it is not """
# considered a resource, since namespace packages can't have them. Test thata zip with two unrelated subpackages return
self.assertFalse(resources.is_resource( distinct resources. Ref python/importlib_resources#44.
'test.test_importlib.data03.namespace', """
'resource1.txt')) self.assertEqual(
# We should get an exception if we try to read it or open it. set(resources.contents('ziptestdata.one')), {'__init__.py', 'resource1.txt'}
self.assertRaises( )
FileNotFoundError, self.assertEqual(
resources.open_text, set(resources.contents('ziptestdata.two')), {'__init__.py', 'resource2.txt'}
'test.test_importlib.data03.namespace', 'resource1.txt') )
self.assertRaises(
FileNotFoundError,
resources.open_binary,
'test.test_importlib.data03.namespace', 'resource1.txt')
self.assertRaises(
FileNotFoundError,
resources.read_text,
'test.test_importlib.data03.namespace', 'resource1.txt')
self.assertRaises(
FileNotFoundError,
resources.read_binary,
'test.test_importlib.data03.namespace', 'resource1.txt')
class DeletingZipsTest(unittest.TestCase): class DeletingZipsTest(unittest.TestCase):
"""Having accessed resources in a zip file should not keep an open """Having accessed resources in a zip file should not keep an open
reference to the zip. reference to the zip.
""" """
ZIP_MODULE = zipdata01 ZIP_MODULE = zipdata01
def setUp(self): def setUp(self):
@ -241,5 +214,41 @@ class DeletingZipsTest(unittest.TestCase):
del c del c
class ResourceFromNamespaceTest01(unittest.TestCase):
site_dir = str(pathlib.Path(__file__).parent)
@classmethod
def setUpClass(cls):
sys.path.append(cls.site_dir)
@classmethod
def tearDownClass(cls):
sys.path.remove(cls.site_dir)
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__': if __name__ == '__main__':
unittest.main() unittest.main()

View file

@ -0,0 +1,53 @@
"""
Generate the zip test data files.
Run to build the tests/zipdataNN/ziptestdata.zip files from
files in tests/dataNN.
Replaces the file with the working copy, but does commit anything
to the source repo.
"""
import contextlib
import os
import pathlib
import zipfile
def main():
"""
>>> from unittest import mock
>>> monkeypatch = getfixture('monkeypatch')
>>> monkeypatch.setattr(zipfile, 'ZipFile', mock.MagicMock())
>>> print(); main() # print workaround for bpo-32509
<BLANKLINE>
...data01... -> ziptestdata/...
...
...data02... -> ziptestdata/...
...
"""
suffixes = '01', '02'
tuple(map(generate, suffixes))
def generate(suffix):
root = pathlib.Path(__file__).parent.relative_to(os.getcwd())
zfpath = root / f'zipdata{suffix}/ziptestdata.zip'
with zipfile.ZipFile(zfpath, 'w') as zf:
for src, rel in walk(root / f'data{suffix}'):
dst = 'ziptestdata' / pathlib.PurePosixPath(rel.as_posix())
print(src, '->', dst)
zf.write(src, dst)
def walk(datapath):
for dirpath, dirnames, filenames in os.walk(datapath):
with contextlib.suppress(KeyError):
dirnames.remove('__pycache__')
for filename in filenames:
res = pathlib.Path(dirpath) / filename
rel = res.relative_to(datapath)
yield res, rel
__name__ == '__main__' and main()

View file

@ -0,0 +1,3 @@
``importlib.resources`` now honors namespace packages, merging resources
from each location in the namespace as introduced in
``importlib_resources`` 3.2 and including incidental changes through 5.0.3.

File diff suppressed because it is too large Load diff