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/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())