meditopia-cli 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.
- meditopia/__init__.py +0 -0
- meditopia/cli.py +413 -0
- meditopia/client.py +116 -0
- meditopia/config.py +42 -0
- meditopia/uploader.py +32 -0
- meditopia_cli-0.1.0.dist-info/METADATA +47 -0
- meditopia_cli-0.1.0.dist-info/RECORD +10 -0
- meditopia_cli-0.1.0.dist-info/WHEEL +5 -0
- meditopia_cli-0.1.0.dist-info/entry_points.txt +2 -0
- meditopia_cli-0.1.0.dist-info/top_level.txt +1 -0
meditopia/__init__.py
ADDED
|
File without changes
|
meditopia/cli.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""
|
|
2
|
+
medi — Meditopia CLI
|
|
3
|
+
|
|
4
|
+
Usage examples:
|
|
5
|
+
medi login --token mtc_xxxx
|
|
6
|
+
medi whoami
|
|
7
|
+
medi add dataset ./my-data --to pedram/projects/radiology/chest-xray --privacy public --modality imaging
|
|
8
|
+
medi add model ./my-model --to pedram/projects/radiology/chexnet --privacy public --task radiology --framework pytorch
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from rich import print as rprint
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
from rich import box
|
|
20
|
+
|
|
21
|
+
from . import config, client
|
|
22
|
+
from .uploader import collect_files
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="medi",
|
|
26
|
+
help="Meditopia CLI — push models and datasets from your terminal.",
|
|
27
|
+
add_completion=False,
|
|
28
|
+
)
|
|
29
|
+
add_app = typer.Typer(help="Upload a model or dataset to Meditopia.")
|
|
30
|
+
download_app = typer.Typer(help="Download a model or dataset from Meditopia.")
|
|
31
|
+
app.add_typer(add_app, name="add")
|
|
32
|
+
app.add_typer(download_app, name="download")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_to(to: str) -> tuple[str, str, str]:
|
|
39
|
+
"""
|
|
40
|
+
Split 'owner/collection/path/name' into (owner, collection_path, slug).
|
|
41
|
+
The last segment is the slug; everything between owner and slug is collection_path.
|
|
42
|
+
e.g. 'pedram/projects/radiology/chest-xray' → ('pedram', 'projects/radiology', 'chest-xray')
|
|
43
|
+
'pedram/chest-xray' → ('pedram', '', 'chest-xray')
|
|
44
|
+
"""
|
|
45
|
+
parts = to.strip("/").split("/")
|
|
46
|
+
if len(parts) < 2:
|
|
47
|
+
rprint(f"[red]--to must be at least owner/name, got: {to!r}[/red]")
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
owner = parts[0]
|
|
50
|
+
slug = parts[-1]
|
|
51
|
+
collection_path = "/".join(parts[1:-1])
|
|
52
|
+
return owner, collection_path, slug
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _abort_if_error(r: dict) -> None:
|
|
56
|
+
if "detail" in r or "error" in r:
|
|
57
|
+
rprint(f"[red]API error:[/red] {r.get('detail') or r.get('error')}")
|
|
58
|
+
raise typer.Exit(1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── login ─────────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command()
|
|
65
|
+
def login(
|
|
66
|
+
token: str = typer.Option(..., help="Your Meditopia API token (starts with mtc_)"),
|
|
67
|
+
api_url: str = typer.Option(config.DEFAULT_API_URL, help="API base URL"),
|
|
68
|
+
):
|
|
69
|
+
"""Save your API token and verify it against the server."""
|
|
70
|
+
config.set_credentials(token, api_url)
|
|
71
|
+
try:
|
|
72
|
+
me = client.whoami()
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
rprint(f"[red]Login failed:[/red] {exc}")
|
|
75
|
+
config.clear()
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
rprint(
|
|
78
|
+
f"[green]✓[/green] Logged in as [bold]{me['username']}[/bold] ({me.get('plan', 'free')} plan)"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ── whoami ────────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def whoami():
|
|
87
|
+
"""Show the currently authenticated user."""
|
|
88
|
+
try:
|
|
89
|
+
me = client.whoami()
|
|
90
|
+
except RuntimeError as exc:
|
|
91
|
+
rprint(f"[yellow]{exc}[/yellow]")
|
|
92
|
+
raise typer.Exit(1)
|
|
93
|
+
rprint(
|
|
94
|
+
f"[bold]{me['username']}[/bold] · {me.get('email', '')} · {me.get('plan', 'free')} plan"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── add dataset ───────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@add_app.command("dataset")
|
|
102
|
+
def add_dataset(
|
|
103
|
+
local_path: str = typer.Argument(..., help="Local file or directory to upload"),
|
|
104
|
+
to: str = typer.Option(..., help="Destination path: owner[/collection/...]/ name"),
|
|
105
|
+
privacy: str = typer.Option("public", help="public | private | restricted"),
|
|
106
|
+
modality: str = typer.Option(
|
|
107
|
+
...,
|
|
108
|
+
help="imaging | ehr | genomics | clinical-notes | waveform | multimodal | other",
|
|
109
|
+
),
|
|
110
|
+
description: str = typer.Option("", help="Short description of the dataset"),
|
|
111
|
+
license: str = typer.Option(
|
|
112
|
+
"unknown", help="License identifier, e.g. mit, apache-2.0, cc-by-4.0"
|
|
113
|
+
),
|
|
114
|
+
institution: str = typer.Option("", help="Publishing institution"),
|
|
115
|
+
dua: bool = typer.Option(False, help="Require a Data Use Agreement"),
|
|
116
|
+
compliance: Optional[List[str]] = typer.Option(
|
|
117
|
+
None, help="Compliance badges: hipaa, gdpr, fda-track, ce-mark, fhir"
|
|
118
|
+
),
|
|
119
|
+
tags: Optional[List[str]] = typer.Option(None, help="Free-form tags"),
|
|
120
|
+
num_patients: Optional[int] = typer.Option(
|
|
121
|
+
None, help="Number of patients in the dataset"
|
|
122
|
+
),
|
|
123
|
+
num_records: Optional[int] = typer.Option(None, help="Number of records"),
|
|
124
|
+
):
|
|
125
|
+
"""Upload a local dataset (files + metadata) to Meditopia."""
|
|
126
|
+
owner, collection_path, slug = _parse_to(to)
|
|
127
|
+
|
|
128
|
+
rprint(
|
|
129
|
+
f"[bold]Creating dataset[/bold] [cyan]{owner}/{collection_path}/{slug}[/cyan] …"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
payload = {
|
|
133
|
+
"name": slug,
|
|
134
|
+
"description": description,
|
|
135
|
+
"modality": modality,
|
|
136
|
+
"visibility": privacy,
|
|
137
|
+
"license": license,
|
|
138
|
+
"institution": institution,
|
|
139
|
+
"requires_dua": dua,
|
|
140
|
+
"compliance": compliance or [],
|
|
141
|
+
"tags": tags or [],
|
|
142
|
+
"collection_path": collection_path,
|
|
143
|
+
"num_patients": num_patients,
|
|
144
|
+
"num_records": num_records,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
ds = client.create_dataset(payload)
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
rprint(f"[red]Failed to create dataset:[/red] {exc}")
|
|
151
|
+
raise typer.Exit(1)
|
|
152
|
+
|
|
153
|
+
_abort_if_error(ds)
|
|
154
|
+
rprint(f"[green]✓[/green] Dataset created: [bold]{ds['full_path']}[/bold]")
|
|
155
|
+
|
|
156
|
+
# Upload files
|
|
157
|
+
rprint(f"Collecting files from [cyan]{local_path}[/cyan] …")
|
|
158
|
+
try:
|
|
159
|
+
file_tuples = collect_files(local_path)
|
|
160
|
+
except FileNotFoundError:
|
|
161
|
+
rprint(f"[red]Path not found:[/red] {local_path}")
|
|
162
|
+
raise typer.Exit(1)
|
|
163
|
+
|
|
164
|
+
if not file_tuples:
|
|
165
|
+
rprint("[yellow]No files found — dataset created with no files.[/yellow]")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
rprint(f"Uploading [bold]{len(file_tuples)}[/bold] file(s) …")
|
|
169
|
+
try:
|
|
170
|
+
result = client.upload_dataset_files(owner, ds["slug"], file_tuples)
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
rprint(f"[red]Upload failed:[/red] {exc}")
|
|
173
|
+
raise typer.Exit(1)
|
|
174
|
+
|
|
175
|
+
_print_upload_summary(result)
|
|
176
|
+
rprint(
|
|
177
|
+
f"\n[green]✓ Done.[/green] View at: {config.get_api_url().replace('/api/v1', '')}/datasets/{ds['full_path']}"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ── add model ─────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@add_app.command("model")
|
|
185
|
+
def add_model(
|
|
186
|
+
local_path: str = typer.Argument(..., help="Local file or directory to upload"),
|
|
187
|
+
to: str = typer.Option(..., help="Destination path: owner[/collection/...]/name"),
|
|
188
|
+
privacy: str = typer.Option("public", help="public | private | restricted"),
|
|
189
|
+
task: str = typer.Option(
|
|
190
|
+
...,
|
|
191
|
+
help="diagnosis | radiology | pathology | genomics | clinical-nlp | drug-discovery | risk-scoring | cardiology | neurology | dermatology | ophthalmology | other",
|
|
192
|
+
),
|
|
193
|
+
framework: str = typer.Option(
|
|
194
|
+
..., help="pytorch | tensorflow | jax | onnx | scikit-learn | other"
|
|
195
|
+
),
|
|
196
|
+
modality: str = typer.Option(
|
|
197
|
+
"",
|
|
198
|
+
help="Imaging modality if applicable: CT | MRI | X-ray | PET | Ultrasound | other",
|
|
199
|
+
),
|
|
200
|
+
description: str = typer.Option("", help="Short description of the model"),
|
|
201
|
+
license: str = typer.Option("unknown", help="License identifier"),
|
|
202
|
+
institution: str = typer.Option("", help="Publishing institution"),
|
|
203
|
+
compliance: Optional[List[str]] = typer.Option(None, help="Compliance badges"),
|
|
204
|
+
tags: Optional[List[str]] = typer.Option(None, help="Free-form tags"),
|
|
205
|
+
auc: Optional[float] = typer.Option(None, help="Primary AUC metric (0–1)"),
|
|
206
|
+
):
|
|
207
|
+
"""Upload a local model (weights + metadata) to Meditopia."""
|
|
208
|
+
owner, collection_path, slug = _parse_to(to)
|
|
209
|
+
|
|
210
|
+
rprint(
|
|
211
|
+
f"[bold]Creating model[/bold] [cyan]{owner}/{collection_path}/{slug}[/cyan] …"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
payload = {
|
|
215
|
+
"name": slug,
|
|
216
|
+
"description": description,
|
|
217
|
+
"task": task,
|
|
218
|
+
"framework": framework,
|
|
219
|
+
"modality": modality,
|
|
220
|
+
"visibility": privacy,
|
|
221
|
+
"license": license,
|
|
222
|
+
"institution": institution,
|
|
223
|
+
"compliance": compliance or [],
|
|
224
|
+
"tags": tags or [],
|
|
225
|
+
"collection_path": collection_path,
|
|
226
|
+
"auc": auc,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
model = client.create_model(payload)
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
rprint(f"[red]Failed to create model:[/red] {exc}")
|
|
233
|
+
raise typer.Exit(1)
|
|
234
|
+
|
|
235
|
+
_abort_if_error(model)
|
|
236
|
+
rprint(f"[green]✓[/green] Model created: [bold]{model['full_path']}[/bold]")
|
|
237
|
+
|
|
238
|
+
rprint(f"Collecting files from [cyan]{local_path}[/cyan] …")
|
|
239
|
+
try:
|
|
240
|
+
file_tuples = collect_files(local_path)
|
|
241
|
+
except FileNotFoundError:
|
|
242
|
+
rprint(f"[red]Path not found:[/red] {local_path}")
|
|
243
|
+
raise typer.Exit(1)
|
|
244
|
+
|
|
245
|
+
if not file_tuples:
|
|
246
|
+
rprint("[yellow]No files found — model created with no files.[/yellow]")
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
rprint(f"Uploading [bold]{len(file_tuples)}[/bold] file(s) …")
|
|
250
|
+
try:
|
|
251
|
+
result = client.upload_model_files(owner, model["slug"], file_tuples)
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
rprint(f"[red]Upload failed:[/red] {exc}")
|
|
254
|
+
raise typer.Exit(1)
|
|
255
|
+
|
|
256
|
+
_print_upload_summary(result)
|
|
257
|
+
rprint(
|
|
258
|
+
f"\n[green]✓ Done.[/green] View at: {config.get_api_url().replace('/api/v1', '')}/models/{model['full_path']}"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ── download dataset ──────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@download_app.command("dataset")
|
|
266
|
+
def download_dataset(
|
|
267
|
+
path: str = typer.Argument(..., help="Dataset path: owner/slug"),
|
|
268
|
+
to: str = typer.Option(".", help="Directory to save the downloaded ZIP"),
|
|
269
|
+
extract: bool = typer.Option(
|
|
270
|
+
False, "--extract/--no-extract", help="Extract the ZIP after downloading"
|
|
271
|
+
),
|
|
272
|
+
):
|
|
273
|
+
"""Download all files of a dataset as a ZIP."""
|
|
274
|
+
owner, slug = _parse_owner_slug(path)
|
|
275
|
+
dest = _resolve_dest(to, slug)
|
|
276
|
+
|
|
277
|
+
rprint(f"Downloading dataset [cyan]{owner}/{slug}[/cyan] → [bold]{dest}[/bold] …")
|
|
278
|
+
try:
|
|
279
|
+
total = client.download_dataset(owner, slug, dest)
|
|
280
|
+
except Exception as exc:
|
|
281
|
+
rprint(f"[red]Download failed:[/red] {exc}")
|
|
282
|
+
raise typer.Exit(1)
|
|
283
|
+
|
|
284
|
+
rprint(f"[green]✓[/green] {total / 1_048_576:.2f} MB saved to [bold]{dest}[/bold]")
|
|
285
|
+
if extract:
|
|
286
|
+
_extract_zip(dest, to)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ── download model ────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@download_app.command("model")
|
|
293
|
+
def download_model(
|
|
294
|
+
path: str = typer.Argument(..., help="Model path: owner/slug"),
|
|
295
|
+
to: str = typer.Option(".", help="Directory to save the downloaded ZIP"),
|
|
296
|
+
extract: bool = typer.Option(
|
|
297
|
+
False, "--extract/--no-extract", help="Extract the ZIP after downloading"
|
|
298
|
+
),
|
|
299
|
+
):
|
|
300
|
+
"""Download all files of a model as a ZIP."""
|
|
301
|
+
owner, slug = _parse_owner_slug(path)
|
|
302
|
+
dest = _resolve_dest(to, slug)
|
|
303
|
+
|
|
304
|
+
rprint(f"Downloading model [cyan]{owner}/{slug}[/cyan] → [bold]{dest}[/bold] …")
|
|
305
|
+
try:
|
|
306
|
+
total = client.download_model(owner, slug, dest)
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
rprint(f"[red]Download failed:[/red] {exc}")
|
|
309
|
+
raise typer.Exit(1)
|
|
310
|
+
|
|
311
|
+
rprint(f"[green]✓[/green] {total / 1_048_576:.2f} MB saved to [bold]{dest}[/bold]")
|
|
312
|
+
if extract:
|
|
313
|
+
_extract_zip(dest, to)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ── download function ─────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
_LANG_EXT = {"python": "py", "rust": "rs", "api": "json"}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@download_app.command("function")
|
|
322
|
+
def download_function(
|
|
323
|
+
name: str = typer.Argument(..., help="Function name"),
|
|
324
|
+
to: str = typer.Option(".", help="Directory to save the function script + metadata"),
|
|
325
|
+
):
|
|
326
|
+
"""Download a function's script (+ a .meta.json sidecar) for use in Airflow DAGs."""
|
|
327
|
+
import os
|
|
328
|
+
import json
|
|
329
|
+
|
|
330
|
+
rprint(f"Fetching function [cyan]{name}[/cyan] …")
|
|
331
|
+
try:
|
|
332
|
+
fn = client.get_function(name)
|
|
333
|
+
except Exception as exc:
|
|
334
|
+
rprint(f"[red]Download failed:[/red] {exc}")
|
|
335
|
+
raise typer.Exit(1)
|
|
336
|
+
|
|
337
|
+
_abort_if_error(fn)
|
|
338
|
+
|
|
339
|
+
os.makedirs(to, exist_ok=True)
|
|
340
|
+
ext = _LANG_EXT.get(fn["language"], "txt")
|
|
341
|
+
script_path = os.path.join(to, f"{name}.{ext}")
|
|
342
|
+
meta_path = os.path.join(to, f"{name}.meta.json")
|
|
343
|
+
|
|
344
|
+
with open(script_path, "w") as f:
|
|
345
|
+
f.write(fn["script"])
|
|
346
|
+
|
|
347
|
+
with open(meta_path, "w") as f:
|
|
348
|
+
json.dump(
|
|
349
|
+
{
|
|
350
|
+
"name": fn["name"],
|
|
351
|
+
"display_name": fn["display_name"],
|
|
352
|
+
"description": fn["description"],
|
|
353
|
+
"language": fn["language"],
|
|
354
|
+
"tags": fn["tags"],
|
|
355
|
+
"input_ports": fn["input_ports"],
|
|
356
|
+
"output_ports": fn["output_ports"],
|
|
357
|
+
},
|
|
358
|
+
f,
|
|
359
|
+
indent=2,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
rprint(f"[green]✓[/green] Saved [bold]{script_path}[/bold] and [bold]{meta_path}[/bold]")
|
|
363
|
+
if fn["language"] == "rust":
|
|
364
|
+
rprint(
|
|
365
|
+
"[yellow]Note:[/yellow] rust functions are downloaded as source only — "
|
|
366
|
+
"there is no build/compile step yet, so this script cannot be executed "
|
|
367
|
+
"via functions/_run.py until that's implemented (known gap, carried over "
|
|
368
|
+
"from the old pipelines executor)."
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _parse_owner_slug(path: str) -> tuple[str, str]:
|
|
376
|
+
parts = path.strip("/").split("/")
|
|
377
|
+
if len(parts) < 2:
|
|
378
|
+
rprint(f"[red]Path must be owner/slug, got: {path!r}[/red]")
|
|
379
|
+
raise typer.Exit(1)
|
|
380
|
+
return parts[0], parts[-1]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _resolve_dest(directory: str, slug: str) -> str:
|
|
384
|
+
import os
|
|
385
|
+
|
|
386
|
+
os.makedirs(directory, exist_ok=True)
|
|
387
|
+
return os.path.join(directory, f"{slug}.zip")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _extract_zip(zip_path: str, dest_dir: str) -> None:
|
|
391
|
+
import zipfile
|
|
392
|
+
|
|
393
|
+
rprint(f"Extracting [bold]{zip_path}[/bold] …")
|
|
394
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
395
|
+
zf.extractall(dest_dir)
|
|
396
|
+
rprint(f"[green]✓[/green] Extracted to [bold]{dest_dir}[/bold]")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _print_upload_summary(result: dict) -> None:
|
|
400
|
+
table = Table(box=box.SIMPLE, show_header=True, header_style="bold")
|
|
401
|
+
table.add_column("File")
|
|
402
|
+
table.add_column("Status", style="green")
|
|
403
|
+
for f in result.get("files", []):
|
|
404
|
+
table.add_row(f, "✓ uploaded")
|
|
405
|
+
rprint(table)
|
|
406
|
+
mb = result.get("bytes", 0) / 1_048_576
|
|
407
|
+
rprint(
|
|
408
|
+
f"Total: [bold]{result.get('uploaded', 0)}[/bold] files · [bold]{mb:.2f} MB[/bold]"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
if __name__ == "__main__":
|
|
413
|
+
app()
|
meditopia/client.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP client for the Meditopia API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from . import config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _headers() -> dict[str, str]:
|
|
14
|
+
token = config.get_token()
|
|
15
|
+
if not token:
|
|
16
|
+
raise RuntimeError("Not authenticated. Run: medi login --token <your-token>")
|
|
17
|
+
return {"Authorization": f"Bearer {token}"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _base() -> str:
|
|
21
|
+
return config.get_api_url().rstrip("/")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── Auth ──────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def whoami() -> dict[str, Any]:
|
|
28
|
+
r = httpx.get(f"{_base()}/auth/me", headers=_headers(), timeout=10)
|
|
29
|
+
r.raise_for_status()
|
|
30
|
+
return r.json()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── Datasets ──────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def create_dataset(payload: dict[str, Any]) -> dict[str, Any]:
|
|
37
|
+
r = httpx.post(f"{_base()}/datasets", json=payload, headers=_headers(), timeout=30)
|
|
38
|
+
r.raise_for_status()
|
|
39
|
+
return r.json()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def upload_dataset_files(
|
|
43
|
+
owner: str, slug: str, file_tuples: list[tuple]
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""
|
|
46
|
+
file_tuples: list of (filename, file_bytes, content_type)
|
|
47
|
+
"""
|
|
48
|
+
files = [("files", (name, data, ct)) for name, data, ct in file_tuples]
|
|
49
|
+
r = httpx.post(
|
|
50
|
+
f"{_base()}/datasets/{owner}/{slug}/upload",
|
|
51
|
+
headers=_headers(),
|
|
52
|
+
files=files,
|
|
53
|
+
timeout=300,
|
|
54
|
+
)
|
|
55
|
+
r.raise_for_status()
|
|
56
|
+
return r.json()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── Models ────────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_model(payload: dict[str, Any]) -> dict[str, Any]:
|
|
63
|
+
r = httpx.post(f"{_base()}/models", json=payload, headers=_headers(), timeout=30)
|
|
64
|
+
r.raise_for_status()
|
|
65
|
+
return r.json()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def upload_model_files(
|
|
69
|
+
owner: str, slug: str, file_tuples: list[tuple]
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
files = [("files", (name, data, ct)) for name, data, ct in file_tuples]
|
|
72
|
+
r = httpx.post(
|
|
73
|
+
f"{_base()}/models/{owner}/{slug}/upload",
|
|
74
|
+
headers=_headers(),
|
|
75
|
+
files=files,
|
|
76
|
+
timeout=300,
|
|
77
|
+
)
|
|
78
|
+
r.raise_for_status()
|
|
79
|
+
return r.json()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def download_model(owner: str, slug: str, dest: str) -> int:
|
|
83
|
+
"""Stream the model ZIP to *dest*. Returns bytes written."""
|
|
84
|
+
return _stream_zip(f"{_base()}/models/{owner}/{slug}/download", dest)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── Functions ──────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_function(name: str) -> dict[str, Any]:
|
|
91
|
+
"""Fetch a function's metadata + script by name. Public endpoint, no auth needed."""
|
|
92
|
+
r = httpx.get(f"{_base()}/functions/{name}", timeout=15)
|
|
93
|
+
r.raise_for_status()
|
|
94
|
+
return r.json()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Downloads (shared) ────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def download_dataset(owner: str, slug: str, dest: str) -> int:
|
|
101
|
+
"""Stream the dataset ZIP to *dest*. Returns bytes written."""
|
|
102
|
+
return _stream_zip(f"{_base()}/datasets/{owner}/{slug}/download", dest)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _stream_zip(url: str, dest: str) -> int:
|
|
106
|
+
"""Stream a ZIP from *url* into file *dest*, return total bytes."""
|
|
107
|
+
with httpx.stream(
|
|
108
|
+
"GET", url, headers=_headers(), timeout=600, follow_redirects=True
|
|
109
|
+
) as r:
|
|
110
|
+
r.raise_for_status()
|
|
111
|
+
total = 0
|
|
112
|
+
with open(dest, "wb") as fh:
|
|
113
|
+
for chunk in r.iter_bytes(chunk_size=1024 * 256):
|
|
114
|
+
fh.write(chunk)
|
|
115
|
+
total += len(chunk)
|
|
116
|
+
return total
|
meditopia/config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Manages ~/.meditopia/config.json — stores token and API URL.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = Path.home() / ".meditopia"
|
|
9
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
10
|
+
|
|
11
|
+
DEFAULT_API_URL = "http://localhost:8000/api/v1"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load() -> dict:
|
|
15
|
+
if CONFIG_FILE.exists():
|
|
16
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
17
|
+
return {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def save(data: dict) -> None:
|
|
21
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
CONFIG_FILE.write_text(json.dumps(data, indent=2))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_token() -> str | None:
|
|
26
|
+
return load().get("token")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_api_url() -> str:
|
|
30
|
+
return load().get("api_url", DEFAULT_API_URL)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def set_credentials(token: str, api_url: str = DEFAULT_API_URL) -> None:
|
|
34
|
+
cfg = load()
|
|
35
|
+
cfg["token"] = token
|
|
36
|
+
cfg["api_url"] = api_url
|
|
37
|
+
save(cfg)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def clear() -> None:
|
|
41
|
+
if CONFIG_FILE.exists():
|
|
42
|
+
CONFIG_FILE.unlink()
|
meditopia/uploader.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Walks a local directory and returns file tuples ready for upload.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import mimetypes
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def collect_files(local_path: str) -> list[tuple[str, bytes, str]]:
|
|
13
|
+
"""
|
|
14
|
+
Walk *local_path* (file or directory) and return a list of
|
|
15
|
+
(relative_filename, file_bytes, mime_type) tuples.
|
|
16
|
+
"""
|
|
17
|
+
root = Path(local_path).resolve()
|
|
18
|
+
tuples: list[tuple[str, bytes, str]] = []
|
|
19
|
+
|
|
20
|
+
if root.is_file():
|
|
21
|
+
mime = mimetypes.guess_type(root.name)[0] or "application/octet-stream"
|
|
22
|
+
tuples.append((root.name, root.read_bytes(), mime))
|
|
23
|
+
return tuples
|
|
24
|
+
|
|
25
|
+
for dirpath, _, filenames in os.walk(root):
|
|
26
|
+
for fname in filenames:
|
|
27
|
+
abs_path = Path(dirpath) / fname
|
|
28
|
+
rel_path = str(abs_path.relative_to(root))
|
|
29
|
+
mime = mimetypes.guess_type(fname)[0] or "application/octet-stream"
|
|
30
|
+
tuples.append((rel_path, abs_path.read_bytes(), mime))
|
|
31
|
+
|
|
32
|
+
return tuples
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: meditopia-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool for uploading models and datasets to Meditopia
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://meditopia.io
|
|
7
|
+
Project-URL: Repository, https://github.com/meditopia/meditopia-cli
|
|
8
|
+
Keywords: meditopia,cli,medical-ai,machine-learning
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: typer>=0.12
|
|
15
|
+
Requires-Dist: httpx>=0.27
|
|
16
|
+
Requires-Dist: rich>=13
|
|
17
|
+
|
|
18
|
+
# meditopia-cli
|
|
19
|
+
|
|
20
|
+
Official CLI for [MedOpia](https://meditopia.io) — upload and manage clinical AI models and datasets from your terminal.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install meditopia-cli
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
medi login --token <your-api-token>
|
|
32
|
+
medi upload model ./my-model --name "chest-xray-classifier"
|
|
33
|
+
medi upload dataset ./my-dataset --name "mimic-cxr-subset"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Commands
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
medi --help
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Getting an API token
|
|
43
|
+
|
|
44
|
+
1. Log in to [meditopia.io](https://meditopia.io)
|
|
45
|
+
2. Go to **Settings → API Tokens**
|
|
46
|
+
3. Create a new token and copy it
|
|
47
|
+
4. Run `medi login --token <token>`
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
meditopia/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
meditopia/cli.py,sha256=QAEyw-6VU-wBqv6J8ce5PNob2sYqK0rSQCgrrLaFLyk,15294
|
|
3
|
+
meditopia/client.py,sha256=t5L00JrcxM1ie9f6o7vO-NCVyPtvXre9mOaFlybkDiY,3984
|
|
4
|
+
meditopia/config.py,sha256=P58V4uF1MuyldcKAWDfx6F_1XotK8nfehN_Dmat7t3M,877
|
|
5
|
+
meditopia/uploader.py,sha256=I6wGk9kyRPGVqktGp-48pKfNQh5yEaeN6kttwVv-SPY,984
|
|
6
|
+
meditopia_cli-0.1.0.dist-info/METADATA,sha256=mA-AgRf8-005M-xtTufe3EAT2j1_3HoKIrNZT4QAtgg,1184
|
|
7
|
+
meditopia_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
8
|
+
meditopia_cli-0.1.0.dist-info/entry_points.txt,sha256=3_F6FNHXSSwHq9GnQfFU0Rqw1r70nAION7og6t938g4,43
|
|
9
|
+
meditopia_cli-0.1.0.dist-info/top_level.txt,sha256=YRMv_tbnISbMxeZwdXvMtTuWIz3gdmjrefeU7UcwBOU,10
|
|
10
|
+
meditopia_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
meditopia
|