ipaapi 1.0.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.
- ipaapi/__init__.py +92 -0
- ipaapi/_payload.py +150 -0
- ipaapi/auth.py +575 -0
- ipaapi/cli.py +971 -0
- ipaapi/client.py +658 -0
- ipaapi/dataset.py +285 -0
- ipaapi/errors.py +101 -0
- ipaapi/history.py +145 -0
- ipaapi/mapping.py +485 -0
- ipaapi/models.py +193 -0
- ipaapi/triage.py +136 -0
- ipaapi-1.0.0.dist-info/METADATA +833 -0
- ipaapi-1.0.0.dist-info/RECORD +16 -0
- ipaapi-1.0.0.dist-info/WHEEL +4 -0
- ipaapi-1.0.0.dist-info/entry_points.txt +2 -0
- ipaapi-1.0.0.dist-info/licenses/LICENSE +21 -0
ipaapi/cli.py
ADDED
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
"""Command-line interface for :mod:`ipaapi`.
|
|
2
|
+
|
|
3
|
+
Installed as the ``ipaapi`` console script::
|
|
4
|
+
|
|
5
|
+
ipaapi --help
|
|
6
|
+
ipaapi submit data.txt --ID 0:ensembl --FC 1:foldchange --project MyProject
|
|
7
|
+
|
|
8
|
+
Column positions are **0-based**: ``--ID 0`` is the first column in the file.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import fnmatch
|
|
15
|
+
import pathlib
|
|
16
|
+
import sys
|
|
17
|
+
from typing import List, Optional, Sequence, Tuple
|
|
18
|
+
|
|
19
|
+
from . import __version__, history
|
|
20
|
+
from .dataset import Dataset, load_table
|
|
21
|
+
from .errors import (
|
|
22
|
+
AnalysisRefusedError,
|
|
23
|
+
IPAError,
|
|
24
|
+
MalformedRequestError,
|
|
25
|
+
QuotaExceededError,
|
|
26
|
+
)
|
|
27
|
+
from .mapping import ColumnMapping, Measurement, Observation
|
|
28
|
+
from .models import GENE_ID_TYPES, MeasurementType, ReferenceSet
|
|
29
|
+
from .triage import TRIAGE_DIRNAMES, Triage
|
|
30
|
+
|
|
31
|
+
__all__ = ["main"]
|
|
32
|
+
|
|
33
|
+
#: Extensions searched in a directory when no --pattern is given.
|
|
34
|
+
TABLE_PATTERNS = ("*.txt", "*.tsv", "*.csv")
|
|
35
|
+
|
|
36
|
+
_GLOB_CHARS = set("*?[")
|
|
37
|
+
|
|
38
|
+
# The accepted gene ID vocabulary is documented (IPA Integration Module, April
|
|
39
|
+
# 2026, section 3.1) and lives in models.GENE_ID_TYPES. Nothing is guessed here
|
|
40
|
+
# any more: an earlier hand-made list of "common" types walked users straight
|
|
41
|
+
# into failed submissions, because plausible names like 'genesymbol' and 'hgnc'
|
|
42
|
+
# are not accepted while 'hugo' is.
|
|
43
|
+
|
|
44
|
+
#: A short list for help text; the full mapping is in GENE_ID_TYPES.
|
|
45
|
+
COMMON_ID_TYPES = ("ensembl", "hugo", "entrezgene", "refseq", "swissprot")
|
|
46
|
+
|
|
47
|
+
_EPILOG = f"""\
|
|
48
|
+
column positions are 0-based: --ID 0 is the first column in the file
|
|
49
|
+
|
|
50
|
+
--ID may be given up to twice. The first is the primary identifier and is what
|
|
51
|
+
IPA is told the gene ID type is. The second, if present, is used only for rows
|
|
52
|
+
where the primary is blank. Because IPA accepts one gene ID type per submission,
|
|
53
|
+
rows filled from a second identifier of a different type are uploaded under the
|
|
54
|
+
primary's type and may not map; the fill count is always reported.
|
|
55
|
+
|
|
56
|
+
common gene ID types: {', '.join(COMMON_ID_TYPES)}
|
|
57
|
+
Species is carried by the ID type, not a separate parameter: hugo is human,
|
|
58
|
+
mousesymeg mouse, ratsymeg rat. Note 'genesymbol' and 'hgnc' are NOT accepted
|
|
59
|
+
for gene symbols -- the value is 'hugo'. Run with --list-id-types for all of
|
|
60
|
+
them.
|
|
61
|
+
measurement types for --FC: ratio, foldchange, logratio
|
|
62
|
+
|
|
63
|
+
PATH may be a single file or a directory. Given a directory, --pattern selects
|
|
64
|
+
which files to use and each matched file is submitted as its own dataset and
|
|
65
|
+
analysis, named after the file. Every file must fit the same --ID/--FC layout;
|
|
66
|
+
all of them are validated before any is uploaded.
|
|
67
|
+
|
|
68
|
+
examples:
|
|
69
|
+
ipaapi validate rnaseq.txt --ID 0:ensembl --FC 1:foldchange
|
|
70
|
+
ipaapi submit rnaseq.txt --ID 0:ensembl --FC 1:foldchange:1.5 --project Study1
|
|
71
|
+
ipaapi submit rnaseq.txt --ID 0:ensembl --ID 4:hugo \\
|
|
72
|
+
--FC 1:foldchange --project Study1
|
|
73
|
+
|
|
74
|
+
# every .txt/.tsv/.csv in a folder, one analysis each
|
|
75
|
+
ipaapi submit ~/data --ID 0:ensembl --FC 1:foldchange --project Study1
|
|
76
|
+
|
|
77
|
+
# only files whose name contains SampleA
|
|
78
|
+
ipaapi validate ~/data --pattern SampleA --ID 0:ensembl --FC 1:foldchange
|
|
79
|
+
|
|
80
|
+
# glob, searching subfolders too
|
|
81
|
+
ipaapi submit ~/data --pattern "*_DEG.tsv" --recursive \\
|
|
82
|
+
--ID 0:ensembl --FC 1:foldchange --project GroupB
|
|
83
|
+
|
|
84
|
+
# submit returns as soon as the analyses are queued; --wait blocks instead
|
|
85
|
+
ipaapi submit rnaseq.txt --ID 0:ensembl --FC 1:foldchange --project Study1 --wait
|
|
86
|
+
|
|
87
|
+
ipaapi status abc-123 abc-124
|
|
88
|
+
ipaapi report abc-123 --open
|
|
89
|
+
|
|
90
|
+
# every analysis you have submitted through this tool, with current status
|
|
91
|
+
ipaapi history --status
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _Formatter(
|
|
96
|
+
argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter
|
|
97
|
+
):
|
|
98
|
+
"""Show defaults, but leave the epilog's line breaks alone."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def version_banner() -> str:
|
|
102
|
+
"""Version plus enough context to identify *which* install is running.
|
|
103
|
+
|
|
104
|
+
With several machines and hand-built wheels in play, the version number
|
|
105
|
+
alone does not answer "am I running the build I think I am". The install
|
|
106
|
+
path and interpreter do.
|
|
107
|
+
"""
|
|
108
|
+
import sys as _sys
|
|
109
|
+
|
|
110
|
+
location = pathlib.Path(__file__).resolve().parent
|
|
111
|
+
lines = [
|
|
112
|
+
f"ipaapi {__version__}",
|
|
113
|
+
f"installed at {location}",
|
|
114
|
+
f"python {_sys.version.split()[0]} ({_sys.executable})",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
# An editable install runs straight from a checkout, where the working tree
|
|
118
|
+
# may be ahead of the version number. Say so rather than let it mislead.
|
|
119
|
+
if (location.parent.parent / ".git").exists():
|
|
120
|
+
lines.append("running from a source checkout (editable install)")
|
|
121
|
+
return "\n".join(lines)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# -- argument specs --------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_id_spec(text: str) -> Tuple[int, str]:
|
|
128
|
+
"""Parse ``COLUMN:TYPE`` for ``--ID``, e.g. ``0:ensembl``."""
|
|
129
|
+
parts = text.split(":")
|
|
130
|
+
if len(parts) != 2:
|
|
131
|
+
raise argparse.ArgumentTypeError(
|
|
132
|
+
f"--ID expects COLUMN:TYPE (for example 0:ensembl), got {text!r}."
|
|
133
|
+
)
|
|
134
|
+
column, id_type = parts[0].strip(), parts[1].strip()
|
|
135
|
+
if not id_type:
|
|
136
|
+
raise argparse.ArgumentTypeError(
|
|
137
|
+
f"--ID {text!r} is missing the identifier type, e.g. "
|
|
138
|
+
f"{text.rstrip(':')}:{COMMON_ID_TYPES[0]}."
|
|
139
|
+
)
|
|
140
|
+
if id_type.lower() not in GENE_ID_TYPES:
|
|
141
|
+
close = [t for t in GENE_ID_TYPES if id_type.lower() in t or t in id_type.lower()]
|
|
142
|
+
hint = f" Did you mean: {', '.join(sorted(close))}?" if close else ""
|
|
143
|
+
print(
|
|
144
|
+
f"Warning: {id_type!r} is not in IPA's documented gene ID type list."
|
|
145
|
+
+ hint
|
|
146
|
+
+ " Sending it anyway; run 'ipaapi submit --list-id-types' for the"
|
|
147
|
+
" full list.",
|
|
148
|
+
file=sys.stderr,
|
|
149
|
+
)
|
|
150
|
+
return _parse_index(column, "--ID"), id_type
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def parse_fc_spec(text: str) -> Tuple[int, MeasurementType, Optional[float]]:
|
|
154
|
+
"""Parse ``COLUMN:TYPE[:CUTOFF]`` for ``--FC``, e.g. ``1:foldchange:1.5``."""
|
|
155
|
+
parts = text.split(":")
|
|
156
|
+
if len(parts) not in (2, 3):
|
|
157
|
+
raise argparse.ArgumentTypeError(
|
|
158
|
+
f"--FC expects COLUMN:TYPE[:CUTOFF] (for example 1:foldchange:1.5), "
|
|
159
|
+
f"got {text!r}."
|
|
160
|
+
)
|
|
161
|
+
index = _parse_index(parts[0].strip(), "--FC")
|
|
162
|
+
|
|
163
|
+
raw_type = parts[1].strip().lower()
|
|
164
|
+
try:
|
|
165
|
+
measurement = MeasurementType(raw_type)
|
|
166
|
+
except ValueError:
|
|
167
|
+
allowed = ", ".join(m.value for m in MeasurementType)
|
|
168
|
+
raise argparse.ArgumentTypeError(
|
|
169
|
+
f"--FC has unknown measurement type {raw_type!r}. Allowed: {allowed}."
|
|
170
|
+
) from None
|
|
171
|
+
|
|
172
|
+
cutoff = None
|
|
173
|
+
if len(parts) == 3 and parts[2].strip():
|
|
174
|
+
try:
|
|
175
|
+
cutoff = float(parts[2])
|
|
176
|
+
except ValueError:
|
|
177
|
+
raise argparse.ArgumentTypeError(
|
|
178
|
+
f"--FC cutoff {parts[2]!r} is not a number."
|
|
179
|
+
) from None
|
|
180
|
+
return index, measurement, cutoff
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _parse_index(text: str, flag: str) -> int:
|
|
184
|
+
try:
|
|
185
|
+
index = int(text)
|
|
186
|
+
except ValueError:
|
|
187
|
+
raise argparse.ArgumentTypeError(
|
|
188
|
+
f"{flag} column must be a 0-based integer position, got {text!r}."
|
|
189
|
+
) from None
|
|
190
|
+
if index < 0:
|
|
191
|
+
raise argparse.ArgumentTypeError(
|
|
192
|
+
f"{flag} column must be 0 or greater (positions are 0-based), got {index}."
|
|
193
|
+
)
|
|
194
|
+
return index
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _column_at(columns: Sequence[str], index: int, flag: str) -> str:
|
|
198
|
+
"""Resolve a 0-based position to a column name, with a legible error."""
|
|
199
|
+
if index >= len(columns):
|
|
200
|
+
listing = ", ".join(f"{i}={c!r}" for i, c in enumerate(columns[:12]))
|
|
201
|
+
raise IPAError(
|
|
202
|
+
f"{flag} refers to column {index}, but the file has only {len(columns)} "
|
|
203
|
+
f"column(s) (positions 0-{len(columns) - 1}). Columns: {listing}"
|
|
204
|
+
+ (" ..." if len(columns) > 12 else "")
|
|
205
|
+
)
|
|
206
|
+
return columns[index]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# -- mapping construction --------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def build_mapping(
|
|
213
|
+
columns: Sequence[str],
|
|
214
|
+
id_specs: List[Tuple[int, str]],
|
|
215
|
+
fc_spec: Tuple[int, MeasurementType, Optional[float]],
|
|
216
|
+
observation_name: Optional[str] = None,
|
|
217
|
+
) -> ColumnMapping:
|
|
218
|
+
"""Turn parsed CLI specs into a :class:`ColumnMapping`."""
|
|
219
|
+
if not id_specs:
|
|
220
|
+
raise IPAError("--ID is required.")
|
|
221
|
+
if len(id_specs) > 2:
|
|
222
|
+
raise IPAError(
|
|
223
|
+
f"--ID may be given at most twice (primary and fallback); got "
|
|
224
|
+
f"{len(id_specs)}."
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
primary_index, primary_type = id_specs[0]
|
|
228
|
+
primary_column = _column_at(columns, primary_index, "--ID")
|
|
229
|
+
|
|
230
|
+
fallback_column = fallback_type = None
|
|
231
|
+
if len(id_specs) == 2:
|
|
232
|
+
fallback_index, fallback_type = id_specs[1]
|
|
233
|
+
fallback_column = _column_at(columns, fallback_index, "--ID")
|
|
234
|
+
if fallback_index == primary_index:
|
|
235
|
+
raise IPAError(
|
|
236
|
+
f"Both --ID flags refer to column {primary_index} "
|
|
237
|
+
f"({primary_column!r}); the fallback must be a different column."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
fc_index, fc_type, fc_cutoff = fc_spec
|
|
241
|
+
fc_column = _column_at(columns, fc_index, "--FC")
|
|
242
|
+
if fc_column in (primary_column, fallback_column):
|
|
243
|
+
raise IPAError(
|
|
244
|
+
f"--FC refers to column {fc_index} ({fc_column!r}), which is already "
|
|
245
|
+
"used as an identifier column."
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return ColumnMapping(
|
|
249
|
+
gene_id_column=primary_column,
|
|
250
|
+
gene_id_type=primary_type,
|
|
251
|
+
gene_id_fallback_column=fallback_column,
|
|
252
|
+
gene_id_fallback_type=fallback_type,
|
|
253
|
+
observations=[
|
|
254
|
+
Observation(
|
|
255
|
+
name=observation_name or fc_column,
|
|
256
|
+
measurements=[Measurement(fc_column, fc_type, cutoff=fc_cutoff)],
|
|
257
|
+
)
|
|
258
|
+
],
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# -- file discovery --------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def expand_pattern(pattern: Optional[str]) -> List[str]:
|
|
266
|
+
"""Turn a ``--pattern`` value into fnmatch patterns.
|
|
267
|
+
|
|
268
|
+
Plain search text with no glob characters is treated as a substring, so
|
|
269
|
+
``--pattern SampleA`` matches ``SampleA_DEG.txt``. Anything containing
|
|
270
|
+
``*``, ``?`` or ``[`` is used verbatim. With no pattern at all, the common
|
|
271
|
+
delimited-text extensions are searched.
|
|
272
|
+
"""
|
|
273
|
+
if pattern is None:
|
|
274
|
+
return list(TABLE_PATTERNS)
|
|
275
|
+
pattern = pattern.strip()
|
|
276
|
+
if not pattern:
|
|
277
|
+
return list(TABLE_PATTERNS)
|
|
278
|
+
if any(char in pattern for char in _GLOB_CHARS):
|
|
279
|
+
return [pattern]
|
|
280
|
+
return [f"*{pattern}*"]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def discover_files(
|
|
284
|
+
path: str, pattern: Optional[str] = None, recursive: bool = False
|
|
285
|
+
) -> List[pathlib.Path]:
|
|
286
|
+
"""Return the files to submit, sorted for a predictable run order.
|
|
287
|
+
|
|
288
|
+
*path* may be a single file, in which case it is returned as-is, or a
|
|
289
|
+
directory to search.
|
|
290
|
+
"""
|
|
291
|
+
root = pathlib.Path(path).expanduser()
|
|
292
|
+
if root.is_file():
|
|
293
|
+
return [root]
|
|
294
|
+
if not root.exists():
|
|
295
|
+
raise IPAError(f"No such file or directory: {str(root)!r}")
|
|
296
|
+
if not root.is_dir():
|
|
297
|
+
raise IPAError(f"Not a readable file or directory: {str(root)!r}")
|
|
298
|
+
|
|
299
|
+
patterns = expand_pattern(pattern)
|
|
300
|
+
candidates = root.rglob("*") if recursive else root.glob("*")
|
|
301
|
+
matched = sorted(
|
|
302
|
+
candidate
|
|
303
|
+
for candidate in candidates
|
|
304
|
+
if candidate.is_file()
|
|
305
|
+
and not candidate.name.startswith(".")
|
|
306
|
+
# Files already filed into submitted/ or failed/ are not input, or a
|
|
307
|
+
# second run would resubmit work that succeeded the first time.
|
|
308
|
+
and not (TRIAGE_DIRNAMES & set(candidate.relative_to(root).parts[:-1]))
|
|
309
|
+
and any(fnmatch.fnmatch(candidate.name, pat) for pat in patterns)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if not matched:
|
|
313
|
+
present = sorted(
|
|
314
|
+
child.name for child in root.iterdir() if child.is_file()
|
|
315
|
+
)[:10]
|
|
316
|
+
raise IPAError(
|
|
317
|
+
f"No files in {str(root)!r} matched {', '.join(repr(p) for p in patterns)}"
|
|
318
|
+
+ (" (searched recursively)" if recursive else "")
|
|
319
|
+
+ (
|
|
320
|
+
"\nFiles present: " + ", ".join(repr(n) for n in present)
|
|
321
|
+
if present
|
|
322
|
+
else "\nThe directory contains no files."
|
|
323
|
+
)
|
|
324
|
+
+ ("\nUse --recursive to search subdirectories." if not recursive else "")
|
|
325
|
+
)
|
|
326
|
+
return matched
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# -- dataset loading -------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
_SINGLE_FILE_FLAGS = ("observation", "analysis_name", "dataset_name")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _load_datasets(args) -> Tuple[List[Dataset], List[Tuple[pathlib.Path, str]]]:
|
|
335
|
+
"""Discover files, build the mapping for each, and validate them.
|
|
336
|
+
|
|
337
|
+
Returns the datasets that validated and a list of ``(path, reason)`` for
|
|
338
|
+
those that did not. Callers decide what to do with the failures: `validate`
|
|
339
|
+
reports them all and stops, while `submit` on a directory files them into
|
|
340
|
+
``failed/`` and carries on with the rest.
|
|
341
|
+
"""
|
|
342
|
+
paths = discover_files(args.path, getattr(args, "pattern", None), getattr(args, "recursive", False))
|
|
343
|
+
|
|
344
|
+
if len(paths) > 1:
|
|
345
|
+
for attr in _SINGLE_FILE_FLAGS:
|
|
346
|
+
if getattr(args, attr, None):
|
|
347
|
+
flag = "--" + attr.replace("_", "-")
|
|
348
|
+
raise IPAError(
|
|
349
|
+
f"{flag} applies to a single file, but {len(paths)} files matched. "
|
|
350
|
+
"Names are taken from each filename; use --project to group them."
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
datasets: List[Dataset] = []
|
|
354
|
+
problems: List[Tuple[pathlib.Path, str]] = []
|
|
355
|
+
for path in paths:
|
|
356
|
+
try:
|
|
357
|
+
frame = load_table(path, sep=args.sep, skip_rows=args.skip_rows)
|
|
358
|
+
mapping = build_mapping(
|
|
359
|
+
columns=list(frame.columns),
|
|
360
|
+
id_specs=args.ID,
|
|
361
|
+
fc_spec=args.FC,
|
|
362
|
+
observation_name=getattr(args, "observation", None) or path.stem,
|
|
363
|
+
)
|
|
364
|
+
dataset = Dataset.from_frame(
|
|
365
|
+
frame,
|
|
366
|
+
mapping,
|
|
367
|
+
name=getattr(args, "dataset_name", None) or path.stem,
|
|
368
|
+
check_ranges=not args.no_range_check,
|
|
369
|
+
)
|
|
370
|
+
dataset.source_path = str(path.resolve())
|
|
371
|
+
datasets.append(dataset)
|
|
372
|
+
except IPAError as exc:
|
|
373
|
+
problems.append((path, str(exc)))
|
|
374
|
+
|
|
375
|
+
return datasets, problems
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# -- shared arguments ------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _add_mapping_arguments(parser: argparse.ArgumentParser) -> None:
|
|
382
|
+
parser.add_argument(
|
|
383
|
+
"path",
|
|
384
|
+
help="a delimited dataset file, or a directory to search for them",
|
|
385
|
+
)
|
|
386
|
+
parser.add_argument(
|
|
387
|
+
"--pattern",
|
|
388
|
+
default=None,
|
|
389
|
+
metavar="TEXT",
|
|
390
|
+
help="when PATH is a directory, only use files matching this. Plain text "
|
|
391
|
+
"matches as a substring (SampleA finds SampleA_DEG.txt); text containing "
|
|
392
|
+
"* ? or [ is treated as a glob. Default: "
|
|
393
|
+
+ ", ".join(TABLE_PATTERNS),
|
|
394
|
+
)
|
|
395
|
+
parser.add_argument(
|
|
396
|
+
"--recursive",
|
|
397
|
+
action="store_true",
|
|
398
|
+
help="search subdirectories of PATH as well",
|
|
399
|
+
)
|
|
400
|
+
parser.add_argument(
|
|
401
|
+
"--ID",
|
|
402
|
+
action="append",
|
|
403
|
+
required=True,
|
|
404
|
+
type=parse_id_spec,
|
|
405
|
+
metavar="COLUMN:TYPE",
|
|
406
|
+
help="0-based identifier column and its IPA gene ID type, e.g. 0:ensembl. "
|
|
407
|
+
"IPA validates the type and names it if unrecognised. "
|
|
408
|
+
"Give twice for a fallback identifier used where the primary is blank",
|
|
409
|
+
)
|
|
410
|
+
parser.add_argument(
|
|
411
|
+
"--FC",
|
|
412
|
+
required=True,
|
|
413
|
+
type=parse_fc_spec,
|
|
414
|
+
metavar="COLUMN:TYPE[:CUTOFF]",
|
|
415
|
+
help="0-based fold-change column, its measurement type and optional "
|
|
416
|
+
"cutoff, e.g. 1:foldchange:1.5",
|
|
417
|
+
)
|
|
418
|
+
parser.add_argument(
|
|
419
|
+
"--sep",
|
|
420
|
+
default=None,
|
|
421
|
+
help="field delimiter; sniffed from the header line when omitted",
|
|
422
|
+
)
|
|
423
|
+
parser.add_argument(
|
|
424
|
+
"--skip-rows",
|
|
425
|
+
type=int,
|
|
426
|
+
default=0,
|
|
427
|
+
metavar="N",
|
|
428
|
+
help="discard N lines before the header row, for files with a comment "
|
|
429
|
+
"or title line above it. Column numbers still count from the header",
|
|
430
|
+
)
|
|
431
|
+
parser.add_argument(
|
|
432
|
+
"--observation",
|
|
433
|
+
default=None,
|
|
434
|
+
help="observation name shown in IPA (default: the filename). Single file only",
|
|
435
|
+
)
|
|
436
|
+
parser.add_argument(
|
|
437
|
+
"--no-range-check",
|
|
438
|
+
action="store_true",
|
|
439
|
+
help="skip checking that values fall in the range IPA expects for their type",
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _add_auth_arguments(parser: argparse.ArgumentParser) -> None:
|
|
444
|
+
parser.add_argument(
|
|
445
|
+
"--no-cache", action="store_true", help="ignore any cached OAuth token"
|
|
446
|
+
)
|
|
447
|
+
parser.add_argument(
|
|
448
|
+
"--application-name",
|
|
449
|
+
default="PythonAPI",
|
|
450
|
+
help="applicationname IPA scopes the session to",
|
|
451
|
+
)
|
|
452
|
+
parser.add_argument(
|
|
453
|
+
"--token-file",
|
|
454
|
+
default=None,
|
|
455
|
+
metavar="PATH",
|
|
456
|
+
help="token cache to use instead of the default in ~/.cache/ipaapi. "
|
|
457
|
+
"Point this at a cache copied from a machine that can run a browser",
|
|
458
|
+
)
|
|
459
|
+
parser.add_argument(
|
|
460
|
+
"--browser",
|
|
461
|
+
default=None,
|
|
462
|
+
metavar="NAME",
|
|
463
|
+
help="browser to open for login, e.g. firefox. Only used when a login "
|
|
464
|
+
"is actually needed; under ssh -X this displays on your local machine",
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _client(args):
|
|
469
|
+
from .auth import TokenCache
|
|
470
|
+
from .client import IPAClient
|
|
471
|
+
|
|
472
|
+
cache = None
|
|
473
|
+
if not args.no_cache:
|
|
474
|
+
token_file = getattr(args, "token_file", None)
|
|
475
|
+
cache = TokenCache(path=token_file) if token_file else TokenCache()
|
|
476
|
+
|
|
477
|
+
return IPAClient.login(
|
|
478
|
+
cache=cache,
|
|
479
|
+
application_name=args.application_name,
|
|
480
|
+
browser=getattr(args, "browser", None),
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# -- subcommands -----------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def cmd_validate(args) -> int:
|
|
488
|
+
"""Check the mapping against the file(s) without contacting IPA."""
|
|
489
|
+
datasets, problems = _load_datasets(args)
|
|
490
|
+
if problems:
|
|
491
|
+
raise IPAError(
|
|
492
|
+
f"{len(problems)} of {len(datasets) + len(problems)} file(s) do not fit "
|
|
493
|
+
"the mapping:\n - "
|
|
494
|
+
+ "\n - ".join(f"{path.name}: {reason}" for path, reason in problems)
|
|
495
|
+
)
|
|
496
|
+
for index, dataset in enumerate(datasets):
|
|
497
|
+
if index:
|
|
498
|
+
print()
|
|
499
|
+
print(dataset.describe())
|
|
500
|
+
if len(datasets) == 1:
|
|
501
|
+
print()
|
|
502
|
+
print(dataset.preview())
|
|
503
|
+
noun = "file" if len(datasets) == 1 else "files"
|
|
504
|
+
print(f"\n{len(datasets)} {noun} valid. Nothing was uploaded.")
|
|
505
|
+
return 0
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def cmd_submit(args) -> int:
|
|
509
|
+
"""Upload the dataset(s) into a project and start the analyses."""
|
|
510
|
+
root = pathlib.Path(args.path).expanduser()
|
|
511
|
+
directory_mode = root.is_dir()
|
|
512
|
+
datasets, problems = _load_datasets(args)
|
|
513
|
+
|
|
514
|
+
# Filing only applies to a directory of files. A single named file is left
|
|
515
|
+
# exactly where the user put it.
|
|
516
|
+
triage = Triage(root, dry_run=args.dry_run) if directory_mode else None
|
|
517
|
+
|
|
518
|
+
if problems and triage is None:
|
|
519
|
+
raise IPAError(problems[0][1])
|
|
520
|
+
|
|
521
|
+
# If every file fails the same way, the mapping is wrong, not the data.
|
|
522
|
+
# Quarantining the whole directory for a command-line mistake just means
|
|
523
|
+
# fishing it all back out again.
|
|
524
|
+
systemic = bool(problems) and not datasets
|
|
525
|
+
for path, reason in problems:
|
|
526
|
+
print(f"failed validation {path.name}: {reason}", file=sys.stderr)
|
|
527
|
+
if not systemic:
|
|
528
|
+
triage.mark_failed(path, f"Validation failed.\n\n{reason}")
|
|
529
|
+
|
|
530
|
+
if systemic:
|
|
531
|
+
print(
|
|
532
|
+
f"\nAll {len(problems)} file(s) failed validation the same way, so this "
|
|
533
|
+
"looks like the mapping rather than the data.\nNothing was moved. Check "
|
|
534
|
+
"--ID, --FC and --skip-rows, then re-run the same command.",
|
|
535
|
+
file=sys.stderr,
|
|
536
|
+
)
|
|
537
|
+
return 1
|
|
538
|
+
|
|
539
|
+
if not datasets:
|
|
540
|
+
print("\nNo files left to submit.", file=sys.stderr)
|
|
541
|
+
if triage is not None and triage.summary():
|
|
542
|
+
print(triage.summary(), file=sys.stderr)
|
|
543
|
+
return 1
|
|
544
|
+
|
|
545
|
+
for index, dataset in enumerate(datasets):
|
|
546
|
+
if index:
|
|
547
|
+
print()
|
|
548
|
+
print(dataset.describe())
|
|
549
|
+
|
|
550
|
+
if args.dry_run:
|
|
551
|
+
noun = "file" if len(datasets) == 1 else "files"
|
|
552
|
+
print(f"\nDry run: {len(datasets)} {noun} valid; stopping before login.")
|
|
553
|
+
if triage is not None and triage.summary():
|
|
554
|
+
print(triage.summary())
|
|
555
|
+
return 0
|
|
556
|
+
|
|
557
|
+
client = _client(args)
|
|
558
|
+
|
|
559
|
+
analysis_ids: List[str] = []
|
|
560
|
+
failures: List[str] = []
|
|
561
|
+
records: List[history.SubmissionRecord] = []
|
|
562
|
+
quota_reached = False
|
|
563
|
+
malformed = False
|
|
564
|
+
|
|
565
|
+
for position, dataset in enumerate(datasets):
|
|
566
|
+
source = pathlib.Path(dataset.source_path) if dataset.source_path else None
|
|
567
|
+
try:
|
|
568
|
+
submitted = client.submit(
|
|
569
|
+
dataset,
|
|
570
|
+
project=args.project,
|
|
571
|
+
analysis_name=args.analysis_name,
|
|
572
|
+
dataset_name=args.dataset_name,
|
|
573
|
+
reference_set=(
|
|
574
|
+
None if args.reference_set == "omit" else args.reference_set
|
|
575
|
+
),
|
|
576
|
+
)
|
|
577
|
+
except MalformedRequestError as exc:
|
|
578
|
+
# The command line is wrong, not the data. Touch nothing.
|
|
579
|
+
print(f"\n{exc}\n", file=sys.stderr)
|
|
580
|
+
if triage is not None:
|
|
581
|
+
for remaining in datasets[position:]:
|
|
582
|
+
if remaining.source_path:
|
|
583
|
+
triage.mark_left(pathlib.Path(remaining.source_path))
|
|
584
|
+
failures.append(f"{dataset.name}: malformed request")
|
|
585
|
+
malformed = True
|
|
586
|
+
break
|
|
587
|
+
except (QuotaExceededError, AnalysisRefusedError) as exc:
|
|
588
|
+
# The file is fine; IPA will not run it right now. Leave this one
|
|
589
|
+
# and everything after it for the next run.
|
|
590
|
+
quota_reached = True
|
|
591
|
+
label = (
|
|
592
|
+
"Allowance exhausted"
|
|
593
|
+
if isinstance(exc, QuotaExceededError)
|
|
594
|
+
else "IPA declined to start the analysis"
|
|
595
|
+
)
|
|
596
|
+
print(f"\n{label} while submitting {dataset.name}:\n{exc}",
|
|
597
|
+
file=sys.stderr)
|
|
598
|
+
if triage is not None:
|
|
599
|
+
for remaining in datasets[position:]:
|
|
600
|
+
if remaining.source_path:
|
|
601
|
+
triage.mark_left(pathlib.Path(remaining.source_path))
|
|
602
|
+
break
|
|
603
|
+
except IPAError as exc:
|
|
604
|
+
failures.append(f"{dataset.name}: {exc}")
|
|
605
|
+
print(f"FAILED {dataset.name}: {exc}", file=sys.stderr)
|
|
606
|
+
if triage is not None and source is not None:
|
|
607
|
+
triage.mark_failed(source, f"Submission rejected by IPA.\n\n{exc}")
|
|
608
|
+
continue
|
|
609
|
+
|
|
610
|
+
analysis_ids.extend(submitted)
|
|
611
|
+
print(f"submitted {dataset.name}: {', '.join(submitted)}")
|
|
612
|
+
|
|
613
|
+
# One record per analysis, so an ID is never only in the scrollback.
|
|
614
|
+
observations = [obs.name for obs in dataset.mapping.observations]
|
|
615
|
+
records.extend(
|
|
616
|
+
history.SubmissionRecord(
|
|
617
|
+
analysis_id=analysis_id,
|
|
618
|
+
project=args.project,
|
|
619
|
+
dataset_name=dataset.name or "",
|
|
620
|
+
observation=observations[i] if i < len(observations) else "",
|
|
621
|
+
source_file=dataset.source_path or "",
|
|
622
|
+
application_name=client.application_name,
|
|
623
|
+
host=client.host,
|
|
624
|
+
)
|
|
625
|
+
for i, analysis_id in enumerate(submitted)
|
|
626
|
+
)
|
|
627
|
+
if triage is not None and source is not None:
|
|
628
|
+
triage.mark_submitted(source)
|
|
629
|
+
|
|
630
|
+
log_path = history.append(records, path=args.log_file)
|
|
631
|
+
|
|
632
|
+
if triage is not None and triage.summary():
|
|
633
|
+
print("\n" + triage.summary())
|
|
634
|
+
if quota_reached:
|
|
635
|
+
print(
|
|
636
|
+
"Re-run the same command later; the files left in place are exactly "
|
|
637
|
+
"the ones still to do."
|
|
638
|
+
)
|
|
639
|
+
if malformed:
|
|
640
|
+
# Only claim nothing moved when nothing did -- earlier files in the
|
|
641
|
+
# batch may well have been submitted and filed before this one failed.
|
|
642
|
+
if triage is not None and (triage.submitted or triage.failed):
|
|
643
|
+
print(
|
|
644
|
+
"Files submitted before the failure have been filed; the rest "
|
|
645
|
+
"were left in place. Fix the parameter and re-run the same "
|
|
646
|
+
"command.",
|
|
647
|
+
file=sys.stderr,
|
|
648
|
+
)
|
|
649
|
+
else:
|
|
650
|
+
print(
|
|
651
|
+
"No files were moved. Fix the parameter and re-run the same "
|
|
652
|
+
"command.",
|
|
653
|
+
file=sys.stderr,
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
if not analysis_ids:
|
|
657
|
+
print("\nNothing was submitted successfully.", file=sys.stderr)
|
|
658
|
+
return 1
|
|
659
|
+
|
|
660
|
+
noun = "analysis" if len(analysis_ids) == 1 else "analyses"
|
|
661
|
+
print(f"\nSubmitted {len(analysis_ids)} {noun}.")
|
|
662
|
+
if failures:
|
|
663
|
+
print(f"{len(failures)} of {len(datasets)} file(s) failed to submit.", file=sys.stderr)
|
|
664
|
+
|
|
665
|
+
if not args.wait:
|
|
666
|
+
joined = " ".join(analysis_ids)
|
|
667
|
+
print(
|
|
668
|
+
"Analyses are running in IPA. Check on them with:\n"
|
|
669
|
+
f" ipaapi status {joined}\n"
|
|
670
|
+
f" ipaapi report {joined}\n"
|
|
671
|
+
"Or re-run with --wait to block until they finish."
|
|
672
|
+
)
|
|
673
|
+
if log_path:
|
|
674
|
+
print(f"Recorded in {log_path} -- see 'ipaapi history'.")
|
|
675
|
+
return 1 if (failures or quota_reached) else 0
|
|
676
|
+
|
|
677
|
+
statuses = client.wait_for(analysis_ids, interval=args.interval, timeout=args.timeout)
|
|
678
|
+
exit_code = 1 if (failures or quota_reached) else 0
|
|
679
|
+
for analysis_id, status in statuses.items():
|
|
680
|
+
print(f"{analysis_id}: {status.name.lower()}")
|
|
681
|
+
if status.succeeded:
|
|
682
|
+
try:
|
|
683
|
+
print(f" {client.report_url(analysis_id)}")
|
|
684
|
+
except IPAError as exc:
|
|
685
|
+
print(f" no report link: {exc}")
|
|
686
|
+
else:
|
|
687
|
+
exit_code = 1
|
|
688
|
+
return exit_code
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def cmd_status(args) -> int:
|
|
692
|
+
"""Report the current status of one or more analyses."""
|
|
693
|
+
client = _client(args)
|
|
694
|
+
exit_code = 0
|
|
695
|
+
for analysis_id in args.analysis_ids:
|
|
696
|
+
status = client.status(analysis_id)
|
|
697
|
+
print(f"{analysis_id}: {status.name.lower()}")
|
|
698
|
+
if not status.succeeded:
|
|
699
|
+
exit_code = 1
|
|
700
|
+
return exit_code
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def cmd_report(args) -> int:
|
|
704
|
+
"""Print (and optionally open) IPA Interpret links."""
|
|
705
|
+
client = _client(args)
|
|
706
|
+
exit_code = 0
|
|
707
|
+
for analysis_id in args.analysis_ids:
|
|
708
|
+
# An unfinished analysis has no Interpret link yet and the endpoint
|
|
709
|
+
# answers with a bare HTTP 500, so say what is actually going on.
|
|
710
|
+
status = client.status(analysis_id)
|
|
711
|
+
if not status.is_terminal:
|
|
712
|
+
print(f"{analysis_id}: still running -- the link exists once it finishes")
|
|
713
|
+
exit_code = 1
|
|
714
|
+
continue
|
|
715
|
+
if not status.succeeded:
|
|
716
|
+
print(f"{analysis_id}: {status.name.lower()} -- no report for this analysis")
|
|
717
|
+
exit_code = 1
|
|
718
|
+
continue
|
|
719
|
+
try:
|
|
720
|
+
url = client.report_url(analysis_id)
|
|
721
|
+
except IPAError as exc:
|
|
722
|
+
print(f"{analysis_id}: {exc}", file=sys.stderr)
|
|
723
|
+
exit_code = 1
|
|
724
|
+
continue
|
|
725
|
+
print(f"{analysis_id}: {url}")
|
|
726
|
+
if args.open:
|
|
727
|
+
import webbrowser
|
|
728
|
+
|
|
729
|
+
webbrowser.open(url)
|
|
730
|
+
return exit_code
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def cmd_history(args) -> int:
|
|
734
|
+
"""List analyses submitted through this tool, oldest first."""
|
|
735
|
+
rows = history.read(args.log_file)
|
|
736
|
+
|
|
737
|
+
if args.project:
|
|
738
|
+
rows = [r for r in rows if r.get("project") == args.project]
|
|
739
|
+
if args.since:
|
|
740
|
+
rows = [r for r in rows if r.get("timestamp", "") >= args.since]
|
|
741
|
+
if args.limit:
|
|
742
|
+
rows = rows[-args.limit :]
|
|
743
|
+
|
|
744
|
+
if not rows:
|
|
745
|
+
print(
|
|
746
|
+
"No submissions recorded"
|
|
747
|
+
+ (f" in {args.log_file}" if args.log_file else "")
|
|
748
|
+
+ ". The log only covers analyses submitted through this tool."
|
|
749
|
+
)
|
|
750
|
+
return 0
|
|
751
|
+
|
|
752
|
+
client = _client(args) if args.status else None
|
|
753
|
+
|
|
754
|
+
widths = {
|
|
755
|
+
"timestamp": max(len(r.get("timestamp", "")) for r in rows),
|
|
756
|
+
"analysis_id": max(len(r.get("analysis_id", "")) for r in rows),
|
|
757
|
+
"project": max(len(r.get("project", "")) for r in rows),
|
|
758
|
+
"dataset_name": max(len(r.get("dataset_name", "")) for r in rows),
|
|
759
|
+
}
|
|
760
|
+
for row in rows:
|
|
761
|
+
line = " ".join(
|
|
762
|
+
[
|
|
763
|
+
row.get("timestamp", "").ljust(widths["timestamp"]),
|
|
764
|
+
row.get("analysis_id", "").ljust(widths["analysis_id"]),
|
|
765
|
+
row.get("project", "").ljust(widths["project"]),
|
|
766
|
+
row.get("dataset_name", "").ljust(widths["dataset_name"]),
|
|
767
|
+
]
|
|
768
|
+
)
|
|
769
|
+
if client is not None:
|
|
770
|
+
try:
|
|
771
|
+
line += " " + client.status(row["analysis_id"]).name.lower()
|
|
772
|
+
except IPAError as exc:
|
|
773
|
+
line += f" (status unavailable: {exc})"
|
|
774
|
+
print(line)
|
|
775
|
+
|
|
776
|
+
ids = " ".join(r.get("analysis_id", "") for r in rows)
|
|
777
|
+
print(f"\n{len(rows)} submission(s). Report links: ipaapi report {ids}")
|
|
778
|
+
return 0
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
# -- parser ----------------------------------------------------------------
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
class _ListIdTypes(argparse.Action):
|
|
785
|
+
"""Print every documented gene ID type and exit."""
|
|
786
|
+
|
|
787
|
+
def __init__(self, option_strings, dest, **kwargs):
|
|
788
|
+
super().__init__(option_strings, dest, nargs=0, **kwargs)
|
|
789
|
+
|
|
790
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
791
|
+
width = max(len(t) for t in GENE_ID_TYPES)
|
|
792
|
+
print("gene ID types accepted by IPA (Integration Module, April 2026 s3.1):\n")
|
|
793
|
+
for value, database in GENE_ID_TYPES.items():
|
|
794
|
+
print(f" {value.ljust(width)} {database}")
|
|
795
|
+
print(
|
|
796
|
+
"\nSpecies is carried by the identifier type -- there is no species "
|
|
797
|
+
"parameter.\nSeveral values are aliases: hugo / humansymeg / humanegsym "
|
|
798
|
+
"are the same thing."
|
|
799
|
+
)
|
|
800
|
+
parser.exit()
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
804
|
+
parser = argparse.ArgumentParser(
|
|
805
|
+
prog="ipaapi",
|
|
806
|
+
description="Submit datasets to QIAGEN Ingenuity Pathway Analysis.",
|
|
807
|
+
epilog=_EPILOG,
|
|
808
|
+
formatter_class=_Formatter,
|
|
809
|
+
)
|
|
810
|
+
parser.add_argument(
|
|
811
|
+
"--version",
|
|
812
|
+
action="version",
|
|
813
|
+
version=version_banner(),
|
|
814
|
+
help="show the version, and which installation is being run",
|
|
815
|
+
)
|
|
816
|
+
subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
817
|
+
|
|
818
|
+
validate = subparsers.add_parser(
|
|
819
|
+
"validate",
|
|
820
|
+
help="check the mapping against a file without uploading",
|
|
821
|
+
description=cmd_validate.__doc__,
|
|
822
|
+
epilog=_EPILOG,
|
|
823
|
+
formatter_class=_Formatter,
|
|
824
|
+
)
|
|
825
|
+
_add_mapping_arguments(validate)
|
|
826
|
+
validate.set_defaults(func=cmd_validate)
|
|
827
|
+
validate.add_argument(
|
|
828
|
+
"--list-id-types", action=_ListIdTypes, help="list every gene ID type and exit"
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
submit = subparsers.add_parser(
|
|
832
|
+
"submit",
|
|
833
|
+
help="upload a dataset into a project and start the analysis",
|
|
834
|
+
description=cmd_submit.__doc__,
|
|
835
|
+
epilog=_EPILOG,
|
|
836
|
+
formatter_class=_Formatter,
|
|
837
|
+
)
|
|
838
|
+
_add_mapping_arguments(submit)
|
|
839
|
+
_add_auth_arguments(submit)
|
|
840
|
+
submit.add_argument(
|
|
841
|
+
"--list-id-types", action=_ListIdTypes, help="list every gene ID type and exit"
|
|
842
|
+
)
|
|
843
|
+
submit.add_argument("--project", required=True, help="destination IPA project")
|
|
844
|
+
submit.add_argument("--analysis-name", default=None, help="override analysis name")
|
|
845
|
+
submit.add_argument("--dataset-name", default=None, help="override dataset name")
|
|
846
|
+
submit.add_argument(
|
|
847
|
+
"--reference-set",
|
|
848
|
+
default="omit",
|
|
849
|
+
choices=[r.value for r in ReferenceSet] + ["omit"],
|
|
850
|
+
help="background the analysis is scored against. 'ipkb' is the Ingenuity "
|
|
851
|
+
"Knowledge Base; 'dataset' is the genes you uploaded. 'omit' (default) "
|
|
852
|
+
"lets IPA choose. The docs say it picks by size (ipkb under 2000 "
|
|
853
|
+
"identifiers, dataset at 2000+) but that has not been observed to hold, "
|
|
854
|
+
"so set it explicitly for anything you will compare against itself",
|
|
855
|
+
)
|
|
856
|
+
submit.add_argument(
|
|
857
|
+
"--log-file",
|
|
858
|
+
default=None,
|
|
859
|
+
metavar="PATH",
|
|
860
|
+
help="submission log to append to (default: "
|
|
861
|
+
"~/.local/state/ipaapi/submissions.tsv)",
|
|
862
|
+
)
|
|
863
|
+
submit.add_argument(
|
|
864
|
+
"--dry-run",
|
|
865
|
+
action="store_true",
|
|
866
|
+
help="validate and stop before logging in or uploading",
|
|
867
|
+
)
|
|
868
|
+
submit.add_argument(
|
|
869
|
+
"--wait",
|
|
870
|
+
action="store_true",
|
|
871
|
+
help="poll until the analyses finish and print their report links, "
|
|
872
|
+
"instead of returning as soon as they are queued",
|
|
873
|
+
)
|
|
874
|
+
# Accepted silently: --no-wait is now the default, so old commands still run.
|
|
875
|
+
submit.add_argument("--no-wait", action="store_true", help=argparse.SUPPRESS)
|
|
876
|
+
submit.add_argument(
|
|
877
|
+
"--interval",
|
|
878
|
+
type=float,
|
|
879
|
+
default=30.0,
|
|
880
|
+
help="seconds between status polls; only used with --wait",
|
|
881
|
+
)
|
|
882
|
+
submit.add_argument(
|
|
883
|
+
"--timeout",
|
|
884
|
+
type=float,
|
|
885
|
+
default=3600.0,
|
|
886
|
+
help="seconds to wait for completion; only used with --wait",
|
|
887
|
+
)
|
|
888
|
+
submit.set_defaults(func=cmd_submit)
|
|
889
|
+
|
|
890
|
+
status = subparsers.add_parser(
|
|
891
|
+
"status",
|
|
892
|
+
help="check the status of existing analyses",
|
|
893
|
+
description=cmd_status.__doc__,
|
|
894
|
+
formatter_class=_Formatter,
|
|
895
|
+
)
|
|
896
|
+
status.add_argument("analysis_ids", nargs="+", metavar="ANALYSIS_ID")
|
|
897
|
+
_add_auth_arguments(status)
|
|
898
|
+
status.set_defaults(func=cmd_status)
|
|
899
|
+
|
|
900
|
+
report = subparsers.add_parser(
|
|
901
|
+
"report",
|
|
902
|
+
help="print IPA Interpret links for analyses",
|
|
903
|
+
description=cmd_report.__doc__,
|
|
904
|
+
formatter_class=_Formatter,
|
|
905
|
+
)
|
|
906
|
+
report.add_argument("analysis_ids", nargs="+", metavar="ANALYSIS_ID")
|
|
907
|
+
report.add_argument("--open", action="store_true", help="also open them in a browser")
|
|
908
|
+
_add_auth_arguments(report)
|
|
909
|
+
report.set_defaults(func=cmd_report)
|
|
910
|
+
|
|
911
|
+
hist = subparsers.add_parser(
|
|
912
|
+
"history",
|
|
913
|
+
help="list analyses submitted through this tool",
|
|
914
|
+
description=cmd_history.__doc__,
|
|
915
|
+
epilog=(
|
|
916
|
+
"IPA's API cannot list the analyses on an account, so this package "
|
|
917
|
+
"keeps its own log. It covers submissions made through this tool "
|
|
918
|
+
"only -- analyses submitted from the IPA client will not appear.\n\n"
|
|
919
|
+
"examples:\n"
|
|
920
|
+
" ipaapi history\n"
|
|
921
|
+
" ipaapi history --project singlet_RNA_P05\n"
|
|
922
|
+
" ipaapi history --since 2026-08-01 --status\n"
|
|
923
|
+
),
|
|
924
|
+
formatter_class=_Formatter,
|
|
925
|
+
)
|
|
926
|
+
hist.add_argument("--project", default=None, help="only this project")
|
|
927
|
+
hist.add_argument(
|
|
928
|
+
"--since",
|
|
929
|
+
default=None,
|
|
930
|
+
metavar="YYYY-MM-DD",
|
|
931
|
+
help="only submissions on or after this date",
|
|
932
|
+
)
|
|
933
|
+
hist.add_argument(
|
|
934
|
+
"--limit", type=int, default=None, metavar="N", help="only the last N entries"
|
|
935
|
+
)
|
|
936
|
+
hist.add_argument(
|
|
937
|
+
"--status",
|
|
938
|
+
action="store_true",
|
|
939
|
+
help="look up the current status of each analysis (requires login)",
|
|
940
|
+
)
|
|
941
|
+
hist.add_argument(
|
|
942
|
+
"--log-file",
|
|
943
|
+
default=None,
|
|
944
|
+
metavar="PATH",
|
|
945
|
+
help="submission log to read (default: ~/.local/state/ipaapi/submissions.tsv)",
|
|
946
|
+
)
|
|
947
|
+
_add_auth_arguments(hist)
|
|
948
|
+
hist.set_defaults(func=cmd_history)
|
|
949
|
+
|
|
950
|
+
return parser
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
954
|
+
"""Entry point for the ``ipaapi`` console script."""
|
|
955
|
+
parser = build_parser()
|
|
956
|
+
args = parser.parse_args(argv)
|
|
957
|
+
if not getattr(args, "command", None):
|
|
958
|
+
parser.print_help()
|
|
959
|
+
return 2
|
|
960
|
+
try:
|
|
961
|
+
return args.func(args)
|
|
962
|
+
except IPAError as exc:
|
|
963
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
964
|
+
return 1
|
|
965
|
+
except KeyboardInterrupt:
|
|
966
|
+
print("\ninterrupted", file=sys.stderr)
|
|
967
|
+
return 130
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
if __name__ == "__main__": # pragma: no cover
|
|
971
|
+
raise SystemExit(main())
|