Skip to content

Commit c593cd9

Browse files
authored
Merge pull request #303 from encukou/functional
Add functional API
2 parents ca03a4d + fa60969 commit c593cd9

File tree

4 files changed

+350
-0
lines changed

4 files changed

+350
-0
lines changed

importlib_resources/__init__.py

+17
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@
77
Anchor,
88
)
99

10+
from .functional import (
11+
contents,
12+
is_resource,
13+
open_binary,
14+
open_text,
15+
path,
16+
read_binary,
17+
read_text,
18+
)
19+
1020
from .abc import ResourceReader
1121

1222

@@ -16,4 +26,11 @@
1626
'ResourceReader',
1727
'as_file',
1828
'files',
29+
'contents',
30+
'is_resource',
31+
'open_binary',
32+
'open_text',
33+
'path',
34+
'read_binary',
35+
'read_text',
1936
]

importlib_resources/functional.py

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Simplified function-based API for importlib.resources"""
2+
3+
import warnings
4+
5+
from ._common import files, as_file
6+
7+
8+
_MISSING = object()
9+
10+
11+
def open_binary(anchor, *path_names):
12+
"""Open for binary reading the *resource* within *package*."""
13+
return _get_resource(anchor, path_names).open('rb')
14+
15+
16+
def open_text(anchor, *path_names, encoding=_MISSING, errors='strict'):
17+
"""Open for text reading the *resource* within *package*."""
18+
encoding = _get_encoding_arg(path_names, encoding)
19+
resource = _get_resource(anchor, path_names)
20+
return resource.open('r', encoding=encoding, errors=errors)
21+
22+
23+
def read_binary(anchor, *path_names):
24+
"""Read and return contents of *resource* within *package* as bytes."""
25+
return _get_resource(anchor, path_names).read_bytes()
26+
27+
28+
def read_text(anchor, *path_names, encoding=_MISSING, errors='strict'):
29+
"""Read and return contents of *resource* within *package* as str."""
30+
encoding = _get_encoding_arg(path_names, encoding)
31+
resource = _get_resource(anchor, path_names)
32+
return resource.read_text(encoding=encoding, errors=errors)
33+
34+
35+
def path(anchor, *path_names):
36+
"""Return the path to the *resource* as an actual file system path."""
37+
return as_file(_get_resource(anchor, path_names))
38+
39+
40+
def is_resource(anchor, *path_names):
41+
"""Return ``True`` if there is a resource named *name* in the package,
42+
43+
Otherwise returns ``False``.
44+
"""
45+
return _get_resource(anchor, path_names).is_file()
46+
47+
48+
def contents(anchor, *path_names):
49+
"""Return an iterable over the named resources within the package.
50+
51+
The iterable returns :class:`str` resources (e.g. files).
52+
The iterable does not recurse into subdirectories.
53+
"""
54+
warnings.warn(
55+
"importlib.resources.contents is deprecated. "
56+
"Use files(anchor).iterdir() instead.",
57+
DeprecationWarning,
58+
stacklevel=1,
59+
)
60+
return (resource.name for resource in _get_resource(anchor, path_names).iterdir())
61+
62+
63+
def _get_encoding_arg(path_names, encoding):
64+
# For compatibility with versions where *encoding* was a positional
65+
# argument, it needs to be given explicitly when there are multiple
66+
# *path_names*.
67+
# This limitation can be removed in Python 3.15.
68+
if encoding is _MISSING:
69+
if len(path_names) > 1:
70+
raise TypeError(
71+
"'encoding' argument required with multiple path names",
72+
)
73+
else:
74+
return 'utf-8'
75+
return encoding
76+
77+
78+
def _get_resource(anchor, path_names):
79+
if anchor is None:
80+
raise TypeError("anchor must be module or string, got None")
81+
return files(anchor).joinpath(*path_names)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import unittest
2+
import os
3+
import contextlib
4+
5+
try:
6+
from test.support.warnings_helper import ignore_warnings, check_warnings
7+
except ImportError:
8+
# older Python versions
9+
from test.support import ignore_warnings, check_warnings
10+
11+
import importlib_resources as resources
12+
13+
# Since the functional API forwards to Traversable, we only test
14+
# filesystem resources here -- not zip files, namespace packages etc.
15+
# We do test for two kinds of Anchor, though.
16+
17+
18+
class StringAnchorMixin:
19+
anchor01 = 'importlib_resources.tests.data01'
20+
anchor02 = 'importlib_resources.tests.data02'
21+
22+
23+
class ModuleAnchorMixin:
24+
from . import data01 as anchor01
25+
from . import data02 as anchor02
26+
27+
28+
class FunctionalAPIBase:
29+
def _gen_resourcetxt_path_parts(self):
30+
"""Yield various names of a text file in anchor02, each in a subTest"""
31+
for path_parts in (
32+
('subdirectory', 'subsubdir', 'resource.txt'),
33+
('subdirectory/subsubdir/resource.txt',),
34+
('subdirectory/subsubdir', 'resource.txt'),
35+
):
36+
with self.subTest(path_parts=path_parts):
37+
yield path_parts
38+
39+
def test_read_text(self):
40+
self.assertEqual(
41+
resources.read_text(self.anchor01, 'utf-8.file'),
42+
'Hello, UTF-8 world!\n',
43+
)
44+
self.assertEqual(
45+
resources.read_text(
46+
self.anchor02,
47+
'subdirectory',
48+
'subsubdir',
49+
'resource.txt',
50+
encoding='utf-8',
51+
),
52+
'a resource',
53+
)
54+
for path_parts in self._gen_resourcetxt_path_parts():
55+
self.assertEqual(
56+
resources.read_text(
57+
self.anchor02,
58+
*path_parts,
59+
encoding='utf-8',
60+
),
61+
'a resource',
62+
)
63+
# Use generic OSError, since e.g. attempting to read a directory can
64+
# fail with PermissionError rather than IsADirectoryError
65+
with self.assertRaises(OSError):
66+
resources.read_text(self.anchor01)
67+
with self.assertRaises(OSError):
68+
resources.read_text(self.anchor01, 'no-such-file')
69+
with self.assertRaises(UnicodeDecodeError):
70+
resources.read_text(self.anchor01, 'utf-16.file')
71+
self.assertEqual(
72+
resources.read_text(
73+
self.anchor01,
74+
'binary.file',
75+
encoding='latin1',
76+
),
77+
'\x00\x01\x02\x03',
78+
)
79+
self.assertEqual(
80+
resources.read_text(
81+
self.anchor01,
82+
'utf-16.file',
83+
errors='backslashreplace',
84+
),
85+
'Hello, UTF-16 world!\n'.encode('utf-16').decode(
86+
errors='backslashreplace',
87+
),
88+
)
89+
90+
def test_read_binary(self):
91+
self.assertEqual(
92+
resources.read_binary(self.anchor01, 'utf-8.file'),
93+
b'Hello, UTF-8 world!\n',
94+
)
95+
for path_parts in self._gen_resourcetxt_path_parts():
96+
self.assertEqual(
97+
resources.read_binary(self.anchor02, *path_parts),
98+
b'a resource',
99+
)
100+
101+
def test_open_text(self):
102+
with resources.open_text(self.anchor01, 'utf-8.file') as f:
103+
self.assertEqual(f.read(), 'Hello, UTF-8 world!\n')
104+
for path_parts in self._gen_resourcetxt_path_parts():
105+
with resources.open_text(
106+
self.anchor02,
107+
*path_parts,
108+
encoding='utf-8',
109+
) as f:
110+
self.assertEqual(f.read(), 'a resource')
111+
# Use generic OSError, since e.g. attempting to read a directory can
112+
# fail with PermissionError rather than IsADirectoryError
113+
with self.assertRaises(OSError):
114+
resources.open_text(self.anchor01)
115+
with self.assertRaises(OSError):
116+
resources.open_text(self.anchor01, 'no-such-file')
117+
with resources.open_text(self.anchor01, 'utf-16.file') as f:
118+
with self.assertRaises(UnicodeDecodeError):
119+
f.read()
120+
with resources.open_text(
121+
self.anchor01,
122+
'binary.file',
123+
encoding='latin1',
124+
) as f:
125+
self.assertEqual(f.read(), '\x00\x01\x02\x03')
126+
with resources.open_text(
127+
self.anchor01,
128+
'utf-16.file',
129+
errors='backslashreplace',
130+
) as f:
131+
self.assertEqual(
132+
f.read(),
133+
'Hello, UTF-16 world!\n'.encode('utf-16').decode(
134+
errors='backslashreplace',
135+
),
136+
)
137+
138+
def test_open_binary(self):
139+
with resources.open_binary(self.anchor01, 'utf-8.file') as f:
140+
self.assertEqual(f.read(), b'Hello, UTF-8 world!\n')
141+
for path_parts in self._gen_resourcetxt_path_parts():
142+
with resources.open_binary(
143+
self.anchor02,
144+
*path_parts,
145+
) as f:
146+
self.assertEqual(f.read(), b'a resource')
147+
148+
def test_path(self):
149+
with resources.path(self.anchor01, 'utf-8.file') as path:
150+
with open(str(path), encoding='utf-8') as f:
151+
self.assertEqual(f.read(), 'Hello, UTF-8 world!\n')
152+
with resources.path(self.anchor01) as path:
153+
with open(os.path.join(path, 'utf-8.file'), encoding='utf-8') as f:
154+
self.assertEqual(f.read(), 'Hello, UTF-8 world!\n')
155+
156+
def test_is_resource(self):
157+
is_resource = resources.is_resource
158+
self.assertTrue(is_resource(self.anchor01, 'utf-8.file'))
159+
self.assertFalse(is_resource(self.anchor01, 'no_such_file'))
160+
self.assertFalse(is_resource(self.anchor01))
161+
self.assertFalse(is_resource(self.anchor01, 'subdirectory'))
162+
for path_parts in self._gen_resourcetxt_path_parts():
163+
self.assertTrue(is_resource(self.anchor02, *path_parts))
164+
165+
def test_contents(self):
166+
with check_warnings((".*contents.*", DeprecationWarning)):
167+
c = resources.contents(self.anchor01)
168+
self.assertGreaterEqual(
169+
set(c),
170+
{'utf-8.file', 'utf-16.file', 'binary.file', 'subdirectory'},
171+
)
172+
with contextlib.ExitStack() as cm:
173+
cm.enter_context(self.assertRaises(OSError))
174+
cm.enter_context(check_warnings((".*contents.*", DeprecationWarning)))
175+
176+
list(resources.contents(self.anchor01, 'utf-8.file'))
177+
178+
for path_parts in self._gen_resourcetxt_path_parts():
179+
with contextlib.ExitStack() as cm:
180+
cm.enter_context(self.assertRaises(OSError))
181+
cm.enter_context(check_warnings((".*contents.*", DeprecationWarning)))
182+
183+
list(resources.contents(self.anchor01, *path_parts))
184+
with check_warnings((".*contents.*", DeprecationWarning)):
185+
c = resources.contents(self.anchor01, 'subdirectory')
186+
self.assertGreaterEqual(
187+
set(c),
188+
{'binary.file'},
189+
)
190+
191+
@ignore_warnings(category=DeprecationWarning)
192+
def test_common_errors(self):
193+
for func in (
194+
resources.read_text,
195+
resources.read_binary,
196+
resources.open_text,
197+
resources.open_binary,
198+
resources.path,
199+
resources.is_resource,
200+
resources.contents,
201+
):
202+
with self.subTest(func=func):
203+
# Rejecting None anchor
204+
with self.assertRaises(TypeError):
205+
func(None)
206+
# Rejecting invalid anchor type
207+
with self.assertRaises((TypeError, AttributeError)):
208+
func(1234)
209+
# Unknown module
210+
with self.assertRaises(ModuleNotFoundError):
211+
func('$missing module$')
212+
213+
def test_text_errors(self):
214+
for func in (
215+
resources.read_text,
216+
resources.open_text,
217+
):
218+
with self.subTest(func=func):
219+
# Multiple path arguments need explicit encoding argument.
220+
with self.assertRaises(TypeError):
221+
func(
222+
self.anchor02,
223+
'subdirectory',
224+
'subsubdir',
225+
'resource.txt',
226+
)
227+
228+
229+
class FunctionalAPITest_StringAnchor(
230+
unittest.TestCase,
231+
FunctionalAPIBase,
232+
StringAnchorMixin,
233+
):
234+
pass
235+
236+
237+
class FunctionalAPITest_ModuleAnchor(
238+
unittest.TestCase,
239+
FunctionalAPIBase,
240+
ModuleAnchorMixin,
241+
):
242+
pass

newsfragments/303.feature.rst

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
The functions
2+
``is_resource()``,
3+
``open_binary()``,
4+
``open_text()``,
5+
``path()``,
6+
``read_binary()``, and
7+
``read_text()`` are un-deprecated, and support
8+
subdirectories via multiple positional arguments.
9+
The ``contents()`` function also allows subdirectories,
10+
but remains deprecated.

0 commit comments

Comments
 (0)