Skip to content

Commit d6523bb

Browse files
committed
RF: Bring in fMRIPrep's data loader
1 parent 8de096e commit d6523bb

File tree

10 files changed

+193
-35
lines changed

10 files changed

+193
-35
lines changed

nibabies/cli/run.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,6 @@ def main():
130130
_copy_any(dseg_tsv, str(config.execution.nibabies_dir / "desc-aparcaseg_dseg.tsv"))
131131
# errno = 0
132132
finally:
133-
from pkg_resources import resource_filename as pkgrf
134-
135133
from ..reports.core import generate_reports
136134

137135
# Generate reports phase

nibabies/cli/workflow.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,6 @@ def build_workflow(config_file):
4444

4545
# Called with reports only
4646
if config.execution.reports_only:
47-
from pkg_resources import resource_filename as pkgrf
48-
4947
build_logger.log(
5048
25,
5149
"Running --reports-only on participants %s",
@@ -132,14 +130,16 @@ def build_boilerplate(workflow):
132130
from shutil import copyfile
133131
from subprocess import CalledProcessError, TimeoutExpired, check_call
134132

135-
from pkg_resources import resource_filename as pkgrf
133+
from nibabies.data import load as load_data
134+
135+
bib = load_data.readable("boilerplate.bib").read_text()
136136

137137
# Generate HTML file resolving citations
138138
cmd = [
139139
"pandoc",
140140
"-s",
141141
"--bibliography",
142-
pkgrf("nibabies", "data/boilerplate.bib"),
142+
bib,
143143
"--citeproc",
144144
"--metadata",
145145
'pagetitle="nibabies citation boilerplate"',
@@ -159,7 +159,7 @@ def build_boilerplate(workflow):
159159
"pandoc",
160160
"-s",
161161
"--bibliography",
162-
pkgrf("nibabies", "data/boilerplate.bib"),
162+
bib,
163163
"--natbib",
164164
str(citation_files["md"]),
165165
"-o",
@@ -171,4 +171,4 @@ def build_boilerplate(workflow):
171171
except (FileNotFoundError, CalledProcessError, TimeoutExpired):
172172
config.loggers.cli.warning("Could not generate CITATION.tex file:\n%s", " ".join(cmd))
173173
else:
174-
copyfile(pkgrf("nibabies", "data/boilerplate.bib"), citation_files["bib"])
174+
copyfile(bib, citation_files["bib"])

nibabies/conftest.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
from tempfile import TemporaryDirectory
55

66
import pytest
7-
from pkg_resources import resource_filename
7+
8+
from nibabies.data import load as load_data
89

910
FILES = (
1011
"functional.nii",
@@ -34,5 +35,5 @@ def data_dir():
3435
@pytest.fixture(autouse=True)
3536
def set_namespace(doctest_namespace, data_dir):
3637
doctest_namespace["data_dir"] = data_dir
37-
doctest_namespace["test_data"] = Path(resource_filename("nibabies", "tests/data"))
38+
doctest_namespace["test_data"] = load_data.cached('../tests/data')
3839
doctest_namespace["Path"] = Path

nibabies/data/__init__.py

Lines changed: 167 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
1+
"""Data file retrieval
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+
14+
from __future__ import annotations
15+
116
import atexit
2-
from contextlib import ExitStack
17+
import os
18+
from contextlib import AbstractContextManager, ExitStack
19+
from functools import cached_property
320
from pathlib import Path
21+
from types import ModuleType
22+
from typing import Union
423

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

20-
path = files(__package__)
180+
__call__ = cached
21181

22182

23-
@cache
24-
def load_resource(fname: str) -> Path:
25-
return exit_stack.enter_context(as_file(path.joinpath(fname)))
183+
load = Loader(__package__)

nibabies/reports/core.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from nireports.assembler.report import Report
44

5-
from nibabies.data import load_resource
5+
from nibabies.data import load as load_data
66

77

88
def run_reports(
@@ -22,7 +22,7 @@ def run_reports(
2222
run_uuid,
2323
subject=subject,
2424
session=session,
25-
bootstrap_file=load_resource('reports-spec.yml'),
25+
bootstrap_file=load_data.readable('reports-spec.yml'),
2626
reportlets_dir=reportlets_dir,
2727
).generate_report()
2828

nibabies/tests/test_config.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@
2727

2828
import pytest
2929
from niworkflows.utils.spaces import format_reference
30-
from pkg_resources import resource_filename as pkgrf
3130
from toml import loads
3231

33-
from .. import config
32+
from nibabies import config
33+
from nibabies.data import load as load_data
3434

3535

3636
def _reset_config():
@@ -58,8 +58,7 @@ def test_reset_config():
5858

5959
def test_config_spaces():
6060
"""Check that all necessary spaces are recorded in the config."""
61-
filename = Path(pkgrf('nibabies', 'data/tests/config.toml'))
62-
settings = loads(filename.read_text())
61+
settings = loads(load_data.readable('tests/config.toml').read_text())
6362
for sectionname, configs in settings.items():
6463
if sectionname != 'environment':
6564
section = getattr(config, sectionname)

nibabies/utils/misc.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import Union
99

1010
from nibabies import __version__
11-
from nibabies.data import load_resource
11+
from nibabies.data import load as load_data
1212

1313

1414
def fix_multi_source_name(in_files):
@@ -144,7 +144,9 @@ def save_fsLR_mcribs(mcribs_dir: str | Path) -> None:
144144
template_dir = Path(mcribs_dir) / 'templates_fsLR'
145145
template_dir.mkdir(exist_ok=True)
146146

147-
for src in load_resource('atlases').glob('*sphere.surf.gii'):
147+
atlases = load_data.cached('atlases')
148+
149+
for src in atlases.glob('*sphere.surf.gii'):
148150
if not (dst := (template_dir / src.name)).exists():
149151
try:
150152
shutil.copyfile(src, dst)

nibabies/workflows/anatomical/resampling.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from smriprep.workflows.surfaces import _collate, init_morph_grayords_wf
99

1010
from nibabies.config import DEFAULT_MEMORY_MIN_GB
11-
from nibabies.data import load_resource
11+
from nibabies.data import load as load_data
1212
from nibabies.interfaces.utils import CiftiSelect
1313

1414

@@ -59,7 +59,7 @@ def init_anat_fsLR_resampling_wf(
5959
select_surfaces = pe.Node(CiftiSelect(), name='select_surfaces')
6060

6161
if mcribs:
62-
atlases = load_resource('atlases')
62+
atlases = load_data.cached('atlases')
6363
# use dHCP 32k fsLR instead
6464
select_surfaces.inputs.template_spheres = [
6565
str(atlases / 'tpl-dHCP_space-fsLR_hemi-L_den-32k_desc-week42_sphere.surf.gii'),
@@ -240,7 +240,7 @@ def init_mcribs_morph_grayords_wf(
240240
],
241241
)
242242

243-
atlases = load_resource('atlases')
243+
atlases = load_data.cached('atlases')
244244
resample.inputs.new_sphere = [ # 32k
245245
str(atlases / 'tpl-dHCP_space-fsLR_hemi-L_den-32k_desc-week42_sphere.surf.gii'),
246246
str(atlases / 'tpl-dHCP_space-fsLR_hemi-R_den-32k_desc-week42_sphere.surf.gii'),

nibabies/workflows/anatomical/surfaces.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
from niworkflows.utils.connections import pop_file
1313
from smriprep.workflows.surfaces import init_gifti_surface_wf
1414

15-
from ...config import DEFAULT_MEMORY_MIN_GB
16-
from ...data import load_resource
15+
from nibabies.config import DEFAULT_MEMORY_MIN_GB
16+
from nibabies.data import load as load_data
1717

1818
SURFACE_INPUTS = [
1919
"subjects_dir",
@@ -239,7 +239,7 @@ def init_mcribs_sphere_reg_wf(*, name="mcribs_sphere_reg_wf"):
239239
fix_meta = pe.MapNode(FixGiftiMetadata(), iterfield="in_file", name="fix_meta")
240240

241241
# load template files
242-
atlases = load_resource('atlases')
242+
atlases = load_data.cached('atlases')
243243

244244
# SurfaceSphereProjectUnProject
245245
# project to 41k dHCP atlas sphere

nibabies/workflows/bold/resampling.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from niworkflows.interfaces.workbench import MetricDilate, MetricMask, MetricResample
2525

2626
from nibabies.config import DEFAULT_MEMORY_MIN_GB
27-
from nibabies.data import load_resource
27+
from nibabies.data import load as load_data
2828

2929
if ty.TYPE_CHECKING:
3030
from niworkflows.utils.spaces import SpatialReferences
@@ -589,7 +589,7 @@ def init_bold_fsLR_resampling_wf(
589589
# select white, midthickness and pial surfaces based on hemi
590590
select_surfaces = pe.Node(CiftiSelect(), name='select_surfaces')
591591
if mcribs:
592-
atlases = load_resource('atlases')
592+
atlases = load_data.cached('atlases')
593593
# use dHCP 32k fsLR instead
594594
select_surfaces.inputs.template_spheres = [
595595
str(atlases / 'tpl-dHCP_space-fsLR_hemi-L_den-32k_desc-week42_sphere.surf.gii'),

0 commit comments

Comments
 (0)