cnda_dl 1.0.1__py3-none-any.whl → 1.2.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.
cnda_dl/cli.py CHANGED
@@ -1,32 +1,32 @@
1
- #!/usr/bin/env python
2
-
3
1
  '''
4
2
  Script to download MRI sessions from the CNDA
5
3
  Authors:
6
4
  Joey Scanga (scanga@wustl.edu)
7
5
  Ramone Agard (rhagard@wustl.edu)
8
6
  '''
9
-
10
7
  from glob import glob
11
- from matplotlib.ticker import EngFormatter
12
8
  from pathlib import Path
13
- from pyxnat import Interface
14
- import pyxnat
9
+ import atexit
10
+ import re
15
11
  import argparse
16
12
  import logging
17
13
  import os
18
- import progressbar
19
14
  import shlex
20
15
  import shutil
21
- import hashlib
22
16
  import subprocess
23
17
  import sys
24
18
  import xml.etree.ElementTree as et
25
- import zipfile
26
19
  import datetime
27
20
 
21
+ import pyxnat as px
22
+ import progressbar as pb
23
+
24
+ from .formatters import ParensOnRightFormatter1
25
+ from .zip_utils import recursive_unzip, unzipped
26
+
28
27
  default_log_format = "%(levelname)s:%(funcName)s: %(message)s"
29
28
  sout_handler = logging.StreamHandler(stream=sys.stdout)
29
+ sout_handler.setFormatter(ParensOnRightFormatter1())
30
30
  logging.basicConfig(level=logging.INFO,
31
31
  handlers=[sout_handler],
32
32
  format=default_log_format)
@@ -49,34 +49,34 @@ def handle_dir_creation(dir_path: Path):
49
49
  ans = ans.lower()
50
50
 
51
51
  if len(ans) != 1 or ans not in 'yn':
52
- logger.info("Invalid response.")
52
+ logger.info("Invalid response")
53
53
  elif ans == 'y':
54
54
  dir_path.mkdir(parents=True)
55
55
  prompt_chosen = True
56
- logger.info(f"new directory created at {dir_path}.")
56
+ logger.info(f"new directory created at {dir_path}")
57
57
  elif ans == 'n':
58
- logger.info("Chose to not create a new directory. Aborting...")
58
+ logger.info("Chose to not create a new directory Aborting")
59
59
  sys.exit(0)
60
60
  else:
61
- logger.info("Invalid response.")
61
+ logger.info("Invalid response")
62
62
 
63
63
 
64
- def download_xml(central: Interface,
64
+ def download_xml(central: px.Interface,
65
65
  subject_id: str,
66
66
  project_id: str,
67
67
  file_path: Path):
68
68
 
69
- logger.info("Downloading session xml...")
69
+ logger.info("Downloading session xml")
70
70
  sub = central.select(f"/projects/{project_id}/subjects/{subject_id}")
71
71
  with open(file_path, "w") as f:
72
72
  f.write(sub.get().decode())
73
73
  return True
74
74
 
75
75
 
76
- def retrieve_experiment(central: Interface,
76
+ def retrieve_experiment(central: px.Interface,
77
77
  session: str,
78
78
  experiment_id: bool = False,
79
- project_id: str = None) -> pyxnat.jsonutil.JsonTable:
79
+ project_id: str = None) -> px.jsonutil.JsonTable:
80
80
 
81
81
  query_params = {}
82
82
  if project_id:
@@ -89,148 +89,147 @@ def retrieve_experiment(central: Interface,
89
89
  return central.array.mrsessions(**query_params)
90
90
 
91
91
 
92
- def get_xml_scans(xml_file: Path,
93
- quality_pair: bool = False) -> dict:
92
+ def get_xml_scans(xml_file: Path) -> dict:
93
+ """
94
+ Create a map of downloaded scan IDs to UIDs to later match with the UIDs in the .dat files
94
95
 
96
+ :param xml_file: path to quality XML
97
+ :type xml_file: pathlib.Path
98
+ """
95
99
  xml_tree = et.parse(xml_file)
96
100
  prefix = "{" + str(xml_tree.getroot()).split("{")[-1].split("}")[0] + "}"
97
101
  scan_xml_entries = xml_tree.getroot().find(
98
102
  f"./{prefix}experiments/{prefix}experiment/{prefix}scans"
99
103
  )
100
- if quality_pair:
101
- return {s.get("ID"): s.find(f"{prefix}quality").text
102
- for s in scan_xml_entries}
103
104
  return scan_xml_entries
104
105
 
105
106
 
106
- def download_experiment_dicoms(session_experiment: pyxnat.jsonutil.JsonTable,
107
- central: Interface,
108
- session_dicom_dir: Path,
109
- xml_file_path: Path,
110
- scan_number_start: str = None,
111
- skip_unusable: bool = False):
112
-
113
- project_id = session_experiment["project"]
114
- exp_id = session_experiment['ID']
115
-
116
- # parse the xml file for the scan quality information
117
- quality_pairs = get_xml_scans(xml_file=xml_file_path,
118
- quality_pair=True)
119
-
120
- # retrieve the list of scans for this session
121
- scans = central.select(f"/projects/{project_id}/experiments/{exp_id}/scans/*/").get()
122
- scans.sort()
123
- logger.info(f"Found {len(scans)} scans for this session")
124
-
125
- # truncate the scan list if a starting point was given
126
- if scan_number_start:
127
- assert scan_number_start in scans, "Specified scan number does not exist for this session/experiment"
128
- sdex = scans.index(scan_number_start)
129
- scans = scans[sdex:]
130
- logger.info(f"Downloading scans for this session starting from series {scan_number_start}")
131
-
132
- # remove the unusable scans from the list if skipping is requested
133
- if skip_unusable:
134
- scans = [s for s in scans if quality_pairs[s] != "unusable"]
135
- logger.info(f"The following scans were marked 'unusable' and will not be downloaded: \n\t {[s for s,q in quality_pairs.items() if q=='unusable']}")
136
-
137
- # Get total number of files
138
- total_file_count, cur_file_count = 0, 0
139
- for s in scans:
140
- files = central.select(f"/projects/{project_id}/experiments/{exp_id}/scans/{s}/resources/files").get("")
141
- total_file_count += len(files)
142
- logger.info(f"Total number of files: {total_file_count}")
143
-
144
- # So log message does not interfere with format of the progress bar
145
- logger.removeHandler(sout_handler)
146
- downloaded_files = set()
147
- zero_size_files = set()
148
- fmt = EngFormatter('B')
149
-
150
- # Download the session files
151
- with progressbar.ProgressBar(max_value=total_file_count, redirect_stdout=True) as bar:
152
- for s in scans:
153
- logger.info(f" Downloading scan {s}...")
154
- print(f"Downloading scan {s}...")
155
- series_path = session_dicom_dir / s / "DICOM"
156
- series_path.mkdir(parents=True, exist_ok=True)
157
- files = central.select(f"/projects/{project_id}/experiments/{exp_id}/scans/{s}/resources/files").get("")
158
- for f in files:
159
- cur_file_count += 1
160
- add_file = True
161
- file_name = series_path / f._uri.split("/")[-1]
162
- file_size = fmt(int(f.size())) if f.size() else fmt(0)
163
- file_info = f"File {f.attributes()['Name']}, {file_size} ({cur_file_count} out of {total_file_count})"
164
- print("\t" + file_info)
165
- logger.info("\t" + file_info)
166
- if not f.size():
167
- msg = "\t-- File is empty"
168
- if file_name in downloaded_files:
169
- msg += " -- another copy was already downloaded, skipping download of this file"
170
- add_file = False
171
- else:
172
- zero_size_files.add(file_name)
173
- print(msg)
174
- logger.info(msg)
175
- elif file_name in zero_size_files:
176
- zero_size_files.remove(file_name)
177
- if add_file:
178
- f.get(file_name)
179
- downloaded_files.add(file_name)
180
- bar.update(cur_file_count)
107
+ def get_scan_types(xml_path):
108
+ # Get unique scan types to include in POST req
109
+ with open(xml_path, "r") as f:
110
+ xml_text = f.read()
111
+ return list(set(
112
+ re.findall(
113
+ r'ID="[\d\w_\-]+"\s+type="([a-zA-Z0-9\-_\.]+)"',
114
+ xml_text
115
+ )
116
+ ))
117
+
118
+
119
+ def get_resources(xml_path):
120
+ # Get "additional resources" that appear on CNDA for the session
121
+ # (usually NORDIC_VOLUMES)
122
+ with open(xml_path, "r") as f:
123
+ xml_text = f.read()
124
+ return list(set(
125
+ re.findall(
126
+ r'resource label="([a-zA-Z0-9\-_]+)"',
127
+ xml_text
128
+ )
129
+ ))
130
+
131
+
132
+ def download_experiment_zip(central: px.Interface,
133
+ exp: px.jsonutil.JsonTable,
134
+ dicom_dir: Path,
135
+ xml_file_path: Path,
136
+ keep_zip: bool = False):
137
+ '''
138
+ Download scan data as .zip from CNDA.
139
+
140
+ :param central: CNDA connection object
141
+ :type central: pyxnat.Interface
142
+ :param exp: object containing experiment information
143
+ :type exp: pyxnat.jsonutil.JsonTable
144
+ :param dicom_dir: Path to session-specific directory where DICOMs should be downloaded
145
+ :type dicom_dir: pathlib.Path
146
+ :param xml_file_path: Path to experiment XML
147
+ :type xml_file_path: pathlib.Path
148
+ :param keep_zip: Will not delete downloaded zip file after unzipping
149
+ :type keep_zip: bool
150
+ '''
151
+ sub_obj = central.select(f"/project/{exp['project']}/subjects/{exp['xnat:mrsessiondata/subject_id']}")
152
+ exp_obj = central.select(f"/project/{exp['project']}/subjects/{exp['xnat:mrsessiondata/subject_id']}/experiments/{exp['ID']}")
153
+ # Step 1: make POST request to prepare .zip download
154
+ res1 = central.post(
155
+ "/xapi/archive/downloadwithsize",
156
+ json={
157
+ "sessions": [f"{exp['project']}:{sub_obj.label()}:{exp_obj.label()}:{exp['ID']}"],
158
+ "projectIds": [exp['project']],
159
+ "scan_formats": ["DICOM"],
160
+ "scan_types": get_scan_types(xml_file_path),
161
+ "resources": get_resources(xml_file_path),
162
+ "options": ["simplified"]
163
+ }
164
+ )
165
+ # Step 2: make GET request with created ID from POST
166
+ cur_bytes, total_bytes = 0, int(res1.json()["size"])
167
+
168
+ def _build_progress_bar():
169
+ widgets = [
170
+ pb.DataSize(), '/', pb.DataSize(variable='max_value'),
171
+ pb.Percentage(),
172
+ ' ',
173
+ pb.RotatingMarker(),
174
+ ' ',
175
+ pb.ETA(),
176
+ ' ',
177
+ pb.FileTransferSpeed()
178
+ ]
179
+ return pb.ProgressBar(
180
+ max_value=total_bytes,
181
+ widgets=widgets
182
+ )
183
+ logger.info("Downloading session .zip")
184
+ res2 = central.get(f"/xapi/archive/download/{res1.json()['id']}/zip", timeout=(60, 300))
185
+ res2.raise_for_status()
186
+ with (
187
+ open(zip_path := (dicom_dir / f"{res1.json()['id']}.zip"), "wb") as f,
188
+ _build_progress_bar() as bar
189
+ ):
190
+ logger.info(f"Request headers: {res2.request.headers}")
191
+ logger.info(f"Response headers: {res2.headers}")
192
+ logger.removeHandler(sout_handler)
193
+ for chunk in res2.iter_content(chunk_size=(chunk_size := 1024)):
194
+ if chunk:
195
+ f.write(chunk)
196
+ cur_bytes += chunk_size
197
+ bar.update(cur_bytes)
181
198
  logger.addHandler(sout_handler)
182
- logger.info("Dicom download complete \n")
183
- if len(zero_size_files) > 0:
184
- logger.warning(f"The following downloaded files contained no data:\n{[f.label() for f in zero_size_files]} \nCheck these files for unintended missing data!")
185
-
186
-
187
- def download_nordic_zips(session: str,
188
- central: Interface,
189
- session_experiment: pyxnat.jsonutil.JsonTable,
190
- session_dicom_dir: Path) -> list[Path]:
191
- dat_dir_list = []
192
- project_id = session_experiment["project"]
193
- exp_id = session_experiment['ID']
194
-
195
- def __digests_identical(zip_path: Path,
196
- cnda_file: pyxnat.core.resources.File):
197
- if zip_path.is_file(): # Compare digests of zip on CNDA to see if we need to redownload
198
- with zip_path.open("rb") as f:
199
- if hashlib.md5(f.read()).hexdigest() == cnda_file.attributes()['digest']: # digests match
200
- return True
201
- return False
202
-
203
- # check for zip file from NORDIC sessions
204
- nordic_volumes = central.select(f"/projects/{project_id}/experiments/{exp_id}/resources/NORDIC_VOLUMES/files").get("")
205
- logger.info(f"Found {len(nordic_volumes)} 'NORDIC_VOLUMES' for this session")
206
- for nv in nordic_volumes:
207
- zip_path = session_dicom_dir / nv._uri.split("/")[-1]
208
- if not __digests_identical(zip_path, nv):
209
- logger.info(f"Downloading {zip_path.name}...")
210
- nv.get(zip_path)
211
- unzip_path = zip_path.parent / zip_path.stem
212
- with zipfile.ZipFile(zip_path, "r") as zip_ref:
213
- logger.info(f"Unzipping to {unzip_path}...")
214
- zip_ref.extractall(unzip_path)
215
- dat_dir_list.append(unzip_path)
216
-
217
- return dat_dir_list
199
+ logger.info("Download complete!")
200
+ unzipped_dir = unzipped(zip_path, keep_zip=False)
201
+ recursive_unzip(unzipped_dir, keep_zip=False) # for NORDIC_VOLUMES already zipped up
218
202
 
219
203
 
220
204
  def dat_dcm_to_nifti(session: str,
221
205
  dat_directory: Path,
222
206
  xml_file_path: Path,
223
207
  session_dicom_dir: Path,
224
- nifti_path: Path,
208
+ session_nifti_dir: Path,
225
209
  skip_short_runs: bool = False):
226
- # check if the required program is on the current PATH
210
+ """
211
+ Pair .dcm/.dat files with dcmdat2niix
212
+
213
+ :param session: Session identifier
214
+ :type session: str
215
+ :param dat_directory: Directory with .dat files
216
+ :type dat_directory: pathlib.Path
217
+ :param xml_file_path: Path to session XML
218
+ :type xml_file_path: pathlib.Path
219
+ :param session_dicom_dir: Path to directory containing DICOM folders for each series
220
+ :type session_dicom_dir: pathlib.Path
221
+ :param session_nifti_dir: Path to directory containing all .dat files
222
+ :type session_nifti_dir: pathlib.Path
223
+ :param skip_short_runs: Flag which denotes we don't want to run dcmdat2niix on runs stopped short
224
+ :type skip_short_runs: bool
225
+ """
227
226
  can_convert = False
228
227
  unconverted_series = set()
229
228
  error_series = set()
230
229
  if shutil.which('dcmdat2niix') is not None:
231
230
  can_convert = True
232
- nifti_path.mkdir(parents=True, exist_ok=True)
233
- logger.info(f"Combined .dcm & .dat files (.nii.gz format) will be stored at: {nifti_path}")
231
+ session_nifti_dir.mkdir(parents=True, exist_ok=True)
232
+ logger.info(f"Combined .dcm & .dat files (.nii.gz format) will be stored at: {session_nifti_dir}")
234
233
  else:
235
234
  logger.warning("dcmdat2niix not installed or has not been added to the PATH. Cannot convert data files into NIFTI")
236
235
 
@@ -239,26 +238,22 @@ def dat_dcm_to_nifti(session: str,
239
238
  if (p / "DICOM").exists()]
240
239
  downloaded_scans.sort()
241
240
 
242
- # create a map of downloaded scan IDs to UIDs to later match with the UIDs in the .dat files
243
241
  xml_scans = get_xml_scans(xml_file=xml_file_path)
244
242
  # [:-6] is to ignore the trailing '.0.0.0' at the end of the UID string
245
243
  uid_to_id = {s.get("UID")[:-6]:s.get("ID") for s in xml_scans if s.get("ID") in downloaded_scans}
246
244
 
247
245
  # collect all of the .dat files and map them to their UIDs
248
- dat_files = list(dat_directory.glob("*.dat"))
246
+ dat_files = list(dat_directory.rglob("*.dat"))
249
247
  uid_to_dats = {uid: [d for d in dat_files if uid in d.name] for uid in uid_to_id.keys()}
250
248
 
251
249
  for uid, dats in uid_to_dats.items():
252
250
  series_id = uid_to_id[uid]
253
251
  series_path = session_dicom_dir / series_id / "DICOM"
254
252
  for dat in dats:
255
- # if (series_path / dat.name).is_file():
256
- # (series_path / dat.name).unlink()
257
253
  shutil.move(dat.resolve(), series_path.resolve())
258
254
 
259
- # Either there are no accompanying dats, or they were already in the series directory
260
255
  if len(dats) == 0:
261
- dats = list(series_path.glob("*.dat"))
256
+ dats = list(series_path.glob("*.dat")) # see if dats already in series dir
262
257
 
263
258
  dcms = list(series_path.glob("*.dcm"))
264
259
  logger.info(f"length of dats: {len(dats)}")
@@ -286,8 +281,8 @@ def dat_dcm_to_nifti(session: str,
286
281
  logger.warning("Could not find the mismatched dicom")
287
282
 
288
283
  # run the dcmdat2niix subprocess
289
- logger.info(f"Running dcmdat2niix on series {series_id}...")
290
- dcmdat2niix_cmd = shlex.split(f"dcmdat2niix -ba n -z o -w 1 -o {nifti_path} {series_path}")
284
+ logger.info(f"Running dcmdat2niix on series {series_id}")
285
+ dcmdat2niix_cmd = shlex.split(f"dcmdat2niix -ba y -z o -w 1 -o {session_nifti_dir} {series_path}")
291
286
  with subprocess.Popen(dcmdat2niix_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) as p:
292
287
  while p.poll() is None:
293
288
  for line in p.stdout:
@@ -332,11 +327,9 @@ def main():
332
327
  help="Query by CNDA experiment identifier (default is to query by experiment 'label', which may be ambiguous)",
333
328
  action='store_true')
334
329
  parser.add_argument("-p", "--project_id",
335
- help="Specify the project ID to narrow down search. Recommended if the session list is not eperiment ids.")
336
- parser.add_argument("-s", "--scan_number",
337
- help="Select the scan number to start the download from (may be used when only ONE session/experiment is specified)")
338
- parser.add_argument("-n", "--ignore_nordic_volumes",
339
- help="Don't download a NORDIC_VOLUMES folder from CNDA if it exists.",
330
+ help="Specify the project ID to narrow down search. Recommended if the session list is not experiment ids.")
331
+ parser.add_argument("--skip_dcmdat2niix",
332
+ help="If NORDIC_VOLUMES folder is available, don't perform dcmdat2niix pairing step",
340
333
  action='store_true')
341
334
  parser.add_argument("--map_dats", type=Path,
342
335
  help="""The path to a directory containting .dat files you wish to pair with DICOM files. Using this argument
@@ -344,15 +337,15 @@ def main():
344
337
  run dcmdat2niix""")
345
338
  parser.add_argument("--log_dir", type=Path,
346
339
  help="Points to a specified directory that will store the log file. Will not make the directory if it doesn't exist.")
347
- parser.add_argument("-ssr","--skip_short_runs",
340
+ parser.add_argument("--skip_short_runs",
348
341
  action="store_true",
349
342
  help="Flag to indicate that runs stopped short should not be converted to NIFTI")
350
- parser.add_argument("--skip_unusable",
351
- help="Don't download any scans marked as 'unusable' in the XML",
352
- action='store_true')
353
343
  parser.add_argument("--dats_only",
354
344
  help="Skip downloading DICOMs, only try to pull .dat files",
355
345
  action='store_true')
346
+ parser.add_argument("--keep_zip",
347
+ help="Option to keep downloaded .zip file after unzipping",
348
+ action='store_true')
356
349
  args = parser.parse_args()
357
350
 
358
351
  # validate argument inputs
@@ -364,9 +357,6 @@ def main():
364
357
  args.log_dir.mkdir(parents=True, exist_ok=True)
365
358
  log_path = args.log_dir / f"cnda-dl_{datetime.datetime.now().strftime('%m-%d-%y_%I:%M%p')}.log"
366
359
 
367
- if args.scan_number and len(args.session_list) > 1:
368
- parser.error("'--scan_number' can only be specified when there is only one session/experiment to download")
369
-
370
360
  if args.map_dats and not args.map_dats.is_dir():
371
361
  parser.error(f"'--map_dats' directory does not exist: {args.map_dats}")
372
362
 
@@ -376,49 +366,51 @@ def main():
376
366
  logger.addHandler(file_handler)
377
367
  logger.addHandler(sout_handler)
378
368
 
379
- logger.info("Starting cnda-dl...")
369
+ logger.info("Starting cnda-dl")
380
370
  logger.info(f"Log will be stored at {log_path}")
381
371
 
382
372
  # set up data paths
383
373
  session_list = args.session_list
384
- dicom_path = args.dicom_dir
374
+ dicom_dir = args.dicom_dir
385
375
  if hasattr(args, 'xml_dir') and args.xml_dir is not None:
386
376
  xml_path = args.xml_dir
387
377
  else:
388
- xml_path = dicom_path
378
+ xml_path = dicom_dir
389
379
 
390
- if not dicom_path.is_dir():
391
- handle_dir_creation(dicom_path)
380
+ if not dicom_dir.is_dir():
381
+ handle_dir_creation(dicom_dir)
392
382
  if not xml_path.is_dir():
393
383
  handle_dir_creation(xml_path)
394
384
 
395
385
  # set up CNDA connection
396
386
  central = None
397
387
  if not args.map_dats:
398
- central = Interface(server="https://cnda.wustl.edu/")
388
+ central = px.Interface(server="https://cnda.wustl.edu/")
389
+ atexit.register(central.disconnect)
399
390
 
400
391
  # main loop
401
392
  for session in session_list:
393
+ download_success = False
402
394
  xml_file_path = xml_path / f"{session}.xml"
403
- session_dicom_dir = dicom_path / session
404
-
395
+ session_dicom_dir = dicom_dir / session
396
+ session_nifti_dir = dicom_dir / f"{session}_nii"
405
397
  # if only mapping is needed
406
398
  if args.map_dats:
407
399
  # map the .dat files to the correct scans and convert the files to NIFTI
408
- nifti_path = dicom_path / f"{session}_nii"
409
400
  try:
410
401
  dat_dcm_to_nifti(session=session,
411
402
  dat_directory=args.map_dats,
412
403
  xml_file_path=xml_file_path,
413
404
  session_dicom_dir=session_dicom_dir,
414
- nifti_path=nifti_path,
405
+ session_nifti_dir=session_nifti_dir,
415
406
  skip_short_runs=args.skip_short_runs)
416
407
  except Exception:
417
408
  logger.exception(f"Error moving the .dat files to the appropriate scan directories and converting to NIFTI for session: {session}")
409
+ download_success = False
418
410
  continue
419
411
 
420
412
  # download the experiment data
421
- logger.info(f"Starting download of session {session}...")
413
+ logger.info(f"Starting download of session {session}")
422
414
 
423
415
  # try to retrieve the experiment corresponding to this session
424
416
  exp = None
@@ -434,47 +426,38 @@ def main():
434
426
 
435
427
  except Exception:
436
428
  logger.exception("Error retrieving the experiment from the given parameters. Double check your inputs or enter more specific parameters.")
429
+ download_success = False
437
430
  continue
438
431
 
439
- # download the xml for this session
440
432
  download_xml(central=central,
441
433
  subject_id=exp["xnat:mrsessiondata/subject_id"],
442
434
  project_id=exp["project"],
443
435
  file_path=xml_file_path)
444
- # try to download the files for this experiment
445
436
  if not args.dats_only:
446
437
  try:
447
- download_experiment_dicoms(session_experiment=exp,
448
- central=central,
449
- session_dicom_dir=session_dicom_dir,
450
- xml_file_path=xml_file_path,
451
- scan_number_start=args.scan_number,
452
- skip_unusable=args.skip_unusable)
453
- except Exception:
438
+ download_experiment_zip(central=central,
439
+ exp=exp,
440
+ dicom_dir=dicom_dir,
441
+ xml_file_path=xml_file_path,
442
+ keep_zip=args.keep_zip)
443
+ except Exception as e:
454
444
  logger.exception(f"Error downloading the experiment data from CNDA for session: {session}")
445
+ logger.exception(f"{e=}")
446
+ download_success = False
455
447
  continue
456
448
 
457
- # if we are not skipping the NORDIC files
458
- if not args.ignore_nordic_volumes:
459
- # try to download NORDIC related files and convert raw data to NIFTI
460
- try:
461
- nordic_dat_dirs = download_nordic_zips(session=session,
462
- central=central,
463
- session_experiment=exp,
464
- session_dicom_dir=session_dicom_dir)
465
- nifti_path = dicom_path / f"{session}_nii"
466
- for nordic_dat_path in nordic_dat_dirs:
467
- dat_dcm_to_nifti(session=session,
468
- dat_directory=nordic_dat_path,
469
- xml_file_path=xml_file_path,
470
- session_dicom_dir=session_dicom_dir,
471
- nifti_path=nifti_path,
472
- skip_short_runs=args.skip_short_runs)
473
- except Exception:
474
- logger.exception(f"Error downloading 'NORDIC_VOLUMES' and converting to NIFTI for session: {session}")
475
- continue
476
-
477
- logger.info("\n...Downloads Complete")
449
+ nordic_dat_dir = session_dicom_dir / "NORDIC_VOLUMES"
450
+ recursive_unzip(nordic_dat_dir)
451
+ if args.skip_dcmdat2niix or not nordic_dat_dir.is_dir():
452
+ continue
453
+ dat_dcm_to_nifti(session=session,
454
+ dat_directory=nordic_dat_dir,
455
+ xml_file_path=xml_file_path,
456
+ session_dicom_dir=session_dicom_dir,
457
+ session_nifti_dir=session_nifti_dir,
458
+ skip_short_runs=args.skip_short_runs)
459
+ if download_success:
460
+ logger.info("\nDownloads Complete")
478
461
 
479
462
 
480
463
  if __name__ == "__main__":
cnda_dl/formatters.py ADDED
@@ -0,0 +1,65 @@
1
+ import logging
2
+ import os
3
+
4
+
5
+ class Colors:
6
+ RESET = "\033[0m"
7
+ BOLD = "\033[1m"
8
+ UNDERLINE = "\033[4m"
9
+
10
+ BLACK = "\033[30m"
11
+ RED = "\033[31m"
12
+ GREEN = "\033[32m"
13
+ YELLOW = "\033[33m"
14
+ BLUE = "\033[34m"
15
+ MAGENTA = "\033[35m"
16
+ CYAN = "\033[36m"
17
+ WHITE = "\033[37m"
18
+ DARK_GREY = "\033[90m"
19
+ LIGHT_RED = "\033[91m"
20
+ LIGHT_GREEN = "\033[92m"
21
+ LIGHT_YELLOW = "\033[93m"
22
+ LIGHT_BLUE = "\033[94m"
23
+ LIGHT_MAGENTA = "\033[95m"
24
+ LIGHT_CYAN = "\033[96m"
25
+ LIGHT_WHITE = "\033[97m"
26
+
27
+ # Background colors
28
+ BACK_BLACK = "\033[40m"
29
+ BACK_RED = "\033[41m"
30
+ BACK_GREEN = "\033[42m"
31
+ BACK_YELLOW = "\033[43m"
32
+ BACK_BLUE = "\033[44m"
33
+ BACK_MAGENTA = "\033[45m"
34
+ BACK_CYAN = "\033[46m"
35
+ BACK_WHITE = "\033[47m"
36
+ BACK_DARK_GREY = "\033[100m"
37
+ BACK_LIGHT_RED = "\033[101m"
38
+ BACK_LIGHT_GREEN = "\033[102m"
39
+ BACK_LIGHT_YELLOW = "\033[103m"
40
+ BACK_LIGHT_BLUE = "\033[104m"
41
+ BACK_LIGHT_MAGENTA = "\033[105m"
42
+ BACK_LIGHT_CYAN = "\033[106m"
43
+ BACK_LIGHT_WHITE = "\033[107m"
44
+
45
+
46
+ class ParensOnRightFormatter1(logging.Formatter):
47
+ def format(self, record):
48
+ log_message = f"{record.msg}"
49
+ log_level = f"{record.levelname}"
50
+ func_name = (f"{record.funcName}")
51
+ if func_name[-1] == '.':
52
+ func_name[-1] = f"{Colors.DARK_GREY}.{Colors.RESET}"
53
+ # Determine total width of terminal window
54
+ terminal_width = os.get_terminal_size()[0]
55
+ # Calculate the right margin position for the log level and function name
56
+ if log_level == "INFO":
57
+ right_margin_text = f"{Colors.LIGHT_GREEN}({log_level}, {func_name}){Colors.RESET}"
58
+ elif log_level == "WARNING":
59
+ right_margin_text = f"{Colors.YELLOW}({log_level}, {func_name}){Colors.RESET}"
60
+ elif log_level == "ERROR":
61
+ right_margin_text = f"{Colors.RED}({log_level}, {func_name}){Colors.RESET}"
62
+ necessary_padding = terminal_width - len(log_message) - len(right_margin_text)
63
+ # Ensure padding is non-negative
64
+ padding = f'{Colors.DARK_GREY}.{Colors.RESET}' * max(0, necessary_padding)
65
+ return f"{log_message}{padding}{right_margin_text}"
cnda_dl/zip_utils.py ADDED
@@ -0,0 +1,28 @@
1
+ import zipfile
2
+ from pathlib import Path
3
+ import logging
4
+
5
+ logger = logging.getLogger()
6
+
7
+
8
+ def unzipped(zip_path: str | Path, keep_zip: bool = False):
9
+ if isinstance(zip_path, str):
10
+ zip_path = Path(zip_path)
11
+ with zipfile.ZipFile(zip_path, "r") as zip_ref:
12
+ logger.info(f"Unzipping {zip_path}...")
13
+ zip_ref.extractall(zip_path.parent)
14
+ if not keep_zip:
15
+ logger.info(f"Removing {zip_path}...")
16
+ zip_path.unlink()
17
+ return zip_path.with_suffix('')
18
+
19
+
20
+ def recursive_unzip(top_dir: str | Path, keep_zip: bool = False):
21
+ if isinstance(top_dir, str):
22
+ top_dir = Path(top_dir)
23
+ zips_exist = True
24
+ while zips_exist:
25
+ zips_exist = False
26
+ for zip_path in top_dir.rglob("*.zip"):
27
+ zips_exist = True
28
+ unzipped(zip_path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cnda_dl
3
- Version: 1.0.1
3
+ Version: 1.2.0
4
4
  Summary: A command line utility for downloading fMRI data from CNDA
5
5
  Author-Email: Ramone Agard <agardr@wustl.edu>, Joey Scanga <joeyscanga92@gmail.com>
6
6
  Requires-Python: <4.0,>=3.9
@@ -0,0 +1,12 @@
1
+ cnda_dl-1.2.0.dist-info/METADATA,sha256=5twb8i-ODrNEhsmlMyqiSLsip37R5NWoHMFpkN3ZBfc,742
2
+ cnda_dl-1.2.0.dist-info/WHEEL,sha256=9P2ygRxDrTJz3gsagc0Z96ukrxjr-LFBGOgv3AuKlCA,90
3
+ cnda_dl-1.2.0.dist-info/entry_points.txt,sha256=U5B378NGa-YaoKi3s456cW9HT1THi1B_vLKzwAHxHi8,61
4
+ cnda_dl-1.2.0.dist-info/licenses/LICENSE,sha256=5Dte9TUnLZzPRs4NQzl-Jc2-Ljd-t_v0ZR5Ng5r0UsY,35131
5
+ cnda_dl/.ruff_cache/.gitignore,sha256=njpg8ebsSuYCFcEdVLFxOSdF7CXp3e1DPVvZITY68xY,35
6
+ cnda_dl/.ruff_cache/0.9.9/15962950311086395899,sha256=2FFgjB2VS-A2SnbYZgGkU-flEhS3KDRCXoPWi-wjLtQ,22959
7
+ cnda_dl/.ruff_cache/CACHEDIR.TAG,sha256=WVMVbX4MVkpCclExbq8m-IcOZIOuIZf5FrYw5Pk-Ma4,43
8
+ cnda_dl/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ cnda_dl/cli.py,sha256=lQqoqdtT2Nonq7kq72Ix7xNrYWjjEozQjh_b2eBXC9g,19154
10
+ cnda_dl/formatters.py,sha256=A9cTQJhW7lYggreRt7zNRKIYQnH70uguc0UrHRIxRsk,2185
11
+ cnda_dl/zip_utils.py,sha256=yu2c7SFMMfPfqvlo-r4rBvmJ89vrs8L8zXuZqjTCiQM,802
12
+ cnda_dl-1.2.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: pdm-backend (2.4.4)
2
+ Generator: pdm-backend (2.4.5)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,10 +0,0 @@
1
- cnda_dl-1.0.1.dist-info/METADATA,sha256=4Y7x_u7x7eZU9f0DN8Q4HYSoaOpF0TZIe2LF57Ds9BY,742
2
- cnda_dl-1.0.1.dist-info/WHEEL,sha256=tSfRZzRHthuv7vxpI4aehrdN9scLjk-dCJkPLzkHxGg,90
3
- cnda_dl-1.0.1.dist-info/entry_points.txt,sha256=U5B378NGa-YaoKi3s456cW9HT1THi1B_vLKzwAHxHi8,61
4
- cnda_dl-1.0.1.dist-info/licenses/LICENSE,sha256=5Dte9TUnLZzPRs4NQzl-Jc2-Ljd-t_v0ZR5Ng5r0UsY,35131
5
- cnda_dl/.ruff_cache/.gitignore,sha256=njpg8ebsSuYCFcEdVLFxOSdF7CXp3e1DPVvZITY68xY,35
6
- cnda_dl/.ruff_cache/0.9.9/15962950311086395899,sha256=2FFgjB2VS-A2SnbYZgGkU-flEhS3KDRCXoPWi-wjLtQ,22959
7
- cnda_dl/.ruff_cache/CACHEDIR.TAG,sha256=WVMVbX4MVkpCclExbq8m-IcOZIOuIZf5FrYw5Pk-Ma4,43
8
- cnda_dl/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- cnda_dl/cli.py,sha256=PqiKj3DgHnIz2ilSQfrNHFFLZARcUCVjGCG6DEc9lyA,21597
10
- cnda_dl-1.0.1.dist-info/RECORD,,