Skip to content

Commit 19ca481

Browse files
authored
Merge pull request #258 from python/feature/203-non-package-modules
Support non-package modules.
2 parents 73d9cdc + 4e63613 commit 19ca481

File tree

9 files changed

+189
-51
lines changed

9 files changed

+189
-51
lines changed

.coveragerc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ omit =
66
*/_itertools.py
77
*/_legacy.py
88
*/simple.py
9+
*/_path.py
910

1011
[report]
1112
show_missing = True

CHANGES.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
v5.10.0
2+
=======
3+
4+
* #203: Lifted restriction on modules passed to ``files``.
5+
Now modules need not be a package and if a non-package
6+
module is passed, resources will be resolved adjacent to
7+
those modules, even for modules not found in any package.
8+
For example, ``files(import_module('mod.py'))`` will
9+
resolve resources found at the root. The parameter to
10+
files was renamed from 'package' to 'anchor', with a
11+
compatibility shim for those passing by keyword.
12+
113
v5.9.0
214
======
315

docs/index.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ in Python packages. It provides functionality similar to ``pkg_resources``
66
`Basic Resource Access`_ API, but without all of the overhead and performance
77
problems of ``pkg_resources``.
88

9-
In our terminology, a *resource* is a file tree that is located within an
10-
importable `Python package`_. Resources can live on the file system or in a
9+
In our terminology, a *resource* is a file tree that is located alongside an
10+
importable `Python module`_. Resources can live on the file system or in a
1111
zip file, with support for other loader_ classes that implement the appropriate
1212
API for reading resources.
1313

@@ -43,5 +43,5 @@ Indices and tables
4343

4444

4545
.. _`Basic Resource Access`: http://setuptools.readthedocs.io/en/latest/pkg_resources.html#basic-resource-access
46-
.. _`Python package`: https://docs.python.org/3/reference/import.html#packages
46+
.. _`Python module`: https://docs.python.org/3/glossary.html#term-module
4747
.. _loader: https://docs.python.org/3/reference/import.html#finders-and-loaders

docs/using.rst

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
===========================
66

77
``importlib_resources`` is a library that leverages Python's import system to
8-
provide access to *resources* within *packages*. Given that this library is
9-
built on top of the import system, it is highly efficient and easy to use.
10-
This library's philosophy is that, if you can import a package, you can access
11-
resources within that package. Resources can be opened or read, in either
12-
binary or text mode.
8+
provide access to *resources* within *packages* and alongside *modules*. Given
9+
that this library is built on top of the import system, it is highly efficient
10+
and easy to use. This library's philosophy is that, if one can import a
11+
module, one can access resources associated with that module. Resources can be
12+
opened or read, in either binary or text mode.
1313

1414
What exactly do we mean by "a resource"? It's easiest to think about the
1515
metaphor of files and directories on the file system, though it's important to
@@ -23,11 +23,14 @@ If you have a file system layout such as::
2323
one/
2424
__init__.py
2525
resource1.txt
26+
module1.py
2627
resources1/
2728
resource1.1.txt
2829
two/
2930
__init__.py
3031
resource2.txt
32+
standalone.py
33+
resource3.txt
3134

3235
then the directories are ``data``, ``data/one``, and ``data/two``. Each of
3336
these are also Python packages by virtue of the fact that they all contain
@@ -48,11 +51,14 @@ package directory, so
4851
``data/one/resource1.txt`` and ``data/two/resource2.txt`` are both resources,
4952
as are the ``__init__.py`` files in all the directories.
5053

51-
Resources are always accessed relative to the package that they live in.
52-
``resource1.txt`` and ``resources1/resource1.1.txt`` are resources within
53-
the ``data.one`` package, and
54-
``two/resource2.txt`` is a resource within the
55-
``data`` package.
54+
Resources in packages are always accessed relative to the package that they
55+
live in. ``resource1.txt`` and ``resources1/resource1.1.txt`` are resources
56+
within the ``data.one`` package, and ``two/resource2.txt`` is a resource
57+
within the ``data`` package.
58+
59+
Resources may also be referenced relative to another *anchor*, a module in a
60+
package (``data.one.module1``) or a standalone module (``standalone``). In
61+
this case, resources are loaded from the same loader that loaded that module.
5662

5763

5864
Example
@@ -103,14 +109,14 @@ using ``importlib_resources`` would look like::
103109
eml = files('email.tests.data').joinpath('message.eml').read_text()
104110

105111

106-
Packages or package names
107-
=========================
112+
Anchors
113+
=======
108114

109-
All of the ``importlib_resources`` APIs take a *package* as their first
110-
parameter, but this can either be a package name (as a ``str``) or an actual
111-
module object, though the module *must* be a package. If a string is
112-
passed in, it must name an importable Python package, and this is first
113-
imported. Thus the above example could also be written as::
115+
The ``importlib_resources`` ``files`` API takes an *anchor* as its first
116+
parameter, which can either be a package name (as a ``str``) or an actual
117+
module object. If a string is passed in, it must name an importable Python
118+
module, which is imported prior to loading any resources. Thus the above
119+
example could also be written as::
114120

115121
import email.tests.data
116122
eml = files(email.tests.data).joinpath('message.eml').read_text()

importlib_resources/_common.py

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,56 @@
55
import contextlib
66
import types
77
import importlib
8+
import warnings
89

9-
from typing import Union, Optional
10+
from typing import Union, Optional, cast
1011
from .abc import ResourceReader, Traversable
1112

1213
from ._compat import wrap_spec
1314

1415
Package = Union[types.ModuleType, str]
16+
Anchor = Package
1517

1618

17-
def files(package: Package) -> Traversable:
19+
def package_to_anchor(func):
1820
"""
19-
Get a Traversable resource from a package
21+
Replace 'package' parameter as 'anchor' and warn about the change.
22+
23+
Other errors should fall through.
24+
25+
>>> files()
26+
Traceback (most recent call last):
27+
TypeError: files() missing 1 required positional argument: 'anchor'
28+
>>> files('a', 'b')
29+
Traceback (most recent call last):
30+
TypeError: files() takes 1 positional argument but 2 were given
31+
"""
32+
undefined = object()
33+
34+
@functools.wraps(func)
35+
def wrapper(anchor=undefined, package=undefined):
36+
if package is not undefined:
37+
if anchor is not undefined:
38+
return func(anchor, package)
39+
warnings.warn(
40+
"First parameter to files is renamed to 'anchor'",
41+
DeprecationWarning,
42+
stacklevel=2,
43+
)
44+
return func(package)
45+
elif anchor is undefined:
46+
return func()
47+
return func(anchor)
48+
49+
return wrapper
50+
51+
52+
@package_to_anchor
53+
def files(anchor: Anchor) -> Traversable:
54+
"""
55+
Get a Traversable resource for an anchor.
2056
"""
21-
return from_package(get_package(package))
57+
return from_package(resolve(anchor))
2258

2359

2460
def get_resource_reader(package: types.ModuleType) -> Optional[ResourceReader]:
@@ -38,27 +74,16 @@ def get_resource_reader(package: types.ModuleType) -> Optional[ResourceReader]:
3874

3975

4076
@functools.singledispatch
41-
def resolve(cand: Package):
42-
return cand
77+
def resolve(cand: Anchor) -> types.ModuleType:
78+
return cast(types.ModuleType, cand)
4379

4480

4581
@resolve.register
46-
def _(cand: str):
82+
def _(cand: str) -> types.ModuleType:
4783
return importlib.import_module(cand)
4884

4985

50-
def get_package(package: Package) -> types.ModuleType:
51-
"""Take a package name or module object and return the module.
52-
53-
Raise an exception if the resolved module is not a package.
54-
"""
55-
resolved = resolve(package)
56-
if wrap_spec(resolved).submodule_search_locations is None:
57-
raise TypeError(f'{package!r} is not a package')
58-
return resolved
59-
60-
61-
def from_package(package):
86+
def from_package(package: types.ModuleType):
6287
"""
6388
Return a Traversable object for the given package.
6489

importlib_resources/tests/_compat.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,20 @@
66
except ImportError:
77
# Python 3.9 and earlier
88
class import_helper: # type: ignore
9-
from test.support import modules_setup, modules_cleanup
9+
from test.support import (
10+
modules_setup,
11+
modules_cleanup,
12+
DirsOnSysPath,
13+
CleanImport,
14+
)
15+
16+
17+
try:
18+
from test.support import os_helper # type: ignore
19+
except ImportError:
20+
# Python 3.9 compat
21+
class os_helper: # type:ignore
22+
from test.support import temp_dir
1023

1124

1225
try:

importlib_resources/tests/_path.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import pathlib
2+
import functools
3+
4+
5+
####
6+
# from jaraco.path 3.4
7+
8+
9+
def build(spec, prefix=pathlib.Path()):
10+
"""
11+
Build a set of files/directories, as described by the spec.
12+
13+
Each key represents a pathname, and the value represents
14+
the content. Content may be a nested directory.
15+
16+
>>> spec = {
17+
... 'README.txt': "A README file",
18+
... "foo": {
19+
... "__init__.py": "",
20+
... "bar": {
21+
... "__init__.py": "",
22+
... },
23+
... "baz.py": "# Some code",
24+
... }
25+
... }
26+
>>> tmpdir = getfixture('tmpdir')
27+
>>> build(spec, tmpdir)
28+
"""
29+
for name, contents in spec.items():
30+
create(contents, pathlib.Path(prefix) / name)
31+
32+
33+
@functools.singledispatch
34+
def create(content, path):
35+
path.mkdir(exist_ok=True)
36+
build(content, prefix=path) # type: ignore
37+
38+
39+
@create.register
40+
def _(content: bytes, path):
41+
path.write_bytes(content)
42+
43+
44+
@create.register
45+
def _(content: str, path):
46+
path.write_text(content)
47+
48+
49+
# end from jaraco.path
50+
####

importlib_resources/tests/test_files.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
import typing
22
import unittest
3+
import warnings
4+
import contextlib
35

46
import importlib_resources as resources
57
from ..abc import Traversable
68
from . import data01
79
from . import util
10+
from . import _path
11+
from ._compat import os_helper, import_helper
12+
13+
14+
@contextlib.contextmanager
15+
def suppress_known_deprecation():
16+
with warnings.catch_warnings(record=True) as ctx:
17+
warnings.simplefilter('default', category=DeprecationWarning)
18+
yield ctx
819

920

1021
class FilesTests:
@@ -25,6 +36,14 @@ def test_read_text(self):
2536
def test_traversable(self):
2637
assert isinstance(resources.files(self.data), Traversable)
2738

39+
def test_old_parameter(self):
40+
"""
41+
Files used to take a 'package' parameter. Make sure anyone
42+
passing by name is still supported.
43+
"""
44+
with suppress_known_deprecation():
45+
resources.files(package=self.data)
46+
2847

2948
class OpenDiskTests(FilesTests, unittest.TestCase):
3049
def setUp(self):
@@ -42,5 +61,28 @@ def setUp(self):
4261
self.data = namespacedata01
4362

4463

64+
class ModulesFilesTests(unittest.TestCase):
65+
def setUp(self):
66+
self.fixtures = contextlib.ExitStack()
67+
self.addCleanup(self.fixtures.close)
68+
self.site_dir = self.fixtures.enter_context(os_helper.temp_dir())
69+
self.fixtures.enter_context(import_helper.DirsOnSysPath(self.site_dir))
70+
self.fixtures.enter_context(import_helper.CleanImport())
71+
72+
def test_module_resources(self):
73+
"""
74+
A module can have resources found adjacent to the module.
75+
"""
76+
spec = {
77+
'mod.py': '',
78+
'res.txt': 'resources are the best',
79+
}
80+
_path.build(spec, self.site_dir)
81+
import mod
82+
83+
actual = resources.files(mod).joinpath('res.txt').read_text()
84+
assert actual == spec['res.txt']
85+
86+
4587
if __name__ == '__main__':
4688
unittest.main()

importlib_resources/tests/util.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,17 +102,6 @@ def test_importing_module_as_side_effect(self):
102102
del sys.modules[data01.__name__]
103103
self.execute(data01.__name__, 'utf-8.file')
104104

105-
def test_non_package_by_name(self):
106-
# The anchor package cannot be a module.
107-
with self.assertRaises(TypeError):
108-
self.execute(__name__, 'utf-8.file')
109-
110-
def test_non_package_by_package(self):
111-
# The anchor package cannot be a module.
112-
with self.assertRaises(TypeError):
113-
module = sys.modules['importlib_resources.tests.util']
114-
self.execute(module, 'utf-8.file')
115-
116105
def test_missing_path(self):
117106
# Attempting to open or read or request the path for a
118107
# non-existent path should succeed if open_resource

0 commit comments

Comments
 (0)