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.
- dsimaging_admin/__init__.py +3 -0
- dsimaging_admin/cli.py +428 -0
- dsimaging_admin/hashing.py +35 -0
- dsimaging_admin/manifest.py +256 -0
- dsimaging_admin/s3.py +64 -0
- dsimaging_admin-0.1.0.dist-info/METADATA +130 -0
- dsimaging_admin-0.1.0.dist-info/RECORD +10 -0
- dsimaging_admin-0.1.0.dist-info/WHEEL +4 -0
- dsimaging_admin-0.1.0.dist-info/entry_points.txt +2 -0
- dsimaging_admin-0.1.0.dist-info/licenses/LICENSE +21 -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,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.
|