Source code for tooluniverse.interpro_domain_arch_tool

# interpro_domain_arch_tool.py
"""
InterPro Domain Architecture tool for ToolUniverse.

Provides access to InterPro API endpoints for domain architecture analysis:
- Protein domain architecture with exact residue positions
- PDB structures containing a specific Pfam domain
- Pfam clan (superfamily) member families

API: https://www.ebi.ac.uk/interpro/api/
No authentication required. Free public access.
"""

import requests
from typing import Dict, Any
from .base_tool import BaseTool


INTERPRO_BASE_URL = "https://www.ebi.ac.uk/interpro/api"


def _json_or_none(response):
    """Parse a JSON response body, returning None when there is no body.

    The InterPro API reports an empty result set as HTTP 204 No Content with a
    zero-length body instead of a 200 carrying ``{"count": 0, "results": []}``.
    ``raise_for_status()`` treats 204 as success, so calling ``.json()`` on it
    raises a JSONDecodeError that used to escape as an opaque
    "Unexpected error querying InterPro API" message -- callers could not tell
    "nothing matched" from "the tool is broken".

    Callers translate a ``None`` return into their own empty-result shape (for
    list endpoints) or a not-found error (for single-record endpoints).
    """
    if response.status_code == 204:
        return None
    body = response.content
    if not body or not body.strip():
        return None
    return response.json()


[docs] class InterProDomainArchTool(BaseTool): """ Tool for InterPro domain architecture analysis via the InterPro API. No authentication required. """
[docs] def __init__(self, tool_config: Dict[str, Any]): super().__init__(tool_config) self.timeout = tool_config.get("timeout", 60) fields = tool_config.get("fields", {}) self.endpoint = fields.get("endpoint", "protein_domain_architecture")
[docs] def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Execute the InterPro API call.""" try: return self._query(arguments) except requests.exceptions.Timeout: return { "status": "error", "error": f"InterPro API timed out after {self.timeout}s", } except requests.exceptions.ConnectionError: return {"status": "error", "error": "Failed to connect to InterPro API"} except requests.exceptions.HTTPError as e: code = e.response.status_code if e.response is not None else "unknown" if code == 404: param = arguments.get( "accession", arguments.get( "pfam_accession", arguments.get("clan_accession", "") ), ) return {"status": "error", "error": f"Not found in InterPro: {param}"} # NB: 204 never reaches here -- raise_for_status() only raises for # 4xx/5xx. It is handled as an empty result set by _json_or_none(). return {"status": "error", "error": f"InterPro API HTTP error: {code}"} except Exception as e: return { "status": "error", "error": f"Unexpected error querying InterPro API: {str(e)}", }
[docs] def _query(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Route to appropriate endpoint.""" if self.endpoint == "protein_domain_architecture": return self._get_protein_domain_architecture(arguments) elif self.endpoint == "structures_for_domain": return self._get_structures_for_domain(arguments) elif self.endpoint == "clan_members": return self._get_clan_members(arguments) else: return {"status": "error", "error": f"Unknown endpoint: {self.endpoint}"}
[docs] def _get_protein_domain_architecture( self, arguments: Dict[str, Any] ) -> Dict[str, Any]: """Get Pfam domain architecture for a protein with exact positions.""" accession = arguments.get("accession", "") if not accession: return { "status": "error", "error": "accession parameter is required (UniProt accession, e.g., 'P04637')", } url = f"{INTERPRO_BASE_URL}/entry/pfam/protein/uniprot/{accession}" params = {"page_size": 50, "format": "json"} response = requests.get(url, params=params, timeout=self.timeout) response.raise_for_status() # HTTP 204 / empty body == "this protein has no Pfam domains". data = _json_or_none(response) or {} results = data.get("results", []) domains = [] protein_length = None for r in results: metadata = r.get("metadata", {}) name_info = metadata.get("name", {}) name = ( name_info.get("name", "") if isinstance(name_info, dict) else str(name_info) ) proteins = r.get("proteins", []) for p in proteins: if protein_length is None: protein_length = p.get("protein_length") for loc in p.get("entry_protein_locations", []): for frag in loc.get("fragments", []): domains.append( { "pfam_accession": metadata.get("accession", ""), "name": name, "type": metadata.get("type", ""), "integrated_interpro": metadata.get("integrated"), "start": frag.get("start"), "end": frag.get("end"), "score": loc.get("score"), } ) # Sort domains by start position domains.sort(key=lambda d: d.get("start", 0)) return { "status": "success", "data": { "accession": accession, "protein_length": protein_length, "domain_count": len(domains), "domains": domains, }, "metadata": { "source": "InterPro API (Pfam domain architecture)", "accession": accession, }, }
[docs] def _get_structures_for_domain(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Find PDB structures containing a specific Pfam domain.""" pfam_acc = arguments.get("pfam_accession", "") if not pfam_acc: return { "status": "error", "error": "pfam_accession parameter is required (e.g., 'PF00870')", } max_results = min(arguments.get("max_results", 20), 200) url = f"{INTERPRO_BASE_URL}/structure/pdb/entry/pfam/{pfam_acc}" params = {"page_size": max_results, "format": "json"} response = requests.get(url, params=params, timeout=self.timeout) response.raise_for_status() # HTTP 204 / empty body == "no PDB structure contains this domain". data = _json_or_none(response) or {} total = data.get("count", 0) results = data.get("results", []) structures = [] for r in results: m = r.get("metadata", {}) structures.append( { "pdb_id": m.get("accession", ""), "name": m.get("name"), "experiment_type": m.get("experiment_type"), "resolution": m.get("resolution"), } ) return { "status": "success", "data": { "pfam_accession": pfam_acc, "total_structures": total, "returned": len(structures), "structures": structures, }, "metadata": { "source": "InterPro API (structures for domain)", "pfam_accession": pfam_acc, }, }
[docs] def _get_clan_members(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Get member families in a Pfam clan (superfamily).""" clan_acc = arguments.get("clan_accession", "") if not clan_acc: return { "status": "error", "error": "clan_accession parameter is required (e.g., 'CL0016')", } max_results = min(arguments.get("max_results", 50), 200) # First get clan metadata url = f"{INTERPRO_BASE_URL}/set/pfam/{clan_acc}" params = {"format": "json"} response = requests.get(url, params=params, timeout=self.timeout) response.raise_for_status() # A single-record endpoint answers an unknown clan with HTTP 204, not # 404. Report it as not-found rather than a success full of blanks. clan_data = _json_or_none(response) if clan_data is None: return {"status": "error", "error": f"Not found in InterPro: {clan_acc}"} metadata = clan_data.get("metadata", {}) name_info = metadata.get("name", {}) clan_name = ( name_info.get("name", "") if isinstance(name_info, dict) else str(name_info) ) # Get member families from relationships relationships = metadata.get("relationships", {}) nodes = relationships.get("nodes", []) members = [] for node in nodes[:max_results]: members.append( { "accession": node.get("accession", ""), "short_name": node.get("short_name"), "name": node.get("name"), "type": node.get("type"), "score": node.get("score"), } ) # Sort by accession members.sort(key=lambda m: m.get("accession", "")) return { "status": "success", "data": { "clan_accession": clan_acc, "clan_name": clan_name, "description": metadata.get("description"), "member_count": len(members), "members": members, }, "metadata": { "source": "InterPro API (Pfam clan members)", "clan_accession": clan_acc, }, }