Skip to content

Commit 08390bd

Browse files
committed
RF: move BIDSFile into bids.schema and make it load ordered entities from schema
1 parent 7f9d585 commit 08390bd

File tree

4 files changed

+167
-164
lines changed

4 files changed

+167
-164
lines changed

heudiconv/bids/schema.py

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
from pathlib import Path
2+
import re
23
import yaml
34

5+
from heudiconv.utils import remove_prefix
6+
7+
import logging
8+
9+
lgr = logging.getLogger(__name__)
10+
411

512
def _load_entities_order():
613
# we carry the copy of the schema
714
schema_p = Path(__file__).parent / "data" / "schema"
815
with (schema_p / "objects" / "entities.yaml").open() as f:
9-
entities = yaml.load(f)
16+
entities = yaml.load(f, yaml.SafeLoader)
1017

1118
with (schema_p / "rules" / "entities.yaml").open() as f:
12-
entities_full_order = yaml.load(f)
19+
entities_full_order = yaml.load(f, yaml.SafeLoader)
1320

1421
# map from full name to short "entity"
1522
return [entities[e]["entity"] for e in entities_full_order]
@@ -23,8 +30,82 @@ class BIDSFile:
2330

2431
_known_entities = _load_entities_order()
2532

33+
def __init__(self, entities, suffix, extension):
34+
self._entities = entities
35+
self._suffix = suffix
36+
self._extension = extension
37+
38+
def __eq__(self, other):
39+
if not isinstance(other, self.__class__):
40+
return False
41+
if (
42+
all([other[k] == v for k, v in self._entities.items()])
43+
and self.extension == other.extension
44+
and self.suffix == other.suffix
45+
):
46+
return True
47+
else:
48+
return False
49+
50+
@classmethod
51+
def parse(cls, filename):
52+
""" Parse the filename for BIDS entities, suffix and extension """
53+
# use re.findall to find all lower-case-letters + '-' + alphanumeric + '_' pairs:
54+
entities_list = re.findall('([a-z]+)-([a-zA-Z0-9]+)[_]*', filename)
55+
# keep only those in the _known_entities list:
56+
entities = {k: v for k, v in entities_list if k in BIDSFile._known_entities}
57+
# get whatever comes after the last key-value pair, and remove any '_' that
58+
# might come in front:
59+
ending = filename.split('-'.join(entities_list[-1]))[-1]
60+
ending = remove_prefix(ending, '_')
61+
# the first dot ('.') separates the suffix from the extension:
62+
if '.' in ending:
63+
suffix, extension = ending.split('.', 1)
64+
else:
65+
suffix, extension = ending, None
66+
return BIDSFile(entities, suffix, extension)
67+
68+
def __str__(self):
69+
""" reconstitute in a legit BIDS filename using the order from entity table """
70+
if 'sub' not in self._entities:
71+
raise ValueError('The \'sub-\' entity is mandatory')
72+
# reconstitute the ending for the filename:
73+
suffix = '_' + self.suffix if self.suffix else ''
74+
extension = '.' + self.extension if self.extension else ''
75+
return '_'.join(
76+
['-'.join([e, self._entities[e]]) for e in self._known_entities if e in self._entities]
77+
) + suffix + extension
78+
79+
def __getitem__(self, entity):
80+
return self._entities[entity] if entity in self._entities else None
81+
82+
def __setitem__(self, entity, value): # would puke with some exception if already known
83+
return self.set(entity, value, overwrite=False)
84+
85+
def set(self, entity, value, overwrite=True):
86+
if entity not in self._entities:
87+
# just set it; no complains here
88+
self._entities[entity] = value
89+
elif overwrite:
90+
lgr.warning("Overwriting the entity %s from %s to %s for file %s",
91+
str(entity),
92+
str(self[entity]),
93+
str(value),
94+
self.__str__()
95+
)
96+
self._entities[entity] = value
97+
else:
98+
# if it already exists, and overwrite is false:
99+
lgr.warning("Setting the entity %s to %s for file %s failed",
100+
str(entity),
101+
str(value),
102+
self.__str__()
103+
)
104+
105+
@property # as needed make them RW
106+
def suffix(self):
107+
return self._suffix
26108

27-
# TEMP: just for now, could be moved/removed
28-
def test_BIDSFile():
29-
assert BIDSFile._known_entities[:2] == ['sub', 'ses']
30-
print(BIDSFile._known_entities)
109+
@property
110+
def extension(self):
111+
return self._extension

heudiconv/bids/tests/test_schema.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from collections import OrderedDict
2+
from random import shuffle
3+
4+
import pytest
5+
6+
from heudiconv.bids import BIDSFile
7+
8+
9+
def test_BIDSFile_known_entries():
10+
assert BIDSFile._known_entities[:2] == ['sub', 'ses']
11+
assert len(BIDSFile._known_entities) > 10 # we do have many
12+
assert 'run' in BIDSFile._known_entities
13+
14+
15+
def test_BIDSFile():
16+
""" Tests for the BIDSFile class """
17+
18+
# define entities in the correct order:
19+
sorted_entities = [
20+
('sub', 'Jason'),
21+
('acq', 'Treadstone'),
22+
('run', '2'),
23+
('echo', '1'),
24+
]
25+
# 'sub-Jason_acq-Treadstone_run-2_echo-1':
26+
expected_sorted_str = '_'.join(['-'.join(e) for e in sorted_entities])
27+
# expected BIDSFile:
28+
suffix = 'T1w'
29+
extension = 'nii.gz'
30+
expected_bids_file = BIDSFile(OrderedDict(sorted_entities), suffix, extension)
31+
32+
# entities in random order:
33+
idcs = list(range(len(sorted_entities)))
34+
shuffle(idcs)
35+
shuffled_entities = [sorted_entities[i] for i in idcs]
36+
shuffled_str = '_'.join(['-'.join(e) for e in shuffled_entities])
37+
38+
# Test __eq__ method.
39+
# It should consider equal BIDSFiles with the same entities even in different order:
40+
assert BIDSFile(OrderedDict(shuffled_entities), suffix, extension) == expected_bids_file
41+
42+
# Test __getitem__:
43+
assert all([expected_bids_file[k] == v for k, v in shuffled_entities])
44+
45+
# Test filename parser and __str__ method:
46+
# Note: the __str__ method should return entities in the correct order
47+
ending = '_T1w.nii.gz' # suffix + extension
48+
my_bids_file = BIDSFile.parse(shuffled_str + ending)
49+
assert my_bids_file == expected_bids_file
50+
assert str(my_bids_file) == expected_sorted_str + ending
51+
52+
ending = '.json' # just extension
53+
my_bids_file = BIDSFile.parse(shuffled_str + ending)
54+
assert my_bids_file.suffix == ''
55+
assert str(my_bids_file) == expected_sorted_str + ending
56+
57+
ending = '_T1w' # just suffix
58+
my_bids_file = BIDSFile.parse(shuffled_str + ending)
59+
assert my_bids_file.extension is None
60+
assert str(my_bids_file) == expected_sorted_str + ending
61+
62+
# Complain if entity 'sub' is not set:
63+
with pytest.raises(ValueError) as e_info:
64+
assert str(BIDSFile.parse('dir-reversed.json'))
65+
assert 'sub-' in e_info.value
66+
67+
# Test set method:
68+
# -for a new entity, just set it without a complaint:
69+
my_bids_file['dir'] = 'AP'
70+
assert my_bids_file['dir'] == 'AP'
71+
# -for an existing entity, don't change it by default:
72+
my_bids_file['echo'] = '2'
73+
assert my_bids_file['echo'] == expected_bids_file['echo'] # still the original value
74+
# -for an existing entity, you can overwrite it with "set":
75+
my_bids_file.set('echo', '2')
76+
assert my_bids_file['echo'] == '2'

heudiconv/bids/tests/test_utils.py

Lines changed: 1 addition & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
"""Test functions in heudiconv.bids module.
1+
"""Test functions of heudiconv.bids.utils module.
22
"""
33

44
import re
55
import os
66
import os.path as op
77
from pathlib import Path
88
from random import (random,
9-
shuffle,
109
choice,
1110
seed
1211
)
@@ -41,7 +40,6 @@
4140
SHIM_KEY,
4241
AllowedCriteriaForFmapAssignment,
4342
KeyInfoForForce,
44-
BIDSFile,
4543
)
4644

4745
from heudiconv.tests.utils import (
@@ -1052,67 +1050,3 @@ def test_populate_intended_for(
10521050
assert '{p}_acq-unmatched_bold.nii.gz'.format(p=run_prefix) not in data['IntendedFor']
10531051
else:
10541052
assert 'IntendedFor' not in data.keys()
1055-
1056-
1057-
def test_BIDSFile():
1058-
""" Tests for the BIDSFile class """
1059-
1060-
# define entities in the correct order:
1061-
sorted_entities = [
1062-
('sub', 'Jason'),
1063-
('acq', 'Treadstone'),
1064-
('run', '2'),
1065-
('echo', '1'),
1066-
]
1067-
# 'sub-Jason_acq-Treadstone_run-2_echo-1':
1068-
expected_sorted_str = '_'.join(['-'.join(e) for e in sorted_entities])
1069-
# expected BIDSFile:
1070-
suffix = 'T1w'
1071-
extension = 'nii.gz'
1072-
expected_bids_file = BIDSFile(OrderedDict(sorted_entities), suffix, extension)
1073-
1074-
# entities in random order:
1075-
idcs = list(range(len(sorted_entities)))
1076-
shuffle(idcs)
1077-
shuffled_entities = [sorted_entities[i] for i in idcs]
1078-
shuffled_str = '_'.join(['-'.join(e) for e in shuffled_entities])
1079-
1080-
# Test __eq__ method.
1081-
# It should consider equal BIDSFiles with the same entities even in different order:
1082-
assert BIDSFile(OrderedDict(shuffled_entities), suffix, extension) == expected_bids_file
1083-
1084-
# Test __getitem__:
1085-
assert all([expected_bids_file[k] == v for k, v in shuffled_entities])
1086-
1087-
# Test filename parser and __str__ method:
1088-
# Note: the __str__ method should return entities in the correct order
1089-
ending = '_T1w.nii.gz' # suffix + extension
1090-
my_bids_file = BIDSFile.parse(shuffled_str + ending)
1091-
assert my_bids_file == expected_bids_file
1092-
assert str(my_bids_file) == expected_sorted_str + ending
1093-
1094-
ending = '.json' # just extension
1095-
my_bids_file = BIDSFile.parse(shuffled_str + ending)
1096-
assert my_bids_file.suffix == ''
1097-
assert str(my_bids_file) == expected_sorted_str + ending
1098-
1099-
ending = '_T1w' # just suffix
1100-
my_bids_file = BIDSFile.parse(shuffled_str + ending)
1101-
assert my_bids_file.extension is None
1102-
assert str(my_bids_file) == expected_sorted_str + ending
1103-
1104-
# Complain if entity 'sub' is not set:
1105-
with pytest.raises(ValueError) as e_info:
1106-
assert str(BIDSFile.parse('dir-reversed.json'))
1107-
assert 'sub-' in e_info.value
1108-
1109-
# Test set method:
1110-
# -for a new entity, just set it without a complaint:
1111-
my_bids_file['dir'] = 'AP'
1112-
assert my_bids_file['dir'] == 'AP'
1113-
# -for an existing entity, don't change it by default:
1114-
my_bids_file['echo'] = '2'
1115-
assert my_bids_file['echo'] == expected_bids_file['echo'] # still the original value
1116-
# -for an existing entity, you can overwrite it with "set":
1117-
my_bids_file.set('echo', '2')
1118-
assert my_bids_file['echo'] == '2'

heudiconv/bids/utils.py

Lines changed: 3 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
)
3131
from .. import __version__
3232

33+
from .schema import BIDSFile
34+
35+
3336
lgr = logging.getLogger(__name__)
3437

3538
# Fields to be populated in _scans files. Order matters
@@ -933,94 +936,3 @@ def populate_intended_for(path_to_bids_session, matching_parameters, criterion):
933936
# Add this intended_for to all fmap files in the fmap_group:
934937
for fm_json in unique_fmap_groups[fmap_group]:
935938
update_json(fm_json, {"IntendedFor": intended_for}, pretty=True)
936-
937-
938-
class BIDSFile(object):
939-
""" as defined in https://bids-specification.readthedocs.io/en/stable/99-appendices/04-entity-table.html
940-
which might soon become machine readable
941-
order matters
942-
"""
943-
944-
_known_entities = ['sub', 'ses', 'task', 'acq', 'ce', 'rec', 'dir', 'run', 'mod',
945-
'echo', 'flip', 'inv', 'mt', 'part', 'recording',
946-
]
947-
948-
def __init__(self, entities, suffix, extension):
949-
self._entities = entities
950-
self._suffix = suffix
951-
self._extension = extension
952-
953-
def __eq__(self, other):
954-
if not isinstance(other, self.__class__):
955-
return False
956-
if (
957-
all([other[k] == v for k, v in self._entities.items()])
958-
and self.extension == other.extension
959-
and self.suffix == other.suffix
960-
):
961-
return True
962-
else:
963-
return False
964-
965-
@classmethod
966-
def parse(cls, filename):
967-
""" Parse the filename for BIDS entities, suffix and extension """
968-
# use re.findall to find all lower-case-letters + '-' + alphanumeric + '_' pairs:
969-
entities_list = re.findall('([a-z]+)-([a-zA-Z0-9]+)[_]*', filename)
970-
# keep only those in the _known_entities list:
971-
entities = {k: v for k, v in entities_list if k in BIDSFile._known_entities}
972-
# get whatever comes after the last key-value pair, and remove any '_' that
973-
# might come in front:
974-
ending = filename.split('-'.join(entities_list[-1]))[-1]
975-
ending = remove_prefix(ending, '_')
976-
# the first dot ('.') separates the suffix from the extension:
977-
if '.' in ending:
978-
suffix, extension = ending.split('.', 1)
979-
else:
980-
suffix, extension = ending, None
981-
return BIDSFile(entities, suffix, extension)
982-
983-
def __str__(self):
984-
""" reconstitute in a legit BIDS filename using the order from entity table """
985-
if 'sub' not in self._entities:
986-
raise ValueError('The \'sub-\' entity is mandatory')
987-
# reconstitute the ending for the filename:
988-
suffix = '_' + self.suffix if self.suffix else ''
989-
extension = '.' + self.extension if self.extension else ''
990-
return '_'.join(
991-
['-'.join([e, self._entities[e]]) for e in self._known_entities if e in self._entities]
992-
) + suffix + extension
993-
994-
def __getitem__(self, entity):
995-
return self._entities[entity] if entity in self._entities else None
996-
997-
def __setitem__(self, entity, value): # would puke with some exception if already known
998-
return self.set(entity, value, overwrite=False)
999-
1000-
def set(self, entity, value, overwrite=True):
1001-
if entity not in self._entities:
1002-
# just set it; no complains here
1003-
self._entities[entity] = value
1004-
elif overwrite:
1005-
lgr.warning("Overwriting the entity %s from %s to %s for file %s",
1006-
str(entity),
1007-
str(self[entity]),
1008-
str(value),
1009-
self.__str__()
1010-
)
1011-
self._entities[entity] = value
1012-
else:
1013-
# if it already exists, and overwrite is false:
1014-
lgr.warning("Setting the entity %s to %s for file %s failed",
1015-
str(entity),
1016-
str(value),
1017-
self.__str__()
1018-
)
1019-
1020-
@property # as needed make them RW
1021-
def suffix(self):
1022-
return self._suffix
1023-
1024-
@property
1025-
def extension(self):
1026-
return self._extension

0 commit comments

Comments
 (0)