Skip to content

Commit 10f7b72

Browse files
committed
Merge branch 'feature/traversable' into 'master'
Introduce the 'tree' module to allow traversal of packages for resources in namespace packages See merge request python-devs/importlib_resources!76
2 parents ce2fa42 + 79da86d commit 10f7b72

File tree

11 files changed

+304
-307
lines changed

11 files changed

+304
-307
lines changed

importlib_resources/__init__.py

+25-8
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,46 @@
44

55

66
__all__ = [
7+
'Package',
8+
'Resource',
9+
'ResourceReader',
710
'contents',
11+
'files',
812
'is_resource',
913
'open_binary',
1014
'open_text',
1115
'path',
1216
'read_binary',
1317
'read_text',
14-
'Package',
15-
'Resource',
16-
'ResourceReader',
1718
]
1819

1920

2021
if sys.version_info >= (3,):
2122
from importlib_resources._py3 import (
22-
Package, Resource, contents, is_resource, open_binary, open_text, path,
23-
read_binary, read_text)
23+
Package,
24+
Resource,
25+
contents,
26+
files,
27+
is_resource,
28+
open_binary,
29+
open_text,
30+
path,
31+
read_binary,
32+
read_text,
33+
)
2434
from importlib_resources.abc import ResourceReader
2535
else:
2636
from importlib_resources._py2 import (
27-
contents, is_resource, open_binary, open_text, path, read_binary,
28-
read_text)
29-
del __all__[-3:]
37+
contents,
38+
files,
39+
is_resource,
40+
open_binary,
41+
open_text,
42+
path,
43+
read_binary,
44+
read_text,
45+
)
46+
del __all__[:3]
3047

3148

3249
__version__ = read_text('importlib_resources', 'version.txt').strip()

importlib_resources/_compat.py

+19
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,22 @@ class ABC(object): # type: ignore
2121
FileNotFoundError = FileNotFoundError # type: ignore
2222
except NameError:
2323
FileNotFoundError = OSError # type: ignore
24+
25+
26+
try:
27+
from zipfile import Path as ZipPath # type: ignore
28+
except ImportError:
29+
from zipp import Path as ZipPath
30+
31+
32+
class PackageSpec(object):
33+
def __init__(self, **kwargs):
34+
vars(self).update(kwargs)
35+
36+
37+
def package_spec(package):
38+
return getattr(package, '__spec__', None) or \
39+
PackageSpec(
40+
origin=package.__file__,
41+
loader=getattr(package, '__loader__', None),
42+
)

importlib_resources/_py2.py

+11-113
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
import os
22
import errno
3-
import tempfile
43

4+
from . import trees
55
from ._compat import FileNotFoundError
6-
from contextlib import contextmanager
76
from importlib import import_module
87
from io import BytesIO, TextIOWrapper, open as io_open
9-
from pathlib2 import Path
10-
from zipfile import ZipFile
118

129

1310
def _resolve(name):
@@ -96,7 +93,10 @@ def read_text(package, resource, encoding='utf-8', errors='strict'):
9693
return fp.read()
9794

9895

99-
@contextmanager
96+
def files(package):
97+
return trees.from_package(_get_package(package))
98+
99+
100100
def path(package, resource):
101101
"""A context manager providing a file path object to the resource.
102102
@@ -106,33 +106,10 @@ def path(package, resource):
106106
raised if the file was deleted prior to the context manager
107107
exiting).
108108
"""
109-
resource = _normalize_path(resource)
110-
package = _get_package(package)
111-
package_directory = Path(package.__file__).parent
112-
file_path = package_directory / resource
113-
# If the file actually exists on the file system, just return it.
114-
if file_path.exists():
115-
yield file_path
116-
return
117-
118-
# Otherwise, it's probably in a zip file, so we need to create a temporary
119-
# file and copy the contents into that file, hence the contextmanager to
120-
# clean up the temp file resource.
121-
with open_binary(package, resource) as fp:
122-
data = fp.read()
123-
# Not using tempfile.NamedTemporaryFile as it leads to deeper 'try'
124-
# blocks due to the need to close the temporary file to work on Windows
125-
# properly.
126-
fd, raw_path = tempfile.mkstemp()
127-
try:
128-
os.write(fd, data)
129-
os.close(fd)
130-
yield Path(raw_path)
131-
finally:
132-
try:
133-
os.remove(raw_path)
134-
except FileNotFoundError:
135-
pass
109+
path = files(package).joinpath(_normalize_path(resource))
110+
if not path.is_file():
111+
raise FileNotFoundError(path)
112+
return trees.as_file(path)
136113

137114

138115
def is_resource(package, name):
@@ -155,42 +132,7 @@ def is_resource(package, name):
155132
return False
156133
if name not in package_contents:
157134
return False
158-
# Just because the given file_name lives as an entry in the package's
159-
# contents doesn't necessarily mean it's a resource. Directories are not
160-
# resources, so let's try to find out if it's a directory or not.
161-
path = Path(package.__file__).parent / name
162-
if path.is_file():
163-
return True
164-
if path.is_dir():
165-
return False
166-
# If it's not a file and it's not a directory, what is it? Well, this
167-
# means the file doesn't exist on the file system, so it probably lives
168-
# inside a zip file. We have to crack open the zip, look at its table of
169-
# contents, and make sure that this entry doesn't have sub-entries.
170-
archive_path = package.__loader__.archive # type: ignore
171-
package_directory = Path(package.__file__).parent
172-
with ZipFile(archive_path) as zf:
173-
toc = zf.namelist()
174-
relpath = package_directory.relative_to(archive_path)
175-
candidate_path = relpath / name
176-
for entry in toc: # pragma: nobranch
177-
try:
178-
relative_to_candidate = Path(entry).relative_to(candidate_path)
179-
except ValueError:
180-
# The two paths aren't relative to each other so we can ignore it.
181-
continue
182-
# Since directories aren't explicitly listed in the zip file, we must
183-
# infer their 'directory-ness' by looking at the number of path
184-
# components in the path relative to the package resource we're
185-
# looking up. If there are zero additional parts, it's a file, i.e. a
186-
# resource. If there are more than zero it's a directory, i.e. not a
187-
# resource. It has to be one of these two cases.
188-
return len(relative_to_candidate.parts) == 0
189-
# I think it's impossible to get here. It would mean that we are looking
190-
# for a resource in a zip file, there's an entry matching it in the return
191-
# value of contents(), but we never actually found it in the zip's table of
192-
# contents.
193-
raise AssertionError('Impossible situation')
135+
return (trees.from_package(package) / name).is_file()
194136

195137

196138
def contents(package):
@@ -201,48 +143,4 @@ def contents(package):
201143
to check if it is a resource or not.
202144
"""
203145
package = _get_package(package)
204-
package_directory = Path(package.__file__).parent
205-
try:
206-
return os.listdir(str(package_directory))
207-
except OSError as error:
208-
if error.errno not in (errno.ENOENT, errno.ENOTDIR):
209-
# We won't hit this in the Python 2 tests, so it'll appear
210-
# uncovered. We could mock os.listdir() to return a non-ENOENT or
211-
# ENOTDIR, but then we'd have to depend on another external
212-
# library since Python 2 doesn't have unittest.mock. It's not
213-
# worth it.
214-
raise # pragma: nocover
215-
# The package is probably in a zip file.
216-
archive_path = getattr(package.__loader__, 'archive', None)
217-
if archive_path is None:
218-
raise
219-
relpath = package_directory.relative_to(archive_path)
220-
with ZipFile(archive_path) as zf:
221-
toc = zf.namelist()
222-
subdirs_seen = set()
223-
subdirs_returned = []
224-
for filename in toc:
225-
path = Path(filename)
226-
# Strip off any path component parts that are in common with the
227-
# package directory, relative to the zip archive's file system
228-
# path. This gives us all the parts that live under the named
229-
# package inside the zip file. If the length of these subparts is
230-
# exactly 1, then it is situated inside the package. The resulting
231-
# length will be 0 if it's above the package, and it will be
232-
# greater than 1 if it lives in a subdirectory of the package
233-
# directory.
234-
#
235-
# However, since directories themselves don't appear in the zip
236-
# archive as a separate entry, we need to return the first path
237-
# component for any case that has > 1 subparts -- but only once!
238-
if path.parts[:len(relpath.parts)] != relpath.parts:
239-
continue
240-
subparts = path.parts[len(relpath.parts):]
241-
if len(subparts) == 1:
242-
subdirs_returned.append(subparts[0])
243-
elif len(subparts) > 1: # pragma: nobranch
244-
subdir = subparts[0]
245-
if subdir not in subdirs_seen:
246-
subdirs_seen.add(subdir)
247-
subdirs_returned.append(subdir)
248-
return subdirs_returned
146+
return list(item.name for item in trees.from_package(package).iterdir())

0 commit comments

Comments
 (0)