Source code for tooluniverse.pdbe_ligands_tool
# pdbe_ligands_tool.py
"""
PDBe Ligands and Residues tool for ToolUniverse.
The PDBe REST API provides information about ligands bound to PDB structures
and detailed per-residue information for protein chains. These complement the
existing PDBe compound tools (which look up compounds by ID) by providing
structure-centric queries.
API: https://www.ebi.ac.uk/pdbe/api/pdb/entry/
No authentication required.
"""
import requests
from typing import Dict, Any
from .base_tool import BaseTool
from .tool_registry import register_tool
PDBE_API_BASE_URL = "https://www.ebi.ac.uk/pdbe/api/pdb/entry"
[docs]
@register_tool("PDBeLigandsTool")
class PDBeLigandsTool(BaseTool):
"""
Tool for querying PDBe structure-bound ligands and residue details.
Supports:
- Get all ligands bound in a PDB structure (drug-like, cofactors, ions)
- Get per-residue listing with observed ratio for a PDB chain
No authentication required.
"""
[docs]
def __init__(self, tool_config: Dict[str, Any]):
super().__init__(tool_config)
self.timeout = tool_config.get("timeout", 30)
fields = tool_config.get("fields", {})
self.endpoint = fields.get("endpoint", "ligand_monomers")
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Execute the PDBe API call."""
try:
return self._query(arguments)
except requests.exceptions.Timeout:
return {
"status": "error",
"error": f"PDBe API timed out after {self.timeout}s",
}
except requests.exceptions.ConnectionError:
return {"status": "error", "error": "Failed to connect to PDBe API"}
except requests.exceptions.HTTPError as e:
status = e.response.status_code if e.response is not None else "unknown"
if status == 404:
pdb_id = arguments.get("pdb_id", "unknown")
return {
"status": "error",
"error": f"PDB entry '{pdb_id}' not found. Provide a valid 4-character PDB ID (e.g., '4hhb', '3ert').",
}
return {"status": "error", "error": f"PDBe API HTTP {status}"}
except Exception as e:
return {"status": "error", "error": f"Unexpected error: {str(e)}"}
[docs]
def _entry_exists(self, pdb_id: str) -> bool:
"""Check whether a PDB entry exists via the (near-universally populated) summary endpoint."""
try:
resp = requests.get(
f"{PDBE_API_BASE_URL}/summary/{pdb_id}", timeout=self.timeout
)
return resp.status_code == 200
except requests.exceptions.RequestException:
return False
[docs]
def _query(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Route to appropriate endpoint."""
if self.endpoint == "ligand_monomers":
return self._get_ligand_monomers(arguments)
elif self.endpoint == "residue_listing":
return self._get_residue_listing(arguments)
else:
return {"status": "error", "error": f"Unknown endpoint: {self.endpoint}"}
[docs]
def _get_ligand_monomers(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Get all ligand monomers bound in a PDB structure."""
pdb_id = arguments.get("pdb_id", "")
if not pdb_id:
return {
"status": "error",
"error": "pdb_id is required (4-character PDB ID, e.g., '4hhb', '3ert', '1m17').",
}
pdb_id = pdb_id.lower().strip()
url = f"{PDBE_API_BASE_URL}/ligand_monomers/{pdb_id}"
response = requests.get(url, timeout=self.timeout)
if response.status_code == 404:
# Fix-R2A-003: this endpoint 404s whenever an entry has no
# non-polymer ligand data — including well-known entries whose
# only "ligand" (e.g. a peptidomimetic inhibitor) is modeled as a
# polymer chain, not just when the entry itself doesn't exist.
# Disambiguate via the summary endpoint instead of reporting a
# real, well-known entry as "not found".
if self._entry_exists(pdb_id):
return {
"status": "success",
"data": {"pdb_id": pdb_id, "ligands": [], "total_ligands": 0},
"metadata": {"source": "PDBe REST API (ebi.ac.uk/pdbe)"},
"note": (
f"PDB entry '{pdb_id}' exists but has no non-polymer "
"ligand data. This commonly means the entry's "
"inhibitor/ligand is modeled as a polymer or peptide "
"chain rather than a discrete non-polymer ligand; "
"check the entry's polymer entities instead."
),
}
return {
"status": "error",
"error": f"PDB entry '{pdb_id}' not found. Provide a valid 4-character PDB ID (e.g., '4hhb', '3ert').",
}
response.raise_for_status()
data = response.json()
if pdb_id not in data:
return {"status": "error", "error": f"No ligand data for PDB '{pdb_id}'."}
ligands_raw = data[pdb_id]
ligands = []
for lig in ligands_raw[:50]:
annotations = lig.get("annotations", [])
annotation_info = []
for ann in annotations[:5]:
interacting = ann.get("interacting_entity", {})
annotation_info.append(
{
"type": ann.get("type"),
"interacting_entity_id": interacting.get("entity_id"),
"interacting_chain": interacting.get("auth_asym_id"),
"interacting_uniprot": interacting.get("best_unp_accession"),
}
)
ligands.append(
{
"chem_comp_id": lig.get("chem_comp_id"),
"chem_comp_name": lig.get("chem_comp_name"),
"weight": lig.get("weight"),
"chain_id": lig.get("chain_id"),
"entity_id": lig.get("entity_id"),
"author_residue_number": lig.get("author_residue_number"),
"carbohydrate_polymer": lig.get("carbohydrate_polymer", False),
"annotations": annotation_info,
}
)
return {
"status": "success",
"data": {
"pdb_id": pdb_id,
"ligands": ligands,
"total_ligands": len(ligands_raw),
},
"metadata": {
"source": "PDBe REST API (ebi.ac.uk/pdbe)",
},
}
[docs]
def _get_residue_listing(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Get per-residue listing for a PDB structure chain."""
pdb_id = arguments.get("pdb_id", "")
if not pdb_id:
return {
"status": "error",
"error": "pdb_id is required (4-character PDB ID, e.g., '4hhb', '3ert').",
}
chain_id = arguments.get("chain_id", None)
pdb_id = pdb_id.lower().strip()
if chain_id:
url = f"{PDBE_API_BASE_URL}/residue_listing/{pdb_id}/chain/{chain_id}"
else:
url = f"{PDBE_API_BASE_URL}/residue_listing/{pdb_id}"
response = requests.get(url, timeout=self.timeout)
if response.status_code == 404:
# Fix-R2A-003: see _get_ligand_monomers — a 404 here can mean "no
# data for this specific chain/entry at this endpoint" rather than
# "entry does not exist". Disambiguate via the summary endpoint.
if self._entry_exists(pdb_id):
return {
"status": "success",
"data": {"pdb_id": pdb_id, "molecules": []},
"metadata": {"source": "PDBe REST API (ebi.ac.uk/pdbe)"},
"note": (
f"PDB entry '{pdb_id}' exists but has no residue data "
f"at this endpoint{f' for chain {chain_id}' if chain_id else ''}."
),
}
return {
"status": "error",
"error": f"PDB entry '{pdb_id}' not found. Provide a valid 4-character PDB ID (e.g., '4hhb', '3ert').",
}
response.raise_for_status()
data = response.json()
if pdb_id not in data:
return {"status": "error", "error": f"No residue data for PDB '{pdb_id}'."}
entry_data = data[pdb_id]
molecules = entry_data.get("molecules", [])
result_molecules = []
for mol in molecules[:10]:
chains = mol.get("chains", [])
chain_results = []
for chain in chains[:5]:
residues = chain.get("residues", [])
# Summarize residues - show first/last and count
residue_summary = []
for res in residues[:30]:
residue_summary.append(
{
"residue_number": res.get("residue_number"),
"residue_name": res.get("residue_name"),
"author_residue_number": res.get("author_residue_number"),
"observed_ratio": res.get("observed_ratio"),
}
)
chain_results.append(
{
"chain_id": chain.get("chain_id"),
"struct_asym_id": chain.get("struct_asym_id"),
"total_residues": len(residues),
"residues": residue_summary,
}
)
result_molecules.append(
{
"entity_id": mol.get("entity_id"),
"chains": chain_results,
}
)
return {
"status": "success",
"data": {
"pdb_id": pdb_id,
"molecules": result_molecules,
"total_molecules": len(molecules),
},
"metadata": {
"source": "PDBe REST API (ebi.ac.uk/pdbe)",
},
}