Spaces:
Running
Running
feat(uniprot,domains): reviewed/organism search filters + ScanProsite raw-sequence motif scan (3c,3d)
Browse files- app/routers/domains.py +35 -1
- app/routers/uniprot.py +10 -1
- app/tools/domain_analysis.py +77 -1
- app/tools/uniprot.py +1 -0
- tests/test_scanprosite.py +127 -0
- tests/test_uniprot_search.py +107 -0
app/routers/domains.py
CHANGED
|
@@ -15,7 +15,8 @@ Provides comprehensive protein feature analysis:
|
|
| 15 |
- Combined analysis endpoint
|
| 16 |
"""
|
| 17 |
from fastapi import APIRouter, HTTPException
|
| 18 |
-
from pydantic import BaseModel
|
|
|
|
| 19 |
from app.tools.domain_analysis import (
|
| 20 |
_sanitize,
|
| 21 |
fetch_interpro_domains,
|
|
@@ -31,6 +32,7 @@ from app.tools.domain_analysis import (
|
|
| 31 |
extract_go_terms,
|
| 32 |
extract_pathways,
|
| 33 |
full_analysis,
|
|
|
|
| 34 |
)
|
| 35 |
|
| 36 |
router = APIRouter(prefix="/api/domains", tags=["domains"])
|
|
@@ -278,6 +280,38 @@ async def get_all_features(accession: str):
|
|
| 278 |
return FullAnalysisResponse(**result)
|
| 279 |
|
| 280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
# ---------------------------------------------------------------------------
|
| 282 |
# Helpers
|
| 283 |
# ---------------------------------------------------------------------------
|
|
|
|
| 15 |
- Combined analysis endpoint
|
| 16 |
"""
|
| 17 |
from fastapi import APIRouter, HTTPException
|
| 18 |
+
from pydantic import BaseModel, Field
|
| 19 |
+
from app.config import settings
|
| 20 |
from app.tools.domain_analysis import (
|
| 21 |
_sanitize,
|
| 22 |
fetch_interpro_domains,
|
|
|
|
| 32 |
extract_go_terms,
|
| 33 |
extract_pathways,
|
| 34 |
full_analysis,
|
| 35 |
+
scan_prosite_sequence,
|
| 36 |
)
|
| 37 |
|
| 38 |
router = APIRouter(prefix="/api/domains", tags=["domains"])
|
|
|
|
| 280 |
return FullAnalysisResponse(**result)
|
| 281 |
|
| 282 |
|
| 283 |
+
# ---------------------------------------------------------------------------
|
| 284 |
+
# ScanProsite — raw-sequence motif scanning
|
| 285 |
+
# ---------------------------------------------------------------------------
|
| 286 |
+
|
| 287 |
+
class ScanPrositeRequest(BaseModel):
|
| 288 |
+
sequence: str = Field(..., min_length=1, description="Raw protein sequence (no FASTA header required)")
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
class ScanPrositeMatch(BaseModel):
|
| 292 |
+
signature_ac: str
|
| 293 |
+
name: str = ""
|
| 294 |
+
start: int
|
| 295 |
+
stop: int
|
| 296 |
+
level_tag: str = ""
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
class ScanPrositeResponse(BaseModel):
|
| 300 |
+
sequence_length: int
|
| 301 |
+
count: int
|
| 302 |
+
matches: list[ScanPrositeMatch]
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
@router.post("/scan", response_model=ScanPrositeResponse)
|
| 306 |
+
async def scan_prosite(req: ScanPrositeRequest):
|
| 307 |
+
"""Scan a raw protein sequence against PROSITE signatures (best-effort)."""
|
| 308 |
+
email = settings.NCBI_EMAIL or ""
|
| 309 |
+
result = await scan_prosite_sequence(req.sequence, email)
|
| 310 |
+
if "error" in result:
|
| 311 |
+
raise HTTPException(status_code=400, detail=result["error"])
|
| 312 |
+
return ScanPrositeResponse(**result)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
# ---------------------------------------------------------------------------
|
| 316 |
# Helpers
|
| 317 |
# ---------------------------------------------------------------------------
|
app/routers/uniprot.py
CHANGED
|
@@ -14,6 +14,8 @@ ncbi_service = NCBIService()
|
|
| 14 |
class UniprotSearchRequest(BaseModel):
|
| 15 |
query: str = Field(..., min_length=2, description="Free-text search (gene name, protein name, keyword)")
|
| 16 |
max_results: int = Field(20, ge=1, le=50)
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
class UniprotAccessionRequest(BaseModel):
|
|
@@ -27,7 +29,12 @@ class UniprotCDSRequest(BaseModel):
|
|
| 27 |
@router.post("/search")
|
| 28 |
async def search_uniprot(req: UniprotSearchRequest):
|
| 29 |
url = f"{settings.UNIPROT_BASE_URL}/search"
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
async with httpx.AsyncClient(timeout=15) as client:
|
| 32 |
resp = await client.get(url, params=params)
|
| 33 |
if resp.status_code != 200:
|
|
@@ -36,12 +43,14 @@ async def search_uniprot(req: UniprotSearchRequest):
|
|
| 36 |
results = data.get("results", [])
|
| 37 |
out = []
|
| 38 |
for r in results:
|
|
|
|
| 39 |
out.append({
|
| 40 |
"accession": r.get("primaryAccession", ""),
|
| 41 |
"name": ((r.get("proteinDescription", {}) or {}).get("recommendedName", {}) or {}).get("fullName", {}).get("value", ""),
|
| 42 |
"gene_names": [g.get("geneName", {}).get("value", "") for g in (r.get("genes") or []) if g.get("geneName")],
|
| 43 |
"organism": (r.get("organism", {}) or {}).get("scientificName", ""),
|
| 44 |
"length": ((r.get("sequence", {}) or {}).get("length", 0)),
|
|
|
|
| 45 |
})
|
| 46 |
return {"results": out, "count": len(out)}
|
| 47 |
|
|
|
|
| 14 |
class UniprotSearchRequest(BaseModel):
|
| 15 |
query: str = Field(..., min_length=2, description="Free-text search (gene name, protein name, keyword)")
|
| 16 |
max_results: int = Field(20, ge=1, le=50)
|
| 17 |
+
reviewed: bool = Field(False, description="Only Swiss-Prot (reviewed) entries")
|
| 18 |
+
organism: str = Field("", description="Restrict results to an organism (e.g. Homo sapiens)")
|
| 19 |
|
| 20 |
|
| 21 |
class UniprotAccessionRequest(BaseModel):
|
|
|
|
| 29 |
@router.post("/search")
|
| 30 |
async def search_uniprot(req: UniprotSearchRequest):
|
| 31 |
url = f"{settings.UNIPROT_BASE_URL}/search"
|
| 32 |
+
query = req.query.strip()
|
| 33 |
+
if req.organism.strip():
|
| 34 |
+
query = f'{query} AND organism_name:"{req.organism.strip()}"'
|
| 35 |
+
if req.reviewed:
|
| 36 |
+
query = f"{query} AND reviewed:true"
|
| 37 |
+
params = {"query": query, "format": "json", "size": req.max_results}
|
| 38 |
async with httpx.AsyncClient(timeout=15) as client:
|
| 39 |
resp = await client.get(url, params=params)
|
| 40 |
if resp.status_code != 200:
|
|
|
|
| 43 |
results = data.get("results", [])
|
| 44 |
out = []
|
| 45 |
for r in results:
|
| 46 |
+
entry_type = (r.get("entryType", "") or "").lower()
|
| 47 |
out.append({
|
| 48 |
"accession": r.get("primaryAccession", ""),
|
| 49 |
"name": ((r.get("proteinDescription", {}) or {}).get("recommendedName", {}) or {}).get("fullName", {}).get("value", ""),
|
| 50 |
"gene_names": [g.get("geneName", {}).get("value", "") for g in (r.get("genes") or []) if g.get("geneName")],
|
| 51 |
"organism": (r.get("organism", {}) or {}).get("scientificName", ""),
|
| 52 |
"length": ((r.get("sequence", {}) or {}).get("length", 0)),
|
| 53 |
+
"reviewed": "reviewed" in entry_type and "unreviewed" not in entry_type,
|
| 54 |
})
|
| 55 |
return {"results": out, "count": len(out)}
|
| 56 |
|
app/tools/domain_analysis.py
CHANGED
|
@@ -7,19 +7,28 @@ functional sites, PTMs, topology, motifs, variants, GO terms, pathways).
|
|
| 7 |
Provides the shared analysis functions used by both the standalone router
|
| 8 |
and the pipeline_v2 orchestrator.
|
| 9 |
"""
|
|
|
|
| 10 |
import re
|
| 11 |
import httpx
|
| 12 |
from typing import Any
|
| 13 |
|
|
|
|
|
|
|
| 14 |
|
| 15 |
INTERPRO_API = "https://www.ebi.ac.uk/interpro/api/entry/all/protein/UniProt/{accession}/?format=json&page_size=50"
|
| 16 |
UNIPROT_API = "https://rest.uniprot.org/uniprotkb/{accession}.json"
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def _sanitize(s: str) -> str:
|
| 20 |
return re.sub(r'[\x00-\x1f\x7f-\x9f]', '', s).strip().upper()
|
| 21 |
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
async def fetch_uniprot_raw(accession: str) -> dict:
|
| 24 |
"""Fetch raw UniProt JSON for an accession."""
|
| 25 |
accession = _sanitize(accession)
|
|
@@ -384,7 +393,6 @@ def extract_pathways(raw: dict) -> list[dict]:
|
|
| 384 |
# ---------------------------------------------------------------------------
|
| 385 |
# 12. Combined Analysis (all features at once)
|
| 386 |
# ---------------------------------------------------------------------------
|
| 387 |
-
|
| 388 |
async def full_analysis(accession: str) -> dict:
|
| 389 |
"""Run all domain/motif analyses for a UniProt accession."""
|
| 390 |
accession = _sanitize(accession)
|
|
@@ -430,3 +438,71 @@ async def full_analysis(accession: str) -> dict:
|
|
| 430 |
cat: len(items) for cat, items in features.items() if items
|
| 431 |
},
|
| 432 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
Provides the shared analysis functions used by both the standalone router
|
| 8 |
and the pipeline_v2 orchestrator.
|
| 9 |
"""
|
| 10 |
+
import hashlib
|
| 11 |
import re
|
| 12 |
import httpx
|
| 13 |
from typing import Any
|
| 14 |
|
| 15 |
+
from app.config import settings
|
| 16 |
+
from app.services.cache import cache_get, cache_set
|
| 17 |
|
| 18 |
INTERPRO_API = "https://www.ebi.ac.uk/interpro/api/entry/all/protein/UniProt/{accession}/?format=json&page_size=50"
|
| 19 |
UNIPROT_API = "https://rest.uniprot.org/uniprotkb/{accession}.json"
|
| 20 |
+
SCANPROSITE_URL = "https://prosite.expasy.org/cgi-bin/prosite/scanprosite/PSScan.cgi"
|
| 21 |
+
INTERPRO_ENTRY_API = "https://www.ebi.ac.uk/interpro/api/entry/prosite/{signature}/"
|
| 22 |
|
| 23 |
|
| 24 |
def _sanitize(s: str) -> str:
|
| 25 |
return re.sub(r'[\x00-\x1f\x7f-\x9f]', '', s).strip().upper()
|
| 26 |
|
| 27 |
|
| 28 |
+
def _clean_sequence(sequence: str) -> str:
|
| 29 |
+
return "".join(c for c in _sanitize(sequence) if c.isalpha())
|
| 30 |
+
|
| 31 |
+
|
| 32 |
async def fetch_uniprot_raw(accession: str) -> dict:
|
| 33 |
"""Fetch raw UniProt JSON for an accession."""
|
| 34 |
accession = _sanitize(accession)
|
|
|
|
| 393 |
# ---------------------------------------------------------------------------
|
| 394 |
# 12. Combined Analysis (all features at once)
|
| 395 |
# ---------------------------------------------------------------------------
|
|
|
|
| 396 |
async def full_analysis(accession: str) -> dict:
|
| 397 |
"""Run all domain/motif analyses for a UniProt accession."""
|
| 398 |
accession = _sanitize(accession)
|
|
|
|
| 438 |
cat: len(items) for cat, items in features.items() if items
|
| 439 |
},
|
| 440 |
}
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
# ---------------------------------------------------------------------------
|
| 444 |
+
# 13. ScanProsite — raw-sequence motif scan
|
| 445 |
+
# ---------------------------------------------------------------------------
|
| 446 |
+
|
| 447 |
+
def _signature_cache_key(signature_ac: str) -> str:
|
| 448 |
+
return f"prosite_signature_name:{hashlib.sha256(signature_ac.encode()).hexdigest()[:16]}"
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
async def _prosite_signature_name(signature_ac: str) -> str:
|
| 452 |
+
"""Best-effort PROSITE signature name resolution via InterPro (cached)."""
|
| 453 |
+
cached = cache_get(_signature_cache_key(signature_ac))
|
| 454 |
+
if cached is not None:
|
| 455 |
+
return cached
|
| 456 |
+
name = ""
|
| 457 |
+
try:
|
| 458 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 459 |
+
resp = await client.get(INTERPRO_ENTRY_API.format(signature=signature_ac))
|
| 460 |
+
if resp.status_code == 200:
|
| 461 |
+
md = resp.json().get("metadata") or {}
|
| 462 |
+
nm = md.get("name") or {}
|
| 463 |
+
name = str(nm.get("name") or "")
|
| 464 |
+
except Exception:
|
| 465 |
+
pass
|
| 466 |
+
if name:
|
| 467 |
+
cache_set(_signature_cache_key(signature_ac), name, ttl=86400)
|
| 468 |
+
return name
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
async def scan_prosite_sequence(sequence: str, email: str = "") -> dict:
|
| 472 |
+
"""Scan a raw protein sequence against PROSITE signatures.
|
| 473 |
+
|
| 474 |
+
Contract verified live (2026-08-03): POST the sequence to ScanProsite with
|
| 475 |
+
``output=json``; the response is ``{"n_match", "n_seq", "matchset": [...]}``
|
| 476 |
+
where each match has ``sequence_ac``/``start``/``stop``/``signature_ac``/
|
| 477 |
+
``level_tag``. Returns ``{"sequence_length", "count", "matches"}``.
|
| 478 |
+
"""
|
| 479 |
+
clean = _clean_sequence(sequence)
|
| 480 |
+
if len(clean) < 10:
|
| 481 |
+
return {"error": "Sequence too short (min 10 amino acids)"}
|
| 482 |
+
|
| 483 |
+
async with httpx.AsyncClient(timeout=60) as client:
|
| 484 |
+
resp = await client.post(
|
| 485 |
+
SCANPROSITE_URL,
|
| 486 |
+
data={"seq": clean, "output": "json", "email": email or "bioflow@example.com"},
|
| 487 |
+
)
|
| 488 |
+
if resp.status_code != 200:
|
| 489 |
+
return {"error": f"ScanProsite returned HTTP {resp.status_code}"}
|
| 490 |
+
data = resp.json()
|
| 491 |
+
|
| 492 |
+
matchset = data.get("matchset") or []
|
| 493 |
+
matches: list[dict] = []
|
| 494 |
+
for m in matchset:
|
| 495 |
+
signature_ac = str(m.get("signature_ac", ""))
|
| 496 |
+
matches.append({
|
| 497 |
+
"signature_ac": signature_ac,
|
| 498 |
+
"name": await _prosite_signature_name(signature_ac),
|
| 499 |
+
"start": int(m.get("start", 0)),
|
| 500 |
+
"stop": int(m.get("stop", 0)),
|
| 501 |
+
"level_tag": str(m.get("level_tag", "")),
|
| 502 |
+
})
|
| 503 |
+
|
| 504 |
+
return {
|
| 505 |
+
"sequence_length": len(clean),
|
| 506 |
+
"count": len(matches),
|
| 507 |
+
"matches": matches,
|
| 508 |
+
}
|
app/tools/uniprot.py
CHANGED
|
@@ -21,6 +21,7 @@ class UniprotTool(BaseTool):
|
|
| 21 |
|
| 22 |
return {
|
| 23 |
"accession": data.get("primaryAccession", ""),
|
|
|
|
| 24 |
"full_name": self._extract_name(data),
|
| 25 |
"ec_number": (data.get("proteinDescription", {}) or {}).get("ecNumbers", [{}])[0].get("ecNumber", "") if data.get("proteinDescription") else "",
|
| 26 |
"gene_names": [g.get("geneName", {}).get("value", "") for g in (data.get("genes") or []) if g.get("geneName")],
|
|
|
|
| 21 |
|
| 22 |
return {
|
| 23 |
"accession": data.get("primaryAccession", ""),
|
| 24 |
+
"reviewed": "reviewed" in (data.get("entryType", "") or "").lower() and "unreviewed" not in (data.get("entryType", "") or "").lower(),
|
| 25 |
"full_name": self._extract_name(data),
|
| 26 |
"ec_number": (data.get("proteinDescription", {}) or {}).get("ecNumbers", [{}])[0].get("ecNumber", "") if data.get("proteinDescription") else "",
|
| 27 |
"gene_names": [g.get("geneName", {}).get("value", "") for g in (data.get("genes") or []) if g.get("geneName")],
|
tests/test_scanprosite.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for 3d ScanProsite raw-sequence motif scanning:
|
| 3 |
+
|
| 4 |
+
- scan_prosite_sequence parses the verified ScanProsite JSON contract
|
| 5 |
+
- short sequences return a clear error
|
| 6 |
+
- empty matchset -> count 0
|
| 7 |
+
- POST /api/domains/scan router: success + 400 on short sequence
|
| 8 |
+
|
| 9 |
+
Network calls are mocked; these run fully offline.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
from fastapi import HTTPException
|
| 14 |
+
|
| 15 |
+
from app.tools import domain_analysis as da
|
| 16 |
+
from app.routers import domains as domains_router
|
| 17 |
+
from app.routers.domains import ScanPrositeRequest, scan_prosite
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class FakeResp:
|
| 21 |
+
def __init__(self, status_code, json_data):
|
| 22 |
+
self.status_code = status_code
|
| 23 |
+
self._json = json_data
|
| 24 |
+
|
| 25 |
+
def json(self):
|
| 26 |
+
return self._json
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class FakeClient:
|
| 30 |
+
def __init__(self, *args, **kwargs):
|
| 31 |
+
self.calls = []
|
| 32 |
+
|
| 33 |
+
async def __aenter__(self):
|
| 34 |
+
return self
|
| 35 |
+
|
| 36 |
+
async def __aexit__(self, *args):
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
async def post(self, url, data=None, **kwargs):
|
| 40 |
+
self.calls.append((url, data))
|
| 41 |
+
return FakeResp(200, {
|
| 42 |
+
"n_match": 1,
|
| 43 |
+
"n_seq": 1,
|
| 44 |
+
"matchset": [
|
| 45 |
+
{"sequence_ac": "USERSEQ1", "start": 237, "stop": 249,
|
| 46 |
+
"signature_ac": "PS00348", "level_tag": "(0)"},
|
| 47 |
+
],
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class TestScanPrositeSequence:
|
| 52 |
+
@pytest.mark.asyncio
|
| 53 |
+
async def test_parses_json_contract(self, monkeypatch):
|
| 54 |
+
fake = FakeClient()
|
| 55 |
+
monkeypatch.setattr(da.httpx, "AsyncClient", lambda *a, **k: fake)
|
| 56 |
+
monkeypatch.setattr(da, "_prosite_signature_name",
|
| 57 |
+
lambda sig: _fake_name(sig))
|
| 58 |
+
|
| 59 |
+
seq = "M" * 250
|
| 60 |
+
result = await da.scan_prosite_sequence(seq)
|
| 61 |
+
assert result["sequence_length"] == 250
|
| 62 |
+
assert result["count"] == 1
|
| 63 |
+
m = result["matches"][0]
|
| 64 |
+
assert m["signature_ac"] == "PS00348"
|
| 65 |
+
assert m["start"] == 237 and m["stop"] == 249
|
| 66 |
+
assert m["name"] == "p53 family signature"
|
| 67 |
+
|
| 68 |
+
@pytest.mark.asyncio
|
| 69 |
+
async def test_empty_matchset(self, monkeypatch):
|
| 70 |
+
class FakeClientEmpty(FakeClient):
|
| 71 |
+
async def post(self, url, data=None, **kwargs):
|
| 72 |
+
self.calls.append((url, data))
|
| 73 |
+
return FakeResp(200, {"n_match": 0, "n_seq": 1})
|
| 74 |
+
|
| 75 |
+
monkeypatch.setattr(da.httpx, "AsyncClient", lambda *a, **k: FakeClientEmpty())
|
| 76 |
+
result = await da.scan_prosite_sequence("M" * 50)
|
| 77 |
+
assert result["count"] == 0
|
| 78 |
+
assert result["matches"] == []
|
| 79 |
+
|
| 80 |
+
@pytest.mark.asyncio
|
| 81 |
+
async def test_short_sequence_rejected(self):
|
| 82 |
+
result = await da.scan_prosite_sequence("MEEPQ")
|
| 83 |
+
assert "error" in result
|
| 84 |
+
assert "too short" in result["error"]
|
| 85 |
+
|
| 86 |
+
@pytest.mark.asyncio
|
| 87 |
+
async def test_http_error(self, monkeypatch):
|
| 88 |
+
class FakeClientFail(FakeClient):
|
| 89 |
+
async def post(self, url, data=None, **kwargs):
|
| 90 |
+
return FakeResp(500, {})
|
| 91 |
+
|
| 92 |
+
monkeypatch.setattr(da.httpx, "AsyncClient", lambda *a, **k: FakeClientFail())
|
| 93 |
+
result = await da.scan_prosite_sequence("M" * 50)
|
| 94 |
+
assert "error" in result
|
| 95 |
+
assert "HTTP 500" in result["error"]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class TestScanPrositeEndpoint:
|
| 99 |
+
@pytest.mark.asyncio
|
| 100 |
+
async def test_success(self, monkeypatch):
|
| 101 |
+
async def fake_scan(sequence, email=""):
|
| 102 |
+
assert email == "bioflow@example.com"
|
| 103 |
+
return {"sequence_length": 10, "count": 1, "matches": [
|
| 104 |
+
{"signature_ac": "PS00001", "name": "N-glycosylation site",
|
| 105 |
+
"start": 2, "stop": 5, "level_tag": "(0)"},
|
| 106 |
+
]}
|
| 107 |
+
|
| 108 |
+
monkeypatch.setattr(domains_router, "scan_prosite_sequence", fake_scan)
|
| 109 |
+
resp = await scan_prosite(ScanPrositeRequest(sequence="MEEPQSDPSV"))
|
| 110 |
+
assert resp.sequence_length == 10
|
| 111 |
+
assert resp.count == 1
|
| 112 |
+
assert resp.matches[0].name == "N-glycosylation site"
|
| 113 |
+
|
| 114 |
+
@pytest.mark.asyncio
|
| 115 |
+
async def test_short_sequence_400(self, monkeypatch):
|
| 116 |
+
async def fake_scan(sequence, email=""):
|
| 117 |
+
return {"error": "Sequence too short (min 10 amino acids)"}
|
| 118 |
+
|
| 119 |
+
monkeypatch.setattr(domains_router, "scan_prosite_sequence", fake_scan)
|
| 120 |
+
with pytest.raises(HTTPException) as exc:
|
| 121 |
+
await scan_prosite(ScanPrositeRequest(sequence="MEEPQ"))
|
| 122 |
+
assert exc.value.status_code == 400
|
| 123 |
+
assert "too short" in exc.value.detail
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
async def _fake_name(sig: str) -> str:
|
| 127 |
+
return "p53 family signature"
|
tests/test_uniprot_search.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for 3c UniProt search polish:
|
| 3 |
+
|
| 4 |
+
- reviewed/organism filters are folded into the UniProt query string
|
| 5 |
+
- search results carry a `reviewed` flag derived from entryType
|
| 6 |
+
|
| 7 |
+
Network calls are mocked; these run fully offline.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
from fastapi import HTTPException
|
| 12 |
+
|
| 13 |
+
from app.routers import uniprot as uniprot_router
|
| 14 |
+
from app.routers.uniprot import UniprotSearchRequest, search_uniprot
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class FakeResp:
|
| 18 |
+
def __init__(self, status_code, json_data):
|
| 19 |
+
self.status_code = status_code
|
| 20 |
+
self._json = json_data
|
| 21 |
+
|
| 22 |
+
def json(self):
|
| 23 |
+
return self._json
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class FakeClient:
|
| 27 |
+
def __init__(self, *args, **kwargs):
|
| 28 |
+
self.calls = []
|
| 29 |
+
|
| 30 |
+
async def __aenter__(self):
|
| 31 |
+
return self
|
| 32 |
+
|
| 33 |
+
async def __aexit__(self, *args):
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
async def get(self, url, params=None, **kwargs):
|
| 37 |
+
self.calls.append((url, params))
|
| 38 |
+
results = []
|
| 39 |
+
for i in range(params.get("size", 1)):
|
| 40 |
+
results.append({
|
| 41 |
+
"primaryAccession": f"P{i:05d}",
|
| 42 |
+
"proteinDescription": {"recommendedName": {"fullName": {"value": "Test protein"}}},
|
| 43 |
+
"genes": [{"geneName": {"value": "TP53"}}],
|
| 44 |
+
"organism": {"scientificName": "Homo sapiens"},
|
| 45 |
+
"sequence": {"length": 100},
|
| 46 |
+
"entryType": "UniProtKB reviewed (Swiss-Prot)",
|
| 47 |
+
})
|
| 48 |
+
return FakeResp(200, {"results": results})
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@pytest.mark.asyncio
|
| 52 |
+
async def test_reviewed_and_organism_fold_into_query(monkeypatch):
|
| 53 |
+
fake = FakeClient()
|
| 54 |
+
monkeypatch.setattr(uniprot_router.httpx, "AsyncClient", lambda *a, **k: fake)
|
| 55 |
+
req = UniprotSearchRequest(query="p53", max_results=1, reviewed=True, organism="Homo sapiens")
|
| 56 |
+
res = await search_uniprot(req)
|
| 57 |
+
assert res["count"] == 1
|
| 58 |
+
assert res["results"][0]["reviewed"] is True
|
| 59 |
+
_, params = fake.calls[0]
|
| 60 |
+
assert "AND organism_name:\"Homo sapiens\"" in params["query"]
|
| 61 |
+
assert "AND reviewed:true" in params["query"]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@pytest.mark.asyncio
|
| 65 |
+
async def test_no_filters_query_unchanged(monkeypatch):
|
| 66 |
+
fake = FakeClient()
|
| 67 |
+
monkeypatch.setattr(uniprot_router.httpx, "AsyncClient", lambda *a, **k: fake)
|
| 68 |
+
req = UniprotSearchRequest(query="BRCA1")
|
| 69 |
+
res = await search_uniprot(req)
|
| 70 |
+
assert res["results"][0]["reviewed"] is True
|
| 71 |
+
_, params = fake.calls[0]
|
| 72 |
+
assert params["query"] == "BRCA1"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@pytest.mark.asyncio
|
| 76 |
+
async def test_reviewed_flag_false_for_trembl(monkeypatch):
|
| 77 |
+
class FakeClientTrEmbl(FakeClient):
|
| 78 |
+
async def get(self, url, params=None, **kwargs):
|
| 79 |
+
self.calls.append((url, params))
|
| 80 |
+
return FakeResp(200, {"results": [{
|
| 81 |
+
"primaryAccession": "A0A1111111",
|
| 82 |
+
"proteinDescription": {},
|
| 83 |
+
"genes": [],
|
| 84 |
+
"organism": {"scientificName": "Unknown"},
|
| 85 |
+
"sequence": {"length": 50},
|
| 86 |
+
"entryType": "UniProtKB unreviewed (TrEMBL)",
|
| 87 |
+
}]})
|
| 88 |
+
|
| 89 |
+
fake = FakeClientTrEmbl()
|
| 90 |
+
monkeypatch.setattr(uniprot_router.httpx, "AsyncClient", lambda *a, **k: fake)
|
| 91 |
+
req = UniprotSearchRequest(query="hypothetical")
|
| 92 |
+
res = await search_uniprot(req)
|
| 93 |
+
assert res["results"][0]["reviewed"] is False
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@pytest.mark.asyncio
|
| 97 |
+
async def test_search_failure_raises_502(monkeypatch):
|
| 98 |
+
class FakeClientFail(FakeClient):
|
| 99 |
+
async def get(self, url, params=None, **kwargs):
|
| 100 |
+
self.calls.append((url, params))
|
| 101 |
+
return FakeResp(500, {})
|
| 102 |
+
|
| 103 |
+
fake = FakeClientFail()
|
| 104 |
+
monkeypatch.setattr(uniprot_router.httpx, "AsyncClient", lambda *a, **k: fake)
|
| 105 |
+
with pytest.raises(HTTPException) as exc:
|
| 106 |
+
await search_uniprot(UniprotSearchRequest(query="p53"))
|
| 107 |
+
assert exc.value.status_code == 502
|