Source code for tooluniverse.lincs_tool

"""
LINCS SigCom (Signature Commons) API tool for ToolUniverse.

LINCS (Library of Integrated Network-Based Cellular Signatures) provides
drug perturbation gene expression signatures from the L1000 assay and other
high-throughput profiling technologies. SigCom LINCS aggregates 1.5M+
signatures across 431+ libraries.

Metadata API: https://maayanlab.cloud/sigcom-lincs/metadata-api/
No authentication required.
"""

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

LINCS_BASE = "https://maayanlab.cloud/sigcom-lincs/metadata-api"


def _signature_file(meta: dict) -> dict:
    """Where the signature's data actually is, and how to verify it.

    The search returned a signature's annotations -- drug, cell line, dose,
    time -- and nothing that leads to the signature itself. SigCom LINCS
    answers a resolvable `persistent_id` for every signature
    ("https://lincs-dcic.s3.amazonaws.com/LINCS-sigs-2021/cd/cp/L1000_LINCS_
    DCIC_CPC006_TYKNU_6H_F09_dexamethasone_10uM.tsv"), the file's name, its
    size, and both checksums, so a caller can fetch the differential
    expression and check that it arrived intact. A search that cannot reach
    the data it describes is a search for metadata about a file the caller
    cannot find.
    """
    return {
        "url": meta.get("persistent_id"),
        "filename": meta.get("filename"),
        "size_in_bytes": meta.get("size_in_bytes"),
        "uncompressed_size_in_bytes": meta.get("uncompressed_size_in_bytes"),
        "sha256": meta.get("sha256"),
        "md5": meta.get("md5"),
    }


[docs] @register_tool("LINCSSignatureTool") class LINCSSignatureTool(BaseTool): """ Tool for querying LINCS SigCom drug perturbation signatures. Supports: search_signatures (find signatures by drug/perturbagen), list_libraries (browse available signature libraries). No authentication required. """
[docs] def __init__(self, tool_config: Dict[str, Any]): super().__init__(tool_config) self.timeout = tool_config.get("timeout", 30) self.endpoint_type = tool_config.get("fields", {}).get( "endpoint_type", "search_signatures" )
[docs] def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Execute the LINCS API call.""" try: return self._query(arguments) except requests.exceptions.Timeout: return { "status": "error", "error": f"LINCS API request timed out after {self.timeout}s", } except requests.exceptions.ConnectionError: return { "status": "error", "error": "Failed to connect to LINCS SigCom API.", } except Exception as e: return { "status": "error", "error": f"Unexpected error querying LINCS: {str(e)}", }
[docs] def _query(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Route to the appropriate LINCS endpoint.""" if self.endpoint_type == "search_signatures": return self._search_signatures(arguments) elif self.endpoint_type == "list_libraries": return self._list_libraries(arguments) return { "status": "error", "error": f"Unknown endpoint type: {self.endpoint_type}", }
[docs] def _search_signatures(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Search LINCS signatures by drug/perturbagen name.""" drug_name = arguments.get("drug_name", "") if not drug_name: return {"status": "error", "error": "drug_name parameter is required"} limit = arguments.get("limit", 20) offset = arguments.get("offset", 0) if limit is None: limit = 20 if offset is None: offset = 0 if ( isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 1000 ): return { "status": "error", "error": "limit must be an integer between 1 and 1000", } if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0: return { "status": "error", "error": "offset must be a non-negative integer", } cell_line = arguments.get("cell_line") # Build filter where = {"meta.pert_name": drug_name} if cell_line: where["meta.cell_line"] = cell_line # SigCom has no count endpoint. Fetch one lookahead record so has_more # is reliable without pretending that the current page length is total. payload = {"filter": {"where": where, "limit": limit + 1, "skip": offset}} resp = requests.post( f"{LINCS_BASE}/signatures/find", json=payload, timeout=self.timeout, ) resp.raise_for_status() signatures = resp.json() has_more = len(signatures) > limit signatures = signatures[:limit] results = [] for sig in signatures: meta = sig.get("meta", {}) results.append( { "signature_id": sig.get("id"), "drug_name": meta.get("pert_name"), "cell_line": meta.get("cell_line"), "dose": meta.get("pert_dose"), "time_point": meta.get("pert_time"), "perturbation_type": meta.get("pert_type"), "tissue": meta.get("tissue"), "disease": meta.get("disease"), "anatomy": meta.get("anatomy"), "pubchem_id": meta.get("pubchem_id"), "library_id": sig.get("library"), # The CMap identifier, which joins this signature to the # rest of the LINCS ecosystem, and SigCom's own local id. "cmap_id": meta.get("cmap_id"), "local_id": meta.get("local_id"), "disease_ontology_id": meta.get("doid"), "data_level": meta.get("data_level"), "version": meta.get("version"), "created": meta.get("creation_time"), # How to fetch the signature and verify what arrives. "data_file": _signature_file(meta), } ) return { "status": "success", "data": results, "metadata": { "total_results": ( offset + len(results) if not has_more and (offset == 0 or bool(results)) else None ), "total_is_exact": not has_more and (offset == 0 or bool(results)), "matches_lower_bound": offset + len(results) + (1 if has_more else 0), "returned": len(results), "limit": limit, "offset": offset, "has_more": has_more, "truncated": has_more, "next_offset": offset + len(results) if has_more else None, "query_drug": drug_name, "query_cell_line": cell_line, "source": "LINCS SigCom", }, }
[docs] def _list_libraries(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """List available LINCS signature libraries.""" resp = requests.get( f"{LINCS_BASE}/libraries", timeout=self.timeout, ) resp.raise_for_status() libraries = resp.json() keyword = arguments.get("keyword", "").lower() limit = arguments.get("limit", 50) offset = arguments.get("offset", 0) if limit is None: limit = 50 if offset is None: offset = 0 if ( isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 1000 ): return { "status": "error", "error": "limit must be an integer between 1 and 1000", } if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0: return { "status": "error", "error": "offset must be a non-negative integer", } matching_libraries = [] for lib in libraries: meta = lib.get("meta", {}) entry = { "library_id": lib.get("id"), "dataset": lib.get("dataset"), "dataset_type": lib.get("dataset_type"), "assay": meta.get("assay"), "organism": meta.get("organism"), "description": meta.get("description") or meta.get("summary"), } if keyword: searchable = " ".join(str(v) for v in entry.values() if v).lower() if keyword not in searchable: continue matching_libraries.append(entry) results = matching_libraries[offset : offset + limit] has_more = offset + len(results) < len(matching_libraries) return { "status": "success", "data": results, "metadata": { "total_libraries": len(libraries), "total_matches": len(matching_libraries), "returned": len(results), "limit": limit, "offset": offset, "has_more": has_more, "truncated": has_more, "next_offset": offset + len(results) if has_more else None, "keyword_filter": keyword or None, "source": "LINCS SigCom", }, }