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/login.py ADDED
@@ -0,0 +1,142 @@
1
+ import argparse
2
+ import configparser
3
+ import getpass
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from pyxnat import Interface
9
+
10
+ CONFIG_DIR = Path.home() / ".xnatbidscli"
11
+ CONFIG_PATH = CONFIG_DIR / "credentials.cfg"
12
+
13
+ # Pre-rename location (the package was named "xnatcli" before the 1.x rename
14
+ # to "xnatbidscli"); migrated automatically the first time credentials are read.
15
+ _LEGACY_CONFIG_DIR = Path.home() / ".xnatcli"
16
+ _LEGACY_CONFIG_PATH = _LEGACY_CONFIG_DIR / "credentials.cfg"
17
+ _LEGACY_SECTION = "xnatcli"
18
+
19
+
20
+ def _prompt(label: str) -> str:
21
+ value = input(f"{label}: ").strip()
22
+ if not value:
23
+ sys.exit(f"Error: {label.lower()} is required.")
24
+ return value
25
+
26
+
27
+ def _verify(server: str, username: str, password: str) -> None:
28
+ interface = None
29
+ try:
30
+ interface = Interface(server=server, user=username, password=password)
31
+ # Force a real authenticated request so bad creds/URLs fail here.
32
+ interface.select.projects().get()
33
+ except Exception as e:
34
+ msg = str(e)
35
+ lowered = msg.lower()
36
+ if "401" in lowered or "unauthorized" in lowered or "authentication" in lowered:
37
+ sys.exit(
38
+ f"Error: authentication failed for user '{username}' on {server}. "
39
+ "Check the username and password and try again."
40
+ )
41
+ if (
42
+ "connection" in lowered
43
+ or "timeout" in lowered
44
+ or "not found" in lowered
45
+ or "name or service" in lowered
46
+ or "resolve" in lowered
47
+ ):
48
+ sys.exit(
49
+ f"Error: could not reach {server}. Check the URL and your network connection."
50
+ )
51
+ sys.exit(f"Error: could not verify credentials on {server}: {msg}")
52
+ finally:
53
+ if interface is not None:
54
+ try:
55
+ interface.disconnect()
56
+ except Exception:
57
+ pass
58
+
59
+
60
+ def _migrate_legacy_credentials() -> None:
61
+ """Move a pre-rename ``~/.xnatcli/credentials.cfg`` to the new location.
62
+
63
+ No-op if the new config already exists, the legacy file doesn't, or the
64
+ legacy file is malformed. Reuses ``_write_credentials`` so the migrated
65
+ file gets the same ``0o600`` permissions as a freshly written one.
66
+ """
67
+ if CONFIG_PATH.exists() or not _LEGACY_CONFIG_PATH.exists():
68
+ return
69
+ legacy = configparser.ConfigParser()
70
+ legacy.read(_LEGACY_CONFIG_PATH)
71
+ if _LEGACY_SECTION not in legacy:
72
+ return
73
+ section = legacy[_LEGACY_SECTION]
74
+ server, username, password = (
75
+ section.get("server"),
76
+ section.get("username"),
77
+ section.get("password"),
78
+ )
79
+ if not (server and username and password):
80
+ return
81
+
82
+ _write_credentials(server, username, password)
83
+ _LEGACY_CONFIG_PATH.unlink()
84
+ try:
85
+ _LEGACY_CONFIG_DIR.rmdir()
86
+ except OSError:
87
+ pass # directory has other files; leave it
88
+ print(f"Migrated credentials from {_LEGACY_CONFIG_PATH} to {CONFIG_PATH}")
89
+
90
+
91
+ def load_credentials() -> tuple[str, str, str]:
92
+ _migrate_legacy_credentials()
93
+ if not CONFIG_PATH.exists():
94
+ sys.exit(
95
+ f"Error: no credentials found at {CONFIG_PATH}. "
96
+ "Run 'xnatbidscli login' first."
97
+ )
98
+ config = configparser.ConfigParser()
99
+ config.read(CONFIG_PATH)
100
+ if "xnatbidscli" not in config:
101
+ sys.exit(
102
+ f"Error: {CONFIG_PATH} is missing the [xnatbidscli] section. "
103
+ "Run 'xnatbidscli login' again."
104
+ )
105
+ section = config["xnatbidscli"]
106
+ for key in ("server", "username", "password"):
107
+ if not section.get(key):
108
+ sys.exit(
109
+ f"Error: {CONFIG_PATH} is missing '{key}'. "
110
+ "Run 'xnatbidscli login' again."
111
+ )
112
+ return section["server"], section["username"], section["password"]
113
+
114
+
115
+ def _write_credentials(server: str, username: str, password: str) -> None:
116
+ config = configparser.ConfigParser()
117
+ config["xnatbidscli"] = {
118
+ "server": server,
119
+ "username": username,
120
+ "password": password,
121
+ }
122
+
123
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
124
+ fd = os.open(str(CONFIG_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
125
+ with os.fdopen(fd, "w") as f:
126
+ config.write(f)
127
+ # os.open's mode is masked by umask; force 0o600 explicitly.
128
+ os.chmod(CONFIG_PATH, 0o600)
129
+
130
+
131
+ def login_cmd(args: argparse.Namespace) -> int:
132
+ server = _prompt("XNAT server URL").rstrip("/")
133
+ username = _prompt("Username")
134
+ password = getpass.getpass("Password: ")
135
+ if not password:
136
+ sys.exit("Error: password is required.")
137
+
138
+ _verify(server, username, password)
139
+ _write_credentials(server, username, password)
140
+
141
+ print(f"Credentials verified and saved to {CONFIG_PATH}")
142
+ return 0
@@ -0,0 +1,560 @@
1
+ import argparse
2
+ import csv
3
+ import json
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import threading
9
+ import time
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ from .sysinfo import get_system_username
15
+
16
+ STATUS_COMPLETE = "COMPLETE"
17
+ STATUS_FAILURE = "FAILURE"
18
+
19
+ _NON_ALNUM = re.compile(r"[^A-Za-z0-9]")
20
+ _DIRECTION_CODES = ("AP", "PA", "RL", "LR", "SI", "IS")
21
+ _DIRECTION_RE = re.compile(
22
+ rf"(?<![A-Za-z])({'|'.join(_DIRECTION_CODES)})(?![A-Za-z])"
23
+ )
24
+
25
+ _print_lock = threading.Lock()
26
+
27
+
28
+ def _safe_print(msg: str) -> None:
29
+ with _print_lock:
30
+ print(msg)
31
+
32
+
33
+ def _logging_now() -> str:
34
+ now = datetime.now()
35
+ return f"{now.strftime('%Y-%m-%d %H:%M:%S')},{now.microsecond // 1000:03d}"
36
+
37
+
38
+ class _LogWriter:
39
+ def __init__(self, path: Path | None):
40
+ self._path = path
41
+ self._lock = threading.Lock()
42
+ self._user = get_system_username()
43
+ if path is not None:
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ with path.open("w", newline="") as f:
46
+ csv.writer(f).writerow(
47
+ [
48
+ "DATESTAMP",
49
+ "USER",
50
+ "PROJECT",
51
+ "SUBJECT",
52
+ "EXPERIMENT",
53
+ "STATUS",
54
+ ]
55
+ )
56
+
57
+ def write(
58
+ self,
59
+ datestamp: str,
60
+ project: str,
61
+ subject: str,
62
+ experiment: str,
63
+ status: str,
64
+ ) -> None:
65
+ if self._path is None:
66
+ return
67
+ with self._lock, self._path.open("a", newline="") as f:
68
+ csv.writer(f).writerow(
69
+ [datestamp, self._user, project, subject, experiment, status]
70
+ )
71
+
72
+
73
+ def _detect_direction(identity: str) -> str | None:
74
+ """Last letter-bordered direction code in identity, or None."""
75
+ matches = _DIRECTION_RE.findall(identity)
76
+ return matches[-1] if matches else None
77
+
78
+
79
+ def _mask_direction(identity: str, code: str) -> str:
80
+ """Drop all letter-bordered occurrences of code from identity."""
81
+ pattern = re.compile(rf"(?<![A-Za-z]){re.escape(code)}(?![A-Za-z])")
82
+ return pattern.sub("", identity)
83
+
84
+
85
+ def _require_tool(name: str) -> str:
86
+ path = shutil.which(name)
87
+ if path is None:
88
+ sys.exit(
89
+ f"Error: required tool '{name}' was not found on PATH. "
90
+ "Install it and try again."
91
+ )
92
+ return path
93
+
94
+
95
+ def _parse_bids_guess_suffix(suffix_str: str) -> tuple[tuple[str, ...], str]:
96
+ """Split BidsGuess[1] like '_task-rest_bold' into (entities, suffix).
97
+
98
+ Returns (("task-rest",), "bold"). For '_T1w' returns ((), "T1w").
99
+ """
100
+ parts = suffix_str.lstrip("_").split("_")
101
+ return tuple(parts[:-1]), parts[-1]
102
+
103
+
104
+ def _common_prefix(strings: list[str]) -> str:
105
+ if not strings:
106
+ return ""
107
+ s_min = min(strings)
108
+ s_max = max(strings)
109
+ i = 0
110
+ while i < len(s_min) and i < len(s_max) and s_min[i] == s_max[i]:
111
+ i += 1
112
+ return s_min[:i]
113
+
114
+
115
+ def _common_suffix(strings: list[str]) -> str:
116
+ return _common_prefix([s[::-1] for s in strings])[::-1]
117
+
118
+
119
+ def _disambiguation_labels(values: list[str]) -> list[str]:
120
+ """One BIDS-safe acq label per value; all guaranteed unique and non-empty.
121
+
122
+ Tries minimal-diff (strip longest common prefix and suffix, sanitize to
123
+ [A-Za-z0-9]). Falls back to fully sanitized values if any minimal label is
124
+ empty or duplicates another. Final safety net appends an index when even
125
+ the fallback is empty or non-unique.
126
+ """
127
+ prefix = _common_prefix(values)
128
+ suffix = _common_suffix(values)
129
+
130
+ minimal: list[str] = []
131
+ for v in values:
132
+ if len(prefix) + len(suffix) <= len(v):
133
+ core = v[len(prefix): len(v) - len(suffix)] if suffix else v[len(prefix):]
134
+ else:
135
+ core = ""
136
+ minimal.append(_NON_ALNUM.sub("", core))
137
+ if all(minimal) and len(set(minimal)) == len(minimal):
138
+ return minimal
139
+
140
+ full = [_NON_ALNUM.sub("", v) for v in values]
141
+ if all(full) and len(set(full)) == len(full):
142
+ return full
143
+
144
+ seen: dict[str, int] = {}
145
+ out: list[str] = []
146
+ for f in full:
147
+ base = f or "acq"
148
+ seen[base] = seen.get(base, 0) + 1
149
+ out.append(f"{base}{seen[base]}")
150
+ return out
151
+
152
+
153
+ def _identity_for_sidecar(
154
+ jf_name: str, data: dict
155
+ ) -> tuple[str, dict[str, str]]:
156
+ """Return (identity_value, criteria_dict) for one helper sidecar.
157
+
158
+ identity_value drives the acq-label LCP/LCS comparison. criteria_dict is
159
+ written verbatim into the dcm2bids description's `criteria` field.
160
+ Priority: SeriesDescription > ProtocolName > SidecarFilename.
161
+ """
162
+ sd = data.get("SeriesDescription")
163
+ pn = data.get("ProtocolName")
164
+ sd = sd.strip() if isinstance(sd, str) else ""
165
+ pn = pn.strip() if isinstance(pn, str) else ""
166
+ if sd:
167
+ return sd, {"SeriesDescription": sd}
168
+ if pn:
169
+ return pn, {"ProtocolName": pn}
170
+ return Path(jf_name).stem, {"SidecarFilename": jf_name}
171
+
172
+
173
+ def _draft_config(target: Path) -> None:
174
+ helper_root = target / "tmp_dcm2bids" / "helper"
175
+
176
+ if not helper_root.is_dir():
177
+ print(f"Helper output directory not found: {helper_root}; skipping config draft.")
178
+ return
179
+
180
+ # With dcm2bids_helper -n <EXPERIMENT>, sidecars live at
181
+ # <helper_root>/<EXPERIMENT>/*.json. Aggregate across every nested
182
+ # experiment subdirectory so the drafted config covers the whole project.
183
+ json_files = sorted(helper_root.rglob("*.json"))
184
+ if not json_files:
185
+ print(f"No JSON sidecars found under {helper_root}; no config drafted.")
186
+ return
187
+
188
+ # Outer group: (datatype, entities, suffix). Inner sub-group: unique
189
+ # identity (criteria field + value) within that group, mapped to the raw
190
+ # identity string used for acq-label disambiguation.
191
+ groups: dict[
192
+ tuple[str, tuple[str, ...], str],
193
+ dict[tuple[str, str], str],
194
+ ] = {}
195
+
196
+ for jf in json_files:
197
+ try:
198
+ data = json.loads(jf.read_text())
199
+ except (json.JSONDecodeError, OSError) as e:
200
+ print(f"Warning: skipping {jf.name}: cannot read JSON ({e}).")
201
+ continue
202
+
203
+ guess = data.get("BidsGuess")
204
+ if (
205
+ not guess
206
+ or not isinstance(guess, list)
207
+ or len(guess) != 2
208
+ or not guess[0]
209
+ or not guess[1]
210
+ ):
211
+ print(
212
+ f"Warning: skipping {jf.name}: missing or empty BidsGuess."
213
+ )
214
+ continue
215
+
216
+ datatype, suffix_str = guess[0], guess[1]
217
+ entities, suffix = _parse_bids_guess_suffix(suffix_str)
218
+ # dcm2bids assigns run/echo numbering automatically. acq- from
219
+ # BidsGuess is a protocol-name shorthand (e.g., "acq-epfid2p3") that
220
+ # collides with the SeriesDescription-based acq label this draft
221
+ # adds for disambiguation; drop it here so we never emit two acq-.
222
+ entities = tuple(
223
+ e for e in entities
224
+ if not (
225
+ e.startswith("run-")
226
+ or e.startswith("echo-")
227
+ or e.startswith("acq-")
228
+ )
229
+ )
230
+ if not suffix:
231
+ print(
232
+ f"Warning: skipping {jf.name}: cannot parse suffix from "
233
+ f"BidsGuess {guess!r}."
234
+ )
235
+ continue
236
+
237
+ identity, criteria = _identity_for_sidecar(jf.name, data)
238
+ ckey = next(iter(criteria.items()))
239
+ groups.setdefault((datatype, entities, suffix), {}).setdefault(
240
+ ckey, identity
241
+ )
242
+
243
+ if not groups:
244
+ print("No usable BidsGuess entries; no config drafted.")
245
+ return
246
+
247
+ descriptions: list[dict] = []
248
+ for (datatype, entities, suffix), sub in groups.items():
249
+ items = list(sub.items()) # [((field, value), identity), ...]
250
+ if len(items) == 1:
251
+ (field, value), _identity = items[0]
252
+ desc: dict = {"datatype": datatype, "suffix": suffix}
253
+ if entities:
254
+ desc["custom_entities"] = list(entities)
255
+ desc["criteria"] = {field: value}
256
+ descriptions.append(desc)
257
+ continue
258
+
259
+ # Multi-identity slot. Try to refine via direction codes.
260
+ guess_dir = next(
261
+ (e[len("dir-"):] for e in entities if e.startswith("dir-")),
262
+ None,
263
+ )
264
+ slot = (
265
+ f"{datatype}/"
266
+ f"{('_'.join(entities) + '_') if entities else ''}{suffix}"
267
+ )
268
+
269
+ if guess_dir is not None:
270
+ # BidsGuess already pinned a direction. Don't promote; flag
271
+ # any identity whose detected direction disagrees with it.
272
+ adjusted: list[tuple[tuple[str, str], str]] = []
273
+ for ckey, identity in items:
274
+ detected = _detect_direction(identity)
275
+ if detected and detected != guess_dir:
276
+ print(
277
+ f"WARNING: identity {identity!r} contains direction "
278
+ f"code {detected!r} but BidsGuess for slot {slot} "
279
+ f"says dir-{guess_dir}. Leaving identity unchanged."
280
+ )
281
+ adjusted.append((ckey, identity))
282
+ elif detected == guess_dir:
283
+ adjusted.append((ckey, _mask_direction(identity, detected)))
284
+ else:
285
+ adjusted.append((ckey, identity))
286
+ labels = _disambiguation_labels([i for _, i in adjusted])
287
+ for ((field, value), _ident), label in zip(adjusted, labels):
288
+ descriptions.append({
289
+ "datatype": datatype,
290
+ "suffix": suffix,
291
+ "custom_entities": list(entities) + [f"acq-{label}"],
292
+ "criteria": {field: value},
293
+ })
294
+ continue
295
+
296
+ # No dir in BidsGuess. Bucket identities by detected direction code.
297
+ by_dir: dict[str | None, list[tuple[tuple[str, str], str]]] = {}
298
+ for ckey, identity in items:
299
+ detected = _detect_direction(identity)
300
+ by_dir.setdefault(detected, []).append((ckey, identity))
301
+
302
+ for code, members in by_dir.items():
303
+ if code is not None:
304
+ masked = [_mask_direction(i, code) for _, i in members]
305
+ bucket_entities = list(entities) + [f"dir-{code}"]
306
+ else:
307
+ masked = [i for _, i in members]
308
+ bucket_entities = list(entities)
309
+
310
+ if len(members) == 1:
311
+ (field, value), _identity = members[0]
312
+ desc = {"datatype": datatype, "suffix": suffix}
313
+ if bucket_entities:
314
+ desc["custom_entities"] = bucket_entities
315
+ desc["criteria"] = {field: value}
316
+ descriptions.append(desc)
317
+ else:
318
+ labels = _disambiguation_labels(masked)
319
+ # Place acq- before dir- to follow BIDS canonical ordering.
320
+ base_entities = list(entities)
321
+ tail = [f"dir-{code}"] if code is not None else []
322
+ for ((field, value), _ident), label in zip(members, labels):
323
+ descriptions.append({
324
+ "datatype": datatype,
325
+ "suffix": suffix,
326
+ "custom_entities": base_entities
327
+ + [f"acq-{label}"]
328
+ + tail,
329
+ "criteria": {field: value},
330
+ })
331
+
332
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
333
+ config_path = target / f"dcm2bids_config_{ts}.json"
334
+
335
+ config_path.write_text(
336
+ json.dumps({"descriptions": descriptions}, indent=2) + "\n"
337
+ )
338
+ print(
339
+ f"Drafted dcm2bids config at {config_path} "
340
+ f"({len(descriptions)} description(s))."
341
+ )
342
+
343
+
344
+ def _draft_blank_config(target: Path) -> None:
345
+ """Draft a bare-bones dcm2bids config with one entry per unique identity.
346
+
347
+ Each description has blank datatype/suffix/custom_entities and a single
348
+ criteria field (SeriesDescription, falling back to ProtocolName or
349
+ SidecarFilename per `_identity_for_sidecar`). Intended as a manually
350
+ editable starting point rather than the BidsGuess-derived draft from
351
+ `_draft_config`.
352
+ """
353
+ helper_root = target / "tmp_dcm2bids" / "helper"
354
+
355
+ if not helper_root.is_dir():
356
+ print(f"Helper output directory not found: {helper_root}; skipping blank config draft.")
357
+ return
358
+
359
+ json_files = sorted(helper_root.rglob("*.json"))
360
+ if not json_files:
361
+ print(f"No JSON sidecars found under {helper_root}; no blank config drafted.")
362
+ return
363
+
364
+ seen: dict[tuple[str, str], None] = {}
365
+ for jf in json_files:
366
+ try:
367
+ data = json.loads(jf.read_text())
368
+ except (json.JSONDecodeError, OSError) as e:
369
+ print(f"Warning: skipping {jf.name}: cannot read JSON ({e}).")
370
+ continue
371
+ _identity, criteria = _identity_for_sidecar(jf.name, data)
372
+ ckey = next(iter(criteria.items()))
373
+ seen.setdefault(ckey, None)
374
+
375
+ descriptions = [
376
+ {
377
+ "datatype": "",
378
+ "suffix": "",
379
+ "custom_entities": [],
380
+ "criteria": {field: value},
381
+ }
382
+ for field, value in seen
383
+ ]
384
+
385
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
386
+ config_path = target / f"dcm2bids_config_blank_{ts}.json"
387
+ config_path.write_text(
388
+ json.dumps({"descriptions": descriptions}, indent=2) + "\n"
389
+ )
390
+ print(
391
+ f"Drafted blank dcm2bids config at {config_path} "
392
+ f"({len(descriptions)} description(s))."
393
+ )
394
+
395
+
396
+ def _discover_experiments(
397
+ input_root: Path, args: argparse.Namespace
398
+ ) -> list[tuple[str, str, str]]:
399
+ if args.triplet is not None:
400
+ p, s, e = args.triplet
401
+ exp_dir = input_root / p / s / e
402
+ if not exp_dir.is_dir():
403
+ sys.exit(f"Error: experiment directory not found: {exp_dir}")
404
+ return [(p, s, e)]
405
+
406
+ if args.subject is not None:
407
+ project, subject = args.subject
408
+ subject_dir = input_root / project / subject
409
+ if not subject_dir.is_dir():
410
+ sys.exit(f"Error: subject directory not found: {subject_dir}")
411
+ exps = [
412
+ (project, subject, exp.name)
413
+ for exp in sorted(subject_dir.iterdir())
414
+ if exp.is_dir()
415
+ ]
416
+ if not exps:
417
+ sys.exit(f"Error: no experiments found under {subject_dir}")
418
+ return exps
419
+
420
+ project = args.project
421
+ project_dir = input_root / project
422
+ if not project_dir.is_dir():
423
+ sys.exit(f"Error: project directory not found: {project_dir}")
424
+ exps: list[tuple[str, str, str]] = []
425
+ for subject_dir in sorted(project_dir.iterdir()):
426
+ if not subject_dir.is_dir():
427
+ continue
428
+ for exp_dir in sorted(subject_dir.iterdir()):
429
+ if exp_dir.is_dir():
430
+ exps.append((project, subject_dir.name, exp_dir.name))
431
+ if not exps:
432
+ sys.exit(f"Error: no experiments found under {project_dir}")
433
+ return exps
434
+
435
+
436
+ def _delete_niftis(target: Path, experiment: str) -> int:
437
+ """Remove *.nii.gz from this experiment's helper subdir; return count."""
438
+ helper_subdir = target / "tmp_dcm2bids" / "helper" / experiment
439
+ if not helper_subdir.is_dir():
440
+ return 0
441
+ count = 0
442
+ for f in helper_subdir.glob("*.nii.gz"):
443
+ try:
444
+ f.unlink()
445
+ count += 1
446
+ except OSError:
447
+ pass
448
+ return count
449
+
450
+
451
+ def _run_helper(
452
+ input_root: Path,
453
+ project: str,
454
+ subject: str,
455
+ experiment: str,
456
+ target: Path,
457
+ helper_path: str,
458
+ ) -> tuple[str, str | None]:
459
+ exp_dir = input_root / project / subject / experiment
460
+ scans_dir = exp_dir / "scans"
461
+ if not scans_dir.is_dir():
462
+ return STATUS_FAILURE, f"no 'scans/' subdirectory under {exp_dir}"
463
+
464
+ cmd = [
465
+ helper_path,
466
+ "-d", str(scans_dir),
467
+ "-o", str(target),
468
+ "-n", experiment,
469
+ "--force",
470
+ ]
471
+ result = subprocess.run(cmd, capture_output=True, text=True)
472
+ if result.returncode != 0:
473
+ stderr_tail = (result.stderr or "").strip().splitlines()[-1:] or [""]
474
+ return STATUS_FAILURE, (
475
+ f"dcm2bids_helper exited with code {result.returncode}"
476
+ + (f" — {stderr_tail[0]}" if stderr_tail[0] else "")
477
+ )
478
+ return STATUS_COMPLETE, None
479
+
480
+
481
+ def mriconfig_cmd(args: argparse.Namespace) -> int:
482
+ if args.nprep < 1:
483
+ sys.exit("Error: -n/--nprep must be >= 1.")
484
+
485
+ input_root = Path(args.input).resolve()
486
+ if not input_root.is_dir():
487
+ sys.exit(f"Error: input directory not found: {input_root}")
488
+
489
+ experiments = _discover_experiments(input_root, args)
490
+ project = experiments[0][0]
491
+
492
+ output_dir = Path(args.output).resolve()
493
+ target = output_dir / f"PROJECT-{project}_mriconfig"
494
+
495
+ # --maps: skip running dcm2bids_helper and only (re)draft the dcm2bids config
496
+ # from the helper JSON sidecars already present under <target>. The helper
497
+ # and dcm2niix tools are unused on this path, so neither is required.
498
+ if args.maps:
499
+ if not target.is_dir():
500
+ sys.exit(
501
+ f"Error: mriconfig output directory not found: {target}; run "
502
+ "mriconfig without -m/--maps first."
503
+ )
504
+ _draft_config(target)
505
+ _draft_blank_config(target)
506
+ return 0
507
+
508
+ helper_path = _require_tool("dcm2bids_helper")
509
+ _require_tool("dcm2niix")
510
+ output_dir.mkdir(parents=True, exist_ok=True)
511
+ target.mkdir(parents=True, exist_ok=True)
512
+
513
+ log_path: Path | None = None
514
+ if args.log:
515
+ while True:
516
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
517
+ log_path = output_dir / "log" / f"mriconfig_{ts}_log.csv"
518
+ if not log_path.exists():
519
+ break
520
+ time.sleep(1)
521
+ log_writer = _LogWriter(log_path)
522
+
523
+ counts = {STATUS_COMPLETE: 0, STATUS_FAILURE: 0}
524
+
525
+ def _one(triplet: tuple[str, str, str]) -> str:
526
+ p, s, e = triplet
527
+ start = _logging_now()
528
+ status, detail = _run_helper(
529
+ input_root, p, s, e, target, helper_path
530
+ )
531
+ deleted = _delete_niftis(target, e) if args.delete else 0
532
+ line = f"{p}/{s}/{e}: {status}"
533
+ if detail:
534
+ line += f" — {detail}"
535
+ if args.delete:
536
+ line += f" (removed {deleted} .nii.gz)"
537
+ _safe_print(line)
538
+ log_writer.write(start, p, s, e, status)
539
+ return status
540
+
541
+ if args.nprep <= 1:
542
+ for triplet in experiments:
543
+ counts[_one(triplet)] += 1
544
+ else:
545
+ with ThreadPoolExecutor(max_workers=args.nprep) as ex:
546
+ futures = [ex.submit(_one, t) for t in experiments]
547
+ for fut in as_completed(futures):
548
+ counts[fut.result()] += 1
549
+
550
+ total = sum(counts.values())
551
+ print(f"\nProcessed {total} experiment(s):")
552
+ for status in (STATUS_COMPLETE, STATUS_FAILURE):
553
+ print(f" {status}: {counts[status]}")
554
+ if log_path is not None:
555
+ print(f"Log written to {log_path}")
556
+
557
+ _draft_config(target)
558
+ _draft_blank_config(target)
559
+
560
+ return 0 if counts[STATUS_FAILURE] == 0 else 1