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/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"|©\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(" ", " ").replace("&", "&")
|
|
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
|