Skip to content

Commit e2e2529

Browse files
committed
rf: Factor templateflow.api into classes
1 parent 214205d commit e2e2529

File tree

3 files changed

+554
-0
lines changed

3 files changed

+554
-0
lines changed

templateflow/client.py

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
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 2024 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+
"""TemplateFlow's Python Client."""
24+
25+
from __future__ import annotations
26+
27+
import sys
28+
from json import loads
29+
from pathlib import Path
30+
31+
from bids.layout import Query
32+
33+
from .conf.cache import CacheConfig, TemplateFlowCache
34+
35+
36+
class TemplateFlowClient:
37+
def __init__(self, cache=None, config=None):
38+
if cache is None:
39+
if config is None:
40+
config = CacheConfig()
41+
cache = TemplateFlowCache(config)
42+
self.cache = cache
43+
44+
def __getattr__(self, name: str):
45+
name = name.replace('ls_', 'get_')
46+
try:
47+
if name.startswith('get_') and name not in dir(self.cache.layout):
48+
return getattr(self.cache.layout, name)
49+
except AttributeError:
50+
pass
51+
msg = f"'{self.__class__.__name__}' object has no attribute '{name}'"
52+
raise AttributeError(msg) from None
53+
54+
def ls(self, template, **kwargs):
55+
"""
56+
List files pertaining to one or more templates.
57+
58+
Parameters
59+
----------
60+
template : str
61+
A template identifier (e.g., ``MNI152NLin2009cAsym``).
62+
63+
Keyword Arguments
64+
-----------------
65+
resolution: int or None
66+
Index to an specific spatial resolution of the template.
67+
suffix : str or None
68+
BIDS suffix
69+
atlas : str or None
70+
Name of a particular atlas
71+
hemi : str or None
72+
Hemisphere
73+
space : str or None
74+
Space template is mapped to
75+
density : str or None
76+
Surface density
77+
desc : str or None
78+
Description field
79+
80+
Examples
81+
--------
82+
>>> ls('MNI152Lin', resolution=1, suffix='T1w', desc=None) # doctest: +ELLIPSIS
83+
[PosixPath('.../tpl-MNI152Lin/tpl-MNI152Lin_res-01_T1w.nii.gz')]
84+
85+
>>> ls('MNI152Lin', resolution=2, suffix='T1w', desc=None) # doctest: +ELLIPSIS
86+
[PosixPath('.../tpl-MNI152Lin/tpl-MNI152Lin_res-02_T1w.nii.gz')]
87+
88+
>>> ls('MNI152Lin', suffix='T1w', desc=None) # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE
89+
[PosixPath('.../tpl-MNI152Lin/tpl-MNI152Lin_res-01_T1w.nii.gz'),
90+
PosixPath('.../tpl-MNI152Lin/tpl-MNI152Lin_res-02_T1w.nii.gz')]
91+
92+
>>> ls('fsLR', space=None, hemi='L',
93+
... density='32k', suffix='sphere') # doctest: +ELLIPSIS
94+
[PosixPath('.../tpl-fsLR_hemi-L_den-32k_sphere.surf.gii')]
95+
96+
>>> ls('fsLR', space='madeup')
97+
[]
98+
99+
"""
100+
# Normalize extensions to always have leading dot
101+
if 'extension' in kwargs:
102+
kwargs['extension'] = _normalize_ext(kwargs['extension'])
103+
104+
return [
105+
Path(p)
106+
for p in self.cache.layout.get(
107+
template=Query.ANY if template is None else template, return_type='file', **kwargs
108+
)
109+
]
110+
111+
def get(self, template, raise_empty=False, **kwargs):
112+
"""
113+
Pull files pertaining to one or more templates down.
114+
115+
Parameters
116+
----------
117+
template : str
118+
A template identifier (e.g., ``MNI152NLin2009cAsym``).
119+
raise_empty : bool, optional
120+
Raise exception if no files were matched
121+
122+
Keyword Arguments
123+
-----------------
124+
resolution: int or None
125+
Index to an specific spatial resolution of the template.
126+
suffix : str or None
127+
BIDS suffix
128+
atlas : str or None
129+
Name of a particular atlas
130+
hemi : str or None
131+
Hemisphere
132+
space : str or None
133+
Space template is mapped to
134+
density : str or None
135+
Surface density
136+
desc : str or None
137+
Description field
138+
139+
Examples
140+
--------
141+
>>> str(get('MNI152Lin', resolution=1, suffix='T1w', desc=None)) # doctest: +ELLIPSIS
142+
'.../tpl-MNI152Lin/tpl-MNI152Lin_res-01_T1w.nii.gz'
143+
144+
>>> str(get('MNI152Lin', resolution=2, suffix='T1w', desc=None)) # doctest: +ELLIPSIS
145+
'.../tpl-MNI152Lin/tpl-MNI152Lin_res-02_T1w.nii.gz'
146+
147+
>>> [str(p) for p in get(
148+
... 'MNI152Lin', suffix='T1w', desc=None)] # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE
149+
['.../tpl-MNI152Lin/tpl-MNI152Lin_res-01_T1w.nii.gz',
150+
'.../tpl-MNI152Lin/tpl-MNI152Lin_res-02_T1w.nii.gz']
151+
152+
>>> str(get('fsLR', space=None, hemi='L',
153+
... density='32k', suffix='sphere')) # doctest: +ELLIPSIS
154+
'.../tpl-fsLR_hemi-L_den-32k_sphere.surf.gii'
155+
156+
>>> get('fsLR', space='madeup')
157+
[]
158+
159+
>>> get('fsLR', raise_empty=True, space='madeup') # doctest: +IGNORE_EXCEPTION_DETAIL
160+
Traceback (most recent call last):
161+
Exception:
162+
...
163+
164+
"""
165+
# List files available
166+
out_file = self.ls(template, **kwargs)
167+
168+
if raise_empty and not out_file:
169+
raise Exception('No results found')
170+
171+
# Truncate possible S3 error files from previous attempts
172+
_truncate_s3_errors(out_file)
173+
174+
# Try DataLad first
175+
dl_missing = [p for p in out_file if not p.is_file()]
176+
if self.cache.config.use_datalad and dl_missing:
177+
for filepath in dl_missing:
178+
_datalad_get(self.cache.config, filepath)
179+
dl_missing.remove(filepath)
180+
181+
# Fall-back to S3 if some files are still missing
182+
s3_missing = [p for p in out_file if p.is_file() and p.stat().st_size == 0]
183+
for filepath in s3_missing + dl_missing:
184+
_s3_get(self.cache.config, filepath)
185+
186+
not_fetched = [str(p) for p in out_file if not p.is_file() or p.stat().st_size == 0]
187+
188+
if not_fetched:
189+
msg = 'Could not fetch template files: {}.'.format(', '.join(not_fetched))
190+
if dl_missing and not self.cache.config.use_datalad:
191+
msg += f"""\
192+
The $TEMPLATEFLOW_HOME folder {self.cache.config.root} seems to contain an initiated DataLad \
193+
dataset, but the environment variable $TEMPLATEFLOW_USE_DATALAD is not \
194+
set or set to one of (false, off, 0). Please set $TEMPLATEFLOW_USE_DATALAD \
195+
on (possible values: true, on, 1)."""
196+
197+
if s3_missing and self.cache.config.use_datalad:
198+
msg += f"""\
199+
The $TEMPLATEFLOW_HOME folder {self.cache.layout.root} seems to contain an plain \
200+
dataset, but the environment variable $TEMPLATEFLOW_USE_DATALAD is \
201+
set to one of (true, on, 1). Please set $TEMPLATEFLOW_USE_DATALAD \
202+
off (possible values: false, off, 0)."""
203+
204+
raise RuntimeError(msg)
205+
206+
if len(out_file) == 1:
207+
return out_file[0]
208+
return out_file
209+
210+
def get_metadata(self, template):
211+
"""
212+
Fetch one file from one template.
213+
214+
Parameters
215+
----------
216+
template : str
217+
A template identifier (e.g., ``MNI152NLin2009cAsym``).
218+
219+
Examples
220+
--------
221+
>>> get_metadata('MNI152Lin')['Name']
222+
'Linear ICBM Average Brain (ICBM152) Stereotaxic Registration Model'
223+
224+
"""
225+
tf_home = Path(self.cache.layout.root)
226+
filepath = tf_home / (f'tpl-{template}') / 'template_description.json'
227+
228+
# Ensure that template is installed and file is available
229+
if not filepath.is_file():
230+
_datalad_get(filepath)
231+
return loads(filepath.read_text())
232+
233+
def get_citations(self, template, bibtex=False):
234+
"""
235+
Fetch template citations
236+
237+
Parameters
238+
----------
239+
template : :obj:`str`
240+
A template identifier (e.g., ``MNI152NLin2009cAsym``).
241+
bibtex : :obj:`bool`, optional
242+
Generate citations in BibTeX format.
243+
244+
"""
245+
data = self.get_metadata(template)
246+
refs = data.get('ReferencesAndLinks', [])
247+
if isinstance(refs, dict):
248+
refs = list(refs.values())
249+
250+
if not bibtex:
251+
return refs
252+
253+
return [
254+
_to_bibtex(ref, template, idx, self.cache.config.timeout).rstrip()
255+
for idx, ref in enumerate(refs, 1)
256+
]
257+
258+
259+
def _datalad_get(config: CacheConfig, filepath: Path):
260+
if not filepath:
261+
return
262+
263+
from datalad import api
264+
from datalad.support.exceptions import IncompleteResultsError
265+
266+
try:
267+
api.get(filepath, dataset=config.root)
268+
except IncompleteResultsError as exc:
269+
if exc.failed[0]['message'] == 'path not associated with any dataset':
270+
api.install(path=config.root, source=config.origin, recursive=True)
271+
api.get(filepath, dataset=config.root)
272+
else:
273+
raise
274+
275+
276+
def _s3_get(config: CacheConfig, filepath: Path):
277+
from sys import stderr
278+
from urllib.parse import quote
279+
280+
import requests
281+
from tqdm import tqdm
282+
283+
path = quote(filepath.relative_to(config.root).as_posix())
284+
url = f'{config.http_root}/{path}'
285+
286+
print(f'Downloading {url}', file=stderr)
287+
# Streaming, so we can iterate over the response.
288+
r = requests.get(url, stream=True, timeout=config.timeout)
289+
if r.status_code != 200:
290+
raise RuntimeError(f'Failed to download {url} with status code {r.status_code}')
291+
292+
# Total size in bytes.
293+
total_size = int(r.headers.get('content-length', 0))
294+
block_size = 1024
295+
wrote = 0
296+
if not filepath.is_file():
297+
filepath.unlink()
298+
299+
with filepath.open('wb') as f:
300+
with tqdm(total=total_size, unit='B', unit_scale=True) as t:
301+
for data in r.iter_content(block_size):
302+
wrote = wrote + len(data)
303+
f.write(data)
304+
t.update(len(data))
305+
306+
if total_size != 0 and wrote != total_size:
307+
raise RuntimeError('ERROR, something went wrong')
308+
309+
310+
def _to_bibtex(doi, template, idx, timeout):
311+
if 'doi.org' not in doi:
312+
return doi
313+
314+
# Is a DOI URL
315+
import requests
316+
317+
response = requests.post(
318+
doi,
319+
headers={'Accept': 'application/x-bibtex; charset=utf-8'},
320+
timeout=timeout,
321+
)
322+
if not response.ok:
323+
print(
324+
f'Failed to convert DOI <{doi}> to bibtex, returning URL.',
325+
file=sys.stderr,
326+
)
327+
return doi
328+
329+
# doi.org may not honor requested charset, to safeguard force a bytestream with
330+
# response.content, then decode into UTF-8.
331+
bibtex = response.content.decode()
332+
333+
# doi.org / crossref may still point to the no longer preferred proxy service
334+
return bibtex.replace('http://dx.doi.org/', 'https://doi.org/')
335+
336+
337+
def _normalize_ext(value):
338+
"""
339+
Normalize extensions to have a leading dot.
340+
341+
Examples
342+
--------
343+
>>> _normalize_ext(".nii.gz")
344+
'.nii.gz'
345+
>>> _normalize_ext("nii.gz")
346+
'.nii.gz'
347+
>>> _normalize_ext(("nii", ".nii.gz"))
348+
['.nii', '.nii.gz']
349+
>>> _normalize_ext(("", ".nii.gz"))
350+
['', '.nii.gz']
351+
>>> _normalize_ext((None, ".nii.gz"))
352+
[None, '.nii.gz']
353+
>>> _normalize_ext([])
354+
[]
355+
356+
"""
357+
358+
if not value:
359+
return value
360+
361+
if isinstance(value, str):
362+
return f'{"" if value.startswith(".") else "."}{value}'
363+
return [_normalize_ext(v) for v in value]
364+
365+
366+
def _truncate_s3_errors(filepaths):
367+
"""
368+
Truncate XML error bodies saved by previous versions of TemplateFlow.
369+
370+
Parameters
371+
----------
372+
filepaths : list of Path
373+
List of file paths to check and truncate if necessary.
374+
"""
375+
for filepath in filepaths:
376+
if filepath.is_file(follow_symlinks=False) and 0 < filepath.stat().st_size < 1024:
377+
with open(filepath, 'rb') as f:
378+
content = f.read(100)
379+
if content.startswith(b'<?xml') and b'<Error><Code>' in content:
380+
filepath.write_bytes(b'') # Truncate file to zero bytes

0 commit comments

Comments
 (0)