diff --git a/CLAUDE.md b/CLAUDE.md index cc0a558..0d5ac20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,10 @@ uv run babel-explorer ids MONDO:0004979 # Test concordance changes with NodeNorm uv run babel-explorer test-concord MONDO:0004979 HP:0000001 +# Search external providers (OLS4, MyChem.info) for candidate xrefs +# and diff against Babel's Concord — finds xrefs worth importing into Babel. +uv run babel-explorer search-xrefs CHEBI:31941 --ignore-known + # Use custom Babel server or local directory uv run babel-explorer xrefs MONDO:0004979 --local-dir data/2025nov19 --babel-url https://stars.renci.org:443/var/babel/2025nov19/ ``` @@ -91,7 +95,18 @@ uv run ruff format 4. **CLI** (`src/babel_explorer/cli.py`): - Click-based command-line interface - - Three main commands: `xrefs`, `ids`, `test-concord` + - Four main commands: `xrefs`, `ids`, `test-concord`, `search-xrefs` + - Shared `@format_option` decorator adds `--format [console|json|tsv|csv]` and `--json-indent` to every command + +5. **XRef Providers** (`src/babel_explorer/core/providers/`): + - Pluggable external mapping sources used by `search-xrefs` (currently OLS4 and MyChem.info) + - `XRefProvider` `typing.Protocol` + module-level `PROVIDERS` registry dict (see `providers/__init__.py`) + - Each provider mirrors the `NodeNorm` pattern: `requests`, `@functools.lru_cache(maxsize=None)` on `fetch()`, empty-URL skip, frozen-dataclass results + - **Adding a new provider**: write a class with `name: str` and `fetch(curie) -> list[CandidateXRef]`, then append a factory to `PROVIDERS` in `providers/__init__.py`. No CLI changes needed — the registry drives `--providers` selection. + +6. **curie_utils** (`src/babel_explorer/core/curie_utils.py`): + - Shared CURIE↔IRI helpers (`split_curie`, `to_iri`, `from_iri`) and a `DEFAULT_PREFIX_MAP` of common Translator prefixes + - Used by providers that query external services by IRI (e.g. OLS4) ### Data Flow @@ -123,6 +138,10 @@ Tests live in `tests/` and are split into fast **unit tests** (mocked, no networ | `tests/test_babel_xrefs.py` | 23 | 20 | 3 | 46 | | `tests/test_nodenorm.py` | 20 | 13 | 0 | 33 | | `tests/test_cli.py` | 24 | 0 | 0 | 24 | +| `tests/test_curie_utils.py` | 26 | 0 | 0 | 26 | +| `tests/test_providers_ols.py` | 17 | 2 | 0 | 19 | +| `tests/test_providers_mychem.py` | 21 | 3 | 0 | 24 | +| `tests/test_search_xrefs_cli.py` | 15 | 0 | 0 | 15 | ### Test Infrastructure @@ -136,6 +155,7 @@ Tests live in `tests/` and are split into fast **unit tests** (mocked, no networ - **`CrossReference`** — Frozen dataclass for Concord.parquet rows (filename, subj, pred, obj) - **`LabeledCrossReference`** — Extends CrossReference with labels and biolink types from NodeNorm - **`IdentifierRecord`** — Frozen dataclass for Identifiers.parquet rows (curie + dynamic extra fields). Returned by `BabelXRefs.get_curie_ids()`. +- **`CandidateXRef`** — Frozen dataclass for an xref candidate from an external provider (query_curie, target_curie, provider, predicate, confidence, evidence, in_babel, target_label, target_biolink_type). Returned by `XRefProvider.fetch()`. ## Important Notes diff --git a/src/babel_explorer/cli.py b/src/babel_explorer/cli.py index 58f1e5e..c84255d 100644 --- a/src/babel_explorer/cli.py +++ b/src/babel_explorer/cli.py @@ -1,11 +1,18 @@ # Command line interface for babel-explorer +import dataclasses import click import logging from babel_explorer.core.downloader import BabelDownloader from babel_explorer.core.babel_xrefs import BabelXRefs from babel_explorer.core.nodenorm import NodeNorm from babel_explorer.core.babel_xrefs import LabeledCrossReference -from babel_explorer.formatting import write_records, _record_to_dict, make_console, hl_curie +from babel_explorer.core.providers import PROVIDERS +from babel_explorer.formatting import ( + write_records, + _record_to_dict, + make_console, + hl_curie, +) from rich.markup import escape @@ -181,7 +188,14 @@ def xrefs( "'never' disables re-checking and always uses cached files; '0' forces a re-check every time.", ) @format_option -def ids(curies: list[str], babel_url: str, local_dir: str, check_download: str, fmt: str, json_indent: int): +def ids( + curies: list[str], + babel_url: str, + local_dir: str, + check_download: str, + fmt: str, + json_indent: int, +): """ Fetches and prints the ID records for the given CURIEs, along with Biolink type if provided. @@ -248,5 +262,163 @@ def test_concord(curies, nodenorm_url, fmt, json_indent): write_records(rows, fmt=fmt, indent=json_indent) +@cli.command("search-xrefs") +@click.argument("curies", type=str, required=True, nargs=-1) +@click.option( + "--providers", + "providers_arg", + type=str, + default="", + help="Comma-separated list of provider names to query " + "(default: all registered, e.g. 'ols,mychem').", +) +@click.option( + "--ols-url", + type=str, + default="https://www.ebi.ac.uk/ols4", + show_default=True, + help="Base URL of the OLS4 server.", +) +@click.option( + "--mychem-url", + type=str, + default="https://mychem.info/v1", + show_default=True, + help="Base URL of the MyChem.info API.", +) +@click.option( + "--external-timeout", + type=int, + default=30, + show_default=True, + help="HTTP request timeout (seconds) for external providers.", +) +@click.option( + "--ignore-known", + is_flag=True, + help="Drop candidates that already exist as a Concord edge in Babel.", +) +@click.option("--labels", is_flag=True, help="Resolve target labels via NodeNorm.") +@click.option( + "--local-dir", + type=str, + default="data/2025nov19", + help="Local location to save Babel download files to", +) +@click.option( + "--babel-url", + type=str, + default="https://stars.renci.org:443/var/babel/2025nov19/", + help="Base URL of the Babel server", +) +@click.option( + "--nodenorm-url", + type=str, + default="https://nodenormalization-sri.renci.org/", + help="NodeNorm base URL (used for --labels and for MyChem InChIKey resolution).", +) +@click.option( + "--check-download", + type=str, + default="3h", + show_default=True, + help="How often to re-check downloads (e.g. '3h', '30m', '1d', '0', 'never').", +) +@format_option +def search_xrefs( + curies: tuple[str, ...], + providers_arg: str, + ols_url: str, + mychem_url: str, + external_timeout: int, + ignore_known: bool, + labels: bool, + local_dir: str, + babel_url: str, + nodenorm_url: str, + check_download: str, + fmt: str, + json_indent: int, +): + """Search external mapping providers for cross-references and diff against Babel. + + For each CURIE, query the selected providers (OLS4, MyChem.info, ...), then + annotate each candidate with whether it already exists as an edge in Babel's + local Concord.parquet. Use ``--ignore-known`` to filter the output to only + candidates Babel does not yet know about. + """ + logging.basicConfig(level=logging.INFO) + + selected = [n.strip() for n in providers_arg.split(",") if n.strip()] or list( + PROVIDERS.keys() + ) + for name in selected: + if name not in PROVIDERS: + raise click.BadParameter( + f"Unknown provider {name!r}. Known: {', '.join(PROVIDERS)}." + ) + + freshness = parse_duration(check_download) + nodenorm = NodeNorm(nodenorm_url) + bxref = BabelXRefs( + BabelDownloader(babel_url, local_path=local_dir, freshness_seconds=freshness), + nodenorm, + ) + + provider_kwargs = { + "ols_url": ols_url, + "mychem_url": mychem_url, + "nodenorm": nodenorm, + "timeout": external_timeout, + } + providers = [PROVIDERS[name](**provider_kwargs) for name in selected] + + candidates = [] + for curie in curies: + known_pairs = {x.curies for x in bxref.get_curie_xref(curie)} + for provider in providers: + for cand in provider.fetch(curie): + in_babel = ( + frozenset({cand.query_curie, cand.target_curie}) in known_pairs + ) + if ignore_known and in_babel: + continue + target_label = "" + target_biolink_type: tuple[str, ...] = () + if labels: + ident = nodenorm.get_identifier(cand.target_curie) + target_label = ident.label + target_biolink_type = ident.biolink_type + candidates.append( + dataclasses.replace( + cand, + in_babel=in_babel, + target_label=target_label, + target_biolink_type=target_biolink_type, + ) + ) + + candidates.sort() + + if fmt == "console": + console = make_console() + query_set = set(curies) + for c in candidates: + query_str = hl_curie(c.query_curie, c.query_curie in query_set) + target_str = hl_curie(c.target_curie, c.target_curie in query_set) + if c.target_label: + target_str += f" ({escape(c.target_label)})" + marker = ( + "[bold green]NEW[/bold green]" if not c.in_babel else "[dim]known[/dim]" + ) + console.print( + f"{query_str} [dim]→[/dim] {target_str} " + f"[dim]\\[{escape(c.provider)}][/dim] " + f"[dim]{escape(c.predicate)}[/dim] {marker}" + ) + else: + write_records(candidates, fmt=fmt, indent=json_indent) + + if __name__ == "__main__": cli() diff --git a/src/babel_explorer/core/curie_utils.py b/src/babel_explorer/core/curie_utils.py new file mode 100644 index 0000000..34c313a --- /dev/null +++ b/src/babel_explorer/core/curie_utils.py @@ -0,0 +1,68 @@ +"""CURIE/IRI helpers shared by external xref providers. + +A *CURIE* is a compact identifier of the form ``PREFIX:LOCAL_ID`` (e.g. +``CHEBI:31941``). An *IRI* is the expanded form +(``http://purl.obolibrary.org/obo/CHEBI_31941``). Providers like OLS4 query by +IRI, so each provider client uses the helpers here to translate. + +The default prefix map covers the subset of identifier types currently used +by babel-explorer. Extend it as new providers come online. +""" + +from typing import Mapping + + +DEFAULT_PREFIX_MAP: dict[str, str] = { + "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", + "MONDO": "http://purl.obolibrary.org/obo/MONDO_", + "HP": "http://purl.obolibrary.org/obo/HP_", + "PUBCHEM.COMPOUND": "http://identifiers.org/pubchem.compound/", + "UMLS": "http://linkedlifedata.com/resource/umls/id/", + "CHEMBL.COMPOUND": "http://identifiers.org/chembl.compound/", + "DRUGBANK": "http://identifiers.org/drugbank/", + "KEGG.COMPOUND": "http://identifiers.org/kegg.compound/", + "UNII": "http://fdasis.nlm.nih.gov/srs/unii/", + "INCHIKEY": "http://identifiers.org/inchikey/", +} + + +def split_curie(curie: str) -> tuple[str, str]: + """Split ``curie`` into ``(prefix, local_id)`` on the first colon. + + :raises ValueError: If ``curie`` is empty or has no colon. + """ + if not curie or ":" not in curie: + raise ValueError(f"Not a valid CURIE: {curie!r}") + prefix, local_id = curie.split(":", 1) + if not prefix or not local_id: + raise ValueError(f"Not a valid CURIE: {curie!r}") + return prefix, local_id + + +def to_iri(curie: str, prefix_map: Mapping[str, str] = DEFAULT_PREFIX_MAP) -> str: + """Expand ``curie`` to an IRI using ``prefix_map``. + + :raises KeyError: If the CURIE prefix is not in ``prefix_map``. + """ + prefix, local_id = split_curie(curie) + try: + iri_prefix = prefix_map[prefix] + except KeyError: + raise KeyError(f"Unknown CURIE prefix {prefix!r} (not in prefix map)") + return iri_prefix + local_id + + +def from_iri(iri: str, prefix_map: Mapping[str, str] = DEFAULT_PREFIX_MAP) -> str: + """Contract ``iri`` back to a CURIE using ``prefix_map``. + + Tries the longest matching prefix first so e.g. ``CHEMBL.COMPOUND`` wins + over a hypothetical bare ``CHEMBL`` entry. + + :raises ValueError: If no prefix in ``prefix_map`` matches the IRI. + """ + for prefix, iri_prefix in sorted( + prefix_map.items(), key=lambda kv: len(kv[1]), reverse=True + ): + if iri.startswith(iri_prefix): + return f"{prefix}:{iri[len(iri_prefix) :]}" + raise ValueError(f"No CURIE prefix in map matches IRI {iri!r}") diff --git a/src/babel_explorer/core/providers/__init__.py b/src/babel_explorer/core/providers/__init__.py new file mode 100644 index 0000000..0f55d03 --- /dev/null +++ b/src/babel_explorer/core/providers/__init__.py @@ -0,0 +1,75 @@ +"""Pluggable cross-reference providers for `babel-explorer search-xrefs`. + +Each provider queries an external mapping source (OLS4, MyChem.info, ...) for +candidate cross-references and returns a list of ``CandidateXRef`` records that +the CLI then diffs against Babel's local ``Concord.parquet``. + +Adding a new provider: + 1. Create a class in this package whose interface matches ``XRefProvider`` + (a ``name`` attribute and a ``fetch(curie) -> list[CandidateXRef]`` method). + 2. Register a factory in ``PROVIDERS`` below. +""" + +import dataclasses +from typing import Callable, Protocol, runtime_checkable + + +@dataclasses.dataclass(frozen=True) +class CandidateXRef: + """A candidate cross-reference proposed by an external mapping source.""" + + query_curie: str + target_curie: str + provider: str + predicate: str + confidence: float | None + evidence: str + in_babel: bool + target_label: str = "" + target_biolink_type: tuple[str, ...] = () + + def __lt__(self, other): + return (self.query_curie, self.provider, self.target_curie) < ( + other.query_curie, + other.provider, + other.target_curie, + ) + + +@runtime_checkable +class XRefProvider(Protocol): + """Interface that every cross-reference provider implements.""" + + name: str + + def fetch(self, curie: str) -> list[CandidateXRef]: ... + + +# Provider registry — populated after provider classes are imported. +PROVIDERS: dict[str, Callable[..., XRefProvider]] = {} + + +# Imports placed after type definitions so provider modules can safely +# `from babel_explorer.core.providers import CandidateXRef`. +from babel_explorer.core.providers.ols import OLS4Provider # noqa: E402 + + +def _build_ols(**kw) -> "OLS4Provider": + return OLS4Provider(kw.get("ols_url", ""), timeout=kw.get("timeout", 30)) + + +PROVIDERS["ols"] = _build_ols + + +from babel_explorer.core.providers.mychem import MyChemProvider # noqa: E402 + + +def _build_mychem(**kw) -> "MyChemProvider": + return MyChemProvider( + kw.get("mychem_url", ""), + nodenorm=kw.get("nodenorm"), + timeout=kw.get("timeout", 30), + ) + + +PROVIDERS["mychem"] = _build_mychem diff --git a/src/babel_explorer/core/providers/mychem.py b/src/babel_explorer/core/providers/mychem.py new file mode 100644 index 0000000..7c69354 --- /dev/null +++ b/src/babel_explorer/core/providers/mychem.py @@ -0,0 +1,181 @@ +"""MyChem.info cross-reference provider. + +Hits ``/chem/{id}`` on MyChem.info and emits the canonical per-source IDs +(CHEBI, DrugBank, PubChem CID, UNII, ChEMBL, InChIKey) as ``CandidateXRef``s. +MyChem is forgiving about input IDs (CHEBI CURIEs, PubChem CIDs, ChEMBL IDs, +DrugBank IDs, UNIIs, InChIKeys all work), so for most inputs we pass the local +ID through directly. For inputs MyChem doesn't recognise, we fall back to +looking up an InChIKey in the NodeNorm clique. +""" + +import functools +import logging + +import requests + +from babel_explorer.core.providers import CandidateXRef +from babel_explorer.core.nodenorm import NodeNorm + + +_MYCHEM_PREDICATE = "skos:exactMatch" +_MYCHEM_CONFIDENCE = 0.9 + +# Fields to request from MyChem.info — one canonical ID per source section. +_FIELDS = "chebi.id,pubchem.cid,unii.unii,drugbank.id,chembl.molecule_chembl_id" + +# Source section → (value-field, Babel CURIE prefix, value-already-a-curie?) +# Used to translate each per-source ID in the MyChem response to a CandidateXRef. +_SOURCE_TO_BABEL = [ + ("chebi", "id", "CHEBI", True), + ("drugbank", "id", "DRUGBANK", False), + ("pubchem", "cid", "PUBCHEM.COMPOUND", False), + ("unii", "unii", "UNII", False), + ("chembl", "molecule_chembl_id", "CHEMBL.COMPOUND", False), +] + +# CURIE prefixes that MyChem.info accepts as direct lookup IDs (we pass through +# the local-ID portion). Anything else routes via NodeNorm InChIKey resolution. +_DIRECT_LOOKUP_PREFIXES = { + "CHEBI", + "DRUGBANK", + "PUBCHEM.COMPOUND", + "UNII", + "CHEMBL.COMPOUND", + "INCHIKEY", +} + + +class MyChemProvider: + """Client for the MyChem.info v1 chemical-annotation API.""" + + name = "MyChem.info" + + def __init__( + self, + mychem_url: str = "", + nodenorm: NodeNorm | None = None, + timeout: int = 30, + ): + """ + :param mychem_url: Base URL of MyChem.info (e.g. ``https://mychem.info/v1``). + Pass an empty string to skip all network calls. + :param nodenorm: Optional ``NodeNorm`` client used to resolve inputs that + MyChem can't look up directly (e.g. UMLS CUIs → InChIKey via the clique). + :param timeout: HTTP request timeout in seconds. + """ + self.mychem_url = mychem_url.rstrip("/") + self.nodenorm = nodenorm + self.timeout = timeout + + @functools.lru_cache(maxsize=None) + def fetch(self, curie: str) -> list[CandidateXRef]: + """Return candidate cross-references from MyChem.info for ``curie``. + + Returns an empty list if MyChem doesn't recognise the CURIE (even after + NodeNorm InChIKey fallback), or if ``mychem_url`` is empty. + + :raises requests.HTTPError: If MyChem returns a non-2xx status that + isn't 404 (404 is treated as "unknown ID", not an error). + """ + if not self.mychem_url: + return [] + if ":" not in curie: + return [] + + for lookup_id in self._lookup_ids_for(curie): + data = self._fetch_chem(lookup_id) + if data is None: + continue + candidates = self._extract_candidates(curie, data) + if candidates: + return candidates + return [] + + def _lookup_ids_for(self, curie: str): + """Yield MyChem-compatible lookup IDs to try for ``curie``, in order.""" + prefix, _, local_id = curie.partition(":") + if prefix.upper() in _DIRECT_LOOKUP_PREFIXES: + yield local_id if prefix.upper() != "CHEBI" else curie + # Fallback: ask NodeNorm for an InChIKey in the clique. + if self.nodenorm is not None: + for ident in self.nodenorm.get_clique_identifiers(curie): + if ident.curie.startswith("INCHIKEY:"): + yield ident.curie.split(":", 1)[1] + return # only one InChIKey is needed + + def _fetch_chem(self, lookup_id: str) -> dict | None: + """GET /chem/{id}; return JSON dict, or None on 404.""" + try: + response = requests.get( + f"{self.mychem_url}/chem/{lookup_id}", + params={"fields": _FIELDS}, + timeout=self.timeout, + ) + except requests.RequestException: + raise + if response.status_code == 404: + return None + response.raise_for_status() + data = response.json() + if isinstance(data, dict) and data.get("success") is False: + return None + return data + + def _extract_candidates(self, curie: str, data: dict) -> list[CandidateXRef]: + """Walk a MyChem response and emit a candidate per known equivalent ID.""" + evidence_id = data.get("_id", "") + evidence = ( + f"{self.mychem_url}/chem/{evidence_id}" if evidence_id else self.mychem_url + ) + + seen: set[str] = set() + candidates: list[CandidateXRef] = [] + + # InChIKey from the _id field — high-confidence structural equivalence. + if evidence_id: + target = f"INCHIKEY:{evidence_id}" + if target != curie: + seen.add(target) + candidates.append(self._make_candidate(curie, target, evidence)) + + for section, field, prefix, is_curie in _SOURCE_TO_BABEL: + for entry in _iter_section_entries(data.get(section)): + value = entry.get(field) if isinstance(entry, dict) else None + if value is None or value == "": + continue + target = str(value) if is_curie else f"{prefix}:{value}" + if target == curie or target in seen: + continue + seen.add(target) + candidates.append(self._make_candidate(curie, target, evidence)) + + return candidates + + def _make_candidate(self, query: str, target: str, evidence: str) -> CandidateXRef: + return CandidateXRef( + query_curie=query, + target_curie=target, + provider=self.name, + predicate=_MYCHEM_PREDICATE, + confidence=_MYCHEM_CONFIDENCE, + evidence=evidence, + in_babel=False, + ) + + +def _iter_section_entries(section): + """Normalise a MyChem source-section value into an iterable of dict entries. + + MyChem may return a section as a dict (single record) or a list of dicts + (multiple records for the same InChIKey, e.g. multiple PubChem CIDs). + """ + if section is None: + return + if isinstance(section, list): + for entry in section: + if isinstance(entry, dict): + yield entry + elif isinstance(section, dict): + yield section + else: + logging.debug(f"MyChem: unexpected section type {type(section).__name__}") diff --git a/src/babel_explorer/core/providers/ols.py b/src/babel_explorer/core/providers/ols.py new file mode 100644 index 0000000..a8cd7c1 --- /dev/null +++ b/src/babel_explorer/core/providers/ols.py @@ -0,0 +1,95 @@ +"""OLS4 (Ontology Lookup Service v4) cross-reference provider. + +Looks up a CURIE in OLS4's v2 entities endpoint and emits each ``oboInOwl:hasDbXref`` +value as a ``CandidateXRef``. OLS4 returns dbxref CURIEs with lowercase prefixes +(e.g. ``drugbank:DB00526``); we uppercase the prefix to match Babel convention. +""" + +import functools +import logging + +import requests + +from babel_explorer.core.providers import CandidateXRef +from babel_explorer.core.curie_utils import to_iri + + +_DBXREF_KEY = "http://www.geneontology.org/formats/oboInOwl#hasDbXref" +_OLS_PREDICATE = "oboInOwl:hasDbXref" + + +class OLS4Provider: + """Client for the OLS4 v2 entities API (https://www.ebi.ac.uk/ols4/).""" + + name = "OLS4" + + def __init__(self, ols_url: str = "", timeout: int = 30): + """ + :param ols_url: Base URL of the OLS4 server (e.g. ``https://www.ebi.ac.uk/ols4``). + Pass an empty string to skip all network calls and have every lookup + return an empty list. + :param timeout: HTTP request timeout in seconds. + """ + self.ols_url = ols_url.rstrip("/") + self.timeout = timeout + + @functools.lru_cache(maxsize=None) + def fetch(self, curie: str) -> list[CandidateXRef]: + """Return candidate cross-references from OLS4 for ``curie``. + + Returns an empty list if OLS doesn't recognise the CURIE, the prefix + isn't in the default prefix map, or ``ols_url`` is empty. + + :raises requests.HTTPError: If OLS returns a non-2xx status. + """ + if not self.ols_url: + return [] + + try: + iri = to_iri(curie) + except (ValueError, KeyError) as e: + logging.debug(f"OLS4: cannot expand {curie!r} to IRI ({e}); skipping") + return [] + + url = f"{self.ols_url}/api/v2/entities" + response = requests.get( + url, + params={"iri": iri, "size": 50}, + timeout=self.timeout, + ) + response.raise_for_status() + data = response.json() + + seen: set[str] = set() + candidates: list[CandidateXRef] = [] + for element in data.get("elements", []): + for entry in element.get(_DBXREF_KEY, []) or []: + raw = entry.get("value") if isinstance(entry, dict) else entry + if not raw or not isinstance(raw, str) or ":" not in raw: + continue + prefix, _, local_id = raw.partition(":") + if not prefix or not local_id: + continue + target = _normalize_curie_prefix(raw) + if target == curie or target in seen: + continue + seen.add(target) + candidates.append( + CandidateXRef( + query_curie=curie, + target_curie=target, + provider=self.name, + predicate=_OLS_PREDICATE, + confidence=None, + evidence=iri, + in_babel=False, + ) + ) + + return candidates + + +def _normalize_curie_prefix(curie: str) -> str: + """Uppercase the prefix portion of a CURIE; leave the local ID untouched.""" + prefix, _, local_id = curie.partition(":") + return f"{prefix.upper()}:{local_id}" diff --git a/tests/data/valid_curies.txt b/tests/data/valid_curies.txt index 89a53b3..f014fdd 100644 --- a/tests/data/valid_curies.txt +++ b/tests/data/valid_curies.txt @@ -3,3 +3,7 @@ MONDO:0004979 MONDO:0005044 NCIT:C55060 +# Issue-#715 CURIEs (clique that should be combined but isn't) +CHEBI:31941 +PUBCHEM.COMPOUND:43805 +UMLS:C1314429 diff --git a/tests/test_curie_utils.py b/tests/test_curie_utils.py new file mode 100644 index 0000000..2e1062a --- /dev/null +++ b/tests/test_curie_utils.py @@ -0,0 +1,107 @@ +"""Unit tests for babel_explorer.core.curie_utils.""" + +import pytest + +from babel_explorer.core.curie_utils import ( + DEFAULT_PREFIX_MAP, + split_curie, + to_iri, + from_iri, +) + + +class TestSplitCurie: + def test_basic(self): + assert split_curie("CHEBI:31941") == ("CHEBI", "31941") + + def test_compound_prefix(self): + assert split_curie("PUBCHEM.COMPOUND:43805") == ("PUBCHEM.COMPOUND", "43805") + + def test_only_splits_on_first_colon(self): + assert split_curie("UMLS:C1314429:extra") == ("UMLS", "C1314429:extra") + + @pytest.mark.parametrize( + "bad", ["", "no_colon_here", ":missing_prefix", "missing_local:"] + ) + def test_rejects_invalid(self, bad): + with pytest.raises(ValueError): + split_curie(bad) + + +class TestToIri: + def test_obo_purl(self): + assert to_iri("CHEBI:31941") == "http://purl.obolibrary.org/obo/CHEBI_31941" + + def test_pubchem(self): + assert ( + to_iri("PUBCHEM.COMPOUND:43805") + == "http://identifiers.org/pubchem.compound/43805" + ) + + def test_umls(self): + assert ( + to_iri("UMLS:C1314429") + == "http://linkedlifedata.com/resource/umls/id/C1314429" + ) + + def test_unknown_prefix_raises(self): + with pytest.raises(KeyError): + to_iri("WIBBLE:123") + + def test_custom_prefix_map(self): + custom = {"FOO": "http://example.com/foo/"} + assert to_iri("FOO:bar", custom) == "http://example.com/foo/bar" + + +class TestFromIri: + def test_obo_purl(self): + assert from_iri("http://purl.obolibrary.org/obo/CHEBI_31941") == "CHEBI:31941" + + def test_pubchem(self): + assert ( + from_iri("http://identifiers.org/pubchem.compound/43805") + == "PUBCHEM.COMPOUND:43805" + ) + + def test_unknown_iri_raises(self): + with pytest.raises(ValueError): + from_iri("http://example.com/wibble/123") + + def test_longest_match_wins(self): + """If two prefixes share an IRI base, the longer one is preferred.""" + m = { + "SHORT": "http://x.org/", + "LONG": "http://x.org/sub/", + } + assert from_iri("http://x.org/sub/42", m) == "LONG:42" + + +class TestRoundTrip: + @pytest.mark.parametrize( + "curie", + [ + "CHEBI:31941", + "MONDO:0004979", + "HP:0000001", + "PUBCHEM.COMPOUND:43805", + "UMLS:C1314429", + "CHEMBL.COMPOUND:CHEMBL25", + "DRUGBANK:DB00945", + "KEGG.COMPOUND:C00031", + "UNII:R16CO5Y76E", + "INCHIKEY:BSYNRYMUTXBXSQ-UHFFFAOYSA-N", + ], + ) + def test_curie_iri_roundtrip(self, curie): + assert from_iri(to_iri(curie)) == curie + + +class TestDefaultPrefixMap: + def test_has_issue_715_prefixes(self): + for prefix in ("CHEBI", "PUBCHEM.COMPOUND", "UMLS"): + assert prefix in DEFAULT_PREFIX_MAP + + def test_all_iri_prefixes_end_with_separator(self): + """Each IRI prefix must end with '_' or '/' so naive concat works.""" + for prefix, iri in DEFAULT_PREFIX_MAP.items(): + assert iri.endswith(("_", "/")), f"{prefix}: {iri!r}" diff --git a/tests/test_providers_mychem.py b/tests/test_providers_mychem.py new file mode 100644 index 0000000..52fb94a --- /dev/null +++ b/tests/test_providers_mychem.py @@ -0,0 +1,288 @@ +"""Tests for the MyChem.info cross-reference provider. + +Unit tests use mocks; integration tests call the real MyChem.info API. +""" + +from unittest.mock import Mock, patch, MagicMock + +import pytest +import requests + +from babel_explorer.core.providers.mychem import ( + MyChemProvider, + _iter_section_entries, +) + + +# Captured-style response from /chem/CHEBI:31941 with the canonical-ID fields. +SAMPLE_CHEBI_RESPONSE = { + "_id": "ZROHGHOFXNOHSO-BNTLRKBRSA-L", + "_version": 1, + "chebi": {"id": "CHEBI:31941"}, + "drugbank": {"id": "DB00526"}, + "pubchem": {"cid": 9887054}, + "unii": {"unii": "04ZR38536J"}, +} + +# /chem/43805 returns pubchem-only (a list of two CIDs). +SAMPLE_PUBCHEM_RESPONSE = { + "_id": "DWAFYCQODLXJNR-BNTLRKBRSA-L", + "_version": 1, + "pubchem": [{"cid": 43805}, {"cid": 11947679}], +} + + +def _http(status_code, json_body): + """Build a mock requests.Response with the given status and JSON body.""" + r = Mock() + r.status_code = status_code + r.json.return_value = json_body + if status_code >= 400 and status_code != 404: + r.raise_for_status.side_effect = requests.HTTPError(f"{status_code}") + else: + r.raise_for_status = Mock() + return r + + +class TestInit: + def test_default_url_empty(self): + assert MyChemProvider().mychem_url == "" + + def test_strips_trailing_slash(self): + assert MyChemProvider("https://x.org/").mychem_url == "https://x.org" + + def test_empty_url_returns_empty_without_network(self): + p = MyChemProvider("") + p.fetch.cache_clear() + with patch("babel_explorer.core.providers.mychem.requests.get") as mock_get: + assert p.fetch("CHEBI:31941") == [] + mock_get.assert_not_called() + + def test_non_curie_input_returns_empty(self): + p = MyChemProvider("https://mychem.info/v1") + p.fetch.cache_clear() + with patch("babel_explorer.core.providers.mychem.requests.get") as mock_get: + assert p.fetch("not-a-curie") == [] + mock_get.assert_not_called() + + +class TestFetchMocked: + def _make(self, nodenorm=None): + p = MyChemProvider("https://mychem.info/v1", nodenorm=nodenorm) + p.fetch.cache_clear() + return p + + def test_chebi_lookup_yields_curie_form_id(self): + """CHEBI input is passed as the full CURIE to MyChem (not just the local ID).""" + p = self._make() + with patch( + "babel_explorer.core.providers.mychem.requests.get", + return_value=_http(200, SAMPLE_CHEBI_RESPONSE), + ) as mock_get: + p.fetch("CHEBI:31941") + args, kwargs = mock_get.call_args + assert args[0] == "https://mychem.info/v1/chem/CHEBI:31941" + assert "fields" in kwargs["params"] + + def test_pubchem_lookup_uses_bare_local_id(self): + p = self._make() + with patch( + "babel_explorer.core.providers.mychem.requests.get", + return_value=_http(200, SAMPLE_PUBCHEM_RESPONSE), + ) as mock_get: + p.fetch("PUBCHEM.COMPOUND:43805") + args, _ = mock_get.call_args + assert args[0] == "https://mychem.info/v1/chem/43805" + + def test_extracts_all_canonical_ids(self): + p = self._make() + with patch( + "babel_explorer.core.providers.mychem.requests.get", + return_value=_http(200, SAMPLE_CHEBI_RESPONSE), + ): + candidates = p.fetch("CHEBI:31941") + + targets = {c.target_curie for c in candidates} + # query CURIE itself is filtered out, but the rest should be present + assert "INCHIKEY:ZROHGHOFXNOHSO-BNTLRKBRSA-L" in targets + assert "DRUGBANK:DB00526" in targets + assert "PUBCHEM.COMPOUND:9887054" in targets + assert "UNII:04ZR38536J" in targets + assert "CHEBI:31941" not in targets # self-loop filtered + + for c in candidates: + assert c.provider == "MyChem.info" + assert c.predicate == "skos:exactMatch" + assert c.confidence == 0.9 + assert c.in_babel is False + assert c.query_curie == "CHEBI:31941" + + def test_pubchem_list_returns_multiple_cids(self): + """When MyChem returns a list of pubchem entries, all CIDs are emitted.""" + p = self._make() + with patch( + "babel_explorer.core.providers.mychem.requests.get", + return_value=_http(200, SAMPLE_PUBCHEM_RESPONSE), + ): + candidates = p.fetch("PUBCHEM.COMPOUND:43805") + + targets = {c.target_curie for c in candidates} + # The queried CID is filtered; the second CID and the InChIKey remain. + assert "PUBCHEM.COMPOUND:11947679" in targets + assert "INCHIKEY:DWAFYCQODLXJNR-BNTLRKBRSA-L" in targets + assert "PUBCHEM.COMPOUND:43805" not in targets + + def test_404_returns_empty(self): + p = self._make() + with patch( + "babel_explorer.core.providers.mychem.requests.get", + return_value=_http(404, {"success": False, "error": "not found"}), + ): + assert p.fetch("CHEBI:99999999") == [] + + def test_success_false_payload_returns_empty(self): + p = self._make() + with patch( + "babel_explorer.core.providers.mychem.requests.get", + return_value=_http(200, {"success": False}), + ): + assert p.fetch("CHEBI:31941") == [] + + def test_500_raises(self): + p = self._make() + with patch( + "babel_explorer.core.providers.mychem.requests.get", + return_value=_http(500, {}), + ): + with pytest.raises(requests.HTTPError): + p.fetch("CHEBI:31941") + + def test_lru_caching(self): + p = self._make() + with patch( + "babel_explorer.core.providers.mychem.requests.get", + return_value=_http(200, SAMPLE_CHEBI_RESPONSE), + ) as mock_get: + p.fetch("CHEBI:31941") + p.fetch("CHEBI:31941") + mock_get.assert_called_once() + + def test_unknown_prefix_without_nodenorm_returns_empty(self): + """UMLS isn't in the direct-lookup set; with no NodeNorm, we give up.""" + p = self._make(nodenorm=None) + with patch("babel_explorer.core.providers.mychem.requests.get") as mock_get: + assert p.fetch("UMLS:C1314429") == [] + mock_get.assert_not_called() + + +class TestNodeNormFallback: + def test_uses_inchikey_from_nodenorm_clique(self): + """UMLS input falls back to NodeNorm to find an InChIKey, then MyChem-looks-up that.""" + # Build a NodeNorm mock whose clique includes an INCHIKEY entry. + nn = MagicMock() + inchikey_ident = MagicMock() + inchikey_ident.curie = "INCHIKEY:ZROHGHOFXNOHSO-BNTLRKBRSA-L" + nn.get_clique_identifiers.return_value = [inchikey_ident] + + p = MyChemProvider("https://mychem.info/v1", nodenorm=nn) + p.fetch.cache_clear() + with patch( + "babel_explorer.core.providers.mychem.requests.get", + return_value=_http(200, SAMPLE_CHEBI_RESPONSE), + ) as mock_get: + candidates = p.fetch("UMLS:C1314429") + + args, _ = mock_get.call_args + assert args[0] == "https://mychem.info/v1/chem/ZROHGHOFXNOHSO-BNTLRKBRSA-L" + assert len(candidates) > 0 + for c in candidates: + assert c.query_curie == "UMLS:C1314429" + + def test_no_inchikey_in_clique_returns_empty(self): + nn = MagicMock() + nn.get_clique_identifiers.return_value = [] # no InChIKey available + p = MyChemProvider("https://mychem.info/v1", nodenorm=nn) + p.fetch.cache_clear() + with patch("babel_explorer.core.providers.mychem.requests.get") as mock_get: + assert p.fetch("UMLS:C1314429") == [] + mock_get.assert_not_called() + + def test_direct_lookup_404_falls_back_to_nodenorm(self): + """If direct CHEBI lookup 404s, try the InChIKey from NodeNorm.""" + nn = MagicMock() + inchikey_ident = MagicMock() + inchikey_ident.curie = "INCHIKEY:Z-FAKE" + nn.get_clique_identifiers.return_value = [inchikey_ident] + + p = MyChemProvider("https://mychem.info/v1", nodenorm=nn) + p.fetch.cache_clear() + + responses = [ + _http(404, {"success": False}), + _http(200, {"_id": "Z-FAKE", "drugbank": {"id": "DB1"}}), + ] + with patch( + "babel_explorer.core.providers.mychem.requests.get", + side_effect=responses, + ) as mock_get: + candidates = p.fetch("CHEBI:99999999") + + assert mock_get.call_count == 2 + targets = {c.target_curie for c in candidates} + assert "DRUGBANK:DB1" in targets + + +class TestIterSectionEntries: + def test_none(self): + assert list(_iter_section_entries(None)) == [] + + def test_dict(self): + assert list(_iter_section_entries({"id": "x"})) == [{"id": "x"}] + + def test_list_of_dicts(self): + result = list(_iter_section_entries([{"a": 1}, {"a": 2}])) + assert result == [{"a": 1}, {"a": 2}] + + def test_list_with_non_dicts_filtered(self): + result = list(_iter_section_entries([{"a": 1}, "garbage", None, {"a": 2}])) + assert result == [{"a": 1}, {"a": 2}] + + def test_unexpected_type(self): + assert list(_iter_section_entries("string-not-a-section")) == [] + + +# ========================================================================== +# Integration Tests — require real MyChem.info API +# ========================================================================== + + +@pytest.mark.integration +def test_real_mychem_returns_candidates_for_chebi(): + """Real MyChem call for CHEBI:31941 (oxaliplatin) returns ≥1 candidate.""" + p = MyChemProvider("https://mychem.info/v1") + p.fetch.cache_clear() + candidates = p.fetch("CHEBI:31941") + assert len(candidates) > 0 + assert all(c.provider == "MyChem.info" for c in candidates) + targets = {c.target_curie for c in candidates} + # DrugBank ID for oxaliplatin is a stable equivalence + assert "DRUGBANK:DB00526" in targets + + +@pytest.mark.integration +def test_real_mychem_returns_candidates_for_pubchem(): + """Real MyChem call for PUBCHEM.COMPOUND:43805 returns at least an InChIKey.""" + p = MyChemProvider("https://mychem.info/v1") + p.fetch.cache_clear() + candidates = p.fetch("PUBCHEM.COMPOUND:43805") + assert len(candidates) > 0 + targets = {c.target_curie for c in candidates} + assert any(t.startswith("INCHIKEY:") for t in targets) + + +@pytest.mark.integration +def test_real_mychem_unknown_curie_returns_empty(): + """A made-up CHEBI ID returns an empty list, not an error.""" + p = MyChemProvider("https://mychem.info/v1") + p.fetch.cache_clear() + assert p.fetch("CHEBI:9999999999") == [] diff --git a/tests/test_providers_ols.py b/tests/test_providers_ols.py new file mode 100644 index 0000000..2d55929 --- /dev/null +++ b/tests/test_providers_ols.py @@ -0,0 +1,253 @@ +"""Tests for the OLS4 cross-reference provider. + +Unit tests use mocks; integration tests call the real OLS4 API. +""" + +from unittest.mock import Mock, patch + +import pytest +import requests + +from babel_explorer.core.providers import CandidateXRef +from babel_explorer.core.providers.ols import OLS4Provider, _normalize_curie_prefix + + +# Captured-style fixture mirroring OLS4 v2's /api/v2/entities?iri=... response. +SAMPLE_RESPONSE = { + "elements": [ + { + "iri": "http://purl.obolibrary.org/obo/CHEBI_31941", + "curie": "CHEBI:31941", + "http://www.geneontology.org/formats/oboInOwl#hasDbXref": [ + { + "type": ["reification"], + "value": "drugbank:DB00526", + "axioms": [{"source": "drugbank"}], + }, + { + "type": ["reification"], + "value": "kegg.drug:D01790", + "axioms": [{"source": "kegg.drug"}], + }, + { + "type": ["reification"], + "value": "pubmed:11300320", + "axioms": [{"source": "pubmed"}], + }, + # Duplicate of the first xref — should be deduped. + { + "type": ["reification"], + "value": "drugbank:DB00526", + "axioms": [{"source": "drugbank"}], + }, + ], + } + ] +} + + +class TestInit: + def test_default_url_empty(self): + assert OLS4Provider().ols_url == "" + + def test_strips_trailing_slash(self): + assert OLS4Provider("https://x.org/").ols_url == "https://x.org" + + def test_empty_url_returns_empty_without_network(self): + p = OLS4Provider("") + p.fetch.cache_clear() + with patch("babel_explorer.core.providers.ols.requests.get") as mock_get: + assert p.fetch("CHEBI:31941") == [] + mock_get.assert_not_called() + + +class TestFetchMocked: + def _make(self): + p = OLS4Provider("https://www.ebi.ac.uk/ols4") + p.fetch.cache_clear() + return p + + def test_hits_correct_endpoint_with_iri_param(self): + p = self._make() + mock_resp = Mock() + mock_resp.json.return_value = {"elements": []} + mock_resp.raise_for_status = Mock() + with patch( + "babel_explorer.core.providers.ols.requests.get", return_value=mock_resp + ) as mock_get: + p.fetch("CHEBI:31941") + mock_get.assert_called_once() + args, kwargs = mock_get.call_args + assert args[0] == "https://www.ebi.ac.uk/ols4/api/v2/entities" + assert ( + kwargs["params"]["iri"] == "http://purl.obolibrary.org/obo/CHEBI_31941" + ) + + def test_parses_dbxrefs_into_candidates(self): + p = self._make() + mock_resp = Mock() + mock_resp.json.return_value = SAMPLE_RESPONSE + mock_resp.raise_for_status = Mock() + with patch( + "babel_explorer.core.providers.ols.requests.get", return_value=mock_resp + ): + candidates = p.fetch("CHEBI:31941") + + assert len(candidates) == 3 # 4 entries, one duplicate + targets = [c.target_curie for c in candidates] + assert "DRUGBANK:DB00526" in targets + assert "KEGG.DRUG:D01790" in targets + assert "PUBMED:11300320" in targets + for c in candidates: + assert isinstance(c, CandidateXRef) + assert c.provider == "OLS4" + assert c.predicate == "oboInOwl:hasDbXref" + assert c.confidence is None + assert c.in_babel is False + assert c.query_curie == "CHEBI:31941" + assert c.evidence == "http://purl.obolibrary.org/obo/CHEBI_31941" + + def test_skips_self_xref(self): + """An xref that points back to the query CURIE is filtered.""" + p = self._make() + mock_resp = Mock() + mock_resp.json.return_value = { + "elements": [ + { + "http://www.geneontology.org/formats/oboInOwl#hasDbXref": [ + {"value": "chebi:31941"}, # self-loop after normalisation + {"value": "drugbank:DB00526"}, + ] + } + ] + } + mock_resp.raise_for_status = Mock() + with patch( + "babel_explorer.core.providers.ols.requests.get", return_value=mock_resp + ): + candidates = p.fetch("CHEBI:31941") + assert [c.target_curie for c in candidates] == ["DRUGBANK:DB00526"] + + def test_unknown_prefix_returns_empty(self): + p = self._make() + with patch("babel_explorer.core.providers.ols.requests.get") as mock_get: + assert p.fetch("WIBBLE:1") == [] + mock_get.assert_not_called() # short-circuits before HTTP + + def test_invalid_curie_returns_empty(self): + p = self._make() + with patch("babel_explorer.core.providers.ols.requests.get") as mock_get: + assert p.fetch("not-a-curie") == [] + mock_get.assert_not_called() + + def test_empty_elements_returns_empty(self): + p = self._make() + mock_resp = Mock() + mock_resp.json.return_value = {"elements": []} + mock_resp.raise_for_status = Mock() + with patch( + "babel_explorer.core.providers.ols.requests.get", return_value=mock_resp + ): + assert p.fetch("CHEBI:31941") == [] + + def test_element_without_dbxref_key(self): + p = self._make() + mock_resp = Mock() + mock_resp.json.return_value = {"elements": [{"iri": "x"}]} + mock_resp.raise_for_status = Mock() + with patch( + "babel_explorer.core.providers.ols.requests.get", return_value=mock_resp + ): + assert p.fetch("CHEBI:31941") == [] + + def test_malformed_xref_entry_skipped(self): + p = self._make() + mock_resp = Mock() + mock_resp.json.return_value = { + "elements": [ + { + "http://www.geneontology.org/formats/oboInOwl#hasDbXref": [ + None, + {}, + {"value": ""}, + {"value": "no_colon"}, + {"value": ":"}, # both halves empty + {"value": ":foo"}, # empty prefix + {"value": "foo:"}, # empty local id + {"value": "good:1"}, + ] + } + ] + } + mock_resp.raise_for_status = Mock() + with patch( + "babel_explorer.core.providers.ols.requests.get", return_value=mock_resp + ): + candidates = p.fetch("CHEBI:31941") + assert [c.target_curie for c in candidates] == ["GOOD:1"] + + def test_http_error_raises(self): + p = self._make() + mock_resp = Mock() + mock_resp.raise_for_status.side_effect = requests.HTTPError("500") + with patch( + "babel_explorer.core.providers.ols.requests.get", return_value=mock_resp + ): + with pytest.raises(requests.HTTPError): + p.fetch("CHEBI:31941") + + def test_lru_caching(self): + p = self._make() + mock_resp = Mock() + mock_resp.json.return_value = {"elements": []} + mock_resp.raise_for_status = Mock() + with patch( + "babel_explorer.core.providers.ols.requests.get", return_value=mock_resp + ) as mock_get: + p.fetch("CHEBI:31941") + p.fetch("CHEBI:31941") + mock_get.assert_called_once() + + +class TestNormalizeCuriePrefix: + def test_uppercases_prefix(self): + assert _normalize_curie_prefix("drugbank:DB00526") == "DRUGBANK:DB00526" + + def test_preserves_local_id_case(self): + assert _normalize_curie_prefix("inchikey:abcDEF") == "INCHIKEY:abcDEF" + + def test_dot_separated_prefix(self): + assert ( + _normalize_curie_prefix("pubchem.compound:43805") + == "PUBCHEM.COMPOUND:43805" + ) + + def test_only_uppercases_first_colon_split(self): + assert _normalize_curie_prefix("a:b:c") == "A:b:c" + + +# ========================================================================== +# Integration Tests — require real OLS4 API +# ========================================================================== + + +@pytest.mark.integration +def test_real_ols4_returns_candidates_for_chebi(): + """Real OLS4 call for CHEBI:31941 (oxaliplatin) returns ≥1 candidate.""" + p = OLS4Provider("https://www.ebi.ac.uk/ols4") + p.fetch.cache_clear() + candidates = p.fetch("CHEBI:31941") + assert len(candidates) > 0 + assert all(c.provider == "OLS4" for c in candidates) + assert all(c.query_curie == "CHEBI:31941" for c in candidates) + # DrugBank xref is a known stable mapping for oxaliplatin + assert any(c.target_curie == "DRUGBANK:DB00526" for c in candidates) + + +@pytest.mark.integration +def test_real_ols4_unknown_curie_returns_empty(): + """A made-up CHEBI ID returns an empty list, not an error.""" + p = OLS4Provider("https://www.ebi.ac.uk/ols4") + p.fetch.cache_clear() + candidates = p.fetch("CHEBI:99999999") + assert candidates == [] diff --git a/tests/test_search_xrefs_cli.py b/tests/test_search_xrefs_cli.py new file mode 100644 index 0000000..4a4ed19 --- /dev/null +++ b/tests/test_search_xrefs_cli.py @@ -0,0 +1,240 @@ +"""Tests for the `babel-explorer search-xrefs` CLI command. + +Unit tests — providers and Babel layer are mocked at the CLI import boundary. +""" + +import json + +from click.testing import CliRunner +from unittest.mock import patch, MagicMock + +from babel_explorer.cli import cli +from babel_explorer.core.babel_xrefs import CrossReference +from babel_explorer.core.providers import CandidateXRef + + +def _mock_provider(name: str, candidates: list[CandidateXRef]) -> MagicMock: + """A MagicMock that imitates an XRefProvider returning ``candidates``.""" + p = MagicMock() + p.name = name + p.fetch.return_value = list(candidates) + return p + + +def _patch_cli_layer(provider_map: dict[str, MagicMock], babel_xrefs: list = ()): + """Context manager-like helper: returns a list of patches to use with ExitStack. + + But for brevity we inline the patches in each test instead. + """ + raise NotImplementedError # placeholder; tests inline their patches. + + +# A canonical candidate the provider mock will return. +_CAND = CandidateXRef( + query_curie="CHEBI:31941", + target_curie="DRUGBANK:DB00526", + provider="OLS4", + predicate="oboInOwl:hasDbXref", + confidence=None, + evidence="http://purl.obolibrary.org/obo/CHEBI_31941", + in_babel=False, +) + + +class TestSmoke: + def test_help_exit_zero(self): + runner = CliRunner() + result = runner.invoke(cli, ["search-xrefs", "--help"]) + assert result.exit_code == 0 + assert "search-xrefs" in result.output + + def test_missing_curie_exits_nonzero(self): + runner = CliRunner() + result = runner.invoke(cli, ["search-xrefs"]) + assert result.exit_code != 0 + + def test_unknown_provider_rejected(self): + runner = CliRunner() + with ( + patch("babel_explorer.cli.BabelDownloader"), + patch("babel_explorer.cli.BabelXRefs"), + patch("babel_explorer.cli.NodeNorm"), + ): + result = runner.invoke( + cli, ["search-xrefs", "CHEBI:31941", "--providers", "wibble"] + ) + assert result.exit_code != 0 + assert "wibble" in result.output.lower() or "unknown" in result.output.lower() + + +class TestHappyPath: + def _run(self, args, candidates=None, babel_xrefs=None, label_ident=None): + runner = CliRunner() + mock_provider = _mock_provider("OLS4", candidates or [_CAND]) + with ( + patch("babel_explorer.cli.BabelDownloader"), + patch("babel_explorer.cli.BabelXRefs") as mock_bx, + patch("babel_explorer.cli.NodeNorm") as mock_nn, + patch.dict( + "babel_explorer.cli.PROVIDERS", + {"ols": lambda **kw: mock_provider}, + clear=True, + ), + ): + mock_bx.return_value.get_curie_xref.return_value = babel_xrefs or [] + if label_ident is not None: + mock_nn.return_value.get_identifier.return_value = label_ident + result = runner.invoke(cli, ["search-xrefs", *args]) + return result, mock_provider, mock_bx, mock_nn + + def test_basic_invocation(self): + result, mock_provider, _, _ = self._run(["CHEBI:31941"]) + assert result.exit_code == 0, result.output + mock_provider.fetch.assert_called_once_with("CHEBI:31941") + assert "DRUGBANK:DB00526" in result.output + + def test_in_babel_annotation_when_edge_known(self): + babel_edge = CrossReference( + filename="Concord.parquet", + subj="CHEBI:31941", + pred="skos:exactMatch", + obj="DRUGBANK:DB00526", + ) + result, _, _, _ = self._run(["CHEBI:31941"], babel_xrefs=[babel_edge]) + assert result.exit_code == 0 + # Console output contains the "known" marker + assert "known" in result.output + + def test_new_annotation_when_edge_unknown(self): + result, _, _, _ = self._run(["CHEBI:31941"], babel_xrefs=[]) + assert result.exit_code == 0 + assert "NEW" in result.output + + def test_ignore_known_filters_out_known(self): + babel_edge = CrossReference( + filename="Concord.parquet", + subj="CHEBI:31941", + pred="skos:exactMatch", + obj="DRUGBANK:DB00526", + ) + result, _, _, _ = self._run( + ["CHEBI:31941", "--ignore-known"], + babel_xrefs=[babel_edge], + ) + assert result.exit_code == 0 + # No candidates remain — output is empty (no header, no rows). + assert "DRUGBANK:DB00526" not in result.output + + def test_ignore_known_keeps_new(self): + result, _, _, _ = self._run(["CHEBI:31941", "--ignore-known"], babel_xrefs=[]) + assert result.exit_code == 0 + assert "DRUGBANK:DB00526" in result.output + + def test_labels_flag_fetches_target_label(self): + ident = MagicMock() + ident.label = "oxaliplatin" + ident.biolink_type = ("biolink:ChemicalEntity",) + result, _, _, mock_nn = self._run( + ["CHEBI:31941", "--labels"], label_ident=ident + ) + assert result.exit_code == 0 + mock_nn.return_value.get_identifier.assert_called() + assert "oxaliplatin" in result.output + + +class TestFormats: + """The four --format options all produce parseable output.""" + + def _run(self, args, candidates=None, babel_xrefs=None): + runner = CliRunner() + mock_provider = _mock_provider("OLS4", candidates or [_CAND]) + with ( + patch("babel_explorer.cli.BabelDownloader"), + patch("babel_explorer.cli.BabelXRefs") as mock_bx, + patch("babel_explorer.cli.NodeNorm"), + patch.dict( + "babel_explorer.cli.PROVIDERS", + {"ols": lambda **kw: mock_provider}, + clear=True, + ), + ): + mock_bx.return_value.get_curie_xref.return_value = babel_xrefs or [] + return runner.invoke(cli, ["search-xrefs", *args]) + + def test_format_console(self): + result = self._run(["CHEBI:31941", "--format", "console"]) + assert result.exit_code == 0 + assert "DRUGBANK:DB00526" in result.output + + def test_format_json(self): + result = self._run(["CHEBI:31941", "--format", "json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data) == 1 + assert data[0]["query_curie"] == "CHEBI:31941" + assert data[0]["target_curie"] == "DRUGBANK:DB00526" + assert data[0]["provider"] == "OLS4" + assert data[0]["in_babel"] is False + + def test_format_tsv(self): + result = self._run(["CHEBI:31941", "--format", "tsv"]) + assert result.exit_code == 0 + lines = result.output.splitlines() + assert "query_curie" in lines[0] + assert "target_curie" in lines[0] + assert "CHEBI:31941" in lines[1] + assert "DRUGBANK:DB00526" in lines[1] + + def test_format_csv(self): + result = self._run(["CHEBI:31941", "--format", "csv"]) + assert result.exit_code == 0 + lines = result.output.splitlines() + assert "query_curie,target_curie" in lines[0].replace(" ", "") + assert "CHEBI:31941" in lines[1] + + +class TestMultipleProviders: + def test_providers_arg_selects_subset(self): + """--providers ols selects only the OLS provider.""" + runner = CliRunner() + ols_mock = _mock_provider("OLS4", [_CAND]) + other_mock = _mock_provider("OTHER", []) + with ( + patch("babel_explorer.cli.BabelDownloader"), + patch("babel_explorer.cli.BabelXRefs") as mock_bx, + patch("babel_explorer.cli.NodeNorm"), + patch.dict( + "babel_explorer.cli.PROVIDERS", + {"ols": lambda **kw: ols_mock, "other": lambda **kw: other_mock}, + clear=True, + ), + ): + mock_bx.return_value.get_curie_xref.return_value = [] + result = runner.invoke( + cli, ["search-xrefs", "CHEBI:31941", "--providers", "ols"] + ) + + assert result.exit_code == 0 + ols_mock.fetch.assert_called_once() + other_mock.fetch.assert_not_called() + + def test_default_uses_all_providers(self): + runner = CliRunner() + a = _mock_provider("A", []) + b = _mock_provider("B", []) + with ( + patch("babel_explorer.cli.BabelDownloader"), + patch("babel_explorer.cli.BabelXRefs") as mock_bx, + patch("babel_explorer.cli.NodeNorm"), + patch.dict( + "babel_explorer.cli.PROVIDERS", + {"a": lambda **kw: a, "b": lambda **kw: b}, + clear=True, + ), + ): + mock_bx.return_value.get_curie_xref.return_value = [] + result = runner.invoke(cli, ["search-xrefs", "CHEBI:31941"]) + + assert result.exit_code == 0 + a.fetch.assert_called_once() + b.fetch.assert_called_once()