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.
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__)
@@ -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")
@@ -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
+ [![MIT](https://img.shields.io/badge/license-MIT-green)](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
+ [![MIT](https://img.shields.io/badge/license-MIT-green)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.11
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any