dcccpy 0.1.2__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.
dcccpy/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """Python wrapper for the native DCCCcore executable."""
2
+
3
+ from .core import (
4
+ DCCCResult,
5
+ adad,
6
+ abetaindex,
7
+ abetaload,
8
+ adni_pet_core,
9
+ centaur,
10
+ centaurz,
11
+ centiloid,
12
+ dccccore_path,
13
+ fillstates,
14
+ normalize,
15
+ parse_metrics,
16
+ rigid,
17
+ run,
18
+ suvr,
19
+ )
20
+ from .runtime import DCCCCORE_VERSION, DCCCcoreDownloadError, DCCCcoreNotFoundError, download_dccccore
21
+
22
+ __all__ = [
23
+ "DCCCResult",
24
+ "DCCCCORE_VERSION",
25
+ "DCCCcoreDownloadError",
26
+ "DCCCcoreNotFoundError",
27
+ "adad",
28
+ "abetaindex",
29
+ "abetaload",
30
+ "adni_pet_core",
31
+ "centaur",
32
+ "centaurz",
33
+ "centiloid",
34
+ "dccccore_path",
35
+ "download_dccccore",
36
+ "fillstates",
37
+ "normalize",
38
+ "parse_metrics",
39
+ "rigid",
40
+ "run",
41
+ "suvr",
42
+ ]
dcccpy/cli.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from .core import run
6
+ from .runtime import DCCCcoreDownloadError, DCCCcoreNotFoundError
7
+
8
+
9
+ def main(argv: list[str] | None = None) -> int:
10
+ args = list(sys.argv[1:] if argv is None else argv)
11
+ try:
12
+ result = run(args, check=False)
13
+ except (DCCCcoreDownloadError, DCCCcoreNotFoundError) as exc:
14
+ print(str(exc), file=sys.stderr)
15
+ return 127
16
+
17
+ if result.stdout:
18
+ print(result.stdout, end="")
19
+ if result.stderr:
20
+ print(result.stderr, file=sys.stderr, end="")
21
+ return result.returncode
dcccpy/core.py ADDED
@@ -0,0 +1,361 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import subprocess
6
+ import tempfile
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Mapping, Sequence
10
+
11
+ from .runtime import DCCCcoreNotFoundError, dccccore_path
12
+
13
+
14
+ _NUMBER_RE = r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?"
15
+ _METRIC_RE = re.compile(rf"^\s*([A-Za-z][A-Za-z0-9_. /()-]*):\s*({_NUMBER_RE})\s*(?:%.*)?$")
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class DCCCResult:
20
+ """Completed DCCCcore invocation."""
21
+
22
+ args: tuple[str, ...]
23
+ returncode: int
24
+ stdout: str
25
+ stderr: str
26
+ output: Path | None = None
27
+ temp_dir: Path | None = None
28
+ executable: Path | None = None
29
+ metrics: Mapping[str, float] = field(default_factory=dict)
30
+
31
+ @property
32
+ def command(self) -> tuple[str, ...]:
33
+ if self.executable is None:
34
+ return self.args
35
+ return (str(self.executable), *self.args)
36
+
37
+ def check_returncode(self) -> "DCCCResult":
38
+ if self.returncode:
39
+ raise subprocess.CalledProcessError(
40
+ self.returncode,
41
+ list(self.command),
42
+ output=self.stdout,
43
+ stderr=self.stderr,
44
+ )
45
+ return self
46
+
47
+ def load_output(self):
48
+ """Load the output image with nibabel."""
49
+
50
+ if self.output is None:
51
+ raise ValueError("This DCCCResult does not have an output path.")
52
+ try:
53
+ import nibabel as nib
54
+ except ImportError as exc:
55
+ raise ImportError("nibabel is required to load output images.") from exc
56
+ return nib.load(os.fspath(self.output))
57
+
58
+
59
+ def parse_metrics(text: str) -> dict[str, float]:
60
+ """Parse numeric metric lines from DCCCcore stdout."""
61
+
62
+ metrics: dict[str, float] = {}
63
+ for line in text.splitlines():
64
+ match = _METRIC_RE.match(line)
65
+ if not match:
66
+ continue
67
+ label = " ".join(match.group(1).strip().split())
68
+ metrics[label] = float(match.group(2))
69
+ return metrics
70
+
71
+
72
+ def _as_args(args: Sequence[object]) -> list[str]:
73
+ return [os.fspath(arg) if isinstance(arg, os.PathLike) else str(arg) for arg in args]
74
+
75
+
76
+ def run(
77
+ args: Sequence[object],
78
+ *,
79
+ check: bool = True,
80
+ executable: str | os.PathLike[str] | None = None,
81
+ cwd: str | os.PathLike[str] | None = None,
82
+ env: Mapping[str, str] | None = None,
83
+ output: str | os.PathLike[str] | None = None,
84
+ ) -> DCCCResult:
85
+ """Run DCCCcore with raw command-line arguments."""
86
+
87
+ exe = dccccore_path(executable)
88
+ str_args = _as_args(args)
89
+ completed = subprocess.run(
90
+ [str(exe), *str_args],
91
+ cwd=os.fspath(cwd) if cwd is not None else None,
92
+ env={**os.environ, **dict(env or {})},
93
+ text=True,
94
+ capture_output=True,
95
+ check=False,
96
+ )
97
+ result = DCCCResult(
98
+ args=tuple(str_args),
99
+ returncode=completed.returncode,
100
+ stdout=completed.stdout,
101
+ stderr=completed.stderr,
102
+ output=Path(output) if output is not None else None,
103
+ executable=exe,
104
+ metrics=parse_metrics(completed.stdout),
105
+ )
106
+ return result.check_returncode() if check else result
107
+
108
+
109
+ def _temp_output(suffix: str = ".nii") -> tuple[Path, Path]:
110
+ temp_dir = Path(tempfile.mkdtemp(prefix="dcccpy-"))
111
+ return temp_dir / f"output{suffix}", temp_dir
112
+
113
+
114
+ def _create_temp_dir(existing: Path | None = None) -> Path:
115
+ return existing or Path(tempfile.mkdtemp(prefix="dcccpy-"))
116
+
117
+
118
+ def _resolve_user_path(path: str | os.PathLike[str]) -> Path:
119
+ return Path(path).expanduser().resolve(strict=False)
120
+
121
+
122
+ def _prepare_input(input: object, temp_dir: Path | None) -> tuple[str, Path | None]:
123
+ if isinstance(input, (str, os.PathLike)):
124
+ return os.fspath(_resolve_user_path(input)), temp_dir
125
+
126
+ to_filename = getattr(input, "to_filename", None)
127
+ if callable(to_filename):
128
+ temp_dir = _create_temp_dir(temp_dir)
129
+ input_path = temp_dir / "input.nii.gz"
130
+ to_filename(os.fspath(input_path))
131
+ return os.fspath(input_path), temp_dir
132
+
133
+ raise TypeError(
134
+ "input must be a filesystem path or a nibabel-like image object with to_filename()."
135
+ )
136
+
137
+
138
+ def _prepare_io(input: object, output: str | os.PathLike[str] | None) -> tuple[str, Path, Path | None]:
139
+ temp_dir: Path | None = None
140
+ if output is None:
141
+ actual_output, temp_dir = _temp_output()
142
+ else:
143
+ actual_output = _resolve_user_path(output)
144
+
145
+ input_path, temp_dir = _prepare_input(input, temp_dir)
146
+ return input_path, actual_output, temp_dir
147
+
148
+
149
+ def _append_flag(args: list[str], flag: str, enabled: bool) -> None:
150
+ if enabled:
151
+ args.append(flag)
152
+
153
+
154
+ def _metric_command(
155
+ subcommand: str,
156
+ input: object,
157
+ output: str | os.PathLike[str] | None,
158
+ *,
159
+ batch: bool = False,
160
+ bids: str | None = None,
161
+ config: str | os.PathLike[str] | None = None,
162
+ skip_normalization: bool = False,
163
+ suvr_values: bool = False,
164
+ debug: bool = False,
165
+ extra_args: Sequence[object] = (),
166
+ check: bool = True,
167
+ executable: str | os.PathLike[str] | None = None,
168
+ ) -> DCCCResult:
169
+ input_path, actual_output, temp_dir = _prepare_io(input, output)
170
+
171
+ args = [subcommand, "--input", input_path, "--output", os.fspath(actual_output)]
172
+ _append_flag(args, "--batch", batch)
173
+ _append_flag(args, "--skip-normalization", skip_normalization)
174
+ _append_flag(args, "--suvr", suvr_values)
175
+ _append_flag(args, "--debug", debug)
176
+ if bids is not None:
177
+ args.extend(["--bids", bids])
178
+ if config is not None:
179
+ args.extend(["--config", os.fspath(config)])
180
+ args.extend(_as_args(extra_args))
181
+
182
+ result = run(args, check=check, executable=executable, output=actual_output)
183
+ return DCCCResult(
184
+ args=result.args,
185
+ returncode=result.returncode,
186
+ stdout=result.stdout,
187
+ stderr=result.stderr,
188
+ output=actual_output,
189
+ temp_dir=temp_dir,
190
+ executable=result.executable,
191
+ metrics=result.metrics,
192
+ )
193
+
194
+
195
+ def _coerce_metric_kwargs(kwargs: dict[str, object], *, suvr: bool = False) -> dict[str, object]:
196
+ coerced = dict(kwargs)
197
+ if "include_suvr" in coerced and "suvr_values" not in coerced:
198
+ coerced["suvr_values"] = coerced.pop("include_suvr")
199
+ if "suvr" in coerced and "suvr_values" not in coerced:
200
+ coerced["suvr_values"] = coerced.pop("suvr")
201
+ if suvr and "suvr_values" not in coerced:
202
+ coerced["suvr_values"] = True
203
+ return coerced
204
+
205
+
206
+ def centiloid(
207
+ input: object,
208
+ output: str | os.PathLike[str] | None = None,
209
+ *,
210
+ suvr: bool = False,
211
+ **kwargs: object,
212
+ ) -> DCCCResult:
213
+ """Run the DCCCcore centiloid command."""
214
+
215
+ return _metric_command("centiloid", input, output, **_coerce_metric_kwargs(kwargs, suvr=suvr))
216
+
217
+
218
+ def centaur(
219
+ input: object,
220
+ output: str | os.PathLike[str] | None = None,
221
+ *,
222
+ suvr: bool = False,
223
+ **kwargs: object,
224
+ ) -> DCCCResult:
225
+ return _metric_command("centaur", input, output, **_coerce_metric_kwargs(kwargs, suvr=suvr))
226
+
227
+
228
+ def centaurz(
229
+ input: object,
230
+ output: str | os.PathLike[str] | None = None,
231
+ *,
232
+ suvr: bool = False,
233
+ report_detailed_regions: bool = False,
234
+ **kwargs: object,
235
+ ) -> DCCCResult:
236
+ coerced = _coerce_metric_kwargs(kwargs, suvr=suvr)
237
+ extra_args = list(coerced.pop("extra_args", ()))
238
+ if report_detailed_regions:
239
+ extra_args.append("--report-detailed-regions")
240
+ return _metric_command("centaurz", input, output, extra_args=extra_args, **coerced)
241
+
242
+
243
+ def abetaindex(
244
+ input: object,
245
+ output: str | os.PathLike[str] | None = None,
246
+ **kwargs: object,
247
+ ) -> DCCCResult:
248
+ return _metric_command("abetaindex", input, output, **_coerce_metric_kwargs(kwargs))
249
+
250
+
251
+ def abetaload(
252
+ input: object,
253
+ output: str | os.PathLike[str] | None = None,
254
+ **kwargs: object,
255
+ ) -> DCCCResult:
256
+ return _metric_command("abetaload", input, output, **_coerce_metric_kwargs(kwargs))
257
+
258
+
259
+ def fillstates(
260
+ input: object,
261
+ output: str | os.PathLike[str] | None = None,
262
+ *,
263
+ tracer: str,
264
+ suvr: bool = False,
265
+ **kwargs: object,
266
+ ) -> DCCCResult:
267
+ coerced = _coerce_metric_kwargs(kwargs, suvr=suvr)
268
+ extra_args = list(coerced.pop("extra_args", ()))
269
+ extra_args.extend(["--tracer", tracer])
270
+ return _metric_command("fillstates", input, output, extra_args=extra_args, **coerced)
271
+
272
+
273
+ def suvr(
274
+ input: object,
275
+ output: str | os.PathLike[str] | None = None,
276
+ *,
277
+ voi_mask: str | os.PathLike[str],
278
+ ref_mask: str | os.PathLike[str],
279
+ **kwargs: object,
280
+ ) -> DCCCResult:
281
+ coerced = _coerce_metric_kwargs(kwargs)
282
+ extra_args = list(coerced.pop("extra_args", ()))
283
+ extra_args.extend(["--voi-mask", os.fspath(voi_mask), "--ref-mask", os.fspath(ref_mask)])
284
+ return _metric_command("suvr", input, output, extra_args=extra_args, **coerced)
285
+
286
+
287
+ def adad(
288
+ input: object,
289
+ output: str | os.PathLike[str] | None = None,
290
+ *,
291
+ modality: str | None = None,
292
+ iterative: bool = False,
293
+ **kwargs: object,
294
+ ) -> DCCCResult:
295
+ coerced = _coerce_metric_kwargs(kwargs)
296
+ extra_args = list(coerced.pop("extra_args", ()))
297
+ if modality is not None:
298
+ extra_args.extend(["--modality", modality])
299
+ if iterative:
300
+ extra_args.append("--iterative")
301
+ return _metric_command("adad", input, output, extra_args=extra_args, **coerced)
302
+
303
+
304
+ def _spatial_command(
305
+ subcommand: str,
306
+ input: object,
307
+ output: str | os.PathLike[str] | None = None,
308
+ *,
309
+ iterative: bool = False,
310
+ manual_fov: bool = False,
311
+ batch: bool = False,
312
+ bids: str | None = None,
313
+ extra_args: Sequence[object] = (),
314
+ check: bool = True,
315
+ executable: str | os.PathLike[str] | None = None,
316
+ ) -> DCCCResult:
317
+ input_path, actual_output, temp_dir = _prepare_io(input, output)
318
+
319
+ args = [subcommand, "--input", input_path, "--output", os.fspath(actual_output)]
320
+ _append_flag(args, "--iterative", iterative)
321
+ _append_flag(args, "--manual-fov", manual_fov)
322
+ _append_flag(args, "--batch", batch)
323
+ if bids is not None:
324
+ args.extend(["--bids", bids])
325
+ args.extend(_as_args(extra_args))
326
+
327
+ result = run(args, check=check, executable=executable, output=actual_output)
328
+ return DCCCResult(
329
+ args=result.args,
330
+ returncode=result.returncode,
331
+ stdout=result.stdout,
332
+ stderr=result.stderr,
333
+ output=actual_output,
334
+ temp_dir=temp_dir,
335
+ executable=result.executable,
336
+ metrics=result.metrics,
337
+ )
338
+
339
+
340
+ def normalize(
341
+ input: object,
342
+ output: str | os.PathLike[str] | None = None,
343
+ **kwargs: object,
344
+ ) -> DCCCResult:
345
+ return _spatial_command("normalize", input, output, **kwargs)
346
+
347
+
348
+ def adni_pet_core(
349
+ input: object,
350
+ output: str | os.PathLike[str] | None = None,
351
+ **kwargs: object,
352
+ ) -> DCCCResult:
353
+ return _spatial_command("adni-pet-core", input, output, **kwargs)
354
+
355
+
356
+ def rigid(
357
+ input: object,
358
+ output: str | os.PathLike[str] | None = None,
359
+ **kwargs: object,
360
+ ) -> DCCCResult:
361
+ return _spatial_command("rigid", input, output, **kwargs)
dcccpy/py.typed ADDED
@@ -0,0 +1 @@
1
+
dcccpy/runtime.py ADDED
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import stat
7
+ import sys
8
+ import tempfile
9
+ import urllib.request
10
+ import zipfile
11
+ from pathlib import Path
12
+ from typing import Iterable
13
+
14
+
15
+ DCCCCORE_VERSION = "4.2.3"
16
+ RELEASE_REPO = "tctco/DCCCSlicer"
17
+
18
+
19
+ class DCCCcoreNotFoundError(FileNotFoundError):
20
+ """Raised when dcccpy cannot locate a DCCCcore executable."""
21
+
22
+
23
+ class DCCCcoreDownloadError(RuntimeError):
24
+ """Raised when automatic DCCCcore download fails."""
25
+
26
+
27
+ def platform_key() -> str:
28
+ machine = platform.machine().lower()
29
+ if machine in {"x86_64", "amd64"}:
30
+ arch = "x86_64"
31
+ elif machine in {"aarch64", "arm64"}:
32
+ arch = "arm64"
33
+ else:
34
+ arch = machine
35
+
36
+ if sys.platform.startswith("linux"):
37
+ return f"linux-{arch}"
38
+ if sys.platform == "darwin":
39
+ return f"macos-{arch}"
40
+ if sys.platform.startswith("win"):
41
+ return f"windows-{arch}"
42
+ return f"{sys.platform}-{arch}"
43
+
44
+
45
+ def release_platform(key: str | None = None) -> str:
46
+ key = key or platform_key()
47
+ mapping = {
48
+ "linux-x86_64": "ubuntu-latest-x64",
49
+ "windows-x86_64": "windows-latest-x64",
50
+ "macos-arm64": "macos-latest-arm64",
51
+ }
52
+ try:
53
+ return mapping[key]
54
+ except KeyError as exc:
55
+ raise DCCCcoreNotFoundError(f"No prebuilt DCCCcore release asset is known for {key}.") from exc
56
+
57
+
58
+ def executable_names() -> tuple[str, ...]:
59
+ return ("DCCCcore.exe", "DCCCcore") if os.name == "nt" else ("DCCCcore",)
60
+
61
+
62
+ def package_root() -> Path:
63
+ return Path(__file__).resolve().parent
64
+
65
+
66
+ def cache_root() -> Path:
67
+ explicit = os.environ.get("DCCCPY_CACHE_DIR")
68
+ if explicit:
69
+ return Path(explicit).expanduser()
70
+
71
+ if sys.platform.startswith("win"):
72
+ base = os.environ.get("LOCALAPPDATA")
73
+ if base:
74
+ return Path(base) / "dcccpy"
75
+
76
+ base = os.environ.get("XDG_CACHE_HOME")
77
+ if base:
78
+ return Path(base) / "dcccpy"
79
+
80
+ return Path.home() / ".cache" / "dcccpy"
81
+
82
+
83
+ def runtime_dir(version: str = DCCCCORE_VERSION, key: str | None = None) -> Path:
84
+ return cache_root() / "dccccore" / version / (key or platform_key())
85
+
86
+
87
+ def candidate_executables(base_dirs: Iterable[Path]) -> list[Path]:
88
+ candidates: list[Path] = []
89
+ for base in base_dirs:
90
+ for name in executable_names():
91
+ candidates.append(base / name)
92
+ return candidates
93
+
94
+
95
+ def _main_package_vendor_dirs() -> list[Path]:
96
+ vendor_root = package_root() / "vendor" / "dccccore"
97
+ key = platform_key()
98
+ return [vendor_root / key, vendor_root]
99
+
100
+
101
+ def _runtime_package_dirs() -> list[Path]:
102
+ try:
103
+ import dcccpy_linux_runtime
104
+ except Exception:
105
+ return []
106
+
107
+ root = dcccpy_linux_runtime.dccccore_root()
108
+ return [Path(root)]
109
+
110
+
111
+ def _cached_dirs(version: str = DCCCCORE_VERSION) -> list[Path]:
112
+ return [runtime_dir(version)]
113
+
114
+
115
+ def find_existing_dccccore(version: str = DCCCCORE_VERSION) -> Path | None:
116
+ for candidate in candidate_executables(_main_package_vendor_dirs()):
117
+ if candidate.exists():
118
+ return candidate
119
+
120
+ for candidate in candidate_executables(_runtime_package_dirs()):
121
+ if candidate.exists():
122
+ return candidate
123
+
124
+ for candidate in candidate_executables(_cached_dirs(version)):
125
+ if candidate.exists():
126
+ return candidate
127
+
128
+ found = shutil.which("DCCCcore")
129
+ return Path(found) if found else None
130
+
131
+
132
+ def auto_download_enabled() -> bool:
133
+ value = os.environ.get("DCCCPY_AUTO_DOWNLOAD", "1").strip().lower()
134
+ return value not in {"0", "false", "no", "off"}
135
+
136
+
137
+ def release_url(version: str = DCCCCORE_VERSION, repo: str = RELEASE_REPO, platform_name: str | None = None) -> str:
138
+ platform_name = platform_name or release_platform()
139
+ asset = f"DCCCcore-{version}-{platform_name}.zip"
140
+ return f"https://github.com/{repo}/releases/download/v{version}/{asset}"
141
+
142
+
143
+ def _safe_extract(zip_file: zipfile.ZipFile, destination: Path) -> None:
144
+ destination = destination.resolve()
145
+ for member in zip_file.infolist():
146
+ target = (destination / member.filename).resolve()
147
+ if not str(target).startswith(str(destination)):
148
+ raise DCCCcoreDownloadError(f"Unsafe path in DCCCcore archive: {member.filename}")
149
+ zip_file.extractall(destination)
150
+
151
+
152
+ def _copy_runtime_tree(extracted_root: Path, destination: Path) -> None:
153
+ destination.parent.mkdir(parents=True, exist_ok=True)
154
+ if destination.exists():
155
+ shutil.rmtree(destination)
156
+ shutil.copytree(extracted_root, destination)
157
+
158
+ for candidate in candidate_executables([destination]):
159
+ if candidate.exists() and os.name != "nt":
160
+ mode = candidate.stat().st_mode
161
+ candidate.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
162
+
163
+
164
+ def download_dccccore(
165
+ *,
166
+ version: str = DCCCCORE_VERSION,
167
+ repo: str | None = None,
168
+ key: str | None = None,
169
+ force: bool = False,
170
+ ) -> Path:
171
+ key = key or platform_key()
172
+ destination = runtime_dir(version, key)
173
+ existing = next((path for path in candidate_executables([destination]) if path.exists()), None)
174
+ if existing is not None and not force:
175
+ return existing
176
+
177
+ repo = repo or os.environ.get("DCCCPY_RELEASE_REPO", RELEASE_REPO)
178
+ url = os.environ.get("DCCCPY_DCCCCORE_URL") or release_url(
179
+ version=version,
180
+ repo=repo,
181
+ platform_name=release_platform(key),
182
+ )
183
+
184
+ with tempfile.TemporaryDirectory(prefix="dcccpy-runtime-") as temp_name:
185
+ temp = Path(temp_name)
186
+ archive = temp / "DCCCcore.zip"
187
+ try:
188
+ with urllib.request.urlopen(url) as response, archive.open("wb") as handle:
189
+ shutil.copyfileobj(response, handle)
190
+ except Exception as exc:
191
+ raise DCCCcoreDownloadError(f"Failed to download DCCCcore from {url}") from exc
192
+
193
+ extract_dir = temp / "extract"
194
+ extract_dir.mkdir()
195
+ try:
196
+ with zipfile.ZipFile(archive) as zf:
197
+ _safe_extract(zf, extract_dir)
198
+ except Exception as exc:
199
+ raise DCCCcoreDownloadError(f"Failed to extract DCCCcore archive from {url}") from exc
200
+
201
+ roots = [path for path in extract_dir.iterdir() if path.is_dir()]
202
+ if len(roots) != 1:
203
+ raise DCCCcoreDownloadError(f"Expected one top-level directory in DCCCcore archive, found {len(roots)}.")
204
+
205
+ _copy_runtime_tree(roots[0], destination)
206
+
207
+ existing = next((path for path in candidate_executables([destination]) if path.exists()), None)
208
+ if existing is None:
209
+ raise DCCCcoreDownloadError(f"DCCCcore archive did not contain an executable for {key}.")
210
+ return existing
211
+
212
+
213
+ def dccccore_path(
214
+ executable: str | os.PathLike[str] | None = None,
215
+ *,
216
+ allow_download: bool | None = None,
217
+ version: str = DCCCCORE_VERSION,
218
+ ) -> Path:
219
+ explicit = executable or os.environ.get("DCCCPY_DCCCCORE")
220
+ if explicit:
221
+ path = Path(explicit).expanduser()
222
+ if path.exists():
223
+ return path
224
+ raise DCCCcoreNotFoundError(f"DCCCcore executable does not exist: {path}")
225
+
226
+ existing = find_existing_dccccore(version)
227
+ if existing is not None:
228
+ return existing
229
+
230
+ should_download = auto_download_enabled() if allow_download is None else allow_download
231
+ if should_download:
232
+ return download_dccccore(version=version)
233
+
234
+ raise DCCCcoreNotFoundError(
235
+ "Could not find DCCCcore. Install dcccpy[linux-runtime], set DCCCPY_DCCCCORE, "
236
+ "put DCCCcore on PATH, or enable automatic download with DCCCPY_AUTO_DOWNLOAD=1."
237
+ )
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: dcccpy
3
+ Version: 0.1.2
4
+ Summary: Python wrapper for the DCCCcore PET biomarker command-line tool
5
+ Author: DCCCSlicer contributors
6
+ License-Expression: CC-BY-NC-ND-4.0
7
+ Project-URL: Homepage, https://github.com/tctco/DCCCSlicer
8
+ Project-URL: Repository, https://github.com/tctco/DCCCSlicer
9
+ Project-URL: Issues, https://github.com/tctco/DCCCSlicer/issues
10
+ Keywords: PET,neuroimaging,centiloid,DCCC,DCCCSlicer
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: nibabel>=5
19
+ Provides-Extra: linux-runtime
20
+ Requires-Dist: dcccpy-linux-runtime==0.1.0; extra == "linux-runtime"
21
+
22
+ # dcccpy
23
+
24
+ `dcccpy` is a Python wrapper for the `DCCCcore` command-line tool from
25
+ DCCCSlicer. It keeps the native C++ core as the execution engine and provides a
26
+ small Python API for running common PET biomarker workflows.
27
+
28
+ ## Install
29
+
30
+ The default package is slim:
31
+
32
+ ```bash
33
+ pip install dcccpy
34
+ ```
35
+
36
+ The first call downloads the matching `DCCCcore` release package into the local
37
+ user cache if no native runtime is otherwise available. The slim package also
38
+ installs `nibabel` for image loading and nibabel-style image inputs.
39
+
40
+ For Linux users who prefer `pip install` to include the native runtime and avoid
41
+ first-run download:
42
+
43
+ ```bash
44
+ pip install "dcccpy[linux-runtime]"
45
+ ```
46
+
47
+ ## Python API
48
+
49
+ ```python
50
+ import dcccpy
51
+
52
+ result = dcccpy.centiloid("amyloid_pet.nii", skip_normalization=True)
53
+ print(result.metrics)
54
+ print(result.output)
55
+ ```
56
+
57
+ The `output` argument is optional for single-image Python calls. When omitted,
58
+ `dcccpy` creates a temporary output NIfTI path and returns it in the result.
59
+ Relative input and output paths are resolved from the current Python working
60
+ directory before `DCCCcore` is invoked.
61
+
62
+ ```python
63
+ result = dcccpy.centiloid("amyloid_pet.nii")
64
+ print(result.output) # temporary output path
65
+ print(result.metrics.get("fbp"))
66
+ ```
67
+
68
+ The wrapper accepts nibabel-style image objects with `to_filename()`:
69
+
70
+ ```python
71
+ import nibabel as nib
72
+ import dcccpy
73
+
74
+ image = nib.load("amyloid_pet.nii.gz")
75
+ result = dcccpy.centiloid(image, skip_normalization=True)
76
+ output_image = result.load_output()
77
+ ```
78
+
79
+ Common helpers mirror `DCCCcore` subcommands:
80
+
81
+ ```python
82
+ dcccpy.centiloid("amyloid.nii", suvr=True)
83
+ dcccpy.centaurz("tau.nii", report_detailed_regions=True)
84
+ dcccpy.fillstates("fdg.nii", tracer="fdg")
85
+ dcccpy.normalize("pet.nii", iterative=True)
86
+ dcccpy.run(["centiloid", "--input", "a.nii", "--output", "b.nii"])
87
+ ```
88
+
89
+ Each helper returns `DCCCResult` with:
90
+
91
+ - `returncode`
92
+ - `stdout` / `stderr`
93
+ - `output`
94
+ - `metrics`, parsed from numeric lines in stdout
95
+ - `load_output()`, which loads the output with nibabel
96
+
97
+ ## Command Line
98
+
99
+ `dcccpy` also forwards raw arguments to `DCCCcore`:
100
+
101
+ ```bash
102
+ dcccpy --help
103
+ dcccpy centiloid --input amyloid_pet.nii --output result.nii
104
+ ```
105
+
106
+ ## Runtime lookup
107
+
108
+ At runtime, `dcccpy` looks for `DCCCcore` in this order:
109
+
110
+ 1. `DCCCPY_DCCCCORE` environment variable.
111
+ 2. A vendored binary inside the installed `dcccpy` wheel.
112
+ 3. A binary from `dcccpy-linux-runtime`, installed by `dcccpy[linux-runtime]`.
113
+ 4. The local dcccpy cache populated by automatic download.
114
+ 5. `DCCCcore` on `PATH`.
115
+ 6. Automatic download from GitHub releases, unless disabled.
116
+
117
+ Useful environment variables:
118
+
119
+ - `DCCCPY_DCCCCORE`: exact path to a `DCCCcore` executable.
120
+ - `DCCCPY_AUTO_DOWNLOAD=0`: disable first-run automatic download.
121
+ - `DCCCPY_CACHE_DIR`: override the runtime cache directory.
122
+ - `DCCCPY_RELEASE_REPO`: override the GitHub release repository.
123
+ - `DCCCPY_DCCCCORE_URL`: override the release asset URL.
124
+
125
+ ## Runtime Packaging
126
+
127
+ Release wheels should vendor the matching `DCCCcore` runtime tree before build:
128
+
129
+ ```bash
130
+ python scripts/vendor_dccccore.py --version 4.2.3 --release-platform ubuntu-latest-x64
131
+ python -m build --wheel
132
+ ```
133
+
134
+ The source tree intentionally does not commit the vendored runtime because it
135
+ contains large ONNX model and NIfTI runtime assets.
136
+
137
+ The preferred distribution layout is:
138
+
139
+ - `dcccpy`: slim Python package with nibabel; downloads runtime on first use.
140
+ - `dcccpy-linux-runtime`: optional Linux runtime wheel.
141
+ - `dcccpy[linux-runtime]`: installs both packages.
142
+
143
+ ## Packaging note
144
+
145
+ The Linux `DCCCcore-4.2.3-ubuntu-latest-x64.zip` release asset contains the
146
+ native executable, `libtbb`, ONNX registration/decoupling models, configuration
147
+ files, and NIfTI template/mask assets. That full runtime is the correct unit to
148
+ vendor for a wheel that works immediately after `pip install dcccpy`.
149
+
150
+ The full Linux runtime wheel is about 167 MB for version 4.2.3, so a public
151
+ PyPI upload may require a file size limit increase unless a future release
152
+ splits a smaller runtime profile from the full `DCCCcore` package.
@@ -0,0 +1,10 @@
1
+ dcccpy/__init__.py,sha256=4idApts9ezIKuaQLtp4t0cbQ7XSAjPgByAKur38M5Lg,769
2
+ dcccpy/cli.py,sha256=sRTu6kOA79KzGYYXKvqWUwQb0RxZnV3B0UkbV4jFoCE,582
3
+ dcccpy/core.py,sha256=6Ic3SQ8EEVJDuNa_LLUGyhdAEF4QEqgQBitjLLfquRU,11013
4
+ dcccpy/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
5
+ dcccpy/runtime.py,sha256=BxiVkq-36pVR6Ze-wLtqUOW9G7GeDeA4tHf1v60z7fM,7568
6
+ dcccpy-0.1.2.dist-info/METADATA,sha256=xmZ0ds64GInXcL8acEqaz1I7EbuL7_WT36N7XzlaDZo,4933
7
+ dcccpy-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ dcccpy-0.1.2.dist-info/entry_points.txt,sha256=oPy0K-Lgnn7bd_IupKYTRe3Z2K53gJI4Osx7cA92hks,43
9
+ dcccpy-0.1.2.dist-info/top_level.txt,sha256=KeC_Zh9c6znBR8V5IHmeAglErsBEbmfTYSgCxgJkp1U,7
10
+ dcccpy-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dcccpy = dcccpy.cli:main
@@ -0,0 +1 @@
1
+ dcccpy