Skip to content

Commit 4050293

Browse files
authored
Merge pull request #34 from BioImage-Archive/use_ontology_labels
Use ontology labels
2 parents d3bcda1 + 4e5a35f commit 4050293

12 files changed

Lines changed: 563 additions & 33 deletions

File tree

.github/workflows/ci.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
services:
14+
elasticsearch:
15+
image: elasticsearch:8.11.0
16+
env:
17+
discovery.type: single-node
18+
xpack.security.enabled: false
19+
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
20+
ports:
21+
- 9200:9200
22+
options: >-
23+
--health-cmd "curl -f http://localhost:9200/_cluster/health || exit 1"
24+
--health-interval 10s
25+
--health-timeout 5s
26+
--health-retries 10
27+
28+
steps:
29+
- uses: actions/checkout@v4
30+
31+
- name: Set up Python
32+
uses: actions/setup-python@v4
33+
with:
34+
python-version: '3.13'
35+
36+
- name: Install uv
37+
run: |
38+
curl -LsSf https://astral.sh/uv/install.sh | sh
39+
echo "$HOME/.local/bin" >> $GITHUB_PATH
40+
41+
- name: Install dependencies
42+
run: uv sync --dev
43+
44+
- name: Wait for Elasticsearch
45+
run: |
46+
until curl -f http://localhost:9200/_cluster/health; do
47+
echo "Waiting for Elasticsearch..."
48+
sleep 5
49+
done
50+
51+
- name: Run tests
52+
run: uv run pytest tests/ -v

src/gide_search/search/schema_search_object.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,31 @@
1+
import logging
2+
3+
from typing import Protocol
14
from urllib import parse
25

3-
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
6+
from pydantic import (
7+
BaseModel,
8+
ConfigDict,
9+
Field,
10+
ValidationInfo,
11+
field_validator,
12+
model_validator,
13+
)
414
from typing_extensions import Self
515

16+
logger = logging.getLogger()
17+
618
# Prefixes that might occur in object IDs that are likely to get shortened, which would be better left as full IRIs.
719
PREFIXES_TO_EXPAND = {
820
"obo": "http://purl.obolibrary.org/obo/",
921
"bao": "http://www.bioassayontology.org/bao#",
1022
}
1123

1224

25+
class TermLabelProvider(Protocol):
26+
def fetch_label_by_iri(self, term_iri: str) -> str | None: ...
27+
28+
1329
class JsonLdNode(BaseModel):
1430
model_config = ConfigDict(
1531
populate_by_name=True, # accept `id` AND `@id`
@@ -152,7 +168,7 @@ class Dataset(JsonLdNode):
152168

153169
@field_validator("about", mode="before")
154170
@classmethod
155-
def discriminate_about(cls, value):
171+
def discriminate_about(cls, value, info: ValidationInfo):
156172
if not isinstance(value, list):
157173
return value
158174
out = []
@@ -175,7 +191,7 @@ def discriminate_about(cls, value):
175191

176192
@field_validator("measurementMethod", mode="before")
177193
@classmethod
178-
def discriminate_measurement_method(cls, value):
194+
def discriminate_measurement_method(cls, value, info: ValidationInfo):
179195
if not isinstance(value, list):
180196
return value
181197
out = []
@@ -210,7 +226,7 @@ class IndexableDataset(Dataset):
210226
imaging_method_ids: list[DefinedTerm] = Field(default_factory=list)
211227

212228
@model_validator(mode="after")
213-
def poplate_additional_index_fields(self) -> Self:
229+
def populate_additional_index_fields(self) -> Self:
214230
"""
215231
Populate the fields that get used for facetting
216232
"""
@@ -224,3 +240,14 @@ def poplate_additional_index_fields(self) -> Self:
224240
):
225241
self.imaging_method_ids.append(measurment_object)
226242
return self
243+
244+
def fetch_labels(self, label_provider: TermLabelProvider) -> None:
245+
def _fetch_for_defined_term(term: DefinedTerm):
246+
label = label_provider.fetch_label_by_iri(term.id)
247+
if label:
248+
term.name = label
249+
else:
250+
logger.warning(f"{term.id} not found in ontology.")
251+
252+
for term in self.imaging_method_ids:
253+
_fetch_for_defined_term(term)

src/gide_search/transformers/bia_to_rocrate.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def _get_taxons_from_ontology(self, bia_bio_sample):
125125
else:
126126
continue
127127

128-
term_with_labels = self.ontology_term_finder.fetch_labels_for_term(
128+
term_with_labels = self.ontology_term_finder.fetch_term_from_ontology(
129129
"ncbitaxon", ncbi_id
130130
)
131131
if term_with_labels:
@@ -217,7 +217,7 @@ def _get_imaging_method_from_ontology(self, bia_image_acquisition_protocol):
217217
)
218218
else:
219219
for fbbi_id in bia_image_acquisition_protocol["fbbi_id"]:
220-
term_with_labels = self.ontology_term_finder.fetch_labels_for_term(
220+
term_with_labels = self.ontology_term_finder.fetch_term_from_ontology(
221221
"fbbi", fbbi_id
222222
)
223223
if term_with_labels:

src/gide_search/transformers/frame_transformer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,6 @@ def _get_ro_crate_context_with_containers(self) -> list | str:
3838
"@id": "http://schema.org/taxonomicRange",
3939
"@container": "@set",
4040
},
41-
"@type": {"@container": "@set"},
41+
"@type": {"@container": "@set"}
4242
},
4343
]

src/gide_search/transformers/rocrate_to_index.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from gide_search.search.schema_search_object import IndexableDataset
77
from gide_search.transformers.frame_transformer import FrameTransformer
8+
from gide_search.utils.ontology_term_finder import OntologyTermFinder
89

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

@@ -21,16 +22,23 @@ def __init__(self):
2122
self.frame = self.FRAME_BASE | {
2223
"@context": self._get_ro_crate_context_with_containers()
2324
}
25+
self.ontology_lookup = OntologyTermFinder()
2426
super().__init__()
2527

2628
def transform(self, single_object: dict):
2729

28-
# FIXME: currently replacing context with defined one while we all update our ro-crates.
29-
single_object["@context"] = (
30-
"https://www.gide-project.org/ro-crate/search/1.0/context"
31-
)
30+
base_iri = self._find_root_object(single_object).get("about", {}).get("@id")
31+
32+
# FIXME: currently replacing context with defined one while we all update our ro-crates. The base IRI still needs to be present.
33+
single_object["@context"] = [
34+
"https://www.gide-project.org/ro-crate/search/1.0/context",
35+
{"@base": base_iri},
36+
]
3237

33-
framed_doc = jsonld.frame(single_object, self.frame)
38+
framed_doc = jsonld.frame(
39+
single_object,
40+
self.frame,
41+
)
3442

3543
if not isinstance(framed_doc, dict):
3644
raise TypeError()
@@ -39,10 +47,26 @@ def transform(self, single_object: dict):
3947

4048
try:
4149
dataset = IndexableDataset.model_validate(framed_doc)
50+
dataset.fetch_labels(self.ontology_lookup)
4251
except ValidationError as e:
4352
logger.error(
44-
f"Validation failed for: {framed_doc.get("@id", "Unknown object")}"
53+
f"Validation failed for: {framed_doc.get('@id', 'Unknown object')}"
4554
)
4655
raise e
4756

4857
return dataset.model_dump(by_alias=False)
58+
59+
@staticmethod
60+
def _find_root_object(ro_crate_metadata: dict) -> dict:
61+
for entity in ro_crate_metadata.get("@graph"):
62+
if entity.get("@id") == "ro-crate-metadata.json":
63+
return entity
64+
65+
# Waiting on update to example ro-crate to correct mistake we made about the ID of the root object.
66+
for entity in ro_crate_metadata.get("@graph"):
67+
if entity.get("@type") == "CreativeWork" and all(
68+
(x in entity for x in ("about", "conformsTo"))
69+
):
70+
return entity
71+
72+
raise ValueError("Cannot find root entity in ro-crate document")

src/gide_search/utils/ontology_term_finder.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111

1212
@dataclass
13-
class TermWithLabels:
13+
class OntologyTerm:
1414
iri: str
1515
label: list[str]
1616
additional_label: list[str]
@@ -58,7 +58,9 @@ def _collect_short_ids(short_id: str | list[str], short_ids: list):
5858
short_ids += short_id
5959

6060
@cache
61-
def fetch_labels_for_term(self, ontology: str, term_iri: str):
61+
def fetch_term_from_ontology(
62+
self, ontology: str, term_iri: str
63+
) -> None | OntologyTerm:
6264
if ontology not in self.avaliable_ontology_ids:
6365
raise KeyError(f"{ontology} is not in ols")
6466

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

7678
return self._create_term_with_labels(term_info)
7779

80+
def fetch_term_by_iri(self, term_iri: str) -> None | OntologyTerm:
81+
ontology = self._ontology_for_term_iri(term_iri)
82+
if ontology is None:
83+
return
84+
85+
try:
86+
return self.fetch_term_from_ontology(ontology, term_iri)
87+
except KeyError:
88+
return
89+
90+
def fetch_label_by_iri(self, term_iri: str) -> str | None:
91+
term_with_labels = self.fetch_term_by_iri(term_iri)
92+
if term_with_labels is None:
93+
return None
94+
return term_with_labels.label[0] if term_with_labels.label else None
95+
96+
def _ontology_for_term_iri(self, term_iri: str) -> str | None:
97+
if term_iri.startswith("obo:"):
98+
term_iri = term_iri.removeprefix("obo:")
99+
100+
if term_iri.startswith("http://purl.obolibrary.org/obo/"):
101+
local_part = term_iri.rsplit("/", 1)[-1]
102+
prefix = local_part.split("_", 1)[0].lower()
103+
return prefix
104+
105+
if term_iri.startswith("http://www.bioassayontology.org/bao#"):
106+
return "bao"
107+
if term_iri.startswith("bao:"):
108+
return "bao"
109+
110+
return None
111+
78112
@cache
79113
def _get_iri_for_class_in_ontology(
80114
self, ontology: str, search_terms: str, required_superclass: str | None = None
81-
) -> list[TermWithLabels]:
115+
) -> list[OntologyTerm]:
82116
api_response = self._find_class_in_ontology(ontology, search_terms)
83117

84-
iris_and_labels: list[TermWithLabels] = []
118+
iris_and_labels: list[OntologyTerm] = []
85119
for ontology_term in api_response["elements"]:
86120
if required_superclass:
87121
if not ontology_term["hasDirectParents"]:
@@ -97,7 +131,7 @@ def _get_iri_for_class_in_ontology(
97131

98132
return iris_and_labels
99133

100-
def _create_term_with_labels(self, ontology_term) -> TermWithLabels:
134+
def _create_term_with_labels(self, ontology_term) -> OntologyTerm:
101135
short_ids = []
102136
self._collect_short_ids(ontology_term.get("obo_id"), short_ids)
103137
self._collect_short_ids(ontology_term.get("curie"), short_ids)
@@ -109,7 +143,7 @@ def _create_term_with_labels(self, ontology_term) -> TermWithLabels:
109143
else [ontology_term["label"]]
110144
)
111145

112-
return TermWithLabels(
146+
return OntologyTerm(
113147
**{
114148
"iri": ontology_term["iri"],
115149
"label": label,

tests/conftest.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Shared pytest fixtures for tests."""
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
from typer.testing import CliRunner
7+
8+
from gide_search.cli import app
9+
from gide_search.search.indexer import DatabaseEntryIndexer
10+
11+
runner = CliRunner()
12+
13+
14+
def is_elasticsearch_available(es_url: str = "http://localhost:9200") -> bool:
15+
"""Check if Elasticsearch is available."""
16+
try:
17+
indexer = DatabaseEntryIndexer(es_url=es_url)
18+
return indexer.ping()
19+
except Exception:
20+
return False
21+
22+
23+
@pytest.fixture
24+
def es_available():
25+
"""Skip test if Elasticsearch is not available."""
26+
if not is_elasticsearch_available():
27+
pytest.skip("Elasticsearch is not available on localhost:9200")
28+
29+
30+
@pytest.fixture
31+
def indexed_data(es_available):
32+
"""Fixture to ensure sample data is indexed before tests."""
33+
sample_index_file = (
34+
Path(__file__).parent
35+
/ "data"
36+
/ "index_document"
37+
/ "example_ro_crate_index.json"
38+
)
39+
40+
if not sample_index_file.exists():
41+
pytest.skip(f"Sample index file not found: {sample_index_file}")
42+
43+
# Run the index command
44+
result = runner.invoke(
45+
app,
46+
[
47+
"data",
48+
"index",
49+
str(sample_index_file),
50+
"--es-url",
51+
"http://localhost:9200",
52+
"--recreate",
53+
],
54+
)
55+
56+
if result.exit_code != 0:
57+
pytest.skip(f"Failed to index data: {result.stdout}")
58+
59+
# Return something to indicate success
60+
return True

tests/data/gide_search_ro_crate/EXAMPLE-001-ro-crate-metadata.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@
201201
{
202202
"@id": "http://purl.obolibrary.org/obo/CLO_0003684",
203203
"@type": "DefinedTerm",
204-
"name": "Hela cell"
204+
"name": "HeLa cell"
205205
},
206206
{
207207
"@id": "#total-dataset-size",

0 commit comments

Comments
 (0)