dsimaging-admin 0.1.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.
@@ -0,0 +1,3 @@
1
+ """dsimaging-admin: Admin CLI for managing medical imaging datasets in S3/MinIO."""
2
+
3
+ __version__ = "0.1.0"
dsimaging_admin/cli.py ADDED
@@ -0,0 +1,428 @@
1
+ """dsimaging-admin CLI."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import tempfile
7
+ from urllib.parse import quote
8
+
9
+ import click
10
+
11
+ from . import __version__
12
+ from .s3 import create_client, list_datasets, list_objects
13
+ from .manifest import (
14
+ scan_images, scan_s3_images, validate_dataset_id, generate_manifest,
15
+ write_manifest_yaml, build_hash_index, build_sample_manifests,
16
+ build_samples_metadata,
17
+ )
18
+
19
+
20
+ CONFIG_PATH = os.path.expanduser("~/.dsimaging.yaml")
21
+
22
+
23
+ def _load_config() -> dict:
24
+ """Load config from ~/.dsimaging.yaml if it exists."""
25
+ if not os.path.exists(CONFIG_PATH):
26
+ return {}
27
+ try:
28
+ import yaml
29
+ with open(CONFIG_PATH) as f:
30
+ data = yaml.safe_load(f) or {}
31
+ return data.get("default", data)
32
+ except Exception:
33
+ return {}
34
+
35
+
36
+ def _default(envvar: str, config_key: str, fallback: str) -> str:
37
+ """Resolve CLI defaults as env var > config file > fallback."""
38
+ val = os.environ.get(envvar, "")
39
+ if val:
40
+ return val
41
+ cfg = _load_config()
42
+ val = cfg.get(config_key, "")
43
+ if val:
44
+ return str(val)
45
+ return fallback
46
+
47
+
48
+ @click.group()
49
+ @click.version_option(__version__)
50
+ @click.option("--endpoint", default=_default("DSIMAGING_ENDPOINT", "endpoint", "http://127.0.0.1:9000"),
51
+ help="S3/MinIO endpoint URL")
52
+ @click.option("--access-key", default=_default("DSIMAGING_ACCESS_KEY", "access_key", "minioadmin"),
53
+ help="S3 access key")
54
+ @click.option("--secret-key", default=_default("DSIMAGING_SECRET_KEY", "secret_key", "minioadmin123"),
55
+ help="S3 secret key")
56
+ @click.option("--bucket", default=_default("DSIMAGING_BUCKET", "bucket", "imaging-data"),
57
+ help="S3 bucket name")
58
+ @click.option("--region", default=_default("DSIMAGING_REGION", "region", ""),
59
+ help="S3 region (empty for MinIO)")
60
+ @click.pass_context
61
+ def main(ctx, endpoint, access_key, secret_key, bucket, region):
62
+ """Admin CLI for managing medical imaging datasets in S3/MinIO.
63
+
64
+ Configuration priority: CLI flags > environment variables > ~/.dsimaging.yaml
65
+ """
66
+ ctx.ensure_object(dict)
67
+ ctx.obj["s3"] = create_client(endpoint, access_key, secret_key, region)
68
+ ctx.obj["bucket"] = bucket
69
+ ctx.obj["endpoint"] = endpoint
70
+ ctx.obj["access_key"] = access_key
71
+ ctx.obj["secret_key"] = secret_key
72
+ ctx.obj["region"] = region
73
+
74
+
75
+ @main.command("init")
76
+ @click.option("--endpoint", prompt="S3/MinIO endpoint", default="http://127.0.0.1:9000")
77
+ @click.option("--bucket", prompt="Bucket name", default="imaging-data")
78
+ @click.option("--access-key", prompt="Access key", default="minioadmin")
79
+ @click.option("--secret-key", prompt="Secret key", hide_input=True, default="minioadmin123")
80
+ @click.option("--region", prompt="Region (empty for MinIO)", default="")
81
+ def init_config(endpoint, bucket, access_key, secret_key, region):
82
+ """Create ~/.dsimaging.yaml configuration file."""
83
+ import yaml
84
+ config = {
85
+ "default": {
86
+ "endpoint": endpoint,
87
+ "bucket": bucket,
88
+ "access_key": access_key,
89
+ "secret_key": secret_key,
90
+ "region": region,
91
+ }
92
+ }
93
+ with open(CONFIG_PATH, "w") as f:
94
+ yaml.dump(config, f, default_flow_style=False)
95
+ os.chmod(CONFIG_PATH, 0o600)
96
+ click.echo(f"Config saved to {CONFIG_PATH}")
97
+
98
+
99
+ @main.command()
100
+ @click.option("--dataset-id", required=True, help="Dataset identifier")
101
+ @click.option("--source", required=True, type=click.Path(exists=True),
102
+ help="Local directory containing images")
103
+ @click.option("--modality", default="unknown", help="Imaging modality (ct, mri, etc.)")
104
+ @click.option("--opal-url", default=None, help="Opal server URL for resource registration")
105
+ @click.option("--opal-token", default=None, envvar="OPAL_TOKEN", help="Opal auth token")
106
+ @click.option("--opal-user", default=None, envvar="OPAL_USER", help="Opal username")
107
+ @click.option("--opal-password", default=None, envvar="OPAL_PASSWORD", help="Opal password")
108
+ @click.option("--opal-project", default="IMAGING", help="Opal project name")
109
+ @click.option("--opal-resource", default=None, help="Opal resource name (defaults to dataset_id)")
110
+ @click.option("--opal-replace", is_flag=True, help="Replace an existing Opal resource")
111
+ @click.option("--opal-insecure", is_flag=True, help="Disable TLS certificate verification for Opal")
112
+ @click.pass_context
113
+ def publish(ctx, dataset_id, source, modality, opal_url, opal_token, opal_user,
114
+ opal_password, opal_project, opal_resource, opal_replace,
115
+ opal_insecure):
116
+ """Publish a local dataset to S3/MinIO.
117
+
118
+ Scans images, computes hashes, generates manifests and indexes,
119
+ uploads everything to S3, and optionally registers as a DataSHIELD resource.
120
+ """
121
+ try:
122
+ validate_dataset_id(dataset_id)
123
+ except ValueError as e:
124
+ raise click.ClickException(str(e))
125
+
126
+ s3 = ctx.obj["s3"]
127
+ bucket = ctx.obj["bucket"]
128
+ prefix = f"datasets/{dataset_id}"
129
+
130
+ click.echo(f"Publishing dataset: {dataset_id}")
131
+ click.echo(f" Source: {os.path.abspath(source)}")
132
+
133
+ # 1. Scan images
134
+ click.echo(" Scanning images...")
135
+ samples = scan_images(source)
136
+ if not samples:
137
+ click.echo(" ERROR: No image files found.", err=True)
138
+ sys.exit(1)
139
+ click.echo(f" Found {len(samples)} samples")
140
+
141
+ # 2. Upload images
142
+ click.echo(" Uploading images to S3...")
143
+ for sample in samples:
144
+ if sample["source_kind"] == "single_file":
145
+ key = f"{prefix}/source/images/{sample['primary_filename']}"
146
+ s3.upload_file(sample["local_path"], bucket, key)
147
+ elif sample["source_kind"] == "dicom_series":
148
+ base_dir = os.path.dirname(sample["local_path"])
149
+ for f_info in sample["files"]:
150
+ local = os.path.join(base_dir, f_info["path"])
151
+ key = f"{prefix}/source/images/{f_info['path']}"
152
+ s3.upload_file(local, bucket, key)
153
+ click.echo(f" Uploaded to s3://{bucket}/{prefix}/source/images/")
154
+
155
+ # 3. Generate and upload indexes
156
+ _write_dataset_artifacts(s3, bucket, prefix, dataset_id, modality, samples)
157
+
158
+ resource_url = _resource_url(dataset_id, ctx.obj["endpoint"], bucket,
159
+ prefix, ctx.obj.get("region", ""))
160
+ if opal_url:
161
+ click.echo(" Registering Opal resource...")
162
+ _register_opal_resource(
163
+ opal_url=opal_url,
164
+ project=opal_project,
165
+ name=opal_resource or dataset_id,
166
+ resource_url=resource_url,
167
+ access_key=ctx.obj["access_key"],
168
+ secret_key=ctx.obj["secret_key"],
169
+ token=opal_token,
170
+ username=opal_user,
171
+ password=opal_password,
172
+ replace=opal_replace,
173
+ verify=not opal_insecure,
174
+ )
175
+
176
+ # 4. Summary
177
+ endpoint = ctx.obj["endpoint"]
178
+ click.echo("")
179
+ click.echo(click.style(f"Dataset '{dataset_id}' published!", fg="green", bold=True))
180
+ click.echo(f" Location: s3://{bucket}/{prefix}/")
181
+ click.echo(f" Samples: {len(samples)}")
182
+ click.echo("")
183
+ click.echo(" To use in R:")
184
+ click.echo(f' ds.radiomics.extract(conns, dataset_id = "{dataset_id}", ...)')
185
+ click.echo("")
186
+ click.echo(" DataSHIELD resource config:")
187
+ click.echo(f" URL: {resource_url}")
188
+ click.echo(f" Endpoint: {endpoint}")
189
+ click.echo(f" Bucket: {bucket}")
190
+ click.echo(f" Prefix: {prefix}")
191
+
192
+
193
+ @main.command("list")
194
+ @click.pass_context
195
+ def list_cmd(ctx):
196
+ """List published datasets."""
197
+ datasets = list_datasets(ctx.obj["s3"], ctx.obj["bucket"])
198
+ if not datasets:
199
+ click.echo("No datasets found.")
200
+ return
201
+ click.echo(f"Datasets in s3://{ctx.obj['bucket']}/datasets/:")
202
+ for ds in datasets:
203
+ status_color = "green" if ds["status"] == "published" else "yellow"
204
+ click.echo(f" {ds['dataset_id']} [{click.style(ds['status'], fg=status_color)}]")
205
+
206
+
207
+ @main.command()
208
+ @click.pass_context
209
+ def doctor(ctx):
210
+ """Check system health."""
211
+ s3 = ctx.obj["s3"]
212
+ bucket = ctx.obj["bucket"]
213
+
214
+ click.echo("dsimaging-admin health check")
215
+ click.echo("=" * 40)
216
+
217
+ # 1. Connectivity
218
+ click.echo("\n1. S3 connectivity:")
219
+ try:
220
+ s3.list_buckets()
221
+ click.echo(click.style(" OK", fg="green") + ": Connected")
222
+ except Exception as e:
223
+ click.echo(click.style(" FAIL", fg="red") + f": {e}")
224
+ return
225
+
226
+ # 2. Bucket
227
+ click.echo(f"\n2. Bucket '{bucket}':")
228
+ try:
229
+ s3.head_bucket(Bucket=bucket)
230
+ click.echo(click.style(" OK", fg="green") + ": Exists")
231
+ except Exception:
232
+ click.echo(click.style(" FAIL", fg="red") + ": Not found")
233
+ click.echo(f" Create with: aws s3 mb s3://{bucket} --endpoint-url ...")
234
+ return
235
+
236
+ # 3. Versioning
237
+ click.echo("\n3. Versioning:")
238
+ try:
239
+ resp = s3.get_bucket_versioning(Bucket=bucket)
240
+ status = resp.get("Status", "Disabled")
241
+ if status == "Enabled":
242
+ click.echo(click.style(" OK", fg="green") + ": Enabled")
243
+ else:
244
+ click.echo(click.style(" WARN", fg="yellow") + f": {status}")
245
+ except Exception as e:
246
+ click.echo(click.style(" FAIL", fg="red") + f": {e}")
247
+
248
+ # 4. Datasets
249
+ click.echo("\n4. Datasets:")
250
+ datasets = list_datasets(s3, bucket)
251
+ for ds in datasets:
252
+ color = "green" if ds["status"] == "published" else "yellow"
253
+ click.echo(f" {click.style(ds['status'].upper(), fg=color)}: {ds['dataset_id']}")
254
+ if not datasets:
255
+ click.echo(" (none)")
256
+
257
+ click.echo(f"\n5. Summary: {len(datasets)} dataset(s)")
258
+
259
+
260
+ @main.command()
261
+ @click.option("--dataset-id", required=True)
262
+ @click.pass_context
263
+ def rescan(ctx, dataset_id):
264
+ """Re-scan and update indexes for a dataset."""
265
+ s3 = ctx.obj["s3"]
266
+ bucket = ctx.obj["bucket"]
267
+ prefix = f"datasets/{dataset_id}"
268
+
269
+ try:
270
+ validate_dataset_id(dataset_id)
271
+ except ValueError as e:
272
+ raise click.ClickException(str(e))
273
+
274
+ click.echo(f"Rescanning: {dataset_id}")
275
+
276
+ objects = list_objects(s3, bucket, f"{prefix}/source/images/")
277
+ click.echo(f" Found {len(objects)} objects under source/images/")
278
+
279
+ click.echo(" Computing hashes and rebuilding dataset artifacts...")
280
+ samples = scan_s3_images(s3, bucket, prefix, objects)
281
+ if not samples:
282
+ raise click.ClickException("No supported image objects found.")
283
+
284
+ modality = _existing_modality(s3, bucket, prefix, fallback="unknown")
285
+ _write_dataset_artifacts(s3, bucket, prefix, dataset_id, modality, samples)
286
+
287
+ click.echo(f" Index updated: {len(samples)} samples")
288
+ click.echo(click.style("Rescan complete.", fg="green"))
289
+
290
+
291
+ def _write_dataset_artifacts(s3, bucket: str, prefix: str, dataset_id: str,
292
+ modality: str, samples: list[dict]) -> None:
293
+ import pyarrow.parquet as pq
294
+
295
+ with tempfile.TemporaryDirectory() as tmpdir:
296
+ click.echo(" Building content hash index...")
297
+ idx = build_hash_index(samples, bucket, prefix)
298
+ idx_path = os.path.join(tmpdir, "content_hash_index.parquet")
299
+ pq.write_table(idx, idx_path)
300
+ s3.upload_file(idx_path, bucket, f"{prefix}/indexes/content_hash_index.parquet")
301
+
302
+ click.echo(" Building sample manifests...")
303
+ sm = build_sample_manifests(samples)
304
+ sm_path = os.path.join(tmpdir, "sample_manifests.parquet")
305
+ pq.write_table(sm, sm_path)
306
+ s3.upload_file(sm_path, bucket, f"{prefix}/metadata/sample_manifests.parquet")
307
+
308
+ click.echo(" Building samples metadata...")
309
+ meta = build_samples_metadata(samples)
310
+ meta_path = os.path.join(tmpdir, "samples.parquet")
311
+ pq.write_table(meta, meta_path)
312
+ s3.upload_file(meta_path, bucket, f"{prefix}/metadata/samples.parquet")
313
+
314
+ click.echo(" Building manifest...")
315
+ manifest = generate_manifest(dataset_id, bucket, prefix, modality)
316
+ manifest_path = os.path.join(tmpdir, "manifest.yaml")
317
+ write_manifest_yaml(manifest, manifest_path)
318
+ s3.upload_file(manifest_path, bucket, f"{prefix}/manifest.yaml")
319
+
320
+
321
+ def _existing_modality(s3, bucket: str, prefix: str, fallback: str) -> str:
322
+ try:
323
+ import yaml
324
+ response = s3.get_object(Bucket=bucket, Key=f"{prefix}/manifest.yaml")
325
+ body = response["Body"]
326
+ try:
327
+ manifest = yaml.safe_load(body.read()) or {}
328
+ finally:
329
+ body.close()
330
+ return manifest.get("modality") or fallback
331
+ except Exception:
332
+ return fallback
333
+
334
+
335
+ def _resource_url(dataset_id: str, endpoint: str, bucket: str, prefix: str,
336
+ region: str = "") -> str:
337
+ parts = [
338
+ f"endpoint={quote(endpoint, safe='')}",
339
+ f"bucket={quote(bucket, safe='')}",
340
+ f"prefix={quote(prefix, safe='')}",
341
+ ]
342
+ if region:
343
+ parts.append(f"region={quote(region, safe='')}")
344
+ return f"imaging+dataset://{dataset_id}?" + "&".join(parts)
345
+
346
+
347
+ def _register_opal_resource(opal_url: str, project: str, name: str,
348
+ resource_url: str, access_key: str,
349
+ secret_key: str, token: str | None,
350
+ username: str | None, password: str | None,
351
+ replace: bool, verify: bool) -> None:
352
+ try:
353
+ import requests
354
+ except ImportError as e:
355
+ raise click.ClickException("requests is required for Opal registration") from e
356
+
357
+ if not verify:
358
+ import urllib3
359
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
360
+
361
+ session = requests.Session()
362
+ session.verify = verify
363
+ headers = {"Accept": "application/json"}
364
+ if token:
365
+ headers["X-Opal-Auth"] = token
366
+ elif username and password:
367
+ session.auth = (username, password)
368
+ else:
369
+ raise click.ClickException(
370
+ "Provide --opal-token or --opal-user/--opal-password for Opal registration."
371
+ )
372
+
373
+ base = opal_url.rstrip("/")
374
+
375
+ def request(method: str, path: str, **kwargs):
376
+ response = session.request(method, f"{base}/ws/{path.lstrip('/')}",
377
+ headers=headers, timeout=30, **kwargs)
378
+ if response.status_code >= 400:
379
+ raise click.ClickException(
380
+ f"Opal {method} {path} failed with {response.status_code}: "
381
+ f"{response.text[:500]}"
382
+ )
383
+ return response
384
+
385
+ project_resp = session.get(f"{base}/ws/project/{project}", headers=headers,
386
+ timeout=30, verify=verify)
387
+ if project_resp.status_code == 404:
388
+ request("POST", "projects", json={"name": project, "title": project})
389
+ elif project_resp.status_code >= 400:
390
+ raise click.ClickException(
391
+ f"Opal project check failed with {project_resp.status_code}: "
392
+ f"{project_resp.text[:500]}"
393
+ )
394
+
395
+ resource_resp = session.get(f"{base}/ws/project/{project}/resource/{name}",
396
+ headers=headers, timeout=30, verify=verify)
397
+ if resource_resp.status_code == 200:
398
+ if not replace:
399
+ click.echo(f" Opal resource {project}.{name} already exists; keeping it")
400
+ return
401
+ request("DELETE", f"project/{project}/resource/{name}")
402
+ elif resource_resp.status_code != 404:
403
+ raise click.ClickException(
404
+ f"Opal resource check failed with {resource_resp.status_code}: "
405
+ f"{resource_resp.text[:500]}"
406
+ )
407
+
408
+ parameters = {"url": resource_url, "format": None, "_package": None}
409
+ credentials = {
410
+ "identity": access_key,
411
+ "identifier": access_key,
412
+ "secret": secret_key,
413
+ }
414
+ payload = {
415
+ "provider": "resourcer",
416
+ "factory": "default",
417
+ "project": project,
418
+ "name": name,
419
+ "description": "dsimaging-store dataset",
420
+ "parameters": json.dumps(parameters),
421
+ "credentials": json.dumps(credentials),
422
+ }
423
+ request("POST", f"project/{project}/resources", json=payload)
424
+ click.echo(click.style(f" Registered Opal resource {project}.{name}", fg="green"))
425
+
426
+
427
+ if __name__ == "__main__":
428
+ main()
@@ -0,0 +1,35 @@
1
+ """Content hashing utilities."""
2
+
3
+ import hashlib
4
+ import os
5
+
6
+ HASH_CHUNK = 65536
7
+ IMAGE_EXTENSIONS = frozenset({
8
+ ".nii.gz", ".nii", ".nrrd", ".mha", ".mhd", ".dcm",
9
+ ".svs", ".tif", ".tiff", ".png", ".jpg",
10
+ })
11
+
12
+
13
+ def sha256_file(path: str) -> str:
14
+ """Streaming SHA-256 of a file."""
15
+ h = hashlib.sha256()
16
+ with open(path, "rb") as f:
17
+ while True:
18
+ chunk = f.read(HASH_CHUNK)
19
+ if not chunk:
20
+ break
21
+ h.update(chunk)
22
+ return h.hexdigest()
23
+
24
+
25
+ def is_image_file(filename: str) -> bool:
26
+ lower = filename.lower()
27
+ return any(lower.endswith(ext) for ext in IMAGE_EXTENSIONS)
28
+
29
+
30
+ def sample_id_from_filename(filename: str) -> str:
31
+ """Strip known extensions to get sample_id."""
32
+ for ext in sorted(IMAGE_EXTENSIONS, key=len, reverse=True):
33
+ if filename.lower().endswith(ext):
34
+ return filename[: -len(ext)]
35
+ return os.path.splitext(filename)[0]
@@ -0,0 +1,256 @@
1
+ """Manifest and index generation."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import time
8
+
9
+ import pyarrow as pa
10
+ import yaml
11
+
12
+ from .hashing import sha256_file, is_image_file, sample_id_from_filename
13
+
14
+ DATASET_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
15
+
16
+
17
+ def validate_dataset_id(dataset_id: str) -> str:
18
+ """Validate and return a dsimaging dataset identifier."""
19
+ if not DATASET_ID_RE.match(dataset_id or ""):
20
+ raise ValueError(
21
+ "dataset_id must match ^[a-z0-9][a-z0-9._-]*$ "
22
+ "(lowercase letters, digits, dot, underscore and dash)"
23
+ )
24
+ return dataset_id
25
+
26
+
27
+ def scan_images(source_dir: str) -> list[dict]:
28
+ """Scan a local directory for image files and compute hashes.
29
+
30
+ Returns a list of sample dicts with:
31
+ sample_id, source_kind, primary_filename, files, content_hash, size, local_path
32
+ """
33
+ samples = []
34
+ images_dir = _find_images_dir(source_dir)
35
+ if not images_dir:
36
+ return samples
37
+
38
+ for entry in sorted(os.listdir(images_dir)):
39
+ filepath = os.path.join(images_dir, entry)
40
+
41
+ if os.path.isfile(filepath) and is_image_file(entry):
42
+ samples.append({
43
+ "sample_id": sample_id_from_filename(entry),
44
+ "source_kind": "single_file",
45
+ "primary_filename": entry,
46
+ "uri_path": entry,
47
+ "files": [{"path": entry, "role": "primary"}],
48
+ "content_hash": sha256_file(filepath),
49
+ "size": os.path.getsize(filepath),
50
+ "local_path": filepath,
51
+ })
52
+ elif os.path.isdir(filepath):
53
+ dcm_files = sorted(
54
+ f for f in os.listdir(filepath) if f.lower().endswith(".dcm")
55
+ )
56
+ if dcm_files:
57
+ h = hashlib.sha256()
58
+ total_size = 0
59
+ for dcm in dcm_files:
60
+ dcm_path = os.path.join(filepath, dcm)
61
+ h.update(sha256_file(dcm_path).encode())
62
+ total_size += os.path.getsize(dcm_path)
63
+ samples.append({
64
+ "sample_id": entry,
65
+ "source_kind": "dicom_series",
66
+ "primary_filename": None,
67
+ "uri_path": f"{entry}/",
68
+ "files": [{"path": f"{entry}/{f}", "role": "slice"} for f in dcm_files],
69
+ "content_hash": h.hexdigest(),
70
+ "size": total_size,
71
+ "local_path": filepath,
72
+ })
73
+
74
+ return samples
75
+
76
+
77
+ def scan_s3_images(s3, bucket: str, prefix: str, objects: list[dict]) -> list[dict]:
78
+ """Build sample records from S3 objects under ``<prefix>/source/images``.
79
+
80
+ Hashes are computed by streaming objects through temporary files. Single-file
81
+ samples and one-directory DICOM series use the same sample model as
82
+ ``scan_images()`` so publish, rescan and the store controller converge on
83
+ identical parquet schemas.
84
+ """
85
+ root = f"{prefix.rstrip('/')}/source/images/"
86
+ single_files = []
87
+ dicom_groups = {}
88
+
89
+ for obj in objects:
90
+ key = obj["key"]
91
+ if not key.startswith(root):
92
+ continue
93
+ rel = key[len(root):]
94
+ if not rel or rel.endswith("/"):
95
+ continue
96
+ filename = rel.rsplit("/", 1)[-1]
97
+ if not is_image_file(filename):
98
+ continue
99
+ if "/" in rel and filename.lower().endswith(".dcm"):
100
+ sample_id = rel.split("/", 1)[0]
101
+ dicom_groups.setdefault(sample_id, []).append((rel, obj))
102
+ else:
103
+ single_files.append((rel, obj))
104
+
105
+ samples = []
106
+ for rel, obj in sorted(single_files, key=lambda item: item[0]):
107
+ content_hash = _sha256_s3_object(s3, bucket, obj["key"])
108
+ filename = rel.rsplit("/", 1)[-1]
109
+ samples.append({
110
+ "sample_id": sample_id_from_filename(filename),
111
+ "source_kind": "single_file",
112
+ "primary_filename": filename,
113
+ "uri_path": rel,
114
+ "files": [{"path": rel, "role": "primary"}],
115
+ "content_hash": content_hash,
116
+ "size": int(obj.get("size", 0)),
117
+ "last_modified": obj.get("last_modified"),
118
+ "version_id": obj.get("version_id"),
119
+ "etag": obj.get("etag"),
120
+ })
121
+
122
+ for sample_id in sorted(dicom_groups):
123
+ h = hashlib.sha256()
124
+ total_size = 0
125
+ files = []
126
+ last_modified = None
127
+ etags = []
128
+ for rel, obj in sorted(dicom_groups[sample_id], key=lambda item: item[0]):
129
+ content_hash = _sha256_s3_object(s3, bucket, obj["key"])
130
+ h.update(content_hash.encode())
131
+ total_size += int(obj.get("size", 0))
132
+ last_modified = obj.get("last_modified") or last_modified
133
+ if obj.get("etag"):
134
+ etags.append(obj["etag"])
135
+ files.append({"path": rel, "role": "slice"})
136
+ samples.append({
137
+ "sample_id": sample_id,
138
+ "source_kind": "dicom_series",
139
+ "primary_filename": None,
140
+ "uri_path": f"{sample_id}/",
141
+ "files": files,
142
+ "content_hash": h.hexdigest(),
143
+ "size": total_size,
144
+ "last_modified": last_modified,
145
+ "version_id": None,
146
+ "etag": ",".join(etags) if etags else None,
147
+ })
148
+
149
+ return sorted(samples, key=lambda sample: sample["sample_id"])
150
+
151
+
152
+ def generate_manifest(dataset_id: str, bucket: str, prefix: str,
153
+ modality: str = "unknown") -> dict:
154
+ """Generate a manifest dict for a dataset."""
155
+ validate_dataset_id(dataset_id)
156
+ return {
157
+ "schema_version": 1,
158
+ "dataset_id": dataset_id,
159
+ "modality": modality,
160
+ "assets": {
161
+ "images": {
162
+ "uri": f"s3://{bucket}/{prefix}/source/images/",
163
+ "kind": "image_root",
164
+ },
165
+ },
166
+ "metadata": {
167
+ "uri": f"s3://{bucket}/{prefix}/metadata/samples.parquet",
168
+ "format": "parquet",
169
+ },
170
+ "content_hash_index": {
171
+ "uri": f"s3://{bucket}/{prefix}/indexes/content_hash_index.parquet",
172
+ "format": "parquet",
173
+ },
174
+ "sample_manifests": {
175
+ "uri": f"s3://{bucket}/{prefix}/metadata/sample_manifests.parquet",
176
+ "format": "parquet",
177
+ },
178
+ }
179
+
180
+
181
+ def write_manifest_yaml(manifest: dict, path: str):
182
+ with open(path, "w") as f:
183
+ yaml.dump(manifest, f, default_flow_style=False, sort_keys=False)
184
+
185
+
186
+ def build_hash_index(samples: list[dict], bucket: str, prefix: str) -> pa.Table:
187
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ")
188
+ return pa.table({
189
+ "sample_id": [s["sample_id"] for s in samples],
190
+ "uri": [
191
+ f"s3://{bucket}/{prefix}/source/images/{s.get('uri_path') or s['primary_filename']}"
192
+ if s.get("uri_path") or s["primary_filename"]
193
+ else f"s3://{bucket}/{prefix}/source/images/{s['sample_id']}/"
194
+ for s in samples
195
+ ],
196
+ "content_hash": [s["content_hash"] for s in samples],
197
+ "size": pa.array([s["size"] for s in samples], type=pa.int64()),
198
+ "last_modified": [s.get("last_modified") or now for s in samples],
199
+ "version_id": pa.array([s.get("version_id") for s in samples], type=pa.string()),
200
+ "etag": pa.array([s.get("etag") for s in samples], type=pa.string()),
201
+ "source_kind": [s["source_kind"] for s in samples],
202
+ })
203
+
204
+
205
+ def build_sample_manifests(samples: list[dict]) -> pa.Table:
206
+ return pa.table({
207
+ "sample_id": [s["sample_id"] for s in samples],
208
+ "source_kind": [s["source_kind"] for s in samples],
209
+ "primary_uri": pa.array(
210
+ [s["primary_filename"] for s in samples], type=pa.string()
211
+ ),
212
+ "files_json": [json.dumps(s["files"]) for s in samples],
213
+ "content_hash": [s["content_hash"] for s in samples],
214
+ "n_files": pa.array([len(s["files"]) for s in samples], type=pa.int32()),
215
+ })
216
+
217
+
218
+ def build_samples_metadata(samples: list[dict]) -> pa.Table:
219
+ return pa.table({
220
+ "sample_id": [s["sample_id"] for s in samples],
221
+ "source_kind": [s["source_kind"] for s in samples],
222
+ "n_files": pa.array([len(s["files"]) for s in samples], type=pa.int32()),
223
+ })
224
+
225
+
226
+ def _find_images_dir(source_dir: str) -> str | None:
227
+ """Find the directory containing image files."""
228
+ for candidate in ["images", "source/images", "."]:
229
+ d = os.path.join(source_dir, candidate)
230
+ if os.path.isdir(d) and _contains_supported_images(d):
231
+ return d
232
+ return None
233
+
234
+
235
+ def _contains_supported_images(directory: str) -> bool:
236
+ for entry in os.listdir(directory):
237
+ path = os.path.join(directory, entry)
238
+ if os.path.isfile(path) and is_image_file(entry):
239
+ return True
240
+ if os.path.isdir(path):
241
+ if any(f.lower().endswith(".dcm") for f in os.listdir(path)):
242
+ return True
243
+ return False
244
+
245
+
246
+ def _sha256_s3_object(s3, bucket: str, key: str) -> str:
247
+ h = hashlib.sha256()
248
+ response = s3.get_object(Bucket=bucket, Key=key)
249
+ body = response["Body"]
250
+ try:
251
+ for chunk in iter(lambda: body.read(65536), b""):
252
+ if chunk:
253
+ h.update(chunk)
254
+ finally:
255
+ body.close()
256
+ return h.hexdigest()
dsimaging_admin/s3.py ADDED
@@ -0,0 +1,64 @@
1
+ """S3/MinIO client wrapper."""
2
+
3
+ import boto3
4
+ from botocore.config import Config
5
+
6
+
7
+ def create_client(endpoint: str, access_key: str, secret_key: str,
8
+ region: str = "") -> boto3.client:
9
+ """Create a boto3 S3 client.
10
+
11
+ For MinIO (IP/localhost endpoints), region defaults to "us-east-1"
12
+ as a dummy value that boto3 accepts.
13
+ """
14
+ effective_region = region if region else "us-east-1"
15
+ kwargs = {"region_name": effective_region}
16
+ if endpoint:
17
+ kwargs["endpoint_url"] = endpoint
18
+
19
+ return boto3.client(
20
+ "s3",
21
+ aws_access_key_id=access_key,
22
+ aws_secret_access_key=secret_key,
23
+ config=Config(signature_version="s3v4"),
24
+ **kwargs,
25
+ )
26
+
27
+
28
+ def list_datasets(s3, bucket: str) -> list[dict]:
29
+ """List published datasets in the bucket."""
30
+ datasets = []
31
+ paginator = s3.get_paginator("list_objects_v2")
32
+ for page in paginator.paginate(Bucket=bucket, Prefix="datasets/", Delimiter="/"):
33
+ for cp in page.get("CommonPrefixes", []):
34
+ ds_id = cp["Prefix"].strip("/").split("/")[-1]
35
+ has_manifest = _object_exists(s3, bucket, f"datasets/{ds_id}/manifest.yaml")
36
+ datasets.append({
37
+ "dataset_id": ds_id,
38
+ "status": "published" if has_manifest else "incomplete",
39
+ })
40
+ return datasets
41
+
42
+
43
+ def list_objects(s3, bucket: str, prefix: str) -> list[dict]:
44
+ """List all objects under a prefix (with pagination)."""
45
+ objects = []
46
+ paginator = s3.get_paginator("list_objects_v2")
47
+ for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
48
+ for obj in page.get("Contents", []):
49
+ objects.append({
50
+ "key": obj["Key"],
51
+ "size": obj["Size"],
52
+ "last_modified": obj["LastModified"].isoformat(),
53
+ "etag": obj.get("ETag", "").strip('"') or None,
54
+ "version_id": None,
55
+ })
56
+ return objects
57
+
58
+
59
+ def _object_exists(s3, bucket: str, key: str) -> bool:
60
+ try:
61
+ s3.head_object(Bucket=bucket, Key=key)
62
+ return True
63
+ except Exception:
64
+ return False
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: dsimaging-admin
3
+ Version: 0.1.0
4
+ Summary: Admin CLI for managing medical imaging datasets in S3/MinIO for DataSHIELD
5
+ Project-URL: Homepage, https://davidsarrat.com
6
+ Project-URL: Repository, https://github.com/isglobal-brge/dsimaging-admin
7
+ Project-URL: Issues, https://github.com/isglobal-brge/dsimaging-admin/issues
8
+ Project-URL: Documentation, https://github.com/isglobal-brge/dsimaging-admin#readme
9
+ Author-email: David Sarrat Gonzalez <david.sarrat@isglobal.org>, Juan R Gonzalez <juanr.gonzalez@isglobal.org>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: datashield,dicom,medical-imaging,minio,radiomics,s3
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Healthcare Industry
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
25
+ Classifier: Topic :: System :: Archiving
26
+ Classifier: Topic :: System :: Distributed Computing
27
+ Requires-Python: >=3.9
28
+ Requires-Dist: boto3>=1.28.0
29
+ Requires-Dist: click>=8.0.0
30
+ Requires-Dist: pyarrow>=14.0.0
31
+ Requires-Dist: pyyaml>=6.0
32
+ Requires-Dist: requests>=2.31.0
33
+ Description-Content-Type: text/markdown
34
+
35
+ # dsimaging-admin
36
+
37
+ Admin CLI for managing medical imaging datasets in S3/MinIO for DataSHIELD.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install dsimaging-admin
43
+ ```
44
+
45
+ ## Quick start
46
+
47
+ ```bash
48
+ # 1. Publish a local dataset to MinIO
49
+ dsimaging-admin --endpoint http://minio:9000 publish \
50
+ --dataset-id lung_ct_v1 \
51
+ --source /data/lung_ct \
52
+ --modality ct
53
+
54
+ # 2. List published datasets
55
+ dsimaging-admin --endpoint http://minio:9000 list
56
+
57
+ # 3. Check health
58
+ dsimaging-admin --endpoint http://minio:9000 doctor
59
+
60
+ # 4. Re-scan after adding images
61
+ dsimaging-admin --endpoint http://minio:9000 rescan --dataset-id lung_ct_v1
62
+ ```
63
+
64
+ For repeated use, create `~/.dsimaging.yaml` once:
65
+
66
+ ```bash
67
+ dsimaging-admin init
68
+ ```
69
+
70
+ ## What `publish` does
71
+
72
+ 1. Scans your local image directory (NIfTI, DICOM, NRRD, etc.)
73
+ 2. Computes SHA-256 content hash for every file
74
+ 3. Uploads images to `s3://<bucket>/datasets/<dataset_id>/source/images/`
75
+ 4. Generates and uploads:
76
+ - `manifest.yaml` (dataset descriptor)
77
+ - `content_hash_index.parquet` (dedup index)
78
+ - `sample_manifests.parquet` (multi-file sample support)
79
+ - `samples.parquet` (basic metadata)
80
+ 5. Optionally registers the dataset as an Opal resource
81
+ 6. Prints the DataSHIELD resource configuration
82
+
83
+ `publish` can register the resource directly in Opal:
84
+
85
+ ```bash
86
+ dsimaging-admin --endpoint http://localhost:9000 publish \
87
+ --dataset-id lung_ct_v1 \
88
+ --source /data/lung_ct \
89
+ --modality ct \
90
+ --opal-url https://opal.example.org \
91
+ --opal-user administrator \
92
+ --opal-password "$OPAL_PASSWORD" \
93
+ --opal-project IMAGING
94
+ ```
95
+
96
+ ## Environment variables
97
+
98
+ | Variable | Default | Description |
99
+ |---|---|---|
100
+ | `DSIMAGING_ENDPOINT` | `http://127.0.0.1:9000` | S3/MinIO endpoint |
101
+ | `DSIMAGING_ACCESS_KEY` | `minioadmin` | S3 access key |
102
+ | `DSIMAGING_SECRET_KEY` | `minioadmin123` | S3 secret key |
103
+ | `DSIMAGING_BUCKET` | `imaging-data` | Bucket name |
104
+ | `DSIMAGING_REGION` | (empty) | S3 region |
105
+ | `OPAL_TOKEN` | (empty) | Optional Opal token for `publish --opal-url` |
106
+ | `OPAL_USER` | (empty) | Optional Opal username for `publish --opal-url` |
107
+ | `OPAL_PASSWORD` | (empty) | Optional Opal password for `publish --opal-url` |
108
+
109
+ ## Rescan
110
+
111
+ `rescan --dataset-id <id>` rebuilds `content_hash_index.parquet`,
112
+ `sample_manifests.parquet`, `samples.parquet` and `manifest.yaml` from the
113
+ current contents of `source/images/`. This is the same contract maintained
114
+ automatically by the dsimaging-store controller when MinIO webhooks are enabled.
115
+
116
+ ## Dataset layout in S3
117
+
118
+ ```
119
+ s3://<bucket>/datasets/<dataset_id>/
120
+ manifest.yaml
121
+ metadata/
122
+ samples.parquet
123
+ sample_manifests.parquet
124
+ indexes/
125
+ content_hash_index.parquet
126
+ source/
127
+ images/
128
+ derived/
129
+ qc/
130
+ ```
@@ -0,0 +1,10 @@
1
+ dsimaging_admin/__init__.py,sha256=8jQ0qfys9B9xcCqYliau-5rlQko-zKVqSXWrRtH7iwo,107
2
+ dsimaging_admin/cli.py,sha256=GcQZucbX6BECasEt0bWPqNqRc5kbc_o5HRODNNmaIB8,16143
3
+ dsimaging_admin/hashing.py,sha256=Wso0w-RSRmmjllUURL9DX89lE5BgbRNHOVbUVgCrVuc,937
4
+ dsimaging_admin/manifest.py,sha256=WeG5wLfUAN5p5_81fwQohvHSJx7JPA4wjfj0TjMYI0M,9289
5
+ dsimaging_admin/s3.py,sha256=qsu_Jl6G07BX8pe4RhgKPfLt437TM6_EFNQZp4u87LA,2124
6
+ dsimaging_admin-0.1.0.dist-info/METADATA,sha256=jYixpn0EGzYkIR-WGCmQqjv8UJ-ULLtoduefN3SHi0g,4250
7
+ dsimaging_admin-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ dsimaging_admin-0.1.0.dist-info/entry_points.txt,sha256=3yKyhItp2qS3UPIDZs6rNK3KWXLOvgBqQYjE9blPS2c,61
9
+ dsimaging_admin-0.1.0.dist-info/licenses/LICENSE,sha256=MbaF7Y7CDdVsRbfLvBjhoAcHsPtGIvuv3CA5tLxrisg,1107
10
+ dsimaging_admin-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dsimaging-admin = dsimaging_admin.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Sarrat González, Juan R González, ISGlobal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.