petfit-docker 0.1.4__tar.gz

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.
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: petfit-docker
3
+ Version: 0.1.4
4
+ Summary: A wrapper for generating Docker commands using regular PETFit syntax
5
+ Author: The PETFit developers
6
+ License: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Science/Research
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+
15
+ # petfit-docker
16
+
17
+ `petfit-docker` is a lightweight Python wrapper that turns a BIDS-App-like
18
+ command line into the matching `docker run` invocation for PETFit.
19
+ Interactive Shiny mode is the default; use `--automatic` or
20
+ `--mode automatic` to run a non-interactive pipeline.
21
+
22
+ ```bash
23
+ petfit-docker /path/to/bids /path/to/derivatives participant \
24
+ --app modelling_plasma \
25
+ --blood-dir /path/to/blood \
26
+ --analysis-foldername Primary_Analysis
27
+ ```
28
+
29
+ The command above runs:
30
+
31
+ ```bash
32
+ docker run --rm -it \
33
+ -p 3838:3838 \
34
+ -v /path/to/bids:/data/bids_dir:ro \
35
+ -v /path/to/derivatives:/data/derivatives_dir:rw \
36
+ -v /path/to/blood:/data/blood_dir:ro \
37
+ mathesong/petfit:latest \
38
+ --func modelling_plasma --mode interactive
39
+ ```
40
+
41
+ ## Install for development
42
+
43
+ ```bash
44
+ cd wrapper
45
+ python -m pip install -e .
46
+ ```
47
+
48
+ ## Examples
49
+
50
+ Launch the default region definition app:
51
+
52
+ ```bash
53
+ petfit-docker /path/to/bids /path/to/derivatives/petfit participant
54
+ ```
55
+
56
+ The three positional arguments follow the BIDS App convention:
57
+
58
+ ```text
59
+ petfit-docker <bids_dir> <output_dir> participant
60
+ ```
61
+
62
+ Launch region definition:
63
+
64
+ ```bash
65
+ petfit-docker /path/to/bids /path/to/derivatives participant --app regiondef
66
+ ```
67
+
68
+ The positional `output_dir` can be either the derivatives root or the final
69
+ PETFit output directory. These are equivalent with the default output folder
70
+ name:
71
+
72
+ ```bash
73
+ petfit-docker /path/to/bids /path/to/derivatives/petfit participant
74
+ petfit-docker /path/to/bids /path/to/derivatives participant --app regiondef
75
+ petfit-docker /path/to/bids /path/to/derivatives/petfit participant --app regiondef
76
+ ```
77
+
78
+ Launch plasma-input modelling:
79
+
80
+ ```bash
81
+ petfit-docker /path/to/bids /path/to/derivatives participant \
82
+ --app modelling_plasma \
83
+ --blood-dir /path/to/blood
84
+ ```
85
+
86
+ Run plasma-input modelling automatically:
87
+
88
+ ```bash
89
+ petfit-docker /path/to/bids /path/to/derivatives participant \
90
+ --app modelling_plasma \
91
+ --blood-dir /path/to/blood \
92
+ --automatic
93
+ ```
94
+
95
+ Open a shell in the image:
96
+
97
+ ```bash
98
+ petfit-docker --shell -i mathesong/petfit:latest
99
+ ```
100
+
101
+ ## Patching a local petfit
102
+
103
+ Use `--patch` (or `-f`) to point the wrapper at a local petfit checkout and test
104
+ your local changes without rebuilding the image. The wrapper bind-mounts the
105
+ source into the container, where it is reinstalled from source at startup so it
106
+ overrides the petfit baked into the image:
107
+
108
+ ```bash
109
+ petfit-docker /path/to/bids /path/to/derivatives participant \
110
+ --app modelling_ref \
111
+ --patch /path/to/your/petfit/checkout
112
+ ```
113
+
114
+ This mirrors the `--patch` option of the PETPrep Docker wrapper. Because petfit
115
+ is an R package it is reinstalled (not run directly from source), so the first
116
+ few seconds of startup are spent installing the patched package. The patch
117
+ works with every mode, including `--shell`.
118
+
119
+ ## Apple Silicon
120
+
121
+ The published PETFit Docker images are currently `linux/amd64` only. The
122
+ wrapper therefore requests `--platform linux/amd64` by default, which avoids
123
+ Docker's platform-mismatch warning on Apple Silicon while running under
124
+ emulation. If a native or multi-architecture image is published later, override
125
+ the platform with `--platform linux/arm64` or disable the explicit platform with
126
+ `--platform ""`.
@@ -0,0 +1,112 @@
1
+ # petfit-docker
2
+
3
+ `petfit-docker` is a lightweight Python wrapper that turns a BIDS-App-like
4
+ command line into the matching `docker run` invocation for PETFit.
5
+ Interactive Shiny mode is the default; use `--automatic` or
6
+ `--mode automatic` to run a non-interactive pipeline.
7
+
8
+ ```bash
9
+ petfit-docker /path/to/bids /path/to/derivatives participant \
10
+ --app modelling_plasma \
11
+ --blood-dir /path/to/blood \
12
+ --analysis-foldername Primary_Analysis
13
+ ```
14
+
15
+ The command above runs:
16
+
17
+ ```bash
18
+ docker run --rm -it \
19
+ -p 3838:3838 \
20
+ -v /path/to/bids:/data/bids_dir:ro \
21
+ -v /path/to/derivatives:/data/derivatives_dir:rw \
22
+ -v /path/to/blood:/data/blood_dir:ro \
23
+ mathesong/petfit:latest \
24
+ --func modelling_plasma --mode interactive
25
+ ```
26
+
27
+ ## Install for development
28
+
29
+ ```bash
30
+ cd wrapper
31
+ python -m pip install -e .
32
+ ```
33
+
34
+ ## Examples
35
+
36
+ Launch the default region definition app:
37
+
38
+ ```bash
39
+ petfit-docker /path/to/bids /path/to/derivatives/petfit participant
40
+ ```
41
+
42
+ The three positional arguments follow the BIDS App convention:
43
+
44
+ ```text
45
+ petfit-docker <bids_dir> <output_dir> participant
46
+ ```
47
+
48
+ Launch region definition:
49
+
50
+ ```bash
51
+ petfit-docker /path/to/bids /path/to/derivatives participant --app regiondef
52
+ ```
53
+
54
+ The positional `output_dir` can be either the derivatives root or the final
55
+ PETFit output directory. These are equivalent with the default output folder
56
+ name:
57
+
58
+ ```bash
59
+ petfit-docker /path/to/bids /path/to/derivatives/petfit participant
60
+ petfit-docker /path/to/bids /path/to/derivatives participant --app regiondef
61
+ petfit-docker /path/to/bids /path/to/derivatives/petfit participant --app regiondef
62
+ ```
63
+
64
+ Launch plasma-input modelling:
65
+
66
+ ```bash
67
+ petfit-docker /path/to/bids /path/to/derivatives participant \
68
+ --app modelling_plasma \
69
+ --blood-dir /path/to/blood
70
+ ```
71
+
72
+ Run plasma-input modelling automatically:
73
+
74
+ ```bash
75
+ petfit-docker /path/to/bids /path/to/derivatives participant \
76
+ --app modelling_plasma \
77
+ --blood-dir /path/to/blood \
78
+ --automatic
79
+ ```
80
+
81
+ Open a shell in the image:
82
+
83
+ ```bash
84
+ petfit-docker --shell -i mathesong/petfit:latest
85
+ ```
86
+
87
+ ## Patching a local petfit
88
+
89
+ Use `--patch` (or `-f`) to point the wrapper at a local petfit checkout and test
90
+ your local changes without rebuilding the image. The wrapper bind-mounts the
91
+ source into the container, where it is reinstalled from source at startup so it
92
+ overrides the petfit baked into the image:
93
+
94
+ ```bash
95
+ petfit-docker /path/to/bids /path/to/derivatives participant \
96
+ --app modelling_ref \
97
+ --patch /path/to/your/petfit/checkout
98
+ ```
99
+
100
+ This mirrors the `--patch` option of the PETPrep Docker wrapper. Because petfit
101
+ is an R package it is reinstalled (not run directly from source), so the first
102
+ few seconds of startup are spent installing the patched package. The patch
103
+ works with every mode, including `--shell`.
104
+
105
+ ## Apple Silicon
106
+
107
+ The published PETFit Docker images are currently `linux/amd64` only. The
108
+ wrapper therefore requests `--platform linux/amd64` by default, which avoids
109
+ Docker's platform-mismatch warning on Apple Silicon while running under
110
+ emulation. If a native or multi-architecture image is published later, override
111
+ the platform with `--platform linux/arm64` or disable the explicit platform with
112
+ `--platform ""`.
@@ -0,0 +1,5 @@
1
+ """Docker wrapper for PETFit."""
2
+
3
+ from ._version import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,7 @@
1
+ """Module entry point for ``python -m petfit_docker``."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ """Version information for petfit-docker."""
2
+
3
+ __version__ = "0.1.4"
@@ -0,0 +1,362 @@
1
+ """Command line wrapper for running PETFit in Docker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import shlex
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import List, Optional, Sequence
11
+
12
+ from ._version import __version__
13
+
14
+ DEFAULT_IMAGE = "mathesong/petfit:latest"
15
+ DEFAULT_PLATFORM = "linux/amd64"
16
+ DEFAULT_PORT = 3838
17
+ APPS = ("regiondef", "modelling_plasma", "modelling_ref")
18
+ MODES = ("automatic", "interactive")
19
+ STEPS = ("datadef", "weights", "delay", "reference_tac", "model1", "model2", "model3")
20
+ MISSING_IMAGE = "Image '{}' is missing\nWould you like to download? [Y/n] "
21
+
22
+
23
+ class PETFitHelpFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter):
24
+ """Preserve paragraphs while still showing defaults."""
25
+
26
+
27
+ class PathAction(argparse.Action):
28
+ """Expand user paths while preserving argparse's standard display."""
29
+
30
+ def __call__(self, parser, namespace, values, option_string=None):
31
+ if values is None:
32
+ setattr(namespace, self.dest, None)
33
+ return
34
+ if isinstance(values, list):
35
+ setattr(namespace, self.dest, [str(Path(value).expanduser()) for value in values])
36
+ else:
37
+ setattr(namespace, self.dest, str(Path(values).expanduser()))
38
+
39
+
40
+ def _parser() -> argparse.ArgumentParser:
41
+ parser = argparse.ArgumentParser(
42
+ prog="petfit-docker",
43
+ formatter_class=PETFitHelpFormatter,
44
+ description=(
45
+ "The PETFit on Docker wrapper\n\n"
46
+ "This is a lightweight Python wrapper to run PETFit. Docker must be "
47
+ "installed and running. This can be checked running::\n\n"
48
+ " docker info\n\n"
49
+ "The wrapper accepts a BIDS-App-like command line and translates host "
50
+ "paths into Docker bind mounts before executing the PETFit image."
51
+ ),
52
+ )
53
+
54
+ parser.add_argument("bids_dir", nargs="?", action=PathAction)
55
+ parser.add_argument("output_dir", nargs="?", action=PathAction)
56
+ parser.add_argument("analysis_level", nargs="?", choices=("participant",), default="participant")
57
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
58
+ parser.add_argument("-i", "--image", default=DEFAULT_IMAGE, help="image name")
59
+
60
+ wrapper = parser.add_argument_group(
61
+ "Wrapper options",
62
+ "Standard options that require mapping files into the container; see petfit usage for complete descriptions",
63
+ )
64
+ wrapper.add_argument(
65
+ "--app",
66
+ choices=APPS,
67
+ default="regiondef",
68
+ help=(
69
+ "PETFit app to run: "
70
+ "'regiondef' = define brain regions and build combined TACs; "
71
+ "'modelling_plasma' = invasive plasma-input models "
72
+ "(1TCM, 2TCM, 2TCM_irr, Logan, MA1, Patlak), requires blood data; "
73
+ "'modelling_ref' = non-invasive reference-tissue models "
74
+ "(SRTM, SRTM2, refLogan, MRTM1, MRTM2)"
75
+ ),
76
+ )
77
+ wrapper.add_argument(
78
+ "--mode",
79
+ choices=MODES,
80
+ default="interactive",
81
+ help="execution mode; or use the --interactive / --automatic shorthands below",
82
+ )
83
+ wrapper.add_argument(
84
+ "--interactive",
85
+ dest="mode",
86
+ action="store_const",
87
+ const="interactive",
88
+ default=argparse.SUPPRESS,
89
+ help="shorthand for --mode interactive (launch the Shiny app in the browser)",
90
+ )
91
+ wrapper.add_argument(
92
+ "--automatic",
93
+ dest="mode",
94
+ action="store_const",
95
+ const="automatic",
96
+ default=argparse.SUPPRESS,
97
+ help="shorthand for --mode automatic (run the non-interactive pipeline)",
98
+ )
99
+ wrapper.add_argument("--step", choices=STEPS, help="single automatic modelling step to run")
100
+ wrapper.add_argument("--blood-dir", action=PathAction, help="blood data directory for plasma input models")
101
+ wrapper.add_argument("-w", "--work-dir", action=PathAction, help="working directory to mount in the container")
102
+ wrapper.add_argument("--petfit-output-foldername", default="petfit", help="petfit output folder within derivatives")
103
+ wrapper.add_argument(
104
+ "--analysis-foldername",
105
+ default="Primary_Analysis",
106
+ help=(
107
+ "name of the analysis subfolder holding this run's config and outputs; "
108
+ "multiple can sit side by side, e.g. 'Reference_Analysis', "
109
+ "'Baseline_Only'"
110
+ ),
111
+ )
112
+ wrapper.add_argument("--cores", type=int, default=1, help="number of cores for parallel processing")
113
+ wrapper.add_argument(
114
+ "--ancillary-analysis-folder",
115
+ help="sibling analysis folder to inherit delay or k2prime estimates from",
116
+ )
117
+ wrapper.add_argument("--port", type=int, default=DEFAULT_PORT, help="host and container port for interactive Shiny apps")
118
+
119
+ developer = parser.add_argument_group("Developer options", "Tools for testing and debugging PETFit")
120
+ developer.add_argument("--shell", action="store_true", help="open shell in image instead of running PETFit")
121
+ developer.add_argument(
122
+ "-f",
123
+ "--patch",
124
+ action=PathAction,
125
+ help="local petfit checkout to install over the image's petfit at container "
126
+ "start (for testing local changes without rebuilding the image)",
127
+ )
128
+ developer.add_argument(
129
+ "-e",
130
+ "--env",
131
+ nargs=2,
132
+ action="append",
133
+ metavar=("ENV_VAR", "value"),
134
+ help="set custom environment variables within container",
135
+ )
136
+ developer.add_argument(
137
+ "-u",
138
+ "--user",
139
+ help="run container as a given user/uid. A group/gid can also be assigned, i.e. --user <UID>:<GID>",
140
+ )
141
+ developer.add_argument("--network", help='run container with a different network driver, e.g. "none"')
142
+ developer.add_argument(
143
+ "--platform",
144
+ default=DEFAULT_PLATFORM,
145
+ help="run Docker with a specific platform; PETFit images are currently published for linux/amd64",
146
+ )
147
+ developer.add_argument("--no-tty", action="store_true", help="run docker without TTY flag -it")
148
+ developer.add_argument("--dry-run", action="store_true", help="print the Docker command without executing it")
149
+ developer.add_argument(
150
+ "--skip-image-check",
151
+ action="store_true",
152
+ help="do not check whether the Docker image exists before running",
153
+ )
154
+
155
+ return parser
156
+
157
+
158
+ def _absolute_path(path: Optional[str], *, create: bool = False) -> Optional[str]:
159
+ if path is None:
160
+ return None
161
+
162
+ resolved = Path(path).expanduser().absolute()
163
+ if create:
164
+ resolved.mkdir(parents=True, exist_ok=True)
165
+ return str(resolved)
166
+
167
+
168
+ def _derivatives_mount_from_output(output_dir: str, petfit_output_foldername: str) -> str:
169
+ """Map BIDS-App-like output_dir to PETFit's derivatives mount point.
170
+
171
+ PETFit's container entry point expects the derivatives root and then appends
172
+ ``petfit_output_foldername`` internally. If the wrapper user passes the final
173
+ PETFit output directory, such as ``derivatives/petfit``, mount its parent so
174
+ the container does not look for ``petfit/petfit``.
175
+ """
176
+
177
+ output_path = Path(output_dir)
178
+ if output_path.name == petfit_output_foldername:
179
+ return str(output_path.parent)
180
+ return output_dir
181
+
182
+
183
+ def _mount_argument(host_path: str, container_path: str, mode: str) -> str:
184
+ return f"{host_path}:{container_path}:{mode}"
185
+
186
+
187
+ def build_docker_command(opts: argparse.Namespace) -> List[str]:
188
+ """Build the Docker command corresponding to parsed options."""
189
+
190
+ if opts.mode == "interactive" and (opts.port < 1 or opts.port > 65535):
191
+ raise SystemExit("--port must be between 1 and 65535")
192
+ if opts.cores < 1:
193
+ raise SystemExit("--cores must be at least 1")
194
+ if opts.app == "regiondef" and opts.step:
195
+ raise SystemExit("--step is only valid for modelling_plasma and modelling_ref")
196
+
197
+ bids_dir = _absolute_path(opts.bids_dir) if opts.bids_dir else None
198
+ output_dir = _absolute_path(opts.output_dir, create=not opts.shell) if opts.output_dir else None
199
+ derivatives_dir = (
200
+ _derivatives_mount_from_output(output_dir, opts.petfit_output_foldername)
201
+ if output_dir
202
+ else None
203
+ )
204
+ blood_dir = _absolute_path(opts.blood_dir) if opts.blood_dir else None
205
+ work_dir = _absolute_path(opts.work_dir, create=True) if opts.work_dir else None
206
+
207
+ if not opts.shell:
208
+ missing = []
209
+ if not bids_dir:
210
+ missing.append("bids_dir")
211
+ if not output_dir:
212
+ missing.append("output_dir")
213
+ if missing:
214
+ raise SystemExit("the following arguments are required unless --shell is used: " + ", ".join(missing))
215
+
216
+ command = ["docker", "run", "--rm"]
217
+ if opts.platform:
218
+ command.extend(["--platform", opts.platform])
219
+ if not opts.no_tty:
220
+ command.append("-it")
221
+
222
+ if opts.user:
223
+ command.extend(["--user", opts.user])
224
+ if opts.network:
225
+ command.extend(["--network", opts.network])
226
+
227
+ env = list(opts.env or [])
228
+ if opts.mode == "interactive":
229
+ env.append(("PETFIT_SHINY_PORT", str(opts.port)))
230
+ env.append(("SHINY_SERVER_VERSION", ""))
231
+ command.extend(["-p", f"{opts.port}:{opts.port}"])
232
+
233
+ for key, value in env:
234
+ command.extend(["-e", f"{key}={value}"])
235
+
236
+ if bids_dir:
237
+ command.extend(["-v", _mount_argument(bids_dir, "/data/bids_dir", "ro")])
238
+ if derivatives_dir:
239
+ command.extend(["-v", _mount_argument(derivatives_dir, "/data/derivatives_dir", "rw")])
240
+ if blood_dir:
241
+ command.extend(["-v", _mount_argument(blood_dir, "/data/blood_dir", "ro")])
242
+ if work_dir:
243
+ command.extend(["-v", _mount_argument(work_dir, "/data/work_dir", "rw")])
244
+
245
+ patch_dir = _absolute_path(opts.patch) if opts.patch else None
246
+ if patch_dir:
247
+ command.extend(["-v", _mount_argument(patch_dir, "/patch/petfit", "ro")])
248
+
249
+ if opts.shell:
250
+ command.extend(["--entrypoint", "/bin/bash", opts.image])
251
+ return command
252
+
253
+ command.append(opts.image)
254
+ command.extend(
255
+ [
256
+ "--func",
257
+ opts.app,
258
+ "--mode",
259
+ opts.mode,
260
+ "--petfit_output_foldername",
261
+ opts.petfit_output_foldername,
262
+ "--analysis_foldername",
263
+ opts.analysis_foldername,
264
+ "--cores",
265
+ str(opts.cores),
266
+ ]
267
+ )
268
+
269
+ if opts.step:
270
+ command.extend(["--step", opts.step])
271
+ if opts.ancillary_analysis_folder:
272
+ command.extend(["--ancillary_analysis_folder", opts.ancillary_analysis_folder])
273
+
274
+ return command
275
+
276
+
277
+ def check_docker() -> None:
278
+ """Fail early if Docker is unavailable, preserving Docker's own message."""
279
+
280
+ try:
281
+ subprocess.run(["docker", "info"], capture_output=True, text=True, check=True)
282
+ except OSError as error:
283
+ raise SystemExit(f"Could not run Docker: {error}") from error
284
+ except subprocess.CalledProcessError as error:
285
+ docker_output = (error.stderr or error.stdout or "").strip()
286
+ if docker_output:
287
+ print(docker_output, file=sys.stderr)
288
+ print("Could not detect memory capacity of Docker container.", file=sys.stderr)
289
+ raise SystemExit("Do you have permission to run docker?") from error
290
+
291
+ return None
292
+
293
+
294
+ def image_exists(image: str) -> bool:
295
+ """Return whether a Docker image is available locally."""
296
+
297
+ try:
298
+ result = subprocess.run(["docker", "images", "-q", image], stdout=subprocess.PIPE)
299
+ except OSError:
300
+ return False
301
+ return bool(result.stdout)
302
+
303
+
304
+ def check_memory(image: str, platform: Optional[str] = DEFAULT_PLATFORM) -> int:
305
+ """Return available container memory in MB, or -1 if Docker cannot report it."""
306
+
307
+ command = ["docker", "run", "--rm"]
308
+ if platform:
309
+ command.extend(["--platform", platform])
310
+ command.extend(["--entrypoint=free", image, "-m"])
311
+
312
+ try:
313
+ result = subprocess.run(command, stdout=subprocess.PIPE)
314
+ except OSError:
315
+ return -1
316
+
317
+ if result.returncode:
318
+ return -1
319
+
320
+ for line in result.stdout.splitlines():
321
+ if line.startswith(b"Mem:"):
322
+ return int(line.decode().split()[1])
323
+ return -1
324
+
325
+
326
+ def maybe_pull_image(image: str) -> None:
327
+ """Offer to download a missing image before Docker pulls via ``docker run``."""
328
+
329
+ if image_exists(image):
330
+ return
331
+
332
+ answer = input(MISSING_IMAGE.format(image))
333
+ if answer.strip().lower() not in ("", "y", "yes"):
334
+ raise SystemExit(f"Image '{image}' is required")
335
+
336
+ print("Downloading. This may take a while...", flush=True)
337
+
338
+
339
+ def ensure_image_ready(image: str, platform: Optional[str] = DEFAULT_PLATFORM) -> None:
340
+ """Confirm image availability and that Docker can run a tiny memory probe."""
341
+
342
+ maybe_pull_image(image)
343
+ if check_memory(image, platform=platform) == -1:
344
+ print("Could not detect memory capacity of Docker container.", file=sys.stderr)
345
+ raise SystemExit("Do you have permission to run docker?")
346
+
347
+
348
+ def main(argv: Optional[Sequence[str]] = None) -> int:
349
+ parser = _parser()
350
+ opts = parser.parse_args(argv)
351
+
352
+ if not opts.dry_run:
353
+ check_docker()
354
+ if not opts.skip_image_check:
355
+ ensure_image_ready(opts.image, platform=opts.platform)
356
+
357
+ command = build_docker_command(opts)
358
+ print("RUNNING: " + shlex.join(command))
359
+
360
+ if opts.dry_run:
361
+ return 0
362
+ return subprocess.call(command)
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: petfit-docker
3
+ Version: 0.1.4
4
+ Summary: A wrapper for generating Docker commands using regular PETFit syntax
5
+ Author: The PETFit developers
6
+ License: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Science/Research
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+
15
+ # petfit-docker
16
+
17
+ `petfit-docker` is a lightweight Python wrapper that turns a BIDS-App-like
18
+ command line into the matching `docker run` invocation for PETFit.
19
+ Interactive Shiny mode is the default; use `--automatic` or
20
+ `--mode automatic` to run a non-interactive pipeline.
21
+
22
+ ```bash
23
+ petfit-docker /path/to/bids /path/to/derivatives participant \
24
+ --app modelling_plasma \
25
+ --blood-dir /path/to/blood \
26
+ --analysis-foldername Primary_Analysis
27
+ ```
28
+
29
+ The command above runs:
30
+
31
+ ```bash
32
+ docker run --rm -it \
33
+ -p 3838:3838 \
34
+ -v /path/to/bids:/data/bids_dir:ro \
35
+ -v /path/to/derivatives:/data/derivatives_dir:rw \
36
+ -v /path/to/blood:/data/blood_dir:ro \
37
+ mathesong/petfit:latest \
38
+ --func modelling_plasma --mode interactive
39
+ ```
40
+
41
+ ## Install for development
42
+
43
+ ```bash
44
+ cd wrapper
45
+ python -m pip install -e .
46
+ ```
47
+
48
+ ## Examples
49
+
50
+ Launch the default region definition app:
51
+
52
+ ```bash
53
+ petfit-docker /path/to/bids /path/to/derivatives/petfit participant
54
+ ```
55
+
56
+ The three positional arguments follow the BIDS App convention:
57
+
58
+ ```text
59
+ petfit-docker <bids_dir> <output_dir> participant
60
+ ```
61
+
62
+ Launch region definition:
63
+
64
+ ```bash
65
+ petfit-docker /path/to/bids /path/to/derivatives participant --app regiondef
66
+ ```
67
+
68
+ The positional `output_dir` can be either the derivatives root or the final
69
+ PETFit output directory. These are equivalent with the default output folder
70
+ name:
71
+
72
+ ```bash
73
+ petfit-docker /path/to/bids /path/to/derivatives/petfit participant
74
+ petfit-docker /path/to/bids /path/to/derivatives participant --app regiondef
75
+ petfit-docker /path/to/bids /path/to/derivatives/petfit participant --app regiondef
76
+ ```
77
+
78
+ Launch plasma-input modelling:
79
+
80
+ ```bash
81
+ petfit-docker /path/to/bids /path/to/derivatives participant \
82
+ --app modelling_plasma \
83
+ --blood-dir /path/to/blood
84
+ ```
85
+
86
+ Run plasma-input modelling automatically:
87
+
88
+ ```bash
89
+ petfit-docker /path/to/bids /path/to/derivatives participant \
90
+ --app modelling_plasma \
91
+ --blood-dir /path/to/blood \
92
+ --automatic
93
+ ```
94
+
95
+ Open a shell in the image:
96
+
97
+ ```bash
98
+ petfit-docker --shell -i mathesong/petfit:latest
99
+ ```
100
+
101
+ ## Patching a local petfit
102
+
103
+ Use `--patch` (or `-f`) to point the wrapper at a local petfit checkout and test
104
+ your local changes without rebuilding the image. The wrapper bind-mounts the
105
+ source into the container, where it is reinstalled from source at startup so it
106
+ overrides the petfit baked into the image:
107
+
108
+ ```bash
109
+ petfit-docker /path/to/bids /path/to/derivatives participant \
110
+ --app modelling_ref \
111
+ --patch /path/to/your/petfit/checkout
112
+ ```
113
+
114
+ This mirrors the `--patch` option of the PETPrep Docker wrapper. Because petfit
115
+ is an R package it is reinstalled (not run directly from source), so the first
116
+ few seconds of startup are spent installing the patched package. The patch
117
+ works with every mode, including `--shell`.
118
+
119
+ ## Apple Silicon
120
+
121
+ The published PETFit Docker images are currently `linux/amd64` only. The
122
+ wrapper therefore requests `--platform linux/amd64` by default, which avoids
123
+ Docker's platform-mismatch warning on Apple Silicon while running under
124
+ emulation. If a native or multi-architecture image is published later, override
125
+ the platform with `--platform linux/arm64` or disable the explicit platform with
126
+ `--platform ""`.
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ petfit_docker/__init__.py
5
+ petfit_docker/__main__.py
6
+ petfit_docker/_version.py
7
+ petfit_docker/cli.py
8
+ petfit_docker.egg-info/PKG-INFO
9
+ petfit_docker.egg-info/SOURCES.txt
10
+ petfit_docker.egg-info/dependency_links.txt
11
+ petfit_docker.egg-info/entry_points.txt
12
+ petfit_docker.egg-info/top_level.txt
13
+ tests/test_cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ petfit-docker = petfit_docker.cli:main
@@ -0,0 +1 @@
1
+ petfit_docker
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "petfit-docker"
7
+ dynamic = ["version"]
8
+ description = "A wrapper for generating Docker commands using regular PETFit syntax"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "The PETFit developers" }
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Science/Research",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Scientific/Engineering :: Medical Science Apps."
21
+ ]
22
+
23
+ [project.scripts]
24
+ petfit-docker = "petfit_docker.cli:main"
25
+
26
+ [tool.setuptools.dynamic]
27
+ version = { attr = "petfit_docker._version.__version__" }
28
+
29
+ [tool.setuptools.packages.find]
30
+ include = ["petfit_docker*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """Compatibility setup.py for editable installs and older packaging tools."""
2
+
3
+ from setuptools import setup
4
+
5
+
6
+ setup()
@@ -0,0 +1,343 @@
1
+ import tempfile
2
+ import unittest
3
+ from pathlib import Path
4
+ import sys
5
+ from io import StringIO
6
+ from unittest.mock import patch
7
+ import subprocess
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
10
+
11
+ from petfit_docker.cli import (
12
+ _parser,
13
+ build_docker_command,
14
+ check_docker,
15
+ check_memory,
16
+ ensure_image_ready,
17
+ main,
18
+ maybe_pull_image,
19
+ )
20
+
21
+
22
+ def abs_path(path):
23
+ return str(Path(path).expanduser().absolute())
24
+
25
+
26
+ class DockerCommandTests(unittest.TestCase):
27
+ def test_no_arguments_parse_without_path_traceback(self):
28
+ opts = _parser().parse_args([])
29
+
30
+ self.assertIsNone(opts.bids_dir)
31
+ self.assertIsNone(opts.output_dir)
32
+
33
+ def test_builds_bids_app_like_modelling_command(self):
34
+ with tempfile.TemporaryDirectory() as tmpdir:
35
+ root = Path(tmpdir)
36
+ bids = root / "bids"
37
+ derivatives = root / "derivatives"
38
+ blood = root / "blood"
39
+ bids.mkdir()
40
+ blood.mkdir()
41
+
42
+ opts = _parser().parse_args(
43
+ [
44
+ str(bids),
45
+ str(derivatives),
46
+ "participant",
47
+ "--app",
48
+ "modelling_plasma",
49
+ "--blood-dir",
50
+ str(blood),
51
+ "--step",
52
+ "weights",
53
+ "--automatic",
54
+ "--no-tty",
55
+ "--dry-run",
56
+ ]
57
+ )
58
+
59
+ command = build_docker_command(opts)
60
+
61
+ self.assertIn("docker", command)
62
+ self.assertIn("--func", command)
63
+ self.assertIn("modelling_plasma", command)
64
+ self.assertIn("--platform", command)
65
+ self.assertIn("linux/amd64", command)
66
+ self.assertIn("--step", command)
67
+ self.assertIn("weights", command)
68
+ self.assertIn(f"{abs_path(bids)}:/data/bids_dir:ro", command)
69
+ self.assertIn(f"{abs_path(derivatives)}:/data/derivatives_dir:rw", command)
70
+ self.assertIn(f"{abs_path(blood)}:/data/blood_dir:ro", command)
71
+ self.assertTrue(derivatives.exists())
72
+
73
+ def test_interactive_mode_is_default(self):
74
+ with tempfile.TemporaryDirectory() as tmpdir:
75
+ root = Path(tmpdir)
76
+ bids = root / "bids"
77
+ derivatives = root / "derivatives"
78
+ bids.mkdir()
79
+
80
+ opts = _parser().parse_args(
81
+ [
82
+ str(bids),
83
+ str(derivatives),
84
+ "participant",
85
+ "--app",
86
+ "regiondef",
87
+ "--no-tty",
88
+ "--dry-run",
89
+ ]
90
+ )
91
+
92
+ command = build_docker_command(opts)
93
+
94
+ mode_index = command.index("--mode")
95
+ self.assertEqual(command[mode_index + 1], "interactive")
96
+ self.assertIn("PETFIT_SHINY_PORT=3838", command)
97
+ self.assertIn("SHINY_SERVER_VERSION=", command)
98
+
99
+ def test_output_dir_can_be_final_petfit_directory(self):
100
+ with tempfile.TemporaryDirectory() as tmpdir:
101
+ root = Path(tmpdir)
102
+ bids = root / "bids"
103
+ derivatives = root / "derivatives"
104
+ petfit_output = derivatives / "petfit"
105
+ bids.mkdir()
106
+
107
+ opts = _parser().parse_args(
108
+ [
109
+ str(bids),
110
+ str(petfit_output),
111
+ "participant",
112
+ "--app",
113
+ "regiondef",
114
+ "--no-tty",
115
+ "--dry-run",
116
+ ]
117
+ )
118
+
119
+ command = build_docker_command(opts)
120
+
121
+ self.assertIn(f"{abs_path(derivatives)}:/data/derivatives_dir:rw", command)
122
+ self.assertNotIn(f"{abs_path(petfit_output)}:/data/derivatives_dir:rw", command)
123
+ self.assertTrue(petfit_output.exists())
124
+
125
+ def test_interactive_mode_maps_port_and_env(self):
126
+ with tempfile.TemporaryDirectory() as tmpdir:
127
+ root = Path(tmpdir)
128
+ bids = root / "bids"
129
+ derivatives = root / "derivatives"
130
+ bids.mkdir()
131
+
132
+ opts = _parser().parse_args(
133
+ [
134
+ str(bids),
135
+ str(derivatives),
136
+ "participant",
137
+ "--mode",
138
+ "interactive",
139
+ "--port",
140
+ "3840",
141
+ "--no-tty",
142
+ "--dry-run",
143
+ ]
144
+ )
145
+
146
+ command = build_docker_command(opts)
147
+
148
+ self.assertIn("-p", command)
149
+ self.assertIn("3840:3840", command)
150
+ self.assertIn("-e", command)
151
+ self.assertIn("PETFIT_SHINY_PORT=3840", command)
152
+
153
+ def test_patch_mounts_local_petfit(self):
154
+ with tempfile.TemporaryDirectory() as tmpdir:
155
+ root = Path(tmpdir)
156
+ bids = root / "bids"
157
+ derivatives = root / "derivatives"
158
+ patch = root / "petfit"
159
+ bids.mkdir()
160
+ patch.mkdir()
161
+
162
+ opts = _parser().parse_args(
163
+ [
164
+ str(bids),
165
+ str(derivatives),
166
+ "participant",
167
+ "--app",
168
+ "regiondef",
169
+ "--patch",
170
+ str(patch),
171
+ "--no-tty",
172
+ "--dry-run",
173
+ ]
174
+ )
175
+
176
+ command = build_docker_command(opts)
177
+
178
+ self.assertIn(f"{abs_path(patch)}:/patch/petfit:ro", command)
179
+
180
+ def test_patch_works_with_shell(self):
181
+ with tempfile.TemporaryDirectory() as tmpdir:
182
+ patch = Path(tmpdir) / "petfit"
183
+ patch.mkdir()
184
+
185
+ opts = _parser().parse_args(
186
+ [
187
+ "--shell",
188
+ "--patch",
189
+ str(patch),
190
+ "--no-tty",
191
+ "--dry-run",
192
+ ]
193
+ )
194
+
195
+ command = build_docker_command(opts)
196
+
197
+ self.assertIn(f"{abs_path(patch)}:/patch/petfit:ro", command)
198
+
199
+ def test_regiondef_rejects_step(self):
200
+ with tempfile.TemporaryDirectory() as tmpdir:
201
+ root = Path(tmpdir)
202
+ bids = root / "bids"
203
+ derivatives = root / "derivatives"
204
+ bids.mkdir()
205
+
206
+ opts = _parser().parse_args(
207
+ [
208
+ str(bids),
209
+ str(derivatives),
210
+ "participant",
211
+ "--app",
212
+ "regiondef",
213
+ "--step",
214
+ "weights",
215
+ ]
216
+ )
217
+
218
+ with self.assertRaises(SystemExit):
219
+ build_docker_command(opts)
220
+
221
+ def test_check_docker_surfaces_daemon_error(self):
222
+ error = subprocess.CalledProcessError(
223
+ 1,
224
+ ["docker", "info"],
225
+ stderr="Cannot connect to the Docker daemon at unix:///tmp/docker.sock. Is the docker daemon running?\n",
226
+ )
227
+
228
+ with patch("petfit_docker.cli.subprocess.run", side_effect=error):
229
+ with patch("sys.stderr", new_callable=StringIO) as stderr:
230
+ with self.assertRaises(SystemExit) as caught:
231
+ check_docker()
232
+
233
+ self.assertIn("Cannot connect to the Docker daemon", stderr.getvalue())
234
+ self.assertIn("Could not detect memory capacity", stderr.getvalue())
235
+ self.assertEqual(str(caught.exception), "Do you have permission to run docker?")
236
+
237
+ def test_main_checks_docker_and_image_before_required_paths(self):
238
+ with patch("petfit_docker.cli.check_docker") as docker_check:
239
+ with patch("petfit_docker.cli.ensure_image_ready") as ensure_image:
240
+ with self.assertRaises(SystemExit) as caught:
241
+ main([])
242
+
243
+ docker_check.assert_called_once()
244
+ ensure_image.assert_called_once_with("mathesong/petfit:latest", platform="linux/amd64")
245
+ self.assertIn("bids_dir", str(caught.exception))
246
+
247
+ def test_main_stops_on_docker_failure_before_image_check(self):
248
+ with patch("petfit_docker.cli.check_docker", side_effect=SystemExit("docker failed")):
249
+ with patch("petfit_docker.cli.ensure_image_ready") as ensure_image:
250
+ with self.assertRaises(SystemExit) as caught:
251
+ main([])
252
+
253
+ ensure_image.assert_not_called()
254
+ self.assertEqual(str(caught.exception), "docker failed")
255
+
256
+ def test_main_dry_run_skips_docker_and_image_checks(self):
257
+ with patch("petfit_docker.cli.check_docker") as docker_check:
258
+ with patch("petfit_docker.cli.ensure_image_ready") as ensure_image:
259
+ with self.assertRaises(SystemExit) as caught:
260
+ main(["--dry-run"])
261
+
262
+ docker_check.assert_not_called()
263
+ ensure_image.assert_not_called()
264
+ self.assertIn("bids_dir", str(caught.exception))
265
+
266
+ def test_main_skip_image_check_still_checks_docker(self):
267
+ with patch("petfit_docker.cli.check_docker") as docker_check:
268
+ with patch("petfit_docker.cli.ensure_image_ready") as ensure_image:
269
+ with self.assertRaises(SystemExit) as caught:
270
+ main(["--skip-image-check"])
271
+
272
+ docker_check.assert_called_once()
273
+ ensure_image.assert_not_called()
274
+ self.assertIn("bids_dir", str(caught.exception))
275
+
276
+ def test_missing_image_prompts_and_defers_pull_to_memory_probe(self):
277
+ with patch("petfit_docker.cli.image_exists", return_value=False):
278
+ with patch("builtins.input", return_value="y"):
279
+ with patch("sys.stdout", new_callable=StringIO) as stdout:
280
+ maybe_pull_image("mathesong/petfit:latest")
281
+
282
+ self.assertIn("Downloading. This may take a while...", stdout.getvalue())
283
+
284
+ def test_check_memory_reads_container_memory(self):
285
+ result = subprocess.CompletedProcess(
286
+ [
287
+ "docker",
288
+ "run",
289
+ "--rm",
290
+ "--platform",
291
+ "linux/amd64",
292
+ "--entrypoint=free",
293
+ "mathesong/petfit:latest",
294
+ "-m",
295
+ ],
296
+ 0,
297
+ stdout=b" total used free\nMem: 15999 1000 14999\n",
298
+ )
299
+
300
+ with patch("petfit_docker.cli.subprocess.run", return_value=result) as run:
301
+ self.assertEqual(check_memory("mathesong/petfit:latest"), 15999)
302
+ run.assert_called_once_with(
303
+ [
304
+ "docker",
305
+ "run",
306
+ "--rm",
307
+ "--platform",
308
+ "linux/amd64",
309
+ "--entrypoint=free",
310
+ "mathesong/petfit:latest",
311
+ "-m",
312
+ ],
313
+ stdout=subprocess.PIPE,
314
+ )
315
+
316
+ def test_platform_can_be_disabled_for_native_multiarch_images(self):
317
+ result = subprocess.CompletedProcess(
318
+ ["docker", "run", "--rm", "--entrypoint=free", "custom/petfit:arm64", "-m"],
319
+ 0,
320
+ stdout=b"Mem: 32000 1000 31000\n",
321
+ )
322
+
323
+ with patch("petfit_docker.cli.subprocess.run", return_value=result) as run:
324
+ self.assertEqual(check_memory("custom/petfit:arm64", platform=None), 32000)
325
+
326
+ run.assert_called_once_with(
327
+ ["docker", "run", "--rm", "--entrypoint=free", "custom/petfit:arm64", "-m"],
328
+ stdout=subprocess.PIPE,
329
+ )
330
+
331
+ def test_failed_memory_probe_exits_without_traceback(self):
332
+ with patch("petfit_docker.cli.maybe_pull_image"):
333
+ with patch("petfit_docker.cli.check_memory", return_value=-1):
334
+ with patch("sys.stderr", new_callable=StringIO) as stderr:
335
+ with self.assertRaises(SystemExit) as caught:
336
+ ensure_image_ready("mathesong/petfit:latest")
337
+
338
+ self.assertIn("Could not detect memory capacity", stderr.getvalue())
339
+ self.assertEqual(str(caught.exception), "Do you have permission to run docker?")
340
+
341
+
342
+ if __name__ == "__main__":
343
+ unittest.main()