Skip to content

Commit d3b8210

Browse files
authored
Merge pull request #11 from cbinyu/BIDSFile_helper
ENH: Add BIDSFile class
2 parents bdbc91e + 2886525 commit d3b8210

File tree

2 files changed

+160
-2
lines changed

2 files changed

+160
-2
lines changed

heudiconv/bids.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,7 @@ def get_key_info_for_fmap_assignment(json_file, matching_parameter='ImagingVolum
619619
modality = op.basename(op.dirname(json_file))
620620
if modality == 'fmap':
621621
# extract the <acq> entity:
622-
acq_label = re.search('(?<=[/_]acq-)\w+', json_file).group(0).split('_')[0]
622+
acq_label = BIDSFile.parse(op.basename(json_file))['acq']
623623
if any(s in acq_label.lower() for s in ['fmri', 'bold', 'func']):
624624
key_info = ['func']
625625
elif any(s in acq_label.lower() for s in ['diff', 'dwi']):
@@ -915,3 +915,94 @@ def populate_intended_for(path_to_bids_session, matching_parameters='ImagingVolu
915915
# Add this intended_for to all fmap files in the fmap_group:
916916
for fm_json in unique_fmap_groups[fmap_group]:
917917
update_json(fm_json, {"IntendedFor": intended_for}, pretty=True)
918+
919+
920+
class BIDSFile(object):
921+
""" as defined in https://bids-specification.readthedocs.io/en/stable/99-appendices/04-entity-table.html
922+
which might soon become machine readable
923+
order matters
924+
"""
925+
926+
_known_entities = ['sub', 'ses', 'task', 'acq', 'ce', 'rec', 'dir', 'run', 'mod',
927+
'echo', 'flip', 'inv', 'mt', 'part', 'recording',
928+
]
929+
930+
def __init__(self, entities, suffix, extension):
931+
self._entities = entities
932+
self._suffix = suffix
933+
self._extension = extension
934+
935+
def __eq__(self, other):
936+
if not isinstance(other, self.__class__):
937+
return False
938+
if (
939+
all([other[k] == v for k, v in self._entities.items()])
940+
and self.extension == other.extension
941+
and self.suffix == other.suffix
942+
):
943+
return True
944+
else:
945+
return False
946+
947+
@classmethod
948+
def parse(cls, filename):
949+
""" Parse the filename for BIDS entities, suffix and extension """
950+
# use re.findall to find all lower-case-letters + '-' + alphanumeric + '_' pairs:
951+
entities_list = re.findall('([a-z]+)-([a-zA-Z0-9]+)[_]*', filename)
952+
# keep only those in the _known_entities list:
953+
entities = {k: v for k, v in entities_list if k in BIDSFile._known_entities}
954+
# get whatever comes after the last key-value pair, and remove any '_' that
955+
# might come in front:
956+
ending = filename.split('-'.join(entities_list[-1]))[-1]
957+
ending = remove_prefix(ending, '_')
958+
# the first dot ('.') separates the suffix from the extension:
959+
if '.' in ending:
960+
suffix, extension = ending.split('.', 1)
961+
else:
962+
suffix, extension = ending, None
963+
return BIDSFile(entities, suffix, extension)
964+
965+
def __str__(self):
966+
""" reconstitute in a legit BIDS filename using the order from entity table """
967+
if 'sub' not in self._entities:
968+
raise ValueError('The \'sub-\' entity is mandatory')
969+
# reconstitute the ending for the filename:
970+
suffix = '_' + self.suffix if self.suffix else ''
971+
extension = '.' + self.extension if self.extension else ''
972+
return '_'.join(
973+
['-'.join([e, self._entities[e]]) for e in self._known_entities if e in self._entities]
974+
) + suffix + extension
975+
976+
def __getitem__(self, entity):
977+
return self._entities[entity] if entity in self._entities else None
978+
979+
def __setitem__(self, entity, value): # would puke with some exception if already known
980+
return self.set(entity, value, overwrite=False)
981+
982+
def set(self, entity, value, overwrite=True):
983+
if entity not in self._entities:
984+
# just set it; no complains here
985+
self._entities[entity] = value
986+
elif overwrite:
987+
lgr.warning("Overwriting the entity %s from %s to %s for file %s",
988+
str(entity),
989+
str(self[entity]),
990+
str(value),
991+
self.__str__()
992+
)
993+
self._entities[entity] = value
994+
else:
995+
# if it already exists, and overwrite is false:
996+
lgr.warning("Setting the entity %s to %s for file %s failed",
997+
str(entity),
998+
str(value),
999+
self.__str__()
1000+
)
1001+
1002+
@property # as needed make them RW
1003+
def suffix(self):
1004+
return self._suffix
1005+
1006+
@property
1007+
def extension(self):
1008+
return self._extension

heudiconv/tests/test_bids.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import re
55
import os
66
import os.path as op
7-
from random import random
7+
from random import (random,
8+
shuffle,
9+
)
810
from datetime import (datetime,
911
timedelta,
1012
)
@@ -33,6 +35,7 @@
3335
SHIM_KEY,
3436
AllowedCriteriaForFmapAssignment,
3537
KeyInfoForForce,
38+
BIDSFile,
3639
)
3740

3841
import pytest
@@ -868,3 +871,67 @@ def test_populate_intended_for(
868871
assert '{p}_acq-unmatched_bold.nii.gz'.format(p=run_prefix) not in data['IntendedFor']
869872
else:
870873
assert 'IntendedFor' not in data.keys()
874+
875+
876+
def test_BIDSFile():
877+
""" Tests for the BIDSFile class """
878+
879+
# define entities in the correct order:
880+
sorted_entities = [
881+
('sub', 'Jason'),
882+
('acq', 'Treadstone'),
883+
('run', '2'),
884+
('echo', '1'),
885+
]
886+
# 'sub-Jason_acq-Treadstone_run-2_echo-1':
887+
expected_sorted_str = '_'.join(['-'.join(e) for e in sorted_entities])
888+
# expected BIDSFile:
889+
suffix = 'T1w'
890+
extension = 'nii.gz'
891+
expected_bids_file = BIDSFile(OrderedDict(sorted_entities), suffix, extension)
892+
893+
# entities in random order:
894+
idcs = list(range(len(sorted_entities)))
895+
shuffle(idcs)
896+
shuffled_entities = [sorted_entities[i] for i in idcs]
897+
shuffled_str = '_'.join(['-'.join(e) for e in shuffled_entities])
898+
899+
# Test __eq__ method.
900+
# It should consider equal BIDSFiles with the same entities even in different order:
901+
assert BIDSFile(OrderedDict(shuffled_entities), suffix, extension) == expected_bids_file
902+
903+
# Test __getitem__:
904+
assert all([expected_bids_file[k] == v for k, v in shuffled_entities])
905+
906+
# Test filename parser and __str__ method:
907+
# Note: the __str__ method should return entities in the correct order
908+
ending = '_T1w.nii.gz' # suffix + extension
909+
my_bids_file = BIDSFile.parse(shuffled_str + ending)
910+
assert my_bids_file == expected_bids_file
911+
assert str(my_bids_file) == expected_sorted_str + ending
912+
913+
ending = '.json' # just extension
914+
my_bids_file = BIDSFile.parse(shuffled_str + ending)
915+
assert my_bids_file.suffix == ''
916+
assert str(my_bids_file) == expected_sorted_str + ending
917+
918+
ending = '_T1w' # just suffix
919+
my_bids_file = BIDSFile.parse(shuffled_str + ending)
920+
assert my_bids_file.extension is None
921+
assert str(my_bids_file) == expected_sorted_str + ending
922+
923+
# Complain if entity 'sub' is not set:
924+
with pytest.raises(ValueError) as e_info:
925+
assert str(BIDSFile.parse('dir-reversed.json'))
926+
assert 'sub-' in e_info.value
927+
928+
# Test set method:
929+
# -for a new entity, just set it without a complaint:
930+
my_bids_file['dir'] = 'AP'
931+
assert my_bids_file['dir'] == 'AP'
932+
# -for an existing entity, don't change it by default:
933+
my_bids_file['echo'] = '2'
934+
assert my_bids_file['echo'] == expected_bids_file['echo'] # still the original value
935+
# -for an existing entity, you can overwrite it with "set":
936+
my_bids_file.set('echo', '2')
937+
assert my_bids_file['echo'] == '2'

0 commit comments

Comments
 (0)