PROTEIN_GENERATOR / protenix_api.py
0merle0
Add Protenix v2 FastAPI folding endpoint
5d72c9d
Raw
History Blame Contribute Delete
4.53 kB
"""
FastAPI service that wraps Protenix v2 for protein structure prediction.
POST /fold
Form fields:
sequence (required) — single-letter amino acid sequence
msa_file (optional) — pre-computed MSA in .a3m format
Returns the predicted structure as a CIF (or PDB) file download.
Start with:
uvicorn protenix_api:app --host 0.0.0.0 --port 8000
Environment variables:
PROTENIX_MODEL — model name passed to `protenix pred -n` (default: protenix-v2)
PROTENIX_TIMEOUT — max seconds for inference subprocess (default: 600)
"""
import json
import os
import shutil
import subprocess
import tempfile
import uuid
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
app = FastAPI(title="Protenix Folding API", version="1.0.0")
MODEL_NAME = os.environ.get("PROTENIX_MODEL", "protenix-v2")
TIMEOUT = int(os.environ.get("PROTENIX_TIMEOUT", "600"))
@app.get("/health")
def health():
return {"status": "ok", "model": MODEL_NAME}
@app.post("/fold")
async def fold(
sequence: str = Form(..., description="Amino acid sequence in single-letter codes"),
msa_file: UploadFile | None = File(None, description="Pre-computed MSA in .a3m format"),
):
"""
Fold a protein sequence using Protenix v2.
Optionally supply a pre-computed MSA (.a3m) to improve accuracy.
Returns the predicted structure as a CIF file.
"""
sequence = sequence.strip().upper()
if not sequence:
raise HTTPException(status_code=422, detail="sequence must not be empty")
valid_aa = set("ACDEFGHIKLMNPQRSTVWYX")
invalid = set(sequence) - valid_aa
if invalid:
raise HTTPException(
status_code=422,
detail=f"Invalid amino acid characters: {sorted(invalid)}",
)
job_id = uuid.uuid4().hex[:12]
work_dir = Path(tempfile.mkdtemp(prefix=f"protenix_{job_id}_"))
try:
protein_chain: dict = {"sequence": sequence, "count": 1}
if msa_file is not None:
msa_path = work_dir / "input.a3m"
msa_path.write_bytes(await msa_file.read())
# Protenix expects absolute paths for MSA files
protein_chain["unpairedMsaPath"] = str(msa_path.resolve())
input_data = [
{
"name": job_id,
"sequences": [{"proteinChain": protein_chain}],
"covalent_bonds": [],
}
]
input_json = work_dir / "input.json"
input_json.write_text(json.dumps(input_data, indent=2))
output_dir = work_dir / "output"
output_dir.mkdir()
result = subprocess.run(
[
"protenix", "pred",
"-i", str(input_json),
"-o", str(output_dir),
"-n", MODEL_NAME,
],
capture_output=True,
text=True,
timeout=TIMEOUT,
)
if result.returncode != 0:
raise HTTPException(
status_code=500,
detail=f"Protenix inference failed:\n{result.stderr[-3000:]}",
)
# Prefer CIF over PDB; take the first hit if multiple seeds/samples exist
structure_files = sorted(output_dir.rglob("*.cif")) or sorted(output_dir.rglob("*.pdb"))
if not structure_files:
raise HTTPException(
status_code=500,
detail="Protenix produced no output structure file",
)
best = structure_files[0]
out_path = work_dir / f"{job_id}{best.suffix}"
shutil.copy(best, out_path)
media_type = "chemical/x-mmcif" if best.suffix == ".cif" else "chemical/x-pdb"
return FileResponse(
path=str(out_path),
media_type=media_type,
filename=f"{job_id}{best.suffix}",
background=BackgroundTask(shutil.rmtree, work_dir, ignore_errors=True),
)
except HTTPException:
shutil.rmtree(work_dir, ignore_errors=True)
raise
except subprocess.TimeoutExpired:
shutil.rmtree(work_dir, ignore_errors=True)
raise HTTPException(
status_code=504,
detail=f"Inference timed out after {TIMEOUT}s. Try a shorter sequence or increase PROTENIX_TIMEOUT.",
)
except Exception as exc:
shutil.rmtree(work_dir, ignore_errors=True)
raise HTTPException(status_code=500, detail=str(exc)) from exc