Skip to content

Commit

Permalink
Implement path()
Browse files Browse the repository at this point in the history
  • Loading branch information
brettcannon authored Oct 27, 2017
2 parents be38279 + 5fdfc5f commit d99bcd0
Show file tree
Hide file tree
Showing 3 changed files with 122 additions and 58 deletions.
58 changes: 5 additions & 53 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,47 +53,19 @@ class ResourceReader(abc.ABC):
## High-level
For `importlib.resources`:
```python
import contextlib
import importlib
import os
import pathlib
import tempfile
import types
from typing import ContextManager, Iterator, Union
from typing import ContextManager, Union
from typing.io import BinaryIO


Package = Union[str, types.ModuleType]
FileName = Union[str, os.PathLike]


def _get_package(package):
if hasattr(package, '__spec__'):
if package.__spec__.submodule_search_locations is None:
raise TypeError(f"{package.__spec__.name!r} is not a package")
else:
return package
else:
module = importlib.import_module(package_name)
if module.__spec__.submodule_search_locations is None:
raise TypeError(f"{package_name!r} is not a package")
else:
return module


def _normalize_path(path):
directory, file_name = os.path.split(path)
if directory:
raise ValueError(f"{path!r} is not just a file name")
else:
return file_name


def open(package: Package, file_name: FileName) -> BinaryIO:
"""Return a file-like object opened for binary-reading of the resource."""
normalized_path = _normalize_path(file_name)
module = _get_package(package)
return module.__spec__.loader.open_resource(normalized_path)
...


def read(package: Package, file_name: FileName, encoding: str = "utf-8",
Expand All @@ -103,15 +75,11 @@ def read(package: Package, file_name: FileName, encoding: str = "utf-8",
The decoding-related arguments have the same semantics as those of
bytes.decode().
"""
# Note this is **not** builtins.open()!
with open(package, file_name) as binary_file:
text_file = io.TextIOWrapper(binary_file, encoding=encoding,
errors=errors)
return text_file.read()
...


@contextlib.contextmanager
def path(package: Package, file_name: FileName) -> Iterator[pathlib.Path]:
def path(package: Package, file_name: FileName) -> 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,
Expand All @@ -120,23 +88,7 @@ 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)
package = _get_package(package)
try:
yield pathlib.Path(package.__spec__.resource_path(normalized_path))
except FileNotFoundError:
with package.__spec__.open_resource(normalized_path) as file:
data = file.read()
raw_path = tempfile.mkstemp()
try:
with open(raw_path, 'wb') as file:
file.write(data)
yield pathlib.Path(raw_path)
finally:
try:
os.delete(raw_path)
except FileNotFoundError:
pass
...
```

If *package* is an actual package, it is used directly. Otherwise the
Expand Down
48 changes: 43 additions & 5 deletions importlib_resources/__init__.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import builtins
import contextlib
import importlib
import importlib.abc
import io
import os.path
import pathlib
import sys
import tempfile
import types
import typing
from typing import Union
from typing import Iterator, Union
from typing.io import BinaryIO


Package = Union[types.ModuleType, str]
if sys.version_info >= (3, 6):
Path = Union[str, os.PathLike]
FileName = Union[str, os.PathLike]
else:
Path = str
FileName = str


def _get_package(package) -> types.ModuleType:
Expand All @@ -38,7 +42,7 @@ def _normalize_path(path) -> str:
return file_name


def open(package: Package, file_name: Path) -> BinaryIO:
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)
Expand All @@ -56,7 +60,7 @@ def open(package: Package, file_name: Path) -> BinaryIO:
return io.BytesIO(data)


def read(package: Package, file_name: Path, encoding: str = 'utf-8',
def read(package: Package, file_name: FileName, encoding: str = 'utf-8',
errors: str = 'strict') -> str:
"""Return the decoded string of the resource.
Expand All @@ -72,3 +76,37 @@ def read(package: Package, file_name: Path, encoding: str = 'utf-8',
text_file = io.TextIOWrapper(binary_file, encoding=encoding,
errors=errors)
return text_file.read()


@contextlib.contextmanager
def path(package: Package, file_name: FileName) -> Iterator[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).
"""
normalized_path = _normalize_path(file_name)
package = _get_package(package)
package_directory = pathlib.Path(package.__spec__.origin).parent
file_path = package_directory / normalized_path
if file_path.exists():
yield file_path
else:
with open(package, normalized_path) 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
# Windows properly.
fd, raw_path = tempfile.mkstemp()
try:
os.write(fd, data)
os.close(fd)
yield pathlib.Path(raw_path)
finally:
try:
os.remove(raw_path)
except FileNotFoundError:
pass
74 changes: 74 additions & 0 deletions importlib_resources/tests/test_path.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import io
import os.path
import pathlib
import sys
import unittest

import importlib_resources as resources
from importlib_resources.tests import data


class CommonTests(unittest.TestCase):

def test_package_name(self):
# Passing in the package name should succeed.
with resources.path(data.__name__, 'utf-8.file') as path:
pass # No error.

def test_package_object(self):
# Passing in the package itself should succeed.
with resources.path(data, 'utf-8.file') as path:
pass # No error.

def test_string_path(self):
path = 'utf-8.file'
# Passing in a string for the path should succeed.
with resources.path(data, path) as path:
pass # No error.

@unittest.skipIf(sys.version_info < (3, 6), 'requires os.PathLike support')
def test_pathlib_path(self):
# Passing in a pathlib.PurePath object for the path should succeed.
path = pathlib.PurePath('utf-8.file')
with resources.path(data, path) as path:
pass # No error.

# Don't fail if run under e.g. pytest.
def test_absolute_path(self):
# An absolute path is a ValueError.
path = pathlib.Path(__file__)
full_path = path.parent/'utf-8.file'
with self.assertRaises(ValueError):
with resources.path(data, str(full_path)) as path:
pass

def test_relative_path(self):
# A reative path is a ValueError.
with self.assertRaises(ValueError):
with resources.path(data, '../data/utf-8.file') as path:
pass

def test_importing_module_as_side_effect(self):
# The anchor package can already be imported.
del sys.modules[data.__name__]
with resources.path(data.__name__, 'utf-8.file') as path:
pass # No Errors.

def test_non_package(self):
# The anchor package cannot be a module.
with self.assertRaises(TypeError):
with resources.path(__spec__.name, 'utf-8.file') as path:
pass


class PathTests(unittest.TestCase):

def test_reading(self):
# Path should be readable.
# Test also implicitly verifies the returned object is a pathlib.Path
# instance.
with resources.path(data, 'utf-8.file') as path:
# pathlib.Path.read_text() was introduced in Python 3.5.
with path.open('r', encoding='utf-8') as file:
text = file.read()
self.assertEqual('Hello, UTF-8 world!\n', text)

0 comments on commit d99bcd0

Please sign in to comment.