diff --git a/CHANGELOG.md b/CHANGELOG.md index d84b0bc..f197169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +- Added a `ChEBI` web converter querying the ChEBI service (https://www.ebi.ac.uk/chebi/) + - Supports `compound_name`, `iupac_name`, `inchikey`, `inchi`, `smiles`, and `chebiid` as source attributes and returns `chebiid`, `compound_name`, `iupac_name`, `inchikey`, `inchi`, `smiles`, and `formula` + - Structure search from SMILES uses the ChEBI `structure_search` endpoint with `type=similarity` and a similarity threshold of 1 + ## [0.6.1] - 2026-10-22 ### Added diff --git a/MSMetaEnhancer/libs/converters/web/ChEBI.py b/MSMetaEnhancer/libs/converters/web/ChEBI.py new file mode 100644 index 0000000..8240e3f --- /dev/null +++ b/MSMetaEnhancer/libs/converters/web/ChEBI.py @@ -0,0 +1,283 @@ +import json +from urllib.parse import quote + +from frozendict import frozendict + +from MSMetaEnhancer.libs.converters.web.WebConverter import WebConverter + + +class ChEBI(WebConverter): + """ + ChEBI (Chemical Entities of Biological Interest) is a freely available dictionary of + molecular entities focused on small chemical compounds. + + ChEBI service: https://www.ebi.ac.uk/chebi/ + API documentation: https://www.ebi.ac.uk/chebi/backend/api/docs/ + + Identifiers and names are resolved by the `es_search` endpoint, structures by the + `structure_search` endpoint. Both only return the fields of a compound summary, so the + obtained accession is used to retrieve the whole record from the `compound` endpoint, + which is the only one reporting the IUPAC name. + """ + + SEARCH_SIZE = 15 + """Number of search results requested from the ChEBI search endpoints.""" + + IDENTIFIER_FIELDS = ("chebi_accession", "inchikey", "inchi", "smiles") + """Fields used to positively identify a search result by the searched identifier.""" + + NAME_FIELDS = ("ascii_name", "name") + """Fields used to positively identify a search result by the searched name.""" + + def __init__(self, session): + super().__init__(session) + # service URLs + self.endpoints = { + "ChEBI": "https://www.ebi.ac.uk/chebi/backend/api/public/", + } + + # attributes as reported by the compound endpoint + self.attributes = [ + {"code": "chebiid", "paths": [("chebi_accession",)]}, + {"code": "compound_name", "paths": [("ascii_name",), ("name",)]}, + {"code": "formula", "paths": [("chemical_data", "formula")]}, + {"code": "smiles", "paths": [("default_structure", "smiles")]}, + {"code": "inchi", "paths": [("default_structure", "standard_inchi")]}, + { + "code": "inchikey", + "paths": [("default_structure", "standard_inchi_key")], + }, + ] + + # generate top level methods defining allowed conversions + conversions = [ + ("compound_name", "chebiid", "from_name"), + ("compound_name", "iupac_name", "from_name"), + ("compound_name", "inchikey", "from_name"), + ("compound_name", "inchi", "from_name"), + ("compound_name", "smiles", "from_name"), + ("compound_name", "formula", "from_name"), + ("iupac_name", "chebiid", "from_iupac_name"), + ("iupac_name", "compound_name", "from_iupac_name"), + ("iupac_name", "inchikey", "from_iupac_name"), + ("iupac_name", "inchi", "from_iupac_name"), + ("iupac_name", "smiles", "from_iupac_name"), + ("iupac_name", "formula", "from_iupac_name"), + ("inchikey", "chebiid", "from_inchikey"), + ("inchikey", "compound_name", "from_inchikey"), + ("inchikey", "iupac_name", "from_inchikey"), + ("inchikey", "inchi", "from_inchikey"), + ("inchikey", "smiles", "from_inchikey"), + ("inchikey", "formula", "from_inchikey"), + ("inchi", "chebiid", "from_inchi"), + ("inchi", "compound_name", "from_inchi"), + ("inchi", "iupac_name", "from_inchi"), + ("inchi", "inchikey", "from_inchi"), + ("inchi", "smiles", "from_inchi"), + ("inchi", "formula", "from_inchi"), + ("smiles", "chebiid", "from_smiles"), + ("smiles", "compound_name", "from_smiles"), + ("smiles", "iupac_name", "from_smiles"), + ("smiles", "inchikey", "from_smiles"), + ("smiles", "inchi", "from_smiles"), + ("smiles", "formula", "from_smiles"), + ("chebiid", "compound_name", "from_chebiid"), + ("chebiid", "iupac_name", "from_chebiid"), + ("chebiid", "inchikey", "from_chebiid"), + ("chebiid", "inchi", "from_chebiid"), + ("chebiid", "smiles", "from_chebiid"), + ("chebiid", "formula", "from_chebiid"), + ] + self.create_top_level_conversion_methods(conversions) + + async def from_name(self, name): + """ + Convert compound name to all possible attributes using ChEBI service. + + Only compounds reported under the given name by ChEBI are accepted, common names + present in ChEBI merely as synonyms are not resolved by the search endpoint. + + :param name: given compound name + :return: all found data + """ + return await self._from_es_search(name) + + async def from_iupac_name(self, iupac_name): + """ + Convert IUPAC name to all possible attributes using ChEBI service. + + :param iupac_name: given IUPAC name + :return: all found data + """ + return await self._from_es_search(iupac_name, by_term=False) + + async def from_inchikey(self, inchikey): + """ + Convert InChIKey to all possible attributes using ChEBI service. + + :param inchikey: given InChIKey + :return: all found data + """ + return await self._from_es_search(inchikey) + + async def from_inchi(self, inchi): + """ + Convert InChI to all possible attributes using ChEBI service. + + Only standard InChIs are accepted by the ChEBI search endpoint. + + :param inchi: given InChI string + :return: all found data + """ + return await self._from_es_search(inchi) + + async def _from_es_search(self, term, by_term=True): + """ + Search ChEBI for the given term and return the attributes of the matching compound. + + :param term: search term (name, IUPAC name, InChIKey or InChI) + :param by_term: whether the reported fields of a result are compared to the searched + term; IUPAC names are not reported by the search endpoint, so searches by IUPAC + name accept a single unambiguous result instead + :return: all found data + """ + args = f"es_search/?term={quote(term)}&page=1&size={self.SEARCH_SIZE}" + response = await self.query_the_service("ChEBI", args) + if not response: + return None + chebiid = self.extract_chebiid(response, term if by_term else None) + if chebiid is None: + return None + return await self.from_chebiid(chebiid) + + async def from_smiles(self, smiles): + """ + Convert SMILES to all possible attributes using ChEBI structure search service. + + ChEBI scores the submitted structure without re-canonicalising it, so the similarity + threshold of 1 accepts structures that ChEBI reports in the submitted form. + + :param smiles: given SMILES string + :return: all found data + """ + data = frozendict({"structure": smiles, "type": "similarity", "similarity": 1}) + response = await self.query_the_service( + "ChEBI", "structure_search/", method="POST", data=data + ) + if not response: + return None + chebiid = self.extract_chebiid(response) + if chebiid is None: + return None + return await self.from_chebiid(chebiid) + + async def from_chebiid(self, chebiid): + """ + Convert ChEBI ID to all possible attributes using ChEBI service. + + :param chebiid: given ChEBI ID (e.g. 'CHEBI:60888') + :return: all found data + """ + args = f"compound/{chebiid}/" + response = await self.query_the_service("ChEBI", args) + if response: + return self.parse_entity(response) + + def parse_entity(self, response): + """ + Parse attributes from a single ChEBI compound response. + + :param response: JSON string from /compound/{chebiId}/ endpoint + :return: dict of parsed attributes + """ + return self._extract_attributes(json.loads(response)) + + def extract_chebiid(self, response, term=None): + """ + Obtain the ChEBI accession of the search result corresponding to the searched term. + + ChEBI searches are ranked by relevance and report related compounds first (searching + for 'bapta' ranks '5,5'-dibromo-BAPTA above 'BAPTA'), so a result is accepted only + when it can be positively identified - either its reported accession, identifier or + name equals the searched term, or, when no term is given, the search returned a + single compound. + + :param response: JSON string from a ChEBI search endpoint + :param term: term that was used for the search, None to require a single result + :return: ChEBI accession of the matching compound or None + """ + entities = [ + result.get("_source", result) + for result in json.loads(response).get("results", []) + if isinstance(result, dict) + ] + entities = [entity for entity in entities if isinstance(entity, dict)] + if not entities: + return None + if term is None: + # structure searches are not ranked by a term, a single hit is required + return entities[0].get("chebi_accession") if len(entities) == 1 else None + searched = term.strip().casefold() + for field in self.IDENTIFIER_FIELDS + self.NAME_FIELDS: + for entity in entities: + value = entity.get(field) + if isinstance(value, str) and value.strip().casefold() == searched: + return entity.get("chebi_accession") + return None + + def _extract_attributes(self, entity): + """ + Extract known attributes from a ChEBI compound dict. + + :param entity: dict representing a ChEBI compound + :return: dict of parsed attributes + """ + if not isinstance(entity, dict): + return None + result = {} + for att in self.attributes: + value = self._get_first_value(entity, att["paths"]) + if value is not None: + result[att["code"]] = value + iupac_name = self._extract_iupac_name(entity) + if iupac_name is not None: + result["iupac_name"] = iupac_name + return result if result else None + + @staticmethod + def _extract_iupac_name(entity): + """ + Extract the IUPAC name from the names of a ChEBI compound. + + :param entity: dict representing a ChEBI compound + :return: IUPAC name string or None + """ + names = entity.get("names") + if not isinstance(names, dict): + return None + for entry in names.get("IUPAC NAME", []): + if isinstance(entry, dict): + value = entry.get("ascii_name") or entry.get("name") + if value: + return value + return None + + def _get_first_value(self, entity, paths): + """ + Return the first non-empty value found in the given candidate paths. + Paths that yield no value are skipped and the following ones are tried. + + :param entity: dict representing a ChEBI compound + :param paths: candidate key paths to try + :return: first non-empty value or None + """ + for path in paths: + value = entity + for key in path: + if not isinstance(value, dict): + value = None + break + value = value.get(key) + if value: + return value + return None diff --git a/MSMetaEnhancer/libs/converters/web/__init__.py b/MSMetaEnhancer/libs/converters/web/__init__.py index 8b86930..d4d87d1 100644 --- a/MSMetaEnhancer/libs/converters/web/__init__.py +++ b/MSMetaEnhancer/libs/converters/web/__init__.py @@ -3,6 +3,7 @@ from MSMetaEnhancer.libs.converters.web.CIR import CIR from MSMetaEnhancer.libs.converters.web.PubChem import PubChem from MSMetaEnhancer.libs.converters.web.BridgeDb import BridgeDb +from MSMetaEnhancer.libs.converters.web.ChEBI import ChEBI from MSMetaEnhancer.libs.converters.web.Wikidata import Wikidata -__all__ = ["IDSM", "CTS", "CIR", "PubChem", "BridgeDb", "Wikidata"] +__all__ = ["IDSM", "CTS", "CIR", "PubChem", "BridgeDb", "Wikidata", "ChEBI"] diff --git a/README.md b/README.md index 3db0baa..d37dd66 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![DOI](https://joss.theoj.org/papers/10.21105/joss.04494/status.svg)](https://doi.org/10.21105/joss.04494) **MSMetaEnhancer** is a tool used for `.msp` files annotation. -It adds metadata like SMILES, InChI, and CAS number fetched from the following services: [CIR](https://cactus.nci.nih.gov/chemical/structure_documentation), [CTS](https://cts.fiehnlab.ucdavis.edu/), [PubChem](https://pubchem.ncbi.nlm.nih.gov/), [IDSM](https://idsm.elixir-czech.cz/), [BridgeDb](https://bridgedb.github.io/), and [Wikidata](https://www.wikidata.org/). +It adds metadata like SMILES, InChI, and CAS number fetched from the following services: [CIR](https://cactus.nci.nih.gov/chemical/structure_documentation), [CTS](https://cts.fiehnlab.ucdavis.edu/), [PubChem](https://pubchem.ncbi.nlm.nih.gov/), [IDSM](https://idsm.elixir-czech.cz/), [BridgeDb](https://bridgedb.github.io/), [Wikidata](https://www.wikidata.org/), and [ChEBI](https://www.ebi.ac.uk/chebi/). The app uses asynchronous implementation of annotation process allowing for optimal fetching speed. If you use MSMetaEnhancer in your work, please cite the following publication: @@ -27,11 +27,12 @@ from MSMetaEnhancer.libs.converters.web import ( PubChem, BridgeDb, Wikidata, + ChEBI, ) from MSMetaEnhancer.libs.converters.compute import RDKit from MSMetaEnhancer.libs.utils.ConverterBuilder import ConverterBuilder -ConverterBuilder.register([CTS, CIR, IDSM, PubChem, BridgeDb, Wikidata, RDKit]) +ConverterBuilder.register([CTS, CIR, IDSM, PubChem, BridgeDb, Wikidata, ChEBI, RDKit]) app = Application() @@ -42,7 +43,7 @@ app.load_data('sample.msp', file_format='msp') app.curate_metadata() # specify requested services (these are supported) -services = ['CTS', 'CIR', 'IDSM', 'PubChem', 'BridgeDb', 'Wikidata', 'RDKit'] +services = ['CTS', 'CIR', 'IDSM', 'PubChem', 'BridgeDb', 'Wikidata', 'ChEBI', 'RDKit'] # specify requested jobs jobs = [('name', 'inchi', 'IDSM'), ('inchi', 'formula', 'IDSM'), ('inchi', 'inchikey', 'IDSM'), diff --git a/docs/source/MSMetaEnhancer.libs.webConverters.rst b/docs/source/MSMetaEnhancer.libs.webConverters.rst index 26ee3d5..2deca68 100644 --- a/docs/source/MSMetaEnhancer.libs.webConverters.rst +++ b/docs/source/MSMetaEnhancer.libs.webConverters.rst @@ -49,6 +49,14 @@ Wikidata :undoc-members: :show-inheritance: +ChEBI +----- + +.. automodule:: MSMetaEnhancer.libs.converters.web.ChEBI + :members: + :undoc-members: + :show-inheritance: + General Web Converter --------------------- diff --git a/galaxy/generate_options.py b/galaxy/generate_options.py index 5474747..cea4410 100644 --- a/galaxy/generate_options.py +++ b/galaxy/generate_options.py @@ -11,6 +11,7 @@ PubChem, BridgeDb, Wikidata, + ChEBI, ) from MSMetaEnhancer.libs.converters.compute import RDKit from MSMetaEnhancer.libs.utils.ConverterBuilder import ConverterBuilder @@ -20,7 +21,7 @@ def generate_options(): - ConverterBuilder.register([CTS, CIR, IDSM, PubChem, BridgeDb, Wikidata, RDKit]) + ConverterBuilder.register([CTS, CIR, IDSM, PubChem, BridgeDb, Wikidata, ChEBI, RDKit]) jobs = [] converters = web_converters + compute_converters diff --git a/tests/test_ChEBI.py b/tests/test_ChEBI.py new file mode 100644 index 0000000..f02f75a --- /dev/null +++ b/tests/test_ChEBI.py @@ -0,0 +1,503 @@ +import asyncio +import json +import pytest + +from rdkit.Chem import MolFromInchi, MolFromSmiles, MolToInchi, MolToSmiles +from rdkit.Chem.inchi import InchiToInchiKey + +from MSMetaEnhancer.libs.converters.web import ChEBI +from MSMetaEnhancer.libs.utils.Errors import UnknownResponse +from tests.utils import wrap_with_session + + +CHEBI_ID = "CHEBI:60888" +INCHIKEY = "FTEDXVNDVHYDQW-UHFFFAOYSA-N" +INCHI = "InChI=1S/C22H24N2O10/c25-19(26)11-23(12-20(27)28)15-5-1-3-7-17(15)33-9-10-34-18-8-4-2-6-16(18)24(13-21(29)30)14-22(31)32/h1-8H,9-14H2,(H,25,26)(H,27,28)(H,29,30)(H,31,32)" +COMPOUND_NAME = "bapta" +CHEBI_NAME = "BAPTA" +IUPAC_NAME = ( + "2,2',2'',2'''-[ethane-1,2-diylbis(oxy-2,1-phenylenenitrilo)]tetraacetic acid" +) +SMILES = "O=C(O)CN(CC(=O)O)c1ccccc1OCCOc1ccccc1N(CC(=O)O)CC(=O)O" +FORMULA = "C22H24N2O10" + + +# Responses below were recorded from the ChEBI API on 2026-10-29 and shortened to the +# fields the converter reads, the reported keys and values were not altered. + +ENTITY_RESPONSE = json.dumps( + { + "id": 60888, + "chebi_accession": CHEBI_ID, + "name": CHEBI_NAME, + "stars": 3, + "ascii_name": CHEBI_NAME, + "names": { + "SYNONYM": [ + { + "name": "1,2-Bis(2-aminophenoxy)ethane-N,N,N',N'-tetraacetic acid", + "status": "C", + "type": "SYNONYM", + "source": "ChemIDplus", + "ascii_name": "1,2-Bis(2-aminophenoxy)ethane-N,N,N',N'-tetraacetic acid", + "adapted": False, + "language_code": "en", + } + ], + "IUPAC NAME": [ + { + "name": IUPAC_NAME, + "status": "C", + "type": "IUPAC NAME", + "source": "IUPAC", + "ascii_name": IUPAC_NAME, + "adapted": False, + "language_code": "en", + } + ], + }, + "chemical_data": { + "formula": FORMULA, + "charge": 0, + "mass": "476.438", + "monoisotopic_mass": "476.14309", + }, + "default_structure": { + "id": 2706518, + "smiles": SMILES, + "standard_inchi": INCHI, + "standard_inchi_key": INCHIKEY, + "wurcs": None, + "is_r_group": False, + }, + "secondary_ids": [], + } +) + +SEARCH_BAPTA_RESPONSE = json.dumps( + { + "results": [ + { + "_index": "chebi-prod-compounds-29102026", + "_type": "_doc", + "_id": "60890", + "_score": 67.00641, + "_source": { + "chebi_accession": "CHEBI:60890", + "name": "5,5'-dibromo-BAPTA", + "ascii_name": "5,5'-dibromo-BAPTA", + "stars": 3, + "default_structure": 2706528, + "charge": 0, + "formula": "C22H22Br2N2O10", + "inchikey": "KCTRRYZOCJDOTC-UHFFFAOYSA-N", + }, + }, + { + "_index": "chebi-prod-compounds-29102026", + "_type": "_doc", + "_id": "60889", + "_score": 65.14058, + "_source": { + "chebi_accession": "CHEBI:60889", + "name": "5,5'-dimethyl-BAPTA", + "ascii_name": "5,5'-dimethyl-BAPTA", + "stars": 3, + "default_structure": 2706524, + "charge": 0, + "formula": "C24H28N2O10", + "inchikey": "BOMWLYNXTGNSSE-UHFFFAOYSA-N", + }, + }, + { + "_index": "chebi-prod-compounds-29102026", + "_type": "_doc", + "_id": "60888", + "_score": 35.251152, + "_source": { + "chebi_accession": CHEBI_ID, + "name": CHEBI_NAME, + "ascii_name": CHEBI_NAME, + "stars": 3, + "default_structure": 2706518, + "charge": 0, + "formula": FORMULA, + "inchikey": INCHIKEY, + }, + }, + ], + "total": 3, + "number_pages": 1, + } +) + +SEARCH_INCHIKEY_RESPONSE = json.dumps( + { + "results": [ + { + "_index": "chebi-prod-compounds-29102026", + "_type": "_doc", + "_id": "60888", + "_score": 11.740927, + "_source": { + "chebi_accession": CHEBI_ID, + "name": CHEBI_NAME, + "ascii_name": CHEBI_NAME, + "stars": 3, + "default_structure": 2706518, + "charge": 0, + "formula": FORMULA, + "inchikey": INCHIKEY, + "inchi": INCHI, + "smiles": SMILES, + }, + } + ], + "total": 1, + "number_pages": 1, + } +) + +SEARCH_IUPAC_RESPONSE = json.dumps( + { + "results": [ + { + "_index": "chebi-prod-compounds-29102026", + "_type": "_doc", + "_id": "60888", + "_score": 117.740105, + "_source": { + "chebi_accession": CHEBI_ID, + "name": CHEBI_NAME, + "ascii_name": CHEBI_NAME, + "stars": 3, + "default_structure": 2706518, + "charge": 0, + "formula": FORMULA, + "inchikey": INCHIKEY, + "inchi": INCHI, + "smiles": SMILES, + }, + } + ], + "total": 1, + "number_pages": 1, + } +) + +STRUCTURE_SEARCH_RESPONSE = json.dumps( + { + "results": [ + { + "_index": "chebi-prod-compounds-29102026", + "_type": "_doc", + "_id": "60888", + "_score": 1.0, + "_source": { + "chebi_accession": CHEBI_ID, + "name": CHEBI_NAME, + "ascii_name": CHEBI_NAME, + "stars": 3, + "default_structure": 2706518, + "charge": 0, + "formula": FORMULA, + "inchikey": INCHIKEY, + "inchi": INCHI, + "smiles": SMILES, + }, + } + ], + "total": 1, + "number_pages": 1, + } +) + + +@pytest.mark.dependency() +def test_service_available(): + asyncio.run(wrap_with_session(ChEBI, "chebiid_to_inchikey", [CHEBI_ID])) + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_chebiid_to_inchikey(): + result = asyncio.run(wrap_with_session(ChEBI, "chebiid_to_inchikey", [CHEBI_ID])) + assert result is not None + assert result["inchikey"] == INCHIKEY + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_chebiid_to_inchi(): + result = asyncio.run(wrap_with_session(ChEBI, "chebiid_to_inchi", [CHEBI_ID])) + assert result is not None + assert result["inchi"] == INCHI + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_chebiid_to_smiles(): + result = asyncio.run(wrap_with_session(ChEBI, "chebiid_to_smiles", [CHEBI_ID])) + assert result is not None + assert result["smiles"] == SMILES + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_chebiid_to_formula(): + result = asyncio.run(wrap_with_session(ChEBI, "chebiid_to_formula", [CHEBI_ID])) + assert result is not None + assert result["formula"] == FORMULA + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_chebiid_to_compound_name(): + result = asyncio.run( + wrap_with_session(ChEBI, "chebiid_to_compound_name", [CHEBI_ID]) + ) + assert result is not None + assert result["compound_name"] == CHEBI_NAME + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_chebiid_to_iupac_name(): + result = asyncio.run(wrap_with_session(ChEBI, "chebiid_to_iupac_name", [CHEBI_ID])) + assert result is not None + assert result["iupac_name"] == IUPAC_NAME + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_compound_name_to_chebiid(): + result = asyncio.run( + wrap_with_session(ChEBI, "compound_name_to_chebiid", [COMPOUND_NAME]) + ) + assert result is not None + assert result["chebiid"] == CHEBI_ID + assert result["compound_name"] == CHEBI_NAME + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_compound_name_to_iupac_name(): + result = asyncio.run( + wrap_with_session(ChEBI, "compound_name_to_iupac_name", [COMPOUND_NAME]) + ) + assert result is not None + assert result["iupac_name"] == IUPAC_NAME + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_inchikey_to_chebiid(): + result = asyncio.run(wrap_with_session(ChEBI, "inchikey_to_chebiid", [INCHIKEY])) + assert result is not None + assert result["chebiid"] == CHEBI_ID + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_inchikey_to_iupac_name(): + result = asyncio.run(wrap_with_session(ChEBI, "inchikey_to_iupac_name", [INCHIKEY])) + assert result is not None + assert result["iupac_name"] == IUPAC_NAME + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_inchi_to_chebiid(): + result = asyncio.run(wrap_with_session(ChEBI, "inchi_to_chebiid", [INCHI])) + assert result is not None + assert result["chebiid"] == CHEBI_ID + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_iupac_name_to_chebiid(): + result = asyncio.run( + wrap_with_session(ChEBI, "iupac_name_to_chebiid", [IUPAC_NAME]) + ) + assert result is not None + assert result["chebiid"] == CHEBI_ID + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_smiles_to_chebiid(): + result = asyncio.run(wrap_with_session(ChEBI, "smiles_to_chebiid", [SMILES])) + assert result is not None + assert result["chebiid"] == CHEBI_ID + assert result["inchikey"] == INCHIKEY + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_unknown_compound_raises(): + with pytest.raises(UnknownResponse): + asyncio.run( + wrap_with_session( + ChEBI, "from_name", ["not-a-compound-name-msmetaenhancer"] + ) + ) + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_format_search_response(): + args = f"es_search/?term={INCHIKEY}&page=1&size=15" + response = asyncio.run( + wrap_with_session(ChEBI, "query_the_service", ["ChEBI", args]) + ) + + assert isinstance(response, str) + data = json.loads(response) + assert {"results", "total", "number_pages"} <= data.keys() + assert "_source" in data["results"][0] + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_format_structure_search_response(): + from frozendict import frozendict + + data = frozendict({"structure": SMILES, "type": "similarity", "similarity": 1}) + response = asyncio.run( + wrap_with_session( + ChEBI, "query_the_service", ["ChEBI", "structure_search/", "POST", data] + ) + ) + + assert isinstance(response, str) + data = json.loads(response) + assert {"results", "total", "number_pages"} <= data.keys() + assert data["results"][0]["_source"]["chebi_accession"] == CHEBI_ID + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_format_entity_response(): + args = f"compound/{CHEBI_ID}/" + response = asyncio.run( + wrap_with_session(ChEBI, "query_the_service", ["ChEBI", args]) + ) + + assert isinstance(response, str) + data = json.loads(response) + assert data["chebi_accession"] == CHEBI_ID + assert data["names"]["IUPAC NAME"][0]["name"] == IUPAC_NAME + + +def test_parse_entity(): + parsed = ChEBI(None).parse_entity(ENTITY_RESPONSE) + + assert parsed == { + "chebiid": CHEBI_ID, + "compound_name": CHEBI_NAME, + "iupac_name": IUPAC_NAME, + "formula": FORMULA, + "smiles": SMILES, + "inchi": INCHI, + "inchikey": INCHIKEY, + } + + +def test_parse_entity_without_structures(): + response = json.dumps( + { + "id": 26649, + "chebi_accession": "CHEBI:26649", + "name": "serine derivative", + "ascii_name": "serine derivative", + "stars": 3, + "names": { + "SYNONYM": [ + { + "name": "serine derivatives", + "status": "C", + "type": "SYNONYM", + "source": "ChEBI", + "ascii_name": "serine derivatives", + "adapted": False, + "language_code": "en", + } + ] + }, + "chemical_data": None, + "default_structure": None, + "secondary_ids": [], + } + ) + + parsed = ChEBI(None).parse_entity(response) + + assert parsed == { + "chebiid": "CHEBI:26649", + "compound_name": "serine derivative", + } + + +def test_extract_chebiid_prefers_matching_name(): + # ChEBI ranks '5,5'-dibromo-BAPTA highest for the term 'bapta' + assert ChEBI(None).extract_chebiid(SEARCH_BAPTA_RESPONSE, COMPOUND_NAME) == CHEBI_ID + + +def test_extract_chebiid_matching_identifier(): + assert ChEBI(None).extract_chebiid(SEARCH_INCHIKEY_RESPONSE, INCHIKEY) == CHEBI_ID + + +def test_extract_chebiid_matching_identifier_from_multiple_results(): + assert ChEBI(None).extract_chebiid(SEARCH_BAPTA_RESPONSE, INCHIKEY) == CHEBI_ID + + +def test_extract_chebiid_rejects_unmatched_multiple_results(): + # related compounds are reported for terms ChEBI does not know as names + assert ChEBI(None).extract_chebiid(SEARCH_BAPTA_RESPONSE, "bapta acid") is None + + +def test_extract_chebiid_rejects_other_identifier(): + assert ( + ChEBI(None).extract_chebiid( + SEARCH_INCHIKEY_RESPONSE, "UNSRVVFXOFNFJF-UHFFFAOYSA-N" + ) + is None + ) + + +def test_extract_chebiid_accepts_single_unmatched_result(): + # IUPAC names are not reported by the search endpoint, a single result identifies them + assert ChEBI(None).extract_chebiid(SEARCH_IUPAC_RESPONSE) == CHEBI_ID + + +def test_extract_chebiid_without_term_requires_single_result(): + assert ChEBI(None).extract_chebiid(SEARCH_BAPTA_RESPONSE) is None + + +def test_extract_chebiid_from_structure_search_response(): + assert ChEBI(None).extract_chebiid(STRUCTURE_SEARCH_RESPONSE) == CHEBI_ID + + +def test_extract_chebiid_without_results(): + assert ChEBI(None).extract_chebiid(json.dumps({"results": [], "total": 0})) is None + + +def test_get_first_value_skips_unusable_paths(): + converter = ChEBI(None) + entity = {"default_structure": 2706518, "name": CHEBI_NAME} + paths = [("default_structure", "smiles"), ("name",)] + + assert converter._get_first_value(entity, paths) == CHEBI_NAME + + +def test_get_first_value_missing_paths(): + converter = ChEBI(None) + + assert ( + converter._get_first_value({"name": None}, [("name",), ("ascii_name",)]) is None + ) + assert converter._get_first_value({}, [("default_structure", "smiles")]) is None + + +def test_reference_data_describes_single_compound(): + # the constants above are recorded from ChEBI and must describe one structure + mol = MolFromSmiles(SMILES) + assert MolToSmiles(mol) == MolToSmiles(MolFromInchi(INCHI)) + assert MolToInchi(MolFromSmiles(SMILES)) == INCHI + assert InchiToInchiKey(INCHI) == INCHIKEY + + +def test_get_conversions(): + jobs = ChEBI(None).get_conversion_functions() + assert ("chebiid", "inchikey", "ChEBI") in jobs + assert ("inchikey", "chebiid", "ChEBI") in jobs + assert ("compound_name", "chebiid", "ChEBI") in jobs + assert ("compound_name", "iupac_name", "ChEBI") in jobs + assert ("chebiid", "iupac_name", "ChEBI") in jobs + assert ("inchikey", "iupac_name", "ChEBI") in jobs + assert ("iupac_name", "chebiid", "ChEBI") in jobs + assert ("inchi", "chebiid", "ChEBI") in jobs + assert ("smiles", "chebiid", "ChEBI") in jobs