diff --git a/docs/API.md b/docs/API.md index 0a83e80..22a9e23 100644 --- a/docs/API.md +++ b/docs/API.md @@ -152,8 +152,25 @@ asyncio.run(main()) | `paper` | `biocontext paper ` | Fetch detailed paper metadata and abstract by PMID, PMCID, or DOI. | | `graph` | `biocontext graph [--taxon INT]` | Construct connected biological Knowledge Subgraph (PRD-21). | | `traverse` | `biocontext traverse [--max-hops INT]` | Find and explain deterministic multi-hop reasoning paths (PRD-22). | +| `api` | `biocontext api [--host HOST] [--port PORT]` | Start standalone BioContext REST API v1 server (PRD-18). | | `mouse` | `biocontext mouse ` | Lookup mouse gene model via MGI. | | `cache` | `biocontext cache [stats|clear]` | Inspect or clear SQLite cache. | | `serve` | `biocontext serve` | Start stdio MCP server for AI clients. | +--- + +## 5. REST API v1 Endpoints Reference + +Start the server using: `biocontext api --port 8000` + +| Method | Endpoint | Description | Example | +| :--- | :--- | :--- | :--- | +| `GET` | `/health` | Service health status and version info | `curl http://127.0.0.1:8000/health` | +| `GET` | `/v1/gene/{symbol}` | Resolve gene symbol to canonical entity | `curl http://127.0.0.1:8000/v1/gene/TP53` | +| `GET` | `/v1/protein/{accession}` | Fetch UniProt protein model | `curl http://127.0.0.1:8000/v1/protein/P04637` | +| `GET` | `/v1/disease/{query}` | Search MONDO disease ontology | `curl http://127.0.0.1:8000/v1/disease/cancer` | +| `GET` | `/v1/graph/{symbol}` | Construct interconnected Knowledge Subgraph | `curl http://127.0.0.1:8000/v1/graph/TP53` | +| `GET` | `/v1/traverse?source=X&target=Y` | Deterministic multi-hop path reasoning | `curl "http://127.0.0.1:8000/v1/traverse?source=TP53&target=cancer"` | + + diff --git a/pyproject.toml b/pyproject.toml index e298680..a7cad07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "biocontext-mcp" -version = "0.9.0" +version = "1.0.0" description = "Authoritative Biological Entity Resolution & Contextual Intelligence Framework" readme = "README.md" authors = [ @@ -18,7 +18,7 @@ keywords = [ "uniprot", ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering :: Bio-Informatics", "License :: OSI Approved :: Apache Software License", diff --git a/src/biocontext/__init__.py b/src/biocontext/__init__.py index 7812b2d..7aad049 100644 --- a/src/biocontext/__init__.py +++ b/src/biocontext/__init__.py @@ -1,2 +1,170 @@ -def main() -> None: - print("Hello from biocontext!") +"""BioContext top-level SDK client and core exports (PRD-18).""" + +from typing import List, Optional + +from biocontext.graph.builder import KnowledgeGraphBuilder +from biocontext.graph.models import SubGraphContext, TraversalResult +from biocontext.graph.traversal import TraversalEngine +from biocontext.resolver import EntityResolver +from biocontext.schemas import ( + BatchResolutionSummary, + DiseaseEntity, + FunctionalAnnotation, + GeneEntity, + LiteratureContext, + PathwayContext, + ProteinEntity, + PublicationEntity, + ResolutionContext, + ResolutionResult, + TargetAssociationContext, +) + + +class BioContext: + """High-level unified Python SDK client for biological entity intelligence and graph reasoning.""" + + def __init__( + self, + ncbi_api_key: Optional[str] = None, + email: Optional[str] = None, + tool: Optional[str] = None, + ): + self.resolver = EntityResolver( + ncbi_api_key=ncbi_api_key, + email=email, + tool=tool, + ) + self.graph_builder = KnowledgeGraphBuilder(resolver=self.resolver) + self.traversal_engine = TraversalEngine(resolver=self.resolver, builder=self.graph_builder) + + async def resolve_gene( + self, + query: str, + taxon_id: int = 9606, + chromosome: Optional[str] = None, + locus_type: Optional[str] = None, + ) -> ResolutionResult: + """Resolve a gene symbol or alias to its canonical entity model.""" + ctx = None + if chromosome or locus_type: + ctx = ResolutionContext(chromosome=chromosome, locus_type=locus_type) + return await self.resolver.resolve(query=query, taxon_id=taxon_id, context=ctx) + + async def get_gene(self, query: str, taxon_id: int = 9606) -> Optional[GeneEntity]: + """Convenience helper to retrieve resolved canonical GeneEntity directly.""" + res = await self.resolve_gene(query, taxon_id=taxon_id) + return res.resolved_entity + + async def get_protein(self, accession: str) -> Optional[ProteinEntity]: + """Retrieve protein metadata from UniProt.""" + return await self.resolver.uniprot.fetch_by_accession(accession) + + async def get_functional_annotations( + self, + query: str, + taxon_id: int = 9606, + max_terms: int = 25, + ) -> Optional[FunctionalAnnotation]: + """Retrieve QuickGO functional annotations (molecular function, biological process, cellular component).""" + return await self.resolver.annotate_gene(query, taxon_id=taxon_id, max_terms_per_aspect=max_terms) + + async def get_pathways( + self, + query: str, + taxon_id: int = 9606, + limit: int = 25, + ) -> Optional[PathwayContext]: + """Retrieve biological pathways from Reactome.""" + return await self.resolver.get_pathways(query, taxon_id=taxon_id, limit=limit) + + async def get_target_diseases( + self, + query: str, + limit: int = 10, + ) -> Optional[TargetAssociationContext]: + """Retrieve Open Targets disease associations with evidence scores.""" + return await self.resolver.get_target_diseases(query, limit=limit) + + async def resolve_disease( + self, + query: str, + limit: int = 5, + ) -> List[DiseaseEntity]: + """Search and resolve disease names against the MONDO Disease Ontology.""" + return await self.resolver.resolve_disease(query, limit=limit) + + async def get_literature( + self, + query: str, + limit: int = 5, + ) -> LiteratureContext: + """Fetch supporting scientific publications from Europe PMC / PubMed.""" + return await self.resolver.get_supporting_publications(query, limit=limit) + + async def get_publication( + self, + identifier: str, + ) -> Optional[PublicationEntity]: + """Fetch detailed publication metadata by PMID, PMCID, or DOI.""" + return await self.resolver.get_publication_details(identifier) + + async def resolve_batch( + self, + queries: List[str], + taxon_id: int = 9606, + concurrency: int = 10, + ) -> BatchResolutionSummary: + """Resolve a large list of gene queries concurrently.""" + return await self.resolver.resolve_batch(queries, taxon_id=taxon_id, concurrency=concurrency) + + async def build_subgraph( + self, + query: str, + taxon_id: int = 9606, + include_pathways: bool = True, + include_go: bool = True, + include_diseases: bool = True, + include_literature: bool = True, + ) -> SubGraphContext: + """Construct an interconnected biological Knowledge Subgraph (PRD-21).""" + return await self.graph_builder.build_gene_subgraph( + query=query, + taxon_id=taxon_id, + include_pathways=include_pathways, + include_go=include_go, + include_diseases=include_diseases, + include_literature=include_literature, + ) + + async def traverse( + self, + source_query: str, + target_query: str, + max_hops: int = 3, + min_score: float = 0.0, + ) -> TraversalResult: + """Perform deterministic multi-hop reasoning between entities (PRD-22).""" + return await self.traversal_engine.traverse( + source_query=source_query, + target_query=target_query, + max_hops=max_hops, + min_score=min_score, + ) + + +__all__ = [ + "BioContext", + "EntityResolver", + "GeneEntity", + "ProteinEntity", + "ResolutionResult", + "FunctionalAnnotation", + "PathwayContext", + "TargetAssociationContext", + "DiseaseEntity", + "LiteratureContext", + "PublicationEntity", + "SubGraphContext", + "TraversalResult", +] diff --git a/src/biocontext/api.py b/src/biocontext/api.py new file mode 100644 index 0000000..5ffcd46 --- /dev/null +++ b/src/biocontext/api.py @@ -0,0 +1,107 @@ +"""Lightweight REST API v1 server for BioContext (PRD-18). + +Runs standalone using Python's standard library http.server and asyncio, +ensuring zero extra heavy dependencies (FastAPI/Starlette not required). +""" + +import asyncio +import json +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, HTTPServer +import urllib.parse +from typing import Any, Dict + +from biocontext import BioContext +from biocontext.logging import get_logger + +logger = get_logger("api") + + +class BioContextAPIHandler(BaseHTTPRequestHandler): + """Handler for REST API v1 endpoints.""" + + client: BioContext = None # Injected during startup + loop: asyncio.AbstractEventLoop = None + + def _send_json(self, data: Any, status: int = HTTPStatus.OK): + body = json.dumps(data, indent=2).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + def _send_error_json(self, message: str, status: int = HTTPStatus.BAD_REQUEST): + self._send_json({"error": message, "status_code": status}, status=status) + + def do_GET(self): + parsed_url = urllib.parse.urlparse(self.path) + path = parsed_url.path.rstrip("/") + query_params = urllib.parse.parse_qs(parsed_url.query) + + # Healthcheck + if path == "/health" or path == "": + return self._send_json({"status": "ok", "version": "1.0.0", "service": "BioContext API v1"}) + + # GET /v1/gene/{symbol} + if path.startswith("/v1/gene/"): + symbol = path.replace("/v1/gene/", "").strip() + if not symbol: + return self._send_error_json("Gene symbol is required") + + res = self.loop.run_until_complete(self.client.resolve_gene(symbol)) + return self._send_json(res.model_dump()) + + # GET /v1/protein/{accession} + if path.startswith("/v1/protein/"): + accession = path.replace("/v1/protein/", "").strip() + protein = self.loop.run_until_complete(self.client.get_protein(accession)) + if not protein: + return self._send_error_json(f"Protein '{accession}' not found", status=HTTPStatus.NOT_FOUND) + return self._send_json(protein.model_dump()) + + # GET /v1/disease/{query} + if path.startswith("/v1/disease/"): + disease_q = path.replace("/v1/disease/", "").strip() + diseases = self.loop.run_until_complete(self.client.resolve_disease(disease_q)) + return self._send_json([d.model_dump() for d in diseases]) + + # GET /v1/graph/{symbol} + if path.startswith("/v1/graph/"): + symbol = path.replace("/v1/graph/", "").strip() + subgraph = self.loop.run_until_complete(self.client.build_subgraph(symbol)) + return self._send_json(subgraph.model_dump()) + + # GET /v1/traverse?source=TP53&target=cancer + if path == "/v1/traverse": + source = query_params.get("source", [""])[0].strip() + target = query_params.get("target", [""])[0].strip() + max_hops = int(query_params.get("max_hops", [3])[0]) + + if not source or not target: + return self._send_error_json("Both 'source' and 'target' query parameters are required") + + res = self.loop.run_until_complete(self.client.traverse(source, target, max_hops=max_hops)) + return self._send_json(res.model_dump()) + + return self._send_error_json("Endpoint not found", status=HTTPStatus.NOT_FOUND) + + +def run_api_server(host: str = "127.0.0.1", port: int = 8000): + """Launch the lightweight REST API v1 server.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + client = BioContext() + + BioContextAPIHandler.client = client + BioContextAPIHandler.loop = loop + + server = HTTPServer((host, port), BioContextAPIHandler) + logger.info(f"BioContext REST API v1 listening at http://{host}:{port}") + try: + server.serve_forever() + except KeyboardInterrupt: + logger.info("Shutting down REST API server...") + finally: + server.server_close() diff --git a/src/biocontext/cli.py b/src/biocontext/cli.py index 0993cae..76bb707 100644 --- a/src/biocontext/cli.py +++ b/src/biocontext/cli.py @@ -287,6 +287,9 @@ def main(): if args.command == "server": mcp.run(transport="stdio") + elif args.command == "api": + from biocontext.api import run_api_server + run_api_server(host=args.host, port=args.port) elif args.command == "bench": import pytest sys.exit(pytest.main(["tests/test_benchmark.py", "-v"])) diff --git a/src/biocontext/config.py b/src/biocontext/config.py index cc555de..4d0f799 100644 --- a/src/biocontext/config.py +++ b/src/biocontext/config.py @@ -219,6 +219,15 @@ def get_headers(cls, email: Optional[str] = None) -> Dict[str, str]: {"flags": ["--min-score"], "type": float, "default": 0.0, "help": "Minimum cumulative score (default: 0.0)"} ] }, + { + "name": "api", + "help": "Start standalone BioContext REST API v1 HTTP server (PRD-18)", + "arguments": [ + {"flags": ["--host"], "default": "127.0.0.1", "help": "Host address (default: 127.0.0.1)"}, + {"flags": ["--port"], "type": int, "default": 8000, "help": "Port number (default: 8000)"} + ] + }, + diff --git a/tests/test_sdk_and_api.py b/tests/test_sdk_and_api.py new file mode 100644 index 0000000..f93531e --- /dev/null +++ b/tests/test_sdk_and_api.py @@ -0,0 +1,63 @@ +"""Unit and integration tests for the unified Python SDK and REST API v1 (PRD-18).""" + +import pytest +from biocontext import BioContext +from biocontext.api import BioContextAPIHandler +from http import HTTPStatus + + +@pytest.mark.asyncio +async def test_sdk_client_get_gene_and_protein(): + bc = BioContext() + gene = await bc.get_gene("TP53") + assert gene is not None + assert gene.symbol == "TP53" + assert gene.hgnc_id == "HGNC:11998" + + protein = await bc.get_protein("P04637") + assert protein is not None + assert protein.accession == "P04637" + assert "tumor protein p53" in protein.name.lower() or "cellular tumor antigen p53" in protein.name.lower() + + +@pytest.mark.asyncio +async def test_sdk_client_subgraph_and_traverse(): + bc = BioContext() + subgraph = await bc.build_subgraph("TP53", include_literature=False) + assert subgraph.root_node_id == "gene:TP53" + assert subgraph.total_nodes > 1 + + traversal = await bc.traverse("TP53", "cancer", max_hops=2) + assert traversal.source_query == "TP53" + assert traversal.total_paths >= 1 + assert "TP53" in traversal.paths_found[0].human_explanation + + +def test_api_handler_health(): + import urllib.request + import threading + from http.server import HTTPServer + import asyncio + import json + + loop = asyncio.new_event_loop() + client = BioContext() + BioContextAPIHandler.client = client + BioContextAPIHandler.loop = loop + + # Bind to ephemeral port + server = HTTPServer(("127.0.0.1", 0), BioContextAPIHandler) + port = server.server_port + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + + try: + url = f"http://127.0.0.1:{port}/health" + with urllib.request.urlopen(url, timeout=5) as response: + assert response.status == 200 + data = json.loads(response.read().decode()) + assert data["status"] == "ok" + assert data["version"] == "1.0.0" + finally: + server.shutdown() + server.server_close()