xnattools 1.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.
xnattools/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ from importlib.metadata import version
2
+ from xnattools.core import run_tools
3
+ from xnattools.dataclasses.core import GeneralSettings
4
+ from xnattools.dataclasses.datmat import DatMatSettings
5
+ from xnattools.dataclasses.dcm2nifti import Dcm2niftiSettings
6
+ from xnattools.dataclasses.dcm2snapshots import Dcm2snapshotsSettings
7
+ from xnattools.dataclasses.scanmetadata import ScanMetaDataSettings
8
+
9
+ __version__ = version("xnattools")
10
+ __all__ = [
11
+ "DatMatSettings",
12
+ "Dcm2niftiSettings",
13
+ "Dcm2snapshotsSettings",
14
+ "GeneralSettings",
15
+ "ScanMetaDataSettings",
16
+ "run_tools",
17
+ ]
xnattools/cli.py ADDED
@@ -0,0 +1,328 @@
1
+ """Command-line interface for XNAT Tools.
2
+
3
+ This module provides a CLI parser for running XNAT tools via command line.
4
+ """
5
+
6
+ import argparse
7
+ import json
8
+ import logging
9
+ import sys
10
+ from importlib.metadata import PackageNotFoundError
11
+ from importlib.metadata import version
12
+ from pathlib import Path
13
+ from typing import Any
14
+ import xnattools as xt
15
+
16
+ try:
17
+ import tomllib
18
+ except ImportError:
19
+ try:
20
+ import tomli as tomllib # type: ignore[import-not-found,no-redef]
21
+ except ImportError:
22
+ tomllib = None # type: ignore[assignment]
23
+
24
+ from xnattools.logger_config import get_logger
25
+
26
+ logger = get_logger(__name__)
27
+
28
+
29
+ def get_version() -> str:
30
+ """Get the version of xnattools.
31
+
32
+ First tries to get it from installed package metadata.
33
+ If not installed (e.g., running from source), reads from pyproject.toml.
34
+
35
+ Returns:
36
+ Version string (e.g., "0.1.0")
37
+ """
38
+ try:
39
+ return version("xnattools")
40
+ except PackageNotFoundError:
41
+ if tomllib is None:
42
+ return "unknown"
43
+ try:
44
+ pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
45
+ if pyproject_path.exists():
46
+ with pyproject_path.open("rb") as f:
47
+ pyproject_data = tomllib.load(f)
48
+ return pyproject_data.get("project", {}).get("version", "unknown")
49
+ except (OSError, KeyError, AttributeError):
50
+ logger.exception("Exception occurred while reading version from pyproject.toml")
51
+
52
+ return "unknown"
53
+
54
+
55
+ def parse_scans_argument(scans_input: str) -> list[str] | dict[str, list[str | int]]:
56
+ """Parses the scan arguments.
57
+
58
+ Parse the scans argument which can be:
59
+ - A comma-separated list of experiment IDs: "exp1,exp2,exp3"
60
+ - A JSON string mapping experiments to scan IDs: '{"exp1": [1,2], "exp2": ["3","4"]}'
61
+
62
+ Args:
63
+ scans_input: String input from command line
64
+
65
+ Returns:
66
+ Either a list of experiment IDs or a dictionary mapping experiments to scan IDs
67
+ """
68
+ # Try to parse as JSON first
69
+ if scans_input.startswith(("{", "[")):
70
+ try:
71
+ scans_data = json.loads(scans_input)
72
+ # If it's a list, it's invalid - we only accept dict or comma-separated strings
73
+ if isinstance(scans_data, list):
74
+ msg = (
75
+ "JSON arrays are not supported. Use comma-separated experiment IDs "
76
+ "(e.g., 'EXP1,EXP2') or a JSON dictionary mapping experiments to scan IDs "
77
+ '(e.g., \'{"EXP1": [1,2], "EXP2": [3]}\')'
78
+ )
79
+ raise TypeError(msg)
80
+
81
+ # Validate dictionary structure
82
+ if not isinstance(scans_data, dict):
83
+ msg = "JSON must be a dictionary"
84
+ raise TypeError(msg)
85
+ for key, value in scans_data.items():
86
+ if not isinstance(key, str):
87
+ msg = f"Experiment ID '{key}' must be a string"
88
+ raise TypeError(msg)
89
+ if not isinstance(value, list):
90
+ msg = f"Scan IDs for '{key}' must be a list"
91
+ raise TypeError(msg)
92
+ except json.JSONDecodeError as e:
93
+ msg = f"Invalid JSON format for scans: {e}"
94
+ raise ValueError(msg) from e
95
+ return scans_data
96
+ # Parse as comma-separated list
97
+ experiment_list = [s.strip() for s in scans_input.split(",") if s.strip()]
98
+
99
+ # Validate that the result doesn't look like malformed JSON
100
+ for exp_id in experiment_list:
101
+ if exp_id.startswith(("{", "[", '"')):
102
+ msg = (
103
+ f"Invalid experiment ID '{exp_id}'. "
104
+ "Experiment IDs should be simple strings separated by commas, "
105
+ "not JSON syntax. Use proper JSON format if you need to specify scan IDs."
106
+ )
107
+ raise ValueError(msg)
108
+
109
+ return experiment_list
110
+
111
+
112
+ def create_parser() -> argparse.ArgumentParser:
113
+ """Create and configure the argument parser for XNAT Tools CLI."""
114
+ parser = argparse.ArgumentParser(
115
+ description="xnattools - Python scripts for handy operations on XNAT data",
116
+ formatter_class=argparse.RawDescriptionHelpFormatter,
117
+ epilog="""
118
+ Examples:
119
+ # Run dcm2nifti on entire project
120
+ %(prog)s dcm2nifti https://xnat.example.com PROJECT_ID
121
+
122
+ # Run with custom output directory
123
+ %(prog)s dcm2nii https://xnat.example.com PROJECT_ID --output-dir /custom/output/dir
124
+
125
+ # Run multiple tools on specific subjects
126
+ %(prog)s dcm2nifti dcm2snapshots https://xnat.example.com PROJECT_ID --subjects SUB001 SUB002
127
+
128
+ # Run on specific experiments (as list)
129
+ %(prog)s scanmetadata https://xnat.example.com PROJECT_ID --scans EXP001,EXP002
130
+
131
+ # Run on specific scans within experiments (as JSON mapping)
132
+ %(prog)s dcm2nifti https://xnat.example.com PROJECT_ID --scans '{"EXP001": [1, 2, 3], "EXP002": [1]}'
133
+
134
+ # With additional options
135
+ %(prog)s dcm2nifti https://xnat.example.com PROJECT_ID --tempdir /custom/tmp --overwrite --subjects SUB001
136
+
137
+ # Run dcm2nifti and scanmetadata with independent options
138
+ %(prog)s dcm2nifti scanmetadata https://xnat.example.com MY_PROJECT \\
139
+ --dcm2nifti-overwrite --dcm2nifti-use-dicom-series-description \\
140
+ --scanmetadata-output-location ./meta.csv --scanmetadata-headtype CT
141
+ """,
142
+ )
143
+
144
+ # Positional arguments
145
+ parser.add_argument(
146
+ "tools",
147
+ nargs="+",
148
+ choices=["dcm2nifti", "dcm2snapshots", "scanmetadata", "datmat"],
149
+ help="XNAT tools to run (can specify multiple)",
150
+ )
151
+ # general settings - mandatory
152
+ parser.add_argument("system_url", type=str, help="URL of the XNAT instance (e.g., https://xnat.example.com)")
153
+ parser.add_argument("project_id", type=str, help="XNAT project ID to process")
154
+
155
+ # general settings - Optional arguments - Filtering
156
+ filter_group = parser.add_argument_group("Filtering options")
157
+ filter_group.add_argument(
158
+ "--subjects",
159
+ nargs="+",
160
+ metavar="SUBJECT_ID",
161
+ help="List of subject IDs to process (space-separated). If omitted, all subjects.",
162
+ )
163
+ filter_group.add_argument(
164
+ "--scans",
165
+ type=str,
166
+ metavar="SCANS",
167
+ help="Experiments/scans to process. Can be: "
168
+ '(1) Comma-separated experiment IDs: "EXP1,EXP2" '
169
+ '(2) JSON mapping experiments to scan IDs: \'{"EXP1": [1,2], "EXP2": [3]}\'',
170
+ )
171
+
172
+ # general settings - Optional arguments - General
173
+ general_group = parser.add_argument_group("General options")
174
+ general_group.add_argument(
175
+ "--output-dir", type=Path, default=Path.cwd(), help="Output directory (default: current directory)"
176
+ )
177
+ general_group.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging")
178
+ general_group.add_argument("--version", action="version", version=f"%(prog)s {get_version()}")
179
+
180
+ # Tool-specific options
181
+ dcm2nifti_group = parser.add_argument_group("dcm2nifti options")
182
+ dcm2nifti_group.add_argument(
183
+ "--dcm2nifti-overwrite", action="store_true", help="Overwrite existing files locally and on XNAT"
184
+ )
185
+ dcm2nifti_group.add_argument(
186
+ "--dcm2nifti-allow-multiple-niftis", action="store_true", help="Allow multiple nifti files per series"
187
+ )
188
+ dcm2nifti_group.add_argument("--dcm2nifti-make-3d-niftis", action="store_true", help="Create 3D nifti files")
189
+ dcm2nifti_group.add_argument("--dcm2nifti-rename-temporal", action="store_true", help="Rename temporal series")
190
+ dcm2nifti_group.add_argument("--dcm2nifti-rename-dwi", action="store_true", help="Rename DWI series")
191
+ dcm2nifti_group.add_argument(
192
+ "--dcm2nifti-use-dicom-series-description",
193
+ action="store_true",
194
+ help="Use DICOM series description for descrip field in nifti",
195
+ )
196
+
197
+ dcm2snapshots_group = parser.add_argument_group("dcm2snapshots options")
198
+ dcm2snapshots_group.add_argument(
199
+ "--dcm2snapshots-overwrite", action="store_true", help="Overwrite existing snapshots"
200
+ )
201
+ dcm2snapshots_group.add_argument(
202
+ "--dcm2snapshots-snapshot-size", action="store_true", help="Size of the snapshot (used as width and height)"
203
+ )
204
+
205
+ scanmetadata_group = parser.add_argument_group("scanmetadata options")
206
+ scanmetadata_group.add_argument(
207
+ "--scanmetadata-output-location", type=Path, help="Output CSV location (default: output-dir/metadata.csv)"
208
+ )
209
+ scanmetadata_group.add_argument(
210
+ "--scanmetadata-headtype", default="MR", help="Type of scan to process, compared to DICOM Tag (0008,0060)"
211
+ )
212
+ scanmetadata_group.add_argument(
213
+ "--scanmetadata-t1-repetition-time", default=300, type=int, help="Repetition time to standardize T1 scans"
214
+ )
215
+ scanmetadata_group.add_argument(
216
+ "--scanmetadata-t2-echo-time", default=10, type=int, help="Echo time to standardize T2 scans"
217
+ )
218
+ scanmetadata_group.add_argument(
219
+ "--scanmetadata-process-tag-value", action="store_true", help="Process tag values in metadata"
220
+ )
221
+
222
+ datmat_group = parser.add_argument_group("datmat options")
223
+ datmat_group.add_argument(
224
+ "--datmat-output-location", help="Directory where downloaded files will be saved, can be local or remote"
225
+ )
226
+ datmat_group.add_argument("--datmat-file-types", default="DICOM", help="Type of file to download from XNAT")
227
+ datmat_group.add_argument(
228
+ "--datmat-output-folder-structure", nargs="+", help="Folder hierarchy for organizing downloads."
229
+ )
230
+ datmat_group.add_argument(
231
+ "--datmat-pseudo-anonymize", action="store_true", help="Hash folder names for pseudo-anonymization."
232
+ )
233
+
234
+ return parser
235
+
236
+
237
+ def _build_general_settings(args: argparse.Namespace) -> xt.GeneralSettings:
238
+ """Construct GeneralSettings from shared arguments."""
239
+ subjects = list(args.subjects) if args.subjects else None
240
+ scans = None
241
+ if args.scans:
242
+ scans = parse_scans_argument(args.scans)
243
+ return xt.GeneralSettings(
244
+ system_url=args.system_url,
245
+ project_id=args.project_id,
246
+ subjects=subjects,
247
+ scans=scans,
248
+ output_dir=args.output_dir,
249
+ )
250
+
251
+
252
+ def _build_tool_settings(args: argparse.Namespace, tools: list[str]) -> list[Any]:
253
+ """Instantiate tool-specific settings for each requested tool."""
254
+ tools_to_run = []
255
+
256
+ for tool in tools:
257
+ if tool == "dcm2nifti":
258
+ settings = xt.Dcm2niftiSettings(
259
+ overwrite=args.dcm2nifti_overwrite,
260
+ allow_multiple_niftis=args.dcm2nifti_allow_multiple_niftis,
261
+ make_3d_niftis=args.dcm2nifti_make_3d_niftis,
262
+ rename_temporal=args.dcm2nifti_rename_temporal,
263
+ rename_dwi=args.dcm2nifti_rename_dwi,
264
+ use_dicom_series_description=args.dcm2nifti_use_dicom_series_description,
265
+ )
266
+ tools_to_run.append(settings)
267
+
268
+ elif tool == "dcm2snapshots":
269
+ settings = xt.Dcm2snapshotsSettings(
270
+ overwrite=args.dcm2snapshots_overwrite,
271
+ )
272
+ tools_to_run.append(settings)
273
+
274
+ elif tool == "scanmetadata":
275
+ settings = xt.ScanMetaDataSettings(
276
+ output_location=args.scanmetadata_output_location,
277
+ headtype=args.scanmetadata_headtype,
278
+ t1_repetition_time=args.scanmetadata_t1_repetition_time,
279
+ t2_echo_time=args.scanmetadata_t2_echo_time,
280
+ process_tag_value=args.scanmetadata_process_tag_value,
281
+ )
282
+ tools_to_run.append(settings)
283
+
284
+ elif tool == "datmat":
285
+ settings = xt.DatMatSettings(
286
+ output_location=args.datmat_output_location,
287
+ file_types=args.datmat_file_types,
288
+ output_folder_structure=args.datmat_output_folder_structure,
289
+ pseudo_anonymize=args.datmat_pseudo_anonymize,
290
+ )
291
+ tools_to_run.append(settings)
292
+
293
+ else:
294
+ msg = f"Unknown tool: {tool}"
295
+ raise ValueError(msg)
296
+
297
+ return tools_to_run
298
+
299
+
300
+ def main() -> int:
301
+ """Main entry point for the CLI.
302
+
303
+ Returns:
304
+ True if run was successfull, False otherwise
305
+ """
306
+ parser = create_parser()
307
+ args = parser.parse_args()
308
+
309
+ # Configure logging based on verbosity
310
+ if args.verbose:
311
+ logging.basicConfig(level=logging.DEBUG)
312
+ logger.setLevel(logging.DEBUG) # TODO: fix levels later
313
+
314
+ # Run the tools
315
+ try:
316
+ general = _build_general_settings(args)
317
+ tool_settings = _build_tool_settings(args, args.tools)
318
+ xt.run_tools(tools_to_run=tool_settings, general_settings=general)
319
+ except Exception:
320
+ logger.exception("Error during execution.")
321
+ return 1
322
+
323
+ logger.info("Successfully completed tools: %s", ", ".join(args.tools))
324
+ return 0
325
+
326
+
327
+ if __name__ == "__main__":
328
+ sys.exit(main())