Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
558a1b2
Initial plan
Copilot Aug 25, 2026
e07a8fd
Add ChEBI web service converter
Copilot Aug 25, 2026
5b2da6a
Use CHEBI:60888 as test entity in test_ChEBI.py
Copilot Aug 25, 2026
5326dab
Fix ChEBI API endpoints and add name search test for 'bapta'
Copilot Aug 25, 2026
5abfa37
Fix ChEBI accession field mapping
Copilot Aug 25, 2026
d53ed44
Fix ChEBI response parsing for current API
Copilot Aug 25, 2026
1ce8cde
Handle falsy ChEBI field values safely
Copilot Aug 25, 2026
493730f
Fix failing ChEBI tests: update INCHIKEY to match current ChEBI API d…
Copilot Aug 25, 2026
a6d074c
Also URL-encode query parameters in ChEBI search methods
Copilot Aug 25, 2026
7e2bb0b
Add INCHI/IUPAC name constants and iupac_name support to ChEBI conver…
Copilot Aug 25, 2026
67be3d8
Fix ChEBI from_chebiid to supplement entity endpoint with es_search f…
Copilot Aug 26, 2026
e1d7674
Only call es_search fallback in from_chebiid when iupac_name is missing
Copilot Aug 26, 2026
6aca29c
Fix ChEBI iupac_name extraction: use names dict from entity endpoint,…
Copilot Aug 26, 2026
4983295
Skip falsy list elements in _get_first_value
Copilot Aug 26, 2026
47ac1bf
Use structure_search endpoint with similarity threshold=1 for SMILES …
Copilot Aug 26, 2026
58b6b6c
Fix ChEBI structure_search API: use similarity parameter instead of t…
Copilot Aug 26, 2026
c1a6235
Merge branch 'main' into copilot/add-new-chebi-service
hechth Oct 20, 2026
1118126
Merge branch 'main' into copilot/add-new-chebi-service
hechth Oct 29, 2026
106b5f1
added more tests for chebi
hechth Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
283 changes: 283 additions & 0 deletions MSMetaEnhancer/libs/converters/web/ChEBI.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
import json
from urllib.parse import quote

from frozendict import frozendict

from MSMetaEnhancer.libs.converters.web.WebConverter import WebConverter


class ChEBI(WebConverter):
"""
ChEBI (Chemical Entities of Biological Interest) is a freely available dictionary of
molecular entities focused on small chemical compounds.

ChEBI service: https://www.ebi.ac.uk/chebi/
API documentation: https://www.ebi.ac.uk/chebi/backend/api/docs/

Identifiers and names are resolved by the `es_search` endpoint, structures by the
`structure_search` endpoint. Both only return the fields of a compound summary, so the
obtained accession is used to retrieve the whole record from the `compound` endpoint,
which is the only one reporting the IUPAC name.
"""

SEARCH_SIZE = 15
"""Number of search results requested from the ChEBI search endpoints."""

IDENTIFIER_FIELDS = ("chebi_accession", "inchikey", "inchi", "smiles")
"""Fields used to positively identify a search result by the searched identifier."""

NAME_FIELDS = ("ascii_name", "name")
"""Fields used to positively identify a search result by the searched name."""

def __init__(self, session):
super().__init__(session)
# service URLs
self.endpoints = {
"ChEBI": "https://www.ebi.ac.uk/chebi/backend/api/public/",
}

# attributes as reported by the compound endpoint
self.attributes = [
{"code": "chebiid", "paths": [("chebi_accession",)]},
{"code": "compound_name", "paths": [("ascii_name",), ("name",)]},
{"code": "formula", "paths": [("chemical_data", "formula")]},
{"code": "smiles", "paths": [("default_structure", "smiles")]},
{"code": "inchi", "paths": [("default_structure", "standard_inchi")]},
{
"code": "inchikey",
"paths": [("default_structure", "standard_inchi_key")],
},
]

# generate top level methods defining allowed conversions
conversions = [
("compound_name", "chebiid", "from_name"),
("compound_name", "iupac_name", "from_name"),
("compound_name", "inchikey", "from_name"),
("compound_name", "inchi", "from_name"),
("compound_name", "smiles", "from_name"),
("compound_name", "formula", "from_name"),
("iupac_name", "chebiid", "from_iupac_name"),
("iupac_name", "compound_name", "from_iupac_name"),
("iupac_name", "inchikey", "from_iupac_name"),
("iupac_name", "inchi", "from_iupac_name"),
("iupac_name", "smiles", "from_iupac_name"),
("iupac_name", "formula", "from_iupac_name"),
("inchikey", "chebiid", "from_inchikey"),
("inchikey", "compound_name", "from_inchikey"),
("inchikey", "iupac_name", "from_inchikey"),
("inchikey", "inchi", "from_inchikey"),
("inchikey", "smiles", "from_inchikey"),
("inchikey", "formula", "from_inchikey"),
("inchi", "chebiid", "from_inchi"),
("inchi", "compound_name", "from_inchi"),
("inchi", "iupac_name", "from_inchi"),
("inchi", "inchikey", "from_inchi"),
("inchi", "smiles", "from_inchi"),
("inchi", "formula", "from_inchi"),
("smiles", "chebiid", "from_smiles"),
("smiles", "compound_name", "from_smiles"),
("smiles", "iupac_name", "from_smiles"),
("smiles", "inchikey", "from_smiles"),
("smiles", "inchi", "from_smiles"),
("smiles", "formula", "from_smiles"),
("chebiid", "compound_name", "from_chebiid"),
("chebiid", "iupac_name", "from_chebiid"),
("chebiid", "inchikey", "from_chebiid"),
("chebiid", "inchi", "from_chebiid"),
("chebiid", "smiles", "from_chebiid"),
("chebiid", "formula", "from_chebiid"),
]
self.create_top_level_conversion_methods(conversions)

async def from_name(self, name):
"""
Convert compound name to all possible attributes using ChEBI service.

Only compounds reported under the given name by ChEBI are accepted, common names
present in ChEBI merely as synonyms are not resolved by the search endpoint.

:param name: given compound name
:return: all found data
"""
return await self._from_es_search(name)

async def from_iupac_name(self, iupac_name):
"""
Convert IUPAC name to all possible attributes using ChEBI service.

:param iupac_name: given IUPAC name
:return: all found data
"""
return await self._from_es_search(iupac_name, by_term=False)

async def from_inchikey(self, inchikey):
"""
Convert InChIKey to all possible attributes using ChEBI service.

:param inchikey: given InChIKey
:return: all found data
"""
return await self._from_es_search(inchikey)

async def from_inchi(self, inchi):
"""
Convert InChI to all possible attributes using ChEBI service.

Only standard InChIs are accepted by the ChEBI search endpoint.

:param inchi: given InChI string
:return: all found data
"""
return await self._from_es_search(inchi)

async def _from_es_search(self, term, by_term=True):
"""
Search ChEBI for the given term and return the attributes of the matching compound.

:param term: search term (name, IUPAC name, InChIKey or InChI)
:param by_term: whether the reported fields of a result are compared to the searched
term; IUPAC names are not reported by the search endpoint, so searches by IUPAC
name accept a single unambiguous result instead
:return: all found data
"""
args = f"es_search/?term={quote(term)}&page=1&size={self.SEARCH_SIZE}"
response = await self.query_the_service("ChEBI", args)
if not response:
return None
chebiid = self.extract_chebiid(response, term if by_term else None)
if chebiid is None:
return None
return await self.from_chebiid(chebiid)

async def from_smiles(self, smiles):
"""
Convert SMILES to all possible attributes using ChEBI structure search service.

ChEBI scores the submitted structure without re-canonicalising it, so the similarity
threshold of 1 accepts structures that ChEBI reports in the submitted form.

:param smiles: given SMILES string
:return: all found data
"""
data = frozendict({"structure": smiles, "type": "similarity", "similarity": 1})
response = await self.query_the_service(
"ChEBI", "structure_search/", method="POST", data=data
)
if not response:
return None
chebiid = self.extract_chebiid(response)
if chebiid is None:
return None
return await self.from_chebiid(chebiid)

async def from_chebiid(self, chebiid):
"""
Convert ChEBI ID to all possible attributes using ChEBI service.

:param chebiid: given ChEBI ID (e.g. 'CHEBI:60888')
:return: all found data
"""
args = f"compound/{chebiid}/"
response = await self.query_the_service("ChEBI", args)
if response:
return self.parse_entity(response)

def parse_entity(self, response):
"""
Parse attributes from a single ChEBI compound response.

:param response: JSON string from /compound/{chebiId}/ endpoint
:return: dict of parsed attributes
"""
return self._extract_attributes(json.loads(response))

def extract_chebiid(self, response, term=None):
"""
Obtain the ChEBI accession of the search result corresponding to the searched term.

ChEBI searches are ranked by relevance and report related compounds first (searching
for 'bapta' ranks '5,5'-dibromo-BAPTA above 'BAPTA'), so a result is accepted only
when it can be positively identified - either its reported accession, identifier or
name equals the searched term, or, when no term is given, the search returned a
single compound.

:param response: JSON string from a ChEBI search endpoint
:param term: term that was used for the search, None to require a single result
:return: ChEBI accession of the matching compound or None
"""
entities = [
result.get("_source", result)
for result in json.loads(response).get("results", [])
if isinstance(result, dict)
]
entities = [entity for entity in entities if isinstance(entity, dict)]
if not entities:
return None
if term is None:
# structure searches are not ranked by a term, a single hit is required
return entities[0].get("chebi_accession") if len(entities) == 1 else None
searched = term.strip().casefold()
for field in self.IDENTIFIER_FIELDS + self.NAME_FIELDS:
for entity in entities:
value = entity.get(field)
if isinstance(value, str) and value.strip().casefold() == searched:
return entity.get("chebi_accession")
return None

def _extract_attributes(self, entity):
"""
Extract known attributes from a ChEBI compound dict.

:param entity: dict representing a ChEBI compound
:return: dict of parsed attributes
"""
if not isinstance(entity, dict):
return None
result = {}
for att in self.attributes:
value = self._get_first_value(entity, att["paths"])
if value is not None:
result[att["code"]] = value
iupac_name = self._extract_iupac_name(entity)
if iupac_name is not None:
result["iupac_name"] = iupac_name
return result if result else None

@staticmethod
def _extract_iupac_name(entity):
"""
Extract the IUPAC name from the names of a ChEBI compound.

:param entity: dict representing a ChEBI compound
:return: IUPAC name string or None
"""
names = entity.get("names")
if not isinstance(names, dict):
return None
for entry in names.get("IUPAC NAME", []):
if isinstance(entry, dict):
value = entry.get("ascii_name") or entry.get("name")
if value:
return value
return None

def _get_first_value(self, entity, paths):
"""
Return the first non-empty value found in the given candidate paths.
Paths that yield no value are skipped and the following ones are tried.

:param entity: dict representing a ChEBI compound
:param paths: candidate key paths to try
:return: first non-empty value or None
"""
for path in paths:
value = entity
for key in path:
if not isinstance(value, dict):
value = None
break
value = value.get(key)
if value:
return value
return None
3 changes: 2 additions & 1 deletion MSMetaEnhancer/libs/converters/web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from MSMetaEnhancer.libs.converters.web.CIR import CIR
from MSMetaEnhancer.libs.converters.web.PubChem import PubChem
from MSMetaEnhancer.libs.converters.web.BridgeDb import BridgeDb
from MSMetaEnhancer.libs.converters.web.ChEBI import ChEBI
from MSMetaEnhancer.libs.converters.web.Wikidata import Wikidata

__all__ = ["IDSM", "CTS", "CIR", "PubChem", "BridgeDb", "Wikidata"]
__all__ = ["IDSM", "CTS", "CIR", "PubChem", "BridgeDb", "Wikidata", "ChEBI"]
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()

Expand All @@ -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'),
Expand Down
8 changes: 8 additions & 0 deletions docs/source/MSMetaEnhancer.libs.webConverters.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ Wikidata
:undoc-members:
:show-inheritance:

ChEBI
-----

.. automodule:: MSMetaEnhancer.libs.converters.web.ChEBI
:members:
:undoc-members:
:show-inheritance:

General Web Converter
---------------------

Expand Down
3 changes: 2 additions & 1 deletion galaxy/generate_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
PubChem,
BridgeDb,
Wikidata,
ChEBI,
)
from MSMetaEnhancer.libs.converters.compute import RDKit
from MSMetaEnhancer.libs.utils.ConverterBuilder import ConverterBuilder
Expand All @@ -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
Expand Down
Loading
Loading