Skip to content

Commit 6d778c4

Browse files
authored
Merge pull request #401 from mgxd/maint/update-parser
MAINT: Port over parser and tests from fmriprep
2 parents 6e48832 + 8162d10 commit 6d778c4

File tree

4 files changed

+323
-13
lines changed

4 files changed

+323
-13
lines changed

nibabies/cli/parser.py

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -102,19 +102,36 @@ def _drop_sub(value):
102102
def _drop_ses(value):
103103
return value[4:] if value.startswith('ses-') else value
104104

105-
def _filter_pybids_none_any(dct):
105+
def _process_value(value):
106106
import bids
107107

108-
return {
109-
k: bids.layout.Query.NONE if v is None else (bids.layout.Query.ANY if v == '*' else v)
110-
for k, v in dct.items()
111-
}
112-
113-
def _bids_filter(value):
114-
from json import loads
108+
if value is None:
109+
return bids.layout.Query.NONE
110+
elif value == '*':
111+
return bids.layout.Query.ANY
112+
else:
113+
return value
115114

116-
if value and Path(value).exists():
117-
return loads(Path(value).read_text(), object_hook=_filter_pybids_none_any)
115+
def _filter_pybids_none_any(dct):
116+
d = {}
117+
for k, v in dct.items():
118+
if isinstance(v, list):
119+
d[k] = [_process_value(val) for val in v]
120+
else:
121+
d[k] = _process_value(v)
122+
return d
123+
124+
def _bids_filter(value, parser):
125+
from json import JSONDecodeError, loads
126+
127+
if value:
128+
if Path(value).exists():
129+
try:
130+
return loads(Path(value).read_text(), object_hook=_filter_pybids_none_any)
131+
except JSONDecodeError as e:
132+
raise parser.error(f'JSON syntax error in: <{value}>.') from e
133+
else:
134+
raise parser.error(f'Path does not exist: <{value}>.')
118135

119136
def _slice_time_ref(value, parser):
120137
if value == 'start':
@@ -142,6 +159,7 @@ def _slice_time_ref(value, parser):
142159
DirNotEmpty = partial(_dir_not_empty, parser=parser)
143160
IsFile = partial(_is_file, parser=parser)
144161
PositiveInt = partial(_min_one, parser=parser)
162+
BIDSFilter = partial(_bids_filter, parser=parser)
145163
SliceTimeRef = partial(_slice_time_ref, parser=parser)
146164

147165
parser.description = f"""
@@ -214,7 +232,7 @@ def _slice_time_ref(value, parser):
214232
'--bids-filter-file',
215233
dest='bids_filters',
216234
action='store',
217-
type=_bids_filter,
235+
type=BIDSFilter,
218236
metavar='FILE',
219237
help='a JSON file describing custom BIDS input filters using PyBIDS. '
220238
'For further details, please check out '
@@ -546,9 +564,13 @@ def _slice_time_ref(value, parser):
546564
g_syn = parser.add_argument_group('Specific options for SyN distortion correction')
547565
g_syn.add_argument(
548566
'--use-syn-sdc',
549-
action='store_true',
567+
nargs='?',
568+
choices=['warn', 'error'],
569+
action='store',
570+
const='error',
550571
default=False,
551-
help='EXPERIMENTAL: Use fieldmap-free distortion correction',
572+
help='Use fieldmap-less distortion correction based on anatomical image; '
573+
'if unable, error (default) or warn based on optional argument.',
552574
)
553575
g_syn.add_argument(
554576
'--force-syn',

nibabies/cli/tests/__init__.py

Whitespace-only changes.

nibabies/cli/tests/test_parser.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
2+
# vi: set ft=python sts=4 ts=4 sw=4 et:
3+
#
4+
# Copyright The NiPreps Developers <[email protected]>
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
# We support and encourage derived works from this project, please read
19+
# about our expectations at
20+
#
21+
# https://www.nipreps.org/community/licensing/
22+
#
23+
"""Test parser."""
24+
25+
from argparse import ArgumentError
26+
from contextlib import nullcontext
27+
28+
import pytest
29+
from packaging.version import Version
30+
31+
from nibabies import config
32+
from nibabies.cli import version as _version
33+
from nibabies.cli.parser import _build_parser, parse_args
34+
from nibabies.tests.test_config import _reset_config
35+
36+
MIN_ARGS = ['data/', 'out/', 'participant']
37+
38+
39+
@pytest.mark.parametrize(
40+
('args', 'code'),
41+
[
42+
([], 2),
43+
(MIN_ARGS, 2), # bids_dir does not exist
44+
(MIN_ARGS + ['--fs-license-file'], 2),
45+
(MIN_ARGS + ['--fs-license-file', 'fslicense.txt'], 2),
46+
],
47+
)
48+
def test_parser_errors(args, code):
49+
"""Check behavior of the parser."""
50+
with pytest.raises(SystemExit) as error:
51+
_build_parser().parse_args(args)
52+
53+
assert error.value.code == code
54+
55+
56+
@pytest.mark.parametrize('args', [MIN_ARGS, MIN_ARGS + ['--fs-license-file']])
57+
def test_parser_valid(tmp_path, args):
58+
"""Check valid arguments."""
59+
datapath = tmp_path / 'data'
60+
datapath.mkdir(exist_ok=True)
61+
args[0] = str(datapath)
62+
63+
if '--fs-license-file' in args:
64+
_fs_file = tmp_path / 'license.txt'
65+
_fs_file.write_text('')
66+
args.insert(args.index('--fs-license-file') + 1, str(_fs_file.absolute()))
67+
68+
opts = _build_parser().parse_args(args)
69+
70+
assert opts.bids_dir == datapath
71+
72+
73+
@pytest.mark.parametrize(
74+
('argval', 'gb'),
75+
[
76+
('1G', 1),
77+
('1GB', 1),
78+
('1000', 1), # Default units are MB
79+
('32000', 32), # Default units are MB
80+
('4000', 4), # Default units are MB
81+
('1000M', 1),
82+
('1000MB', 1),
83+
('1T', 1000),
84+
('1TB', 1000),
85+
('%dK' % 1e6, 1),
86+
('%dKB' % 1e6, 1),
87+
('%dB' % 1e9, 1),
88+
],
89+
)
90+
def test_memory_arg(tmp_path, argval, gb):
91+
"""Check the correct parsing of the memory argument."""
92+
datapath = tmp_path / 'data'
93+
datapath.mkdir(exist_ok=True)
94+
_fs_file = tmp_path / 'license.txt'
95+
_fs_file.write_text('')
96+
97+
args = [str(datapath)] + MIN_ARGS[1:] + ['--fs-license-file', str(_fs_file), '--mem', argval]
98+
opts = _build_parser().parse_args(args)
99+
100+
assert opts.memory_gb == gb
101+
102+
103+
@pytest.mark.parametrize(('current', 'latest'), [('1.0.0', '1.3.2'), ('1.3.2', '1.3.2')])
104+
def test_get_parser_update(monkeypatch, capsys, current, latest):
105+
"""Make sure the out-of-date banner is shown."""
106+
expectation = Version(current) < Version(latest)
107+
108+
def _mock_check_latest(*args, **kwargs):
109+
return Version(latest)
110+
111+
monkeypatch.setattr(config.environment, 'version', current)
112+
monkeypatch.setattr(_version, 'check_latest', _mock_check_latest)
113+
114+
_build_parser()
115+
captured = capsys.readouterr().err
116+
117+
msg = f"""\
118+
You are using NiBabies-{current}, and a newer version of NiBabies is available: {latest}.
119+
Please check out our documentation about how and when to upgrade:
120+
https://fmriprep.readthedocs.io/en/latest/faq.html#upgrading"""
121+
122+
assert (msg in captured) is expectation
123+
124+
125+
@pytest.mark.parametrize('flagged', [(True, None), (True, 'random reason'), (False, None)])
126+
def test_get_parser_blacklist(monkeypatch, capsys, flagged):
127+
"""Make sure the blacklisting banner is shown."""
128+
129+
def _mock_is_bl(*args, **kwargs):
130+
return flagged
131+
132+
monkeypatch.setattr(_version, 'is_flagged', _mock_is_bl)
133+
134+
_build_parser()
135+
captured = capsys.readouterr().err
136+
137+
assert ('FLAGGED' in captured) is flagged[0]
138+
if flagged[0]:
139+
assert (flagged[1] or 'reason: unknown') in captured
140+
141+
142+
def test_parse_args(tmp_path, minimal_bids):
143+
"""Basic smoke test showing that our parse_args() function
144+
implements the BIDS App protocol"""
145+
out_dir = tmp_path / 'out'
146+
work_dir = tmp_path / 'work'
147+
148+
parse_args(
149+
args=[
150+
str(minimal_bids),
151+
str(out_dir),
152+
'participant', # BIDS App
153+
'-w',
154+
str(work_dir), # Don't pollute CWD
155+
'--skip-bids-validation', # Empty files make BIDS sad
156+
]
157+
)
158+
assert config.execution.layout.root == str(minimal_bids)
159+
_reset_config()
160+
161+
162+
def test_bids_filter_file(tmp_path, capsys):
163+
bids_path = tmp_path / 'data'
164+
out_path = tmp_path / 'out'
165+
bff = tmp_path / 'filter.json'
166+
args = [str(bids_path), str(out_path), 'participant', '--bids-filter-file', str(bff)]
167+
bids_path.mkdir()
168+
169+
parser = _build_parser()
170+
171+
with pytest.raises(SystemExit):
172+
parser.parse_args(args)
173+
174+
err = capsys.readouterr().err
175+
assert 'Path does not exist:' in err
176+
177+
bff.write_text('{"invalid json": }')
178+
179+
with pytest.raises(SystemExit):
180+
parser.parse_args(args)
181+
182+
err = capsys.readouterr().err
183+
assert 'JSON syntax error in:' in err
184+
_reset_config()
185+
186+
187+
@pytest.mark.parametrize('st_ref', (None, '0', '1', '0.5', 'start', 'middle')) # noqa: PT007
188+
def test_slice_time_ref(tmp_path, st_ref):
189+
bids_path = tmp_path / 'data'
190+
out_path = tmp_path / 'out'
191+
args = [str(bids_path), str(out_path), 'participant']
192+
if st_ref:
193+
args.extend(['--slice-time-ref', st_ref])
194+
bids_path.mkdir()
195+
196+
parser = _build_parser()
197+
198+
parser.parse_args(args)
199+
_reset_config()
200+
201+
202+
@pytest.mark.parametrize(
203+
('args', 'expectation'),
204+
[
205+
([], False),
206+
(['--use-syn-sdc'], 'error'),
207+
(['--use-syn-sdc', 'error'], 'error'),
208+
(['--use-syn-sdc', 'warn'], 'warn'),
209+
(['--use-syn-sdc', 'other'], (SystemExit, ArgumentError)),
210+
],
211+
)
212+
def test_use_syn_sdc(tmp_path, args, expectation):
213+
bids_path = tmp_path / 'data'
214+
out_path = tmp_path / 'out'
215+
args = [str(bids_path), str(out_path), 'participant'] + args
216+
bids_path.mkdir()
217+
218+
parser = _build_parser()
219+
220+
cm = nullcontext()
221+
if isinstance(expectation, tuple):
222+
cm = pytest.raises(expectation)
223+
224+
with cm:
225+
opts = parser.parse_args(args)
226+
227+
if not isinstance(expectation, tuple):
228+
assert opts.use_syn_sdc == expectation
229+
230+
_reset_config()
231+
232+
233+
def test_derivatives(tmp_path):
234+
"""Check the correct parsing of the derivatives argument."""
235+
bids_path = tmp_path / 'data'
236+
out_path = tmp_path / 'out'
237+
args = [str(bids_path), str(out_path), 'participant']
238+
bids_path.mkdir()
239+
240+
parser = _build_parser()
241+
242+
# Providing --derivatives without a path should raise an error
243+
temp_args = args + ['--derivatives']
244+
with pytest.raises((SystemExit, ArgumentError)):
245+
parser.parse_args(temp_args)
246+
_reset_config()
247+
248+
# Providing --derivatives without names should automatically label them
249+
temp_args = args + ['--derivatives', str(bids_path / 'derivatives/smriprep')]
250+
opts = parser.parse_args(temp_args)
251+
assert opts.derivatives == {'smriprep': bids_path / 'derivatives/smriprep'}
252+
_reset_config()
253+
254+
# Providing --derivatives with names should use them
255+
temp_args = args + [
256+
'--derivatives',
257+
f'anat={str(bids_path / "derivatives/smriprep")}',
258+
]
259+
opts = parser.parse_args(temp_args)
260+
assert opts.derivatives == {'anat': bids_path / 'derivatives/smriprep'}
261+
_reset_config()
262+
263+
# Providing multiple unlabeled derivatives with the same name should raise an error
264+
temp_args = args + [
265+
'--derivatives',
266+
str(bids_path / 'derivatives_01/smriprep'),
267+
str(bids_path / 'derivatives_02/smriprep'),
268+
]
269+
with pytest.raises(ValueError, match='Received duplicate derivative name'):
270+
parser.parse_args(temp_args)
271+
272+
_reset_config()

nibabies/conftest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""py.test configuration"""
22

3+
import json
34
from pathlib import Path
45
from tempfile import TemporaryDirectory
56

7+
import nibabel as nb
8+
import numpy as np
69
import pytest
710

811
from nibabies.data import load as load_data
@@ -37,3 +40,16 @@ def _populate_namespace(doctest_namespace, data_dir):
3740
doctest_namespace['data_dir'] = data_dir
3841
doctest_namespace['test_data'] = load_data.cached('../tests/data')
3942
doctest_namespace['Path'] = Path
43+
44+
45+
@pytest.fixture
46+
def minimal_bids(tmp_path):
47+
bids = tmp_path / 'bids'
48+
bids.mkdir()
49+
Path.write_text(
50+
bids / 'dataset_description.json', json.dumps({'Name': 'Test DS', 'BIDSVersion': '1.8.0'})
51+
)
52+
T1w = bids / 'sub-01' / 'anat' / 'sub-01_T1w.nii.gz'
53+
T1w.parent.mkdir(parents=True)
54+
nb.Nifti1Image(np.zeros((5, 5, 5)), np.eye(4)).to_filename(T1w)
55+
return bids

0 commit comments

Comments
 (0)