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/client.py ADDED
@@ -0,0 +1,658 @@
1
+ """High-level client for the IPA analysis API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+ import webbrowser
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Sequence, Union
10
+
11
+ import requests
12
+ from requests.adapters import HTTPAdapter
13
+
14
+ from . import _payload
15
+ from .auth import Credentials, TokenCache, login
16
+ from .dataset import Dataset
17
+ from .errors import (
18
+ AnalysisError,
19
+ AnalysisRefusedError,
20
+ IPAError,
21
+ MalformedRequestError,
22
+ QuotaExceededError,
23
+ ResultsUnavailableError,
24
+ SubmissionError,
25
+ )
26
+ from .models import AnalysisStatus, ReferenceSet
27
+
28
+ if TYPE_CHECKING: # pragma: no cover - typing only
29
+ import pandas as pd
30
+
31
+ __all__ = [
32
+ "IPAClient",
33
+ "AnalysisResults",
34
+ "QUOTA_PATTERNS",
35
+ "looks_like_quota",
36
+ "looks_like_html",
37
+ "html_error_text",
38
+ ]
39
+
40
+ _ENTITY_ENDPOINTS = {
41
+ "CANONICAL_PATHWAY": ("allCanonicalPathways", "pathways"),
42
+ "UPSTREAM_REGULATOR": ("allUpstreamRegulators", "regulators"),
43
+ "BIOFUNCTION": ("allBioFunctions", "functions"),
44
+ }
45
+
46
+ #: Analysis IDs come back as a bare comma-separated string; this is the shape
47
+ #: of a plausible ID, used to tell a real response from an error page.
48
+ _ID_RE = re.compile(r"^[A-Za-z0-9._:-]+$")
49
+
50
+
51
+ @dataclass
52
+ class AnalysisResults:
53
+ """Scored results for one analysis, as three DataFrames.
54
+
55
+ Attributes:
56
+ analysis_id: The analysis these results belong to.
57
+ canonical_pathways: Canonical pathway scores, sorted by p-value.
58
+ upstream_regulators: Upstream regulator scores, sorted by p-value.
59
+ bio_functions: Diseases and biological function scores, sorted by p-value.
60
+ """
61
+
62
+ analysis_id: str
63
+ canonical_pathways: "pd.DataFrame"
64
+ upstream_regulators: "pd.DataFrame"
65
+ bio_functions: "pd.DataFrame"
66
+
67
+ def __iter__(self):
68
+ """Unpack as ``cp, ur, df = results`` for parity with the demo code."""
69
+ yield self.canonical_pathways
70
+ yield self.upstream_regulators
71
+ yield self.bio_functions
72
+
73
+ def summary(self) -> str:
74
+ return (
75
+ f"analysis {self.analysis_id}: "
76
+ f"{len(self.canonical_pathways)} canonical pathways, "
77
+ f"{len(self.upstream_regulators)} upstream regulators, "
78
+ f"{len(self.bio_functions)} diseases & functions"
79
+ )
80
+
81
+
82
+ class IPAClient:
83
+ """Submit datasets to IPA and track the resulting analyses.
84
+
85
+ Args:
86
+ credentials: Token and session context from :func:`ipaapi.auth.login`.
87
+ timeout: Per-request timeout in seconds. Submissions carry the whole
88
+ dataset in the body, so the default is generous.
89
+ retries: Retry count for idempotent GET requests. Submissions are never
90
+ retried automatically, since a retried POST could create a duplicate
91
+ analysis.
92
+ session: Optional pre-configured :class:`requests.Session`, e.g. one
93
+ carrying proxy settings.
94
+
95
+ Example:
96
+ >>> client = IPAClient.login() # doctest: +SKIP
97
+ >>> ids = client.submit(dataset, project="MyProject") # doctest: +SKIP
98
+ >>> client.wait_for(ids) # doctest: +SKIP
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ credentials: Credentials,
104
+ timeout: float = 600.0,
105
+ retries: int = 3,
106
+ session: Optional[requests.Session] = None,
107
+ ) -> None:
108
+ self.credentials = credentials
109
+ self.timeout = timeout
110
+ self.session = session or requests.Session()
111
+ if retries:
112
+ adapter = HTTPAdapter(max_retries=self._retry_policy(retries))
113
+ self.session.mount("https://", adapter)
114
+ self.session.mount("http://", adapter)
115
+
116
+ @staticmethod
117
+ def _retry_policy(retries: int):
118
+ from urllib3.util.retry import Retry
119
+
120
+ return Retry(
121
+ total=retries,
122
+ backoff_factor=0.5,
123
+ status_forcelist=(429, 500, 502, 503, 504),
124
+ allowed_methods=frozenset(["GET"]),
125
+ raise_on_status=False,
126
+ )
127
+
128
+ @classmethod
129
+ def login(cls, cache: Optional[TokenCache] = None, **kwargs) -> "IPAClient":
130
+ """Run the browser OAuth flow and return a ready client.
131
+
132
+ Accepts every keyword :func:`ipaapi.auth.login` takes.
133
+ """
134
+ return cls(credentials=login(cache=cache, **kwargs))
135
+
136
+ # -- plumbing ----------------------------------------------------------
137
+
138
+ @property
139
+ def host(self) -> str:
140
+ return self.credentials.host
141
+
142
+ @property
143
+ def application_name(self) -> str:
144
+ return self.credentials.application_name
145
+
146
+ def _url(self, path: str) -> str:
147
+ return f"https://{self.host}/{path.lstrip('/')}"
148
+
149
+ def _get(self, path: str, **params) -> requests.Response:
150
+ params.setdefault("applicationname", self.application_name)
151
+ try:
152
+ return self.session.get(
153
+ self._url(path),
154
+ headers=self.credentials.auth_header,
155
+ params=params,
156
+ timeout=self.timeout,
157
+ )
158
+ except requests.RequestException as exc:
159
+ raise IPAError(f"Request to {path} failed: {exc}") from exc
160
+
161
+ # -- submission --------------------------------------------------------
162
+
163
+ def submit(
164
+ self,
165
+ dataset: Dataset,
166
+ project: str,
167
+ analysis_name: Optional[str] = None,
168
+ dataset_name: Optional[str] = None,
169
+ reference_set: Optional[Union[ReferenceSet, str]] = None,
170
+ ipa_view: str = "none",
171
+ ) -> List[str]:
172
+ """Upload *dataset* into *project* and start analyses on it.
173
+
174
+ IPA's ``multiobsanalysis`` endpoint performs both halves of the workflow
175
+ in a single call: the dataset is created inside the named project using
176
+ the column mapping supplied, and an analysis is then launched for each
177
+ observation in it. One analysis ID is returned per observation.
178
+
179
+ Args:
180
+ dataset: A validated :class:`~ipaapi.dataset.Dataset`.
181
+ project: Destination IPA project. IPA creates it if it does not
182
+ already exist.
183
+ analysis_name: Base name for the analyses. Defaults to the dataset
184
+ name.
185
+ dataset_name: Name for the uploaded dataset. Defaults to
186
+ ``dataset.name``, which itself defaults to the source filename.
187
+ reference_set: Background analyses are scored against. Defaults
188
+ to ``None``, which omits the parameter so IPA applies its own
189
+ default -- confirmed to produce real p-values and FDR. Pass
190
+ ``ReferenceSet.DATASET`` to score against the uploaded genes
191
+ instead, which is appropriate only when the upload is a
192
+ complete measured transcriptome rather than a filtered list.
193
+ ipa_view: IPA view parameter; ``"none"`` unless you have a reason.
194
+
195
+ Returns:
196
+ Analysis IDs, one per observation, in mapping order.
197
+
198
+ Raises:
199
+ SubmissionError: If IPA rejects the submission or returns something
200
+ that is not a list of analysis IDs.
201
+ """
202
+ effective_dataset_name = dataset_name or dataset.name or "dataset"
203
+ if reference_set is None:
204
+ reference = None
205
+ elif isinstance(reference_set, ReferenceSet):
206
+ reference = reference_set.value
207
+ else:
208
+ reference = str(reference_set)
209
+
210
+ pairs = _payload.build_submission_pairs(
211
+ frame=dataset.frame,
212
+ mapping=dataset.mapping,
213
+ application_name=self.application_name,
214
+ project_name=project,
215
+ dataset_name=effective_dataset_name,
216
+ analysis_name=analysis_name,
217
+ reference_set=reference,
218
+ ipa_view=ipa_view,
219
+ )
220
+ body = _payload.encode_submission(pairs)
221
+
222
+ try:
223
+ response = self.session.post(
224
+ self._url("/pa/api/v2/multiobsanalysis"),
225
+ headers={
226
+ **self.credentials.auth_header,
227
+ "Content-Type": "application/x-www-form-urlencoded",
228
+ },
229
+ data=body.encode("utf-8"),
230
+ timeout=self.timeout,
231
+ )
232
+ except requests.RequestException as exc:
233
+ raise SubmissionError(f"Submission request failed: {exc}") from exc
234
+
235
+ return self._parse_analysis_ids(response, len(dataset.mapping.observations))
236
+
237
+ @staticmethod
238
+ def _parse_analysis_ids(response: requests.Response, expected: int) -> List[str]:
239
+ text = (response.text or "").strip()
240
+ if response.status_code != 200:
241
+ _raise_submission_error(
242
+ f"IPA rejected the submission (HTTP {response.status_code}).",
243
+ response.status_code,
244
+ text,
245
+ )
246
+ ids = [part.strip() for part in text.split(",") if part.strip()]
247
+ if not ids or not all(_ID_RE.match(i) for i in ids):
248
+ _raise_submission_error(
249
+ "IPA returned a response that does not look like analysis IDs. "
250
+ "This usually means the request was malformed, the allowance is "
251
+ "exhausted, or the token lacks permission for the project.",
252
+ response.status_code,
253
+ text,
254
+ )
255
+ if len(ids) != expected:
256
+ # Not fatal -- surface it rather than silently mismatching.
257
+ print(
258
+ f"Warning: submitted {expected} observation(s) but IPA returned "
259
+ f"{len(ids)} analysis ID(s): {', '.join(ids)}"
260
+ )
261
+ return ids
262
+
263
+ # -- status ------------------------------------------------------------
264
+
265
+ def status(self, analysis_id: str) -> AnalysisStatus:
266
+ """Return the current status of one analysis."""
267
+ response = self._get("/pa/api/v2/analysisstatus", analysisuid=analysis_id)
268
+ if response.status_code != 200:
269
+ raise IPAError(
270
+ f"Status check for {analysis_id} failed (HTTP {response.status_code}): "
271
+ f"{(response.text or '')[:300]}"
272
+ )
273
+ return AnalysisStatus.from_code(response.text)
274
+
275
+ def wait_for(
276
+ self,
277
+ analysis_ids: Union[str, Sequence[str]],
278
+ interval: float = 30.0,
279
+ timeout: Optional[float] = 3600.0,
280
+ progress: bool = True,
281
+ raise_on_failure: bool = False,
282
+ ) -> Dict[str, AnalysisStatus]:
283
+ """Poll until every analysis reaches a terminal state.
284
+
285
+ Args:
286
+ analysis_ids: One ID or a sequence of them.
287
+ interval: Seconds between polling rounds.
288
+ timeout: Overall budget in seconds; ``None`` waits indefinitely.
289
+ progress: Print a line per state change.
290
+ raise_on_failure: Raise :class:`AnalysisError` if any analysis ends
291
+ failed or canceled, instead of just reporting it.
292
+
293
+ Returns:
294
+ Mapping of analysis ID to its final (or last observed) status.
295
+ """
296
+ ids = [analysis_ids] if isinstance(analysis_ids, str) else list(analysis_ids)
297
+ if not ids:
298
+ return {}
299
+
300
+ deadline = None if timeout is None else time.monotonic() + timeout
301
+ final: Dict[str, AnalysisStatus] = {}
302
+ last_seen: Dict[str, AnalysisStatus] = {}
303
+
304
+ while True:
305
+ for analysis_id in ids:
306
+ if analysis_id in final:
307
+ continue
308
+ state = self.status(analysis_id)
309
+ if progress and last_seen.get(analysis_id) is not state:
310
+ print(f"analysis {analysis_id}: {state.name.lower()}")
311
+ last_seen[analysis_id] = state
312
+ if state.is_terminal:
313
+ final[analysis_id] = state
314
+
315
+ if len(final) == len(ids):
316
+ break
317
+ if deadline is not None and time.monotonic() >= deadline:
318
+ if progress:
319
+ pending = [i for i in ids if i not in final]
320
+ print(
321
+ f"Gave up after {timeout:g}s; still running: {', '.join(pending)}"
322
+ )
323
+ break
324
+ time.sleep(interval)
325
+
326
+ result = {i: final.get(i, last_seen.get(i, AnalysisStatus.IN_PROGRESS)) for i in ids}
327
+
328
+ if raise_on_failure:
329
+ bad = {i: s for i, s in result.items() if not s.succeeded}
330
+ if bad:
331
+ detail = ", ".join(f"{i}={s.name.lower()}" for i, s in bad.items())
332
+ raise AnalysisError(f"Analyses did not succeed: {detail}")
333
+ return result
334
+
335
+ def submit_and_wait(
336
+ self,
337
+ dataset: Dataset,
338
+ project: str,
339
+ interval: float = 30.0,
340
+ timeout: Optional[float] = 3600.0,
341
+ **submit_kwargs,
342
+ ) -> Dict[str, AnalysisStatus]:
343
+ """Submit *dataset* and block until the analyses finish."""
344
+ ids = self.submit(dataset, project=project, **submit_kwargs)
345
+ return self.wait_for(ids, interval=interval, timeout=timeout)
346
+
347
+ # -- results -----------------------------------------------------------
348
+
349
+ def results(self, analysis_id: str) -> AnalysisResults:
350
+ """Fetch scored results for a completed analysis.
351
+
352
+ .. note::
353
+ Programmatic result retrieval is a commercial IPA add-on. Without
354
+ that licence these endpoints return an error and
355
+ :class:`~ipaapi.errors.ResultsUnavailableError` is raised; the
356
+ analysis itself is unaffected and can still be opened in IPA.
357
+ """
358
+ import pandas as pd
359
+
360
+ frames = {}
361
+ for entity in ("CANONICAL_PATHWAY", "UPSTREAM_REGULATOR", "BIOFUNCTION"):
362
+ records = self._entity_scores(analysis_id, entity)
363
+ frames[entity] = pd.DataFrame(records)
364
+
365
+ return AnalysisResults(
366
+ analysis_id=analysis_id,
367
+ canonical_pathways=frames["CANONICAL_PATHWAY"],
368
+ upstream_regulators=frames["UPSTREAM_REGULATOR"],
369
+ bio_functions=frames["BIOFUNCTION"],
370
+ )
371
+
372
+ def _entity_scores(self, analysis_id: str, entity: str) -> List[dict]:
373
+ endpoint, json_key = _ENTITY_ENDPOINTS[entity]
374
+ url = f"/pa/ipa/analysisResults/{endpoint}/{self.application_name}/{analysis_id}"
375
+ try:
376
+ response = self.session.get(
377
+ self._url(url),
378
+ headers=self.credentials.auth_header,
379
+ timeout=self.timeout,
380
+ )
381
+ except requests.RequestException as exc:
382
+ raise ResultsUnavailableError(
383
+ f"Could not fetch {entity.lower()} results for {analysis_id}: {exc}"
384
+ ) from exc
385
+
386
+ if response.status_code != 200:
387
+ raise ResultsUnavailableError(
388
+ f"Could not fetch {entity.lower()} results for {analysis_id} "
389
+ f"(HTTP {response.status_code}). Programmatic result retrieval "
390
+ "requires the commercial IPA add-on licence."
391
+ )
392
+ try:
393
+ payload = response.json()
394
+ except ValueError as exc:
395
+ raise ResultsUnavailableError(
396
+ f"Result response for {analysis_id} was not valid JSON."
397
+ ) from exc
398
+
399
+ records = []
400
+ for item in payload.get(json_key, []) or []:
401
+ row = {}
402
+ for key, value in item.items():
403
+ if isinstance(value, str) and key == "name":
404
+ value = value.replace("+", " ")
405
+ elif isinstance(value, bool):
406
+ pass
407
+ elif isinstance(value, (int, float)):
408
+ value = float(value)
409
+ row[key] = value
410
+ records.append(row)
411
+
412
+ records.sort(key=lambda r: _sort_key(r.get("pvalue", r.get("Pvalue"))))
413
+ return records
414
+
415
+ # -- reports -----------------------------------------------------------
416
+
417
+ def report_url(self, analysis_id: str) -> str:
418
+ """Return the IPA Interpret link for *analysis_id*."""
419
+ url = f"/pa/ipa/analysisResults/interpretLink/{self.application_name}/{analysis_id}"
420
+ try:
421
+ response = self.session.get(
422
+ self._url(url),
423
+ headers=self.credentials.auth_header,
424
+ timeout=self.timeout,
425
+ )
426
+ except requests.RequestException as exc:
427
+ raise IPAError(f"Could not fetch the report URL for {analysis_id}: {exc}") from exc
428
+
429
+ if response.status_code != 200:
430
+ body = (response.text or "").strip()[:1000]
431
+ raise IPAError(
432
+ f"Could not fetch the report URL for {analysis_id} "
433
+ f"(HTTP {response.status_code}) from {url}."
434
+ + (f"\nIPA said: {body!r}" if body else "\nThe response was empty.")
435
+ + "\nInterpret links may require the commercial IPA add-on; the "
436
+ "analysis itself is unaffected and can be opened in IPA directly."
437
+ )
438
+ try:
439
+ payload = response.json()
440
+ except ValueError as exc:
441
+ body = (response.text or "").strip()[:1000]
442
+ raise IPAError(
443
+ f"Report URL response for {analysis_id} was not JSON: {body!r}"
444
+ ) from exc
445
+ link = payload.get("link")
446
+ if not link:
447
+ raise IPAError(
448
+ f"No 'link' field in the report response for {analysis_id}. "
449
+ f"Response keys: {sorted(payload)!r}"
450
+ )
451
+ return link
452
+
453
+ def open_report(self, analysis_id: str) -> str:
454
+ """Open the Interpret report in a browser and return its URL."""
455
+ url = self.report_url(analysis_id)
456
+ webbrowser.open(url)
457
+ return url
458
+
459
+ def report_urls(self, analysis_ids: Iterable[str]) -> Dict[str, Optional[str]]:
460
+ """Fetch report URLs for several analyses, tolerating individual failures."""
461
+ out: Dict[str, Optional[str]] = {}
462
+ for analysis_id in analysis_ids:
463
+ try:
464
+ out[analysis_id] = self.report_url(analysis_id)
465
+ except IPAError as exc:
466
+ print(f"Warning: {exc}")
467
+ out[analysis_id] = None
468
+ return out
469
+
470
+
471
+ #: Phrases that indicate an exhausted allowance rather than a broken request.
472
+ #:
473
+ #: **Confirmed wording**, observed from a live rejection::
474
+ #:
475
+ #: Unable to run analysis: Analysis limit exceeded
476
+ #:
477
+ #: The remaining patterns are still guesses at other phrasings IPA might use.
478
+ #: Matching is deliberately broad and case-insensitive across the whole body,
479
+ #: because the cost of a false positive is mild -- the file is left in place for
480
+ #: the next run rather than quarantined -- while a false negative would file a
481
+ #: retryable submission under ``failed/``. The body is always printed, so a
482
+ #: misclassification stays visible.
483
+ QUOTA_PATTERNS = (
484
+ "analysis limit exceeded", # confirmed
485
+ "quota",
486
+ "allowance",
487
+ "exceeded",
488
+ "limit reached",
489
+ "usage limit",
490
+ "too many analyses",
491
+ "no analyses remaining",
492
+ "insufficient credits",
493
+ )
494
+
495
+
496
+ def looks_like_quota(status_code: Optional[int], body: str) -> bool:
497
+ """Whether a rejection looks like an exhausted allowance.
498
+
499
+ HTTP 429 is treated as a quota response outright; otherwise the body is
500
+ searched for any of :data:`QUOTA_PATTERNS`.
501
+ """
502
+ if status_code == 429:
503
+ return True
504
+ haystack = (body or "").lower()
505
+ return any(pattern in haystack for pattern in QUOTA_PATTERNS)
506
+
507
+
508
+ def looks_like_html(body: str) -> bool:
509
+ """Whether the response is an HTML page rather than the expected plain text.
510
+
511
+ The submission endpoint answers with a bare comma-separated list of IDs. An
512
+ HTML page means the request was rejected before reaching the analysis logic
513
+ -- a malformed parameter rather than bad data.
514
+ """
515
+ head = (body or "").lstrip()[:200].lower()
516
+ return head.startswith(("<html", "<!doctype html", "<?xml")) or "<html" in head
517
+
518
+
519
+ #: IPA's error pages sandwich the actual reason between support boilerplate
520
+ #: above and site chrome below. Stripping both is what makes it readable.
521
+ _BOILERPLATE = re.compile(
522
+ r"If you continue to experience this problem.*?1-650-381-5111\.?",
523
+ re.IGNORECASE | re.DOTALL,
524
+ )
525
+ _PAGE_FOOTER = re.compile(
526
+ r"About QIAGEN Bioinformatics.*$|\(c\)\s*\d{4}-\d{4}\s*QIAGEN.*$"
527
+ r"|&copy;\s*\d{4}-\d{4}\s*QIAGEN.*$",
528
+ re.IGNORECASE | re.DOTALL,
529
+ )
530
+
531
+
532
+ def html_error_text(body: str) -> str:
533
+ """Return the readable message from an IPA HTML error page.
534
+
535
+ Tags are stripped, entities collapsed and the support boilerplate removed,
536
+ because the sentence that actually says what went wrong comes *after* it.
537
+ Truncating from the front -- as this used to -- discards precisely the part
538
+ worth reading.
539
+ """
540
+ text = re.sub(r"<[^>]+>", " ", body or "")
541
+ text = text.replace("&nbsp;", " ").replace("&amp;", "&")
542
+ text = _BOILERPLATE.sub(" ", text)
543
+ text = _PAGE_FOOTER.sub(" ", text)
544
+ text = re.sub(r"\s+", " ", text).strip()
545
+ # "Error | IPA Error" prefixes carry no information.
546
+ text = re.sub(r"^(Error\s*\|\s*IPA\s*)+(Error\s*)*", "", text).strip()
547
+ return text
548
+
549
+
550
+ def _summarise_html_error(body: str) -> str:
551
+ """Describe an HTML error page, keeping the tail where the reason lives."""
552
+ text = html_error_text(body)
553
+ if not text:
554
+ return "an HTML error page with no readable content"
555
+ if len(text) > 1500:
556
+ # Keep both ends rather than losing the conclusion.
557
+ text = f"{text[:500]} [...] {text[-900:]}"
558
+ return repr(text)
559
+
560
+
561
+ #: IPA names the offending value in its error page; catching that turns a
562
+ #: generic "something was wrong" into an actionable message.
563
+ _UNKNOWN_ID_TYPE = re.compile(r"Unknown\s+GeneId\s+Type\s*\(([^)]*)\)", re.IGNORECASE)
564
+
565
+
566
+ def _parameter_hint(body: str):
567
+ """Return ``(headline, detail)`` naming the parameter IPA objected to.
568
+
569
+ The headline is deliberately short and unambiguous, because it is the line
570
+ a reader scanning a wall of error text will actually take in.
571
+ """
572
+ match = _UNKNOWN_ID_TYPE.search(body or "")
573
+ if match:
574
+ rejected = match.group(1).strip()
575
+ return (
576
+ f"REJECTED: IPA does not recognise the gene ID type {rejected!r}.",
577
+ "That is the --ID flag: --ID COLUMN:TYPE. Confirmed values: 'ensembl' "
578
+ "for Ensembl gene IDs, 'hugo' for human gene symbols. The vocabulary "
579
+ "is undocumented and unobvious -- 'genesymbol' and 'Gene Symbol' are "
580
+ "both rejected, so it is neither the compound word nor the desktop "
581
+ "client's display label. IPA names whatever value it rejects, so "
582
+ "candidates can be tried one at a time; examples/probe_geneidtype.py "
583
+ "does that.\n"
584
+ "A type IPA *accepts* creates a real analysis and consumes allowance, "
585
+ "so probe with a small file.",
586
+ )
587
+ return (
588
+ "REJECTED: IPA would not accept one of the submission parameters.",
589
+ "--reference-set and the --ID type are the usual culprits. The response "
590
+ "below is the only description IPA gives.",
591
+ )
592
+
593
+
594
+ #: IPA says this when the request reached the analysis logic but could not be
595
+ #: run -- as opposed to being rejected on a parameter.
596
+ _UNABLE_TO_RUN = re.compile(r"Unable to run analysis", re.IGNORECASE)
597
+
598
+
599
+ def _raise_submission_error(message: str, status_code: Optional[int], body: str):
600
+ """Raise the most specific submission error the response supports."""
601
+ excerpt = body[:2000]
602
+
603
+ # Checked before the HTML branch: an exhausted allowance delivered as an
604
+ # error page is still a quota problem, not a malformed request.
605
+ if looks_like_quota(status_code, body):
606
+ detail = html_error_text(body) if looks_like_html(body) else excerpt
607
+ raise QuotaExceededError(
608
+ f"REJECTED: the analysis allowance appears to be exhausted.\n\n"
609
+ f"IPA said: {detail!r}",
610
+ status_code=status_code,
611
+ body=excerpt,
612
+ )
613
+
614
+ if looks_like_html(body) and _UNABLE_TO_RUN.search(body):
615
+ raise AnalysisRefusedError(
616
+ "REJECTED: IPA accepted the request but would not start the "
617
+ "analysis.\n\n"
618
+ "This is not a parameter problem -- the request reached IPA's "
619
+ "analysis logic, which then declined to run it. The usual causes "
620
+ "are an exhausted analysis allowance, a capacity limit, or a "
621
+ "transient fault on IPA's side; the dataset and the command line "
622
+ "are probably fine. The remaining files have been left in place, "
623
+ "so re-running the same command later resumes.\n\n"
624
+ f"IPA said: {_summarise_html_error(body)}",
625
+ status_code=status_code,
626
+ body=excerpt,
627
+ )
628
+
629
+ if looks_like_html(body):
630
+ # Lead with what IPA actually objected to. Burying it under the
631
+ # explanation invites the reader to skim and conclude it worked.
632
+ headline, detail = _parameter_hint(body)
633
+ raise MalformedRequestError(
634
+ headline
635
+ + "\n\nThis file was NOT submitted.\n\n"
636
+ + detail
637
+ + "\n\nIPA answered with an HTML error page where the API returns "
638
+ "plain text, which means the request was rejected before reaching "
639
+ "the analysis logic -- so any remaining file would fail the same "
640
+ "way.\n"
641
+ f"Full response: {_summarise_html_error(body)}",
642
+ status_code=status_code,
643
+ body=excerpt,
644
+ )
645
+
646
+ detail = f"{message}\nIPA said: {excerpt!r}" if excerpt else message
647
+ if looks_like_quota(status_code, body):
648
+ raise QuotaExceededError(detail, status_code=status_code, body=excerpt)
649
+ raise SubmissionError(detail, status_code=status_code, body=excerpt)
650
+
651
+
652
+ def _sort_key(value) -> float:
653
+ """Sort p-values ascending, pushing missing or unparseable values last."""
654
+ try:
655
+ number = float(value)
656
+ except (TypeError, ValueError):
657
+ return float("inf")
658
+ return float("inf") if number != number else number