← Files NVIDIA BioNeMo Agent ToolkitARCHIVED FILE
skills/complexa-binder-design/vendor/science-skills/uniprot_database/scripts/uniprot_tools.py
16.4 KB · Oct 5, 2026 · 18:30 UTC
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Uniprot tools for accessing UniProtKB, UniParc, and UniRef."""
# MODIFIED for bionemo-nim-skills (2026-06-11): replaced the
# `scienceskillscommon.http_client` import (installed via `uv` inline-script
# metadata) with the small stdlib-`urllib` shim below, aliased as `http_client`
# so every `http_client.X` reference in this file works unchanged. UniProt query
# logic is untouched. Original: google-deepmind/science-skills (Apache-2.0).
from __future__ import annotations
import argparse
import gzip
import json
import re
import sys
import time
import types
from typing import Any, Iterator
import urllib.error
import urllib.parse
import urllib.request
class _HttpError(Exception):
"""Stand-in for scienceskillscommon.http_client.HttpError."""
def __init__(self, message, status_code=None):
super().__init__(message)
self.status_code = status_code
class _HttpResponse:
"""Minimal response: .headers (case-insensitive), .data (bytes), .encoding."""
def __init__(self, headers, data, encoding="utf-8"):
self.headers = headers
self.data = data
self.encoding = encoding
class _HttpClient:
"""Stdlib shim for the subset used here: fetch / fetch_json / stream_lines."""
def __init__(self, base_url, qps=1.0):
self.base_url = base_url
self._ua = {"User-Agent": "bionemo-nim-skills-uniprot/1.0"}
def _request(self, url, headers=None, method="GET", data=None):
hdrs = dict(self._ua)
if headers:
hdrs.update(headers)
body = data.encode() if isinstance(data, str) else data
return urllib.request.Request(url, headers=hdrs, method=method, data=body)
def fetch(self, url, headers=None, method="GET", data=None):
try:
with urllib.request.urlopen(self._request(url, headers, method, data), timeout=120) as r:
charset = r.headers.get_content_charset() or "utf-8"
return _HttpResponse(r.headers, r.read(), charset)
except urllib.error.HTTPError as e:
raise _HttpError(str(e), status_code=e.code) from e
except urllib.error.URLError as e:
raise _HttpError(str(e)) from e
def fetch_json(self, url, headers=None, method="GET", data=None):
resp = self.fetch(url, headers=headers, method=method, data=data)
raw = resp.data
if raw[:2] == b"\x1f\x8b":
raw = gzip.decompress(raw)
return json.loads(raw.decode(resp.encoding))
def stream_lines(self, url, headers=None):
resp = self.fetch(url, headers=headers)
raw = resp.data
if raw[:2] == b"\x1f\x8b":
raw = gzip.decompress(raw)
for line in raw.decode(resp.encoding).splitlines():
yield line
http_client = types.SimpleNamespace(
HttpClient=_HttpClient, HttpResponse=_HttpResponse, HttpError=_HttpError
)
class UniProtError(Exception):
"""Custom exception for UniProt tool errors."""
BASE_URL = "https://rest.uniprot.org"
SPARQL_URL = "https://sparql.uniprot.org/sparql"
CLIENT = http_client.HttpClient(BASE_URL, qps=1.0)
SPARQL_CLIENT = http_client.HttpClient(SPARQL_URL, qps=1.0)
def _add_params_to_url(url: str, params: dict[str, Any] | None = None) -> str:
"""Adds URL parameters to a URL."""
if params:
sep = "&" if "?" in url else "?"
url += f"{sep}{urllib.parse.urlencode(params, doseq=True)}"
return url
def _get_header(resp: http_client.HttpResponse, header_name: str) -> str:
"""Returns the value of a given header, checking both lower and upper case."""
return resp.headers.get(header_name) or resp.headers.get(header_name.lower())
def _get_decompressed_data(resp: http_client.HttpResponse) -> str:
"""Decompresses gzipped data from a response if necessary and decodes it."""
data = resp.data
# UniProt sometimes double-gzips content.
if data.startswith(b"\x1f\x8b"):
data = gzip.decompress(data)
return data.decode(resp.encoding)
def _fetch(
url: str, method="GET", headers=None, data=None, *, as_json=False
) -> dict[str, Any] | str:
"""Fetch JSON and parse, handling server double-gzipping content."""
if not headers:
headers = {}
if as_json:
headers |= {"Accept": "application/json"}
response = CLIENT.fetch(url, headers=headers, method=method, data=data)
decoded_data = _get_decompressed_data(response)
if as_json:
return json.loads(decoded_data)
else:
return decoded_data
def search_proteins(
query: str,
dataset: str = "uniprotkb",
output_format: str = "json",
limit: int | None = None,
fields: list[str] | None = None,
) -> Iterator[dict[str, Any] | str]:
"""Search proteins in a UniProt dataset with automatic pagination."""
url = f"{BASE_URL}/{dataset}/search"
params: dict[str, Any] = {
"query": query,
"format": output_format,
}
# Determine if automatic pagination is needed
# UniProt has a hard limit of 500 for the 'size' parameter.
use_pagination = limit is None or limit > 500
request_size = min(limit, 500) if limit is not None else 500
params["size"] = request_size
if fields:
params["fields"] = ",".join(fields)
if not use_pagination:
def _single_request_iterator():
full_url = _add_params_to_url(url, params)
yield _fetch(full_url, as_json=(output_format == "json"))
return _single_request_iterator()
# Pagination logic
def _paginate_generator():
next_url = url
current_params = params
fetched_count = 0
total_results = None
header = None
while next_url:
full_url = _add_params_to_url(next_url, current_params)
resp = CLIENT.fetch(full_url)
if total_results is None:
total_results = _get_header(resp, "X-Total-Results")
data = _get_decompressed_data(resp)
if output_format == "json":
data = json.loads(data)
# Extract results from this page to handle limits
page_results = []
if isinstance(data, dict) and "results" in data:
page_results = data["results"]
elif isinstance(data, str):
if output_format == "fasta":
# Split by '>' at the start of a line
parts = re.split(r"(?m)^>", data)
page_results = [">" + p for p in parts if p.strip()]
elif output_format == "tsv":
# UniProt includes TSV headers on each page.
lines = data.strip().splitlines()
if lines:
if header is None: # Store the header only from the first page.
header = lines[0]
page_results = lines[1:]
else:
page_results = []
else:
page_results = data.strip().splitlines()
# Apply limit if necessary
if limit is not None:
remaining = limit - fetched_count
if remaining <= 0:
break
if len(page_results) > remaining:
page_results = page_results[:remaining]
# This reconstruction only executes when we need to truncate results.
#
# No Trimming Needed: If limit is None, or if the current page results
# fit within the remaining limit, data already contains the full page
# content as received from the server (either as a parsed dict for
# JSON or a raw string for FASTA/others). We can just yield it.
#
# Trimming Needed: We only need to reconstruct data if we had to slice
# page_results to respect the limit. In that case, build a new data
# object from the truncated page_results.
#
# TSV is the only exception (handled below) where we always
# reconstruct the data, regardless of whether we applied a limit or
# not. This is because we are actively modifying the content by
# removing the header lines from subsequent pages, so we can never
# just yield the raw server response for TSV after the first page.
if isinstance(data, dict):
data["results"] = page_results
elif output_format == "fasta":
data = "".join(page_results)
elif output_format != "tsv":
data = "\n".join(page_results)
# Reconstruct TSV data to ensure headers are only on the first page
if output_format == "tsv":
page_data = "\n".join(page_results)
if fetched_count == 0 and header:
data = header + "\n" + page_data
else:
data = page_data
fetched_count += len(page_results)
if total_results:
print(
f"Progress: {fetched_count} / {total_results} fetched",
file=sys.stderr,
)
else:
print(f"Progress: {fetched_count} fetched", file=sys.stderr)
yield data
if limit is not None and fetched_count >= limit:
break
link_header = _get_header(resp, "Link")
if link_header and 'rel="next"' in link_header:
next_url = link_header.split(";")[0].strip("<>")
current_params = None # Params are already in the URL
else:
next_url = None
return _paginate_generator()
def get_count(query: str, dataset: str = "uniprotkb") -> int:
"""Retrieve the total number of hits for a query."""
url = f"{BASE_URL}/{dataset}/search"
params = {"query": query, "size": 1, "format": "json"}
resp = CLIENT.fetch(_add_params_to_url(url, params))
return int(_get_header(resp, "X-Total-Results") or 0)
def get_entry(
accession: str,
dataset: str = "uniprotkb",
output_format: str = "json",
) -> dict[str, Any] | str:
"""Retrieve a single UniProt entry."""
url = f"{BASE_URL}/{dataset}/{accession}"
params = {"format": output_format}
full_url = _add_params_to_url(url, params)
return _fetch(full_url, as_json=(output_format == "json"))
def run_id_mapping(ids: list[str], from_db: str, to_db: str) -> dict[str, Any]:
"""Execute the ID mapping workflow."""
# 1. Submit job
submit_url = f"{BASE_URL}/idmapping/run"
form_dict = {
"from": from_db,
"to": to_db,
"ids": ",".join(ids),
}
data = urllib.parse.urlencode(form_dict).encode("utf-8")
headers = {"Content-Type": "application/x-www-form-urlencoded"}
job_id = _fetch(
submit_url, method="POST", headers=headers, data=data, as_json=True
)["jobId"]
# 2. Poll for status
status_url = f"{BASE_URL}/idmapping/status/{job_id}"
results_resp = None
while True:
status_resp = _fetch(status_url, as_json=True)
if not isinstance(status_resp, dict):
raise UniProtError(
f"ID mapping job status response is not a dict: {status_resp}"
)
# Check if we were redirected to results (or results are in the status resp)
if "results" in status_resp:
results_resp = status_resp
break
job_status = status_resp.get("jobStatus")
if job_status == "FINISHED":
break
if job_status == "FAILED":
raise UniProtError(f"ID mapping job failed: {status_resp.get('errors')}")
print(f"ID Mapping Job status: {job_status}")
time.sleep(2)
# 3. Get results (if not already fetched during status poll)
if results_resp:
return results_resp
results_url = f"{BASE_URL}/idmapping/results/{job_id}"
return _fetch(results_url, as_json=True)
def sparql_query(query: str) -> dict[str, Any]:
"""Execute a SPARQL query."""
params = {"query": query, "format": "json"}
return SPARQL_CLIENT.fetch_json(_add_params_to_url(SPARQL_URL, params))
def stream_results(
query: str,
dataset: str = "uniprotkb",
output_format: str = "tsv",
fields: list[str] | None = None,
) -> Iterator[str]:
"""Stream all results for a bulk query using the /stream endpoint.
The /stream endpoint always returns the full result set (up to 10M entries).
It does NOT support limiting the number of results. Use `search_proteins`
with a `limit` parameter if you need a subset of results.
Args:
query: The search query.
dataset: The dataset to search in.
output_format: The output format.
fields: The fields to retrieve.
Yields:
str: Each line of the result set.
"""
url = f"{BASE_URL}/{dataset}/stream"
params = {"query": query, "format": output_format}
headers = {"Accept-Encoding": "identity"}
if fields:
params["fields"] = ",".join(fields)
full_url = _add_params_to_url(url, params)
fetched_count = 0
for line in CLIENT.stream_lines(full_url, headers=headers):
if line:
fetched_count += 1
if fetched_count % 1000 == 0:
print(f"Progress: {fetched_count} lines fetched...", file=sys.stderr)
yield line
print(f"Total fetched lines: {fetched_count}", file=sys.stderr)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command")
# Search command
s_parser = subparsers.add_parser("search", help="Search proteins")
s_parser.add_argument("query", help="Query string")
s_parser.add_argument(
"--dataset",
default="uniprotkb",
help="Dataset to search in (e.g. uniprotkb, uniparc, unipref)",
)
s_parser.add_argument(
"--limit", type=int, help="Total number of results to return"
)
s_parser.add_argument("--format", default="json")
s_parser.add_argument("--fields")
# Get command
g_parser = subparsers.add_parser("get", help="Get protein entry")
g_parser.add_argument("accession")
g_parser.add_argument(
"--dataset",
default="uniprotkb",
help="Dataset to search in (e.g. uniprotkb, uniparc, unipref)",
)
g_parser.add_argument("--format", default="json")
# Map command
m_parser = subparsers.add_parser("map", help="Map IDs")
m_parser.add_argument("ids", help="Comma-separated IDs")
m_parser.add_argument("--from_db", required=True)
m_parser.add_argument("--to_db", required=True)
# Count command
c_parser = subparsers.add_parser("count", help="Count results for a query")
c_parser.add_argument("query")
c_parser.add_argument(
"--dataset",
default="uniprotkb",
help="Dataset to search in (e.g. uniprotkb, uniparc, unipref)",
)
# SPARQL command
sp_parser = subparsers.add_parser("sparql", help="Run SPARQL query")
sp_parser.add_argument("query")
# Stream command
st_parser = subparsers.add_parser(
"stream",
help="Stream ALL results for a bulk query (up to 10M entries, no limit)",
)
st_parser.add_argument("query")
st_parser.add_argument(
"--dataset",
default="uniprotkb",
help="Dataset to search in (e.g. uniprotkb, uniparc, unipref)",
)
st_parser.add_argument("--format", default="tsv")
st_parser.add_argument("--fields")
args = parser.parse_args()
# Validate that --format is lowercase (UniProt API requires lowercase).
if hasattr(args, "format") and args.format != args.format.lower():
parser.error(
f"Invalid format '{args.format}': format must be lowercase"
f" (e.g. 'json', 'tsv', 'fasta'). Got '{args.format}',"
f" did you mean '{args.format.lower()}'?"
)
if args.command == "search":
search_fields = args.fields.split(",") if args.fields else None
result_iterator = search_proteins(
args.query,
args.dataset,
output_format=args.format,
limit=args.limit,
fields=search_fields,
)
for page in result_iterator:
if args.format == "json":
print(json.dumps(page, indent=2))
else:
print(page)
elif args.command == "get":
result = get_entry(
args.accession,
args.dataset,
output_format=args.format,
)
if args.format == "json":
print(json.dumps(result, indent=2))
else:
print(result)
elif args.command == "count":
print(get_count(args.query, args.dataset))
elif args.command == "map":
print(
json.dumps(
run_id_mapping(
args.ids.split(","),
args.from_db,
args.to_db,
),
indent=2,
)
)
elif args.command == "sparql":
print(json.dumps(sparql_query(args.query), indent=2))
elif args.command == "stream":
stream_fields = args.fields.split(",") if args.fields else None
for row in stream_results(
args.query,
args.dataset,
output_format=args.format,
fields=stream_fields,
):
print(row)
elif not args.command:
parser.print_help()
SHA-256: c0feb6a3ac5576591e30a59a74ad061b5332b0ed4b399c71d97bcd184b757bf5