""" Utility functions. """
import re
import os
from pathlib import Path
from frozendict import frozendict as _frozendict
from upath import UPath as Path
from functools import cache
# Monkeypatch to print out frozendicts *as if* they were dictionaries.
class frozendict(_frozendict):
"""A hashable dictionary type."""
def __repr__(self):
"""Override frozendict representation."""
return repr({k: v for k, v in self.items()})
[docs]
def listify(obj):
''' Wraps all non-list or tuple objects in a list; provides a simple way
to accept flexible arguments. '''
return obj if isinstance(obj, (list, tuple, type(None))) else [obj]
def hashablefy(obj):
''' Make dictionaries and lists hashable or raise. '''
if isinstance(obj, list):
return tuple([hashablefy(o) for o in obj])
if isinstance(obj, dict):
return frozendict({k: hashablefy(v) for k, v in obj.items()})
return obj
[docs]
def matches_entities(obj, entities, strict=False):
''' Checks whether an object's entities match the input. '''
if strict and set(obj.entities.keys()) != set(entities.keys()):
return False
comm_ents = list(set(obj.entities.keys()) & set(entities.keys()))
for k in comm_ents:
current = obj.entities[k]
target = entities[k]
if isinstance(target, (list, tuple)):
if current not in target:
return False
elif current != target:
return False
return True
def natural_sort(l, field=None):
'''
based on snippet found at https://stackoverflow.com/a/4836734/2445984
'''
convert = lambda text: int(text) if text.isdigit() else text.lower()
def alphanum_key(key):
if field is not None:
key = getattr(key, field)
if not isinstance(key, str):
key = str(key)
return [convert(c) for c in re.split('([0-9]+)', key)]
return sorted(l, key=alphanum_key)
[docs]
def convert_JSON(j):
""" Recursively convert CamelCase keys to snake_case.
From: https://stackoverflow.com/questions/17156078/
converting-identifier-naming-between-camelcase-and-
underscores-during-json-seria
"""
def camel_to_snake(s):
a = re.compile('((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))')
return a.sub(r'_\1', s).lower()
def convertArray(a):
newArr = []
for i in a:
if isinstance(i, list):
newArr.append(convertArray(i))
elif isinstance(i, dict):
newArr.append(convert_JSON(i))
else:
newArr.append(i)
return newArr
out = {}
for k, value in j.items():
newK = camel_to_snake(k)
# Replace transformation uses a dict, so skip lower-casing
if isinstance(value, dict) and k != 'Replace':
out[newK] = convert_JSON(value)
elif isinstance(value, list):
out[newK] = convertArray(value)
else:
out[newK] = value
return out
def splitext(path):
"""splitext for paths with directories that may contain dots.
From https://stackoverflow.com/questions/5930036/separating-file-extensions-using-python-os-path-module"""
li = []
path_without_extensions = os.path.join(os.path.dirname(path),
os.path.basename(path).split(os.extsep)[0])
extensions = os.path.basename(path).split(os.extsep)[1:]
li.append(path_without_extensions)
# li.append(extensions) if you want extensions in another list inside the list that is returned.
li.extend(extensions)
return li
[docs]
def make_bidsfile(filename):
"""Create a BIDSFile instance of the appropriate class. """
from .layout import models
# Extract all extensions from filename (a.tar.gz -> .tar.gz, not just .gz)
ext = ''.join(Path(filename).suffixes)
if ext.endswith(('.nii', '.nii.gz', '.gii')):
cls = 'BIDSImageFile'
elif ext in ['.tsv', '.tsv.gz']:
cls = 'BIDSDataFile'
elif ext == '.json':
cls = 'BIDSJSONFile'
else:
cls = 'BIDSFile'
Cls = getattr(models, cls)
return Cls(filename)
def collect_associated_files(layout, files, extra_entities=()):
"""Collect and group BIDSFiles with multiple files per acquisition.
Parameters
----------
layout
files : list of BIDSFile
extra_entities
Returns
-------
collected_files : list of list of BIDSFile
"""
MULTICONTRAST_ENTITIES = ['echo', 'part', 'ch', 'direction']
MULTICONTRAST_SUFFIXES = [
('bold', 'phase'),
('phase1', 'phase2', 'phasediff', 'magnitude1', 'magnitude2'),
]
if len(extra_entities):
MULTICONTRAST_ENTITIES += extra_entities
collected_files = []
for f in files:
if len(collected_files) and any(f in filegroup for filegroup in collected_files):
continue
ents = f.get_entities()
ents = {k: v for k, v in ents.items() if k not in MULTICONTRAST_ENTITIES}
# Group files with differing multi-contrast entity values, but same
# everything else.
all_suffixes = ents['suffix']
for mcs in MULTICONTRAST_SUFFIXES:
if ents['suffix'] in mcs:
all_suffixes = mcs
break
ents.pop('suffix')
associated_files = layout.get(suffix=all_suffixes, **ents)
collected_files.append(associated_files)
return collected_files
def validate_multiple(val, retval=None):
"""Any click.Option with the multiple flag will return an empty tuple if not set.
This helper method converts empty tuples to a desired return value (default: None).
This helper method selects the first item in single-item tuples.
"""
assert isinstance(val, tuple)
if val == tuple():
return retval
if len(val) == 1:
return val[0]
return val
@cache
def entity_indices(schema_spec=None):
from bidsschematools.schema import load_schema
from collections import defaultdict
entities = load_schema(schema_spec).rules.entities + ['suffix', 'extension', 'datatype']
return defaultdict(lambda e=entities: len(e),
{elem: idx for idx, elem in enumerate(entities)}
)
def bids_sort(unsorted: dict, schema_spec=None):
f"""
Sorts filename entity dictionaries according to their order as defined in
schema.rules.entities as well as suffix, extension. Lastly, appends datatype
to the end of the sort to accommodate pybids datastructures.
Parameters
----------
unsorted: dict
A dictionary containing bids file entities and their values.
schema_spec: str
Path or version of schema to use, defaults to the version bundled
with bidsschematools.
Returns
-------
sorted_bids: dict
"""
indices = entity_indices(schema_spec)
return {k: unsorted[k] for k in sorted(unsorted, key=indices.__getitem__)}