From bd4d6ffaa8d03159685503f5e728ad567932afbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Wed, 12 May 2021 17:07:11 +0100 Subject: [PATCH 01/11] use files() API in contents() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- importlib_resources/__init__.py | 2 +- importlib_resources/_common.py | 15 ++++++++++++++- importlib_resources/_py3.py | 21 ++------------------- importlib_resources/tests/test_resource.py | 10 +++++----- 4 files changed, 22 insertions(+), 26 deletions(-) diff --git a/importlib_resources/__init__.py b/importlib_resources/__init__.py index ea4c7ef..44e096a 100644 --- a/importlib_resources/__init__.py +++ b/importlib_resources/__init__.py @@ -3,12 +3,12 @@ from ._common import ( as_file, files, + contents, ) from importlib_resources._py3 import ( Package, Resource, - contents, is_resource, open_binary, open_text, diff --git a/importlib_resources/_common.py b/importlib_resources/_common.py index d3223bd..63f2642 100644 --- a/importlib_resources/_common.py +++ b/importlib_resources/_common.py @@ -6,7 +6,7 @@ import types import importlib -from typing import Union, Any, Optional +from typing import Union, Any, Optional, Iterable from .abc import ResourceReader, Traversable from ._compat import wrap_spec @@ -113,3 +113,16 @@ def _(path): Degenerate behavior for pathlib.Path objects. """ yield path + + +# legacy API + + +def contents(package: Package) -> Iterable[str]: + """Return an iterable of entries in `package`. + + Note that not all entries are resources. Specifically, directories are + not considered resources. Use `is_resource()` on each entry returned here + to check if it is a resource or not. + """ + return [path.name for path in files(package).iterdir()] diff --git a/importlib_resources/_py3.py b/importlib_resources/_py3.py index 7f42125..d52ada3 100644 --- a/importlib_resources/_py3.py +++ b/importlib_resources/_py3.py @@ -8,7 +8,7 @@ from io import BytesIO, TextIOWrapper from pathlib import Path from types import ModuleType -from typing import ContextManager, Iterable, Union +from typing import ContextManager, Union from typing import cast from typing.io import BinaryIO, TextIO from collections.abc import Sequence @@ -133,29 +133,12 @@ def is_resource(package: Package, name: str) -> bool: reader = _common.get_resource_reader(package) if reader is not None: return reader.is_resource(name) - package_contents = set(contents(package)) + package_contents = set(_common.contents(package)) if name not in package_contents: return False return (_common.from_package(package) / name).is_file() -def contents(package: Package) -> Iterable[str]: - """Return an iterable of entries in `package`. - - Note that not all entries are resources. Specifically, directories are - not considered resources. Use `is_resource()` on each entry returned here - to check if it is a resource or not. - """ - package = _common.get_package(package) - reader = _common.get_resource_reader(package) - if reader is not None: - return _ensure_sequence(reader.contents()) - traversable = _common.from_package(package) - if traversable.is_dir(): - return list(item.name for item in traversable.iterdir()) - return [] - - @singledispatch def _ensure_sequence(iterable): return list(iterable) diff --git a/importlib_resources/tests/test_resource.py b/importlib_resources/tests/test_resource.py index 75748e6..071cb8d 100644 --- a/importlib_resources/tests/test_resource.py +++ b/importlib_resources/tests/test_resource.py @@ -33,14 +33,14 @@ def test_contents(self): # are not germane to this test, so just filter them out. contents.discard('__pycache__') self.assertEqual( - contents, - { + sorted(contents), + [ '__init__.py', - 'subdirectory', - 'utf-8.file', 'binary.file', + 'subdirectory', 'utf-16.file', - }, + 'utf-8.file', + ], ) From 4ff68cfc7ed2b4952c86887a43dc5ad0f8db1bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Thu, 13 May 2021 01:40:23 +0100 Subject: [PATCH 02/11] _adapters: replace DegenerateFiles with CompatibilityFiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- importlib_resources/_adapters.py | 108 ++++++++++++++++++++++++++++--- importlib_resources/_compat.py | 3 +- 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/importlib_resources/_adapters.py b/importlib_resources/_adapters.py index eedde49..9907b14 100644 --- a/importlib_resources/_adapters.py +++ b/importlib_resources/_adapters.py @@ -1,4 +1,5 @@ from contextlib import suppress +from io import TextIOWrapper from . import abc @@ -25,32 +26,119 @@ def __init__(self, spec): self.spec = spec def get_resource_reader(self, name): - return DegenerateFiles(self.spec)._native() + return CompatibilityFiles(self.spec)._native() -class DegenerateFiles: +def _io_wrapper(file, mode='r', *args, **kwargs): + if mode == 'r': + return TextIOWrapper(file, *args, **kwargs) + elif mode == 'rb': + return file + raise ValueError( + "Invalid mode value '{}', only 'r' and 'rb' are supported".format(mode) + ) + + +class CompatibilityFiles: """ Adapter for an existing or non-existant resource reader - to provide a degenerate .files(). + to provide a compability .files(). """ - class Path(abc.Traversable): + class SpecPath(abc.Traversable): + """ + Path tied to a module spec. + Can be read and exposes the resource reader children. + """ + + def __init__(self, spec, reader): + self._spec = spec + self._reader = reader + + def iterdir(self): + if not self._reader: + return iter(()) + return iter( + CompatibilityFiles.ChildPath(self._reader, path) + for path in self._reader.contents() + ) + + def is_file(self): + return False + + is_dir = is_file + + def joinpath(self, other): + if not self._reader: + return CompatibilityFiles.OrphanPath(other) + return CompatibilityFiles.ChildPath(self._reader, other) + + @property + def name(self): + return self._spec.name + + def open(self, mode='r', *args, **kwargs): + return _io_wrapper(self._reader.open_resource(None), mode, *args, **kwargs) + + class ChildPath(abc.Traversable): + """ + Path tied to a resource reader child. + Can be read but doesn't expose any meaningfull children. + """ + + def __init__(self, reader, name): + self._reader = reader + self._name = name + def iterdir(self): return iter(()) + def is_file(self): + return self._reader.is_resource(self.name) + def is_dir(self): + return not self.is_file() + + def joinpath(self, other): + return CompatibilityFiles.OrphanPath(self.name, other) + + @property + def name(self): + return self._name + + def open(self, mode='r', *args, **kwargs): + return _io_wrapper( + self._reader.open_resource(self.name), mode, *args, **kwargs + ) + + class OrphanPath(abc.Traversable): + """ + Orphan path, not tied to a module spec or resource reader. + Can't be read and doesn't expose any meaningful children. + """ + + def __init__(self, *path_parts): + if len(path_parts) < 1: + raise ValueError('Need at least one path part to construct a path') + self._path = path_parts + + def iterdir(self): + return iter(()) + + def is_file(self): return False - is_file = exists = is_dir # type: ignore + is_dir = is_file def joinpath(self, other): - return DegenerateFiles.Path() + return CompatibilityFiles.OrphanPath(*self._path, other) + @property def name(self): - return '' + return self._path[-1] - def open(self): - raise ValueError() + def open(self, mode='r', *args, **kwargs): + raise FileNotFoundError("Can't open orphan path") def __init__(self, spec): self.spec = spec @@ -71,7 +159,7 @@ def __getattr__(self, attr): return getattr(self._reader, attr) def files(self): - return DegenerateFiles.Path() + return CompatibilityFiles.SpecPath(self.spec, self._reader) def wrap_spec(package): diff --git a/importlib_resources/_compat.py b/importlib_resources/_compat.py index 8740601..0d1f72b 100644 --- a/importlib_resources/_compat.py +++ b/importlib_resources/_compat.py @@ -76,7 +76,8 @@ def _file_reader(spec): or # local FileReader _file_reader(self.spec) - or _adapters.DegenerateFiles(self.spec) + # fallback - adapt the spec ResourceReader to TraversableReader + or _adapters.CompatibilityFiles(self.spec) ) From 53fc1c6793a019fec1b00843aa78e0cc32a1ee35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Thu, 20 May 2021 19:01:36 +0100 Subject: [PATCH 03/11] use files() api in open_* and read_* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- importlib_resources/__init__.py | 8 ++-- importlib_resources/_common.py | 39 ++++++++++++++++++ importlib_resources/_py3.py | 72 --------------------------------- 3 files changed, 43 insertions(+), 76 deletions(-) diff --git a/importlib_resources/__init__.py b/importlib_resources/__init__.py index 44e096a..b8d609f 100644 --- a/importlib_resources/__init__.py +++ b/importlib_resources/__init__.py @@ -4,17 +4,17 @@ as_file, files, contents, + open_binary, + read_binary, + open_text, + read_text, ) from importlib_resources._py3 import ( Package, Resource, is_resource, - open_binary, - open_text, path, - read_binary, - read_text, ) from importlib_resources.abc import ResourceReader diff --git a/importlib_resources/_common.py b/importlib_resources/_common.py index 63f2642..838a993 100644 --- a/importlib_resources/_common.py +++ b/importlib_resources/_common.py @@ -7,11 +7,13 @@ import importlib from typing import Union, Any, Optional, Iterable +from typing.io import BinaryIO, TextIO from .abc import ResourceReader, Traversable from ._compat import wrap_spec Package = Union[types.ModuleType, str] +Resource = Union[str, os.PathLike] def files(package): @@ -118,6 +120,43 @@ def _(path): # legacy API +def open_binary(package: Package, resource: Resource) -> BinaryIO: + """Return a file-like object opened for binary reading of the resource.""" + return (files(package) / normalize_path(resource)).open('rb') + + +def read_binary(package: Package, resource: Resource) -> bytes: + """Return the binary contents of the resource.""" + return (files(package) / normalize_path(resource)).read_bytes() + + +def open_text( + package: Package, + resource: Resource, + encoding: str = 'utf-8', + errors: str = 'strict', +) -> TextIO: + """Return a file-like object opened for text reading of the resource.""" + return (files(package) / normalize_path(resource)).open( + 'r', encoding=encoding, errors=errors + ) + + +def read_text( + package: Package, + resource: Resource, + encoding: str = 'utf-8', + errors: str = 'strict', +) -> str: + """Return the decoded string of the resource. + + The decoding-related arguments have the same semantics as those of + bytes.decode(). + """ + with open_text(package, resource, encoding, errors) as fp: + return fp.read() + + def contents(package: Package) -> Iterable[str]: """Return an iterable of entries in `package`. diff --git a/importlib_resources/_py3.py b/importlib_resources/_py3.py index d52ada3..43dce10 100644 --- a/importlib_resources/_py3.py +++ b/importlib_resources/_py3.py @@ -3,14 +3,9 @@ from . import _common from contextlib import suppress -from importlib.abc import ResourceLoader -from importlib.machinery import ModuleSpec -from io import BytesIO, TextIOWrapper from pathlib import Path from types import ModuleType from typing import ContextManager, Union -from typing import cast -from typing.io import BinaryIO, TextIO from collections.abc import Sequence from functools import singledispatch @@ -18,73 +13,6 @@ Resource = Union[str, os.PathLike] -def open_binary(package: Package, resource: Resource) -> BinaryIO: - """Return a file-like object opened for binary reading of the resource.""" - resource = _common.normalize_path(resource) - package = _common.get_package(package) - reader = _common.get_resource_reader(package) - if reader is not None: - return reader.open_resource(resource) - spec = cast(ModuleSpec, package.__spec__) - # Using pathlib doesn't work well here due to the lack of 'strict' - # argument for pathlib.Path.resolve() prior to Python 3.6. - if spec.submodule_search_locations is not None: - paths = spec.submodule_search_locations - elif spec.origin is not None: - paths = [os.path.dirname(os.path.abspath(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, spec.loader) - data = None - if hasattr(spec.loader, 'get_data'): - with suppress(OSError): - data = loader.get_data(full_path) - if data is not None: - return BytesIO(data) - - raise FileNotFoundError(f'{resource!r} resource not found in {spec.name!r}') - - -def open_text( - package: Package, - resource: Resource, - encoding: str = 'utf-8', - errors: str = 'strict', -) -> TextIO: - """Return a file-like object opened for text reading of the resource.""" - return TextIOWrapper( - open_binary(package, resource), encoding=encoding, errors=errors - ) - - -def read_binary(package: Package, resource: Resource) -> bytes: - """Return the binary contents of the resource.""" - with open_binary(package, resource) as fp: - return fp.read() - - -def read_text( - package: Package, - resource: Resource, - encoding: str = 'utf-8', - errors: str = 'strict', -) -> str: - """Return the decoded string of the resource. - - The decoding-related arguments have the same semantics as those of - bytes.decode(). - """ - with open_text(package, resource, encoding, errors) as fp: - return fp.read() - - def path( package: Package, resource: Resource, From 30cf1a9feeb59fa1d38cde0d9f57e9d2812efeb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Thu, 20 May 2021 19:39:51 +0100 Subject: [PATCH 04/11] use files() api in is_resource() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The files() api makes the identification of resources blury. Here we re-implement is_resource() by mirroring the previous logic to the best extent we can. This is not fully correct, as the files() api is not fully compatible with the legacy api, but it is pretty close and as correct as we can get. The semantics for what constitutes resources have always been blurry in the first place. Signed-off-by: Filipe Laíns --- importlib_resources/__init__.py | 2 +- importlib_resources/_common.py | 12 ++++++++++++ importlib_resources/_py3.py | 16 ---------------- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/importlib_resources/__init__.py b/importlib_resources/__init__.py index b8d609f..fcb31b3 100644 --- a/importlib_resources/__init__.py +++ b/importlib_resources/__init__.py @@ -8,12 +8,12 @@ read_binary, open_text, read_text, + is_resource, ) from importlib_resources._py3 import ( Package, Resource, - is_resource, path, ) from importlib_resources.abc import ResourceReader diff --git a/importlib_resources/_common.py b/importlib_resources/_common.py index 838a993..d0b353b 100644 --- a/importlib_resources/_common.py +++ b/importlib_resources/_common.py @@ -165,3 +165,15 @@ def contents(package: Package) -> Iterable[str]: to check if it is a resource or not. """ return [path.name for path in files(package).iterdir()] + + +def is_resource(package: Package, name: str) -> bool: + """True if `name` is a resource inside `package`. + + Directories are *not* resources. + """ + resource = normalize_path(name) + return any( + traversable.name == resource and traversable.is_file() + for traversable in files(package).iterdir() + ) diff --git a/importlib_resources/_py3.py b/importlib_resources/_py3.py index 43dce10..3a791fc 100644 --- a/importlib_resources/_py3.py +++ b/importlib_resources/_py3.py @@ -51,22 +51,6 @@ def _path_from_open_resource(reader, resource): return _common._tempfile(saved.read, suffix=resource) -def is_resource(package: Package, name: str) -> bool: - """True if `name` is a resource inside `package`. - - Directories are *not* resources. - """ - package = _common.get_package(package) - _common.normalize_path(name) - reader = _common.get_resource_reader(package) - if reader is not None: - return reader.is_resource(name) - package_contents = set(_common.contents(package)) - if name not in package_contents: - return False - return (_common.from_package(package) / name).is_file() - - @singledispatch def _ensure_sequence(iterable): return list(iterable) From b1c38ab32c72198adb3ee1f8a540cb7beb488a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sat, 29 May 2021 19:28:09 +0100 Subject: [PATCH 05/11] compat: fix selecting the correct reader if the spec is None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- importlib_resources/_compat.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/importlib_resources/_compat.py b/importlib_resources/_compat.py index 0d1f72b..61e48d4 100644 --- a/importlib_resources/_compat.py +++ b/importlib_resources/_compat.py @@ -61,7 +61,11 @@ def _native_reader(spec): return reader if hasattr(reader, 'files') else None def _file_reader(spec): - if pathlib.Path(self.path).exists(): + try: + path = pathlib.Path(self.path) + except TypeError: + return None + if path.exists(): return readers.FileReader(self) return ( From 62e905c1cd3c37e73d9661fe67dff595bbbe9fbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sat, 29 May 2021 19:28:42 +0100 Subject: [PATCH 06/11] use files() api in path() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- importlib_resources/__init__.py | 2 +- importlib_resources/_common.py | 17 +++++++++- importlib_resources/_py3.py | 56 +-------------------------------- 3 files changed, 18 insertions(+), 57 deletions(-) diff --git a/importlib_resources/__init__.py b/importlib_resources/__init__.py index fcb31b3..1f71018 100644 --- a/importlib_resources/__init__.py +++ b/importlib_resources/__init__.py @@ -9,12 +9,12 @@ open_text, read_text, is_resource, + path, ) from importlib_resources._py3 import ( Package, Resource, - path, ) from importlib_resources.abc import ResourceReader diff --git a/importlib_resources/_common.py b/importlib_resources/_common.py index d0b353b..44650ca 100644 --- a/importlib_resources/_common.py +++ b/importlib_resources/_common.py @@ -6,7 +6,7 @@ import types import importlib -from typing import Union, Any, Optional, Iterable +from typing import Union, Any, Optional, Iterable, ContextManager from typing.io import BinaryIO, TextIO from .abc import ResourceReader, Traversable @@ -177,3 +177,18 @@ def is_resource(package: Package, name: str) -> bool: traversable.name == resource and traversable.is_file() for traversable in files(package).iterdir() ) + + +def path( + package: Package, + resource: Resource, +) -> ContextManager[pathlib.Path]: + """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, + a temporary file will be created. If the file was created, the file + will be deleted upon exiting the context manager (no exception is + raised if the file was deleted prior to the context manager + exiting). + """ + return as_file(files(package) / normalize_path(resource)) diff --git a/importlib_resources/_py3.py b/importlib_resources/_py3.py index 3a791fc..b7f541b 100644 --- a/importlib_resources/_py3.py +++ b/importlib_resources/_py3.py @@ -1,61 +1,7 @@ import os -import io -from . import _common -from contextlib import suppress -from pathlib import Path from types import ModuleType -from typing import ContextManager, Union -from collections.abc import Sequence -from functools import singledispatch +from typing import Union Package = Union[str, ModuleType] Resource = Union[str, os.PathLike] - - -def path( - package: Package, - resource: Resource, -) -> 'ContextManager[Path]': - """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, - a temporary file will be created. If the file was created, the file - will be deleted upon exiting the context manager (no exception is - raised if the file was deleted prior to the context manager - exiting). - """ - reader = _common.get_resource_reader(_common.get_package(package)) - return ( - _path_from_reader(reader, _common.normalize_path(resource)) - if reader - else _common.as_file( - _common.files(package).joinpath(_common.normalize_path(resource)) - ) - ) - - -def _path_from_reader(reader, resource): - return _path_from_resource_path(reader, resource) or _path_from_open_resource( - reader, resource - ) - - -def _path_from_resource_path(reader, resource): - with suppress(FileNotFoundError): - return Path(reader.resource_path(resource)) - - -def _path_from_open_resource(reader, resource): - saved = io.BytesIO(reader.open_resource(resource).read()) - return _common._tempfile(saved.read, suffix=resource) - - -@singledispatch -def _ensure_sequence(iterable): - return list(iterable) - - -@_ensure_sequence.register(Sequence) -def _(iterable): - return iterable From c2d85c9ac08a186c88aa7a441c9c5c032034d2bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sat, 29 May 2021 19:30:46 +0100 Subject: [PATCH 07/11] remove _py3 implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- importlib_resources/__init__.py | 4 +--- importlib_resources/_py3.py | 7 ------- 2 files changed, 1 insertion(+), 10 deletions(-) delete mode 100644 importlib_resources/_py3.py diff --git a/importlib_resources/__init__.py b/importlib_resources/__init__.py index 1f71018..d4136aa 100644 --- a/importlib_resources/__init__.py +++ b/importlib_resources/__init__.py @@ -10,12 +10,10 @@ read_text, is_resource, path, -) - -from importlib_resources._py3 import ( Package, Resource, ) + from importlib_resources.abc import ResourceReader diff --git a/importlib_resources/_py3.py b/importlib_resources/_py3.py deleted file mode 100644 index b7f541b..0000000 --- a/importlib_resources/_py3.py +++ /dev/null @@ -1,7 +0,0 @@ -import os - -from types import ModuleType -from typing import Union - -Package = Union[str, ModuleType] -Resource = Union[str, os.PathLike] From 5e8c1d2d0b5b8550623b2410b9f2a26b0e46f1dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sat, 29 May 2021 19:34:33 +0100 Subject: [PATCH 08/11] common: ignore PermissionError in _tempfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- importlib_resources/_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/importlib_resources/_common.py b/importlib_resources/_common.py index 44650ca..10cafc4 100644 --- a/importlib_resources/_common.py +++ b/importlib_resources/_common.py @@ -95,7 +95,7 @@ def _tempfile(reader, suffix=''): finally: try: os.remove(raw_path) - except FileNotFoundError: + except (FileNotFoundError, PermissionError): pass From 97083d2b3eb6a5e9fc91943bf61eeb23fa7134a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sat, 29 May 2021 20:27:05 +0100 Subject: [PATCH 09/11] tests: add tests for CompatibiltyFiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- .../tests/test_compatibilty_files.py | 95 +++++++++++++++++++ importlib_resources/tests/util.py | 10 +- 2 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 importlib_resources/tests/test_compatibilty_files.py diff --git a/importlib_resources/tests/test_compatibilty_files.py b/importlib_resources/tests/test_compatibilty_files.py new file mode 100644 index 0000000..c6ece7e --- /dev/null +++ b/importlib_resources/tests/test_compatibilty_files.py @@ -0,0 +1,95 @@ +import io +import unittest + +import importlib_resources as resources + +from importlib_resources._adapters import CompatibilityFiles + +from . import util + + +class CompatibilityFilesTests(unittest.TestCase): + @property + def package(self): + bytes_data = io.BytesIO(b'Hello, world!') + return util.create_package( + file=bytes_data, + path='some_path', + contents=('a', 'b', 'c'), + ) + + @property + def files(self): + return resources.files(self.package) + + def test_spec_path_iter(self): + self.assertEqual( + sorted(path.name for path in self.files.iterdir()), + ['a', 'b', 'c'], + ) + + def test_child_path_iter(self): + self.assertEqual(list((self.files / 'a').iterdir()), []) + + def test_orphan_path_iter(self): + self.assertEqual(list((self.files / 'a' / 'a').iterdir()), []) + self.assertEqual(list((self.files / 'a' / 'a' / 'a').iterdir()), []) + + def test_spec_path_is(self): + self.assertFalse(self.files.is_file()) + self.assertFalse(self.files.is_dir()) + + def test_child_path_is(self): + self.assertTrue((self.files / 'a').is_file()) + self.assertFalse((self.files / 'a').is_dir()) + + def test_orphan_path_is(self): + self.assertFalse((self.files / 'a' / 'a').is_file()) + self.assertFalse((self.files / 'a' / 'a').is_dir()) + self.assertFalse((self.files / 'a' / 'a' / 'a').is_file()) + self.assertFalse((self.files / 'a' / 'a' / 'a').is_dir()) + + def test_spec_path_name(self): + self.assertEqual(self.files.name, 'testingpackage') + + def test_child_path_name(self): + self.assertEqual((self.files / 'a').name, 'a') + + def test_orphan_path_name(self): + self.assertEqual((self.files / 'a' / 'b').name, 'b') + self.assertEqual((self.files / 'a' / 'b' / 'c').name, 'c') + + def test_spec_path_open(self): + self.assertEqual(self.files.read_bytes(), b'Hello, world!') + self.assertEqual(self.files.read_text(), 'Hello, world!') + + def test_child_path_open(self): + self.assertEqual((self.files / 'a').read_bytes(), b'Hello, world!') + self.assertEqual((self.files / 'a').read_text(), 'Hello, world!') + + def test_orphan_path_open(self): + with self.assertRaises(FileNotFoundError): + (self.files / 'a' / 'b').read_bytes() + with self.assertRaises(FileNotFoundError): + (self.files / 'a' / 'b' / 'c').read_bytes() + + def test_open_invalid_mode(self): + with self.assertRaises(ValueError): + self.files.open('0') + + def test_orphan_path_invalid(self): + with self.assertRaises(ValueError): + CompatibilityFiles.OrphanPath() + + +class CompatibilityFilesNoReaderTests(unittest.TestCase): + @property + def package(self): + return util.create_package_from_loader(None) + + @property + def files(self): + return resources.files(self.package) + + def test_spec_path_joinpath(self): + self.assertIsInstance(self.files / 'a', CompatibilityFiles.OrphanPath) diff --git a/importlib_resources/tests/util.py b/importlib_resources/tests/util.py index 206774b..6ac4332 100644 --- a/importlib_resources/tests/util.py +++ b/importlib_resources/tests/util.py @@ -51,16 +51,22 @@ def contents(self): yield from self._contents -def create_package(file, path, is_package=True, contents=()): +def create_package_from_loader(loader, is_package=True): name = 'testingpackage' module = types.ModuleType(name) - loader = Reader(file=file, path=path, _contents=contents) spec = ModuleSpec(name, loader, origin='does-not-exist', is_package=is_package) module.__spec__ = spec module.__loader__ = loader return module +def create_package(file=None, path=None, is_package=True, contents=()): + return create_package_from_loader( + Reader(file=file, path=path, _contents=contents), + is_package, + ) + + class CommonTests(metaclass=abc.ABCMeta): """ Tests shared by test_open, test_path, and test_read. From ef2f7c2f263c3b52d896eab213609f2a94879907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sat, 29 May 2021 20:32:32 +0100 Subject: [PATCH 10/11] tests: add test for CompatibilityFiles wrap_spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- importlib_resources/tests/test_compatibilty_files.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/importlib_resources/tests/test_compatibilty_files.py b/importlib_resources/tests/test_compatibilty_files.py index c6ece7e..d92c7c5 100644 --- a/importlib_resources/tests/test_compatibilty_files.py +++ b/importlib_resources/tests/test_compatibilty_files.py @@ -3,7 +3,10 @@ import importlib_resources as resources -from importlib_resources._adapters import CompatibilityFiles +from importlib_resources._adapters import ( + CompatibilityFiles, + wrap_spec, +) from . import util @@ -81,6 +84,10 @@ def test_orphan_path_invalid(self): with self.assertRaises(ValueError): CompatibilityFiles.OrphanPath() + def test_wrap_spec(self): + spec = wrap_spec(self.package) + self.assertIsInstance(spec.loader.get_resource_reader(None), CompatibilityFiles) + class CompatibilityFilesNoReaderTests(unittest.TestCase): @property From e016e668de95f6c4fa76741b5631627cd26d2aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sat, 26 Jun 2021 20:41:52 +0100 Subject: [PATCH 11/11] legacy: move legacy API to separate module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- importlib_resources/__init__.py | 7 ++- importlib_resources/_common.py | 80 +------------------------------ importlib_resources/_legacy.py | 85 +++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 81 deletions(-) create mode 100644 importlib_resources/_legacy.py diff --git a/importlib_resources/__init__.py b/importlib_resources/__init__.py index d4136aa..2468f57 100644 --- a/importlib_resources/__init__.py +++ b/importlib_resources/__init__.py @@ -3,6 +3,11 @@ from ._common import ( as_file, files, + Package, + Resource, +) + +from ._legacy import ( contents, open_binary, read_binary, @@ -10,8 +15,6 @@ read_text, is_resource, path, - Package, - Resource, ) from importlib_resources.abc import ResourceReader diff --git a/importlib_resources/_common.py b/importlib_resources/_common.py index 10cafc4..9bb6bda 100644 --- a/importlib_resources/_common.py +++ b/importlib_resources/_common.py @@ -6,8 +6,7 @@ import types import importlib -from typing import Union, Any, Optional, Iterable, ContextManager -from typing.io import BinaryIO, TextIO +from typing import Union, Any, Optional from .abc import ResourceReader, Traversable from ._compat import wrap_spec @@ -115,80 +114,3 @@ def _(path): Degenerate behavior for pathlib.Path objects. """ yield path - - -# legacy API - - -def open_binary(package: Package, resource: Resource) -> BinaryIO: - """Return a file-like object opened for binary reading of the resource.""" - return (files(package) / normalize_path(resource)).open('rb') - - -def read_binary(package: Package, resource: Resource) -> bytes: - """Return the binary contents of the resource.""" - return (files(package) / normalize_path(resource)).read_bytes() - - -def open_text( - package: Package, - resource: Resource, - encoding: str = 'utf-8', - errors: str = 'strict', -) -> TextIO: - """Return a file-like object opened for text reading of the resource.""" - return (files(package) / normalize_path(resource)).open( - 'r', encoding=encoding, errors=errors - ) - - -def read_text( - package: Package, - resource: Resource, - encoding: str = 'utf-8', - errors: str = 'strict', -) -> str: - """Return the decoded string of the resource. - - The decoding-related arguments have the same semantics as those of - bytes.decode(). - """ - with open_text(package, resource, encoding, errors) as fp: - return fp.read() - - -def contents(package: Package) -> Iterable[str]: - """Return an iterable of entries in `package`. - - Note that not all entries are resources. Specifically, directories are - not considered resources. Use `is_resource()` on each entry returned here - to check if it is a resource or not. - """ - return [path.name for path in files(package).iterdir()] - - -def is_resource(package: Package, name: str) -> bool: - """True if `name` is a resource inside `package`. - - Directories are *not* resources. - """ - resource = normalize_path(name) - return any( - traversable.name == resource and traversable.is_file() - for traversable in files(package).iterdir() - ) - - -def path( - package: Package, - resource: Resource, -) -> ContextManager[pathlib.Path]: - """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, - a temporary file will be created. If the file was created, the file - will be deleted upon exiting the context manager (no exception is - raised if the file was deleted prior to the context manager - exiting). - """ - return as_file(files(package) / normalize_path(resource)) diff --git a/importlib_resources/_legacy.py b/importlib_resources/_legacy.py new file mode 100644 index 0000000..3b9a534 --- /dev/null +++ b/importlib_resources/_legacy.py @@ -0,0 +1,85 @@ +import os +import pathlib +import types + +from typing import Union, Iterable, ContextManager +from typing.io import BinaryIO, TextIO + +from . import _common + +Package = Union[types.ModuleType, str] +Resource = Union[str, os.PathLike] + + +def open_binary(package: Package, resource: Resource) -> BinaryIO: + """Return a file-like object opened for binary reading of the resource.""" + return (_common.files(package) / _common.normalize_path(resource)).open('rb') + + +def read_binary(package: Package, resource: Resource) -> bytes: + """Return the binary contents of the resource.""" + return (_common.files(package) / _common.normalize_path(resource)).read_bytes() + + +def open_text( + package: Package, + resource: Resource, + encoding: str = 'utf-8', + errors: str = 'strict', +) -> TextIO: + """Return a file-like object opened for text reading of the resource.""" + return (_common.files(package) / _common.normalize_path(resource)).open( + 'r', encoding=encoding, errors=errors + ) + + +def read_text( + package: Package, + resource: Resource, + encoding: str = 'utf-8', + errors: str = 'strict', +) -> str: + """Return the decoded string of the resource. + + The decoding-related arguments have the same semantics as those of + bytes.decode(). + """ + with open_text(package, resource, encoding, errors) as fp: + return fp.read() + + +def contents(package: Package) -> Iterable[str]: + """Return an iterable of entries in `package`. + + Note that not all entries are resources. Specifically, directories are + not considered resources. Use `is_resource()` on each entry returned here + to check if it is a resource or not. + """ + return [path.name for path in _common.files(package).iterdir()] + + +def is_resource(package: Package, name: str) -> bool: + """True if `name` is a resource inside `package`. + + Directories are *not* resources. + """ + resource = _common.normalize_path(name) + return any( + traversable.name == resource and traversable.is_file() + for traversable in _common.files(package).iterdir() + ) + + +def path( + package: Package, + resource: Resource, +) -> ContextManager[pathlib.Path]: + """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, + a temporary file will be created. If the file was created, the file + will be deleted upon exiting the context manager (no exception is + raised if the file was deleted prior to the context manager + exiting). + """ + return _common.as_file(_common.files(package) / _common.normalize_path(resource))