fw-bids-utils 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fw_bids_utils/__init__.py +0 -0
- fw_bids_utils/classifications.py +148 -0
- fw_bids_utils/dependencies.py +18 -0
- fw_bids_utils/errors.py +66 -0
- fw_bids_utils/helpers.py +448 -0
- fw_bids_utils/validate.py +89 -0
- fw_bids_utils-0.1.0.dist-info/METADATA +136 -0
- fw_bids_utils-0.1.0.dist-info/RECORD +9 -0
- fw_bids_utils-0.1.0.dist-info/WHEEL +4 -0
|
File without changes
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Used by fw_bids_io.uploader and fw_bids_curate.match"""
|
|
2
|
+
|
|
3
|
+
from .dependencies import Any, Dict, Optional, log
|
|
4
|
+
|
|
5
|
+
classifications = {
|
|
6
|
+
"anat": {
|
|
7
|
+
"T1w": {"Measurement": "T1", "Intent": "Structural"},
|
|
8
|
+
"T2w": {"Measurement": "T2", "Intent": "Structural"},
|
|
9
|
+
"T1rho": {"Custom": "T1rho"},
|
|
10
|
+
"T1map": {
|
|
11
|
+
"Measurement": "T1",
|
|
12
|
+
"Intent": "Structural",
|
|
13
|
+
"Features": "Quantitative",
|
|
14
|
+
},
|
|
15
|
+
"T2map": {
|
|
16
|
+
"Measurement": "T2",
|
|
17
|
+
"Intent": "Structural",
|
|
18
|
+
"Features": "Quantitative",
|
|
19
|
+
},
|
|
20
|
+
"T2star": {"Measurement": "T2*", "Intent": "Structural"},
|
|
21
|
+
"FLAIR": {"Custom": "FLAIR"},
|
|
22
|
+
"FLASH": {"Custom": "FLASH"},
|
|
23
|
+
"PD": {"Measurement": "PD", "Intent": "Structural"},
|
|
24
|
+
"PDmap": {"Custom": "PD-Map"},
|
|
25
|
+
"PDT2": {"Measurement": ["PD", "T2"], "Intent": "Structural"},
|
|
26
|
+
"inplaneT1": {
|
|
27
|
+
"Measurement": "T1",
|
|
28
|
+
"Intent": "Structural",
|
|
29
|
+
"Features": "In-Plane",
|
|
30
|
+
},
|
|
31
|
+
"inplaneT2": {
|
|
32
|
+
"Measurement": "T2",
|
|
33
|
+
"Intent": "Structural",
|
|
34
|
+
"Features": "In-Plane",
|
|
35
|
+
},
|
|
36
|
+
"angio": {"Custom": "Angio"},
|
|
37
|
+
"defacemask": {"Custom": "Defacemask"},
|
|
38
|
+
"SWImagandphase": {"Custom": "SWI"},
|
|
39
|
+
},
|
|
40
|
+
"func": {
|
|
41
|
+
"bold": {"Intent": "Functional"},
|
|
42
|
+
"events": {"Intent": "Functional"},
|
|
43
|
+
"sbref": {"Intent": "Functional"},
|
|
44
|
+
"stim": {"Intent": "Functional", "Custom": "Stim"},
|
|
45
|
+
"physio": {"Intent": "Functional", "Custom": "Physio"},
|
|
46
|
+
},
|
|
47
|
+
"beh": {
|
|
48
|
+
"events": {"Custom": "Behavioral"},
|
|
49
|
+
"stim": {"Custom": "Stim"},
|
|
50
|
+
"physio": {"Custom": "Physio"},
|
|
51
|
+
},
|
|
52
|
+
"dwi": {
|
|
53
|
+
"dwi": {"Measurement": "Diffusion", "Intent": "Structural"},
|
|
54
|
+
"sbref": {"Measurement": "Diffusion", "Intent": "Structural"},
|
|
55
|
+
},
|
|
56
|
+
"fmap": {
|
|
57
|
+
"phasediff": {"Measurement": "B0", "Intent": "Fieldmap"},
|
|
58
|
+
"magnitude1": {"Measurement": "B0", "Intent": "Fieldmap"},
|
|
59
|
+
"magnitude2": {"Measurement": "B0", "Intent": "Fieldmap"},
|
|
60
|
+
"phase1": {"Measurement": "B0", "Intent": "Fieldmap"},
|
|
61
|
+
"phase2": {"Measurement": "B0", "Intent": "Fieldmap"},
|
|
62
|
+
"magnitude": {"Measurement": "B0", "Intent": "Fieldmap"},
|
|
63
|
+
"fieldmap": {"Measurement": "B0", "Intent": "Fieldmap"},
|
|
64
|
+
"epi": {"Measurement": "B0", "Intent": "Fieldmap"},
|
|
65
|
+
"m0scan": {"Measurement": "B0", "Intent": "Fieldmap"},
|
|
66
|
+
},
|
|
67
|
+
"perf": {
|
|
68
|
+
"asl": {"Measurement": "Perfusion"},
|
|
69
|
+
"m0scan": {"Measurement": "Perfusion", "Intent": "Fieldmap"},
|
|
70
|
+
"stim": {"Measurement": "Perfusion", "Custom": "Stim"},
|
|
71
|
+
"physio": {"Measurement": "Perfusion", "Custom": "Physio"},
|
|
72
|
+
"aslcontext": {"Measurement": "Perfusion", "Custom": "Context"},
|
|
73
|
+
"asllabeling": {"Measurement": "Perfusion", "Intent": "Screenshot"},
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def determine_modality(parent_dir_name: str) -> Optional[str]:
|
|
79
|
+
"""Determine which modality a file belongs to based on the parent directory's name.
|
|
80
|
+
|
|
81
|
+
This function scans through the BIDS classifications dictionary to find matching keys
|
|
82
|
+
in the provided directory or filename, returning the appropriate BIDS modality.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
parent_dir_name: Folder or filename containing matching information for BIDS classification.
|
|
86
|
+
The function performs a case-insensitive substring match against known BIDS entities.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
str: The BIDS format modality folder name (e.g., 'anat', 'func', 'dwi', 'fmap', 'perf').
|
|
90
|
+
None: If no match is found.
|
|
91
|
+
|
|
92
|
+
Examples:
|
|
93
|
+
>>> determine_modality("sub-01_T1w")
|
|
94
|
+
'anat'
|
|
95
|
+
>>> determine_modality("PCASL")
|
|
96
|
+
'perf'
|
|
97
|
+
>>> determine_modality("fieldmap_magnitude1")
|
|
98
|
+
'fmap'
|
|
99
|
+
"""
|
|
100
|
+
for bids_folder, classificaton_dict in classifications.items():
|
|
101
|
+
for k in classificaton_dict.keys():
|
|
102
|
+
if k.lower() in parent_dir_name.lower():
|
|
103
|
+
return bids_folder
|
|
104
|
+
log.error(
|
|
105
|
+
"Could not match modality in classification table with %s" % parent_dir_name
|
|
106
|
+
)
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def search_classifications(
|
|
111
|
+
bids_folder: str, search_name: str
|
|
112
|
+
) -> Optional[Dict[str, Any]]:
|
|
113
|
+
"""Robust search on a filename to determine classifications.
|
|
114
|
+
|
|
115
|
+
This function performs a case-insensitive search for BIDS entities within a filename
|
|
116
|
+
and returns the associated classification information for the best matching entity.
|
|
117
|
+
In case of multiple matches, it selects the longest matching entity name.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
bids_folder: Modality folder, e.g., 'anat', 'func', 'dwi', 'fmap', 'perf'.
|
|
121
|
+
Must be a valid key in the classifications dictionary.
|
|
122
|
+
search_name: Filename containing matching information for BIDS classification.
|
|
123
|
+
The function performs a case-insensitive substring match against known BIDS entities.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Dict[str, Any]: Dictionary containing classification entries for the found entity.
|
|
127
|
+
Typically includes keys like 'Measurement', 'Intent', 'Features', or 'Custom'.
|
|
128
|
+
None: If no match is found.
|
|
129
|
+
|
|
130
|
+
Examples:
|
|
131
|
+
>>> search_classifications("anat", "T1w")
|
|
132
|
+
{'Measurement': 'T1', 'Intent': 'Structural'}
|
|
133
|
+
>>> search_classifications("func", "bold")
|
|
134
|
+
{'Intent': 'Functional'}
|
|
135
|
+
"""
|
|
136
|
+
matches = []
|
|
137
|
+
for k in classifications[bids_folder].keys():
|
|
138
|
+
if k.lower() in search_name.lower():
|
|
139
|
+
matches.append(k)
|
|
140
|
+
if matches:
|
|
141
|
+
# In cases of multiple matches, choose the longest match
|
|
142
|
+
return classifications[bids_folder][max(matches, key=len)]
|
|
143
|
+
else:
|
|
144
|
+
log.error(
|
|
145
|
+
"Did not find info in the classification table under %s for %s"
|
|
146
|
+
% (bids_folder, search_name)
|
|
147
|
+
)
|
|
148
|
+
return None
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Common dependencies for fw_bids_utils."""
|
|
2
|
+
|
|
3
|
+
# Standard library imports
|
|
4
|
+
import collections # noqa: F401
|
|
5
|
+
import logging
|
|
6
|
+
import re # noqa: F401
|
|
7
|
+
import subprocess # noqa: F401
|
|
8
|
+
import sys # noqa: F401
|
|
9
|
+
from typing import Any, Dict, List, Match, Optional, Pattern, Tuple, Union # noqa: F401
|
|
10
|
+
|
|
11
|
+
# Constants
|
|
12
|
+
BIDS_VALIDATOR_PATHS = [
|
|
13
|
+
"/usr/local/bin/bids-validator",
|
|
14
|
+
"/usr/bin/bids-validator",
|
|
15
|
+
"/opt/conda/bin/bids-validator",
|
|
16
|
+
]
|
|
17
|
+
# Configure logging
|
|
18
|
+
log = logging.getLogger(__name__)
|
fw_bids_utils/errors.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Error handling for BIDS operations."""
|
|
2
|
+
|
|
3
|
+
from .dependencies import Any, Dict, log
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BIDSException(Exception):
|
|
7
|
+
"""Base exception for BIDS operations."""
|
|
8
|
+
|
|
9
|
+
status_code = 1
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
msg: str = None,
|
|
14
|
+
errors: Dict = None,
|
|
15
|
+
log_error: bool = False,
|
|
16
|
+
log_msg: str = None,
|
|
17
|
+
):
|
|
18
|
+
# If log_msg is provided and msg is None, use log_msg as the message
|
|
19
|
+
self.message = (
|
|
20
|
+
msg
|
|
21
|
+
if msg is not None
|
|
22
|
+
else (log_msg or "There was an error with the BIDS operation")
|
|
23
|
+
)
|
|
24
|
+
self.errors = errors or {}
|
|
25
|
+
self.should_log = log_error or bool(log_msg)
|
|
26
|
+
self.log_msg = log_msg or self.message
|
|
27
|
+
super().__init__(self.message)
|
|
28
|
+
|
|
29
|
+
def __str__(self):
|
|
30
|
+
return self.message
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BIDSImportError(BIDSException):
|
|
34
|
+
"""Exception raised during BIDS import."""
|
|
35
|
+
|
|
36
|
+
status_code = 2
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
msg: str = "There was an error with the BIDS import",
|
|
41
|
+
errors: Dict = None,
|
|
42
|
+
log_error: bool = False,
|
|
43
|
+
log_msg: str = None,
|
|
44
|
+
):
|
|
45
|
+
super().__init__(msg=msg, errors=errors, log_error=log_error, log_msg=log_msg)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BIDSExportError(BIDSException):
|
|
49
|
+
"""Exception raised during BIDS export."""
|
|
50
|
+
|
|
51
|
+
status_code = 3
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
msg: str = "There was an error with the BIDS export",
|
|
56
|
+
errors: Dict = None,
|
|
57
|
+
log_error: bool = False,
|
|
58
|
+
log_msg: str = None,
|
|
59
|
+
):
|
|
60
|
+
super().__init__(msg=msg, errors=errors, log_error=log_error, log_msg=log_msg)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def warn_if_bids_invalid(file_info: Dict[str, Any], namespace: str = "BIDS") -> None:
|
|
64
|
+
"""Warn if file doesn't have BIDS metadata."""
|
|
65
|
+
if not file_info.get("info", {}).get(namespace):
|
|
66
|
+
log.warning(f"File {file_info['name']} is not properly BIDS curated")
|
fw_bids_utils/helpers.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
# Miscellaneous helper functions for Flywheel BIDS operations.
|
|
2
|
+
from .dependencies import (
|
|
3
|
+
Any,
|
|
4
|
+
Dict,
|
|
5
|
+
List,
|
|
6
|
+
Optional,
|
|
7
|
+
Pattern,
|
|
8
|
+
Tuple,
|
|
9
|
+
Union,
|
|
10
|
+
collections,
|
|
11
|
+
log,
|
|
12
|
+
re,
|
|
13
|
+
sys,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_project_id_from_subject_id(fw: Any, subject_id: str) -> str:
|
|
18
|
+
"""Get project ID associated with a subject ID.
|
|
19
|
+
|
|
20
|
+
This function retrieves the project ID associated with a given subject
|
|
21
|
+
by querying the Flywheel API.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
fw: Flywheel client instance with appropriate authentication
|
|
25
|
+
subject_id: Subject ID to look up
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
str: Project ID associated with the subject
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
SystemExit: If subject cannot be found in Flywheel
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
>>> client = flywheel.Client()
|
|
35
|
+
>>> project_id = get_project_id_from_subject_id(client, '5e1c2a95d5e9520d30fa0939')
|
|
36
|
+
"""
|
|
37
|
+
# Find project id from subject
|
|
38
|
+
subject = fw.get_subject(subject_id)
|
|
39
|
+
if not subject:
|
|
40
|
+
log.error("Could not load subject %s." % subject_id)
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
return subject["project"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_project_id_from_session_id(fw: Any, session_id: str) -> str:
|
|
46
|
+
"""Get project ID associated with a session ID.
|
|
47
|
+
|
|
48
|
+
This function retrieves the project ID associated with a given session
|
|
49
|
+
by querying the Flywheel API.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
fw: Flywheel client instance with appropriate authentication
|
|
53
|
+
session_id: Session ID to look up
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
str: Project ID associated with the session
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
SystemExit: If session cannot be found in Flywheel
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
>>> client = flywheel.Client()
|
|
63
|
+
>>> project_id = get_project_id_from_session_id(client, '5e1c2a95d5e9520d30fa0940')
|
|
64
|
+
"""
|
|
65
|
+
# Find project id from session
|
|
66
|
+
session = fw.get_session(session_id)
|
|
67
|
+
if not session:
|
|
68
|
+
log.error("Could not load session %s." % session_id)
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
return session["project"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_extension(fname: str) -> Optional[str]:
|
|
74
|
+
"""Get the extension from a filename, including compound extensions.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
fname: The filename (or path) to extract the extension from
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The extension (e.g. ".txt", ".nii.gz"), or None if there isn't one
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
>>> get_extension("sub-01_T1w.nii.gz")
|
|
84
|
+
'.nii.gz'
|
|
85
|
+
>>> get_extension("README")
|
|
86
|
+
"""
|
|
87
|
+
ext = re.search(r"\.[a-zA-Z]*[\.]?[A-Za-z0-9]+$", fname)
|
|
88
|
+
if ext:
|
|
89
|
+
return ext.group()
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def dict_lookup(obj: Union[Dict, List], value: str, default: Any = None) -> Any:
|
|
94
|
+
"""Recursively looks up a value in a nested dictionary or list using a dot-separated string.
|
|
95
|
+
|
|
96
|
+
This function traverses a complex nested structure (dictionaries or lists) using
|
|
97
|
+
a dot-separated path string to locate a value. If the path doesn't exist, it returns
|
|
98
|
+
the specified default value.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
obj: The dictionary or list to search
|
|
102
|
+
value: The dot-separated string representing the path to the value
|
|
103
|
+
default: The value to return if the path does not exist (defaults to None)
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The value found at the specified path, or the default value if the path does not exist
|
|
107
|
+
|
|
108
|
+
Examples:
|
|
109
|
+
>>> data = {"a": {"b": {"c": 42}}, "list": [1, 2, {"x": "found"}]}
|
|
110
|
+
>>> dict_lookup(data, "a.b.c")
|
|
111
|
+
42
|
|
112
|
+
>>> dict_lookup(data, "list.2.x")
|
|
113
|
+
'found'
|
|
114
|
+
>>> dict_lookup(data, "a.b.d", "not found")
|
|
115
|
+
'not found'
|
|
116
|
+
"""
|
|
117
|
+
# For now, we don't support escaping of dots
|
|
118
|
+
parts = value.split(".")
|
|
119
|
+
curr = obj
|
|
120
|
+
for part in parts:
|
|
121
|
+
if isinstance(curr, (dict, collections.abc.Mapping)) and part in curr:
|
|
122
|
+
curr = curr[part]
|
|
123
|
+
elif isinstance(curr, list) and part.isdigit() and int(part) < len(curr):
|
|
124
|
+
curr = curr[int(part)]
|
|
125
|
+
else:
|
|
126
|
+
curr = default
|
|
127
|
+
break
|
|
128
|
+
return curr
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def dict_set(obj: Dict, key: str, value: Any) -> None:
|
|
132
|
+
"""Set a value in a nested dictionary using a dot-separated key path.
|
|
133
|
+
|
|
134
|
+
This function traverses a dictionary using a dot-separated path string
|
|
135
|
+
and sets a value at the specified location. It can also handle numeric
|
|
136
|
+
indices for lists within the nested structure.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
obj: Target dictionary to modify
|
|
140
|
+
key: Dot-separated path where the value should be set
|
|
141
|
+
value: Value to set at the specified path
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
ValueError: If the path cannot be traversed (intermediate keys don't exist)
|
|
145
|
+
|
|
146
|
+
Examples:
|
|
147
|
+
>>> data = {"a": {"b": {}}, "list": [1, 2, {}]}
|
|
148
|
+
>>> dict_set(data, "a.b.c", 42)
|
|
149
|
+
>>> data
|
|
150
|
+
{"a": {"b": {"c": 42}}, "list": [1, 2, {}]}
|
|
151
|
+
>>> dict_set(data, "list.2.x", "new value")
|
|
152
|
+
>>> data
|
|
153
|
+
{"a": {"b": {"c": 42}}, "list": [1, 2, {"x": "new value"}]}
|
|
154
|
+
"""
|
|
155
|
+
parts = key.split(".")
|
|
156
|
+
curr = obj
|
|
157
|
+
for part in parts[:-1]:
|
|
158
|
+
if isinstance(curr, (dict, collections.abc.Mapping)) and part in curr:
|
|
159
|
+
curr = curr[part]
|
|
160
|
+
elif isinstance(curr, list) and part.isdigit() and int(part) < len(curr):
|
|
161
|
+
curr = curr[int(part)]
|
|
162
|
+
else:
|
|
163
|
+
raise ValueError("Could not set value for key: " + key)
|
|
164
|
+
curr[parts[-1]] = value
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def dict_match(matcher: Dict[str, Any], matchee: Dict[str, Any]) -> bool:
|
|
168
|
+
"""Returns True if each key-value pair in matcher is present in the matchee.
|
|
169
|
+
|
|
170
|
+
This function checks if all key-value pairs from the matcher dictionary
|
|
171
|
+
exist in the matchee dictionary. For list values, it checks if each item
|
|
172
|
+
in the matcher's list is present in the matchee's corresponding list.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
matcher: Dictionary with keys and values to match
|
|
176
|
+
matchee: Dictionary to check against
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
bool: True if matchee contains all key/value pairs from matcher, False otherwise
|
|
180
|
+
|
|
181
|
+
Examples:
|
|
182
|
+
>>> dict_match({"a": 1, "b": 2}, {"a": 1, "b": 2, "c": 3})
|
|
183
|
+
True
|
|
184
|
+
>>> dict_match({"a": 1, "b": 3}, {"a": 1, "b": 2})
|
|
185
|
+
False
|
|
186
|
+
>>> dict_match({"a": [1, 2]}, {"a": [1, 2, 3]})
|
|
187
|
+
True
|
|
188
|
+
"""
|
|
189
|
+
for key, val in matcher.items():
|
|
190
|
+
if key not in matchee:
|
|
191
|
+
return False
|
|
192
|
+
elif not isinstance(matchee.get(key), list):
|
|
193
|
+
mval = [matchee.get(key)]
|
|
194
|
+
else:
|
|
195
|
+
mval = matchee.get(key)
|
|
196
|
+
if isinstance(val, list):
|
|
197
|
+
for item in val:
|
|
198
|
+
if item not in mval:
|
|
199
|
+
return False
|
|
200
|
+
elif val not in mval:
|
|
201
|
+
return False
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _compile_template_regexes() -> Tuple[Pattern, Pattern]:
|
|
206
|
+
"""Compile and return regex patterns used for template processing.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Tuple containing two compiled regex patterns:
|
|
210
|
+
- tokens_pattern: For identifying tokens in the template
|
|
211
|
+
- values_pattern: For identifying placeholder values within tokens
|
|
212
|
+
"""
|
|
213
|
+
tokens_pattern = re.compile(r"[^\[][A-Za-z0-9\.><}{-]+|\[[/A-Za-z0-9><}{_\.-]+\]")
|
|
214
|
+
values_pattern = re.compile(r"[{<][A-Za-z0-9\.-_]+[>}]|\[[A-Za-z0-9\.-_]+\]")
|
|
215
|
+
return tokens_pattern, values_pattern
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _extract_value_from_context(path: str, context: Dict[str, Any]) -> Optional[Any]:
|
|
219
|
+
"""Extract a value from the context using dot notation path.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
path: Dot-separated path to the value in the context
|
|
223
|
+
context: Dictionary containing the hierarchical data
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
The value found at the specified path, or None if not found
|
|
227
|
+
"""
|
|
228
|
+
keys = path.split(".")
|
|
229
|
+
result = context
|
|
230
|
+
for key in keys:
|
|
231
|
+
if key in result:
|
|
232
|
+
result = result[key]
|
|
233
|
+
else:
|
|
234
|
+
return None
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _format_bids_value(value: Any) -> str:
|
|
239
|
+
"""Format a value according to BIDS conventions.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
value: The value to format
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Formatted string value
|
|
246
|
+
"""
|
|
247
|
+
if not isinstance(value, str):
|
|
248
|
+
value = str(value)
|
|
249
|
+
# Check if value is already in BIDS format (sub-X, ses-X)
|
|
250
|
+
if re.match("(sub|ses)-[a-zA-Z0-9]+", value):
|
|
251
|
+
# Extract just the label part after the hyphen
|
|
252
|
+
label, value = value.split("-")
|
|
253
|
+
else:
|
|
254
|
+
# Remove underscores and dashes, keep only alphanumeric characters
|
|
255
|
+
value = "".join(
|
|
256
|
+
x for x in value.replace("_", " ").replace("-", " ") if x.isalnum()
|
|
257
|
+
)
|
|
258
|
+
return value
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _replace_tokens_in_template(
|
|
262
|
+
template: str, token: str, replace_token: str, result: Any
|
|
263
|
+
) -> str:
|
|
264
|
+
"""Replace a token in the template with its value.
|
|
265
|
+
Args:
|
|
266
|
+
template: The template string being processed
|
|
267
|
+
token: The full token containing the placeholder
|
|
268
|
+
replace_token: The placeholder to replace (like <path> or {path})
|
|
269
|
+
result: The value to substitute
|
|
270
|
+
|
|
271
|
+
Returns
|
|
272
|
+
Updated template string with the replacement made
|
|
273
|
+
"""
|
|
274
|
+
# Handle optional tokens (enclosed in square brackets)
|
|
275
|
+
is_optional = token.startswith("[") and token.endswith("]")
|
|
276
|
+
if is_optional and (result is None or replace_token in token and result is None):
|
|
277
|
+
# For optional tokens, remove them entirely if the value is None
|
|
278
|
+
return template.replace(token, "")
|
|
279
|
+
|
|
280
|
+
# If we have a result, format it and replace the token
|
|
281
|
+
if result:
|
|
282
|
+
if replace_token[0] == "<":
|
|
283
|
+
# Format according to BIDS conventions for <> tokens
|
|
284
|
+
result = _format_bids_value(result)
|
|
285
|
+
# Replace the placeholder with the string result
|
|
286
|
+
return template.replace(replace_token, str(result))
|
|
287
|
+
# No changes if we can't handle the token
|
|
288
|
+
return template
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def process_string_template(template: str, context: Dict[str, Any]) -> str:
|
|
292
|
+
"""Process a string template by substituting values from a context dictionary.
|
|
293
|
+
|
|
294
|
+
This function finds placeholder tokens in the template string and replaces them
|
|
295
|
+
with values from the context object. It supports two types of placeholders:
|
|
296
|
+
- <path>: Values are converted to lowerCamelCase (removes underscores and dashes)
|
|
297
|
+
- {path}: Values are used as-is
|
|
298
|
+
The path uses dot notation to navigate the context dictionary for desired values.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
template: Template string with replacement tokens (<path> or {path})
|
|
302
|
+
context: Dictionary containing values to substitute
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
str: Processed template with values substituted
|
|
306
|
+
|
|
307
|
+
Examples:
|
|
308
|
+
>>> context = {"subject": {"code": "01"}, "file": {"info": {"BIDS": {"Modality": "T1w"}}}}
|
|
309
|
+
>>> process_string_template("sub-<subject.code>_{file.info.BIDS.Modality}.nii.gz", context)
|
|
310
|
+
'sub-01_T1w.nii.gz'
|
|
311
|
+
|
|
312
|
+
Note:
|
|
313
|
+
Optional tokens can be enclosed in square brackets and will be removed if
|
|
314
|
+
the corresponding value is not found in the context.
|
|
315
|
+
"""
|
|
316
|
+
# Compile regex patterns for template processing
|
|
317
|
+
tokens_pattern, values_pattern = _compile_template_regexes()
|
|
318
|
+
# Process each token in the template
|
|
319
|
+
for token in tokens_pattern.findall(template):
|
|
320
|
+
if values_pattern.search(token):
|
|
321
|
+
replace_tokens = values_pattern.findall(token)
|
|
322
|
+
for replace_token in replace_tokens:
|
|
323
|
+
# Extract the path from the placeholder
|
|
324
|
+
path = replace_token[1:-1]
|
|
325
|
+
# Get the value from the context
|
|
326
|
+
result = _extract_value_from_context(path, context)
|
|
327
|
+
# Replace the token in the template
|
|
328
|
+
template = _replace_tokens_in_template(
|
|
329
|
+
template, token, replace_token, result
|
|
330
|
+
)
|
|
331
|
+
# Remove any remaining square brackets from optional tokens
|
|
332
|
+
processed_template = re.sub(r"\[|\]", "", template)
|
|
333
|
+
return processed_template
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def get_pattern(format_params: Dict[str, Any]) -> Optional[Union[str, List[str]]]:
|
|
337
|
+
"""Get pattern from format parameters.
|
|
338
|
+
This helper function extracts the $pattern value from formatting parameters.
|
|
339
|
+
Args:
|
|
340
|
+
format_params: Dictionary with formatting parameters that may contain a $pattern key
|
|
341
|
+
|
|
342
|
+
Returns
|
|
343
|
+
str or List[str]: The pattern value if found
|
|
344
|
+
None: If no pattern is found
|
|
345
|
+
"""
|
|
346
|
+
return format_params.get("$pattern")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def format_value(params: List[Dict[str, Any]], value: str) -> str: # noqa: PLR0912
|
|
350
|
+
"""Format a string value based on a list of transformation parameters.
|
|
351
|
+
|
|
352
|
+
This function applies a series of string transformations based on the provided
|
|
353
|
+
parameters. Supported transformations include:
|
|
354
|
+
- $replace: Replace pattern with replacement text
|
|
355
|
+
- $lower: Convert to lowercase
|
|
356
|
+
- $upper: Convert to uppercase
|
|
357
|
+
- $camelCase: Convert to camelCase
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
params: List of formatting parameter dictionaries
|
|
361
|
+
value: String value to format
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
str: Formatted string after applying all transformations
|
|
365
|
+
|
|
366
|
+
Examples:
|
|
367
|
+
>>> format_value([{"$replace": {"$pattern": "ab", "$replacement": "c"}}], "dabf")
|
|
368
|
+
'dcf'
|
|
369
|
+
>>> format_value([{"$lower": True}], "TEXT")
|
|
370
|
+
'text'
|
|
371
|
+
>>> format_value([{"$camelCase": True}], "some_text-here")
|
|
372
|
+
'someTextHere'
|
|
373
|
+
"""
|
|
374
|
+
for param in params:
|
|
375
|
+
if "$replace" in param:
|
|
376
|
+
value = re.sub(
|
|
377
|
+
get_pattern(param["$replace"]),
|
|
378
|
+
param["$replace"].get("$replacement"),
|
|
379
|
+
value,
|
|
380
|
+
)
|
|
381
|
+
elif "$lower" in param:
|
|
382
|
+
if isinstance(param["$lower"], dict) and get_pattern(param["$lower"]):
|
|
383
|
+
value = re.sub(
|
|
384
|
+
get_pattern(param["$lower"]), lambda m: m.group(0).lower(), value
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
value = value.lower()
|
|
388
|
+
elif "$upper" in param:
|
|
389
|
+
if isinstance(param["$upper"], dict) and get_pattern(param["$upper"]):
|
|
390
|
+
value = re.sub(
|
|
391
|
+
get_pattern(param["$upper"]), lambda m: m.group(0).upper(), value
|
|
392
|
+
)
|
|
393
|
+
else:
|
|
394
|
+
value = value.upper()
|
|
395
|
+
elif "$camelCase" in param:
|
|
396
|
+
if isinstance(param["$camelCase"], dict) and get_pattern(
|
|
397
|
+
param["$camelCase"]
|
|
398
|
+
):
|
|
399
|
+
patterns = get_pattern(param["$camelCase"])
|
|
400
|
+
if not isinstance(patterns, list):
|
|
401
|
+
patterns = [patterns]
|
|
402
|
+
for pattern in patterns:
|
|
403
|
+
value = value.replace(pattern, " ")
|
|
404
|
+
value = "".join(x for x in value.title() if x.isalnum())
|
|
405
|
+
value = value[0].lower() + value[1:]
|
|
406
|
+
else:
|
|
407
|
+
# Best to not process string with <...> with $camelCase : true
|
|
408
|
+
value = "".join(
|
|
409
|
+
x
|
|
410
|
+
for x in value.replace("_", " ").replace("-", " ").title()
|
|
411
|
+
if x.isalnum()
|
|
412
|
+
)
|
|
413
|
+
value = value[0].lower() + value[1:]
|
|
414
|
+
return value
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def normalize_strings(obj: Any) -> Any:
|
|
418
|
+
"""Recursively convert all string-like objects in a data structure to Python str type.
|
|
419
|
+
|
|
420
|
+
This function traverses through dictionaries, lists, and other iterables,
|
|
421
|
+
converting any string-like objects to the standard Python str type.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
obj: The object to normalize, can be a string, dictionary, list, or other iterable
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
The normalized object with all strings converted to Python str type
|
|
428
|
+
|
|
429
|
+
Examples:
|
|
430
|
+
>>> normalize_strings("text")
|
|
431
|
+
'text'
|
|
432
|
+
>>> normalize_strings({"key": b"value"})
|
|
433
|
+
{'key': 'value'}
|
|
434
|
+
>>> normalize_strings([1, "two", b"three"])
|
|
435
|
+
[1, 'two', 'three']
|
|
436
|
+
"""
|
|
437
|
+
if isinstance(obj, bytes):
|
|
438
|
+
return obj.decode("utf-8")
|
|
439
|
+
if isinstance(obj, str):
|
|
440
|
+
return str(obj)
|
|
441
|
+
elif isinstance(obj, dict):
|
|
442
|
+
return {normalize_strings(k): normalize_strings(v) for k, v in obj.items()}
|
|
443
|
+
elif isinstance(obj, collections.abc.Iterable) and not isinstance(
|
|
444
|
+
obj, (str, bytes)
|
|
445
|
+
):
|
|
446
|
+
# Use type(obj) to preserve the original collection type (including custom classes)
|
|
447
|
+
return type(obj)(normalize_strings(item) for item in obj)
|
|
448
|
+
return obj
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Validation utilities for BIDS datasets."""
|
|
2
|
+
|
|
3
|
+
from .dependencies import Any, Optional, log, subprocess
|
|
4
|
+
from .errors import BIDSException
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def validate_bids(bids_dir: str) -> bool:
|
|
8
|
+
"""Validate a BIDS dataset using the bids-validator.
|
|
9
|
+
This function runs the external bids-validator tool to check if a BIDS dataset
|
|
10
|
+
conforms to the Brain Imaging Data Structure specification. It captures and logs
|
|
11
|
+
any validation errors or warnings.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
bids_dir: Path to BIDS dataset directory to validate
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
bool: True if validation passed without errors (warnings are acceptable),
|
|
18
|
+
False if validation failed with errors
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
BIDSException: If the bids-validator command fails to execute or
|
|
22
|
+
if the bids-validator executable is not found
|
|
23
|
+
|
|
24
|
+
Note:
|
|
25
|
+
This function requires the bids-validator to be installed.
|
|
26
|
+
You can install it via npm: `npm install -g bids-validator`
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
result = subprocess.run(
|
|
30
|
+
["bids-validator", bids_dir], check=True, capture_output=True, text=True
|
|
31
|
+
)
|
|
32
|
+
# Check for validation errors in output
|
|
33
|
+
if "validator.error" in result.stdout:
|
|
34
|
+
log.error("BIDS validation failed with errors")
|
|
35
|
+
return False
|
|
36
|
+
# Warnings are ok, but we should log them
|
|
37
|
+
if "validator.warn" in result.stdout:
|
|
38
|
+
log.warning("BIDS validation produced warnings")
|
|
39
|
+
return True
|
|
40
|
+
except subprocess.CalledProcessError as e:
|
|
41
|
+
raise BIDSException("BIDS validation failed: %s" % str(e))
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
raise BIDSException(
|
|
44
|
+
"bids-validator not found. Please install NodeJS and run: npm install -g bids-validator"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate_project_label(
|
|
49
|
+
fw: Any, project_label: str, group_id: Optional[str] = None
|
|
50
|
+
) -> str:
|
|
51
|
+
"""Validate that a project label exists and is unique within Flywheel.
|
|
52
|
+
This function searches for projects with the given label and ensures there is exactly
|
|
53
|
+
one matching project. It can optionally filter by group to narrow the search scope.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
fw: Flywheel client instance with appropriate authentication
|
|
57
|
+
project_label: Project label (name) to validate
|
|
58
|
+
group_id: Optional group ID to restrict the search to a specific group
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
str: Project ID of the validated project
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: If no projects are found with the given label, or
|
|
65
|
+
if multiple projects are found with the same label
|
|
66
|
+
|
|
67
|
+
Note:
|
|
68
|
+
When multiple projects with the same label exist, use the group_id
|
|
69
|
+
parameter to disambiguate which project is intended.
|
|
70
|
+
"""
|
|
71
|
+
# Find project id
|
|
72
|
+
# exhaustive=True is needed because site-admin users won't
|
|
73
|
+
# find projects they have not been explicitly added to
|
|
74
|
+
if group_id:
|
|
75
|
+
projects = fw.projects.find(
|
|
76
|
+
f"group={group_id},label={project_label}", exhaustive=True
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
projects = fw.projects.find(f"label={project_label}", exhaustive=True)
|
|
80
|
+
num_projects = len(projects)
|
|
81
|
+
if num_projects > 1:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"Found %d projects with label %s. Use the --group-id option to specify a group id"
|
|
84
|
+
% (num_projects, project_label)
|
|
85
|
+
)
|
|
86
|
+
elif num_projects < 1:
|
|
87
|
+
raise ValueError("Could not find project with label %s" % project_label)
|
|
88
|
+
project = projects[0]
|
|
89
|
+
return project.id
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fw-bids-utils
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Utilities for the Flywheel BIDS suite.
|
|
5
|
+
Keywords: Flywheel,flywheel,BIDS,SDK
|
|
6
|
+
Author: Flywheel
|
|
7
|
+
Author-email: Flywheel <support@flywheel.io>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Topic :: Scientific/Engineering
|
|
10
|
+
Requires-Dist: fw-gear>=0.3,<0.4
|
|
11
|
+
Requires-Python: >=3.11, <4
|
|
12
|
+
Project-URL: Repository, https://gitlab.com/flywheel-io/scientific-solutions/lib/bids-suite/fw-bids-utils
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# fw-bids-utils
|
|
16
|
+
|
|
17
|
+
[](LICENSE)
|
|
18
|
+
|
|
19
|
+
## Overview
|
|
20
|
+
|
|
21
|
+
fw-bids-utils is a Python package that holds helper methods used across the
|
|
22
|
+
fw-bids suite. This library provides common functionality for working with BIDS
|
|
23
|
+
(Brain Imaging Data Structure) datasets in the Flywheel ecosystem. While care
|
|
24
|
+
should be taken to add methods to the appropriate fw-bids subsuite as needs
|
|
25
|
+
arise, this module contains methods that should be maintained in a common repo.
|
|
26
|
+
An example of a common method is the classifications class. Changes to
|
|
27
|
+
classifications will impact `import_bids` as well as `curate_bids`.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
### Using pip
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install fw-bids-utils
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### From source
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git clone https://github.com/flywheel-io/fw-bids-utils.git
|
|
41
|
+
cd fw-bids-utils
|
|
42
|
+
pip install -e .
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Dependencies
|
|
46
|
+
|
|
47
|
+
This package requires Python 3.11+ and includes the following dependencies:
|
|
48
|
+
|
|
49
|
+
- flywheel-gear-toolkit
|
|
50
|
+
- jsonschema
|
|
51
|
+
- rtstatlib
|
|
52
|
+
- fw-meta For development, additional dependencies are available in
|
|
53
|
+
requirements-dev.txt:
|
|
54
|
+
- pytest
|
|
55
|
+
- pytest-cov
|
|
56
|
+
- pytest-mock
|
|
57
|
+
|
|
58
|
+
## Modules
|
|
59
|
+
|
|
60
|
+
### classifications
|
|
61
|
+
|
|
62
|
+
Contains utilities for BIDS classification mapping:
|
|
63
|
+
|
|
64
|
+
- `determine_modality`: Determine which modality a file belongs to based on the
|
|
65
|
+
parent directory's name
|
|
66
|
+
- `search_classifications`: Robust search on a filename to determine
|
|
67
|
+
classifications
|
|
68
|
+
|
|
69
|
+
### validate
|
|
70
|
+
|
|
71
|
+
Provides validation utilities for BIDS datasets:
|
|
72
|
+
|
|
73
|
+
- `validate_bids`: Validate a BIDS dataset using the bids-validator
|
|
74
|
+
- `validate_project_label`: Validate that a project label exists and is unique
|
|
75
|
+
|
|
76
|
+
### helpers
|
|
77
|
+
|
|
78
|
+
General helper functions:
|
|
79
|
+
|
|
80
|
+
- String processing with `process_string_template` and `format_value`
|
|
81
|
+
- Dictionary utilities like `dict_lookup`, `dict_set`, and `dict_match`
|
|
82
|
+
- Project and session ID helpers
|
|
83
|
+
|
|
84
|
+
## Usage Examples
|
|
85
|
+
|
|
86
|
+
### Working with BIDS Classifications
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from fw_bids_utils.classifications import determine_modality, search_classifications
|
|
90
|
+
# Determine the modality from a filename
|
|
91
|
+
modality = determine_modality("sub-01_T1w")
|
|
92
|
+
# Result: "anat"
|
|
93
|
+
# Get classification information
|
|
94
|
+
classification = search_classifications("anat", "T1w")
|
|
95
|
+
# Result: {"Measurement": "T1", "Intent": "Structural"}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Validating BIDS Datasets
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from fw_bids_utils.validate import validate_bids
|
|
102
|
+
# Validate a BIDS dataset
|
|
103
|
+
is_valid = validate_bids("/path/to/bids/dataset")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Using Helpers
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from fw_bids_utils.helpers import process_string_template
|
|
110
|
+
context = {
|
|
111
|
+
"subject": {"code": "01"},
|
|
112
|
+
"session": {"label": "session1"},
|
|
113
|
+
"file": {"info": {"BIDS": {"Modality": "T1w"}}}
|
|
114
|
+
}
|
|
115
|
+
# Process a template string
|
|
116
|
+
filename = process_string_template(
|
|
117
|
+
"sub-<subject.code>_ses-<session.label>_{file.info.BIDS.Modality}.nii.gz",
|
|
118
|
+
context
|
|
119
|
+
)
|
|
120
|
+
# Result: "sub-01_ses-session1_T1w.nii.gz"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Contributing
|
|
124
|
+
|
|
125
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
126
|
+
|
|
127
|
+
1. Fork the repository
|
|
128
|
+
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
|
129
|
+
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
|
130
|
+
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
131
|
+
5. Open a Pull Request
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
[](LICENSE) This project
|
|
136
|
+
is licensed under the MIT License - see the LICENSE file for details.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
fw_bids_utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
fw_bids_utils/classifications.py,sha256=eaozle9T_UN_EB32A6xWnnPwL8WDKTP4V4_7ASsqg-I,5800
|
|
3
|
+
fw_bids_utils/dependencies.py,sha256=1Uv2Pl9vpnitC4aD-A5IMShqJjTyTynXfYbWeS-LDVc,490
|
|
4
|
+
fw_bids_utils/errors.py,sha256=xWoyKgxozu-jeRrIsa5muCN5QI7m_Pm051qqo1NrZBc,1847
|
|
5
|
+
fw_bids_utils/helpers.py,sha256=6r9XuAgsy5g3gs9tgxCpuMVyEfBIGu14OEX64ESkdMM,15928
|
|
6
|
+
fw_bids_utils/validate.py,sha256=uqh2QppY3j6fOZJrU0hSfF20vlOYfuBV7Tk3vOwPkvI,3453
|
|
7
|
+
fw_bids_utils-0.1.0.dist-info/WHEEL,sha256=mru_b36sH6joUMnwf7IlFCun3RoDjrNg9RcfBmEcqsE,81
|
|
8
|
+
fw_bids_utils-0.1.0.dist-info/METADATA,sha256=P5PEG4qY0ShVAbd7-r3OXN17LwaQ-eyHKdCTJNvXY6s,3786
|
|
9
|
+
fw_bids_utils-0.1.0.dist-info/RECORD,,
|