← Files Life Sciences DatabasesARCHIVED FILE

skills/bgee-skill/scripts/sparql_request.py

8.25 KB · Sep 30, 2026 · 23:00 UTC

↓ Download file

#!/usr/bin/env python3
"""Compact Bgee SPARQL client for ChatGPT-imported skills."""

from __future__ import annotations

import json
import sys
from pathlib import Path
from typing import Any

sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts"))
from database_source_contract import apply_source_contract  # noqa: E402

try:
    import requests
except ImportError as exc:  # pragma: no cover
    requests = None
    REQUESTS_IMPORT_ERROR = exc
else:
    REQUESTS_IMPORT_ERROR = None

ENDPOINT = "https://www.bgee.org/sparql/"


def error(code: str, message: str, warnings: list[str] | None = None) -> dict[str, Any]:
    return {
        "ok": False,
        "error": {"code": code, "message": message},
        "warnings": warnings or [],
    }


def _compact(value: Any, max_items: int, max_depth: int) -> Any:
    if isinstance(value, str):
        return value if len(value) <= 240 else value[:240] + "..."
    if max_depth <= 0:
        if isinstance(value, (dict, list)):
            return "..."
        return value
    if isinstance(value, list):
        out = [_compact(item, max_items, max_depth - 1) for item in value[:max_items]]
        if len(value) > max_items:
            out.append(f"... (+{len(value) - max_items} more)")
        return out
    if isinstance(value, dict):
        out: dict[str, Any] = {}
        items = list(value.items())
        for key, item in items[:max_items]:
            out[str(key)] = _compact(item, max_items, max_depth - 1)
        if len(items) > max_items:
            out["_truncated_keys"] = len(items) - max_items
        return out
    return value


def parse_input(payload: Any) -> dict[str, Any]:
    if not isinstance(payload, dict):
        raise ValueError("Input must be one JSON object.")
    query = payload.get("query")
    query_path = payload.get("query_path")
    if query is None and query_path is None:
        raise ValueError("Provide `query` or `query_path`.")
    if query is not None and (not isinstance(query, str) or not query.strip()):
        raise ValueError("`query` must be a non-empty string.")
    if query_path is not None and (not isinstance(query_path, str) or not query_path.strip()):
        raise ValueError("`query_path` must be a non-empty string.")
    method = str(payload.get("method", "GET")).upper()
    if method not in {"GET", "POST"}:
        raise ValueError("`method` must be GET or POST.")
    params = payload.get("params") or {}
    if not isinstance(params, dict):
        raise ValueError("`params` must be an object.")
    response_format = str(payload.get("response_format", "auto")).lower()
    if response_format not in {"auto", "json", "text"}:
        raise ValueError("`response_format` must be auto, json, or text.")
    save_raw = payload.get("save_raw", False)
    if not isinstance(save_raw, bool):
        raise ValueError("`save_raw` must be a boolean.")
    raw_output_path = payload.get("raw_output_path")
    if raw_output_path is not None and (
        not isinstance(raw_output_path, str) or not raw_output_path.strip()
    ):
        raise ValueError("`raw_output_path` must be a non-empty string.")
    for key in ("max_items", "max_depth", "timeout_sec"):
        default = 5 if key == "max_items" else 3 if key == "max_depth" else 60
        value = payload.get(key, default)
        if not isinstance(value, int) or value <= 0:
            raise ValueError(f"`{key}` must be a positive integer.")
        payload[key] = value
    query_text = (
        query.strip() if isinstance(query, str) else Path(query_path).read_text(encoding="utf-8")
    )
    return {
        "query": query_text,
        "method": method,
        "params": params,
        "response_format": response_format,
        "max_items": payload["max_items"],
        "max_depth": payload["max_depth"],
        "timeout_sec": payload["timeout_sec"],
        "save_raw": save_raw,
        "raw_output_path": (raw_output_path.strip() if isinstance(raw_output_path, str) else None),
    }


def execute(payload: Any) -> dict[str, Any]:
    if requests is None:
        return error("missing_dependency", f"`requests` is required: {REQUESTS_IMPORT_ERROR}")
    config = parse_input(payload)
    headers = {
        "Accept": "application/sparql-results+json, application/json;q=0.9, text/tab-separated-values;q=0.8, text/plain;q=0.5"
    }
    try:
        if config["method"] == "GET":
            params = dict(config["params"])
            params.setdefault("query", config["query"])
            params.setdefault("format", "json")
            response = requests.get(
                ENDPOINT, params=params, headers=headers, timeout=config["timeout_sec"]
            )
        else:
            response = requests.post(
                ENDPOINT,
                data={"query": config["query"], **config["params"]},
                headers=headers,
                timeout=config["timeout_sec"],
            )
        response.raise_for_status()
    except requests.RequestException as exc:
        return error("network_error", f"Bgee request failed ({type(exc).__name__}).")

    raw_output_path = None
    if config["save_raw"]:
        suffix = "json" if "json" in (response.headers.get("content-type") or "").lower() else "txt"
        path = Path(config["raw_output_path"] or f"/tmp/bgee-sparql.{suffix}")
        path.parent.mkdir(parents=True, exist_ok=True)
        raw_content = getattr(response, "content", None)
        raw_bytes = (
            bytes(raw_content)
            if isinstance(raw_content, (bytes, bytearray, memoryview))
            else response.text.encode("utf-8")
        )
        path.write_bytes(raw_bytes)
        raw_output_path = str(path)

    content_type = (response.headers.get("content-type") or "").lower()
    wants_text = config["response_format"] == "text"
    auto_json = not wants_text and (
        "json" in content_type or response.text.lstrip().startswith("{")
    )
    if not auto_json and config["response_format"] != "json":
        text_head = None if raw_output_path else response.text[:800]
        return apply_source_contract(
            {
                "ok": True,
                "source": "bgee-sparql",
                "content_type": content_type,
                "text_head": text_head,
                "text_head_truncated": (False if raw_output_path else len(response.text) > 800),
                "raw_output_path": raw_output_path,
                "warnings": [],
            },
            "bgee-skill",
            ENDPOINT,
        )

    try:
        data = response.json()
    except ValueError as exc:
        return error("invalid_response", str(exc))

    if "boolean" in data:
        summary: Any = {"boolean": data["boolean"]}
        top_keys = ["boolean"]
    else:
        results = data.get("results", {})
        bindings = results.get("bindings", []) if isinstance(results, dict) else []
        summary = {
            "head": data.get("head", {}),
            "record_count_returned": min(len(bindings), config["max_items"]),
            "record_count_available": len(bindings),
            "truncated": len(bindings) > config["max_items"],
            "records": _compact(
                bindings[: config["max_items"]],
                config["max_items"],
                config["max_depth"],
            ),
        }
        top_keys = list(summary)[: config["max_items"]]

    return apply_source_contract(
        {
            "ok": True,
            "source": "bgee-sparql",
            "top_keys": top_keys,
            "summary": _compact(summary, config["max_items"], config["max_depth"]),
            "raw_output_path": raw_output_path,
            "warnings": [],
        },
        "bgee-skill",
        ENDPOINT,
        mode=(
            "empty"
            if isinstance(summary, dict) and summary.get("record_count_returned") == 0
            else None
        ),
    )


def main() -> int:
    try:
        payload = json.load(sys.stdin)
    except Exception as exc:  # noqa: BLE001
        sys.stdout.write(json.dumps(error("invalid_json", f"Could not parse JSON input: {exc}")))
        return 2
    try:
        output = execute(payload)
    except ValueError as exc:
        output = error("invalid_input", str(exc))
        code = 2
    else:
        code = 0 if output.get("ok") else 1
    sys.stdout.write(json.dumps(output))
    return code


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 507ce8cb1886942dab6933a7544f23f1388e115ff3f960a1dca309e124f5a59e