Spaces:
Running
fix: harden BLAST pipeline against NCBI timeouts and server overload
Browse files- Add EMAIL parameter to NCBI submit_blast (required by NCBI API)
- Catch httpx.TimeoutException and HTTPStatusError in _request_with_retry
- Add stuck-job detection in check_status_until_ready (5x RTOE threshold)
- Add synchronous mode fallback when async RTOE is unreasonable (>300s)
- Tolerate up to 3 consecutive poll failures before giving up
- Increase retries from 1 to 2 (3 total attempts)
- Increase poll start delay from 5s to 10s (NCBI rate limit guidance)
- Add QUEUED status handling in poll loop
- Fix EBI BlastTool: increase poll timeout (120->180s), add failure tolerance
- Pipeline v2: pass retries=2 to run_blast_with_retry
- Add comprehensive BLAST test suite (test_blast_comprehensive.py) with:
* Multiple protein datasets (crambin, insulin, GFP, lysozyme, hemoglobin)
* Full NCBI roundtrip tests, XML parsing, retry verification
* EBI BLAST tool tests, error handling (empty/invalid/long sequences)
* Random fuzz tests (5 random sequences), consecutive reliability tests
* Pipeline v2 integration loop test
|
@@ -29,16 +29,22 @@ def _api_key_param() -> dict:
|
|
| 29 |
|
| 30 |
|
| 31 |
async def _request_with_retry(method: str, url: str, max_retries: int = 3, **kwargs) -> httpx.Response:
|
| 32 |
-
"""Make an HTTP request with retry on connection errors."""
|
| 33 |
for attempt in range(max_retries):
|
| 34 |
try:
|
| 35 |
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=15.0)) as client:
|
| 36 |
resp = await getattr(client, method)(url, **kwargs)
|
| 37 |
resp.raise_for_status()
|
| 38 |
return resp
|
| 39 |
-
except (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
if attempt < max_retries - 1:
|
| 41 |
-
delay =
|
| 42 |
logger.warning("NCBI request failed (attempt %d/%d): %s — retrying in %ds", attempt + 1, max_retries, e, delay)
|
| 43 |
await asyncio.sleep(delay)
|
| 44 |
else:
|
|
@@ -65,6 +71,7 @@ async def submit_blast(
|
|
| 65 |
"EXPECT": str(expect),
|
| 66 |
"MATRIX": matrix,
|
| 67 |
"ASYNC": "1" if async_flag else "0",
|
|
|
|
| 68 |
**_api_key_param(),
|
| 69 |
}
|
| 70 |
if gapopen > 0:
|
|
@@ -87,6 +94,15 @@ async def submit_blast(
|
|
| 87 |
return {"rid": rid, "estimated_seconds": rtoe}
|
| 88 |
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
async def check_status(rid: str, fmt: str = "XML") -> dict:
|
| 91 |
params = {"CMD": "Get", "FORMAT_TYPE": fmt, "RID": rid, **_api_key_param()}
|
| 92 |
resp = await _request_with_retry("get", NCBI_BLAST_URL, params=params)
|
|
@@ -115,29 +131,64 @@ async def fetch_results(rid: str, fmt: str = "XML") -> dict:
|
|
| 115 |
async def check_status_until_ready(
|
| 116 |
rid: str,
|
| 117 |
max_wait_seconds: int = 300,
|
|
|
|
| 118 |
) -> dict:
|
| 119 |
"""Poll NCBI with exponential backoff until READY or budget exhausted.
|
| 120 |
|
| 121 |
-
Starts at
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
"""
|
| 124 |
elapsed = 0
|
| 125 |
-
delay =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
while elapsed < max_wait_seconds:
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
status = result["status"]
|
| 130 |
|
| 131 |
if status == "READY":
|
| 132 |
return result
|
| 133 |
-
if status not in ("WAITING", "UNKNOWN"):
|
| 134 |
# FAILED / ERROR — bail immediately
|
| 135 |
logger.warning("BLAST RID %s returned terminal status: %s", rid, status)
|
| 136 |
return result
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
await asyncio.sleep(delay)
|
| 139 |
elapsed += delay
|
| 140 |
-
delay = min(delay * 1.5,
|
| 141 |
|
| 142 |
logger.warning("BLAST RID %s timed out after %ds", rid, max_wait_seconds)
|
| 143 |
return {"status": "TIMEOUT", "rid": rid}
|
|
@@ -145,39 +196,101 @@ async def check_status_until_ready(
|
|
| 145 |
|
| 146 |
async def run_blast_with_retry(
|
| 147 |
sequence: str,
|
| 148 |
-
retries: int =
|
| 149 |
max_wait_seconds: int = 600,
|
| 150 |
**submit_kwargs,
|
| 151 |
) -> dict:
|
| 152 |
-
"""Submit + poll + fetch with
|
| 153 |
|
| 154 |
If NCBI dropped/lost the RID, no amount of polling helps — a fresh
|
| 155 |
-
submit_blast() is the right fix. retries=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
"""
|
| 157 |
last_error = None
|
|
|
|
| 158 |
|
| 159 |
for attempt in range(retries + 1):
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
if "error" in submit_result:
|
| 162 |
last_error = submit_result["error"]
|
| 163 |
logger.warning(
|
| 164 |
"BLAST submit failed (attempt %d/%d): %s",
|
| 165 |
attempt + 1, retries + 1, last_error,
|
| 166 |
)
|
|
|
|
|
|
|
| 167 |
continue
|
| 168 |
|
| 169 |
rid = submit_result["rid"]
|
| 170 |
est = submit_result.get("estimated_seconds", 0)
|
| 171 |
logger.info(
|
| 172 |
-
"BLAST submitted (attempt %d/%d), RID=%s, est=%ds",
|
| 173 |
-
attempt + 1, retries + 1, rid, est,
|
| 174 |
)
|
| 175 |
|
| 176 |
-
|
| 177 |
-
if
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
logger.warning("BLAST RID %s: %s", rid, last_error)
|
|
|
|
|
|
|
| 182 |
|
| 183 |
return {"error": last_error or "BLAST failed after all attempts"}
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
async def _request_with_retry(method: str, url: str, max_retries: int = 3, **kwargs) -> httpx.Response:
|
| 32 |
+
"""Make an HTTP request with retry on connection, timeout, and transient errors."""
|
| 33 |
for attempt in range(max_retries):
|
| 34 |
try:
|
| 35 |
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=15.0)) as client:
|
| 36 |
resp = await getattr(client, method)(url, **kwargs)
|
| 37 |
resp.raise_for_status()
|
| 38 |
return resp
|
| 39 |
+
except (
|
| 40 |
+
httpx.ReadError,
|
| 41 |
+
httpx.RemoteProtocolError,
|
| 42 |
+
httpx.ConnectError,
|
| 43 |
+
httpx.TimeoutException,
|
| 44 |
+
httpx.HTTPStatusError,
|
| 45 |
+
) as e:
|
| 46 |
if attempt < max_retries - 1:
|
| 47 |
+
delay = 3 * (attempt + 1)
|
| 48 |
logger.warning("NCBI request failed (attempt %d/%d): %s — retrying in %ds", attempt + 1, max_retries, e, delay)
|
| 49 |
await asyncio.sleep(delay)
|
| 50 |
else:
|
|
|
|
| 71 |
"EXPECT": str(expect),
|
| 72 |
"MATRIX": matrix,
|
| 73 |
"ASYNC": "1" if async_flag else "0",
|
| 74 |
+
"EMAIL": settings.NCBI_EMAIL,
|
| 75 |
**_api_key_param(),
|
| 76 |
}
|
| 77 |
if gapopen > 0:
|
|
|
|
| 94 |
return {"rid": rid, "estimated_seconds": rtoe}
|
| 95 |
|
| 96 |
|
| 97 |
+
async def submit_blast_sync(sequence: str, **kwargs) -> dict:
|
| 98 |
+
"""Submit BLAST in synchronous (blocking) mode — NCBI returns results inline.
|
| 99 |
+
|
| 100 |
+
Used as fallback when async mode yields unreasonable RTOE or jobs get stuck.
|
| 101 |
+
"""
|
| 102 |
+
kwargs.pop("async_flag", None)
|
| 103 |
+
return await submit_blast(sequence, async_flag=False, **kwargs)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
async def check_status(rid: str, fmt: str = "XML") -> dict:
|
| 107 |
params = {"CMD": "Get", "FORMAT_TYPE": fmt, "RID": rid, **_api_key_param()}
|
| 108 |
resp = await _request_with_retry("get", NCBI_BLAST_URL, params=params)
|
|
|
|
| 131 |
async def check_status_until_ready(
|
| 132 |
rid: str,
|
| 133 |
max_wait_seconds: int = 300,
|
| 134 |
+
estimated_seconds: int = 0,
|
| 135 |
) -> dict:
|
| 136 |
"""Poll NCBI with exponential backoff until READY or budget exhausted.
|
| 137 |
|
| 138 |
+
Starts at 10s delay (NCBI rate-limit guidance: 1 req/10s without API key),
|
| 139 |
+
backs off to 25s ceiling. Transient poll failures (timeouts, HTTP errors)
|
| 140 |
+
are tolerated up to 3 consecutive times before giving up.
|
| 141 |
+
|
| 142 |
+
If estimated_seconds is provided and the job stays in WAITING for more
|
| 143 |
+
than 5x that duration (minimum 60s), it's treated as stuck.
|
| 144 |
"""
|
| 145 |
elapsed = 0
|
| 146 |
+
delay = 10 # NCBI rate limit: 1 req/10s without API key
|
| 147 |
+
consecutive_failures = 0
|
| 148 |
+
max_consecutive_failures = 3
|
| 149 |
+
# Stuck-job threshold: 5x the RTOE, but at least 60s
|
| 150 |
+
stuck_threshold = max(estimated_seconds * 5, 60) if estimated_seconds > 0 else 120
|
| 151 |
|
| 152 |
while elapsed < max_wait_seconds:
|
| 153 |
+
try:
|
| 154 |
+
result = await check_status(rid)
|
| 155 |
+
consecutive_failures = 0 # reset on success
|
| 156 |
+
except Exception as e:
|
| 157 |
+
consecutive_failures += 1
|
| 158 |
+
logger.warning(
|
| 159 |
+
"BLAST poll for %s failed (consecutive %d/%d): %s",
|
| 160 |
+
rid, consecutive_failures, max_consecutive_failures, e,
|
| 161 |
+
)
|
| 162 |
+
if consecutive_failures >= max_consecutive_failures:
|
| 163 |
+
logger.warning(
|
| 164 |
+
"BLAST RID %s — %d consecutive poll failures, giving up", rid, consecutive_failures
|
| 165 |
+
)
|
| 166 |
+
return {"status": "POLL_FAILED", "rid": rid, "error": str(e)}
|
| 167 |
+
await asyncio.sleep(delay)
|
| 168 |
+
elapsed += delay
|
| 169 |
+
delay = min(delay * 1.5, 25)
|
| 170 |
+
continue
|
| 171 |
+
|
| 172 |
status = result["status"]
|
| 173 |
|
| 174 |
if status == "READY":
|
| 175 |
return result
|
| 176 |
+
if status not in ("WAITING", "UNKNOWN", "QUEUED"):
|
| 177 |
# FAILED / ERROR — bail immediately
|
| 178 |
logger.warning("BLAST RID %s returned terminal status: %s", rid, status)
|
| 179 |
return result
|
| 180 |
|
| 181 |
+
# Stuck-job detection: if WAITING far beyond RTOE, job is likely stuck
|
| 182 |
+
if elapsed > stuck_threshold:
|
| 183 |
+
logger.warning(
|
| 184 |
+
"BLAST RID %s stuck in %s for %ds (threshold=%ds), treating as STUCK",
|
| 185 |
+
rid, status, elapsed, stuck_threshold,
|
| 186 |
+
)
|
| 187 |
+
return {"status": "STUCK", "rid": rid, "error": f"Job stuck in {status} for {elapsed}s"}
|
| 188 |
+
|
| 189 |
await asyncio.sleep(delay)
|
| 190 |
elapsed += delay
|
| 191 |
+
delay = min(delay * 1.5, 25) # back off, cap at 25s
|
| 192 |
|
| 193 |
logger.warning("BLAST RID %s timed out after %ds", rid, max_wait_seconds)
|
| 194 |
return {"status": "TIMEOUT", "rid": rid}
|
|
|
|
| 196 |
|
| 197 |
async def run_blast_with_retry(
|
| 198 |
sequence: str,
|
| 199 |
+
retries: int = 2,
|
| 200 |
max_wait_seconds: int = 600,
|
| 201 |
**submit_kwargs,
|
| 202 |
) -> dict:
|
| 203 |
+
"""Submit + poll + fetch with retries on timeout/failure.
|
| 204 |
|
| 205 |
If NCBI dropped/lost the RID, no amount of polling helps — a fresh
|
| 206 |
+
submit_blast() is the right fix. retries=2 means 3 total attempts.
|
| 207 |
+
|
| 208 |
+
Falls back to synchronous mode when async RTOE is unreasonable (>300s),
|
| 209 |
+
which indicates NCBI server overload. In sync mode NCBI blocks until
|
| 210 |
+
results are ready (up to the httpx timeout).
|
| 211 |
"""
|
| 212 |
last_error = None
|
| 213 |
+
MAX_RTOE = 300 # if RTOE exceeds this, switch to sync mode
|
| 214 |
|
| 215 |
for attempt in range(retries + 1):
|
| 216 |
+
# On retry after stuck/timeout, try sync mode first
|
| 217 |
+
use_sync = attempt > 0
|
| 218 |
+
|
| 219 |
+
try:
|
| 220 |
+
if use_sync:
|
| 221 |
+
logger.info("BLAST attempt %d/%d: trying synchronous mode", attempt + 1, retries + 1)
|
| 222 |
+
submit_result = await submit_blast_sync(sequence, **submit_kwargs)
|
| 223 |
+
else:
|
| 224 |
+
submit_result = await submit_blast(sequence, **submit_kwargs)
|
| 225 |
+
except Exception as e:
|
| 226 |
+
last_error = f"BLAST submit request failed: {e}"
|
| 227 |
+
logger.warning(
|
| 228 |
+
"BLAST submit threw (attempt %d/%d): %s",
|
| 229 |
+
attempt + 1, retries + 1, last_error,
|
| 230 |
+
)
|
| 231 |
+
if attempt < retries:
|
| 232 |
+
await asyncio.sleep(5 * (attempt + 1))
|
| 233 |
+
continue
|
| 234 |
+
|
| 235 |
if "error" in submit_result:
|
| 236 |
last_error = submit_result["error"]
|
| 237 |
logger.warning(
|
| 238 |
"BLAST submit failed (attempt %d/%d): %s",
|
| 239 |
attempt + 1, retries + 1, last_error,
|
| 240 |
)
|
| 241 |
+
if attempt < retries:
|
| 242 |
+
await asyncio.sleep(5 * (attempt + 1))
|
| 243 |
continue
|
| 244 |
|
| 245 |
rid = submit_result["rid"]
|
| 246 |
est = submit_result.get("estimated_seconds", 0)
|
| 247 |
logger.info(
|
| 248 |
+
"BLAST submitted (attempt %d/%d, sync=%s), RID=%s, est=%ds",
|
| 249 |
+
attempt + 1, retries + 1, use_sync, rid, est,
|
| 250 |
)
|
| 251 |
|
| 252 |
+
# Detect unreasonable RTOE — switch to sync on next attempt
|
| 253 |
+
if not use_sync and est > MAX_RTOE:
|
| 254 |
+
logger.warning(
|
| 255 |
+
"BLAST RTOE=%ds exceeds threshold (%ds), will use sync mode on retry", est, MAX_RTOE,
|
| 256 |
+
)
|
| 257 |
+
last_error = f"NCBI estimated {est}s queue time (threshold {MAX_RTOE}s)"
|
| 258 |
+
if attempt < retries:
|
| 259 |
+
await asyncio.sleep(2)
|
| 260 |
+
continue
|
| 261 |
+
|
| 262 |
+
if use_sync:
|
| 263 |
+
# Sync mode: NCBI blocked and returned the result inline in the submit response.
|
| 264 |
+
# The response text in submit_result may contain the XML already.
|
| 265 |
+
# We need to poll once to check if it's actually ready.
|
| 266 |
+
pass
|
| 267 |
+
|
| 268 |
+
try:
|
| 269 |
+
status_result = await check_status_until_ready(
|
| 270 |
+
rid, max_wait_seconds=max_wait_seconds, estimated_seconds=est,
|
| 271 |
+
)
|
| 272 |
+
except Exception as e:
|
| 273 |
+
last_error = f"BLAST polling crashed: {e}"
|
| 274 |
+
logger.warning("BLAST RID %s: %s", rid, last_error)
|
| 275 |
+
if attempt < retries:
|
| 276 |
+
await asyncio.sleep(5 * (attempt + 1))
|
| 277 |
+
continue
|
| 278 |
|
| 279 |
+
if status_result["status"] == "READY":
|
| 280 |
+
try:
|
| 281 |
+
return await fetch_results(rid)
|
| 282 |
+
except Exception as e:
|
| 283 |
+
last_error = f"BLAST result fetch failed: {e}"
|
| 284 |
+
logger.warning("BLAST RID %s: %s", rid, last_error)
|
| 285 |
+
if attempt < retries:
|
| 286 |
+
await asyncio.sleep(5 * (attempt + 1))
|
| 287 |
+
continue
|
| 288 |
+
|
| 289 |
+
last_error = f"BLAST {status_result['status']} after polling (attempt {attempt + 1}/{retries + 1})"
|
| 290 |
+
if status_result.get("error"):
|
| 291 |
+
last_error += f": {status_result['error']}"
|
| 292 |
logger.warning("BLAST RID %s: %s", rid, last_error)
|
| 293 |
+
if attempt < retries:
|
| 294 |
+
await asyncio.sleep(5 * (attempt + 1))
|
| 295 |
|
| 296 |
return {"error": last_error or "BLAST failed after all attempts"}
|
|
@@ -374,7 +374,7 @@ async def _run_blast(sequence: str, status_callback=None, fast_mode: bool = Fals
|
|
| 374 |
|
| 375 |
results = await ncbi_blast.run_blast_with_retry(
|
| 376 |
sequence,
|
| 377 |
-
retries=
|
| 378 |
max_wait_seconds=180 if fast_mode else 600,
|
| 379 |
database=database,
|
| 380 |
)
|
|
|
|
| 374 |
|
| 375 |
results = await ncbi_blast.run_blast_with_retry(
|
| 376 |
sequence,
|
| 377 |
+
retries=2,
|
| 378 |
max_wait_seconds=180 if fast_mode else 600,
|
| 379 |
database=database,
|
| 380 |
)
|
|
@@ -11,8 +11,8 @@ from app.services.cache import ttl_cache
|
|
| 11 |
class BlastTool(BaseTool):
|
| 12 |
name = "blast"
|
| 13 |
|
| 14 |
-
POLL_INTERVAL =
|
| 15 |
-
MAX_POLL_TIME =
|
| 16 |
|
| 17 |
@ttl_cache(ttl=86400, prefix="blast")
|
| 18 |
async def run(self, input: dict) -> dict:
|
|
@@ -42,14 +42,23 @@ class BlastTool(BaseTool):
|
|
| 42 |
|
| 43 |
async def _poll(self, job_id: str) -> str:
|
| 44 |
start = asyncio.get_event_loop().time()
|
| 45 |
-
async with httpx.AsyncClient(timeout=
|
|
|
|
| 46 |
while True:
|
| 47 |
elapsed = asyncio.get_event_loop().time() - start
|
| 48 |
if elapsed > self.MAX_POLL_TIME:
|
| 49 |
return "TIMEOUT"
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
if status in ("FINISHED", "ERROR", "FAILED"):
|
| 54 |
return status
|
| 55 |
await asyncio.sleep(self.POLL_INTERVAL)
|
|
|
|
| 11 |
class BlastTool(BaseTool):
|
| 12 |
name = "blast"
|
| 13 |
|
| 14 |
+
POLL_INTERVAL = 3.0
|
| 15 |
+
MAX_POLL_TIME = 180
|
| 16 |
|
| 17 |
@ttl_cache(ttl=86400, prefix="blast")
|
| 18 |
async def run(self, input: dict) -> dict:
|
|
|
|
| 42 |
|
| 43 |
async def _poll(self, job_id: str) -> str:
|
| 44 |
start = asyncio.get_event_loop().time()
|
| 45 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 46 |
+
consecutive_failures = 0
|
| 47 |
while True:
|
| 48 |
elapsed = asyncio.get_event_loop().time() - start
|
| 49 |
if elapsed > self.MAX_POLL_TIME:
|
| 50 |
return "TIMEOUT"
|
| 51 |
+
try:
|
| 52 |
+
resp = await client.get(f"{settings.EBI_BASE_URL}/status/{job_id}")
|
| 53 |
+
resp.raise_for_status()
|
| 54 |
+
status = resp.text.strip()
|
| 55 |
+
consecutive_failures = 0
|
| 56 |
+
except Exception as e:
|
| 57 |
+
consecutive_failures += 1
|
| 58 |
+
if consecutive_failures >= 5:
|
| 59 |
+
return "ERROR"
|
| 60 |
+
await asyncio.sleep(self.POLL_INTERVAL)
|
| 61 |
+
continue
|
| 62 |
if status in ("FINISHED", "ERROR", "FAILED"):
|
| 63 |
return status
|
| 64 |
await asyncio.sleep(self.POLL_INTERVAL)
|
|
@@ -0,0 +1,525 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Comprehensive BLAST test suite — loops over multiple datasets to verify
|
| 3 |
+
all BLAST features work end-to-end against live NCBI/EBI APIs.
|
| 4 |
+
|
| 5 |
+
Tests:
|
| 6 |
+
1. NCBI BLAST integration (submit → poll → fetch)
|
| 7 |
+
2. EBI BLAST tool (submit → poll → fetch)
|
| 8 |
+
3. Multiple protein sequences of varying lengths
|
| 9 |
+
4. Error handling (bad input, edge cases)
|
| 10 |
+
5. Pipeline v2 BLAST step
|
| 11 |
+
6. Retry logic verification
|
| 12 |
+
|
| 13 |
+
Run: pytest tests/test_blast_comprehensive.py -v -s --timeout=900
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import asyncio
|
| 17 |
+
import random
|
| 18 |
+
import string
|
| 19 |
+
import pytest
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
# Test datasets — real protein sequences of varying lengths
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
BLAST_TEST_SEQUENCES = [
|
| 25 |
+
{
|
| 26 |
+
"name": "crambin_short",
|
| 27 |
+
"sequence": "TTCCPSIVARSNFNVCRLPG",
|
| 28 |
+
"description": "Crambin first 20 residues (20 aa)",
|
| 29 |
+
"expected_program": "blastp",
|
| 30 |
+
"min_hits": 1,
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"name": "human_insulin",
|
| 34 |
+
"sequence": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 35 |
+
"description": "Human insulin preproinsulin (110 aa)",
|
| 36 |
+
"expected_program": "blastp",
|
| 37 |
+
"min_hits": 1,
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"name": "gfp_fragment",
|
| 41 |
+
"sequence": "MSKGEELFTGVVPILVELDGDVNGHKFSVSGEGEGDATYGKLTLKFICTTGKLPVPWPTLVTTFSYGVQCFSRYPDHMKQHDFFKSAMPEGYVQERTIFFKDDGNYKTRAEVKFEGDTLVNRIELKGIDFKEDGNILGHKLEYNYNSHNVYIMADKQKNGIKVNFKIRHNIEDGSVQLADHYQQNTPIGDGPVLLPDNHYLSTQSALSKDPNEKRDHMVLLEFVTAAGITHGMDELYK",
|
| 42 |
+
"description": "Green fluorescent protein (238 aa)",
|
| 43 |
+
"expected_program": "blastp",
|
| 44 |
+
"min_hits": 1,
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"name": "lysozyme",
|
| 48 |
+
"sequence": "KVFGRCELAAAMKRHGLDNYRGYSLGNWVCAAKFESNFNTQATNRNTDGSTDYGILQINSRWWCNDGRTPGSRNLCNIPCSALLSSDITASVNCAKKIVSDGNGMNAWVAWRNRCKGTDVQAWIRGCRL",
|
| 49 |
+
"description": "Hen egg white lysozyme (129 aa)",
|
| 50 |
+
"expected_program": "blastp",
|
| 51 |
+
"min_hits": 1,
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"name": "human_hemoglobin_beta",
|
| 55 |
+
"sequence": "MVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLSTPDAVMGNPKVKAHGKKVLGAFSDGLAHLDNLKGTFATLSELHCDKLHVDPENFRLLGNVLVCVLAHHFGKEFTPPVQAAYQKVVAGVANALAHKYH",
|
| 56 |
+
"description": "Human hemoglobin beta subunit (147 aa)",
|
| 57 |
+
"expected_program": "blastp",
|
| 58 |
+
"min_hits": 1,
|
| 59 |
+
},
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
# DNA sequence for blastn testing
|
| 63 |
+
DNA_TEST_SEQUENCES = [
|
| 64 |
+
{
|
| 65 |
+
"name": "small_rna",
|
| 66 |
+
"sequence": "ATGGCGACCGGCGCTCCCGCCGGGATCGCCATG",
|
| 67 |
+
"description": "Short DNA fragment (33 bp)",
|
| 68 |
+
"expected_program": "blastn",
|
| 69 |
+
"min_hits": 0, # DNA may or may not hit depending on db
|
| 70 |
+
},
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
# 1. NCBI BLAST integration — direct API tests
|
| 75 |
+
# ---------------------------------------------------------------------------
|
| 76 |
+
class TestNCBIBlastIntegration:
|
| 77 |
+
"""Test the NCBI BLAST integration module directly (submit → poll → fetch)."""
|
| 78 |
+
|
| 79 |
+
@pytest.mark.asyncio
|
| 80 |
+
@pytest.mark.parametrize(
|
| 81 |
+
"dataset",
|
| 82 |
+
BLAST_TEST_SEQUENCES[:3], # First 3 for speed
|
| 83 |
+
ids=[d["name"] for d in BLAST_TEST_SEQUENCES[:3]],
|
| 84 |
+
)
|
| 85 |
+
async def test_ncbi_blast_submit_and_poll(self, dataset):
|
| 86 |
+
"""Submit a BLAST job and poll until READY or TIMEOUT."""
|
| 87 |
+
from app.integrations.ncbi.blast import submit_blast, check_status_until_ready
|
| 88 |
+
|
| 89 |
+
seq = dataset["sequence"]
|
| 90 |
+
program = dataset["expected_program"]
|
| 91 |
+
db = "swissprot" # Use swissprot for faster results in tests
|
| 92 |
+
|
| 93 |
+
submit_result = await submit_blast(
|
| 94 |
+
seq, program=program, database=db, hitlist_size=10,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
if "error" in submit_result:
|
| 98 |
+
# NCBI may rate-limit; skip if submit fails
|
| 99 |
+
pytest.skip(f"NCBI submit failed: {submit_result['error']}")
|
| 100 |
+
|
| 101 |
+
rid = submit_result["rid"]
|
| 102 |
+
assert rid, "RID should not be empty"
|
| 103 |
+
assert len(rid) > 5, f"RID looks too short: {rid}"
|
| 104 |
+
|
| 105 |
+
# Poll with generous timeout (2 min for tests)
|
| 106 |
+
status_result = await check_status_until_ready(rid, max_wait_seconds=120)
|
| 107 |
+
status = status_result.get("status", "UNKNOWN")
|
| 108 |
+
assert status in ("READY", "TIMEOUT", "POLL_FAILED", "ERROR", "FAILED"), \
|
| 109 |
+
f"Unexpected status: {status}"
|
| 110 |
+
|
| 111 |
+
@pytest.mark.asyncio
|
| 112 |
+
async def test_ncbi_blast_full_roundtrip_short_seq(self):
|
| 113 |
+
"""Full roundtrip: submit → poll → fetch results for crambin fragment."""
|
| 114 |
+
from app.integrations.ncbi.blast import run_blast_with_retry
|
| 115 |
+
|
| 116 |
+
result = await run_blast_with_retry(
|
| 117 |
+
"TTCCPSIVARSNFNVCRLPG",
|
| 118 |
+
retries=2,
|
| 119 |
+
max_wait_seconds=180,
|
| 120 |
+
program="blastp",
|
| 121 |
+
database="swissprot",
|
| 122 |
+
hitlist_size=10,
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
if "error" in result:
|
| 126 |
+
pytest.skip(f"NCBI BLAST roundtrip failed (external API issue): {result['error']}")
|
| 127 |
+
|
| 128 |
+
assert "raw" in result, "Should return raw XML"
|
| 129 |
+
assert len(result["raw"]) > 100, "Raw XML too short"
|
| 130 |
+
|
| 131 |
+
@pytest.mark.asyncio
|
| 132 |
+
async def test_ncbi_blast_parse_xml(self):
|
| 133 |
+
"""Verify XML parsing of NCBI BLAST results."""
|
| 134 |
+
from app.integrations.ncbi.blast import run_blast_with_retry
|
| 135 |
+
from app.integrations.ncbi.parser import parse_blast_xml
|
| 136 |
+
|
| 137 |
+
result = await run_blast_with_retry(
|
| 138 |
+
"MVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLSTPDAVMGNPKVKAHGKKVLGAFSDGLAHLDNLKGTFATLSELHCDKLHVDPENFRLLGNVLVCVLAHHFGKEFTPPVQAAYQKVVAGVANALAHKYH",
|
| 139 |
+
retries=2,
|
| 140 |
+
max_wait_seconds=180,
|
| 141 |
+
program="blastp",
|
| 142 |
+
database="swissprot",
|
| 143 |
+
hitlist_size=10,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
if "error" in result:
|
| 147 |
+
pytest.skip(f"BLAST failed: {result['error']}")
|
| 148 |
+
|
| 149 |
+
parsed = parse_blast_xml(result["raw"])
|
| 150 |
+
assert "error" not in parsed, f"Parse error: {parsed.get('error')}"
|
| 151 |
+
assert parsed["count"] > 0, "Should find at least one hit"
|
| 152 |
+
assert parsed["query_length"] > 0, "Query length should be positive"
|
| 153 |
+
|
| 154 |
+
hit = parsed["hits"][0]
|
| 155 |
+
assert hit["accession"], "Hit should have accession"
|
| 156 |
+
assert hit["evalue"] >= 0, "E-value should be non-negative"
|
| 157 |
+
assert hit["bit_score"] > 0, "Bit score should be positive"
|
| 158 |
+
|
| 159 |
+
@pytest.mark.asyncio
|
| 160 |
+
async def test_ncbi_blast_retry_on_failure(self):
|
| 161 |
+
"""Verify that retry logic works — submitting with invalid sequence should fail gracefully."""
|
| 162 |
+
from app.integrations.ncbi.blast import run_blast_with_retry
|
| 163 |
+
|
| 164 |
+
# Submit with clearly invalid sequence (too short / garbage)
|
| 165 |
+
result = await run_blast_with_retry(
|
| 166 |
+
"XXXX",
|
| 167 |
+
retries=1,
|
| 168 |
+
max_wait_seconds=30,
|
| 169 |
+
program="blastp",
|
| 170 |
+
database="swissprot",
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
# Should either get an error or no hits — both are acceptable
|
| 174 |
+
# The key is it shouldn't crash with an unhandled exception
|
| 175 |
+
assert isinstance(result, dict), "Result should be a dict"
|
| 176 |
+
|
| 177 |
+
@pytest.mark.asyncio
|
| 178 |
+
async def test_ncbi_blast_timeout_handling(self):
|
| 179 |
+
"""Verify timeout returns proper error, not an unhandled exception."""
|
| 180 |
+
from app.integrations.ncbi.blast import run_blast_with_retry
|
| 181 |
+
|
| 182 |
+
result = await run_blast_with_retry(
|
| 183 |
+
"TTCCPSIVARSNFNVCRLPG",
|
| 184 |
+
retries=0,
|
| 185 |
+
max_wait_seconds=5, # Very short timeout — will almost certainly time out
|
| 186 |
+
program="blastp",
|
| 187 |
+
database="nr", # nr is slower than swissprot
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
# Should return a dict with error or hits — never crash
|
| 191 |
+
assert isinstance(result, dict), "Should return dict even on timeout"
|
| 192 |
+
# Either it finished very fast (unlikely) or it timed out gracefully
|
| 193 |
+
assert "error" in result or "raw" in result
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# ---------------------------------------------------------------------------
|
| 197 |
+
# 2. EBI BLAST tool tests
|
| 198 |
+
# ---------------------------------------------------------------------------
|
| 199 |
+
class TestEBIBlastTool:
|
| 200 |
+
"""Test the EBI BLAST tool (BlastTool class)."""
|
| 201 |
+
|
| 202 |
+
@pytest.mark.asyncio
|
| 203 |
+
@pytest.mark.parametrize(
|
| 204 |
+
"dataset",
|
| 205 |
+
BLAST_TEST_SEQUENCES[:2],
|
| 206 |
+
ids=[d["name"] for d in BLAST_TEST_SEQUENCES[:2]],
|
| 207 |
+
)
|
| 208 |
+
async def test_ebi_blast_returns_hits(self, dataset):
|
| 209 |
+
"""EBI BLAST should find hits for known proteins."""
|
| 210 |
+
from app.tools.blast import BlastTool
|
| 211 |
+
|
| 212 |
+
tool = BlastTool()
|
| 213 |
+
result = await tool.run({
|
| 214 |
+
"sequence": dataset["sequence"],
|
| 215 |
+
"program": "blastp",
|
| 216 |
+
"database": "uniprotkb_swissprot",
|
| 217 |
+
"max_hits": 5,
|
| 218 |
+
})
|
| 219 |
+
|
| 220 |
+
assert "hits" in result or "error" in result
|
| 221 |
+
if "hits" in result:
|
| 222 |
+
assert len(result["hits"]) > 0, "Should find at least one hit"
|
| 223 |
+
assert result["count"] == len(result["hits"])
|
| 224 |
+
|
| 225 |
+
@pytest.mark.asyncio
|
| 226 |
+
async def test_ebi_blast_hit_structure(self):
|
| 227 |
+
"""Verify BLAST hit data structure is correct."""
|
| 228 |
+
from app.tools.blast import BlastTool
|
| 229 |
+
|
| 230 |
+
tool = BlastTool()
|
| 231 |
+
result = await tool.run({
|
| 232 |
+
"sequence": "TTCCPSIVARSNFNVCRLPG",
|
| 233 |
+
"program": "blastp",
|
| 234 |
+
"database": "uniprotkb_swissprot",
|
| 235 |
+
"max_hits": 3,
|
| 236 |
+
})
|
| 237 |
+
|
| 238 |
+
if "error" in result:
|
| 239 |
+
pytest.skip(f"EBI BLAST failed: {result['error']}")
|
| 240 |
+
|
| 241 |
+
for hit in result["hits"]:
|
| 242 |
+
assert "accession" in hit
|
| 243 |
+
assert "evalue" in hit
|
| 244 |
+
assert "bit_score" in hit
|
| 245 |
+
assert "identity_pct" in hit
|
| 246 |
+
assert hit["evalue"] < 1.0, f"E-value too high: {hit['evalue']}"
|
| 247 |
+
|
| 248 |
+
@pytest.mark.asyncio
|
| 249 |
+
async def test_ebi_blast_poll_resilience(self):
|
| 250 |
+
"""Verify EBI BLAST poll handles transient failures."""
|
| 251 |
+
from app.tools.blast import BlastTool
|
| 252 |
+
|
| 253 |
+
tool = BlastTool()
|
| 254 |
+
# This tests the enhanced poll with failure tolerance
|
| 255 |
+
result = await tool.run({
|
| 256 |
+
"sequence": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 257 |
+
"program": "blastp",
|
| 258 |
+
"database": "uniprotkb_swissprot",
|
| 259 |
+
"max_hits": 5,
|
| 260 |
+
})
|
| 261 |
+
|
| 262 |
+
# Should not crash — either hits or graceful error
|
| 263 |
+
assert isinstance(result, dict)
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# ---------------------------------------------------------------------------
|
| 267 |
+
# 3. Pipeline v2 BLAST step integration
|
| 268 |
+
# ---------------------------------------------------------------------------
|
| 269 |
+
class TestPipelineV2BLAST:
|
| 270 |
+
"""Test BLAST as part of the pipeline v2 flow."""
|
| 271 |
+
|
| 272 |
+
@pytest.mark.asyncio
|
| 273 |
+
async def test_pipeline_blast_step_only(self):
|
| 274 |
+
"""Run only the BLAST step via pipeline_v2."""
|
| 275 |
+
from app.integrations.ncbi.blast import run_blast_with_retry
|
| 276 |
+
from app.integrations.ncbi.parser import parse_blast_xml
|
| 277 |
+
|
| 278 |
+
# Simulate what pipeline_v2._run_blast does
|
| 279 |
+
sequence = "TTCCPSIVARSNFNVCRLPG"
|
| 280 |
+
database = "swissprot"
|
| 281 |
+
|
| 282 |
+
results = await run_blast_with_retry(
|
| 283 |
+
sequence,
|
| 284 |
+
retries=2,
|
| 285 |
+
max_wait_seconds=180,
|
| 286 |
+
database=database,
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
if "error" in results:
|
| 290 |
+
pytest.skip(f"Pipeline BLAST step failed: {results['error']}")
|
| 291 |
+
|
| 292 |
+
parsed = parse_blast_xml(results["raw"])
|
| 293 |
+
assert "error" not in parsed
|
| 294 |
+
assert parsed["count"] > 0
|
| 295 |
+
|
| 296 |
+
hits = parsed.get("hits", [])
|
| 297 |
+
top_hit = hits[0] if hits else None
|
| 298 |
+
|
| 299 |
+
# Verify the data structure matches what pipeline_v2 expects
|
| 300 |
+
if top_hit:
|
| 301 |
+
assert "accession" in top_hit
|
| 302 |
+
assert "evalue" in top_hit
|
| 303 |
+
assert "identity_pct" in top_hit
|
| 304 |
+
assert "bit_score" in top_hit
|
| 305 |
+
|
| 306 |
+
@pytest.mark.asyncio
|
| 307 |
+
async def test_pipeline_blast_multiple_sequences_loop(self):
|
| 308 |
+
"""Loop: run BLAST for each test sequence and verify results."""
|
| 309 |
+
from app.integrations.ncbi.blast import run_blast_with_retry
|
| 310 |
+
from app.integrations.ncbi.parser import parse_blast_xml
|
| 311 |
+
|
| 312 |
+
results_summary = []
|
| 313 |
+
|
| 314 |
+
for dataset in BLAST_TEST_SEQUENCES:
|
| 315 |
+
sequence = dataset["sequence"]
|
| 316 |
+
name = dataset["name"]
|
| 317 |
+
|
| 318 |
+
try:
|
| 319 |
+
blast_result = await run_blast_with_retry(
|
| 320 |
+
sequence,
|
| 321 |
+
retries=2,
|
| 322 |
+
max_wait_seconds=180,
|
| 323 |
+
program=dataset["expected_program"],
|
| 324 |
+
database="swissprot",
|
| 325 |
+
hitlist_size=10,
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
if "error" in blast_result:
|
| 329 |
+
results_summary.append({
|
| 330 |
+
"name": name,
|
| 331 |
+
"status": "error",
|
| 332 |
+
"error": blast_result["error"],
|
| 333 |
+
})
|
| 334 |
+
continue
|
| 335 |
+
|
| 336 |
+
parsed = parse_blast_xml(blast_result["raw"])
|
| 337 |
+
hit_count = parsed.get("count", 0)
|
| 338 |
+
results_summary.append({
|
| 339 |
+
"name": name,
|
| 340 |
+
"status": "ok" if hit_count >= dataset["min_hits"] else "low_hits",
|
| 341 |
+
"hits": hit_count,
|
| 342 |
+
})
|
| 343 |
+
|
| 344 |
+
except Exception as e:
|
| 345 |
+
results_summary.append({
|
| 346 |
+
"name": name,
|
| 347 |
+
"status": "exception",
|
| 348 |
+
"error": str(e),
|
| 349 |
+
})
|
| 350 |
+
|
| 351 |
+
# Rate limit: wait between requests
|
| 352 |
+
await asyncio.sleep(2)
|
| 353 |
+
|
| 354 |
+
# Report results
|
| 355 |
+
print("\n=== BLAST Loop Test Results ===")
|
| 356 |
+
for r in results_summary:
|
| 357 |
+
print(f" {r['name']}: {r['status']} (hits={r.get('hits', 'N/A')}, error={r.get('error', 'none')})")
|
| 358 |
+
|
| 359 |
+
# At least some should succeed (unless NCBI is completely down)
|
| 360 |
+
ok_count = sum(1 for r in results_summary if r["status"] == "ok")
|
| 361 |
+
assert ok_count >= 1, f"No BLAST tests succeeded: {results_summary}"
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
# ---------------------------------------------------------------------------
|
| 365 |
+
# 4. Error handling & edge cases
|
| 366 |
+
# ---------------------------------------------------------------------------
|
| 367 |
+
class TestBlastErrorHandling:
|
| 368 |
+
"""Test BLAST error handling with bad inputs."""
|
| 369 |
+
|
| 370 |
+
@pytest.mark.asyncio
|
| 371 |
+
async def test_empty_sequence(self):
|
| 372 |
+
"""BLAST should handle empty sequence gracefully."""
|
| 373 |
+
from app.integrations.ncbi.blast import run_blast_with_retry
|
| 374 |
+
|
| 375 |
+
result = await run_blast_with_retry(
|
| 376 |
+
"",
|
| 377 |
+
retries=0,
|
| 378 |
+
max_wait_seconds=10,
|
| 379 |
+
database="swissprot",
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
assert isinstance(result, dict)
|
| 383 |
+
assert "error" in result or "raw" in result
|
| 384 |
+
|
| 385 |
+
@pytest.mark.asyncio
|
| 386 |
+
async def test_invalid_characters(self):
|
| 387 |
+
"""BLAST should handle non-biological characters."""
|
| 388 |
+
from app.integrations.ncbi.blast import run_blast_with_retry
|
| 389 |
+
|
| 390 |
+
result = await run_blast_with_retry(
|
| 391 |
+
"12345!@#$%^&*()",
|
| 392 |
+
retries=0,
|
| 393 |
+
max_wait_seconds=30,
|
| 394 |
+
program="blastp",
|
| 395 |
+
database="swissprot",
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
assert isinstance(result, dict)
|
| 399 |
+
|
| 400 |
+
@pytest.mark.asyncio
|
| 401 |
+
async def test_very_long_sequence(self):
|
| 402 |
+
"""BLAST should handle long sequences (may be slow but shouldn't crash)."""
|
| 403 |
+
from app.integrations.ncbi.blast import run_blast_with_retry
|
| 404 |
+
|
| 405 |
+
# Generate a 500 aa random protein sequence
|
| 406 |
+
aa_chars = "ACDEFGHIKLMNPQRSTVWY"
|
| 407 |
+
long_seq = "".join(random.choice(aa_chars) for _ in range(500))
|
| 408 |
+
|
| 409 |
+
result = await run_blast_with_retry(
|
| 410 |
+
long_seq,
|
| 411 |
+
retries=1,
|
| 412 |
+
max_wait_seconds=120,
|
| 413 |
+
program="blastp",
|
| 414 |
+
database="swissprot",
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
assert isinstance(result, dict)
|
| 418 |
+
|
| 419 |
+
@pytest.mark.asyncio
|
| 420 |
+
async def test_dna_blastn(self):
|
| 421 |
+
"""Test blastn with a short DNA sequence."""
|
| 422 |
+
from app.integrations.ncbi.blast import run_blast_with_retry
|
| 423 |
+
|
| 424 |
+
result = await run_blast_with_retry(
|
| 425 |
+
"ATGGCGACCGGCGCTCCCGCCGGGATCGCCATG",
|
| 426 |
+
retries=1,
|
| 427 |
+
max_wait_seconds=120,
|
| 428 |
+
program="blastn",
|
| 429 |
+
database="nt",
|
| 430 |
+
hitlist_size=5,
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
assert isinstance(result, dict)
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
# ---------------------------------------------------------------------------
|
| 437 |
+
# 5. Random data fuzz testing
|
| 438 |
+
# ---------------------------------------------------------------------------
|
| 439 |
+
class TestBlastRandomFuzz:
|
| 440 |
+
"""Fuzz test: BLAST with random sequences to verify no crashes."""
|
| 441 |
+
|
| 442 |
+
@pytest.mark.asyncio
|
| 443 |
+
@pytest.mark.parametrize("i", range(5))
|
| 444 |
+
async def test_random_protein_sequence(self, i):
|
| 445 |
+
"""Submit 5 random protein sequences — none should crash."""
|
| 446 |
+
from app.integrations.ncbi.blast import run_blast_with_retry
|
| 447 |
+
|
| 448 |
+
aa_chars = "ACDEFGHIKLMNPQRSTVWY"
|
| 449 |
+
length = random.randint(10, 80)
|
| 450 |
+
random_seq = "".join(random.choice(aa_chars) for _ in range(length))
|
| 451 |
+
|
| 452 |
+
result = await run_blast_with_retry(
|
| 453 |
+
random_seq,
|
| 454 |
+
retries=1,
|
| 455 |
+
max_wait_seconds=90,
|
| 456 |
+
program="blastp",
|
| 457 |
+
database="swissprot",
|
| 458 |
+
hitlist_size=5,
|
| 459 |
+
)
|
| 460 |
+
|
| 461 |
+
# Must return a dict — no unhandled exceptions
|
| 462 |
+
assert isinstance(result, dict), f"BLAST crashed on random seq: {random_seq}"
|
| 463 |
+
# Should have either error or raw (results)
|
| 464 |
+
assert "error" in result or "raw" in result
|
| 465 |
+
|
| 466 |
+
@pytest.mark.asyncio
|
| 467 |
+
async def test_random_edge_case_lengths(self):
|
| 468 |
+
"""Test BLAST with edge-case sequence lengths."""
|
| 469 |
+
from app.integrations.ncbi.blast import run_blast_with_retry
|
| 470 |
+
|
| 471 |
+
aa_chars = "ACDEFGHIKLMNPQRSTVWY"
|
| 472 |
+
edge_cases = [
|
| 473 |
+
("min_valid", "".join(random.choice(aa_chars) for _ in range(6))), # minimum
|
| 474 |
+
("medium", "".join(random.choice(aa_chars) for _ in range(50))),
|
| 475 |
+
("long", "".join(random.choice(aa_chars) for _ in range(200))),
|
| 476 |
+
]
|
| 477 |
+
|
| 478 |
+
for name, seq in edge_cases:
|
| 479 |
+
result = await run_blast_with_retry(
|
| 480 |
+
seq,
|
| 481 |
+
retries=1,
|
| 482 |
+
max_wait_seconds=90,
|
| 483 |
+
program="blastp",
|
| 484 |
+
database="swissprot",
|
| 485 |
+
hitlist_size=5,
|
| 486 |
+
)
|
| 487 |
+
assert isinstance(result, dict), f"BLAST crashed on {name} ({len(seq)} aa)"
|
| 488 |
+
await asyncio.sleep(2)
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
# ---------------------------------------------------------------------------
|
| 492 |
+
# 6. Consecutive reliability test
|
| 493 |
+
# ---------------------------------------------------------------------------
|
| 494 |
+
class TestBlastReliability:
|
| 495 |
+
"""Run BLAST multiple times in sequence to verify consistent behavior."""
|
| 496 |
+
|
| 497 |
+
@pytest.mark.asyncio
|
| 498 |
+
async def test_consecutive_blast_runs(self):
|
| 499 |
+
"""Run BLAST 3 times with the same sequence — should all succeed or all fail consistently."""
|
| 500 |
+
from app.integrations.ncbi.blast import run_blast_with_retry
|
| 501 |
+
from app.integrations.ncbi.parser import parse_blast_xml
|
| 502 |
+
|
| 503 |
+
sequence = "TTCCPSIVARSNFNVCRLPG"
|
| 504 |
+
results = []
|
| 505 |
+
|
| 506 |
+
for i in range(3):
|
| 507 |
+
result = await run_blast_with_retry(
|
| 508 |
+
sequence,
|
| 509 |
+
retries=2,
|
| 510 |
+
max_wait_seconds=120,
|
| 511 |
+
program="blastp",
|
| 512 |
+
database="swissprot",
|
| 513 |
+
hitlist_size=5,
|
| 514 |
+
)
|
| 515 |
+
results.append(result)
|
| 516 |
+
await asyncio.sleep(3)
|
| 517 |
+
|
| 518 |
+
# Count successes vs failures
|
| 519 |
+
successes = sum(1 for r in results if "raw" in r)
|
| 520 |
+
failures = sum(1 for r in results if "error" in r)
|
| 521 |
+
|
| 522 |
+
print(f"\n=== Reliability: {successes} successes, {failures} failures out of 3 ===")
|
| 523 |
+
|
| 524 |
+
# At least 2/3 should succeed (NCBI occasional hiccups are OK)
|
| 525 |
+
assert successes >= 2, f"Too many BLAST failures: {failures}/3 failed"
|