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/__init__.py +6 -0
- xnatbidscli/archive.py +67 -0
- xnatbidscli/assets/mriconvert_qc.json +82 -0
- xnatbidscli/assets/physioconvert_qc.json +33 -0
- xnatbidscli/bidsmap.py +820 -0
- xnatbidscli/cli.py +525 -0
- xnatbidscli/cubids.py +149 -0
- xnatbidscli/download.py +1009 -0
- xnatbidscli/login.py +142 -0
- xnatbidscli/mriconfig.py +560 -0
- xnatbidscli/mriconvert.py +890 -0
- xnatbidscli/physioconvert.py +845 -0
- xnatbidscli/query.py +684 -0
- xnatbidscli/sysinfo.py +12 -0
- xnatbidscli-2.0.0.dist-info/METADATA +65 -0
- xnatbidscli-2.0.0.dist-info/RECORD +19 -0
- xnatbidscli-2.0.0.dist-info/WHEEL +4 -0
- xnatbidscli-2.0.0.dist-info/entry_points.txt +2 -0
- xnatbidscli-2.0.0.dist-info/licenses/LICENSE +21 -0
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()
|