From 558a1b2a5aecb82b7414c7f2df0791c8f0832b4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:28:34 +0000 Subject: [PATCH 01/17] Initial plan From e07a8fd9e4ac7d21d3121f667bbad17782973e1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:33:37 +0000 Subject: [PATCH 02/17] Add ChEBI web service converter Co-authored-by: hechth <12066490+hechth@users.noreply.github.com> --- MSMetaEnhancer/libs/converters/web/ChEBI.py | 159 ++++++++++++++++++ .../libs/converters/web/__init__.py | 3 +- tests/test_ChEBI.py | 87 ++++++++++ 3 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 MSMetaEnhancer/libs/converters/web/ChEBI.py create mode 100644 tests/test_ChEBI.py diff --git a/MSMetaEnhancer/libs/converters/web/ChEBI.py b/MSMetaEnhancer/libs/converters/web/ChEBI.py new file mode 100644 index 0000000..760e56b --- /dev/null +++ b/MSMetaEnhancer/libs/converters/web/ChEBI.py @@ -0,0 +1,159 @@ +import json + +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/ + """ + + def __init__(self, session): + super().__init__(session) + # service URLs + self.endpoints = { + "ChEBI": "https://www.ebi.ac.uk/chebi/backend/api/", + } + + self.attributes = [ + {"code": "chebiid", "label": "chebiId"}, + {"code": "compound_name", "label": "chebiAsciiName"}, + {"code": "inchikey", "label": "inchiKey"}, + {"code": "inchi", "label": "inchi"}, + {"code": "smiles", "label": "smiles"}, + {"code": "formula", "label": "formula"}, + ] + + # generate top level methods defining allowed conversions + conversions = [ + ("compound_name", "chebiid", "from_name"), + ("compound_name", "inchikey", "from_name"), + ("compound_name", "inchi", "from_name"), + ("compound_name", "smiles", "from_name"), + ("compound_name", "formula", "from_name"), + ("inchikey", "chebiid", "from_inchikey"), + ("inchikey", "compound_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", "inchikey", "from_inchi"), + ("inchi", "smiles", "from_inchi"), + ("inchi", "formula", "from_inchi"), + ("smiles", "chebiid", "from_smiles"), + ("smiles", "compound_name", "from_smiles"), + ("smiles", "inchikey", "from_smiles"), + ("smiles", "inchi", "from_smiles"), + ("smiles", "formula", "from_smiles"), + ("chebiid", "compound_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. + + :param name: given compound name + :return: all found data + """ + args = f"search?query={name}&searchCategory=CHEBI_NAME&maximumResults=10&stars=ALL" + return await self.call_service(args) + + async def from_inchikey(self, inchikey): + """ + Convert InChIKey to all possible attributes using ChEBI service. + + :param inchikey: given InChIKey + :return: all found data + """ + args = f"search?query={inchikey}&searchCategory=INCHI_KEY&maximumResults=10&stars=ALL" + return await self.call_service(args) + + async def from_inchi(self, inchi): + """ + Convert InChI to all possible attributes using ChEBI service. + + :param inchi: given InChI string + :return: all found data + """ + args = f"search?query={inchi}&searchCategory=INCHI&maximumResults=10&stars=ALL" + return await self.call_service(args) + + async def from_smiles(self, smiles): + """ + Convert SMILES to all possible attributes using ChEBI service. + + :param smiles: given SMILES string + :return: all found data + """ + args = f"search?query={smiles}&searchCategory=SMILES&maximumResults=10&stars=ALL" + return await self.call_service(args) + + async def from_chebiid(self, chebiid): + """ + Convert ChEBI ID to all possible attributes using ChEBI service. + + :param chebiid: given ChEBI ID (e.g. 'CHEBI:15422') + :return: all found data + """ + args = f"chemicalentity/{chebiid}" + response = await self.query_the_service("ChEBI", args) + if response: + return self.parse_entity(response) + + async def call_service(self, args): + """ + General method to call ChEBI search service. + + :param args: URL suffix with query arguments + :return: obtained attributes from the first result + """ + response = await self.query_the_service("ChEBI", args) + if response: + return self.parse_search_results(response) + + def parse_entity(self, response): + """ + Parse attributes from a single ChEBI entity response. + + :param response: JSON string from /chemicalentity/{chebiId} endpoint + :return: dict of parsed attributes + """ + entity = json.loads(response) + return self._extract_attributes(entity) + + def parse_search_results(self, response): + """ + Parse attributes from the first result of a ChEBI search response. + + :param response: JSON string from /search endpoint + :return: dict of parsed attributes from the first result + """ + response_json = json.loads(response) + results = response_json.get("priceSearchList", []) + if not results: + return None + return self._extract_attributes(results[0]) + + def _extract_attributes(self, entity): + """ + Extract known attributes from a ChEBI entity dict. + + :param entity: dict representing a ChEBI entity + :return: dict of parsed attributes + """ + result = {} + for att in self.attributes: + value = entity.get(att["label"]) + if value: + result[att["code"]] = value + return result if result else None diff --git a/MSMetaEnhancer/libs/converters/web/__init__.py b/MSMetaEnhancer/libs/converters/web/__init__.py index 76e1e31..61fb420 100644 --- a/MSMetaEnhancer/libs/converters/web/__init__.py +++ b/MSMetaEnhancer/libs/converters/web/__init__.py @@ -3,5 +3,6 @@ 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 -__all__ = ["IDSM", "CTS", "CIR", "PubChem", "BridgeDb"] +__all__ = ["IDSM", "CTS", "CIR", "PubChem", "BridgeDb", "ChEBI"] diff --git a/tests/test_ChEBI.py b/tests/test_ChEBI.py new file mode 100644 index 0000000..d3cf4db --- /dev/null +++ b/tests/test_ChEBI.py @@ -0,0 +1,87 @@ +import asyncio +import pytest + +from MSMetaEnhancer.libs.converters.web import ChEBI +from tests.utils import wrap_with_session + + +CHEBI_ID = "CHEBI:15422" +INCHIKEY = "ZKHQWZAMYRWXGA-KQYNXXCUSA-N" + + +@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 "inchikey" in result + assert result["inchikey"] == INCHIKEY + + +@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 "smiles" in result + + +@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 "inchi" in result + + +@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 "formula" in result + + +@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 "chebiid" in result + assert result["chebiid"] == CHEBI_ID + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_format_search_response(): + args = f"search?query={INCHIKEY}&searchCategory=INCHI_KEY&maximumResults=10&stars=ALL" + response = asyncio.run( + wrap_with_session(ChEBI, "query_the_service", ["ChEBI", args]) + ) + + assert isinstance(response, str) + import json + data = json.loads(response) + assert "priceSearchList" in data + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_format_entity_response(): + args = f"chemicalentity/{CHEBI_ID}" + response = asyncio.run( + wrap_with_session(ChEBI, "query_the_service", ["ChEBI", args]) + ) + + assert isinstance(response, str) + import json + data = json.loads(response) + assert "chebiId" in data + assert data["chebiId"] == CHEBI_ID + + +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 From 5b2da6a4fe6f64982bebec715cfd9d64309260e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:06:52 +0000 Subject: [PATCH 03/17] Use CHEBI:60888 as test entity in test_ChEBI.py Co-authored-by: hechth <12066490+hechth@users.noreply.github.com> --- tests/test_ChEBI.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_ChEBI.py b/tests/test_ChEBI.py index d3cf4db..f5424a1 100644 --- a/tests/test_ChEBI.py +++ b/tests/test_ChEBI.py @@ -5,8 +5,8 @@ from tests.utils import wrap_with_session -CHEBI_ID = "CHEBI:15422" -INCHIKEY = "ZKHQWZAMYRWXGA-KQYNXXCUSA-N" +CHEBI_ID = "CHEBI:60888" +INCHIKEY = "RYYVLZVUVIJVGH-UHFFFAOYSA-N" @pytest.mark.dependency() From 5326dab9ab5ef0b1b81881cc1c998ad17e0aa97c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:20:16 +0000 Subject: [PATCH 04/17] Fix ChEBI API endpoints and add name search test for 'bapta' Co-authored-by: hechth <12066490+hechth@users.noreply.github.com> --- MSMetaEnhancer/libs/converters/web/ChEBI.py | 48 ++++++++++----------- tests/test_ChEBI.py | 22 +++++++--- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/MSMetaEnhancer/libs/converters/web/ChEBI.py b/MSMetaEnhancer/libs/converters/web/ChEBI.py index 760e56b..37585a7 100644 --- a/MSMetaEnhancer/libs/converters/web/ChEBI.py +++ b/MSMetaEnhancer/libs/converters/web/ChEBI.py @@ -16,7 +16,7 @@ def __init__(self, session): super().__init__(session) # service URLs self.endpoints = { - "ChEBI": "https://www.ebi.ac.uk/chebi/backend/api/", + "ChEBI": "https://www.ebi.ac.uk/chebi/backend/api/public/", } self.attributes = [ @@ -65,8 +65,10 @@ async def from_name(self, name): :param name: given compound name :return: all found data """ - args = f"search?query={name}&searchCategory=CHEBI_NAME&maximumResults=10&stars=ALL" - return await self.call_service(args) + args = f"es_search/?term={name}&page=1&size=15" + response = await self.query_the_service("ChEBI", args) + if response: + return self.parse_search_results(response) async def from_inchikey(self, inchikey): """ @@ -75,8 +77,10 @@ async def from_inchikey(self, inchikey): :param inchikey: given InChIKey :return: all found data """ - args = f"search?query={inchikey}&searchCategory=INCHI_KEY&maximumResults=10&stars=ALL" - return await self.call_service(args) + args = f"es_search/?term={inchikey}&page=1&size=15" + response = await self.query_the_service("ChEBI", args) + if response: + return self.parse_search_results(response) async def from_inchi(self, inchi): """ @@ -85,8 +89,10 @@ async def from_inchi(self, inchi): :param inchi: given InChI string :return: all found data """ - args = f"search?query={inchi}&searchCategory=INCHI&maximumResults=10&stars=ALL" - return await self.call_service(args) + args = f"es_search/?term={inchi}&page=1&size=15" + response = await self.query_the_service("ChEBI", args) + if response: + return self.parse_search_results(response) async def from_smiles(self, smiles): """ @@ -95,37 +101,28 @@ async def from_smiles(self, smiles): :param smiles: given SMILES string :return: all found data """ - args = f"search?query={smiles}&searchCategory=SMILES&maximumResults=10&stars=ALL" - return await self.call_service(args) + args = f"es_search/?term={smiles}&page=1&size=15" + response = await self.query_the_service("ChEBI", args) + if response: + return self.parse_search_results(response) async def from_chebiid(self, chebiid): """ Convert ChEBI ID to all possible attributes using ChEBI service. - :param chebiid: given ChEBI ID (e.g. 'CHEBI:15422') + :param chebiid: given ChEBI ID (e.g. 'CHEBI:60888') :return: all found data """ - args = f"chemicalentity/{chebiid}" + args = f"compound/{chebiid}/" response = await self.query_the_service("ChEBI", args) if response: return self.parse_entity(response) - async def call_service(self, args): - """ - General method to call ChEBI search service. - - :param args: URL suffix with query arguments - :return: obtained attributes from the first result - """ - response = await self.query_the_service("ChEBI", args) - if response: - return self.parse_search_results(response) - def parse_entity(self, response): """ Parse attributes from a single ChEBI entity response. - :param response: JSON string from /chemicalentity/{chebiId} endpoint + :param response: JSON string from /compound/{chebiId}/ endpoint :return: dict of parsed attributes """ entity = json.loads(response) @@ -135,11 +132,11 @@ def parse_search_results(self, response): """ Parse attributes from the first result of a ChEBI search response. - :param response: JSON string from /search endpoint + :param response: JSON string from /es_search/ endpoint :return: dict of parsed attributes from the first result """ response_json = json.loads(response) - results = response_json.get("priceSearchList", []) + results = response_json.get("results", []) if not results: return None return self._extract_attributes(results[0]) @@ -157,3 +154,4 @@ def _extract_attributes(self, entity): if value: result[att["code"]] = value return result if result else None + diff --git a/tests/test_ChEBI.py b/tests/test_ChEBI.py index f5424a1..8b57291 100644 --- a/tests/test_ChEBI.py +++ b/tests/test_ChEBI.py @@ -1,4 +1,5 @@ import asyncio +import json import pytest from MSMetaEnhancer.libs.converters.web import ChEBI @@ -7,6 +8,7 @@ CHEBI_ID = "CHEBI:60888" INCHIKEY = "RYYVLZVUVIJVGH-UHFFFAOYSA-N" +COMPOUND_NAME = "bapta" @pytest.mark.dependency() @@ -45,36 +47,41 @@ def test_chebiid_to_formula(): @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 "chebiid" in result + assert result["chebiid"] == CHEBI_ID + + +@pytest.mark.dependency(depends=["test_service_available"]) +def test_compound_name_to_chebiid(): result = asyncio.run( - wrap_with_session(ChEBI, "inchikey_to_chebiid", [INCHIKEY]) + wrap_with_session(ChEBI, "compound_name_to_chebiid", [COMPOUND_NAME]) ) assert result is not None assert "chebiid" in result - assert result["chebiid"] == CHEBI_ID @pytest.mark.dependency(depends=["test_service_available"]) def test_format_search_response(): - args = f"search?query={INCHIKEY}&searchCategory=INCHI_KEY&maximumResults=10&stars=ALL" + 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) - import json data = json.loads(response) - assert "priceSearchList" in data + assert "results" in data @pytest.mark.dependency(depends=["test_service_available"]) def test_format_entity_response(): - args = f"chemicalentity/{CHEBI_ID}" + args = f"compound/{CHEBI_ID}/" response = asyncio.run( wrap_with_session(ChEBI, "query_the_service", ["ChEBI", args]) ) assert isinstance(response, str) - import json data = json.loads(response) assert "chebiId" in data assert data["chebiId"] == CHEBI_ID @@ -85,3 +92,4 @@ def test_get_conversions(): assert ("chebiid", "inchikey", "ChEBI") in jobs assert ("inchikey", "chebiid", "ChEBI") in jobs assert ("compound_name", "chebiid", "ChEBI") in jobs + From 5abfa3779e9dd9f551772d3fd45c28c3a49babe1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:33:46 +0000 Subject: [PATCH 05/17] Fix ChEBI accession field mapping Co-authored-by: hechth <12066490+hechth@users.noreply.github.com> --- MSMetaEnhancer/libs/converters/web/ChEBI.py | 3 +-- tests/test_ChEBI.py | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/MSMetaEnhancer/libs/converters/web/ChEBI.py b/MSMetaEnhancer/libs/converters/web/ChEBI.py index 37585a7..86effc6 100644 --- a/MSMetaEnhancer/libs/converters/web/ChEBI.py +++ b/MSMetaEnhancer/libs/converters/web/ChEBI.py @@ -20,7 +20,7 @@ def __init__(self, session): } self.attributes = [ - {"code": "chebiid", "label": "chebiId"}, + {"code": "chebiid", "label": "chebi_accession"}, {"code": "compound_name", "label": "chebiAsciiName"}, {"code": "inchikey", "label": "inchiKey"}, {"code": "inchi", "label": "inchi"}, @@ -154,4 +154,3 @@ def _extract_attributes(self, entity): if value: result[att["code"]] = value return result if result else None - diff --git a/tests/test_ChEBI.py b/tests/test_ChEBI.py index 8b57291..bbf37cd 100644 --- a/tests/test_ChEBI.py +++ b/tests/test_ChEBI.py @@ -83,8 +83,8 @@ def test_format_entity_response(): assert isinstance(response, str) data = json.loads(response) - assert "chebiId" in data - assert data["chebiId"] == CHEBI_ID + assert "chebi_accession" in data + assert data["chebi_accession"] == CHEBI_ID def test_get_conversions(): @@ -92,4 +92,3 @@ def test_get_conversions(): assert ("chebiid", "inchikey", "ChEBI") in jobs assert ("inchikey", "chebiid", "ChEBI") in jobs assert ("compound_name", "chebiid", "ChEBI") in jobs - From d53ed4418aa9880cf7a5b9882fab6efffad345e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:44:30 +0000 Subject: [PATCH 06/17] Fix ChEBI response parsing for current API Co-authored-by: hechth <12066490+hechth@users.noreply.github.com> --- MSMetaEnhancer/libs/converters/web/ChEBI.py | 57 ++++++++++++++++++--- tests/test_ChEBI.py | 56 ++++++++++++++++++++ 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/MSMetaEnhancer/libs/converters/web/ChEBI.py b/MSMetaEnhancer/libs/converters/web/ChEBI.py index 86effc6..7e6271c 100644 --- a/MSMetaEnhancer/libs/converters/web/ChEBI.py +++ b/MSMetaEnhancer/libs/converters/web/ChEBI.py @@ -20,12 +20,29 @@ def __init__(self, session): } self.attributes = [ - {"code": "chebiid", "label": "chebi_accession"}, - {"code": "compound_name", "label": "chebiAsciiName"}, - {"code": "inchikey", "label": "inchiKey"}, - {"code": "inchi", "label": "inchi"}, - {"code": "smiles", "label": "smiles"}, - {"code": "formula", "label": "formula"}, + {"code": "chebiid", "paths": [("chebi_accession",), ("chebiId",)]}, + { + "code": "compound_name", + "paths": [("ascii_name",), ("name",), ("chebiAsciiName",)], + }, + { + "code": "inchikey", + "paths": [ + ("default_structure", "standard_inchi_key"), + ("inchikey",), + ("standard_inchi_key",), + ("inchiKey",), + ], + }, + { + "code": "inchi", + "paths": [("default_structure", "standard_inchi"), ("inchi",)], + }, + {"code": "smiles", "paths": [("default_structure", "smiles"), ("smiles",)]}, + { + "code": "formula", + "paths": [("chemical_data", "formula"), ("formula",)], + }, ] # generate top level methods defining allowed conversions @@ -139,7 +156,10 @@ def parse_search_results(self, response): results = response_json.get("results", []) if not results: return None - return self._extract_attributes(results[0]) + entity = results[0] + if isinstance(entity, dict): + entity = entity.get("_source", entity) + return self._extract_attributes(entity) def _extract_attributes(self, entity): """ @@ -148,9 +168,30 @@ def _extract_attributes(self, entity): :param entity: dict representing a ChEBI entity :return: dict of parsed attributes """ + if not isinstance(entity, dict): + return None result = {} for att in self.attributes: - value = entity.get(att["label"]) + value = self._get_first_value(entity, att["paths"]) if value: result[att["code"]] = value return result if result else None + + def _get_first_value(self, entity, paths): + """ + Return the first non-empty value found in the given candidate paths. + + :param entity: dict representing a ChEBI entity + :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/tests/test_ChEBI.py b/tests/test_ChEBI.py index bbf37cd..128dd14 100644 --- a/tests/test_ChEBI.py +++ b/tests/test_ChEBI.py @@ -87,6 +87,62 @@ def test_format_entity_response(): assert data["chebi_accession"] == CHEBI_ID +def test_parse_entity_nested_response(): + response = json.dumps( + { + "chebi_accession": CHEBI_ID, + "name": "bapta", + "chemical_data": {"formula": "C22H22N2O10"}, + "default_structure": { + "smiles": "C1=CC=C(C=C1)O", + "standard_inchi": "InChI=1S/C6H6O/c7-6-4-2-1-3-5-6/h1-5,7H", + "standard_inchi_key": INCHIKEY, + }, + } + ) + + parsed = ChEBI(None).parse_entity(response) + + assert parsed == { + "chebiid": CHEBI_ID, + "compound_name": "bapta", + "formula": "C22H22N2O10", + "smiles": "C1=CC=C(C=C1)O", + "inchi": "InChI=1S/C6H6O/c7-6-4-2-1-3-5-6/h1-5,7H", + "inchikey": INCHIKEY, + } + + +def test_parse_search_response_source(): + response = json.dumps( + { + "results": [ + { + "_source": { + "chebi_accession": CHEBI_ID, + "ascii_name": COMPOUND_NAME, + "formula": "C22H22N2O10", + "smiles": "NCCO", + "inchi": "InChI=1S/C2H7NO/c3-1-2-4/h4H,1-3H2", + "standard_inchi_key": INCHIKEY, + } + } + ] + } + ) + + parsed = ChEBI(None).parse_search_results(response) + + assert parsed == { + "chebiid": CHEBI_ID, + "compound_name": COMPOUND_NAME, + "formula": "C22H22N2O10", + "smiles": "NCCO", + "inchi": "InChI=1S/C2H7NO/c3-1-2-4/h4H,1-3H2", + "inchikey": INCHIKEY, + } + + def test_get_conversions(): jobs = ChEBI(None).get_conversion_functions() assert ("chebiid", "inchikey", "ChEBI") in jobs From 1ce8cde9a61c38c4ca372593dab30282777187ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:45:22 +0000 Subject: [PATCH 07/17] Handle falsy ChEBI field values safely Co-authored-by: hechth <12066490+hechth@users.noreply.github.com> --- MSMetaEnhancer/libs/converters/web/ChEBI.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MSMetaEnhancer/libs/converters/web/ChEBI.py b/MSMetaEnhancer/libs/converters/web/ChEBI.py index 7e6271c..ff24909 100644 --- a/MSMetaEnhancer/libs/converters/web/ChEBI.py +++ b/MSMetaEnhancer/libs/converters/web/ChEBI.py @@ -173,7 +173,7 @@ def _extract_attributes(self, entity): result = {} for att in self.attributes: value = self._get_first_value(entity, att["paths"]) - if value: + if value is not None: result[att["code"]] = value return result if result else None @@ -192,6 +192,6 @@ def _get_first_value(self, entity, paths): value = None break value = value.get(key) - if value: + if value is not None: return value return None From 493730fa6edc4334af7b9d808926f0937c84eac1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:45:14 +0000 Subject: [PATCH 08/17] Fix failing ChEBI tests: update INCHIKEY to match current ChEBI API data for CHEBI:60888 Co-authored-by: hechth <12066490+hechth@users.noreply.github.com> --- tests/test_ChEBI.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_ChEBI.py b/tests/test_ChEBI.py index 128dd14..a6e543a 100644 --- a/tests/test_ChEBI.py +++ b/tests/test_ChEBI.py @@ -7,7 +7,7 @@ CHEBI_ID = "CHEBI:60888" -INCHIKEY = "RYYVLZVUVIJVGH-UHFFFAOYSA-N" +INCHIKEY = "FTEDXVNDVHYDQW-UHFFFAOYSA-N" COMPOUND_NAME = "bapta" From a6d074cbe19f4100f0ef1a57b818f1cd91c72341 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:47:42 +0000 Subject: [PATCH 09/17] Also URL-encode query parameters in ChEBI search methods Co-authored-by: hechth <12066490+hechth@users.noreply.github.com> --- MSMetaEnhancer/libs/converters/web/ChEBI.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/MSMetaEnhancer/libs/converters/web/ChEBI.py b/MSMetaEnhancer/libs/converters/web/ChEBI.py index ff24909..480ed64 100644 --- a/MSMetaEnhancer/libs/converters/web/ChEBI.py +++ b/MSMetaEnhancer/libs/converters/web/ChEBI.py @@ -1,4 +1,5 @@ import json +from urllib.parse import quote from MSMetaEnhancer.libs.converters.web.WebConverter import WebConverter @@ -82,7 +83,7 @@ async def from_name(self, name): :param name: given compound name :return: all found data """ - args = f"es_search/?term={name}&page=1&size=15" + args = f"es_search/?term={quote(name)}&page=1&size=15" response = await self.query_the_service("ChEBI", args) if response: return self.parse_search_results(response) @@ -94,7 +95,7 @@ async def from_inchikey(self, inchikey): :param inchikey: given InChIKey :return: all found data """ - args = f"es_search/?term={inchikey}&page=1&size=15" + args = f"es_search/?term={quote(inchikey)}&page=1&size=15" response = await self.query_the_service("ChEBI", args) if response: return self.parse_search_results(response) @@ -106,7 +107,7 @@ async def from_inchi(self, inchi): :param inchi: given InChI string :return: all found data """ - args = f"es_search/?term={inchi}&page=1&size=15" + args = f"es_search/?term={quote(inchi)}&page=1&size=15" response = await self.query_the_service("ChEBI", args) if response: return self.parse_search_results(response) @@ -118,7 +119,7 @@ async def from_smiles(self, smiles): :param smiles: given SMILES string :return: all found data """ - args = f"es_search/?term={smiles}&page=1&size=15" + args = f"es_search/?term={quote(smiles)}&page=1&size=15" response = await self.query_the_service("ChEBI", args) if response: return self.parse_search_results(response) From 7e2bb0b076c436438752b3875ab675cd43affdf4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:13:26 +0000 Subject: [PATCH 10/17] Add INCHI/IUPAC name constants and iupac_name support to ChEBI converter and tests Co-authored-by: hechth <12066490+hechth@users.noreply.github.com> --- MSMetaEnhancer/libs/converters/web/ChEBI.py | 71 +++++++++++++++++---- tests/test_ChEBI.py | 39 +++++++++++ 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/MSMetaEnhancer/libs/converters/web/ChEBI.py b/MSMetaEnhancer/libs/converters/web/ChEBI.py index 480ed64..04a6398 100644 --- a/MSMetaEnhancer/libs/converters/web/ChEBI.py +++ b/MSMetaEnhancer/libs/converters/web/ChEBI.py @@ -26,6 +26,10 @@ def __init__(self, session): "code": "compound_name", "paths": [("ascii_name",), ("name",), ("chebiAsciiName",)], }, + { + "code": "iupac_name", + "paths": [("iupac_names",), ("iupacName",)], + }, { "code": "inchikey", "paths": [ @@ -53,22 +57,32 @@ def __init__(self, session): ("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"), @@ -83,7 +97,25 @@ async def from_name(self, name): :param name: given compound name :return: all found data """ - args = f"es_search/?term={quote(name)}&page=1&size=15" + 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) + + async def _from_es_search(self, term): + """ + Search ChEBI by a given term and return parsed attributes. + + :param term: search term (name, InChIKey, InChI, SMILES, or IUPAC name) + :return: all found data + """ + args = f"es_search/?term={quote(term)}&page=1&size=15" response = await self.query_the_service("ChEBI", args) if response: return self.parse_search_results(response) @@ -95,10 +127,7 @@ async def from_inchikey(self, inchikey): :param inchikey: given InChIKey :return: all found data """ - args = f"es_search/?term={quote(inchikey)}&page=1&size=15" - response = await self.query_the_service("ChEBI", args) - if response: - return self.parse_search_results(response) + return await self._from_es_search(inchikey) async def from_inchi(self, inchi): """ @@ -107,10 +136,7 @@ async def from_inchi(self, inchi): :param inchi: given InChI string :return: all found data """ - args = f"es_search/?term={quote(inchi)}&page=1&size=15" - response = await self.query_the_service("ChEBI", args) - if response: - return self.parse_search_results(response) + return await self._from_es_search(inchi) async def from_smiles(self, smiles): """ @@ -119,10 +145,7 @@ async def from_smiles(self, smiles): :param smiles: given SMILES string :return: all found data """ - args = f"es_search/?term={quote(smiles)}&page=1&size=15" - response = await self.query_the_service("ChEBI", args) - if response: - return self.parse_search_results(response) + return await self._from_es_search(smiles) async def from_chebiid(self, chebiid): """ @@ -176,11 +199,31 @@ def _extract_attributes(self, entity): value = self._get_first_value(entity, att["paths"]) if value is not None: result[att["code"]] = value + # Extract IUPAC name from synonyms list (entity endpoint) + if "iupac_name" not in result: + iupac_name = self._extract_iupac_from_synonyms(entity) + if iupac_name is not None: + result["iupac_name"] = iupac_name return result if result else None + def _extract_iupac_from_synonyms(self, entity): + """ + Extract IUPAC name from the synonyms list in a ChEBI entity response. + + :param entity: dict representing a ChEBI entity + :return: IUPAC name string or None + """ + synonyms = entity.get("synonyms", []) + if isinstance(synonyms, list): + for syn in synonyms: + if isinstance(syn, dict) and syn.get("type") == "IUPAC NAME": + return syn.get("data") + return None + def _get_first_value(self, entity, paths): """ Return the first non-empty value found in the given candidate paths. + If the resolved value is a list, the first element is returned. :param entity: dict representing a ChEBI entity :param paths: candidate key paths to try @@ -194,5 +237,7 @@ def _get_first_value(self, entity, paths): break value = value.get(key) if value is not None: + if isinstance(value, list): + return value[0] if value else None return value return None diff --git a/tests/test_ChEBI.py b/tests/test_ChEBI.py index a6e543a..68db8ae 100644 --- a/tests/test_ChEBI.py +++ b/tests/test_ChEBI.py @@ -8,7 +8,9 @@ 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" +IUPAC_NAME = "2,2',2'',2'''-[ethane-1,2-diylbis(oxy-2,1-phenylenenitrilo)]tetraacetic acid" @pytest.mark.dependency() @@ -36,6 +38,33 @@ def test_chebiid_to_inchi(): result = asyncio.run(wrap_with_session(ChEBI, "chebiid_to_inchi", [CHEBI_ID])) assert result is not None assert "inchi" in result + assert result["inchi"] == INCHI + + +@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 "iupac_name" in result + 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 "chebiid" in result + 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 "chebiid" in result + assert result["chebiid"] == CHEBI_ID @pytest.mark.dependency(depends=["test_service_available"]) @@ -98,6 +127,10 @@ def test_parse_entity_nested_response(): "standard_inchi": "InChI=1S/C6H6O/c7-6-4-2-1-3-5-6/h1-5,7H", "standard_inchi_key": INCHIKEY, }, + "synonyms": [ + {"type": "IUPAC NAME", "data": IUPAC_NAME}, + {"type": "Synonym", "data": "bapta"}, + ], } ) @@ -106,6 +139,7 @@ def test_parse_entity_nested_response(): assert parsed == { "chebiid": CHEBI_ID, "compound_name": "bapta", + "iupac_name": IUPAC_NAME, "formula": "C22H22N2O10", "smiles": "C1=CC=C(C=C1)O", "inchi": "InChI=1S/C6H6O/c7-6-4-2-1-3-5-6/h1-5,7H", @@ -125,6 +159,7 @@ def test_parse_search_response_source(): "smiles": "NCCO", "inchi": "InChI=1S/C2H7NO/c3-1-2-4/h4H,1-3H2", "standard_inchi_key": INCHIKEY, + "iupac_names": [IUPAC_NAME], } } ] @@ -136,6 +171,7 @@ def test_parse_search_response_source(): assert parsed == { "chebiid": CHEBI_ID, "compound_name": COMPOUND_NAME, + "iupac_name": IUPAC_NAME, "formula": "C22H22N2O10", "smiles": "NCCO", "inchi": "InChI=1S/C2H7NO/c3-1-2-4/h4H,1-3H2", @@ -148,3 +184,6 @@ def test_get_conversions(): assert ("chebiid", "inchikey", "ChEBI") in jobs assert ("inchikey", "chebiid", "ChEBI") in jobs assert ("compound_name", "chebiid", "ChEBI") in jobs + assert ("chebiid", "iupac_name", "ChEBI") in jobs + assert ("iupac_name", "chebiid", "ChEBI") in jobs + assert ("inchi", "chebiid", "ChEBI") in jobs From 67be3d83cd96541335fc8901bfbff8d69c402119 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:35:13 +0000 Subject: [PATCH 11/17] Fix ChEBI from_chebiid to supplement entity endpoint with es_search for iupac_name Co-authored-by: hechth <12066490+hechth@users.noreply.github.com> --- MSMetaEnhancer/libs/converters/web/ChEBI.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/MSMetaEnhancer/libs/converters/web/ChEBI.py b/MSMetaEnhancer/libs/converters/web/ChEBI.py index 04a6398..d6d2344 100644 --- a/MSMetaEnhancer/libs/converters/web/ChEBI.py +++ b/MSMetaEnhancer/libs/converters/web/ChEBI.py @@ -28,7 +28,7 @@ def __init__(self, session): }, { "code": "iupac_name", - "paths": [("iupac_names",), ("iupacName",)], + "paths": [("iupac_names",), ("iupacNames",), ("iupacName",), ("iupac_name",)], }, { "code": "inchikey", @@ -156,8 +156,18 @@ async def from_chebiid(self, chebiid): """ args = f"compound/{chebiid}/" response = await self.query_the_service("ChEBI", args) + result = {} if response: - return self.parse_entity(response) + entity_result = self.parse_entity(response) + if entity_result: + result.update(entity_result) + # Supplement with es_search results for fields not in entity response (e.g. iupac_name) + es_result = await self._from_es_search(chebiid) + if es_result: + for key, val in es_result.items(): + if key not in result: + result[key] = val + return result if result else None def parse_entity(self, response): """ @@ -216,7 +226,7 @@ def _extract_iupac_from_synonyms(self, entity): synonyms = entity.get("synonyms", []) if isinstance(synonyms, list): for syn in synonyms: - if isinstance(syn, dict) and syn.get("type") == "IUPAC NAME": + if isinstance(syn, dict) and syn.get("type", "").upper() == "IUPAC NAME": return syn.get("data") return None From e1d767495ca1e868df80f95dd8a5724010f97fc5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:36:19 +0000 Subject: [PATCH 12/17] Only call es_search fallback in from_chebiid when iupac_name is missing Co-authored-by: hechth <12066490+hechth@users.noreply.github.com> --- MSMetaEnhancer/libs/converters/web/ChEBI.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/MSMetaEnhancer/libs/converters/web/ChEBI.py b/MSMetaEnhancer/libs/converters/web/ChEBI.py index d6d2344..bfb4d67 100644 --- a/MSMetaEnhancer/libs/converters/web/ChEBI.py +++ b/MSMetaEnhancer/libs/converters/web/ChEBI.py @@ -162,11 +162,12 @@ async def from_chebiid(self, chebiid): if entity_result: result.update(entity_result) # Supplement with es_search results for fields not in entity response (e.g. iupac_name) - es_result = await self._from_es_search(chebiid) - if es_result: - for key, val in es_result.items(): - if key not in result: - result[key] = val + if "iupac_name" not in result: + es_result = await self._from_es_search(chebiid) + if es_result: + for key, val in es_result.items(): + if key not in result: + result[key] = val return result if result else None def parse_entity(self, response): From 6aca29cfe4a8572eeb6c97c479a4a0353a732828 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:40:01 +0000 Subject: [PATCH 13/17] Fix ChEBI iupac_name extraction: use names dict from entity endpoint, revert es_search fallback Co-authored-by: hechth <12066490+hechth@users.noreply.github.com> --- MSMetaEnhancer/libs/converters/web/ChEBI.py | 32 +++++++++--------- tests/test_ChEBI.py | 36 ++++++++++++++++++--- 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/MSMetaEnhancer/libs/converters/web/ChEBI.py b/MSMetaEnhancer/libs/converters/web/ChEBI.py index bfb4d67..1f024a3 100644 --- a/MSMetaEnhancer/libs/converters/web/ChEBI.py +++ b/MSMetaEnhancer/libs/converters/web/ChEBI.py @@ -156,19 +156,8 @@ async def from_chebiid(self, chebiid): """ args = f"compound/{chebiid}/" response = await self.query_the_service("ChEBI", args) - result = {} if response: - entity_result = self.parse_entity(response) - if entity_result: - result.update(entity_result) - # Supplement with es_search results for fields not in entity response (e.g. iupac_name) - if "iupac_name" not in result: - es_result = await self._from_es_search(chebiid) - if es_result: - for key, val in es_result.items(): - if key not in result: - result[key] = val - return result if result else None + return self.parse_entity(response) def parse_entity(self, response): """ @@ -210,20 +199,31 @@ def _extract_attributes(self, entity): value = self._get_first_value(entity, att["paths"]) if value is not None: result[att["code"]] = value - # Extract IUPAC name from synonyms list (entity endpoint) + # Extract IUPAC name from names dict (entity endpoint) or synonyms list (legacy) if "iupac_name" not in result: - iupac_name = self._extract_iupac_from_synonyms(entity) + iupac_name = self._extract_iupac_from_names(entity) if iupac_name is not None: result["iupac_name"] = iupac_name return result if result else None - def _extract_iupac_from_synonyms(self, entity): + def _extract_iupac_from_names(self, entity): """ - Extract IUPAC name from the synonyms list in a ChEBI entity response. + Extract IUPAC name from the entity response. + + Tries two structures: + - ``names["IUPAC NAME"][0]["name"]`` (entity endpoint format) + - ``synonyms[].type == "IUPAC NAME"`` → ``.data`` (legacy/es_search format) :param entity: dict representing a ChEBI entity :return: IUPAC name string or None """ + names = entity.get("names", {}) + if isinstance(names, dict): + iupac_entries = names.get("IUPAC NAME", []) + if isinstance(iupac_entries, list) and iupac_entries: + entry = iupac_entries[0] + if isinstance(entry, dict): + return entry.get("name") or entry.get("ascii_name") synonyms = entity.get("synonyms", []) if isinstance(synonyms, list): for syn in synonyms: diff --git a/tests/test_ChEBI.py b/tests/test_ChEBI.py index 68db8ae..e8fd0d4 100644 --- a/tests/test_ChEBI.py +++ b/tests/test_ChEBI.py @@ -127,10 +127,19 @@ def test_parse_entity_nested_response(): "standard_inchi": "InChI=1S/C6H6O/c7-6-4-2-1-3-5-6/h1-5,7H", "standard_inchi_key": INCHIKEY, }, - "synonyms": [ - {"type": "IUPAC NAME", "data": IUPAC_NAME}, - {"type": "Synonym", "data": "bapta"}, - ], + "names": { + "IUPAC NAME": [ + { + "name": IUPAC_NAME, + "type": "IUPAC NAME", + "source": "IUPAC", + "ascii_name": IUPAC_NAME, + } + ], + "SYNONYM": [ + {"name": "bapta", "type": "SYNONYM"}, + ], + }, } ) @@ -147,6 +156,25 @@ def test_parse_entity_nested_response(): } +def test_parse_entity_synonyms_fallback(): + """Ensure iupac_name can also be extracted from legacy synonyms list format.""" + response = json.dumps( + { + "chebi_accession": CHEBI_ID, + "name": "bapta", + "synonyms": [ + {"type": "IUPAC NAME", "data": IUPAC_NAME}, + {"type": "Synonym", "data": "bapta"}, + ], + } + ) + + parsed = ChEBI(None).parse_entity(response) + + assert parsed is not None + assert parsed["iupac_name"] == IUPAC_NAME + + def test_parse_search_response_source(): response = json.dumps( { From 498329501139584717af049e6de33b39bd6d002e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:41:10 +0000 Subject: [PATCH 14/17] Skip falsy list elements in _get_first_value Co-authored-by: hechth <12066490+hechth@users.noreply.github.com> --- MSMetaEnhancer/libs/converters/web/ChEBI.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MSMetaEnhancer/libs/converters/web/ChEBI.py b/MSMetaEnhancer/libs/converters/web/ChEBI.py index 1f024a3..89b2a82 100644 --- a/MSMetaEnhancer/libs/converters/web/ChEBI.py +++ b/MSMetaEnhancer/libs/converters/web/ChEBI.py @@ -249,6 +249,6 @@ def _get_first_value(self, entity, paths): value = value.get(key) if value is not None: if isinstance(value, list): - return value[0] if value else None + return next((v for v in value if v), None) return value return None From 47ac1bfdfb29151bbd63121cc9e153c8b430b7bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:50:08 +0000 Subject: [PATCH 15/17] Use structure_search endpoint with similarity threshold=1 for SMILES lookups in ChEBI Co-authored-by: hechth <12066490+hechth@users.noreply.github.com> --- MSMetaEnhancer/libs/converters/web/ChEBI.py | 26 +++++++++++++-- tests/test_ChEBI.py | 35 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/MSMetaEnhancer/libs/converters/web/ChEBI.py b/MSMetaEnhancer/libs/converters/web/ChEBI.py index 89b2a82..023650e 100644 --- a/MSMetaEnhancer/libs/converters/web/ChEBI.py +++ b/MSMetaEnhancer/libs/converters/web/ChEBI.py @@ -1,5 +1,6 @@ import json from urllib.parse import quote +from frozendict import frozendict from MSMetaEnhancer.libs.converters.web.WebConverter import WebConverter @@ -140,12 +141,19 @@ async def from_inchi(self, inchi): async def from_smiles(self, smiles): """ - Convert SMILES to all possible attributes using ChEBI service. + Convert SMILES to all possible attributes using ChEBI structure search service. + + Uses the similarity search endpoint with a threshold of 1.0 (exact match). :param smiles: given SMILES string :return: all found data """ - return await self._from_es_search(smiles) + data = frozendict({"structure": smiles, "type": "similarity", "threshold": "1"}) + response = await self.query_the_service( + "ChEBI", "structure_search/", method="POST", data=data + ) + if response: + return self.parse_structure_search_results(response) async def from_chebiid(self, chebiid): """ @@ -169,6 +177,20 @@ def parse_entity(self, response): entity = json.loads(response) return self._extract_attributes(entity) + def parse_structure_search_results(self, response): + """ + Parse attributes from the first result of a ChEBI structure search response. + + The structure_search endpoint returns a list of compound objects. + + :param response: JSON string from /structure_search/ endpoint + :return: dict of parsed attributes from the first result + """ + results = json.loads(response) + if not isinstance(results, list) or not results: + return None + return self._extract_attributes(results[0]) + def parse_search_results(self, response): """ Parse attributes from the first result of a ChEBI search response. diff --git a/tests/test_ChEBI.py b/tests/test_ChEBI.py index e8fd0d4..5b373b4 100644 --- a/tests/test_ChEBI.py +++ b/tests/test_ChEBI.py @@ -11,6 +11,7 @@ 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" IUPAC_NAME = "2,2',2'',2'''-[ethane-1,2-diylbis(oxy-2,1-phenylenenitrilo)]tetraacetic acid" +SMILES = "OC(=O)CN(CCOc1ccccc1N(CC(O)=O)CC(O)=O)CC(O)=O" @pytest.mark.dependency() @@ -33,6 +34,14 @@ def test_chebiid_to_smiles(): assert "smiles" in result +@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 "chebiid" in result + assert result["chebiid"] == CHEBI_ID + + @pytest.mark.dependency(depends=["test_service_available"]) def test_chebiid_to_inchi(): result = asyncio.run(wrap_with_session(ChEBI, "chebiid_to_inchi", [CHEBI_ID])) @@ -215,3 +224,29 @@ def test_get_conversions(): assert ("chebiid", "iupac_name", "ChEBI") in jobs assert ("iupac_name", "chebiid", "ChEBI") in jobs assert ("inchi", "chebiid", "ChEBI") in jobs + assert ("smiles", "chebiid", "ChEBI") in jobs + + +def test_parse_structure_search_response(): + response = json.dumps( + [ + { + "chebi_accession": CHEBI_ID, + "ascii_name": COMPOUND_NAME, + "chemical_data": {"formula": "C22H24N2O10"}, + "default_structure": { + "smiles": SMILES, + "standard_inchi": INCHI, + "standard_inchi_key": INCHIKEY, + }, + } + ] + ) + + parsed = ChEBI(None).parse_structure_search_results(response) + + assert parsed is not None + assert parsed["chebiid"] == CHEBI_ID + assert parsed["compound_name"] == COMPOUND_NAME + assert parsed["smiles"] == SMILES + assert parsed["inchikey"] == INCHIKEY From 58b6b6c70e515f4eec50e20bbdb9f29337971deb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:10:31 +0000 Subject: [PATCH 16/17] Fix ChEBI structure_search API: use similarity parameter instead of threshold Co-authored-by: hechth <12066490+hechth@users.noreply.github.com> --- MSMetaEnhancer/libs/converters/web/ChEBI.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MSMetaEnhancer/libs/converters/web/ChEBI.py b/MSMetaEnhancer/libs/converters/web/ChEBI.py index 023650e..5bc6a02 100644 --- a/MSMetaEnhancer/libs/converters/web/ChEBI.py +++ b/MSMetaEnhancer/libs/converters/web/ChEBI.py @@ -143,12 +143,12 @@ async def from_smiles(self, smiles): """ Convert SMILES to all possible attributes using ChEBI structure search service. - Uses the similarity search endpoint with a threshold of 1.0 (exact match). + Uses the similarity search endpoint with a similarity of 1.0 (exact match). :param smiles: given SMILES string :return: all found data """ - data = frozendict({"structure": smiles, "type": "similarity", "threshold": "1"}) + data = frozendict({"structure": smiles, "type": "similarity", "similarity": 1.0}) response = await self.query_the_service( "ChEBI", "structure_search/", method="POST", data=data ) From 106b5f14ae2e80ebee86b514bbe16d88c945e456 Mon Sep 17 00:00:00 2001 From: hechth Date: Thu, 29 Oct 2026 17:11:13 +0100 Subject: [PATCH 17/17] added more tests for chebi --- CHANGELOG.md | 5 + MSMetaEnhancer/libs/converters/web/ChEBI.py | 205 ++++---- README.md | 7 +- .../MSMetaEnhancer.libs.webConverters.rst | 8 + galaxy/generate_options.py | 3 +- tests/test_ChEBI.py | 493 +++++++++++++----- 6 files changed, 497 insertions(+), 224 deletions(-) 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 index 5bc6a02..8240e3f 100644 --- a/MSMetaEnhancer/libs/converters/web/ChEBI.py +++ b/MSMetaEnhancer/libs/converters/web/ChEBI.py @@ -1,5 +1,6 @@ import json from urllib.parse import quote + from frozendict import frozendict from MSMetaEnhancer.libs.converters.web.WebConverter import WebConverter @@ -12,8 +13,22 @@ class ChEBI(WebConverter): 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 @@ -21,39 +36,23 @@ def __init__(self, session): "ChEBI": "https://www.ebi.ac.uk/chebi/backend/api/public/", } + # attributes as reported by the compound endpoint self.attributes = [ - {"code": "chebiid", "paths": [("chebi_accession",), ("chebiId",)]}, - { - "code": "compound_name", - "paths": [("ascii_name",), ("name",), ("chebiAsciiName",)], - }, - { - "code": "iupac_name", - "paths": [("iupac_names",), ("iupacNames",), ("iupacName",), ("iupac_name",)], - }, + {"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"), - ("inchikey",), - ("standard_inchi_key",), - ("inchiKey",), - ], - }, - { - "code": "inchi", - "paths": [("default_structure", "standard_inchi"), ("inchi",)], - }, - {"code": "smiles", "paths": [("default_structure", "smiles"), ("smiles",)]}, - { - "code": "formula", - "paths": [("chemical_data", "formula"), ("formula",)], + "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"), @@ -95,6 +94,9 @@ 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 """ @@ -107,19 +109,7 @@ async def from_iupac_name(self, iupac_name): :param iupac_name: given IUPAC name :return: all found data """ - return await self._from_es_search(iupac_name) - - async def _from_es_search(self, term): - """ - Search ChEBI by a given term and return parsed attributes. - - :param term: search term (name, InChIKey, InChI, SMILES, or IUPAC name) - :return: all found data - """ - args = f"es_search/?term={quote(term)}&page=1&size=15" - response = await self.query_the_service("ChEBI", args) - if response: - return self.parse_search_results(response) + return await self._from_es_search(iupac_name, by_term=False) async def from_inchikey(self, inchikey): """ @@ -134,26 +124,52 @@ 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. - Uses the similarity search endpoint with a similarity of 1.0 (exact match). + 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.0}) + data = frozendict({"structure": smiles, "type": "similarity", "similarity": 1}) response = await self.query_the_service( "ChEBI", "structure_search/", method="POST", data=data ) - if response: - return self.parse_structure_search_results(response) + 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): """ @@ -169,49 +185,51 @@ async def from_chebiid(self, chebiid): def parse_entity(self, response): """ - Parse attributes from a single ChEBI entity response. + Parse attributes from a single ChEBI compound response. :param response: JSON string from /compound/{chebiId}/ endpoint :return: dict of parsed attributes """ - entity = json.loads(response) - return self._extract_attributes(entity) + return self._extract_attributes(json.loads(response)) - def parse_structure_search_results(self, response): + def extract_chebiid(self, response, term=None): """ - Parse attributes from the first result of a ChEBI structure search response. + Obtain the ChEBI accession of the search result corresponding to the searched term. - The structure_search endpoint returns a list of compound objects. + 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 /structure_search/ endpoint - :return: dict of parsed attributes from the first result + :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 """ - results = json.loads(response) - if not isinstance(results, list) or not results: - return None - return self._extract_attributes(results[0]) - - def parse_search_results(self, response): - """ - Parse attributes from the first result of a ChEBI search response. - - :param response: JSON string from /es_search/ endpoint - :return: dict of parsed attributes from the first result - """ - response_json = json.loads(response) - results = response_json.get("results", []) - if not results: + 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 - entity = results[0] - if isinstance(entity, dict): - entity = entity.get("_source", entity) - return self._extract_attributes(entity) + 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 entity dict. + Extract known attributes from a ChEBI compound dict. - :param entity: dict representing a ChEBI entity + :param entity: dict representing a ChEBI compound :return: dict of parsed attributes """ if not isinstance(entity, dict): @@ -221,44 +239,35 @@ def _extract_attributes(self, entity): value = self._get_first_value(entity, att["paths"]) if value is not None: result[att["code"]] = value - # Extract IUPAC name from names dict (entity endpoint) or synonyms list (legacy) - if "iupac_name" not in result: - iupac_name = self._extract_iupac_from_names(entity) - if iupac_name is not None: - result["iupac_name"] = iupac_name + iupac_name = self._extract_iupac_name(entity) + if iupac_name is not None: + result["iupac_name"] = iupac_name return result if result else None - def _extract_iupac_from_names(self, entity): + @staticmethod + def _extract_iupac_name(entity): """ - Extract IUPAC name from the entity response. - - Tries two structures: - - ``names["IUPAC NAME"][0]["name"]`` (entity endpoint format) - - ``synonyms[].type == "IUPAC NAME"`` → ``.data`` (legacy/es_search format) + Extract the IUPAC name from the names of a ChEBI compound. - :param entity: dict representing a ChEBI entity + :param entity: dict representing a ChEBI compound :return: IUPAC name string or None """ - names = entity.get("names", {}) - if isinstance(names, dict): - iupac_entries = names.get("IUPAC NAME", []) - if isinstance(iupac_entries, list) and iupac_entries: - entry = iupac_entries[0] - if isinstance(entry, dict): - return entry.get("name") or entry.get("ascii_name") - synonyms = entity.get("synonyms", []) - if isinstance(synonyms, list): - for syn in synonyms: - if isinstance(syn, dict) and syn.get("type", "").upper() == "IUPAC NAME": - return syn.get("data") + 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. - If the resolved value is a list, the first element is returned. + Paths that yield no value are skipped and the following ones are tried. - :param entity: dict representing a ChEBI entity + :param entity: dict representing a ChEBI compound :param paths: candidate key paths to try :return: first non-empty value or None """ @@ -269,8 +278,6 @@ def _get_first_value(self, entity, paths): value = None break value = value.get(key) - if value is not None: - if isinstance(value, list): - return next((v for v in value if v), None) + if value: return value return None 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 index 5b373b4..f02f75a 100644 --- a/tests/test_ChEBI.py +++ b/tests/test_ChEBI.py @@ -2,7 +2,11 @@ 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 @@ -10,8 +14,203 @@ 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" -IUPAC_NAME = "2,2',2'',2'''-[ethane-1,2-diylbis(oxy-2,1-phenylenenitrilo)]tetraacetic acid" -SMILES = "OC(=O)CN(CCOc1ccccc1N(CC(O)=O)CC(O)=O)CC(O)=O" +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() @@ -23,81 +222,111 @@ def 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 "inchikey" in result 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 "smiles" in result + assert result["smiles"] == SMILES @pytest.mark.dependency(depends=["test_service_available"]) -def test_smiles_to_chebiid(): - result = asyncio.run(wrap_with_session(ChEBI, "smiles_to_chebiid", [SMILES])) +def test_chebiid_to_formula(): + result = asyncio.run(wrap_with_session(ChEBI, "chebiid_to_formula", [CHEBI_ID])) assert result is not None - assert "chebiid" in result - assert result["chebiid"] == CHEBI_ID + assert result["formula"] == FORMULA @pytest.mark.dependency(depends=["test_service_available"]) -def test_chebiid_to_inchi(): - result = asyncio.run(wrap_with_session(ChEBI, "chebiid_to_inchi", [CHEBI_ID])) +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 "inchi" in result - assert result["inchi"] == INCHI + 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 "iupac_name" in result 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])) +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 "chebiid" in result assert result["chebiid"] == CHEBI_ID + assert result["compound_name"] == CHEBI_NAME @pytest.mark.dependency(depends=["test_service_available"]) -def test_iupac_name_to_chebiid(): +def test_compound_name_to_iupac_name(): result = asyncio.run( - wrap_with_session(ChEBI, "iupac_name_to_chebiid", [IUPAC_NAME]) + wrap_with_session(ChEBI, "compound_name_to_iupac_name", [COMPOUND_NAME]) ) assert result is not None - assert "chebiid" in result + 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_chebiid_to_formula(): - result = asyncio.run(wrap_with_session(ChEBI, "chebiid_to_formula", [CHEBI_ID])) +def test_inchikey_to_iupac_name(): + result = asyncio.run(wrap_with_session(ChEBI, "inchikey_to_iupac_name", [INCHIKEY])) assert result is not None - assert "formula" in result + 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])) +def test_inchi_to_chebiid(): + result = asyncio.run(wrap_with_session(ChEBI, "inchi_to_chebiid", [INCHI])) assert result is not None - assert "chebiid" in result assert result["chebiid"] == CHEBI_ID @pytest.mark.dependency(depends=["test_service_available"]) -def test_compound_name_to_chebiid(): +def test_iupac_name_to_chebiid(): result = asyncio.run( - wrap_with_session(ChEBI, "compound_name_to_chebiid", [COMPOUND_NAME]) + wrap_with_session(ChEBI, "iupac_name_to_chebiid", [IUPAC_NAME]) ) assert result is not None - assert "chebiid" in result + 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"]) @@ -109,7 +338,25 @@ def test_format_search_response(): assert isinstance(response, str) data = json.loads(response) - assert "results" in data + 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"]) @@ -121,99 +368,126 @@ def test_format_entity_response(): assert isinstance(response, str) data = json.loads(response) - assert "chebi_accession" in data 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_nested_response(): +def test_parse_entity_without_structures(): response = json.dumps( { - "chebi_accession": CHEBI_ID, - "name": "bapta", - "chemical_data": {"formula": "C22H22N2O10"}, - "default_structure": { - "smiles": "C1=CC=C(C=C1)O", - "standard_inchi": "InChI=1S/C6H6O/c7-6-4-2-1-3-5-6/h1-5,7H", - "standard_inchi_key": INCHIKEY, - }, + "id": 26649, + "chebi_accession": "CHEBI:26649", + "name": "serine derivative", + "ascii_name": "serine derivative", + "stars": 3, "names": { - "IUPAC NAME": [ + "SYNONYM": [ { - "name": IUPAC_NAME, - "type": "IUPAC NAME", - "source": "IUPAC", - "ascii_name": IUPAC_NAME, + "name": "serine derivatives", + "status": "C", + "type": "SYNONYM", + "source": "ChEBI", + "ascii_name": "serine derivatives", + "adapted": False, + "language_code": "en", } - ], - "SYNONYM": [ - {"name": "bapta", "type": "SYNONYM"}, - ], + ] }, + "chemical_data": None, + "default_structure": None, + "secondary_ids": [], } ) parsed = ChEBI(None).parse_entity(response) assert parsed == { - "chebiid": CHEBI_ID, - "compound_name": "bapta", - "iupac_name": IUPAC_NAME, - "formula": "C22H22N2O10", - "smiles": "C1=CC=C(C=C1)O", - "inchi": "InChI=1S/C6H6O/c7-6-4-2-1-3-5-6/h1-5,7H", - "inchikey": INCHIKEY, + "chebiid": "CHEBI:26649", + "compound_name": "serine derivative", } -def test_parse_entity_synonyms_fallback(): - """Ensure iupac_name can also be extracted from legacy synonyms list format.""" - response = json.dumps( - { - "chebi_accession": CHEBI_ID, - "name": "bapta", - "synonyms": [ - {"type": "IUPAC NAME", "data": IUPAC_NAME}, - {"type": "Synonym", "data": "bapta"}, - ], - } +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 ) - parsed = ChEBI(None).parse_entity(response) - assert parsed is not None - assert parsed["iupac_name"] == IUPAC_NAME +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_parse_search_response_source(): - response = json.dumps( - { - "results": [ - { - "_source": { - "chebi_accession": CHEBI_ID, - "ascii_name": COMPOUND_NAME, - "formula": "C22H22N2O10", - "smiles": "NCCO", - "inchi": "InChI=1S/C2H7NO/c3-1-2-4/h4H,1-3H2", - "standard_inchi_key": INCHIKEY, - "iupac_names": [IUPAC_NAME], - } - } - ] - } +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 - parsed = ChEBI(None).parse_search_results(response) - assert parsed == { - "chebiid": CHEBI_ID, - "compound_name": COMPOUND_NAME, - "iupac_name": IUPAC_NAME, - "formula": "C22H22N2O10", - "smiles": "NCCO", - "inchi": "InChI=1S/C2H7NO/c3-1-2-4/h4H,1-3H2", - "inchikey": INCHIKEY, - } +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(): @@ -221,32 +495,9 @@ def test_get_conversions(): 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 - - -def test_parse_structure_search_response(): - response = json.dumps( - [ - { - "chebi_accession": CHEBI_ID, - "ascii_name": COMPOUND_NAME, - "chemical_data": {"formula": "C22H24N2O10"}, - "default_structure": { - "smiles": SMILES, - "standard_inchi": INCHI, - "standard_inchi_key": INCHIKEY, - }, - } - ] - ) - - parsed = ChEBI(None).parse_structure_search_results(response) - - assert parsed is not None - assert parsed["chebiid"] == CHEBI_ID - assert parsed["compound_name"] == COMPOUND_NAME - assert parsed["smiles"] == SMILES - assert parsed["inchikey"] == INCHIKEY