Skip to content

Commit 6c945cf

Browse files
committed
ENH: Add templateflow._loader to replace pkg_resources
1 parent 4138883 commit 6c945cf

File tree

2 files changed

+172
-0
lines changed

2 files changed

+172
-0
lines changed

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ setup_requires =
3232
wheel
3333
install_requires =
3434
pybids >= 0.15.2
35+
importlib_resources >= 5.7; python_version < '3.11'
3536
requests
3637
tqdm
3738
test_requires =

templateflow/_loader.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Resource loader module
2+
3+
.. autoclass:: Loader
4+
"""
5+
from __future__ import annotations
6+
7+
import atexit
8+
import os
9+
from contextlib import AbstractContextManager, ExitStack
10+
from functools import cached_property
11+
from pathlib import Path
12+
from types import ModuleType
13+
from typing import Union
14+
15+
try:
16+
from functools import cache
17+
except ImportError: # PY38
18+
from functools import lru_cache as cache
19+
20+
try: # Prefer backport to leave consistency to dependency spec
21+
from importlib_resources import as_file, files
22+
except ImportError:
23+
from importlib.resources import as_file, files # type: ignore
24+
25+
try: # Prefer stdlib so Sphinx can link to authoritative documentation
26+
from importlib.resources.abc import Traversable
27+
except ImportError:
28+
from importlib_resources.abc import Traversable
29+
30+
__all__ = ["Loader"]
31+
32+
33+
class Loader:
34+
"""A loader for package files relative to a module
35+
36+
This class wraps :mod:`importlib.resources` to provide a getter
37+
function with an interpreter-lifetime scope. For typical packages
38+
it simply passes through filesystem paths as :class:`~pathlib.Path`
39+
objects. For zipped distributions, it will unpack the files into
40+
a temporary directory that is cleaned up on interpreter exit.
41+
42+
This loader accepts a fully-qualified module name or a module
43+
object.
44+
45+
Expected usage::
46+
47+
'''Data package
48+
49+
.. autofunction:: load_data
50+
51+
.. automethod:: load_data.readable
52+
53+
.. automethod:: load_data.as_path
54+
55+
.. automethod:: load_data.cached
56+
'''
57+
58+
from templateflow._loader import Loader
59+
60+
load_data = Loader(__package__)
61+
62+
:class:`~Loader` objects implement the :func:`callable` interface
63+
and generate a docstring, and are intended to be treated and documented
64+
as functions.
65+
66+
For greater flexibility and improved readability over the ``importlib.resources``
67+
interface, explicit methods are provided to access resources.
68+
69+
+---------------+----------------+------------------+
70+
| On-filesystem | Lifetime | Method |
71+
+---------------+----------------+------------------+
72+
| `True` | Interpreter | :meth:`cached` |
73+
+---------------+----------------+------------------+
74+
| `True` | `with` context | :meth:`as_path` |
75+
+---------------+----------------+------------------+
76+
| `False` | n/a | :meth:`readable` |
77+
+---------------+----------------+------------------+
78+
79+
It is also possible to use ``Loader`` directly::
80+
81+
from templateflow._loader import Loader
82+
83+
Loader(other_package).readable('data/resource.ext').read_text()
84+
85+
with Loader(other_package).as_path('data') as pkgdata:
86+
# Call function that requires full Path implementation
87+
func(pkgdata)
88+
89+
# contrast to
90+
91+
from importlib_resources import files, as_file
92+
93+
files(other_package).joinpath('data/resource.ext').read_text()
94+
95+
with as_file(files(other_package) / 'data') as pkgdata:
96+
func(pkgdata)
97+
98+
.. automethod:: readable
99+
100+
.. automethod:: as_path
101+
102+
.. automethod:: cached
103+
"""
104+
105+
def __init__(self, anchor: Union[str, ModuleType]):
106+
self._anchor = anchor
107+
self.files = files(anchor)
108+
self.exit_stack = ExitStack()
109+
atexit.register(self.exit_stack.close)
110+
# Allow class to have a different docstring from instances
111+
self.__doc__ = self._doc
112+
113+
@cached_property
114+
def _doc(self):
115+
"""Construct docstring for instances
116+
117+
Lists the public top-level paths inside the location, where
118+
non-public means has a `.` or `_` prefix or is a 'tests'
119+
directory.
120+
"""
121+
top_level = sorted(
122+
os.path.relpath(p, self.files) + "/"[: p.is_dir()]
123+
for p in self.files.iterdir()
124+
if p.name[0] not in (".", "_") and p.name != "tests"
125+
)
126+
doclines = [
127+
f"Load package files relative to ``{self._anchor}``.",
128+
"",
129+
"This package contains the following (top-level) files/directories:",
130+
"",
131+
*(f"* ``{path}``" for path in top_level),
132+
]
133+
134+
return "\n".join(doclines)
135+
136+
def readable(self, *segments) -> Traversable:
137+
"""Provide read access to a resource through a Path-like interface.
138+
139+
This file may or may not exist on the filesystem, and may be
140+
efficiently used for read operations, including directory traversal.
141+
142+
This result is not cached or copied to the filesystem in cases where
143+
that would be necessary.
144+
"""
145+
return self.files.joinpath(*segments)
146+
147+
def as_path(self, *segments) -> AbstractContextManager[Path]:
148+
"""Ensure data is available as a :class:`~pathlib.Path`.
149+
150+
This method generates a context manager that yields a Path when
151+
entered.
152+
153+
This result is not cached, and any temporary files that are created
154+
are deleted when the context is exited.
155+
"""
156+
return as_file(self.files.joinpath(*segments))
157+
158+
@cache
159+
def cached(self, *segments) -> Path:
160+
"""Ensure data is available as a :class:`~pathlib.Path`.
161+
162+
Any temporary files that are created remain available throughout
163+
the duration of the program, and are deleted when Python exits.
164+
165+
Results are cached so that multiple calls do not unpack the same
166+
data multiple times, but the cache is sensitive to the specific
167+
argument(s) passed.
168+
"""
169+
return self.exit_stack.enter_context(as_file(self.files.joinpath(*segments)))
170+
171+
__call__ = cached

0 commit comments

Comments
 (0)