xnatbidscli 2.0.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.
xnatbidscli/cli.py ADDED
@@ -0,0 +1,525 @@
1
+ import argparse
2
+ import sys
3
+
4
+ from .bidsmap import bidsmap_cmd
5
+ from .cubids import cubids_cmd
6
+ from .download import download_cmd
7
+ from .login import login_cmd
8
+ from .mriconfig import mriconfig_cmd
9
+ from .mriconvert import mriconvert_cmd
10
+ from .physioconvert import physioconvert_cmd
11
+ from .query import (
12
+ date_filter,
13
+ handedness_filter,
14
+ query_cmd,
15
+ sex_filter,
16
+ time_filter,
17
+ )
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(
22
+ prog="xnatbidscli",
23
+ description="Command-line client for XNAT servers.",
24
+ )
25
+ subparsers = parser.add_subparsers(dest="command", required=True)
26
+
27
+ login_parser = subparsers.add_parser(
28
+ "login",
29
+ help="Prompt for XNAT server, username, and password; verify; save to "
30
+ "~/.xnatbidscli/credentials.cfg.",
31
+ )
32
+ login_parser.set_defaults(func=login_cmd)
33
+
34
+ query_parser = subparsers.add_parser(
35
+ "query",
36
+ help="Write a CSV of (project, subject, experiment) triplets for a project or subject.",
37
+ )
38
+ query_parser.add_argument(
39
+ "project",
40
+ metavar="PROJECT",
41
+ help="XNAT project (ID or label).",
42
+ )
43
+ query_parser.add_argument(
44
+ "subject",
45
+ metavar="SUBJECT",
46
+ nargs="?",
47
+ default=None,
48
+ help="Optional XNAT subject (ID or label). If omitted, all subjects "
49
+ "in the project are listed.",
50
+ )
51
+ query_parser.add_argument(
52
+ "-o",
53
+ "--output",
54
+ required=True,
55
+ metavar="OUTPUT_DIR",
56
+ help="Directory to write the CSV file into.",
57
+ )
58
+ query_filters = query_parser.add_argument_group(
59
+ "filters",
60
+ "Keep only matching experiments. Different flags must all match; "
61
+ "several values for one flag match if any does. Experiments with a "
62
+ "blank value for a filtered field are dropped.",
63
+ )
64
+ query_filters.add_argument(
65
+ "--accession", nargs="+", metavar="ID",
66
+ help="Subject IDs or labels, experiment IDs or labels, or "
67
+ "StudyInstanceUIDs (STUDY_UID), exact match. A subject keeps all "
68
+ "of its experiments.",
69
+ )
70
+ query_filters.add_argument(
71
+ "--date", nargs="+", type=date_filter, metavar="DATE",
72
+ help="Experiment date as MM, YYYY, YYYYMM or YYYYMMDD.",
73
+ )
74
+ query_filters.add_argument(
75
+ "--time", nargs="+", type=time_filter, metavar="TIME",
76
+ help="Experiment start time as HH, HHMM or HHMMSS.",
77
+ )
78
+ query_filters.add_argument(
79
+ "--scanner", nargs="+", metavar="TEXT",
80
+ help="Substring of the scanner name, manufacturer or model.",
81
+ )
82
+ query_filters.add_argument(
83
+ "--study", nargs="+", metavar="TEXT",
84
+ help="Substring of the DICOM StudyDescription.",
85
+ )
86
+ query_filters.add_argument(
87
+ "--site", nargs="+", metavar="TEXT",
88
+ help="Substring of the acquisition site.",
89
+ )
90
+ query_filters.add_argument(
91
+ "--operator", nargs="+", metavar="TEXT",
92
+ help="Substring of the scanner operator.",
93
+ )
94
+ query_filters.add_argument(
95
+ "--sex", nargs="+", type=sex_filter, metavar="SEX",
96
+ help="M/F/O or male/female/other. Keeps an experiment if either "
97
+ "XNAT_GENDER or DICOM_SEX matches.",
98
+ )
99
+ query_filters.add_argument(
100
+ "--handedness", nargs="+", type=handedness_filter, metavar="{L,R,A,U}",
101
+ help="Subject handedness: L(eft), R(ight), A(mbidextrous), U(nknown).",
102
+ )
103
+ query_filters.add_argument(
104
+ "--age", nargs="+", type=int, metavar="YEARS",
105
+ help="Age in whole years at the experiment.",
106
+ )
107
+ query_parser.set_defaults(func=query_cmd)
108
+
109
+ download_parser = subparsers.add_parser(
110
+ "download",
111
+ help="Download every file from one XNAT experiment (-1), a unique "
112
+ "accession number (--accession), or many (--csv).",
113
+ )
114
+ download_source = download_parser.add_mutually_exclusive_group(required=True)
115
+ download_source.add_argument(
116
+ "-1",
117
+ dest="triplet",
118
+ nargs=3,
119
+ metavar=("PROJECT", "SUBJECT", "EXPERIMENT"),
120
+ help="Download a single experiment. Each value may be either the "
121
+ "XNAT ID or the user-facing label.",
122
+ )
123
+ download_source.add_argument(
124
+ "--accession",
125
+ dest="accession",
126
+ metavar="ACCESSION",
127
+ help="Download by one subject ID or label, experiment ID or label, "
128
+ "or StudyInstanceUID, with no PROJECT/SUBJECT needed. A subject "
129
+ "downloads every experiment for that subject (--rename-experiment "
130
+ "is not allowed, since a subject may have more than one "
131
+ "experiment); an experiment downloads only that one. A label that "
132
+ "matches more than one subject/experiment on the server is an "
133
+ "error listing the matches.",
134
+ )
135
+ download_source.add_argument(
136
+ "-c",
137
+ "--csv",
138
+ "-i",
139
+ "--input",
140
+ dest="input",
141
+ metavar="CSV_FILE",
142
+ help="Path to a CSV file (xnatbidscli query output) listing experiments "
143
+ "to download.",
144
+ )
145
+ download_parser.add_argument(
146
+ "-o",
147
+ "--output",
148
+ required=True,
149
+ metavar="OUTPUT_DIR",
150
+ help="Directory to write the downloaded files into.",
151
+ )
152
+ download_parser.add_argument(
153
+ "--rename-subject",
154
+ metavar="SUBJECT_BIDS_RENAME",
155
+ help="Only with -1 or --accession. Rename the on-disk SUBJECT "
156
+ "directory to this value ('sub-' is prepended if missing); XNAT is "
157
+ "still queried using the original SUBJECT label.",
158
+ )
159
+ download_parser.add_argument(
160
+ "--rename-experiment",
161
+ metavar="EXPERIMENT_BIDS_RENAME",
162
+ help="Only with -1 or an experiment --accession (not a subject "
163
+ "accession, which may have more than one experiment). Rename the "
164
+ "on-disk EXPERIMENT directory to this value ('ses-' is prepended "
165
+ "if missing); XNAT is still queried using the original EXPERIMENT "
166
+ "label.",
167
+ )
168
+ download_parser.add_argument(
169
+ "-n",
170
+ "--ndownload",
171
+ type=int,
172
+ default=1,
173
+ metavar="N",
174
+ help="Number of parallel experiment downloads for --csv input or a "
175
+ "subject --accession (default 1). Not used with -1.",
176
+ )
177
+ download_parser.add_argument(
178
+ "-l",
179
+ "--log",
180
+ action="store_true",
181
+ help="Write a download log CSV to OUTPUT_DIR/log/download_<YYYYMMDD_HHMMSS>_log.csv.",
182
+ )
183
+ download_parser.add_argument(
184
+ "-a",
185
+ "--archive",
186
+ action="store_true",
187
+ help="After downloading each experiment, tar+gzip its "
188
+ "OUTPUT_DIR/PROJECT/SUBJECT/EXPERIMENT directory into "
189
+ "OUTPUT_DIR/archive/PROJECT-<P>_SUBJECT-<S>_EXPERIMENT-<E>.tar.gz. "
190
+ "Existing archives are skipped with a warning.",
191
+ )
192
+ download_parser.add_argument(
193
+ "-d",
194
+ "--delete",
195
+ action="store_true",
196
+ help="After a successful archive (requires --archive), delete the "
197
+ "OUTPUT_DIR/PROJECT/SUBJECT/EXPERIMENT directory. The SUBJECT and "
198
+ "PROJECT parent directories are also removed if they become empty.",
199
+ )
200
+ download_parser.set_defaults(func=download_cmd)
201
+
202
+ mriconfig_parser = subparsers.add_parser(
203
+ "mriconfig",
204
+ help="Run dcm2bids_helper on one or many downloaded XNAT experiment "
205
+ "directories and draft a project-level dcm2bids config.",
206
+ )
207
+ mriconfig_parser.add_argument(
208
+ "-i",
209
+ "--input",
210
+ required=True,
211
+ metavar="INPUT_DIR",
212
+ help="Root directory holding PROJECT/SUBJECT/EXPERIMENT "
213
+ "subdirectories (i.e., the output of `xnatbidscli download`).",
214
+ )
215
+ mriconfig_source = (
216
+ mriconfig_parser.add_mutually_exclusive_group(required=True)
217
+ )
218
+ mriconfig_source.add_argument(
219
+ "-1",
220
+ dest="triplet",
221
+ nargs=3,
222
+ metavar=("PROJECT", "SUBJECT", "EXPERIMENT"),
223
+ help="Run dcm2bids_helper on a single experiment. Each value must match the "
224
+ "corresponding directory name under INPUT_DIR.",
225
+ )
226
+ mriconfig_source.add_argument(
227
+ "-s",
228
+ "--subject",
229
+ nargs=2,
230
+ metavar=("PROJECT", "SUBJECT"),
231
+ help="Run dcm2bids_helper on every experiment of one subject.",
232
+ )
233
+ mriconfig_source.add_argument(
234
+ "-p",
235
+ "--project",
236
+ metavar="PROJECT",
237
+ help="Run dcm2bids_helper on every experiment of every subject in a project.",
238
+ )
239
+ mriconfig_parser.add_argument(
240
+ "-o",
241
+ "--output",
242
+ required=True,
243
+ metavar="OUTPUT_DIR",
244
+ help="Directory to write the mriconfig output into. The dcm2bids_helper "
245
+ "results land under OUTPUT_DIR/PROJECT-<PROJECT>_mriconfig/.",
246
+ )
247
+ mriconfig_parser.add_argument(
248
+ "-n",
249
+ "--nprep",
250
+ type=int,
251
+ default=1,
252
+ metavar="N",
253
+ help="Number of parallel dcm2bids_helper invocations, one per "
254
+ "experiment per core (default 1).",
255
+ )
256
+ mriconfig_parser.add_argument(
257
+ "-l",
258
+ "--log",
259
+ action="store_true",
260
+ help="Write a per-experiment log CSV to "
261
+ "OUTPUT_DIR/log/mriconfig_<YYYYMMDD_HHMMSS>_log.csv.",
262
+ )
263
+ mriconfig_parser.add_argument(
264
+ "-d",
265
+ "--delete",
266
+ action="store_true",
267
+ help="Delete *.nii.gz files from each experiment's dcm2bids_helper subdir "
268
+ "(OUTPUT_DIR/PROJECT-<PROJECT>_mriconfig/tmp_dcm2bids/helper/<EXPERIMENT>/) "
269
+ "right after dcm2bids_helper returns, regardless of STATUS. JSON "
270
+ "sidecars (used by the config draft) are kept.",
271
+ )
272
+ mriconfig_parser.add_argument(
273
+ "-m",
274
+ "--maps",
275
+ action="store_true",
276
+ help="Skip running dcm2bids_helper; only (re)draft the dcm2bids config "
277
+ "from the existing dcm2bids_helper JSON sidecars already under "
278
+ "OUTPUT_DIR/PROJECT-<PROJECT>_mriconfig/. dcm2bids_helper and dcm2niix "
279
+ "are not required with this option.",
280
+ )
281
+ mriconfig_parser.set_defaults(func=mriconfig_cmd)
282
+
283
+ mriconvert_parser = subparsers.add_parser(
284
+ "mriconvert",
285
+ help="Convert XNAT-downloaded sessions to BIDS via dcm2bids.",
286
+ )
287
+ mriconvert_parser.add_argument(
288
+ "-i",
289
+ "--input",
290
+ required=True,
291
+ metavar="INPUT_DIR",
292
+ help="Root directory holding PROJECT/SUBJECT/EXPERIMENT "
293
+ "subdirectories (i.e., the output of `xnatbidscli download`). The "
294
+ "directory names should match those written by `xnatbidscli download` "
295
+ "(XNAT IDs for the project, labels for subject and experiment).",
296
+ )
297
+ mriconvert_source = (
298
+ mriconvert_parser.add_mutually_exclusive_group(required=True)
299
+ )
300
+ mriconvert_source.add_argument(
301
+ "-1",
302
+ dest="triplet",
303
+ nargs=3,
304
+ metavar=("PROJECT", "SUBJECT", "EXPERIMENT"),
305
+ help="Convert a single session. Each value must match the "
306
+ "corresponding directory name under INPUT_DIR.",
307
+ )
308
+ mriconvert_source.add_argument(
309
+ "-s",
310
+ "--subject",
311
+ nargs=2,
312
+ metavar=("PROJECT", "SUBJECT"),
313
+ help="Convert all sessions of one subject. Values must match the "
314
+ "corresponding directory names under INPUT_DIR.",
315
+ )
316
+ mriconvert_source.add_argument(
317
+ "-p",
318
+ "--project",
319
+ metavar="PROJECT",
320
+ help="Convert all sessions of all subjects in a project. Value "
321
+ "must match the project directory name under INPUT_DIR.",
322
+ )
323
+ mriconvert_parser.add_argument(
324
+ "-o",
325
+ "--output",
326
+ required=True,
327
+ metavar="OUTPUT_DIR",
328
+ help="Directory to write BIDS-converted data into. Each project's "
329
+ "BIDS dataset lives at OUTPUT_DIR/PROJECT/.",
330
+ )
331
+ mriconvert_parser.add_argument(
332
+ "-y",
333
+ "--physio",
334
+ dest="physio_parent",
335
+ metavar="PHYSIO_PARENT_DIR",
336
+ default=None,
337
+ help="Optional absolute path to the flat directory holding all raw "
338
+ "physio recordings for this project. Recorded as the top-level "
339
+ "'PhysioParent' key in OUTPUT_DIR/PROJECT-<P>_mriconvert_qc.json for "
340
+ "xnatbidscli physioconvert to resolve "
341
+ "OUTPUT_DIR/PROJECT-<P>_mriconvert_qc.tsv's "
342
+ "'physio' column against. If omitted, a PhysioParent recorded on a "
343
+ "prior run is preserved.",
344
+ )
345
+ mriconvert_parser.add_argument(
346
+ "-c",
347
+ "--config",
348
+ metavar="CONFIG_FILE",
349
+ help="Path to the dcm2bids config JSON to use (e.g., the one drafted "
350
+ "by `xnatbidscli mriconfig`). Required unless -m/--maps is given. "
351
+ "Recorded as the top-level 'Dcm2BidsConfigPath' key (with a "
352
+ "'LastModified' timestamp) in OUTPUT_DIR/PROJECT-<P>_mriconvert_qc.json. "
353
+ "If omitted (only possible with -m/--maps), a Dcm2BidsConfigPath "
354
+ "recorded on a prior run is preserved.",
355
+ )
356
+ mriconvert_parser.add_argument(
357
+ "-n",
358
+ "--nconvert",
359
+ type=int,
360
+ default=1,
361
+ metavar="N",
362
+ help="Number of parallel session conversions (default 1).",
363
+ )
364
+ mriconvert_parser.add_argument(
365
+ "-l",
366
+ "--log",
367
+ action="store_true",
368
+ help="Write a per-session log CSV to "
369
+ "OUTPUT_DIR/log/mriconvert_<YYYYMMDD_HHMMSS>_log.csv.",
370
+ )
371
+ mriconvert_parser.add_argument(
372
+ "-a",
373
+ "--archive",
374
+ action="store_true",
375
+ help="For every session in scope, tar+gzip its "
376
+ "INPUT_DIR/PROJECT/SUBJECT/EXPERIMENT directory into "
377
+ "INPUT_DIR/archive/PROJECT-<P>_SUBJECT-<S>_EXPERIMENT-<E>.tar.gz. "
378
+ "Existing archives are skipped with a warning. Archiving runs "
379
+ "regardless of the dcm2bids conversion outcome.",
380
+ )
381
+ mriconvert_parser.add_argument(
382
+ "-d",
383
+ "--delete",
384
+ action="store_true",
385
+ help="Delete each session's input directory "
386
+ "INPUT_DIR/PROJECT/SUBJECT/EXPERIMENT after it is safely preserved. "
387
+ "Without --archive, deletion runs only when the session converted "
388
+ "with STATUS=COMPLETE or STATUS=EMPTY. With --archive, deletion "
389
+ "runs after a successful archive regardless of conversion status. "
390
+ "The SUBJECT and PROJECT parent directories are also removed if "
391
+ "they become empty.",
392
+ )
393
+ mriconvert_parser.add_argument(
394
+ "-m",
395
+ "--maps",
396
+ action="store_true",
397
+ help="Skip the dcm2bids conversion; only (re)generate "
398
+ "OUTPUT_DIR/PROJECT-<P>_mriconvert_qc.tsv (and copy "
399
+ "OUTPUT_DIR/PROJECT-<P>_mriconvert_qc.json) for every project in scope from "
400
+ "the already-converted BIDS data under OUTPUT_DIR. -c/--config, pydicom, "
401
+ "dcm2bids, and dcm2niix are not required with this option.",
402
+ )
403
+ mriconvert_parser.set_defaults(func=mriconvert_cmd)
404
+
405
+ physioconvert_parser = subparsers.add_parser(
406
+ "physioconvert",
407
+ help="Convert physio recordings associated (via mriconvert_qc.tsv's "
408
+ "'physio' column) with an xnatbidscli mriconvert BIDS dataset, via "
409
+ "phys2bids.",
410
+ )
411
+ physioconvert_parser.add_argument(
412
+ "-o",
413
+ "--output",
414
+ required=True,
415
+ metavar="OUTPUT_DIR",
416
+ help="Same BIDS root xnatbidscli mriconvert wrote to (OUTPUT_DIR must hold "
417
+ "PROJECT-<P>_mriconvert_qc.tsv/PROJECT-<P>_mriconvert_qc.json). Physio "
418
+ "outputs are written directly into "
419
+ "OUTPUT_DIR/PROJECT/sub-X/ses-Y/<datatype>/ alongside the associated "
420
+ ".nii.gz.",
421
+ )
422
+ physioconvert_parser.add_argument(
423
+ "-p",
424
+ "--project",
425
+ required=True,
426
+ metavar="PROJECT",
427
+ help="Project directory name under OUTPUT_DIR identifying the BIDS "
428
+ "dataset produced by xnatbidscli mriconvert.",
429
+ )
430
+ physioconvert_parser.add_argument(
431
+ "-n",
432
+ "--nphysio",
433
+ type=int,
434
+ default=1,
435
+ metavar="N",
436
+ help="Number of physio files to convert in parallel, one phys2bids "
437
+ "conversion per process (default 1).",
438
+ )
439
+ physioconvert_parser.add_argument(
440
+ "-l",
441
+ "--log",
442
+ action="store_true",
443
+ help="Write a per-file log CSV to "
444
+ "OUTPUT_DIR/log/physioconvert_<YYYYMMDD_HHMMSS>_log.csv, and mirror "
445
+ "everything printed to stdout/stderr into a companion text log at "
446
+ "OUTPUT_DIR/log/physioconvert_<YYYYMMDD_HHMMSS>_log.txt.",
447
+ )
448
+ physioconvert_parser.set_defaults(func=physioconvert_cmd)
449
+
450
+ bidsmap_parser = subparsers.add_parser(
451
+ "bidsmap",
452
+ help="Generate (or update) a participant/session map TSV for a BIDS "
453
+ "dataset at INPUT_DIR/PROJECT/ produced by xnatbidscli mriconvert.",
454
+ )
455
+ bidsmap_parser.add_argument(
456
+ "-i",
457
+ "--input",
458
+ required=True,
459
+ metavar="INPUT_DIR",
460
+ help="Root directory holding the BIDS dataset at INPUT_DIR/PROJECT/ "
461
+ "(i.e., the output of `xnatbidscli mriconvert`). The map TSV is written "
462
+ "here as PROJECT-<PROJECT>_bidsmap.tsv.",
463
+ )
464
+ bidsmap_parser.add_argument(
465
+ "-p",
466
+ "--project",
467
+ required=True,
468
+ metavar="PROJECT",
469
+ help="Project directory name under INPUT_DIR identifying the BIDS "
470
+ "dataset to scan for participants and sessions.",
471
+ )
472
+ bidsmap_parser.add_argument(
473
+ "-o",
474
+ "--output",
475
+ metavar="OUTPUT_DIR",
476
+ help="When provided, apply all renames from PROJECT-<PROJECT>_bidsmap.tsv "
477
+ "(participant_rename, session_rename) — and mriconvert_qc.tsv's own rename "
478
+ "column — by recursively copying the BIDS dataset to OUTPUT_DIR/PROJECT/ "
479
+ "with every rename applied. The map TSV generation always runs first "
480
+ "regardless. OUTPUT_DIR/PROJECT/ must not already exist. Skips "
481
+ "tmp_dcm2bids and log scratch directories.",
482
+ )
483
+ bidsmap_parser.set_defaults(func=bidsmap_cmd)
484
+
485
+ cubids_parser = subparsers.add_parser(
486
+ "cubids",
487
+ help="Run cubids add-nifti-info and cubids group on a BIDS dataset.",
488
+ )
489
+ cubids_parser.add_argument(
490
+ "-i",
491
+ "--input",
492
+ required=True,
493
+ metavar="INPUT_DIR",
494
+ help="Parent directory holding the BIDS dataset at INPUT_DIR/PROJECT/ "
495
+ "(i.e., the output of `xnatbidscli mriconvert`). CuBIDS outputs land "
496
+ "under INPUT_DIR/PROJECT-<PROJECT>_cubids/.",
497
+ )
498
+ cubids_parser.add_argument(
499
+ "-p",
500
+ "--project",
501
+ required=True,
502
+ metavar="PROJECT",
503
+ help="Project directory name under INPUT_DIR identifying the BIDS "
504
+ "dataset to process.",
505
+ )
506
+ cubids_parser.add_argument(
507
+ "-l",
508
+ "--log",
509
+ action="store_true",
510
+ help="Write a per-step log CSV to "
511
+ "INPUT_DIR/PROJECT-<PROJECT>_cubids/log/cubids_<YYYYMMDD_HHMMSS>_log.csv.",
512
+ )
513
+ cubids_parser.set_defaults(func=cubids_cmd)
514
+
515
+ return parser
516
+
517
+
518
+ def main(argv: list[str] | None = None) -> int:
519
+ parser = build_parser()
520
+ args = parser.parse_args(argv)
521
+ return args.func(args) or 0
522
+
523
+
524
+ if __name__ == "__main__":
525
+ sys.exit(main())
xnatbidscli/cubids.py ADDED
@@ -0,0 +1,149 @@
1
+ import argparse
2
+ import csv
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ import time
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from .sysinfo import get_system_username
11
+
12
+ STATUS_COMPLETE = "COMPLETE"
13
+ STATUS_FAILURE = "FAILURE"
14
+
15
+
16
+ def _logging_now() -> str:
17
+ now = datetime.now()
18
+ return f"{now.strftime('%Y-%m-%d %H:%M:%S')},{now.microsecond // 1000:03d}"
19
+
20
+
21
+ class _LogWriter:
22
+ def __init__(self, path: Path | None):
23
+ self._path = path
24
+ self._user = get_system_username()
25
+ if path is not None:
26
+ path.parent.mkdir(parents=True, exist_ok=True)
27
+ with path.open("w", newline="") as f:
28
+ csv.writer(f).writerow(
29
+ ["DATESTAMP", "USER", "PROJECT", "STEP", "STATUS"]
30
+ )
31
+
32
+ def write(
33
+ self, datestamp: str, project: str, step: str, status: str
34
+ ) -> None:
35
+ if self._path is None:
36
+ return
37
+ with self._path.open("a", newline="") as f:
38
+ csv.writer(f).writerow(
39
+ [datestamp, self._user, project, step, status]
40
+ )
41
+
42
+
43
+ def _run_step(
44
+ label: str,
45
+ cmd: list[str],
46
+ project: str,
47
+ log_writer: _LogWriter,
48
+ ) -> bool:
49
+ start = _logging_now()
50
+ print(f"Running: {' '.join(cmd)}")
51
+ result = subprocess.run(cmd)
52
+ status = STATUS_COMPLETE if result.returncode == 0 else STATUS_FAILURE
53
+ log_writer.write(start, project, label, status)
54
+ if result.returncode != 0:
55
+ print(
56
+ f"Error: cubids {label} exited with code {result.returncode}.",
57
+ file=sys.stderr,
58
+ )
59
+ return False
60
+ return True
61
+
62
+
63
+ def cubids_cmd(args: argparse.Namespace) -> int:
64
+ input_root = Path(args.input).resolve()
65
+ if not input_root.is_dir():
66
+ sys.exit(f"Error: input directory not found: {input_root}")
67
+
68
+ project = args.project
69
+ bids_dir = input_root / project
70
+ if not bids_dir.is_dir():
71
+ sys.exit(
72
+ f"Error: BIDS dataset for project {project!r} not found at "
73
+ f"{bids_dir}"
74
+ )
75
+
76
+ cubids = shutil.which("cubids")
77
+ if cubids is None:
78
+ sys.exit(
79
+ "Error: required tool 'cubids' was not found on PATH. "
80
+ "Install it (e.g., 'uv sync' or 'pip install cubids') and try again."
81
+ )
82
+
83
+ output_dir = input_root / f"PROJECT-{project}_cubids"
84
+ output_dir.mkdir(parents=True, exist_ok=True)
85
+
86
+ log_path: Path | None = None
87
+ if args.log:
88
+ while True:
89
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
90
+ log_path = output_dir / "log" / f"cubids_{ts}_log.csv"
91
+ if not log_path.exists():
92
+ break
93
+ time.sleep(1)
94
+ log_writer = _LogWriter(log_path)
95
+
96
+ # cubids walks the whole BIDS tree via rglob and has no ignore mechanism,
97
+ # so temporarily move dcm2bids scratch out of the dataset for the run.
98
+ tmp_dcm2bids = bids_dir / "tmp_dcm2bids"
99
+ stash_path = input_root / f".{project}_cubids_stash_tmp_dcm2bids"
100
+ stashed = False
101
+ if tmp_dcm2bids.is_dir():
102
+ if stash_path.exists():
103
+ shutil.rmtree(stash_path)
104
+ shutil.move(str(tmp_dcm2bids), str(stash_path))
105
+ stashed = True
106
+
107
+ try:
108
+ ok = _run_step(
109
+ "add-nifti-info",
110
+ [cubids, "add-nifti-info", str(bids_dir)],
111
+ project,
112
+ log_writer,
113
+ )
114
+ if not ok:
115
+ if log_path is not None:
116
+ print(f"Log written to {log_path}")
117
+ return 1
118
+
119
+ ok = _run_step(
120
+ "group",
121
+ [cubids, "group", str(bids_dir), "v0"],
122
+ project,
123
+ log_writer,
124
+ )
125
+
126
+ if ok:
127
+ cubids_src = bids_dir / "code" / "CuBIDS"
128
+ if cubids_src.is_dir():
129
+ cubids_dst = output_dir / "CuBIDS"
130
+ shutil.copytree(cubids_src, cubids_dst, dirs_exist_ok=True)
131
+ shutil.rmtree(cubids_src)
132
+ code_dir = bids_dir / "code"
133
+ if code_dir.is_dir() and not any(code_dir.iterdir()):
134
+ code_dir.rmdir()
135
+
136
+ if log_path is not None:
137
+ print(f"Log written to {log_path}")
138
+ return 0 if ok else 1
139
+ finally:
140
+ if stashed and stash_path.is_dir():
141
+ if tmp_dcm2bids.exists():
142
+ print(
143
+ f"Warning: cannot restore {tmp_dcm2bids}; it reappeared "
144
+ f"during the cubids run. Stashed copy preserved at "
145
+ f"{stash_path}.",
146
+ file=sys.stderr,
147
+ )
148
+ else:
149
+ shutil.move(str(stash_path), str(tmp_dcm2bids))