Source code for tooluniverse.gtopdb_tool
"""Guide to Pharmacology (GtoPdb) tools, backed by the open bulk downloads.
GtoPdb's REST web services closed. Every path under /services now answers 401:
{"message":"API key is missing. In order to use the GtoPdb Web Services
you must register and request an API Key."}
Their published web-services page does not document how to send a key, and
seven ways of sending one -- ?apiKey=, ?api_key=, ?key=, X-API-Key, apiKey and
Authorization headers, bare and Bearer -- all come back with that same "key is
missing" body, so there is no mechanism to implement even for a registered
user. See data/broken_apis/gtopdb_rest.json.
The bulk CSVs under /DATA are still open and carry the same records, so that is
where these tools read from now. Measured 2026-10-03:
targets_and_families.csv 1.81 MB 2.3 s 3392 targets
ligands.csv 6.54 MB 3.0 s 13991 ligands
interactions.csv 7.20 MB 3.2 s 24936 interactions
ligand_physchem_properties.csv 0.80 MB 2.0 s 11266 ligands
Each file is parsed once per process and kept, so only the first call in a
session pays the download. The two disease tools are gone: GtoPdb publishes no
open disease file, so there is nothing to read them from.
Filtering was always client-side here. The old REST service ignored ?type= when
?name= was present and ignored ?approved= entirely, so this module already
matched types and flags against the records itself; the bulk files just make
that the whole story instead of a workaround.
"""
from __future__ import annotations
import csv
import io
import re
import threading
from typing import Any, Dict, List, Optional
import requests
from .base_tool import BaseTool
from .http_utils import request_with_retry
from .tool_registry import register_tool
GTOPDB_BULK_BASE = "https://www.guidetopharmacology.org/DATA"
TARGETS_FILE = "targets_and_families.csv"
LIGANDS_FILE = "ligands.csv"
INTERACTIONS_FILE = "interactions.csv"
PHYSCHEM_FILE = "ligand_physchem_properties.csv"
DEFAULT_LIMIT = 25
MAX_LIMIT = 200
# GtoPdb writes names with markup -- "5-HT<sub>1A</sub> receptor",
# "α<sub>1A</sub>" -- in both the CSVs and the old JSON. Searching for
# "5-HT1A" has to reach that record, so matching runs on the stripped text and
# the original is returned alongside it.
_TAG_RE = re.compile(r"<[^>]+>")
_ENTITY_RE = re.compile(r"&[a-zA-Z]+;|&#\d+;")
[docs]
def _norm_type(value: Any) -> str:
"""Case-, space- and underscore-insensitive type token.
Collapses the query vocabulary ("CatalyticReceptor"), the record
vocabulary ("catalytic_receptor") and human spellings ("Catalytic
receptor") onto one key.
"""
if value is None:
return ""
return re.sub(r"[\s_\-]+", " ", str(value).strip().lower())
[docs]
class _TypeSpec:
"""One accepted `type` value.
canonical display spelling used in messages
record_types record `type` values that satisfy the filter, or None when
the filter is a boolean record field instead
flag record boolean field that satisfies the filter, e.g.
'approved'
Carried over from the REST implementation unchanged apart from dropping
server_value: there is no upstream query to push a value into any more.
The vocabulary itself is verified knowledge and worth keeping -- notably
AccessoryProtein, whose records carry an empty `type`, and the Ion channel
umbrella, which GtoPdb has no single value for.
"""
__slots__ = ("canonical", "flag", "record_types")
[docs]
def __init__(self, canonical, record_types=None, flag=None):
self.canonical = canonical
self.record_types = (
frozenset(_norm_type(item) for item in record_types)
if record_types is not None
else None
)
self.flag = flag
[docs]
def matches(self, record: Any) -> bool:
if not isinstance(record, dict):
return False
if self.flag is not None:
return record.get(self.flag) is True
return _norm_type(record.get("type")) in self.record_types
[docs]
def _build_specs(entries) -> dict:
table = {}
for aliases, spec in entries:
for alias in aliases:
table[_norm_type(alias)] = spec
return table
_LIGAND_TYPE_SPECS = _build_specs(
[
(("Synthetic organic",), _TypeSpec("Synthetic organic", ["Synthetic organic"])),
(("Peptide",), _TypeSpec("Peptide", ["Peptide"])),
(("Natural product",), _TypeSpec("Natural product", ["Natural product"])),
(("Metabolite",), _TypeSpec("Metabolite", ["Metabolite"])),
(("Antibody",), _TypeSpec("Antibody", ["Antibody"])),
(("Nucleic acid",), _TypeSpec("Nucleic acid", ["Nucleic acid"])),
(("Inorganic",), _TypeSpec("Inorganic", ["Inorganic"])),
# Boolean fields on every ligand record, which GtoPdb also accepted
# through ?type=.
(("Approved",), _TypeSpec("Approved", None, "approved")),
(("Withdrawn",), _TypeSpec("Withdrawn", None, "withdrawn")),
(("Labelled", "Labeled"), _TypeSpec("Labelled", None, "labelled")),
]
)
_TARGET_TYPE_SPECS = _build_specs(
[
# The spelled-out forms are added here, not carried over: _norm_type
# collapses case, spaces, hyphens and underscores, so they resolve to
# the same spec and only widen what a caller may type.
(
("GPCR", "G protein coupled receptor", "G protein-coupled receptor"),
_TypeSpec("GPCR", ["gpcr"]),
),
(
("NHR", "Nuclear receptor", "Nuclear hormone receptor"),
_TypeSpec("NHR", ["nhr"]),
),
(
("LGIC", "Ligand-gated ion channel"),
_TypeSpec("LGIC", ["lgic"]),
),
(
("VGIC", "Voltage-gated ion channel"),
_TypeSpec("VGIC", ["vgic"]),
),
(("OtherIC", "Other ion channel"), _TypeSpec("OtherIC", ["other_ic"])),
(("Enzyme",), _TypeSpec("Enzyme", ["enzyme"])),
(
("CatalyticReceptor", "Catalytic receptor"),
_TypeSpec("CatalyticReceptor", ["catalytic_receptor"]),
),
(("Transporter",), _TypeSpec("Transporter", ["transporter"])),
(
("OtherProtein", "Other protein"),
_TypeSpec("OtherProtein", ["other_protein"]),
),
# These records carry an empty `type`.
(
("AccessoryProtein", "Accessory protein"),
_TypeSpec("AccessoryProtein", [""]),
),
# GtoPdb splits ion channels into three record types with no single
# value covering them.
(
("Ion channel", "Ion channels"),
_TypeSpec("Ion channel", ["lgic", "vgic", "other_ic"]),
),
]
)
# Values GtoPdb's own documentation lists that match no record.
_LIGAND_TYPE_DEAD_VALUES = {
"endogenous peptide": "Peptide",
"inn": "Approved",
}
[docs]
def _type_validation_error(raw_value: Any, specs: dict, dead: dict, kind: str) -> dict:
"""Reject an unrecognised `type` rather than returning everything."""
valid = sorted({spec.canonical for spec in specs.values()})
message = (
f"Invalid {kind} type={raw_value!r}. GtoPdb does not use this value, so "
f"filtering on it would return every record unfiltered. "
f"Valid values: {', '.join(valid)}."
)
hint = dead.get(_norm_type(raw_value))
if hint:
message += (
f" Note: {raw_value!r} appears in GtoPdb's web-service documentation "
f"but matches no record in the database -- use {hint!r} instead."
)
return {"status": "error", "error": message, "valid_types": valid}
_BULK_CACHE: Dict[str, List[Dict[str, str]]] = {}
_BULK_LOCK = threading.Lock()
[docs]
def _plain(value: Any) -> str:
"""Markup-free text for matching and for display."""
text = str(value or "")
text = _TAG_RE.sub("", text)
return _ENTITY_RE.sub("", text).strip()
[docs]
def _as_int(value: Any) -> Optional[int]:
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
[docs]
def _as_bool(value: Any) -> bool:
"""True for every spelling GtoPdb uses across the bulk files.
The files disagree: ligands.csv writes "yes" or an empty cell, while
interactions.csv writes "true"/"false" for both Approved and Primary
Target. Reading either one with the other's vocabulary makes the field
silently always False, which is what the retired REST implementation did
with its interaction `approved` check.
"""
return str(value or "").strip().lower() in {"yes", "true", "t", "1", "y"}
[docs]
def _as_number(value: Any) -> Any:
"""GtoPdb's REST service returned these as numbers; the CSV has strings.
Kept numeric so anything built against the old /molecularProperties shape
still gets 180.0422588 rather than "180.0422588".
"""
text = str(value or "").strip()
if not text:
return None
try:
return int(text)
except ValueError:
pass
try:
return float(text)
except ValueError:
return text
[docs]
def _limit(arguments: Dict[str, Any]) -> int:
requested = _as_int(arguments.get("limit") or arguments.get("max_results"))
if requested is None or requested <= 0:
return DEFAULT_LIMIT
return min(requested, MAX_LIMIT)
[docs]
def _page(arguments: Dict[str, Any], matches: List[Any], key: str) -> Dict[str, Any]:
"""Slice *matches* by limit/offset and say how to reach the rest.
The bulk file is already fully in hand, so every record past the first
page is reachable by offset rather than lost to the limit.
"""
limit = _limit(arguments)
offset = _as_int(arguments.get("offset"))
if offset is None or offset < 0:
offset = 0
page = matches[offset : offset + limit]
next_offset = offset + len(page)
has_more = next_offset < len(matches)
return {
"total_matches": len(matches),
"returned": len(page),
"offset": offset,
"limit": limit,
"has_more": has_more,
"next_offset": next_offset if has_more else None,
"result_set_complete": offset == 0 and not has_more,
key: page,
}
[docs]
def load_bulk(file_name: str, timeout: int = 60) -> List[Dict[str, str]]:
"""Download and parse one GtoPdb bulk CSV, once per process.
The first line is a version banner -- "# GtoPdb Version: 2026.3 -
published: 2026-09-16" -- quoted as a single CSV field, so it is dropped
before the header is read.
"""
cached = _BULK_CACHE.get(file_name)
if cached is not None:
return cached
with _BULK_LOCK:
cached = _BULK_CACHE.get(file_name)
if cached is not None:
return cached
session = requests.Session()
try:
response = request_with_retry(
session,
"GET",
f"{GTOPDB_BULK_BASE}/{file_name}",
timeout=timeout,
max_attempts=3,
)
finally:
session.close()
if response.status_code != 200:
raise RuntimeError(
f"GtoPdb bulk download {file_name} returned HTTP "
f"{response.status_code}"
)
text = response.text
first, newline, rest = text.partition("\n")
if first.lstrip('"').startswith("#"):
text = rest if newline else ""
rows = list(csv.DictReader(io.StringIO(text)))
_BULK_CACHE[file_name] = rows
return rows
[docs]
def clear_bulk_cache() -> None:
"""Drop the parsed files. For tests; a process otherwise keeps them."""
with _BULK_LOCK:
_BULK_CACHE.clear()
[docs]
def _relevance(record: Dict[str, Any], needle: str, *fields: str) -> tuple:
"""Rank an exact hit above a prefix hit above a substring hit.
Searching targets for "EGFR" matched PKR1 and PKR2 first, because GtoPdb
lists EGFR in their synonyms, and buried `epidermal growth factor receptor`
in third place. Searching ligands for "morphine" led with apomorphine.
Both results were right and both read as wrong.
"""
for rank, field in enumerate(fields):
value = _norm(record.get(field))
if not value:
continue
if value == needle:
return (0, rank, len(value))
if value.startswith(needle):
return (1, rank, len(value))
if needle in value:
return (2, rank, len(value))
return (3, len(fields), 0)
[docs]
def _target_record(row: Dict[str, str]) -> Dict[str, Any]:
return {
"targetId": _as_int(row.get("Target id")),
"name": _plain(row.get("Target name")),
"rawName": (row.get("Target name") or "").strip(),
"abbreviation": _plain(row.get("Target abbreviated name")),
"systematicName": _plain(row.get("Target systematic name")),
"type": (row.get("Type") or "").strip(),
"familyId": _as_int(row.get("Family id")),
"familyName": _plain(row.get("Family name")),
"geneSymbol": (row.get("HGNC symbol") or "").strip(),
"hgncId": (row.get("HGNC id") or "").strip(),
"synonyms": _plain(row.get("synonyms")),
}
[docs]
def _ligand_record(row: Dict[str, str]) -> Dict[str, Any]:
return {
"ligandId": _as_int(row.get("Ligand ID")),
"name": _plain(row.get("Name")),
"rawName": (row.get("Name") or "").strip(),
"type": (row.get("Type") or "").strip(),
"approved": _as_bool(row.get("Approved")),
"withdrawn": _as_bool(row.get("Withdrawn")),
"labelled": _as_bool(row.get("Labelled")),
"species": (row.get("Species") or "").strip(),
"pubchemCid": (row.get("PubChem CID") or "").strip(),
"chemblId": (row.get("ChEMBL ID") or "").strip(),
"uniprotId": (row.get("UniProt ID") or "").strip(),
# The retired /structure endpoint returned these four, and ligands.csv
# carries them with the same values -- checked against the recorded
# aspirin (4139) fixture: CC(=O)Oc1ccccc1C(=O)O and
# BSYNRYMUTXBXSQ-UHFFFAOYSA-N match exactly.
"iupacName": (row.get("IUPAC name") or "").strip(),
"inn": (row.get("INN") or "").strip(),
"smiles": (row.get("SMILES") or "").strip(),
"inchi": (row.get("InChI") or "").strip(),
"inchiKey": (row.get("InChIKey") or "").strip(),
"synonyms": _plain(row.get("Synonyms")),
}
[docs]
def _interaction_record(row: Dict[str, str]) -> Dict[str, Any]:
return {
"targetId": _as_int(row.get("Target ID")),
"targetName": _plain(row.get("Target")),
"targetGeneSymbol": (row.get("Target Gene Symbol") or "").strip(),
"targetUniprotId": (row.get("Target UniProt ID") or "").strip(),
"targetSpecies": (row.get("Target Species") or "").strip(),
"ligandId": _as_int(row.get("Ligand ID")),
"ligandName": _plain(row.get("Ligand")),
"ligandType": (row.get("Ligand Type") or "").strip(),
"approvedLigand": _as_bool(row.get("Approved")),
"type": (row.get("Type") or "").strip(),
"action": (row.get("Action") or "").strip(),
"affinityUnits": (row.get("Affinity Units") or "").strip(),
"affinityHigh": (row.get("Affinity High") or "").strip(),
"affinityMedian": (row.get("Affinity Median") or "").strip(),
"affinityLow": (row.get("Affinity Low") or "").strip(),
"selectivity": (row.get("Selectivity") or "").strip(),
"primaryTarget": _as_bool(row.get("Primary Target")),
}
[docs]
@register_tool("GtoPdbRESTTool")
class GtoPdbRESTTool(BaseTool):
"""One GtoPdb lookup, served from the open bulk files.
The class name is kept because the tool configs and the static lazy
registry name it; the transport is no longer REST.
"""
[docs]
def __init__(self, tool_config: Dict):
super().__init__(tool_config)
self.timeout = tool_config.get("timeout", 60)
endpoint = (tool_config.get("fields") or {}).get("endpoint", "") or ""
self.operation = self._operation_for(endpoint, tool_config.get("name") or "")
[docs]
@staticmethod
def _operation_for(endpoint: str, name: str) -> str:
path = endpoint.rstrip("/")
if path.endswith("/ligandProperties") or "ligand_properties" in name.lower():
return "ligand_properties"
if path.endswith("/interactions") or "interactions" in name.lower():
return "interactions"
if path.endswith("/ligands") or "ligand" in name.lower():
return "search_ligands"
return "search_targets"
[docs]
def run(self, arguments: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Never raises; returns {status, data} or {status, error}."""
arguments = dict(arguments or {})
try:
if self.operation == "search_targets":
return self._search_targets(arguments)
if self.operation == "search_ligands":
return self._search_ligands(arguments)
if self.operation == "interactions":
return self._interactions(arguments)
return self._ligand_properties(arguments)
except requests.exceptions.Timeout:
return {
"status": "error",
"error": (
f"GtoPdb bulk download timed out after {self.timeout}s. The "
"files are a few MB each and are fetched once per session."
),
}
except requests.exceptions.RequestException as exc:
return {
"status": "error",
"error": f"Failed to reach the GtoPdb bulk downloads: {exc}",
}
except Exception as exc: # noqa: BLE001 - run() must never raise
return {"status": "error", "error": f"Unexpected GtoPdb error: {exc}"}
# ----------------------------------------------------------------- #
# Targets
# ----------------------------------------------------------------- #
[docs]
def _search_targets(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
query = arguments.get("name") or arguments.get("query")
gene_symbol = arguments.get("gene_symbol")
wanted_type = arguments.get("type")
if not query and not gene_symbol and wanted_type in (None, ""):
return {
"status": "error",
"error": (
"Give at least one of name, gene_symbol or type. GtoPdb has "
"3392 targets and returning all of them is not a search."
),
}
spec = None
if wanted_type not in (None, ""):
spec = _TARGET_TYPE_SPECS.get(_norm_type(wanted_type))
if spec is None:
return _type_validation_error(
wanted_type, _TARGET_TYPE_SPECS, {}, "target"
)
needle = _norm(query) if query else None
gene = _norm(gene_symbol) if gene_symbol else None
matches = []
for row in load_bulk(TARGETS_FILE, self.timeout):
record = _target_record(row)
if spec is not None and not spec.matches(record):
continue
if gene and _norm(record["geneSymbol"]) != gene:
continue
if needle and not any(
needle in _norm(record[field])
for field in ("name", "abbreviation", "systematicName", "synonyms")
):
continue
matches.append(record)
if needle:
matches.sort(
key=lambda record: _relevance(
record,
needle,
"geneSymbol",
"abbreviation",
"name",
"systematicName",
"synonyms",
)
)
payload = {
"source": f"{GTOPDB_BULK_BASE}/{TARGETS_FILE}",
**_page(arguments, matches, "targets"),
}
if spec is not None and not matches and spec.canonical == "AccessoryProtein":
payload["note"] = (
"targets_and_families.csv carries no accessory proteins: every "
"one of its 3392 rows has a Type, and accessory proteins were "
"the records whose type was empty. The retired REST service "
"returned 10 of them, so this is the one gap the bulk files "
"leave in target search."
)
return {"status": "success", "data": payload}
# ----------------------------------------------------------------- #
# Ligands
# ----------------------------------------------------------------- #
[docs]
def _search_ligands(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
query = arguments.get("name") or arguments.get("query")
wanted_type = arguments.get("type")
approved = arguments.get("approved")
if approved is None:
approved = arguments.get("approved_only")
if not query and wanted_type in (None, "") and not approved:
return {
"status": "error",
"error": (
"Give at least one of name, type or approved. GtoPdb has "
"13991 ligands and returning all of them is not a search."
),
}
spec = None
if wanted_type not in (None, ""):
spec = _LIGAND_TYPE_SPECS.get(_norm_type(wanted_type))
if spec is None:
return _type_validation_error(
wanted_type, _LIGAND_TYPE_SPECS, _LIGAND_TYPE_DEAD_VALUES, "ligand"
)
# approved is documented as "approved drugs only (true) or all ligands
# (false/omit)", so false is not a filter for unapproved ligands.
approved_only = bool(approved) is True
needle = _norm(query) if query else None
matches = []
for row in load_bulk(LIGANDS_FILE, self.timeout):
record = _ligand_record(row)
if needle and needle not in _norm(record["name"]):
continue
if spec is not None and not spec.matches(record):
continue
if approved_only and not record["approved"]:
continue
matches.append(record)
if needle:
matches.sort(key=lambda record: _relevance(record, needle, "name"))
payload = {
"source": f"{GTOPDB_BULK_BASE}/{LIGANDS_FILE}",
**_page(arguments, matches, "ligands"),
}
if spec is not None and not matches:
seen = {
_plain(row.get("Type"))
for row in load_bulk(LIGANDS_FILE, self.timeout)
if (row.get("Type") or "").strip()
}
payload["note"] = (
f"No ligand of type {wanted_type!r}. GtoPdb uses: "
+ ", ".join(sorted(seen))
)
return {"status": "success", "data": payload}
# ----------------------------------------------------------------- #
# Interactions
# ----------------------------------------------------------------- #
[docs]
def _interactions(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
target_id = _as_int(arguments.get("targetId") or arguments.get("target_id"))
ligand_id = _as_int(arguments.get("ligandId") or arguments.get("ligand_id"))
gene_symbol = arguments.get("gene_symbol")
species = arguments.get("species")
if target_id is None and ligand_id is None and not gene_symbol:
return {
"status": "error",
"error": (
"Give one of targetId, ligandId or gene_symbol. GtoPdb has "
"24936 interactions and returning all of them is not a query."
),
}
# GtoPdb spells species "Human", "Mouse", "Rat", "SARS-CoV-2".
species_needle = _norm(species) if species else None
gene = _norm(gene_symbol) if gene_symbol else None
matches = []
for row in load_bulk(INTERACTIONS_FILE, self.timeout):
record = _interaction_record(row)
if target_id is not None and record["targetId"] != target_id:
continue
if ligand_id is not None and record["ligandId"] != ligand_id:
continue
if gene and _norm(record["targetGeneSymbol"]) != gene:
continue
if species_needle and _norm(record["targetSpecies"]) != species_needle:
continue
matches.append(record)
payload = {
"source": f"{GTOPDB_BULK_BASE}/{INTERACTIONS_FILE}",
**_page(arguments, matches, "interactions"),
}
if ligand_id is not None and not matches:
payload["note"] = (
f"GtoPdb records no interaction for ligand {ligand_id}. The bulk "
"interactions file covers 11278 of the 13991 ligands -- an "
"approved drug can be absent from it, oxycodone (7093) among "
"them -- so this is 'nothing published here', not 'no such "
"ligand'. GtoPdb_search_ligands confirms the ligand exists. Two "
"things to try: GtoPdb sometimes files a drug's pharmacology "
"under a related research-compound record, so check the ligand's "
"activeDrugIds and prodrugIds and query those; and GtoPdb indexes "
"interactions by target, so searching by targetId finds data that "
"a ligand lookup misses."
)
if gene and not matches:
payload["note"] = (
f"No interaction lists {gene_symbol!r} as its target gene symbol. "
"GtoPdb records interactions against curated targets, so a gene "
"with no ligand in the database has none here. For approved drugs "
"and clinical compounds, run "
f"ChEMBL_search_targets(pref_name__contains='{gene_symbol}') to "
"get a target_chembl_id, then "
"ChEMBL_search_activities(target_chembl_id=...)."
)
elif matches and not any(item["approvedLigand"] for item in matches):
# The retired implementation emitted this caveat on every non-empty
# result, because the field it wanted to test was not on an
# interaction object. interactions.csv carries Approved, so it can
# be said only when it is true: 4576 of the 24936 rows involve an
# approved ligand.
target = gene_symbol or f"target {target_id}"
payload["note"] = (
"Every interaction here involves a research compound rather than "
"an approved drug. For approved drugs and clinical compounds, run "
f"ChEMBL_search_targets(pref_name__contains='{target}') to get a "
"target_chembl_id, then "
"ChEMBL_search_activities(target_chembl_id=...)."
)
return {"status": "success", "data": payload}
# ----------------------------------------------------------------- #
# Ligand properties
# ----------------------------------------------------------------- #
[docs]
def _ligand_properties(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
ligand_id = _as_int(arguments.get("ligand_id") or arguments.get("ligandId"))
if ligand_id is None:
return {
"status": "error",
"error": "ligand_id is required (GtoPdb ligand ID, e.g. 4139).",
}
properties = None
for row in load_bulk(PHYSCHEM_FILE, self.timeout):
if _as_int(row.get("Ligand ID")) == ligand_id:
# Names and numeric types follow the retired
# /molecularProperties response, not the CSV column headings,
# so callers written against the REST shape keep working.
properties = {
"hydrogenBondAcceptors": _as_number(row.get("HBond Acceptors")),
"hydrogenBondDonors": _as_number(row.get("HBond Donors")),
"rotatableBonds": _as_number(row.get("Rotatable Bonds")),
"topologicalPolarSurfaceArea": _as_number(row.get("TPSA")),
"molecularWeight": _as_number(row.get("Mol Weight")),
"logP": _as_number(row.get("XLogP")),
"lipinskisRuleOfFive": _as_number(row.get("LipinskiRO5")),
}
break
identity = None
for row in load_bulk(LIGANDS_FILE, self.timeout):
if _as_int(row.get("Ligand ID")) == ligand_id:
identity = _ligand_record(row)
break
if properties is None and identity is None:
return {
"status": "error",
"error": f"GtoPdb has no ligand {ligand_id}.",
}
if properties is None:
return {
"status": "success",
"data": {
"source": f"{GTOPDB_BULK_BASE}/{PHYSCHEM_FILE}",
"ligandId": ligand_id,
"ligand": identity,
"properties": None,
"note": (
"GtoPdb lists this ligand but publishes no "
"physicochemical properties for it. Of 13991 ligands, "
"11266 have a row in the properties file -- peptides "
"and antibodies mostly do not."
),
},
}
return {
"status": "success",
"data": {
"source": f"{GTOPDB_BULK_BASE}/{PHYSCHEM_FILE}",
"ligandId": ligand_id,
"ligand": identity,
"properties": properties,
},
}