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 +0 -0
- dap_cli/cli.py +678 -0
- dap_cli/client.py +124 -0
- dap_cli/config.py +49 -0
- dovecli-0.1.0.dist-info/METADATA +55 -0
- dovecli-0.1.0.dist-info/RECORD +9 -0
- dovecli-0.1.0.dist-info/WHEEL +5 -0
- dovecli-0.1.0.dist-info/entry_points.txt +2 -0
- dovecli-0.1.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
1
|
+
dap_cli
|