Samad14's picture
Fix pipeline MSA/AI stall, add SwissADME-parity ADMET panel, PubChem search, docking ligand data
5bb077e
Raw History Blame Contribute Delete
2.96 kB
"""PubChem PUG REST + autocomplete helpers for compound lookup."""
from __future__ import annotations
import asyncio
import logging
from urllib.parse import quote
import httpx
logger = logging.getLogger(__name__)
PUG_BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound"
AUTOCOMPLETE_BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/autocomplete/compound"
_REQUEST_TIMEOUT = 12.0
class PubChemError(Exception):
pass
async def _get_json(url: str) -> dict:
try:
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT, follow_redirects=True) as client:
resp = await client.get(url)
resp.raise_for_status()
return resp.json()
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise PubChemError("Compound not found in PubChem")
raise PubChemError(f"PubChem HTTP {e.response.status_code}")
except (httpx.RequestError, ValueError) as e:
raise PubChemError(f"PubChem request failed: {e}")
async def name_to_cid(name: str) -> int | None:
"""Resolve a chemical name to a PubChem CID (first hit)."""
url = f"{PUG_BASE}/name/{quote(name)}/cids/JSON"
try:
data = await _get_json(url)
except PubChemError:
return None
ids = data.get("IdentifierList", {}).get("CID", [])
return int(ids[0]) if ids else None
async def cid_to_record(cid: int) -> dict:
"""Fetch canonical SMILES + name metadata for a CID."""
url = (
f"{PUG_BASE}/cid/{int(cid)}/property/"
f"IsomericSMILES,CanonicalSMILES,IUPACName,MolecularFormula,InChIKey/JSON"
)
data = await _get_json(url)
props = data.get("PropertyTable", {}).get("Properties", [{}])[0]
return {
"cid": int(props.get("CID", cid)),
"smiles": props.get("SMILES") or props.get("IsomericSMILES") or props.get("CanonicalSMILES"),
"name": props.get("IUPACName"),
"formula": props.get("MolecularFormula"),
"inchikey": props.get("InChIKey"),
}
async def search_suggestions(query: str, limit: int = 10) -> list[dict]:
"""Return PubChem autocomplete suggestions for a compound search box.
The autocomplete endpoint returns names only, so CIDs are resolved in
parallel. Each entry: {cid, name}.
"""
if not query or not query.strip():
return []
url = f"{AUTOCOMPLETE_BASE}/{quote(query.strip())}/JSON?limit={int(limit)}"
try:
data = await _get_json(url)
except PubChemError:
return []
names = data.get("dictionary_terms", {}).get("compound", [])[:limit]
if not names:
return []
sem = asyncio.Semaphore(6)
async def _resolve(name: str) -> dict | None:
async with sem:
cid = await name_to_cid(name)
if not cid:
return None
return {"cid": int(cid), "name": name}
resolved = await asyncio.gather(*(_resolve(n) for n in names))
return [r for r in resolved if r]