Python
No client library to install: the API is plain HTTP and JSON. This page uses
httpx and polars; the same
works with requests and pandas.
pip install httpx polars
export PTARMIGAN_API_KEY=... # from https://ptarmigan.app/api-keys
The examples read the key from that environment variable. Don't write it into a script or notebook you might share; if it may have leaked, revoke it on the API keys page and create a new one.
Send a descriptive User-Agent too. Cloudflare can block HTTP-library defaults
before a request reaches the API; see Errors.
Getting-started notebook
Download the getting-started notebook (.ipynb) and open it in Jupyter, VS Code or Colab. It walks through the same steps as the script below, one cell at a time: balance, a target and a library, an estimate, a screen, the top hits, the full ranking with a plot, and one compound residue by residue. It is already set up for https://ptarmigan.app.
A complete script
Screens the smallest ready library against EGFR and prints the top ten. The
call helper turns API errors into exceptions and retries what is worth
retrying; because the screen is queued with one Idempotency-Key, a retry never
pays for a second screen.
import io
import os
import time
import uuid
from urllib.parse import quote
import httpx
import polars as pl
BASE = "https://ptarmigan.app"
USER_AGENT = "acme-screen-python/1.0"
client = httpx.Client(
base_url=BASE,
headers={
"Authorization": "Bearer " + os.environ["PTARMIGAN_API_KEY"],
"User-Agent": USER_AGENT,
},
timeout=60,
)
class ApiError(Exception):
pass
def call(method, path, **kwargs):
"""One API request. Retries a dropped connection, and a 502, 503 or 504
(after its Retry-After); raises ApiError with the API's code otherwise."""
for attempt in range(5):
try:
response = client.request(method, path, **kwargs)
except httpx.TransportError:
time.sleep(2**attempt)
continue
if response.status_code in (502, 503, 504):
time.sleep(int(response.headers.get("Retry-After", "5")))
continue
if response.is_error:
try:
error = response.json()
detail = f"{error['code']}: {error['message']}"
except (ValueError, KeyError, TypeError):
# Not the API's own error: a proxy's page, for one.
detail = response.text[:200]
raise ApiError(f"{response.status_code} {detail}")
return response.json()
raise ApiError(f"{method} {path} kept failing; try again later")
def items(path, **params):
"""Read every page, keeping search filters unchanged."""
while True:
page = call("GET", path, params=params)
yield from page["data"]
if page["next_after"] is None:
return
params["after"] = page["next_after"]
target = next(items("/api/v1/proteins", search="EGFR"))
libraries = items("/api/v1/compound-collections")
library = min((c for c in libraries if c["status"] == "ready"), key=lambda c: c["size"])
body = {"protein_id": target["id"], "collection_id": library["id"]}
estimate = call("POST", "/api/v1/screens/estimate", json=body)
print(f"{estimate['compounds']:,} compounds for ${estimate['cost_usd']}")
if not estimate["sufficient"]:
raise SystemExit(f"Add funds at {BASE}/billing")
key = str(uuid.uuid4()) # one key per screen; every retry reuses it
screen = call("POST", "/api/v1/screens", json=body, headers={"Idempotency-Key": key})
delay = 2
while screen["status"] in ("queued", "running"):
time.sleep(delay)
delay = min(delay * 2, 30)
screen = call("GET", f"/api/v1/screens/{screen['id']}")
if screen["status"] != "completed":
raise SystemExit(f"The screen {screen['status']}; nothing was charged.")
link = call(
"GET", f"/api/v1/screens/{screen['id']}/download", params={"format": "parquet"}
)
hits = pl.read_parquet(
io.BytesIO(httpx.get(link["url"], headers={"User-Agent": USER_AGENT}).content)
)
print(hits.head(10).select("rank", "compound_id", "name", "engagement"))
Working with the results
The download has one row per compound, already sorted by rank. With polars:
hits.filter(pl.col("engagement") > 0.5) # strong predictions
hits.filter(pl.col("name").str.contains("nib")) # by name
hits.write_csv("ranking.csv")
With pandas, read the same file with pandas.read_parquet. To read only the top
of a ranking without downloading it, page through
GET /api/v1/screens/{screen_id}/hits (see REST with curl).
One compound, residue by residue
detail = call(
"GET",
f"/api/v1/screens/{screen['id']}/compounds/{quote(hits['compound_id'][0], safe='')}",
params={"include_residues": "true"},
)
detail["residue_engagement"] # one value per residue; None where not scored
quote keeps an id containing #, ? or / in one piece.
See Reading the results for what each value means, and
Errors for every code ApiError can carry.