Skip to content

Commit f70235b

Browse files
committed
ENH: Add check for updates and check whether version has been flagged.
Adds two new functions to assess the current version of fMRIPrep before operating. Close #1645.
1 parent 499dde7 commit f70235b

File tree

6 files changed

+302
-1
lines changed

6 files changed

+302
-1
lines changed

.versions.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"flagged": {
3+
"1.0.0": "Deprecated / too old"
4+
}
5+
}

fmriprep/cli/_version.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
"""Version CLI helpers."""
4+
5+
from pathlib import Path
6+
from datetime import datetime
7+
import requests
8+
from .. import __version__
9+
10+
11+
RELEASE_EXPIRY_DAYS = 14
12+
DATE_FMT = '%Y%m%d'
13+
14+
15+
def check_latest():
16+
"""Determine whether this is the latest version."""
17+
from packaging.version import Version, InvalidVersion
18+
19+
latest = None
20+
date = None
21+
outdated = None
22+
cachefile = Path.home() / '.cache' / 'fmriprep' / 'latest'
23+
cachefile.parent.mkdir(parents=True, exist_ok=True)
24+
25+
try:
26+
latest, date = cachefile.read_text().split('|')
27+
except Exception:
28+
pass
29+
else:
30+
try:
31+
latest = Version(latest)
32+
date = datetime.strptime(date, DATE_FMT)
33+
except (InvalidVersion, ValueError):
34+
latest = None
35+
else:
36+
if abs((datetime.now() - date).days) > RELEASE_EXPIRY_DAYS:
37+
outdated = True
38+
39+
if latest is None or outdated is True:
40+
try:
41+
response = requests.get(url='https://pypi.org/pypi/fmriprep/json')
42+
except Exception:
43+
pass
44+
45+
if response and response.status_code == 200:
46+
versions = [Version(rel) for rel in response.json()['releases'].keys()]
47+
versions = [rel for rel in versions if not rel.is_prerelease]
48+
if versions:
49+
latest = sorted(versions)[-1]
50+
else:
51+
latest = None
52+
53+
if latest is not None:
54+
try:
55+
cachefile.write_text('|'.join(('%s' % latest, datetime.now().strftime(DATE_FMT))))
56+
except Exception:
57+
pass
58+
59+
return latest
60+
61+
62+
def is_flagged():
63+
"""Check whether current version is flagged."""
64+
# https://raw.githubusercontent.com/poldracklab/fmriprep/master/.versions.json
65+
flagged = tuple()
66+
try:
67+
response = requests.get(url="""\
68+
https://raw.githubusercontent.com/poldracklab/fmriprep/master/.versions.json""")
69+
except Exception:
70+
pass
71+
72+
if response and response.status_code == 200:
73+
flagged = response.json().get('flagged', {}) or {}
74+
75+
if __version__ in flagged:
76+
return True, flagged[__version__]
77+
78+
return False, None

fmriprep/cli/run.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,10 @@ def get_parser():
4040
"""Build parser object"""
4141
from smriprep.cli.utils import ParseTemplates, output_space as _output_space
4242
from templateflow.api import templates
43+
from packaging.version import Version
4344
from ..__about__ import __version__
4445
from ..workflows.bold.resampling import NONSTANDARD_REFERENCES
46+
from ._version import check_latest, is_flagged
4547

4648
verstr = 'fmriprep v{}'.format(__version__)
4749

@@ -274,6 +276,26 @@ def get_parser():
274276
g_other.add_argument('--sloppy', action='store_true', default=False,
275277
help='Use low-quality tools for speed - TESTING ONLY')
276278

279+
currentv = Version(__version__)
280+
latest = check_latest()
281+
282+
if latest is not None and currentv < latest:
283+
print("""\
284+
WARNING: The current version of fMRIPrep (%s) is outdated.
285+
Please consider upgrading to the latest version %s.
286+
Before upgrading, please consider that mixing fMRIPrep versions
287+
within a single study is strongly discouraged.""" % (
288+
__version__, latest), file=sys.stderr)
289+
290+
_blist = is_flagged()
291+
if _blist[0]:
292+
_reason = _blist[1] or 'unknown'
293+
print("""\
294+
WARNING: Version %s of fMRIPrep (current) has been FLAGGED
295+
(reason: %s).
296+
That means some severe flaw was found in it and we strongly
297+
discourage its usage.""" % (__version__, _reason), file=sys.stderr)
298+
277299
return parser
278300

279301

@@ -283,7 +305,6 @@ def main():
283305
from multiprocessing import set_start_method, Process, Manager
284306
from ..utils.bids import write_derivative_description, validate_input_dir
285307
set_start_method('forkserver')
286-
287308
warnings.showwarning = _warn_redirect
288309
opts = get_parser().parse_args()
289310

fmriprep/cli/tests/__init__.py

Whitespace-only changes.

fmriprep/cli/tests/test_run.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Test CLI."""
2+
from packaging.version import Version
3+
import pytest
4+
from .. import _version
5+
from ... import __about__
6+
from ..run import get_parser
7+
8+
9+
@pytest.mark.parametrize(('current', 'latest'), [
10+
('1.0.0', '1.3.2'),
11+
('1.3.2', '1.3.2')
12+
])
13+
def test_get_parser_update(monkeypatch, capsys, current, latest):
14+
"""Make sure the out-of-date banner is shown."""
15+
expectation = Version(current) < Version(latest)
16+
17+
def _mock_check_latest(*args, **kwargs):
18+
return Version(latest)
19+
20+
monkeypatch.setattr(__about__, '__version__', current)
21+
monkeypatch.setattr(_version, 'check_latest', _mock_check_latest)
22+
23+
get_parser()
24+
captured = capsys.readouterr().err
25+
26+
msg = """\
27+
WARNING: The current version of fMRIPrep (%s) is outdated.
28+
Please consider upgrading to the latest version %s.
29+
Before upgrading, please consider that mixing fMRIPrep versions
30+
within a single study is strongly discouraged.""" % (current, latest)
31+
32+
assert (msg in captured) is expectation
33+
34+
35+
@pytest.mark.parametrize('flagged', [
36+
(True, None),
37+
(True, 'random reason'),
38+
(False, None),
39+
])
40+
def test_get_parser_blacklist(monkeypatch, capsys, flagged):
41+
"""Make sure the blacklisting banner is shown."""
42+
def _mock_is_bl(*args, **kwargs):
43+
return flagged
44+
45+
monkeypatch.setattr(_version, 'is_flagged', _mock_is_bl)
46+
47+
get_parser()
48+
captured = capsys.readouterr().err
49+
50+
assert ('FLAGGED' in captured) is flagged[0]
51+
if flagged[0]:
52+
assert ((flagged[1] or 'reason: unknown') in captured)

fmriprep/cli/tests/test_version.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Test version checks."""
2+
from datetime import datetime
3+
from pathlib import Path
4+
from packaging.version import Version
5+
import pytest
6+
from .. import _version
7+
from .._version import check_latest, DATE_FMT, requests, is_flagged
8+
9+
10+
class MockResponse:
11+
"""Mocks the requests module so that Pypi is not actually queried."""
12+
13+
status_code = 200
14+
_json = {
15+
"releases": {
16+
'1.0.0': None,
17+
'1.0.1': None,
18+
'1.1.0': None,
19+
'1.1.1rc1': None
20+
}
21+
}
22+
23+
def __init__(self, code=200, json=None):
24+
"""Allow setting different response codes."""
25+
self.status_code = code
26+
if json is not None:
27+
self._json = json
28+
29+
def json(self):
30+
"""Redefine the response object."""
31+
return self._json
32+
33+
34+
def test_check_latest1(tmpdir, monkeypatch):
35+
"""Test latest version check."""
36+
tmpdir.chdir()
37+
monkeypatch.setenv('HOME', str(tmpdir))
38+
assert str(Path.home()) == str(tmpdir)
39+
40+
def mock_get(*args, **kwargs):
41+
return MockResponse()
42+
monkeypatch.setattr(requests, "get", mock_get)
43+
44+
# Initially, cache should not exist
45+
cachefile = Path.home() / '.cache' / 'fmriprep' / 'latest'
46+
assert not cachefile.exists()
47+
48+
# First check actually fetches from pypi
49+
v = check_latest()
50+
assert cachefile.exists()
51+
assert isinstance(v, Version)
52+
assert v == Version('1.1.0')
53+
assert cachefile.read_text().split('|') == [str(v), datetime.now().strftime(DATE_FMT)]
54+
55+
# Second check - test the cache file is read
56+
cachefile.write_text('|'.join(('1.0.0', cachefile.read_text().split('|')[1])))
57+
v = check_latest()
58+
assert isinstance(v, Version)
59+
assert v == Version('1.0.0')
60+
61+
# Third check - forced oudating of cache
62+
cachefile.write_text('2.0.0|20180121')
63+
v = check_latest()
64+
assert isinstance(v, Version)
65+
assert v == Version('1.1.0')
66+
67+
68+
@pytest.mark.parametrize(('result', 'code', 'json'), [
69+
(None, 404, None),
70+
(None, 200, {'releases': {'1.0.0rc1': None}}),
71+
(Version('1.1.0'), 200, None),
72+
(Version('1.0.0'), 200, {'releases': {'1.0.0': None}}),
73+
])
74+
def test_check_latest2(tmpdir, monkeypatch, result, code, json):
75+
"""Test latest version check with varying server responses."""
76+
tmpdir.chdir()
77+
monkeypatch.setenv('HOME', str(tmpdir))
78+
assert str(Path.home()) == str(tmpdir)
79+
80+
def mock_get(*args, **kwargs):
81+
return MockResponse(code=code, json=json)
82+
monkeypatch.setattr(requests, "get", mock_get)
83+
84+
v = check_latest()
85+
if result is None:
86+
assert v is None
87+
else:
88+
assert isinstance(v, Version)
89+
assert v == result
90+
91+
92+
@pytest.mark.parametrize('bad_cache', [
93+
'3laj#r???d|3akajdf#',
94+
'2.0.0|3akajdf#',
95+
'|'.join(('2.0.0', datetime.now().strftime(DATE_FMT), '')),
96+
''
97+
])
98+
def test_check_latest3(tmpdir, monkeypatch, bad_cache):
99+
"""Test latest version check when the cache file is corrupted."""
100+
tmpdir.chdir()
101+
monkeypatch.setenv('HOME', str(tmpdir))
102+
assert str(Path.home()) == str(tmpdir)
103+
104+
def mock_get(*args, **kwargs):
105+
return MockResponse()
106+
monkeypatch.setattr(requests, "get", mock_get)
107+
108+
# Initially, cache should not exist
109+
cachefile = Path.home() / '.cache' / 'fmriprep' / 'latest'
110+
cachefile.parent.mkdir(parents=True, exist_ok=True)
111+
assert not cachefile.exists()
112+
113+
cachefile.write_text(bad_cache)
114+
v = check_latest()
115+
assert isinstance(v, Version)
116+
assert v == Version('1.1.0')
117+
118+
119+
@pytest.mark.parametrize(('result', 'version', 'code', 'json'), [
120+
(False, '1.2.1', 200, {'flagged': {'1.0.0': None}}),
121+
(True, '1.2.1', 200, {'flagged': {'1.2.1': None}}),
122+
(True, '1.2.1', 200, {'flagged': {'1.2.1': 'FATAL Bug!'}}),
123+
(False, '1.2.1', 404, {'flagged': {'1.0.0': None}}),
124+
(False, '1.2.1', 200, {'flagged': []}),
125+
(False, '1.2.1', 200, {}),
126+
])
127+
def test_is_flagged(monkeypatch, result, version, code, json):
128+
"""Test that the flagged-versions check is correct."""
129+
monkeypatch.setattr(_version, "__version__", version)
130+
131+
def mock_get(*args, **kwargs):
132+
return MockResponse(code=code, json=json)
133+
monkeypatch.setattr(requests, "get", mock_get)
134+
135+
val, reason = is_flagged()
136+
assert val is result
137+
138+
test_reason = None
139+
if val:
140+
test_reason = json.get('flagged', {}).get(version, None)
141+
142+
if test_reason is not None:
143+
assert reason == test_reason
144+
else:
145+
assert reason is None

0 commit comments

Comments
 (0)