dovecli 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.
dap_cli/__init__.py ADDED
File without changes
dap_cli/cli.py ADDED
@@ -0,0 +1,678 @@
1
+ """
2
+ `dap` command-line tool: authenticate against a DAP server and list,
3
+ download, and upload files. Talks to the a_cli Django app (a_cli/views.py)
4
+ over plain HTTP via dap_cli.client.DapClient.
5
+ """
6
+
7
+ import csv
8
+ import math
9
+ import os
10
+ import re
11
+ import time
12
+ from datetime import datetime
13
+
14
+ import click
15
+ import requests
16
+
17
+ from dap_cli import config
18
+ from dap_cli.client import DapClient, DapError
19
+
20
+ MIN_PART_SIZE = 5 * 1024 * 1024
21
+ DOWNLOAD_CHUNK_SIZE = 8 * 1024 * 1024
22
+
23
+ # Cosmetic only -- the server/API always use the real Run.status value
24
+ # ("PENDING"); this just relabels it for display in the CLI's output.
25
+ STATUS_DISPLAY = {"PENDING": "IN-PROGRESS"}
26
+
27
+
28
+ def _display_status(status):
29
+ return STATUS_DISPLAY.get(status, status)
30
+
31
+
32
+ def _format_datetime(value):
33
+ """The server sends upload_date as an ISO 8601 string (Django's
34
+ DjangoJSONEncoder default) -- render it as something a human can
35
+ actually read at a glance instead of raw ISO text."""
36
+ if not value:
37
+ return ""
38
+ try:
39
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M UTC")
40
+ except (TypeError, ValueError):
41
+ return value
42
+
43
+
44
+ def _print_table(headers, rows):
45
+ """Plain column-aligned table -- no new dependency (e.g. tabulate/rich)
46
+ needed for output this simple."""
47
+ widths = [len(h) for h in headers]
48
+ for row in rows:
49
+ for i, cell in enumerate(row):
50
+ widths[i] = max(widths[i], len(str(cell)))
51
+
52
+ def _format_row(row):
53
+ return " ".join(str(cell).ljust(widths[i]) for i, cell in enumerate(row))
54
+
55
+ click.echo(_format_row(headers))
56
+ click.echo(" ".join("-" * w for w in widths))
57
+ for row in rows:
58
+ click.echo(_format_row(row))
59
+
60
+
61
+ def _print_tabs(headers, rows):
62
+ click.echo("\t".join(headers))
63
+ for row in rows:
64
+ click.echo("\t".join(str(cell) for cell in row))
65
+
66
+
67
+ def _client():
68
+ return DapClient()
69
+
70
+
71
+ BANNER = """\
72
+ ····································································
73
+ :██████╗ ██████╗ ██╗ ██╗███████╗████████╗ █████╗ ██╗██╗ :
74
+ :██╔══██╗██╔═══██╗██║ ██║██╔════╝╚══██╔══╝██╔══██╗██║██║ :
75
+ :██║ ██║██║ ██║██║ ██║█████╗ ██║ ███████║██║██║ :
76
+ :██║ ██║██║ ██║╚██╗ ██╔╝██╔══╝ ██║ ██╔══██║██║██║ :
77
+ :██████╔╝╚██████╔╝ ╚████╔╝ ███████╗ ██║ ██║ ██║██║███████╗ :
78
+ :╚═════╝ ╚═════╝ ╚═══╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚══════╝ :
79
+ : :
80
+ : ██████╗ ███████╗███╗ ██╗ ██████╗ ███╗ ███╗██╗ ██████╗███████╗:
81
+ :██╔════╝ ██╔════╝████╗ ██║██╔═══██╗████╗ ████║██║██╔════╝██╔════╝:
82
+ :██║ ███╗█████╗ ██╔██╗ ██║██║ ██║██╔████╔██║██║██║ ███████╗:
83
+ :██║ ██║██╔══╝ ██║╚██╗██║██║ ██║██║╚██╔╝██║██║██║ ╚════██║:
84
+ :╚██████╔╝███████╗██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██║╚██████╗███████║:
85
+ : ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═════╝╚══════╝:
86
+ ····································································\
87
+ """
88
+
89
+
90
+ class DapGroup(click.Group):
91
+ """Prepends the banner to `dap --help` (and `dap <bad-command>`'s usage
92
+ error) -- overriding format_help() instead of stuffing the banner into
93
+ the docstring, since click reflows docstring text to fit the terminal
94
+ width and would mangle the box-drawing art."""
95
+
96
+ def format_help(self, ctx, formatter):
97
+ formatter.write(BANNER + "\n")
98
+ super().format_help(ctx, formatter)
99
+
100
+
101
+ @click.group(cls=DapGroup)
102
+ def main():
103
+ """DAP command-line tool: authenticate, list, download, and upload files."""
104
+
105
+
106
+ @main.command()
107
+ @click.option("--api-url", default=None, help="DAP server base URL (default: https://portal.cantatabio.com)")
108
+ def login(api_url):
109
+ """Log in with your DAP email and password and save a CLI token."""
110
+ email = click.prompt("Email")
111
+ password = click.prompt("Password", hide_input=True)
112
+ client = DapClient(api_url=api_url or config.get_api_url())
113
+ try:
114
+ token = client.login(email, password)
115
+ except DapError as e:
116
+ raise click.ClickException(str(e))
117
+ config.set_token(token, api_url=client.api_url)
118
+ click.echo("Logged in.")
119
+
120
+
121
+ @main.command()
122
+ def logout():
123
+ """Clear the locally saved CLI token."""
124
+ config.clear()
125
+ click.echo("Logged out.")
126
+
127
+
128
+ @main.command()
129
+ def whoami():
130
+ """Show the logged-in account's email and credit balance."""
131
+ try:
132
+ info = _client().whoami()
133
+ except DapError as e:
134
+ raise click.ClickException(str(e))
135
+ click.echo(f"{info['email']} -- {info['credits']} credits")
136
+
137
+
138
+ @main.group()
139
+ def files():
140
+ """List, download, and upload files."""
141
+
142
+
143
+ @files.command("list")
144
+ @click.option("--output", type=click.Choice(["tabs", "table"]), default="tabs", help="Output format (default: tabs)")
145
+ def list_files(output):
146
+ """List your uploaded/registered files."""
147
+ try:
148
+ rows = _client().list_files()
149
+ except DapError as e:
150
+ raise click.ClickException(str(e))
151
+ if not rows:
152
+ click.echo("No files.")
153
+ return
154
+ headers = ["ID", "NAME", "SIZE", "UPLOAD_DATE", "TIME_REMAINING"]
155
+ table_rows = [
156
+ [f["id"], f["name"], f["size"], _format_datetime(f["upload_date"]), f"{f['time_remaining']} days"] for f in rows
157
+ ]
158
+ if output == "table":
159
+ _print_table(headers, table_rows)
160
+ else:
161
+ _print_tabs(headers, table_rows)
162
+
163
+
164
+ @files.command("download")
165
+ @click.argument("file")
166
+ @click.option("-o", "--output", default=None, help="Output path (default: the file's original name)")
167
+ def download(file, output):
168
+ """Download a file by its id or name (see `dap files list`)."""
169
+ client = _client()
170
+ try:
171
+ rows = client.list_files()
172
+ except DapError as e:
173
+ raise click.ClickException(str(e))
174
+
175
+ matches = [f for f in rows if f["id"] == file or f["name"] == file]
176
+ if not matches:
177
+ raise click.ClickException(f"No file found matching '{file}'")
178
+ if len(matches) > 1:
179
+ ids = ", ".join(m["id"] for m in matches)
180
+ raise click.ClickException(f"Multiple files named '{file}' -- specify by id instead: {ids}")
181
+ file_id = matches[0]["id"]
182
+
183
+ try:
184
+ info = client.get_download_link(file_id)
185
+ except DapError as e:
186
+ raise click.ClickException(str(e))
187
+
188
+ output = output or info["name"]
189
+ response = requests.get(info["download_url"], stream=True)
190
+ response.raise_for_status()
191
+ total = int(info.get("size") or 0)
192
+ written = 0
193
+ with open(output, "wb") as f:
194
+ for chunk in response.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE):
195
+ f.write(chunk)
196
+ written += len(chunk)
197
+ if total:
198
+ click.echo(f"\r{written}/{total} bytes ({written * 100 // total}%)", nl=False)
199
+ click.echo(f"\nSaved to {output}")
200
+
201
+
202
+ def _upload_file(client, path, label=None):
203
+ """
204
+ Drives the same S3 multipart flow as `dap files upload` (create/sign
205
+ parts/PUT/complete, aborting the multipart upload on any failure) --
206
+ shared with `dap submit sv`'s auto-upload-local-FASTQ path so there's
207
+ one implementation, not two. `label`, when given, prints an
208
+ announcement line before the transfer starts, so a caller uploading
209
+ several files in sequence (like submit sv) can say which is which;
210
+ `dap files upload` itself omits it, keeping its own output unchanged.
211
+ """
212
+ if not os.path.isfile(path):
213
+ raise click.ClickException(f"No such file: {path}")
214
+
215
+ filename = os.path.basename(path)
216
+ size = os.path.getsize(path)
217
+
218
+ if label:
219
+ click.echo(f"Uploading {label}: {filename}")
220
+
221
+ try:
222
+ created = client.create_upload(filename, "application/octet-stream")
223
+ except DapError as e:
224
+ raise click.ClickException(str(e))
225
+
226
+ key, upload_id = created["key"], created["upload_id"]
227
+ chunk_size = max(MIN_PART_SIZE, math.ceil(size / 10000))
228
+
229
+ try:
230
+ parts = []
231
+ uploaded = 0
232
+ with open(path, "rb") as f:
233
+ part_number = 1
234
+ while True:
235
+ chunk = f.read(chunk_size)
236
+ if not chunk:
237
+ break
238
+ url = client.sign_upload_part(upload_id, key, part_number)
239
+ response = requests.put(url, data=chunk)
240
+ response.raise_for_status()
241
+ parts.append({"part_number": part_number, "etag": response.headers["ETag"]})
242
+ uploaded += len(chunk)
243
+ click.echo(f"\r{uploaded}/{size} bytes ({uploaded * 100 // size}%)", nl=False)
244
+ part_number += 1
245
+
246
+ result = client.complete_upload(upload_id, key, parts)
247
+ except (DapError, requests.RequestException) as e:
248
+ # Best-effort cleanup so a failed upload doesn't leave an orphaned
249
+ # multipart upload sitting in the bucket forever.
250
+ try:
251
+ client.abort_upload(upload_id, key)
252
+ except DapError:
253
+ pass
254
+ raise click.ClickException(str(e))
255
+
256
+ click.echo(f"\nUploaded: {result['name']} ({result['id']})")
257
+ return result
258
+
259
+
260
+ def _resolve_fastq_uri(client, value, label):
261
+ """
262
+ Accepts either an s3:// URI (used as-is, no upload) or a local file
263
+ path (uploaded automatically via _upload_file, returning the new
264
+ S3file's uri) -- auto-detected per value, no separate flag, so a
265
+ single --tumor/--normal pair can even mix one of each.
266
+ """
267
+ if value.startswith("s3://"):
268
+ return value
269
+ if os.path.isfile(value):
270
+ return _upload_file(client, value, label=label)["uri"]
271
+ raise click.ClickException(f"'{value}' is not a valid local file or an S3 URI (must start with s3://)")
272
+
273
+
274
+ def _parse_design_file(path):
275
+ """
276
+ Parses a --design CSV (header: sample_type,r1,r2) into the same
277
+ tuple-of-(r1, r2)-tuples shape repeated --tumor/--normal flags already
278
+ produce, so it can feed straight into submit_sv's existing
279
+ _resolve_pairs. Rep number is implicit: rows are grouped by
280
+ sample_type, and within each group file order determines the rep.
281
+
282
+ Validates the file is actually well-formed before any upload/network
283
+ call happens: tolerates a UTF-8 BOM and stray whitespace around header
284
+ names (common artifacts of Excel/hand-edited CSVs), then rejects a
285
+ wrong header, rows with the wrong number of columns, an invalid
286
+ sample_type, or a missing r1/r2 -- each error names the offending row.
287
+ """
288
+ tumor_pairs, normal_pairs = [], []
289
+ with open(path, newline="", encoding="utf-8-sig") as f:
290
+ reader = csv.DictReader(f)
291
+ if reader.fieldnames:
292
+ reader.fieldnames = [name.strip() for name in reader.fieldnames]
293
+ if reader.fieldnames != ["sample_type", "r1", "r2"]:
294
+ raise click.ClickException(f"{path}: header must be exactly 'sample_type,r1,r2'")
295
+ for row_num, row in enumerate(reader, start=2): # header is row 1
296
+ if row.get(None) is not None:
297
+ raise click.ClickException(f"{path}:{row_num}: too many columns (expected sample_type,r1,r2)")
298
+ if not any((row.get(k) or "").strip() for k in ("sample_type", "r1", "r2")):
299
+ continue
300
+ sample_type = (row["sample_type"] or "").strip().lower()
301
+ r1, r2 = (row["r1"] or "").strip(), (row["r2"] or "").strip()
302
+ if sample_type not in ("tumor", "normal"):
303
+ raise click.ClickException(
304
+ f"{path}:{row_num}: sample_type must be 'tumor' or 'normal', got '{row['sample_type']}'"
305
+ )
306
+ if not r1 or not r2:
307
+ raise click.ClickException(f"{path}:{row_num}: r1 and r2 are both required")
308
+ (tumor_pairs if sample_type == "tumor" else normal_pairs).append((r1, r2))
309
+ return tumor_pairs, normal_pairs
310
+
311
+
312
+ # Mirrors utils/fastq.py's categorize_fastq() marker convention (R1/R2/r1/r2/
313
+ # bare _1/_2, boundary-guarded so R10/_11 don't false-match) -- ported here
314
+ # since dap_cli is a standalone package that can't import Django app code.
315
+ _FASTQ_EXT_RE = re.compile(r"\.(fastq|fq)\.gz$", re.IGNORECASE)
316
+ _R1R2_MARKER_RE = re.compile(r"[._](r[12])(?=[._]|$)|[._]([12])(?=[._]|$)", re.IGNORECASE)
317
+ _SAMPLE_TYPE_MARKER_RE = re.compile(r"(?:^|[._])(tumor|normal)(?:[._]|$)", re.IGNORECASE)
318
+
319
+
320
+ def _build_pairs_from_values(source, values):
321
+ """
322
+ Classifies/pairs a flat list of local paths or s3:// URIs (found by
323
+ scanning a --design directory or S3 prefix) into tumor/normal (r1, r2)
324
+ tuples -- the same shape a hand-written --design CSV or repeated
325
+ --tumor/--normal flags already produce, so it plugs straight into
326
+ submit_sv's existing _resolve_pairs unchanged.
327
+ """
328
+ groups = {"tumor": {}, "normal": {}}
329
+ for value in values:
330
+ name = os.path.basename(value)
331
+ if not _FASTQ_EXT_RE.search(name):
332
+ continue # ignore non-FASTQ files (.DS_Store, stray docs, etc.)
333
+
334
+ type_match = _SAMPLE_TYPE_MARKER_RE.search(name)
335
+ if not type_match:
336
+ raise click.ClickException(f"{source}: '{name}' has no 'tumor'/'normal' marker in its filename.")
337
+ sample_type = type_match.group(1).lower()
338
+
339
+ r_match = _R1R2_MARKER_RE.search(name)
340
+ if not r_match:
341
+ raise click.ClickException(f"{source}: '{name}' has no R1/R2 marker in its filename.")
342
+ which = f"r{(r_match.group(1) or r_match.group(2)).lower().lstrip('r')}"
343
+ group_key = name[: r_match.start()] + name[r_match.end() :]
344
+
345
+ group = groups[sample_type].setdefault(group_key, {})
346
+ if which in group:
347
+ raise click.ClickException(f"{source}: multiple {which.upper()} files match '{group_key}'.")
348
+ group[which] = value
349
+
350
+ def _ordered_pairs(sample_type):
351
+ result = []
352
+ for group_key in sorted(groups[sample_type]):
353
+ pair = groups[sample_type][group_key]
354
+ if "r1" not in pair or "r2" not in pair:
355
+ missing = "R2" if "r1" in pair else "R1"
356
+ raise click.ClickException(f"{source}: '{group_key}' is missing its {missing} mate.")
357
+ result.append((pair["r1"], pair["r2"]))
358
+ return result
359
+
360
+ return _ordered_pairs("tumor"), _ordered_pairs("normal")
361
+
362
+
363
+ def _resolve_design(client, design):
364
+ """
365
+ Dispatches --design to the right source: an existing file is parsed as
366
+ a CSV (see _parse_design_file), a local directory or an s3:// prefix is
367
+ scanned and auto-paired via _build_pairs_from_values.
368
+ """
369
+ if design.startswith("s3://"):
370
+ try:
371
+ values = client.list_s3_prefix(design)
372
+ except DapError as e:
373
+ raise click.ClickException(str(e))
374
+ return _build_pairs_from_values(design, values)
375
+ if os.path.isdir(design):
376
+ values = sorted(
377
+ os.path.join(design, name) for name in os.listdir(design) if os.path.isfile(os.path.join(design, name))
378
+ )
379
+ return _build_pairs_from_values(design, values)
380
+ if os.path.isfile(design):
381
+ return _parse_design_file(design)
382
+ raise click.ClickException(f"'{design}' is not a valid CSV file, directory, or s3:// prefix.")
383
+
384
+
385
+ @files.command("upload")
386
+ @click.argument("path")
387
+ def upload(path):
388
+ """Upload a local file."""
389
+ _upload_file(_client(), path)
390
+
391
+
392
+ @main.group()
393
+ def runs():
394
+ """List pipeline runs."""
395
+
396
+
397
+ @runs.command("list")
398
+ @click.option("--status", default=None, help="Filter by status (e.g. SUCCESS, FAILED, PENDING).")
399
+ @click.option("--pipeline", default=None, help="Filter by pipeline (e.g. sv, epi, diff, qc).")
400
+ @click.option("--limit", type=int, default=20, show_default=True, help="Maximum number of runs to show.")
401
+ @click.option("--output", type=click.Choice(["tabs", "table"]), default="tabs", help="Output format (default: tabs)")
402
+ def list_runs(status, pipeline, limit, output):
403
+ """List your pipeline runs."""
404
+ try:
405
+ rows = _client().list_runs(status=status, pipeline=pipeline, limit=limit)
406
+ except DapError as e:
407
+ raise click.ClickException(str(e))
408
+ if not rows:
409
+ click.echo("No runs.")
410
+ return
411
+ headers = ["ID", "NAME", "STATUS", "PIPELINE", "REF_GENOME", "CREATED", "CREDIT_USE"]
412
+ table_rows = [
413
+ [
414
+ r["id"],
415
+ r["name"],
416
+ _display_status(r["status"]),
417
+ r["run_pipeline"],
418
+ r["ref_genome"],
419
+ _format_datetime(r["created"]),
420
+ r["credit_use"],
421
+ ]
422
+ for r in rows
423
+ ]
424
+ if output == "table":
425
+ _print_table(headers, table_rows)
426
+ else:
427
+ _print_tabs(headers, table_rows)
428
+
429
+
430
+ TERMINAL_STATUSES = {"SUCCESS", "FAILED", "CANCELLED"}
431
+ PROGRESS_BAR_WIDTH = 30
432
+
433
+
434
+ def _progress_bar(percent, width=PROGRESS_BAR_WIDTH):
435
+ filled = max(0, min(width, round(width * percent / 100)))
436
+ return "[" + "█" * filled + "░" * (width - filled) + "]"
437
+
438
+
439
+ def _run_detail_lines(run):
440
+ lines = [
441
+ f"ID: {run['id']}",
442
+ f"Name: {run['name']}",
443
+ f"Status: {_display_status(run['status'])}",
444
+ f"Pipeline: {run['run_pipeline']}",
445
+ f"Ref genome: {run['ref_genome']}",
446
+ f"Created: {_format_datetime(run['created'])}",
447
+ f"Credit use: {run['credit_use']}",
448
+ f"Retry count: {run['retry']}",
449
+ ]
450
+ if run.get("note"):
451
+ lines.append(f"Note: {run['note']}")
452
+
453
+ progress = run.get("progress")
454
+ if progress:
455
+ bar = _progress_bar(progress["percent"])
456
+ lines.append(f"Progress: {bar} {progress['percent']}% ({progress['completed']}/{progress['total']} stages)")
457
+ if progress["running"]:
458
+ lines.append(f"Running: {', '.join(progress['running'])}")
459
+ if progress["retrying"]:
460
+ lines.append(f"Retrying: {', '.join(progress['retrying'])}")
461
+
462
+ if run.get("nextflow_error"):
463
+ lines.append(f"Caused by: {run['nextflow_error']}")
464
+
465
+ return lines
466
+
467
+
468
+ def _print_run_detail(run):
469
+ for line in _run_detail_lines(run):
470
+ click.echo(line)
471
+
472
+
473
+ def _watch_run(client, run_id, interval):
474
+ previous_line_count = 0
475
+ try:
476
+ while True:
477
+ try:
478
+ run = client.get_run(run_id)
479
+ except DapError as e:
480
+ raise click.ClickException(str(e))
481
+
482
+ lines = _run_detail_lines(run)
483
+ if previous_line_count:
484
+ # Move the cursor back to the start of the previous block
485
+ # and clear everything below it, so each poll redraws in
486
+ # place instead of scrolling the terminal.
487
+ click.echo(f"\x1b[{previous_line_count}A\x1b[J", nl=False)
488
+ click.echo("\n".join(lines))
489
+ previous_line_count = len(lines)
490
+
491
+ if run["status"] in TERMINAL_STATUSES:
492
+ return
493
+
494
+ time.sleep(interval)
495
+ except KeyboardInterrupt:
496
+ click.echo("Stopped watching.")
497
+
498
+
499
+ @runs.command("status")
500
+ @click.argument("run_id")
501
+ @click.option("-w", "--watch", is_flag=True, help="Keep polling and show live progress until the run finishes.")
502
+ @click.option("--interval", type=float, default=5.0, show_default=True, help="Seconds between polls in --watch mode.")
503
+ def run_status(run_id, watch, interval):
504
+ """Show a run's status, and its live progress or failure reason."""
505
+ client = _client()
506
+
507
+ if watch:
508
+ _watch_run(client, run_id, interval)
509
+ return
510
+
511
+ try:
512
+ run = client.get_run(run_id)
513
+ except DapError as e:
514
+ raise click.ClickException(str(e))
515
+ _print_run_detail(run)
516
+
517
+
518
+ @runs.command("cancer-types")
519
+ @click.option("--output", type=click.Choice(["tabs", "table"]), default="tabs", help="Output format (default: tabs)")
520
+ def cancer_types(output):
521
+ """List valid --cancer-type values for `dap submit sv`."""
522
+ try:
523
+ types = _client().list_cancer_types()
524
+ except DapError as e:
525
+ raise click.ClickException(str(e))
526
+ rows = [[t] for t in types]
527
+ if output == "table":
528
+ _print_table(["CANCER_TYPE"], rows)
529
+ else:
530
+ _print_tabs(["CANCER_TYPE"], rows)
531
+
532
+
533
+ @main.group()
534
+ def submit():
535
+ """Submit pipeline runs."""
536
+
537
+
538
+ @submit.command("sv")
539
+ @click.option("--name", required=True, help="Run name.")
540
+ @click.option(
541
+ "--tumor",
542
+ "tumor_pairs",
543
+ nargs=2,
544
+ multiple=True,
545
+ metavar="R1 R2",
546
+ help="Tumor FASTQ pair -- each of R1/R2 can be an S3 URI (see `dap files list`) or a local file path, "
547
+ "which is uploaded automatically. Repeat for multiple lanes/reps of the same tumor sample -- each "
548
+ "occurrence adds one replicate to this run, it does not start a separate run. Cannot be combined "
549
+ "with --design.",
550
+ )
551
+ @click.option(
552
+ "--normal",
553
+ "normal_pairs",
554
+ nargs=2,
555
+ multiple=True,
556
+ metavar="R1 R2",
557
+ help="Matched-normal FASTQ pair (S3 URI or local file path, same as --tumor). Repeat for multiple "
558
+ "lanes/reps, same as --tumor. Cannot be combined with --design.",
559
+ )
560
+ @click.option(
561
+ "--design",
562
+ type=str,
563
+ default=None,
564
+ help="Build tumor/normal pairs from a CSV file (columns: sample_type,r1,r2), a local directory, or an "
565
+ "s3:// prefix, instead of repeating --tumor/--normal. For a directory/prefix, each FASTQ filename "
566
+ "must contain a 'tumor'/'normal' marker plus an R1/R2 marker (e.g. tumor_lane1_R1.fastq.gz); pairs "
567
+ "are built automatically. Cannot be combined with --tumor/--normal.",
568
+ )
569
+ @click.option("--ref-genome", type=click.Choice(["hg38", "mm10"]), default="hg38", show_default=True)
570
+ @click.option(
571
+ "--kit",
572
+ type=click.Choice(["linkprep", "ffpe", "microc", "hichip", "capture"]),
573
+ default="linkprep",
574
+ show_default=True,
575
+ )
576
+ @click.option("--cancer-type", default="Not_Specified", show_default=True, help="See `dap runs cancer-types`.")
577
+ @click.option("--ai-summary/--no-ai-summary", default=True, show_default=True)
578
+ @click.option("--min-purity", type=float, default=0.1, show_default=True)
579
+ @click.option("--max-purity", type=float, default=1.0, show_default=True)
580
+ @click.option("--min-ploidy", type=float, default=0.0, show_default=True)
581
+ @click.option("--max-ploidy", type=float, default=5.0, show_default=True)
582
+ @click.option("--outdir", default="", help="Custom S3 output location (default: server-assigned).")
583
+ @click.option("-y", "--yes", is_flag=True, help="Skip the confirmation prompt.")
584
+ def submit_sv(
585
+ name,
586
+ tumor_pairs,
587
+ normal_pairs,
588
+ design,
589
+ ref_genome,
590
+ kit,
591
+ cancer_type,
592
+ ai_summary,
593
+ min_purity,
594
+ max_purity,
595
+ min_ploidy,
596
+ max_ploidy,
597
+ outdir,
598
+ yes,
599
+ ):
600
+ """Submit a structural-variant (SV) pipeline run.
601
+
602
+ Each R1/R2 value in --tumor/--normal can be either an S3 URI (already
603
+ registered, e.g. via `dap files upload`) or a local file path -- local
604
+ files are uploaded automatically before the run is submitted.
605
+ Alternatively, pass --design with a CSV file (columns: sample_type,r1,r2),
606
+ a local directory, or an s3:// prefix, instead of repeating
607
+ --tumor/--normal.
608
+ """
609
+ client = _client()
610
+
611
+ if design and (tumor_pairs or normal_pairs):
612
+ raise click.ClickException("--design cannot be combined with --tumor/--normal -- use one or the other.")
613
+ if design:
614
+ tumor_pairs, normal_pairs = _resolve_design(client, design)
615
+ if not tumor_pairs:
616
+ raise click.ClickException("At least one tumor replicate is required (via --tumor or --design).")
617
+
618
+ try:
619
+ valid_cancer_types = client.list_cancer_types()
620
+ except DapError as e:
621
+ raise click.ClickException(str(e))
622
+ if cancer_type not in valid_cancer_types:
623
+ raise click.ClickException(
624
+ f"'{cancer_type}' is not a valid --cancer-type. Run `dap runs cancer-types` to see valid values."
625
+ )
626
+
627
+ def _resolve_pairs(pairs, sample_type):
628
+ resolved = []
629
+ for i, (r1, r2) in enumerate(pairs, start=1):
630
+ r1_uri = _resolve_fastq_uri(client, r1, f"{sample_type} rep {i} R1")
631
+ r2_uri = _resolve_fastq_uri(client, r2, f"{sample_type} rep {i} R2")
632
+ resolved.append({"r1": r1_uri, "r2": r2_uri})
633
+ return resolved
634
+
635
+ tumor_fastq_pairs = _resolve_pairs(tumor_pairs, "tumor")
636
+ normal_fastq_pairs = _resolve_pairs(normal_pairs, "normal")
637
+
638
+ try:
639
+ estimate = client.estimate_credits(tumor_fastq_pairs + normal_fastq_pairs)
640
+ account = client.whoami()
641
+ except DapError as e:
642
+ raise click.ClickException(str(e))
643
+
644
+ click.echo(f"Name: {name}")
645
+ click.echo(f"Reference genome: {ref_genome}")
646
+ click.echo(f"Kit: {kit}")
647
+ click.echo(f"Cancer type: {cancer_type}")
648
+ click.echo(f"Tumor reps: {len(tumor_fastq_pairs)}")
649
+ click.echo(f"Normal reps: {len(normal_fastq_pairs)}")
650
+ click.echo(f"Estimated cost: {estimate['estimated_credit_use']} credits (you have {account['credits']})")
651
+
652
+ if not yes and not click.confirm("Submit this run?"):
653
+ click.echo("Aborted.")
654
+ return
655
+
656
+ try:
657
+ result = client.submit_sv_run(
658
+ name=name,
659
+ tumor_fastq_pairs=tumor_fastq_pairs,
660
+ normal_fastq_pairs=normal_fastq_pairs,
661
+ ref_genome=ref_genome,
662
+ kit=kit,
663
+ cancer_type=cancer_type,
664
+ ai_summary="yes" if ai_summary else "no",
665
+ min_purity=min_purity,
666
+ max_purity=max_purity,
667
+ min_ploidy=min_ploidy,
668
+ max_ploidy=max_ploidy,
669
+ outdir=outdir,
670
+ )
671
+ except DapError as e:
672
+ raise click.ClickException(str(e))
673
+
674
+ click.echo(f"Submitted: {result['name']} ({result['id']}) -- status={_display_status(result['status'])}")
675
+
676
+
677
+ if __name__ == "__main__":
678
+ main()
dap_cli/client.py ADDED
@@ -0,0 +1,124 @@
1
+ """
2
+ Thin HTTP client for the DAP CLI JSON API (a_cli/views.py, server-side).
3
+ Every method sets Authorization: Bearer <token> and raises a clear error on
4
+ a non-2xx response, rather than letting a requests.HTTPError/JSON-parsing
5
+ error leak up unformatted.
6
+ """
7
+
8
+ import requests
9
+
10
+ from dap_cli import config
11
+
12
+
13
+ class DapError(Exception):
14
+ """A DAP API call failed (non-2xx response, error message from the server)."""
15
+
16
+
17
+ class NotLoggedInError(DapError):
18
+ """No token saved locally, or the server rejected it -- run `dap login`."""
19
+
20
+
21
+ class DapClient:
22
+ def __init__(self, api_url=None, token=None):
23
+ self.api_url = (api_url or config.get_api_url()).rstrip("/")
24
+ self.token = token if token is not None else config.get_token()
25
+ self.session = requests.Session()
26
+
27
+ def _url(self, path):
28
+ return f"{self.api_url}{path}"
29
+
30
+ def _headers(self):
31
+ if not self.token:
32
+ raise NotLoggedInError("Not logged in -- run `dap login` first")
33
+ return {"Authorization": f"Bearer {self.token}"}
34
+
35
+ def _check(self, response):
36
+ if response.status_code == 401:
37
+ raise NotLoggedInError("Session expired or invalid -- run `dap login` again")
38
+ if not response.ok:
39
+ try:
40
+ message = response.json().get("error", response.text)
41
+ except ValueError:
42
+ message = response.text
43
+ raise DapError(f"{response.status_code}: {message}")
44
+ return response
45
+
46
+ def login(self, email, password):
47
+ response = self.session.post(self._url("/cli/auth/login"), json={"email": email, "password": password})
48
+ return self._check(response).json()["token"]
49
+
50
+ def whoami(self):
51
+ response = self.session.get(self._url("/cli/whoami"), headers=self._headers())
52
+ return self._check(response).json()
53
+
54
+ def list_files(self):
55
+ response = self.session.get(self._url("/cli/files"), headers=self._headers())
56
+ return self._check(response).json()["files"]
57
+
58
+ def get_download_link(self, file_id):
59
+ response = self.session.get(self._url(f"/cli/files/{file_id}/download"), headers=self._headers())
60
+ return self._check(response).json()
61
+
62
+ def create_upload(self, filename, content_type):
63
+ response = self.session.post(
64
+ self._url("/cli/uploads"),
65
+ headers=self._headers(),
66
+ json={"filename": filename, "content_type": content_type},
67
+ )
68
+ return self._check(response).json()
69
+
70
+ def sign_upload_part(self, upload_id, key, part_number):
71
+ response = self.session.get(
72
+ self._url(f"/cli/uploads/{upload_id}/parts"),
73
+ headers=self._headers(),
74
+ params={"key": key, "part_number": part_number},
75
+ )
76
+ return self._check(response).json()["url"]
77
+
78
+ def complete_upload(self, upload_id, key, parts):
79
+ response = self.session.post(
80
+ self._url(f"/cli/uploads/{upload_id}/complete"),
81
+ headers=self._headers(),
82
+ json={"key": key, "parts": parts},
83
+ )
84
+ return self._check(response).json()
85
+
86
+ def abort_upload(self, upload_id, key):
87
+ response = self.session.delete(
88
+ self._url(f"/cli/uploads/{upload_id}"), headers=self._headers(), params={"key": key}
89
+ )
90
+ self._check(response)
91
+
92
+ def list_runs(self, status=None, pipeline=None, limit=20):
93
+ params = {"limit": limit}
94
+ if status:
95
+ params["status"] = status
96
+ if pipeline:
97
+ params["pipeline"] = pipeline
98
+ response = self.session.get(self._url("/cli/runs"), headers=self._headers(), params=params)
99
+ return self._check(response).json()["runs"]
100
+
101
+ def get_run(self, run_id):
102
+ response = self.session.get(self._url(f"/cli/runs/{run_id}"), headers=self._headers())
103
+ return self._check(response).json()
104
+
105
+ def submit_sv_run(self, **kwargs):
106
+ response = self.session.post(self._url("/cli/runs/sv"), headers=self._headers(), json=kwargs)
107
+ return self._check(response).json()
108
+
109
+ def list_s3_prefix(self, prefix):
110
+ response = self.session.get(
111
+ self._url("/cli/design/s3-list"), headers=self._headers(), params={"prefix": prefix}
112
+ )
113
+ files = self._check(response).json()["files"]
114
+ return [f["uri"] for f in files]
115
+
116
+ def list_cancer_types(self):
117
+ response = self.session.get(self._url("/cli/cancer-types"), headers=self._headers())
118
+ return self._check(response).json()["cancer_types"]
119
+
120
+ def estimate_credits(self, fastq_pairs):
121
+ response = self.session.post(
122
+ self._url("/cli/estimate-credits"), headers=self._headers(), json={"fastq_pairs": fastq_pairs}
123
+ )
124
+ return self._check(response).json()
dap_cli/config.py ADDED
@@ -0,0 +1,49 @@
1
+ """
2
+ Local config storage for the `dap` CLI: ~/.dap/config.json holding the
3
+ bearer token issued by `dap login` and the API base URL to talk to. This
4
+ runs entirely on the user's own machine -- separate from anything the
5
+ Django server persists.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+
12
+ DEFAULT_API_URL = "https://portal.cantatabio.com"
13
+ CONFIG_DIR = Path(os.environ.get("DAP_CONFIG_DIR", Path.home() / ".dap"))
14
+ CONFIG_PATH = CONFIG_DIR / "config.json"
15
+
16
+
17
+ def load():
18
+ if not CONFIG_PATH.exists():
19
+ return {}
20
+ with open(CONFIG_PATH) as f:
21
+ return json.load(f)
22
+
23
+
24
+ def save(data):
25
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
26
+ with open(CONFIG_PATH, "w") as f:
27
+ json.dump(data, f)
28
+ os.chmod(CONFIG_PATH, 0o600)
29
+
30
+
31
+ def get_api_url():
32
+ return os.environ.get("DAP_API_URL") or load().get("api_url") or DEFAULT_API_URL
33
+
34
+
35
+ def get_token():
36
+ return load().get("token")
37
+
38
+
39
+ def set_token(token, api_url=None):
40
+ data = load()
41
+ data["token"] = token
42
+ if api_url:
43
+ data["api_url"] = api_url
44
+ save(data)
45
+
46
+
47
+ def clear():
48
+ if CONFIG_PATH.exists():
49
+ CONFIG_PATH.unlink()
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: dovecli
3
+ Version: 0.1.0
4
+ Summary: Command-line client for the Dovetail Analysis Portal (DAP): authenticate, list, and submit a run, download, and upload files.
5
+ Author-email: Ekkachai Danwanichakul <edanwanichakul@dovetail-genomics.com>
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: click>=8.1
9
+ Requires-Dist: requests>=2.31
10
+
11
+ # DAP CLI
12
+
13
+ `dap` is the command-line client for the Dovetail Analysis Portal (DAP):
14
+ log in, list and submit runs, and upload/download files without leaving a
15
+ terminal. It talks to the DAP server over plain HTTP and is a standalone
16
+ package, separate from the DAP web app itself.
17
+
18
+ ## Install
19
+
20
+ ```console
21
+ $ pip install dapcli
22
+ ```
23
+
24
+ This installs the `dap` command on your `PATH`, along with its only two
25
+ runtime dependencies, [click](https://click.palletsprojects.com/) and
26
+ [requests](https://requests.readthedocs.io/). Requires Python 3.9+.
27
+
28
+ ## Quickstart
29
+
30
+ ```console
31
+ $ dap login
32
+ Email: you@example.com
33
+ Password:
34
+ Logged in.
35
+
36
+ $ dap whoami
37
+ you@example.com -- 5 credits
38
+
39
+ $ dap files list
40
+ $ dap files upload /path/to/reads_R1.fastq.gz
41
+
42
+ $ dap runs cancer-types
43
+ $ dap submit sv --name my-first-run \
44
+ --tumor s3://bucket/user_1/tumor_R1.fastq.gz s3://bucket/user_1/tumor_R2.fastq.gz \
45
+ --cancer-type Breast
46
+
47
+ $ dap runs list
48
+ $ dap runs status <run-id> --watch
49
+ ```
50
+
51
+ By default `dap` talks to `https://portal.cantatabio.com`. Point it at a
52
+ different server with `dap login --api-url ...` or the `DAP_API_URL`
53
+ environment variable. Your login token is stored locally at
54
+ `~/.dap/config.json` (override with `DAP_CONFIG_DIR`); your password is
55
+ never saved.
@@ -0,0 +1,9 @@
1
+ dap_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ dap_cli/cli.py,sha256=Hcg4Uliz-A75JtFiQKg2tJsCKzAqsUuINE6RzxJKN-c,26755
3
+ dap_cli/client.py,sha256=tyENzNcy-aFCY_RfmTbIDGweh76QbvnPMZDzG2NsJgg,4750
4
+ dap_cli/config.py,sha256=Lc-JxVyHL4M3TMooFy-DCldyUALrd4I2ogaL-Og41Yo,1128
5
+ dovecli-0.1.0.dist-info/METADATA,sha256=0WkhCoenrSpBcx0q6JbIwLxfbbo8FDhy06nYh7GtyGQ,1630
6
+ dovecli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
+ dovecli-0.1.0.dist-info/entry_points.txt,sha256=Hqq81rzGpxHw35IvqmA0PzubcnyAP9KY13bJiyI-yxg,41
8
+ dovecli-0.1.0.dist-info/top_level.txt,sha256=OyCJdddmXnFNaVpSblKod3JjwS6XBdtCbafypjAjPDk,8
9
+ dovecli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dap = dap_cli.cli:main
@@ -0,0 +1 @@
1
+ dap_cli