Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: CI

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
test:
runs-on: ubuntu-latest

services:
elasticsearch:
image: elasticsearch:8.11.0
env:
discovery.type: single-node
xpack.security.enabled: false
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
ports:
- 9200:9200
options: >-
--health-cmd "curl -f http://localhost:9200/_cluster/health || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 10

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.13'

- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH

- name: Install dependencies
run: uv sync --dev

- name: Wait for Elasticsearch
run: |
until curl -f http://localhost:9200/_cluster/health; do
echo "Waiting for Elasticsearch..."
sleep 5
done

- name: Run tests
run: uv run pytest tests/ -v
30 changes: 26 additions & 4 deletions src/gide_search/search/schema_search_object.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
from typing import Protocol
from urllib import parse

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic import (
BaseModel,
ConfigDict,
Field,
ValidationInfo,
field_validator,
model_validator,
)
from typing_extensions import Self

# Prefixes that might occur in object IDs that are likely to get shortened, which would be better left as full IRIs.
Expand All @@ -10,6 +18,10 @@
}


class TermLabelProvider(Protocol):
def fetch_label_by_iri(self, term_iri: str) -> str | None: ...


class JsonLdNode(BaseModel):
model_config = ConfigDict(
populate_by_name=True, # accept `id` AND `@id`
Expand Down Expand Up @@ -152,7 +164,7 @@ class Dataset(JsonLdNode):

@field_validator("about", mode="before")
@classmethod
def discriminate_about(cls, value):
def discriminate_about(cls, value, info: ValidationInfo):
if not isinstance(value, list):
return value
out = []
Expand All @@ -175,7 +187,7 @@ def discriminate_about(cls, value):

@field_validator("measurementMethod", mode="before")
@classmethod
def discriminate_measurement_method(cls, value):
def discriminate_measurement_method(cls, value, info: ValidationInfo):
if not isinstance(value, list):
return value
out = []
Expand Down Expand Up @@ -210,7 +222,7 @@ class IndexableDataset(Dataset):
imaging_method_ids: list[DefinedTerm] = Field(default_factory=list)

@model_validator(mode="after")
def poplate_additional_index_fields(self) -> Self:
def populate_additional_index_fields(self) -> Self:
"""
Populate the fields that get used for facetting
"""
Expand All @@ -224,3 +236,13 @@ def poplate_additional_index_fields(self) -> Self:
):
self.imaging_method_ids.append(measurment_object)
return self

def fetch_labels(self, label_provider: TermLabelProvider) -> None:
def _fetch_for_defined_term(term: DefinedTerm):
label = label_provider.fetch_label_by_iri(term.id)
if label is None:
raise ValueError(f"{term.id} not found in OLS")
term.name = label

for term in self.imaging_method_ids:
_fetch_for_defined_term(term)
4 changes: 2 additions & 2 deletions src/gide_search/transformers/bia_to_rocrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ def _get_taxons_from_ontology(self, bia_bio_sample):
else:
continue

term_with_labels = self.ontology_term_finder.fetch_labels_for_term(
term_with_labels = self.ontology_term_finder.fetch_term_from_ontology(
"ncbitaxon", ncbi_id
)
if term_with_labels:
Expand Down Expand Up @@ -217,7 +217,7 @@ def _get_imaging_method_from_ontology(self, bia_image_acquisition_protocol):
)
else:
for fbbi_id in bia_image_acquisition_protocol["fbbi_id"]:
term_with_labels = self.ontology_term_finder.fetch_labels_for_term(
term_with_labels = self.ontology_term_finder.fetch_term_from_ontology(
"fbbi", fbbi_id
)
if term_with_labels:
Expand Down
5 changes: 4 additions & 1 deletion src/gide_search/transformers/rocrate_to_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from gide_search.search.schema_search_object import IndexableDataset
from gide_search.transformers.frame_transformer import FrameTransformer
from gide_search.utils.ontology_term_finder import OntologyTermFinder

logger = logging.getLogger("__main__." + __name__)

Expand All @@ -21,6 +22,7 @@ def __init__(self):
self.frame = self.FRAME_BASE | {
"@context": self._get_ro_crate_context_with_containers()
}
self.ontology_lookup = OntologyTermFinder()
super().__init__()

def transform(self, single_object: dict):
Expand All @@ -39,9 +41,10 @@ def transform(self, single_object: dict):

try:
dataset = IndexableDataset.model_validate(framed_doc)
dataset.fetch_labels(self.ontology_lookup)
except ValidationError as e:
logger.error(
f"Validation failed for: {framed_doc.get("@id", "Unknown object")}"
f"Validation failed for: {framed_doc.get('@id', 'Unknown object')}"
)
raise e

Expand Down
46 changes: 40 additions & 6 deletions src/gide_search/utils/ontology_term_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@


@dataclass
class TermWithLabels:
class OntologyTerm:
iri: str
label: list[str]
additional_label: list[str]
Expand Down Expand Up @@ -58,7 +58,9 @@ def _collect_short_ids(short_id: str | list[str], short_ids: list):
short_ids += short_id

@cache
def fetch_labels_for_term(self, ontology: str, term_iri: str):
def fetch_term_from_ontology(
self, ontology: str, term_iri: str
) -> None | OntologyTerm:
if ontology not in self.avaliable_ontology_ids:
raise KeyError(f"{ontology} is not in ols")

Expand All @@ -75,13 +77,45 @@ def fetch_labels_for_term(self, ontology: str, term_iri: str):

return self._create_term_with_labels(term_info)

def fetch_term_by_iri(self, term_iri: str) -> None | OntologyTerm:
ontology = self._ontology_for_term_iri(term_iri)
if ontology is None:
return

try:
return self.fetch_term_from_ontology(ontology, term_iri)
except KeyError:
return

def fetch_label_by_iri(self, term_iri: str) -> str | None:
term_with_labels = self.fetch_term_by_iri(term_iri)
if term_with_labels is None:
return None
return term_with_labels.label[0] if term_with_labels.label else None

def _ontology_for_term_iri(self, term_iri: str) -> str | None:
if term_iri.startswith("obo:"):
term_iri = term_iri.removeprefix("obo:")

if term_iri.startswith("http://purl.obolibrary.org/obo/"):
local_part = term_iri.rsplit("/", 1)[-1]
prefix = local_part.split("_", 1)[0].lower()
return prefix

if term_iri.startswith("http://www.bioassayontology.org/bao#"):
return "bao"
if term_iri.startswith("bao:"):
return "bao"

return None

@cache
def _get_iri_for_class_in_ontology(
self, ontology: str, search_terms: str, required_superclass: str | None = None
) -> list[TermWithLabels]:
) -> list[OntologyTerm]:
api_response = self._find_class_in_ontology(ontology, search_terms)

iris_and_labels: list[TermWithLabels] = []
iris_and_labels: list[OntologyTerm] = []
for ontology_term in api_response["elements"]:
if required_superclass:
if not ontology_term["hasDirectParents"]:
Expand All @@ -97,7 +131,7 @@ def _get_iri_for_class_in_ontology(

return iris_and_labels

def _create_term_with_labels(self, ontology_term) -> TermWithLabels:
def _create_term_with_labels(self, ontology_term) -> OntologyTerm:
short_ids = []
self._collect_short_ids(ontology_term.get("obo_id"), short_ids)
self._collect_short_ids(ontology_term.get("curie"), short_ids)
Expand All @@ -109,7 +143,7 @@ def _create_term_with_labels(self, ontology_term) -> TermWithLabels:
else [ontology_term["label"]]
)

return TermWithLabels(
return OntologyTerm(
**{
"iri": ontology_term["iri"],
"label": label,
Expand Down
60 changes: 60 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Shared pytest fixtures for tests."""

from pathlib import Path

import pytest
from typer.testing import CliRunner

from gide_search.cli import app
from gide_search.search.indexer import DatabaseEntryIndexer

runner = CliRunner()


def is_elasticsearch_available(es_url: str = "http://localhost:9200") -> bool:
"""Check if Elasticsearch is available."""
try:
indexer = DatabaseEntryIndexer(es_url=es_url)
return indexer.ping()
except Exception:
return False


@pytest.fixture
def es_available():
"""Skip test if Elasticsearch is not available."""
if not is_elasticsearch_available():
pytest.skip("Elasticsearch is not available on localhost:9200")


@pytest.fixture
def indexed_data(es_available):
"""Fixture to ensure sample data is indexed before tests."""
sample_index_file = (
Path(__file__).parent
/ "data"
/ "index_document"
/ "example_ro_crate_index.json"
)

if not sample_index_file.exists():
pytest.skip(f"Sample index file not found: {sample_index_file}")

# Run the index command
result = runner.invoke(
app,
[
"data",
"index",
str(sample_index_file),
"--es-url",
"http://localhost:9200",
"--recreate",
],
)

if result.exit_code != 0:
pytest.skip(f"Failed to index data: {result.stdout}")

# Return something to indicate success
return True
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@
{
"@id": "http://purl.obolibrary.org/obo/CLO_0003684",
"@type": "DefinedTerm",
"name": "Hela cell"
"name": "HeLa cell"
},
{
"@id": "#total-dataset-size",
Expand Down
4 changes: 2 additions & 2 deletions tests/data/index_document/example_ro_crate_index.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
"type": [
"DefinedTerm"
],
"name": "Hela cell"
"name": "HeLa cell"
}
]
},
Expand All @@ -100,7 +100,7 @@
"type": [
"DefinedTerm"
],
"name": "Hela cell"
"name": "HeLa cell"
}
],
"measurementMethod": [
Expand Down
36 changes: 36 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Tests for the search API."""

import pytest
from fastapi.testclient import TestClient

from gide_search.search.api import app


def test_search_api(indexed_data):
"""Test the /search API endpoint with indexed data."""
client = TestClient(app)

# Search for "confocal" which should match the sample document
response = client.get("/search?q=confocal&size=10")

assert response.status_code == 200

data = response.json()

assert "total" in data
assert "hits" in data
assert "facets" in data

assert data["total"] > 0, "Expected to find at least one result"
assert len(data["hits"]) > 0, "Expected hits array to be non-empty"

# Check that the first hit has expected structure
hit = data["hits"][0]
assert "id" in hit
assert "entry" in hit
assert "score" in hit

# The entry should contain the indexed document data
entry = hit["entry"]
assert "name" in entry
assert "description" in entry
Loading
Loading