Skip to content

Commit c66a4d9

Browse files
authored
Merge pull request #1112 from bpinsard/pr/684
FIX: Make lists and dicts hashable
2 parents 2c0469b + 234f624 commit c66a4d9

File tree

4 files changed

+62
-10
lines changed

4 files changed

+62
-10
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ dependencies = [
3333
"num2words >=0.5.5",
3434
"click >=8.0",
3535
"universal_pathlib >=0.2.2",
36+
"frozendict >=2",
3637
]
3738
dynamic = ["version"]
3839

src/bids/layout/layout.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from sqlalchemy.sql.expression import cast
1717
from bids_validator import BIDSValidator
1818

19-
from ..utils import listify, natural_sort
19+
from ..utils import listify, natural_sort, hashablefy
2020
from ..external import inflect
2121
from ..exceptions import (
2222
BIDSDerivativesValidationError,
@@ -645,6 +645,13 @@ def get(self, return_type='object', target=None, scope='all',
645645
A list of BIDSFiles (default) or strings (see return_type).
646646
"""
647647

648+
if (
649+
not return_type.startswith(("obj", "file"))
650+
and return_type not in ("id", "dir")
651+
):
652+
raise ValueError(f"Invalid return_type <{return_type}> specified (must be one "
653+
"of 'object', 'file', 'filename', 'id', or 'dir').")
654+
648655
if absolute_paths is False:
649656
absolute_path_deprecation_warning()
650657

@@ -691,6 +698,9 @@ def get(self, return_type='object', target=None, scope='all',
691698
message = "Valid targets are: {}".format(potential)
692699
raise TargetError(("Unknown target '{}'. " + message)
693700
.format(target))
701+
elif target is None and return_type in ['id', 'dir']:
702+
raise TargetError('If return_type is "id" or "dir", a valid '
703+
'target entity must also be specified.')
694704

695705
results = []
696706
for l in layouts:
@@ -718,18 +728,22 @@ def get(self, return_type='object', target=None, scope='all',
718728

719729
if return_type.startswith('file'):
720730
results = natural_sort([f.path for f in results])
721-
722731
elif return_type in ['id', 'dir']:
723732
if target is None:
724733
raise TargetError('If return_type is "id" or "dir", a valid '
725734
'target entity must also be specified.')
726735

736+
metadata = target not in self.get_entities(metadata=False)
737+
727738
if return_type == 'id':
739+
ent_iter = (
740+
hashablefy(res.get_entities(metadata=metadata))
741+
for res in results if target in res.entities
742+
)
728743
results = list(dict.fromkeys(
729-
res.entities[target] for res in results
730-
if target in res.entities and isinstance(res.entities[target], Hashable)
744+
ents[target] for ents in ent_iter if target in ents
731745
))
732-
746+
results = natural_sort(list(set(results)))
733747
elif return_type == 'dir':
734748
template = entities[target].directory
735749
if template is None:
@@ -752,12 +766,7 @@ def get(self, return_type='object', target=None, scope='all',
752766
for f in results
753767
if re.search(template, f._dirname.as_posix())
754768
]
755-
756769
results = natural_sort(list(set(matches)))
757-
758-
else:
759-
raise ValueError("Invalid return_type specified (must be one "
760-
"of 'tuple', 'filename', 'id', or 'dir'.")
761770
else:
762771
results = natural_sort(results, 'path')
763772

src/bids/layout/tests/test_layout.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,27 @@ def test_get_tr(layout_7t_trt):
776776
assert sum([t in tr for t in [3.0, 4.0]]) == 2
777777

778778

779+
def test_get_nonhashable_metadata(layout_ds117):
780+
"""Test nonhashable metadata values (#683)."""
781+
assert layout_ds117.get_IntendedFor(subject=['01'])[0] == (
782+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-01_bold.nii.gz",
783+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-02_bold.nii.gz",
784+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-03_bold.nii.gz",
785+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-04_bold.nii.gz",
786+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-05_bold.nii.gz",
787+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-06_bold.nii.gz",
788+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-07_bold.nii.gz",
789+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-08_bold.nii.gz",
790+
"ses-mri/func/sub-01_ses-mri_task-facerecognition_run-09_bold.nii.gz",
791+
)
792+
793+
landmarks = layout_ds117.get_AnatomicalLandmarkCoordinates(subject=['01'])[0]
794+
assert landmarks["Nasion"] == (43, 111, 95)
795+
assert landmarks["LPA"] == (140, 74, 16)
796+
assert landmarks["RPA"] == (143, 74, 173)
797+
798+
799+
779800
def test_to_df(layout_ds117):
780801
# Only filename entities
781802
df = layout_ds117.to_df()

src/bids/utils.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,36 @@
22

33
import re
44
import os
5+
from pathlib import Path
6+
from frozendict import frozendict as _frozendict
57
from upath import UPath as Path
68

79

10+
# Monkeypatch to print out frozendicts *as if* they were dictionaries.
11+
class frozendict(_frozendict):
12+
"""A hashable dictionary type."""
13+
14+
def __repr__(self):
15+
"""Override frozendict representation."""
16+
return repr({k: v for k, v in self.items()})
17+
18+
819
def listify(obj):
920
''' Wraps all non-list or tuple objects in a list; provides a simple way
1021
to accept flexible arguments. '''
1122
return obj if isinstance(obj, (list, tuple, type(None))) else [obj]
1223

1324

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

0 commit comments

Comments
 (0)