Skip to content

Commit 82b6266

Browse files
authored
Merge pull request #379 from effigies/enh/data-loader
ENH: Add data loader to sdcflows.data, drop pkg_resources
2 parents 54b05c0 + e822f4f commit 82b6266

File tree

10 files changed

+216
-41
lines changed

10 files changed

+216
-41
lines changed

docs/api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Information on specific functions, classes, and methods.
77
:glob:
88

99
api/sdcflows.cli
10+
api/sdcflows.data
1011
api/sdcflows.fieldmaps
1112
api/sdcflows.interfaces
1213
api/sdcflows.transform

docs/conf.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
#
8484
# This is also used if you do content translation via gettext catalogs.
8585
# Usually you set "language" from the command line for these cases.
86-
language = None
86+
language = "en"
8787

8888
# List of patterns, relative to source directory, that match files and
8989
# directories to ignore when looking for source files.
@@ -218,7 +218,7 @@
218218

219219
apidoc_module_dir = "../sdcflows"
220220
apidoc_output_dir = "api"
221-
apidoc_excluded_paths = ["conftest.py", "*/tests/*", "tests/*", "data/*"]
221+
apidoc_excluded_paths = ["conftest.py", "*/tests/*", "tests/*"]
222222
apidoc_separate_modules = True
223223
apidoc_extra_args = ["--module-first", "-d 1", "-T"]
224224

docs/requirements.txt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
attrs >= 20.1.0
2-
furo ~= 2021.10.09
2+
furo
33
matplotlib >= 2.2.0
44
nibabel
55
nipype >= 1.5.1
@@ -9,7 +9,6 @@ numpy
99
packaging
1010
pydot >= 1.2.3
1111
pydotplus
12-
sphinx ~= 4.2
12+
sphinx
1313
sphinxcontrib-apidoc
14-
sphinxcontrib-napoleon
1514
templateflow

sdcflows/__init__.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@
44
try:
55
from ._version import __version__
66
except ModuleNotFoundError:
7-
from pkg_resources import get_distribution, DistributionNotFound
8-
7+
from importlib.metadata import version, PackageNotFoundError
98
try:
10-
__version__ = get_distribution(__packagename__).version
11-
except DistributionNotFound:
12-
__version__ = "unknown"
13-
del get_distribution
14-
del DistributionNotFound
9+
__version__ = version(__packagename__)
10+
except PackageNotFoundError:
11+
__version__ = "0+unknown"
12+
del version
13+
del PackageNotFoundError

sdcflows/data/__init__.py

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

sdcflows/tests/test_version.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@
2222
#
2323
"""Test _version.py."""
2424
import sys
25-
from collections import namedtuple
26-
from pkg_resources import DistributionNotFound
25+
from importlib.metadata import PackageNotFoundError
2726
from importlib import reload
2827
import sdcflows
2928

@@ -40,25 +39,24 @@ class _version:
4039

4140

4241
def test_version_scm1(monkeypatch):
43-
"""Retrieve the version via pkg_resources."""
42+
"""Retrieve the version via importlib.metadata."""
4443
monkeypatch.setitem(sys.modules, "sdcflows._version", None)
4544

46-
def _dist(name):
47-
Distribution = namedtuple("Distribution", ["name", "version"])
48-
return Distribution(name, "success")
45+
def _version(name):
46+
return "9.0.0"
4947

50-
monkeypatch.setattr("pkg_resources.get_distribution", _dist)
48+
monkeypatch.setattr("importlib.metadata.version", _version)
5149
reload(sdcflows)
52-
assert sdcflows.__version__ == "success"
50+
assert sdcflows.__version__ == "9.0.0"
5351

5452

5553
def test_version_scm2(monkeypatch):
5654
"""Check version could not be interpolated."""
5755
monkeypatch.setitem(sys.modules, "sdcflows._version", None)
5856

5957
def _raise(name):
60-
raise DistributionNotFound("No get_distribution mock")
58+
raise PackageNotFoundError("No get_distribution mock")
6159

62-
monkeypatch.setattr("pkg_resources.get_distribution", _raise)
60+
monkeypatch.setattr("importlib.metadata.version", _raise)
6361
reload(sdcflows)
64-
assert sdcflows.__version__ == "unknown"
62+
assert sdcflows.__version__ == "0+unknown"

sdcflows/workflows/apply/registration.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@
2929
The target EPI is the distorted dataset (or a reference thereof).
3030
3131
"""
32-
from pkg_resources import resource_filename as pkgrf
3332
from nipype.pipeline import engine as pe
3433
from nipype.interfaces import utility as niu
3534
from niworkflows.engine.workflows import LiterateWorkflow as Workflow
3635

36+
from ... import data
37+
3738

3839
def init_coeff2epi_wf(
3940
omp_nthreads,
@@ -111,13 +112,9 @@ def init_coeff2epi_wf(
111112

112113
# Register the reference of the fieldmap to the reference
113114
# of the target image (the one that shall be corrected)
114-
ants_settings = pkgrf(
115-
"sdcflows", f"data/fmap-any_registration{'_testing' * sloppy}.json"
116-
)
117-
118115
coregister = pe.Node(
119116
Registration(
120-
from_file=ants_settings,
117+
from_file=data.load(f"fmap-any_registration{'_testing' * sloppy}.json"),
121118
output_warped_image=debug,
122119
output_inverse_warped_image=debug,
123120
),

sdcflows/workflows/fit/pepolar.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@
2121
# https://www.nipreps.org/community/licensing/
2222
#
2323
"""Datasets with multiple phase encoded directions."""
24-
from pkg_resources import resource_filename as _pkg_fname
2524
from nipype.pipeline import engine as pe
2625
from nipype.interfaces import utility as niu
2726

2827
from niworkflows.engine.workflows import LiterateWorkflow as Workflow
2928

29+
from ... import data
30+
3031
INPUT_FIELDS = ("metadata", "in_data")
3132
_PEPOLAR_DESC = """\
3233
A *B<sub>0</sub>*-nonuniformity map (or *fieldmap*) was estimated based on two (or more)
@@ -148,7 +149,7 @@ def init_topup_wf(
148149
to_las = pe.Node(ReorientImageAndMetadata(target_orientation="LAS"), name="to_las")
149150
topup = pe.Node(
150151
TOPUP(
151-
config=_pkg_fname("sdcflows", f"data/flirtsch/b02b0{'_quick' * sloppy}.cnf")
152+
config=str(data.load(f"flirtsch/b02b0{'_quick' * sloppy}.cnf"))
152153
),
153154
name="topup",
154155
)
@@ -332,7 +333,7 @@ def init_3dQwarp_wf(omp_nthreads=1, debug=False, name="pepolar_estimate_wf"):
332333

333334
align_pes = pe.Node(
334335
Registration(
335-
from_file=_pkg_fname("sdcflows", "data/translation_rigid.json"),
336+
from_file=data.load("translation_rigid.json"),
336337
output_warped_image=True,
337338
),
338339
name="align_pes",

0 commit comments

Comments
 (0)