"""ChEMBL molecule-search client for resolving drug names to ChEMBL IDs and metadata.
Provides :class:`ChEMBLMoleculeSearch` and related clients plus :func:`return_chembl_id`
to map drug names/synonyms to ChEMBL identifiers and molecule records.
"""
import logging
from dataclasses import dataclass, field
from mkt.databases.api_schema import RESTAPIClient
from mkt.databases.requests_wrapper import get_cached_session
logger = logging.getLogger(__name__)
[docs]
@dataclass
class ChEMBL(RESTAPIClient):
"""ChEMBL API client."""
id: str
"""ID for querying specific entities."""
url_suffix: str
"""URL suffix to update for specific queries."""
url_base: str = "https://www.ebi.ac.uk/chembl/api/data"
"""Base URL for the ChEMBL API."""
url_query: str | None = None
"""URL query for specific queries."""
params: dict = field(default_factory=lambda: {"q": "<ID>", "format": "json"})
"""Parameters for the API query."""
verbose: bool = False
"""Flag to enable verbose logging."""
_json: dict | None = None
"""JSON response from the API query."""
def __post_init__(self):
"""Initialize the ChEMBL API client."""
self.url_query = f"{self.url_base}{self.url_suffix}"
self.params = {k: v.replace("<ID>", self.id) for k, v in self.params.items()}
self.query_api()
[docs]
def query_api(self):
"""Query the ChEMBL API for a given URL."""
if self.url_query is None:
logger.error("URL query is not set. Please update the URL before querying.")
return
if self.params:
res = get_cached_session().get(self.url_query, params=self.params)
else:
res = get_cached_session().get(self.url_query)
self._stamp_from_response(res)
if res.ok:
self._json = res.json()
[docs]
def update_url(self, url: str) -> None:
"""Update the URL for querying the ChEMBL API."""
self.url_query = f"{self.url_base}{self.url.suffix}"
[docs]
def update_params(self, **kwargs) -> None:
"""Update the parameters for the API query."""
self.params.update(kwargs)
[docs]
def check_molecules(self) -> str | None:
"""Check that a single molecule is returned for the query."""
if self._json is not None and "molecules" in self._json:
if len(self._json["molecules"]) == 0:
if self.verbose:
logger.error(f"No molecules found in the response for {self.id}.")
return None
if len(self._json["molecules"]) > 1:
if self.verbose:
logger.warning(
f"Multiple molecules found for {self.id}. Returning the first one."
)
else:
if self.verbose:
logger.error(f"No molecules found in the response for {self.id}.")
return None
[docs]
def get_chembl_id(self) -> str | None:
"""Get the ChEMBL ID for the queried molecule."""
self.check_molecules()
return [i["molecule_chembl_id"] for i in self._json["molecules"]]
[docs]
@dataclass
class ChEMBLMoleculeSearch(ChEMBL):
"""ChEMBL molecule search API client."""
url_suffix: str = "/molecule/search"
"""URL suffix for querying molecule search in ChEMBL."""
[docs]
@dataclass
class ChEMBLMoleculeExact(ChEMBL):
"""ChEMBL molecule exact match API client."""
url_suffix: str = "/molecule"
"""URL suffix for querying exact molecule match in ChEMBL."""
params: dict = field(
default_factory=lambda: {
"molecule_synonyms__molecule_synonym__iexact": "<ID>",
"format": "json",
}
)
"""Parameters for the molecule exact match API query."""
[docs]
@dataclass
class ChEMBLMoleculePreferred(ChEMBL):
"""ChEMBL molecule preferred match API client."""
url_suffix: str = "/molecule"
"""URL suffix for querying exact molecule match in ChEMBL."""
params: dict = field(
default_factory=lambda: {"pref_name__iexact": "<ID>", "format": "json"}
)
"""Parameters for the molecule preferred match API query."""
[docs]
def return_chembl_id(drug: str):
"""Return the ChEMBL ID for a given drug name.
Parameters
----------
drug : str
The name of the drug to search for in ChEMBL.
Returns
-------
tuple
A tuple containing the ChEMBL ID and the source of the ID (exact, preferred, or search);
if no ID is found, returns (None, None).
"""
chembl_id = ChEMBLMoleculeExact(id=drug).get_chembl_id()
source = "exact"
# if None try preferred match
if chembl_id == [] or chembl_id is None:
chembl_id = ChEMBLMoleculePreferred(id=drug).get_chembl_id()
source = "preferred"
# if still None, do search
if chembl_id == [] or chembl_id is None:
chembl_id = ChEMBLMoleculeSearch(id=drug, verbose=True).get_chembl_id()
source = "search"
# if still None, return None
if chembl_id == [] or chembl_id is None:
logger.error(f"No ChEMBL ID found for {drug}.")
chembl_id, source = None, None
return chembl_id, source
[docs]
@dataclass
class ChEMBLMolecule(ChEMBL):
"""ChEMBL molecule API client."""
url_suffix: str = "/molecule"
"""URL suffix for querying exact molecule match in ChEMBL."""
params: dict = field(
default_factory=lambda: {
"molecule_chembl_id": "<ID>",
"format": "json",
}
)
"""Parameters for the molecule API query."""
[docs]
def return_smiles(self) -> str | None:
"""Return the SMILES string for the queried molecule."""
self.check_molecules()
return (
self._json["molecules"][0]
.get("molecule_structures", {})
.get("canonical_smiles", None)
)
[docs]
def return_preferred_name(self) -> str | None:
"""Return the preferred name for the queried molecule."""
self.check_molecules()
return self._json["molecules"][0].get("pref_name", None)
[docs]
def adjudicate_preferred_name(self, str_in: str | None = None) -> str:
"""Return the adjudicated preferred name for the queried molecule.
Parameters
----------
str_in : str | None
The input string to adjudicate the preferred name. If None, will not compare to input string.
Returns
-------
str
The adjudicated preferred name or the original ID if no preferred name is found or matches the input string.
"""
preferred_name = self.return_preferred_name()
if preferred_name is not None:
# prefer INN to internal identifier
if str_in is not None:
if "-" in str_in and "-" not in preferred_name:
return preferred_name.title()
elif "-" in preferred_name and "-" not in str_in:
return str_in
else:
if "-" in preferred_name:
return preferred_name
else:
return preferred_name.title()
return preferred_name.title()
else:
if str_in is not None:
return str_in.title()
else:
logger.error(
f"No preferred name found for {self.id}. Returning original ID."
)
return self.id.upper()