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/query.py ADDED
@@ -0,0 +1,684 @@
1
+ import argparse
2
+ import csv
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import sys
8
+ import threading
9
+ import time
10
+ from dataclasses import dataclass
11
+ from datetime import date, datetime
12
+ from pathlib import Path
13
+ from urllib.parse import quote
14
+
15
+ from pyxnat import Interface
16
+
17
+ from .download import _enable_windows_ansi
18
+ from .login import load_credentials
19
+
20
+
21
+ COLUMNS = [
22
+ "PROJECT",
23
+ "SUBJECT_LABEL",
24
+ "SUBJECT_ID",
25
+ "SUBJECT_BIDS_RENAME",
26
+ "EXPERIMENT_LABEL",
27
+ "EXPERIMENT_ID",
28
+ "EXPERIMENT_DATE",
29
+ "EXPERIMENT_BIDS_RENAME",
30
+ "ESTIMATED_SIZE_BYTES",
31
+ "EXPERIMENT_TIME",
32
+ "STUDY_DESCRIPTION",
33
+ "STUDY_UID",
34
+ "SCANNER_NAME",
35
+ "SCANNER_MANUFACTURER",
36
+ "SCANNER_MODEL",
37
+ "SCANNER_SERIAL",
38
+ "SOFTWARE_VERSION",
39
+ "FIELD_STRENGTH",
40
+ "SITE",
41
+ "OPERATOR",
42
+ "XNAT_GENDER",
43
+ "DICOM_SEX",
44
+ "HANDEDNESS",
45
+ "AGE",
46
+ ]
47
+
48
+ # Non-numeric ESTIMATED_SIZE_BYTES values, used instead of a misleading "0"
49
+ # (or a blank cell) when no usable size total was found.
50
+ SIZE_UNKNOWN = "UNKNOWN"
51
+ SIZE_FILES_WITH_UNLABELED_SIZE = "FILES_WITH_UNLABELED_SIZE"
52
+ SIZE_UNPARSEABLE_SIZE_VALUES = "UNPARSEABLE_SIZE_VALUES"
53
+
54
+ # DICOM tags read via XNAT's dicomdump service: keyword -> 8-digit hex tag.
55
+ DICOM_TAGS = {
56
+ "StudyTime": "00080030",
57
+ "Manufacturer": "00080070",
58
+ "InstitutionName": "00080080",
59
+ "StationName": "00081010",
60
+ "StudyDescription": "00081030",
61
+ "OperatorsName": "00081070",
62
+ "ManufacturerModelName": "00081090",
63
+ "PatientSex": "00100040",
64
+ "PatientAge": "00101010",
65
+ "MagneticFieldStrength": "00180087",
66
+ "DeviceSerialNumber": "00181000",
67
+ "SoftwareVersions": "00181020",
68
+ "StudyInstanceUID": "0020000D",
69
+ }
70
+
71
+ # CSV column -> DICOM keyword used first; XNAT's value fills in if it's blank.
72
+ DICOM_PREFERRED = {
73
+ "EXPERIMENT_TIME": "StudyTime",
74
+ "STUDY_UID": "StudyInstanceUID",
75
+ "SCANNER_NAME": "StationName",
76
+ "SCANNER_MANUFACTURER": "Manufacturer",
77
+ "SCANNER_MODEL": "ManufacturerModelName",
78
+ "SITE": "InstitutionName",
79
+ "OPERATOR": "OperatorsName",
80
+ }
81
+
82
+ # Columns an --accession value can match (any one keeps the experiment).
83
+ ACCESSION_COLUMNS = (
84
+ "SUBJECT_ID", "SUBJECT_LABEL", "EXPERIMENT_ID", "EXPERIMENT_LABEL", "STUDY_UID",
85
+ )
86
+
87
+ # XNAT gender words (and --sex words) -> DICOM PatientSex codes.
88
+ XNAT_SEX_CODES = {"male": "M", "female": "F", "other": "O"}
89
+
90
+ HANDEDNESS_CHOICES = ("L", "R", "A", "U")
91
+
92
+ # Seconds between redraws of the live status line.
93
+ STATUS_INTERVAL_SECONDS = 5
94
+
95
+
96
+ class _QueryStatus:
97
+ """Single in-place "still working" line shown while ``query`` runs.
98
+
99
+ A background thread redraws it every ``STATUS_INTERVAL_SECONDS`` with the
100
+ elapsed time and experiment counts. Disabled when stdout isn't a
101
+ terminal, so redirected output only gets the final summary line.
102
+ """
103
+
104
+ def __init__(self) -> None:
105
+ self._lock = threading.Lock()
106
+ self._stop = threading.Event()
107
+ self._thread: threading.Thread | None = None
108
+ self._start = time.monotonic()
109
+ self._project = ""
110
+ self._drawn = False
111
+ self.checked = 0
112
+ self.matched = 0
113
+
114
+ def start(self, project: str) -> None:
115
+ """Reset the clock and counts, and begin redrawing if on a terminal."""
116
+ self._project = project
117
+ self._start = time.monotonic()
118
+ self.checked = 0
119
+ self.matched = 0
120
+ if not sys.stdout.isatty():
121
+ return
122
+ _enable_windows_ansi()
123
+ self._stop.clear()
124
+ with self._lock:
125
+ self._draw()
126
+ self._thread = threading.Thread(target=self._tick, daemon=True)
127
+ self._thread.start()
128
+
129
+ def stop(self) -> None:
130
+ """Stop redrawing and erase the line."""
131
+ if self._thread is not None:
132
+ self._stop.set()
133
+ self._thread.join()
134
+ self._thread = None
135
+ with self._lock:
136
+ self._clear()
137
+
138
+ def elapsed(self) -> int:
139
+ """Whole seconds since ``start``."""
140
+ return int(time.monotonic() - self._start)
141
+
142
+ def warn(self, msg: str) -> None:
143
+ """Print ``msg`` to stderr without mangling the status line."""
144
+ with self._lock:
145
+ was_drawn = self._drawn
146
+ self._clear()
147
+ print(msg, file=sys.stderr, flush=True)
148
+ if was_drawn:
149
+ self._draw()
150
+
151
+ def _tick(self) -> None:
152
+ while not self._stop.wait(STATUS_INTERVAL_SECONDS):
153
+ with self._lock:
154
+ self._draw()
155
+
156
+ def _draw(self) -> None:
157
+ """Repaint the line in place. Caller holds ``self._lock``."""
158
+ text = (
159
+ f"Querying {self._project}: {self.checked} experiment(s) checked, "
160
+ f"{self.matched} matched, elapsed {self.elapsed()}s"
161
+ )
162
+ # Truncate so the line never wraps; "\r" only rewinds one screen row.
163
+ width = shutil.get_terminal_size().columns - 1
164
+ sys.stdout.write(f"\r\x1b[K{text[:width]}")
165
+ sys.stdout.flush()
166
+ self._drawn = True
167
+
168
+ def _clear(self) -> None:
169
+ """Erase the line if drawn. Caller holds ``self._lock``."""
170
+ if self._drawn:
171
+ sys.stdout.write("\r\x1b[K")
172
+ sys.stdout.flush()
173
+ self._drawn = False
174
+
175
+
176
+ _status = _QueryStatus()
177
+
178
+
179
+ # --- argparse value types -------------------------------------------------
180
+
181
+ def date_filter(value: str) -> str:
182
+ """argparse type for ``--date``: ``MM``, ``YYYY``, ``YYYYMM`` or ``YYYYMMDD``."""
183
+ if not value.isdigit() or len(value) not in (2, 4, 6, 8):
184
+ raise argparse.ArgumentTypeError(
185
+ f"invalid date '{value}': use MM, YYYY, YYYYMM or YYYYMMDD"
186
+ )
187
+ if len(value) == 2 and not 1 <= int(value) <= 12:
188
+ raise argparse.ArgumentTypeError(f"invalid month '{value}'")
189
+ return value
190
+
191
+
192
+ def time_filter(value: str) -> str:
193
+ """argparse type for ``--time``: ``HH``, ``HHMM`` or ``HHMMSS``."""
194
+ if not value.isdigit() or len(value) not in (2, 4, 6):
195
+ raise argparse.ArgumentTypeError(
196
+ f"invalid time '{value}': use HH, HHMM or HHMMSS"
197
+ )
198
+ return value
199
+
200
+
201
+ def handedness_filter(value: str) -> str:
202
+ """argparse type for ``--handedness``: one of L, R, A, U (case-insensitive)."""
203
+ upper = value.upper()
204
+ if upper not in HANDEDNESS_CHOICES:
205
+ raise argparse.ArgumentTypeError(
206
+ f"invalid handedness '{value}': use L, R, A or U"
207
+ )
208
+ return upper
209
+
210
+
211
+ def sex_filter(value: str) -> str:
212
+ """argparse type for ``--sex``: M/F/O or male/female/other, as a code."""
213
+ return _sex_code(value)
214
+
215
+
216
+ # --- filters --------------------------------------------------------------
217
+
218
+ @dataclass
219
+ class Filters:
220
+ """Query filters. Different flags AND together; values within one flag OR.
221
+
222
+ Every list is empty when its flag was not given. An experiment whose
223
+ field is blank never matches an active filter on that field.
224
+ """
225
+
226
+ accession: list[str]
227
+ date: list[str]
228
+ time: list[str]
229
+ scanner: list[str]
230
+ study: list[str]
231
+ site: list[str]
232
+ operator: list[str]
233
+ sex: list[str]
234
+ handedness: list[str]
235
+ age: list[int]
236
+
237
+ @classmethod
238
+ def from_args(cls, args: argparse.Namespace) -> "Filters":
239
+ return cls(**{
240
+ name: list(getattr(args, name, None) or [])
241
+ for name in cls.__dataclass_fields__
242
+ })
243
+
244
+
245
+ def _contains_any(values: list[str], *fields: str) -> bool:
246
+ """Case-insensitive substring match of any value against any non-blank field."""
247
+ haystacks = [f.lower() for f in fields if f]
248
+ return any(v.lower() in h for v in values for h in haystacks)
249
+
250
+
251
+ def _equals_any(values: list[str], *fields: str) -> bool:
252
+ """Case-insensitive exact match of any value against any non-blank field."""
253
+ wanted = {v.lower() for v in values}
254
+ return any(f and f.lower() in wanted for f in fields)
255
+
256
+
257
+ def _date_matches(values: list[str], yyyymmdd: str) -> bool:
258
+ if not yyyymmdd:
259
+ return False
260
+ for v in values:
261
+ # A bare MM is the only form that isn't a prefix of YYYYMMDD.
262
+ if (len(v) == 2 and yyyymmdd[4:6] == v) or (
263
+ len(v) != 2 and yyyymmdd.startswith(v)
264
+ ):
265
+ return True
266
+ return False
267
+
268
+
269
+ def _starts_any(values: list[str], field: str) -> bool:
270
+ """Prefix match of any value against a non-blank field."""
271
+ return bool(field) and any(field.startswith(v) for v in values)
272
+
273
+
274
+ def _looks_like_uid(value: str) -> bool:
275
+ """True for a DICOM UID shape (digits and dots), unlike XNAT IDs."""
276
+ return re.fullmatch(r"[0-9.]+", value) is not None
277
+
278
+
279
+ def _handedness_passes(f: Filters, handedness: str) -> bool:
280
+ # XNAT stores handedness as words (left/right/ambidextrous/unknown).
281
+ return not f.handedness or handedness[:1].upper() in f.handedness
282
+
283
+
284
+ def _experiment_passes(f: Filters, row: dict, pending_dicom: bool = False) -> bool:
285
+ """Check the experiment-level filters against ``row``.
286
+
287
+ With ``pending_dicom`` (before the DICOM header is read), only the
288
+ XNAT-only fields are checked, since DICOM may still set the others.
289
+ The exception is ``--accession``, which is checked early against XNAT's
290
+ study UID when it has one; XNAT copies it from the same DICOM tag at
291
+ import, so the two only differ if the record was edited.
292
+ """
293
+ if (
294
+ f.accession
295
+ and not (pending_dicom and not row.get("STUDY_UID"))
296
+ and not _equals_any(f.accession, *(row.get(c, "") for c in ACCESSION_COLUMNS))
297
+ ):
298
+ return False
299
+ checks = [
300
+ (f.date, ["EXPERIMENT_DATE"], _date_matches),
301
+ ]
302
+ if not pending_dicom:
303
+ checks += [
304
+ (f.time, ["EXPERIMENT_TIME"], _starts_any),
305
+ (f.scanner, ["SCANNER_NAME", "SCANNER_MANUFACTURER", "SCANNER_MODEL"],
306
+ _contains_any),
307
+ (f.site, ["SITE"], _contains_any),
308
+ (f.operator, ["OPERATOR"], _contains_any),
309
+ (f.study, ["STUDY_DESCRIPTION"], _contains_any),
310
+ # Permissive: either source matching keeps the experiment.
311
+ (f.sex, ["XNAT_GENDER", "DICOM_SEX"], _equals_any),
312
+ ]
313
+ for values, cols, matches in checks:
314
+ if values and not matches(values, *(row.get(c, "") for c in cols)):
315
+ return False
316
+ return True
317
+
318
+
319
+ # --- XNAT record access ---------------------------------------------------
320
+
321
+ def _get_item(interface: Interface, uri: str) -> dict:
322
+ """Fetch one XNAT subject/experiment record as XNAT's item JSON.
323
+
324
+ pyxnat's ``_get_json`` forces CSV (listing endpoints only), so the raw
325
+ ``_exec`` primitive is used and the ``{"items": [...]}`` body parsed here.
326
+ """
327
+ content = interface._exec(f"{uri}?format=json", "GET")
328
+ items = json.loads(content).get("items") or []
329
+ return items[0] if items else {}
330
+
331
+
332
+ def _field(item: dict, key: str, child: str | None = None) -> str:
333
+ """Return a data field from an item JSON record, or ``""``.
334
+
335
+ With ``child``, looks inside the first nested item under that child
336
+ field (e.g. ``demographics``). A ``parent/attr`` key missing from the
337
+ flat ``data_fields`` also falls back to the nested ``parent`` child.
338
+ """
339
+ if child is not None:
340
+ for c in item.get("children") or []:
341
+ if c.get("field") == child:
342
+ for sub in c.get("items") or []:
343
+ return _field(sub, key)
344
+ return ""
345
+
346
+ value = (item.get("data_fields") or {}).get(key)
347
+ if value is not None:
348
+ return str(value).strip()
349
+ parent, _, attr = key.partition("/")
350
+ return _field(item, attr, child=parent) if attr else ""
351
+
352
+
353
+ def _parse_xnat_date(raw: str) -> date | None:
354
+ try:
355
+ return datetime.strptime(raw.strip(), "%Y-%m-%d").date()
356
+ except ValueError:
357
+ return None
358
+
359
+
360
+ def _normalize_time(raw: str) -> str:
361
+ """``HH:MM:SS[.fff]`` -> ``HHMMSS``; ``""`` if unparseable."""
362
+ digits = re.sub(r"\D", "", raw.split(".")[0])
363
+ return digits[:6] if len(digits) >= 4 else ""
364
+
365
+
366
+ def _dicom_header_fields(
367
+ interface: Interface, project: str, exp_id: str, label: str
368
+ ) -> dict[str, str] | None:
369
+ """Read the ``DICOM_TAGS`` fields from one of an experiment's DICOMs.
370
+
371
+ Uses XNAT's ``dicomdump`` service, which reads a single DICOM file from
372
+ the session and returns its header as a ResultSet. One REST call. Only
373
+ study-level tags are read, since one file can't speak for every series.
374
+
375
+ Returns
376
+ -------
377
+ dict[str, str] | None
378
+ Values keyed by DICOM keyword (tags absent from the header are
379
+ omitted), or ``None`` if the service call failed (e.g. the
380
+ experiment has no DICOM files).
381
+ """
382
+ src = quote(f"/archive/projects/{project}/experiments/{exp_id}", safe="/")
383
+ try:
384
+ content = interface._exec(
385
+ f"/data/services/dicomdump?src={src}&format=json", "GET"
386
+ )
387
+ results = json.loads(content)["ResultSet"]["Result"]
388
+ except Exception as e:
389
+ _status.warn(f"Warning: could not read DICOM header for {label}: {e}")
390
+ return None
391
+
392
+ keyword_by_tag = {tag: kw for kw, tag in DICOM_TAGS.items()}
393
+ found: dict[str, str] = {}
394
+ for r in results:
395
+ tag = re.sub(r"[^0-9A-Fa-f]", "", str(r.get("tag1", ""))).upper()
396
+ kw = keyword_by_tag.get(tag)
397
+ if kw and kw not in found:
398
+ found[kw] = str(r.get("value") or "").strip()
399
+ return found
400
+
401
+
402
+ def _sex_code(value: str) -> str:
403
+ """Map ``male``/``female``/``other`` to ``M``/``F``/``O``.
404
+
405
+ Anything else (a code already, or e.g. ``unknown``) is returned as-is.
406
+ """
407
+ value = value.strip()
408
+ return XNAT_SEX_CODES.get(value.lower(), value)
409
+
410
+
411
+ def _years_between(born: date, on: date) -> int:
412
+ return on.year - born.year - ((on.month, on.day) < (born.month, born.day))
413
+
414
+
415
+ def _dicom_age_years(raw: str) -> int | None:
416
+ """DICOM AS value (``nnnD|W|M|Y``) -> whole years."""
417
+ m = re.fullmatch(r"(\d{1,3})([DWMY])", raw.strip().upper())
418
+ if not m:
419
+ return None
420
+ n, unit = int(m.group(1)), m.group(2)
421
+ return {"Y": n, "M": n // 12, "W": n // 52, "D": n // 365}[unit]
422
+
423
+
424
+ def _age_years(
425
+ dicom_age: str,
426
+ session_age: str,
427
+ exp_date: date | None,
428
+ dob: str,
429
+ yob: str,
430
+ subject_age: str,
431
+ ) -> int | None:
432
+ """Age in whole years at the experiment, from the best available source.
433
+
434
+ Priority: DICOM PatientAge -> XNAT session ``age`` -> subject ``dob`` +
435
+ experiment date -> subject ``yob`` + experiment year (can be one year
436
+ high) -> subject demographics ``age``.
437
+ """
438
+ from_dicom = _dicom_age_years(dicom_age) if dicom_age else None
439
+ if from_dicom is not None:
440
+ return from_dicom
441
+ try:
442
+ return int(float(session_age))
443
+ except ValueError:
444
+ pass
445
+ born = _parse_xnat_date(dob) if dob else None
446
+ if born and exp_date:
447
+ return _years_between(born, exp_date)
448
+ if yob.isdigit() and exp_date:
449
+ return exp_date.year - int(yob)
450
+ try:
451
+ return int(float(subject_age))
452
+ except ValueError:
453
+ return None
454
+
455
+
456
+ def _experiment_size_bytes(interface: Interface, exp_obj, label: str) -> int | str:
457
+ """Sum file sizes (bytes) for one experiment.
458
+
459
+ Uses XNAT's session-wide ``/files`` listing (one row per file, across
460
+ every scan and session-level resource) rather than pyxnat's modeled
461
+ ``experiment -> scans -> resources`` tree, so this costs exactly one
462
+ REST call per experiment regardless of how many scans it has. pyxnat
463
+ does not wrap this endpoint with a convenience method, so it is reached
464
+ via the same ``interface._get_json`` primitive pyxnat's own ``Resource``
465
+ class uses internally.
466
+
467
+ Parameters
468
+ ----------
469
+ interface : Interface
470
+ Connected pyxnat interface.
471
+ exp_obj
472
+ The experiment element object to sum file sizes for.
473
+ label : str
474
+ Human-readable identifier used in the warning printed on failure.
475
+
476
+ Returns
477
+ -------
478
+ int | str
479
+ Total size in bytes when the files' sizes sum to more than zero.
480
+ Otherwise a categorical string:
481
+ ``SIZE_UNPARSEABLE_SIZE_VALUES`` if any file's ``Size`` value was
482
+ non-numeric (takes priority, since it signals a data anomaly
483
+ rather than a merely-missing value), else
484
+ ``SIZE_FILES_WITH_UNLABELED_SIZE`` if every file's ``Size`` field
485
+ was missing/empty, else ``SIZE_UNKNOWN`` (the ``/files`` request
486
+ failed, listed no files, or its sizes summed to zero).
487
+ """
488
+ try:
489
+ rows = interface._get_json(f"{exp_obj._uri}/files?format=json")
490
+ except Exception as e:
491
+ _status.warn(f"Warning: could not determine size for {label}: {e}")
492
+ return SIZE_UNKNOWN
493
+
494
+ total = 0
495
+ has_unlabeled = False
496
+ has_unparseable = False
497
+ for row in rows:
498
+ raw = row.get("Size")
499
+ if not raw:
500
+ has_unlabeled = True
501
+ continue
502
+ try:
503
+ total += int(float(raw))
504
+ except (TypeError, ValueError):
505
+ has_unparseable = True
506
+ continue
507
+
508
+ if total > 0:
509
+ return total
510
+ if has_unparseable:
511
+ return SIZE_UNPARSEABLE_SIZE_VALUES
512
+ if has_unlabeled:
513
+ return SIZE_FILES_WITH_UNLABELED_SIZE
514
+ return SIZE_UNKNOWN
515
+
516
+
517
+ def _collect_rows(
518
+ interface: Interface,
519
+ project: str,
520
+ subject: str | None,
521
+ filters: Filters,
522
+ ) -> list[dict]:
523
+ """Build one CSV row per experiment that passes ``filters``.
524
+
525
+ Filters are applied cheapest-first so a dropped subject/experiment
526
+ skips the later REST calls: subject record (1 call per subject) ->
527
+ experiment record (1 call) -> DICOM header (1 call) -> file sizes
528
+ (1 call). DICOM is the primary source for age and the session fields
529
+ in ``DICOM_PREFERRED``, with XNAT as the fallback. Sex is kept per source
530
+ (``XNAT_GENDER``, ``DICOM_SEX``).
531
+ """
532
+ proj_obj = interface.select.project(project)
533
+ if not proj_obj.exists():
534
+ sys.exit(
535
+ f"Error: project '{project}' not found on the configured server."
536
+ )
537
+ canonical_project = proj_obj.id()
538
+
539
+ rows: list[dict] = []
540
+
541
+ if subject is None:
542
+ subj_iter = proj_obj.subjects()
543
+ else:
544
+ only = proj_obj.subject(subject)
545
+ if not only.exists():
546
+ sys.exit(
547
+ f"Error: subject '{subject}' not found in project "
548
+ f"'{project}' on the configured server."
549
+ )
550
+ subj_iter = [only]
551
+
552
+ for subj_obj in subj_iter:
553
+ subj_label = subj_obj.label()
554
+ subj_id = subj_obj.id()
555
+ subj_item = _get_item(interface, subj_obj._uri)
556
+ xnat_gender = _sex_code(_field(subj_item, "gender", child="demographics"))
557
+ handedness = _field(subj_item, "handedness", child="demographics")
558
+ if not _handedness_passes(filters, handedness):
559
+ continue
560
+
561
+ for exp_obj in subj_obj.experiments():
562
+ _status.checked += 1
563
+ exp_id = exp_obj.id()
564
+ exp_label = exp_obj.label()
565
+ # Skip fetching the record when no --accession value could be a
566
+ # study UID (digits and dots only) and no ID or label matches.
567
+ if (
568
+ filters.accession
569
+ and not _equals_any(
570
+ filters.accession, subj_id, subj_label, exp_id, exp_label
571
+ )
572
+ and not any(_looks_like_uid(v) for v in filters.accession)
573
+ ):
574
+ continue
575
+ label = f"{subj_label}/{exp_label}"
576
+ exp_item = _get_item(interface, exp_obj._uri)
577
+ exp_date = _parse_xnat_date(_field(exp_item, "date"))
578
+
579
+ row = {
580
+ "PROJECT": canonical_project,
581
+ "SUBJECT_LABEL": subj_label,
582
+ "SUBJECT_ID": subj_id,
583
+ "SUBJECT_BIDS_RENAME": "", # left blank for manual entry
584
+ "EXPERIMENT_LABEL": exp_label,
585
+ "EXPERIMENT_ID": exp_id,
586
+ "EXPERIMENT_DATE": exp_date.strftime("%Y%m%d") if exp_date else "",
587
+ "EXPERIMENT_BIDS_RENAME": "", # left blank for manual entry
588
+ "EXPERIMENT_TIME": _normalize_time(_field(exp_item, "time")),
589
+ "STUDY_UID": _field(exp_item, "UID"),
590
+ "SCANNER_NAME": _field(exp_item, "scanner"),
591
+ "SCANNER_MANUFACTURER": _field(exp_item, "scanner/manufacturer"),
592
+ "SCANNER_MODEL": _field(exp_item, "scanner/model"),
593
+ "SITE": _field(exp_item, "acquisition_site"),
594
+ "OPERATOR": _field(exp_item, "operator"),
595
+ "XNAT_GENDER": xnat_gender,
596
+ "HANDEDNESS": handedness,
597
+ }
598
+ if not _experiment_passes(filters, row, pending_dicom=True):
599
+ continue
600
+
601
+ dicom = _dicom_header_fields(interface, canonical_project, exp_id, label) or {}
602
+ for col, keyword in DICOM_PREFERRED.items():
603
+ row[col] = dicom.get(keyword) or row[col]
604
+ row["EXPERIMENT_TIME"] = _normalize_time(row["EXPERIMENT_TIME"])
605
+ row["STUDY_DESCRIPTION"] = dicom.get("StudyDescription", "")
606
+ row["SCANNER_SERIAL"] = dicom.get("DeviceSerialNumber", "")
607
+ row["SOFTWARE_VERSION"] = dicom.get("SoftwareVersions", "")
608
+ row["FIELD_STRENGTH"] = dicom.get("MagneticFieldStrength", "")
609
+ row["DICOM_SEX"] = dicom.get("PatientSex", "")
610
+ if not _experiment_passes(filters, row):
611
+ continue
612
+
613
+ age = _age_years(
614
+ dicom.get("PatientAge", ""),
615
+ _field(exp_item, "age"),
616
+ exp_date,
617
+ _field(subj_item, "dob", child="demographics"),
618
+ _field(subj_item, "yob", child="demographics"),
619
+ _field(subj_item, "age", child="demographics"),
620
+ )
621
+ row["AGE"] = "" if age is None else age
622
+ if filters.age and age not in filters.age:
623
+ continue
624
+
625
+ row["ESTIMATED_SIZE_BYTES"] = _experiment_size_bytes(interface, exp_obj, label)
626
+ rows.append(row)
627
+ _status.matched += 1
628
+
629
+ rows.sort(key=lambda row: (row["SUBJECT_LABEL"], row["EXPERIMENT_LABEL"]))
630
+
631
+ return rows
632
+
633
+
634
+ def query_cmd(args: argparse.Namespace) -> int:
635
+ server, username, password = load_credentials()
636
+
637
+ output_dir = Path(args.output)
638
+ output_dir.mkdir(parents=True, exist_ok=True)
639
+
640
+ interface = None
641
+ _status.start(args.project)
642
+ try:
643
+ interface = Interface(server=server, user=username, password=password)
644
+ rows = _collect_rows(
645
+ interface, args.project, args.subject, Filters.from_args(args)
646
+ )
647
+ except SystemExit:
648
+ raise
649
+ except Exception as e:
650
+ sys.exit(f"Error: query failed: {e}")
651
+ finally:
652
+ _status.stop()
653
+ if interface is not None:
654
+ try:
655
+ interface.disconnect()
656
+ except Exception:
657
+ pass
658
+
659
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
660
+ if args.subject is None:
661
+ filename = f"PROJECT-{args.project}_{ts}.csv"
662
+ else:
663
+ filename = f"PROJECT-{args.project}_SUBJECT-{args.subject}_{ts}.csv"
664
+ output_path = output_dir / filename
665
+
666
+ # The CSV can hold PII (MRN-like labels, dates, ages), so restrict it to
667
+ # owner only. os.open's mode is masked by umask; force 0o600 after.
668
+ fd = os.open(str(output_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
669
+ with os.fdopen(fd, "w", newline="") as f:
670
+ writer = csv.DictWriter(f, fieldnames=COLUMNS)
671
+ writer.writeheader()
672
+ writer.writerows(rows)
673
+ os.chmod(output_path, 0o600)
674
+
675
+ print(
676
+ f"Took {_status.elapsed()} second(s) to write {len(rows)} row(s) "
677
+ f"to {output_path}"
678
+ )
679
+ print(
680
+ "INFO: The query CSV is readable only by you. To share it, update its "
681
+ "group and permissions (e.g. chgrp, chmod g+r), but take care: it may "
682
+ "contain PII."
683
+ )
684
+ return 0
xnatbidscli/sysinfo.py ADDED
@@ -0,0 +1,12 @@
1
+ import getpass
2
+
3
+
4
+ def get_system_username() -> str:
5
+ """Return the current OS-level username.
6
+
7
+ Uses :func:`getpass.getuser`, which checks the ``LOGNAME``, ``USER``,
8
+ ``LNAME``, and ``USERNAME`` environment variables (in that order)
9
+ before falling back to the password database, so it works the same
10
+ way on Linux, macOS, and Windows.
11
+ """
12
+ return getpass.getuser()