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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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 )