From 09ca9092561df67501b3d6de0a1a9d1a9529d73d Mon Sep 17 00:00:00 2001 From: Team-Subscription Date: Tue, 29 Sep 2026 23:12:25 +0530 Subject: [PATCH] Add Docker images and RunPod serverless handler - Dockerfile: pod image that installs DockQ and stays alive for interactive use - Dockerfile.serverless + serverless/handler.py: RunPod serverless worker that wraps the DockQ CLI (--json) and accepts model/native as text, base64 or URL - .dockerignore to keep build context small Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 9 +++ Dockerfile | 30 ++++++++++ Dockerfile.serverless | 25 +++++++++ serverless/handler.py | 109 ++++++++++++++++++++++++++++++++++++ serverless/requirements.txt | 1 + serverless/test_input.json | 6 ++ 6 files changed, 180 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 Dockerfile.serverless create mode 100644 serverless/handler.py create mode 100644 serverless/requirements.txt create mode 100644 serverless/test_input.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..42f137d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +build/ +*.egg-info +*__pycache__* +*.c +*.so +.coverage +*.ipynb* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..628f321 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# DockQ container image (works locally and on RunPod pods) +# +# Build: docker build -t dockq . +# Run: docker run --rm -v "$PWD":/data dockq DockQ /data/model.pdb /data/native.pdb +# +# On RunPod the default command keeps the pod alive so you can open a +# terminal / SSH in and run `DockQ ` directly. + +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +# Compiler is needed to build the Cython extension (DockQ.operations) +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/DockQ +COPY . . + +RUN pip install --upgrade pip \ + && pip install . \ + && DockQ examples/1A2K_r_l_b.model.pdb examples/1A2K_r_l_b.pdb > /dev/null + +# Working directory for user data (mount a RunPod volume here if desired) +WORKDIR /workspace + +CMD ["sleep", "infinity"] diff --git a/Dockerfile.serverless b/Dockerfile.serverless new file mode 100644 index 0000000..bb07a55 --- /dev/null +++ b/Dockerfile.serverless @@ -0,0 +1,25 @@ +# DockQ image for RunPod Serverless +# +# Build: docker build -f Dockerfile.serverless -t dockq-serverless . +# Local test (uses serverless/test_input.json): +# docker run --rm dockq-serverless python -u /opt/DockQ/serverless/handler.py --test_input "$(cat serverless/test_input.json)" + +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +# Compiler is needed to build the Cython extension (DockQ.operations) +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/DockQ +COPY . . + +RUN pip install --upgrade pip \ + && pip install . -r serverless/requirements.txt \ + && DockQ examples/1A2K_r_l_b.model.pdb examples/1A2K_r_l_b.pdb > /dev/null + +CMD ["python", "-u", "/opt/DockQ/serverless/handler.py"] diff --git a/serverless/handler.py b/serverless/handler.py new file mode 100644 index 0000000..c8a32f1 --- /dev/null +++ b/serverless/handler.py @@ -0,0 +1,109 @@ +"""RunPod serverless handler for DockQ. + +Wraps the installed `DockQ` command line tool; DockQ itself is unchanged. + +Job input (``job["input"]``), for each of ``model`` and ``native`` give ONE of: + "": structure file contents as plain text (PDB or mmCIF) + "_base64": base64-encoded file (plain or gzip-compressed) + "_url": http(s) URL to download the file from (plain or .gz) + +Optional DockQ options (same meaning as the CLI flags): + "mapping": "AB:HL", "small_molecule": false, "capri_peptide": false, + "no_align": false, "allowed_mismatches": 0, "n_cpu": , "max_chunk": 512 + +Returns the same dictionary DockQ writes with ``--json``. +""" + +import base64 +import json +import os +import subprocess +import tempfile +import urllib.request + +import runpod + +GZIP_MAGIC = b"\x1f\x8b" +TIMEOUT = int(os.environ.get("DOCKQ_TIMEOUT", "3600")) +MAX_DOWNLOAD_BYTES = int(os.environ.get("DOCKQ_MAX_DOWNLOAD_BYTES", str(500 * 1024**2))) + +BOOL_FLAGS = ("small_molecule", "capri_peptide", "no_align") +INT_OPTIONS = ("allowed_mismatches", "n_cpu", "max_chunk") + + +def _get_bytes(job_input, name): + given = [k for k in (name, f"{name}_base64", f"{name}_url") if job_input.get(k)] + if len(given) != 1: + raise ValueError( + f"Provide exactly one of '{name}', '{name}_base64' or '{name}_url'" + ) + key = given[0] + value = job_input[key] + if key == name: + return value.encode() + if key.endswith("_base64"): + return base64.b64decode(value) + if not value.startswith(("http://", "https://")): + raise ValueError(f"'{key}' must be an http(s) URL") + with urllib.request.urlopen(value, timeout=300) as resp: + data = resp.read(MAX_DOWNLOAD_BYTES + 1) + if len(data) > MAX_DOWNLOAD_BYTES: + raise ValueError(f"'{key}' is larger than {MAX_DOWNLOAD_BYTES} bytes") + return data + + +def _write_structure(workdir, name, data): + # DockQ picks gzip.open based on the ".gz" suffix and auto-detects PDB/mmCIF + suffix = ".gz" if data[:2] == GZIP_MAGIC else "" + path = os.path.join(workdir, f"{name}{suffix}") + with open(path, "wb") as fh: + fh.write(data) + return path + + +def _build_command(job_input, model_path, native_path, json_path): + cmd = ["DockQ", model_path, native_path, "--json", json_path, "--short"] + for flag in BOOL_FLAGS: + if job_input.get(flag): + cmd.append(f"--{flag}") + for opt in INT_OPTIONS: + if job_input.get(opt) is not None: + cmd += [f"--{opt}", str(int(job_input[opt]))] + if "n_cpu" not in job_input: + cmd += ["--n_cpu", str(os.cpu_count() or 1)] + if job_input.get("mapping"): + cmd += ["--mapping", str(job_input["mapping"])] + return cmd + + +def handler(job): + job_input = job.get("input") or {} + try: + with tempfile.TemporaryDirectory() as workdir: + model_path = _write_structure(workdir, "model", _get_bytes(job_input, "model")) + native_path = _write_structure(workdir, "native", _get_bytes(job_input, "native")) + json_path = os.path.join(workdir, "result.json") + + cmd = _build_command(job_input, model_path, native_path, json_path) + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=TIMEOUT) + if proc.returncode != 0 or not os.path.exists(json_path): + return { + "error": "DockQ failed", + "returncode": proc.returncode, + "stderr": proc.stderr[-4000:], + "stdout": proc.stdout[-4000:], + } + + with open(json_path) as fh: + result = json.load(fh) + except (ValueError, OSError, subprocess.TimeoutExpired) as exc: + return {"error": str(exc)} + + # Temp paths are meaningless to the caller + result.pop("model", None) + result.pop("native", None) + return result + + +if __name__ == "__main__": + runpod.serverless.start({"handler": handler}) diff --git a/serverless/requirements.txt b/serverless/requirements.txt new file mode 100644 index 0000000..f45c6f1 --- /dev/null +++ b/serverless/requirements.txt @@ -0,0 +1 @@ +runpod>=1.7 diff --git a/serverless/test_input.json b/serverless/test_input.json new file mode 100644 index 0000000..38fdab3 --- /dev/null +++ b/serverless/test_input.json @@ -0,0 +1,6 @@ +{ + "input": { + "model_url": "https://raw.githubusercontent.com/bjornwallner/DockQ/master/examples/1A2K_r_l_b.model.pdb", + "native_url": "https://raw.githubusercontent.com/bjornwallner/DockQ/master/examples/1A2K_r_l_b.pdb" + } +}