Skip to content

Commit 1f6203c

Browse files
authored
Merge pull request #816 from effigies/mnt/data_module_and_docs
ENH: Add data loader class, install at root and data modules
2 parents b1b34dd + bdd30db commit 1f6203c

File tree

20 files changed

+273
-117
lines changed

20 files changed

+273
-117
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

88
api/niworkflows.anat
99
api/niworkflows.cli
10+
api/niworkflows.data
1011
api/niworkflows.dwi
1112
api/niworkflows.engine
1213
api/niworkflows.func

docs/conf.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@
111111
#
112112
# This is also used if you do content translation via gettext catalogs.
113113
# Usually you set "language" from the command line for these cases.
114-
language = None
114+
language = 'en'
115115

116116
# List of patterns, relative to source directory, that match files and
117117
# directories to ignore when looking for source files.
@@ -247,7 +247,7 @@
247247

248248
apidoc_module_dir = "../niworkflows"
249249
apidoc_output_dir = "api"
250-
apidoc_excluded_paths = ["conftest.py", "*/tests/*", "tests/*", "data/*", "testing.py"]
250+
apidoc_excluded_paths = ["conftest.py", "*/tests/*", "tests/*", "testing.py"]
251251
apidoc_separate_modules = True
252252
apidoc_extra_args = ["--module-first", "-d 1", "-T"]
253253

docs/requirements.txt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
attrs
2-
furo ~= 2022.4.7
2+
furo
33
nipype >= 1.5.1
44
traits < 6.4
55
packaging
66
pytest
7-
sphinx ~= 4.2
7+
sphinx
88
sphinxcontrib-apidoc
9-
sphinxcontrib-napoleon
109
templateflow

niworkflows/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55

66
from .__about__ import __packagename__, __copyright__, __credits__
7+
from .data import Loader
78
try:
89
from ._version import __version__
910
except ImportError: # pragma: no cover
@@ -16,6 +17,7 @@
1617
"__copyright__",
1718
"__credits__",
1819
"NIWORKFLOWS_LOG",
20+
"load_resource",
1921
]
2022

2123
NIWORKFLOWS_LOG = logging.getLogger(__packagename__)
@@ -27,3 +29,5 @@
2729
matplotlib.use("Agg")
2830
except ImportError:
2931
pass
32+
33+
load_resource = Loader(__package__)

niworkflows/anat/ants.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
# general purpose
2626
from collections import OrderedDict
2727
from multiprocessing import cpu_count
28-
from pkg_resources import resource_filename as pkgr_fn
2928
from warnings import warn
3029

3130
# nipype
@@ -40,6 +39,7 @@
4039
ThresholdImage,
4140
)
4241

42+
from ..data import load as load_data
4343
from ..utils.misc import get_template_specs
4444
from ..utils.connections import pop_file as _pop
4545

@@ -302,9 +302,7 @@ def init_brain_extraction_wf(
302302
else "antsBrainExtractionNoLaplacian_%s.json"
303303
)
304304
norm = pe.Node(
305-
Registration(
306-
from_file=pkgr_fn("niworkflows.data", settings_file % normalization_quality)
307-
),
305+
Registration(from_file=load_data(settings_file % normalization_quality)),
308306
name="norm",
309307
n_procs=omp_nthreads,
310308
mem_gb=mem_gb,

niworkflows/conftest.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
import pytest
3131
import tempfile
3232

33+
from . import load_resource
34+
3335
try:
3436
import importlib_resources
3537
except ImportError:
@@ -40,7 +42,7 @@
4042

4143

4244
def find_resource_or_skip(resource):
43-
pathlike = importlib_resources.files("niworkflows") / resource
45+
pathlike = load_resource(resource)
4446
if not pathlike.exists():
4547
pytest.skip(f"Missing resource {resource}; run this test from a source repository")
4648
return pathlike
@@ -63,7 +65,7 @@ def add_np(doctest_namespace):
6365
doctest_namespace["datadir"] = data_dir
6466
doctest_namespace["data_dir_canary"] = data_dir_canary
6567
doctest_namespace["bids_collect_data"] = collect_data
66-
doctest_namespace["test_data"] = importlib_resources.files("niworkflows") / "tests" / "data"
68+
doctest_namespace["test_data"] = load_resource('tests/data')
6769

6870
tmpdir = tempfile.TemporaryDirectory()
6971

niworkflows/data/__init__.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Niworkflows 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 niworkflows.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 niworkflows.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__)

niworkflows/func/util.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@
2222
#
2323
"""Utility workflows."""
2424
from packaging.version import parse as parseversion, Version
25-
from pkg_resources import resource_filename as pkgr_fn
2625

2726
from nipype.pipeline import engine as pe
2827
from nipype.interfaces import utility as niu, fsl, afni
2928

3029
from templateflow.api import get as get_template
3130

31+
from .. import data
3232
from ..engine.workflows import LiterateWorkflow as Workflow
3333
from ..interfaces.fixes import (
3434
FixHeaderRegistration as Registration,
@@ -452,9 +452,7 @@ def init_enhance_and_skullstrip_bold_wf(
452452

453453
# Set up spatial normalization
454454
norm = pe.Node(
455-
Registration(
456-
from_file=pkgr_fn("niworkflows.data", "epi_atlasbased_brainmask.json")
457-
),
455+
Registration(from_file=data.load("epi_atlasbased_brainmask.json")),
458456
name="norm",
459457
n_procs=omp_nthreads,
460458
)

niworkflows/interfaces/bids.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
from pathlib import Path
2828
import shutil
2929
import os
30-
from pkg_resources import resource_filename as _pkgres
3130
import re
3231

3332
import nibabel as nb
@@ -50,12 +49,13 @@
5049
)
5150
from nipype.interfaces.io import add_traits
5251
import templateflow as tf
52+
from .. import data
5353
from ..utils.bids import _init_layout, relative_to_root
5454
from ..utils.images import set_consumables, unsafe_write_nifti_header_and_data
5555
from ..utils.misc import _copy_any, unlink
5656

5757
regz = re.compile(r"\.gz$")
58-
_pybids_spec = loads(Path(_pkgres("niworkflows", "data/nipreps.json")).read_text())
58+
_pybids_spec = loads(data.load.readable("nipreps.json").read_text())
5959
BIDS_DERIV_ENTITIES = _pybids_spec["entities"]
6060
BIDS_DERIV_PATTERNS = tuple(_pybids_spec["default_path_patterns"])
6161

niworkflows/interfaces/norm.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
from os import path as op
2525

2626
from multiprocessing import cpu_count
27-
import pkg_resources as pkgr
2827
from packaging.version import Version
2928
import numpy as np
3029

@@ -40,6 +39,7 @@
4039

4140
from templateflow.api import get as get_template
4241
from .. import NIWORKFLOWS_LOG, __version__
42+
from ..data import load as load_data
4343
from .fixes import FixHeaderRegistration as Registration
4444

4545

@@ -166,16 +166,13 @@ def _get_settings(self):
166166
self.inputs.moving.lower(), self.inputs.flavor
167167
)
168168

169+
data_dir = load_data()
169170
# Get a list of settings files that match the flavor.
170171
filenames = [
171-
i
172-
for i in pkgr.resource_listdir("niworkflows", "data")
173-
if i.startswith(filestart) and i.endswith(".json")
172+
i for i in data_dir.iterdir() if i.startswith(filestart) and i.endswith(".json")
174173
]
175174
# Return the settings files.
176-
return [
177-
pkgr.resource_filename("niworkflows.data", f) for f in sorted(filenames)
178-
]
175+
return [str(data_dir / f) for f in sorted(filenames)]
179176

180177
def _run_interface(self, runtime):
181178
# Get a list of settings files.

0 commit comments

Comments
 (0)