Skip to content

Commit 80e9d2e

Browse files
committed
FIX: Make lists and dicts hashable
Resolves: #683.
1 parent 5ea3103 commit 80e9d2e

File tree

4 files changed

+85
-43
lines changed

4 files changed

+85
-43
lines changed

bids/layout/layout.py

Lines changed: 43 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from sqlalchemy.sql.expression import cast
1616
from bids_validator import BIDSValidator
1717

18-
from ..utils import listify, natural_sort
18+
from ..utils import listify, natural_sort, hashablefy
1919
from ..external import inflect
2020
from ..exceptions import (
2121
BIDSEntityError,
@@ -591,6 +591,10 @@ def get(self, return_type='object', target=None, scope='all',
591591
A list of BIDSFiles (default) or strings (see return_type).
592592
"""
593593

594+
if return_type not in ("object", "file", "filename", "id", "dir"):
595+
raise ValueError("Invalid return_type specified (must be one "
596+
"of 'object', 'file', 'filename', 'id', or 'dir'.")
597+
594598
if absolute_paths is False:
595599
absolute_path_deprecation_warning()
596600

@@ -637,6 +641,9 @@ def get(self, return_type='object', target=None, scope='all',
637641
message = "Valid targets are: {}".format(potential)
638642
raise TargetError(("Unknown target '{}'. " + message)
639643
.format(target))
644+
elif target is None and return_type in ['id', 'dir']:
645+
raise TargetError('If return_type is "id" or "dir", a valid '
646+
'target entity must also be specified.')
640647

641648
results = []
642649
for l in layouts:
@@ -663,49 +670,42 @@ def get(self, return_type='object', target=None, scope='all',
663670
results[i] = fi
664671

665672
if return_type.startswith('file'):
666-
results = natural_sort([f.path for f in results])
667-
668-
elif return_type in ['id', 'dir']:
669-
if target is None:
670-
raise TargetError('If return_type is "id" or "dir", a valid '
671-
'target entity must also be specified.')
672-
673-
metadata = target not in self.get_entities(metadata=False)
674-
675-
if return_type == 'id':
676-
ent_iter = (x.get_entities(metadata=metadata) for x in results)
677-
results = list({
678-
ents[target] for ents in ent_iter if target in ents
679-
})
680-
681-
elif return_type == 'dir':
682-
template = entities[target].directory
683-
if template is None:
684-
raise ValueError('Return type set to directory, but no '
685-
'directory template is defined for the '
686-
'target entity (\"%s\").' % target)
687-
# Construct regex search pattern from target directory template
688-
# On Windows, the regex won't compile if, e.g., there is a folder starting with "U" on the path.
689-
# Converting to a POSIX path with forward slashes solves this.
690-
template = self._root.as_posix() + template
691-
to_rep = re.findall(r'{(.*?)\}', template)
692-
for ent in to_rep:
693-
patt = entities[ent].pattern
694-
template = template.replace('{%s}' % ent, patt)
695-
# Avoid matching subfolders. We are working with POSIX paths here, so we explicitly use "/"
696-
# as path separator.
697-
template += r'[^/]*$'
698-
matches = [
699-
f.dirname if absolute_paths else str(f._dirname.relative_to(self._root)) # noqa: E501
700-
for f in results
701-
if re.search(template, f._dirname.as_posix())
702-
]
703-
704-
results = natural_sort(list(set(matches)))
673+
return natural_sort([f.path for f in results])
705674

706-
else:
707-
raise ValueError("Invalid return_type specified (must be one "
708-
"of 'tuple', 'filename', 'id', or 'dir'.")
675+
metadata = target not in self.get_entities(metadata=False)
676+
677+
if return_type == 'id':
678+
ent_iter = (
679+
hashablefy(x.get_entities(metadata=metadata))
680+
for x in results
681+
)
682+
results = list({
683+
ents[target] for ents in ent_iter if target in ents
684+
})
685+
elif return_type == 'dir':
686+
template = entities[target].directory
687+
if template is None:
688+
raise ValueError('Return type set to directory, but no '
689+
'directory template is defined for the '
690+
'target entity (\"%s\").' % target)
691+
# Construct regex search pattern from target directory template
692+
# On Windows, the regex won't compile if, e.g., there is a folder starting with "U" on the path.
693+
# Converting to a POSIX path with forward slashes solves this.
694+
template = self._root.as_posix() + template
695+
to_rep = re.findall(r'{(.*?)\}', template)
696+
for ent in to_rep:
697+
patt = entities[ent].pattern
698+
template = template.replace('{%s}' % ent, patt)
699+
# Avoid matching subfolders. We are working with POSIX paths here, so we explicitly use "/"
700+
# as path separator.
701+
template += r'[^/]*$'
702+
matches = [
703+
f.dirname if absolute_paths else str(f._dirname.relative_to(self._root)) # noqa: E501
704+
for f in results
705+
if re.search(template, f._dirname.as_posix())
706+
]
707+
708+
results = natural_sort(list(set(matches)))
709709
else:
710710
results = natural_sort(results, 'path')
711711

bids/layout/tests/test_layout.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,27 @@ def test_get_tr(layout_7t_trt):
548548
assert tr == 4.0
549549

550550

551+
def test_get_nonhashable_metadata(layout_ds117):
552+
"""Test nonhashable metadata values (#683)."""
553+
assert layout_ds117.get_IntendedFor(subject=['01'])[0] == (
554+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-01_bold.nii.gz",
555+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-02_bold.nii.gz",
556+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-03_bold.nii.gz",
557+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-04_bold.nii.gz",
558+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-05_bold.nii.gz",
559+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-06_bold.nii.gz",
560+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-07_bold.nii.gz",
561+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-08_bold.nii.gz",
562+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-09_bold.nii.gz",
563+
)
564+
565+
landmarks = layout_ds117.get_AnatomicalLandmarkCoordinates(subject=['01'])[0]
566+
assert landmarks["Nasion"] == (43, 111, 95)
567+
assert landmarks["LPA"] == (140, 74, 16)
568+
assert landmarks["RPA"] == (143, 74, 173)
569+
570+
571+
551572
def test_to_df(layout_ds117):
552573
# Only filename entities
553574
df = layout_ds117.to_df()

bids/utils.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
import re
44
import os
55
from pathlib import Path
6+
from frozendict import frozendict as _frozendict
7+
8+
9+
# Monkeypatch to print out frozendicts *as if* they were dictionaries.
10+
class frozendict(_frozendict):
11+
"""A hashable dictionary type."""
12+
13+
def __repr__(self):
14+
"""Override frozendict representation."""
15+
return repr({k: v for k, v in self.items()})
616

717

818
def listify(obj):
@@ -11,6 +21,16 @@ def listify(obj):
1121
return obj if isinstance(obj, (list, tuple, type(None))) else [obj]
1222

1323

24+
def hashablefy(obj):
25+
''' Make dictionaries and lists hashable or raise. '''
26+
if isinstance(obj, list):
27+
return tuple([hashablefy(o) for o in obj])
28+
29+
if isinstance(obj, dict):
30+
return frozendict({k: hashablefy(v) for k, v in obj.items()})
31+
return obj
32+
33+
1434
def matches_entities(obj, entities, strict=False):
1535
''' Checks whether an object's entities match the input. '''
1636
if strict and set(obj.entities.keys()) != set(entities.keys()):

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ install_requires =
3131
bids-validator
3232
num2words
3333
click
34+
frozendict
3435
tests_require =
3536
pytest >=3.3
3637
mock

0 commit comments

Comments
 (0)