Skip to content

Commit f0c0bbb

Browse files
authored
Merge pull request #20 from microbiomedata/issue-12-prefix-casing
Match ontology CURIE prefix case-insensitively (closes #12)
2 parents 83383d8 + 6a6411f commit f0c0bbb

2 files changed

Lines changed: 50 additions & 7 deletions

File tree

src/ontology_loader/ontology_processor.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ def __init__(self, ontology: str):
5050
5151
"""
5252
self.ontology = ontology
53+
# Cache the lowercased ontology name once; `_matches_ontology` is called
54+
# in hot loops over millions of entities/ancestors at NCBITaxon scale.
55+
self._ontology_lc = ontology.lower()
5356
self.ontology_db_path = self.download_and_prepare_ontology()
5457
self.adapter = get_adapter(f"sqlite:{self.ontology_db_path}")
5558
self.adapter.precompute_lookups() # Optimize lookups
@@ -115,20 +118,24 @@ def _create_ontology_class(self, entity_id, is_obsolete=False):
115118

116119
return ontology_class
117120

121+
def _matches_ontology(self, entity_id: str) -> bool:
122+
"""Case-insensitive check that ``entity_id`` is a CURIE in this ontology."""
123+
head, sep, _ = entity_id.partition(":")
124+
return bool(sep) and head.lower() == self._ontology_lc
125+
118126
def get_terms_and_metadata(self):
119-
"""Retrieve all terms that start with the ontology prefix and return a list of OntologyClass objects."""
127+
"""Retrieve all terms that belong to this ontology and return a list of OntologyClass objects."""
120128
ontology_classes = []
121-
ontology_prefix = self.ontology.upper() + ":"
122129

123130
# Process non-obsolete entities
124131
for entity in self.adapter.entities(filter_obsoletes=True):
125-
if entity.startswith(ontology_prefix):
132+
if self._matches_ontology(entity):
126133
ontology_class = self._create_ontology_class(entity, is_obsolete=False)
127134
ontology_classes.append(ontology_class)
128135

129136
# Process obsolete entities
130137
for obsolete_entity in self.adapter.obsoletes():
131-
if obsolete_entity.startswith(ontology_prefix):
138+
if self._matches_ontology(obsolete_entity):
132139
ontology_class = self._create_ontology_class(obsolete_entity, is_obsolete=True)
133140
ontology_classes.append(ontology_class)
134141

@@ -143,15 +150,14 @@ def get_relations_closure(self, predicates=None, ontology_terms: list = None) ->
143150
:return: Tuple of (ontology_relations, updated_ontology_terms)
144151
"""
145152
predicates = ["rdfs:subClassOf", "BFO:0000050"] if predicates is None else predicates
146-
ontology_prefix = self.ontology.upper() + ":"
147153
ontology_relations = []
148154

149155
# Create dictionary for fast lookup of ontology terms
150156
ontology_terms_dict = {term.id: term for term in (ontology_terms or [])}
151157

152158
# Get all relevant entities in one pass
153159
logger.info("Collecting relevant entities...")
154-
relevant_entities = set(entity for entity in self.adapter.entities() if entity.startswith(ontology_prefix))
160+
relevant_entities = set(entity for entity in self.adapter.entities() if self._matches_ontology(entity))
155161
logger.info(f"Found {len(relevant_entities)} relevant entities")
156162

157163
# Process all direct relationships in one batch
@@ -177,7 +183,7 @@ def get_relations_closure(self, predicates=None, ontology_terms: list = None) ->
177183
ancestors = set(
178184
ancestor
179185
for ancestor in self.adapter.ancestors(entity, reflexive=True, predicates=predicates)
180-
if ancestor.startswith(ontology_prefix)
186+
if self._matches_ontology(ancestor)
181187
)
182188

183189
# Create relations for each ancestor

tests/test_ontology_processor.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,45 @@
11
"""Test OntologyProcessor class and its methods."""
22

3+
import pytest
4+
35
from src.ontology_loader.ontology_processor import OntologyProcessor
46

57

8+
@pytest.mark.parametrize(
9+
"ontology_name, entity_id, expected",
10+
[
11+
# Same-case prefixes (the historical path)
12+
("envo", "ENVO:00002005", True),
13+
("envo", "envo:00002005", True),
14+
("uberon", "UBERON:0000001", True),
15+
("po", "PO:0000001", True),
16+
# Mixed-case prefixes that the prior `.upper()`-based filter dropped silently
17+
("ncbitaxon", "NCBITaxon:9606", True),
18+
("ncbitaxon", "NCBITAXON:9606", True),
19+
("ncbitaxon", "ncbitaxon:9606", True),
20+
("chebi", "CHEBI:12345", True),
21+
# Wrong ontology — must reject
22+
("envo", "UBERON:0000001", False),
23+
("ncbitaxon", "PR:Q9606", False),
24+
# Missing colon — must reject
25+
("ncbitaxon", "NCBITaxon", False),
26+
("envo", "ENVO", False),
27+
("envo", "", False),
28+
],
29+
)
30+
def test_matches_ontology(ontology_name, entity_id, expected):
31+
"""`_matches_ontology` compares the CURIE head case-insensitively to the configured ontology."""
32+
33+
# Avoid the heavy `OntologyProcessor.__init__` (which downloads + opens sqlite); the method
34+
# only depends on `self._ontology_lc`, so a minimal stand-in object is sufficient.
35+
class _Fake:
36+
pass
37+
38+
fake = _Fake()
39+
fake._ontology_lc = ontology_name.lower()
40+
assert OntologyProcessor._matches_ontology(fake, entity_id) is expected
41+
42+
643
def test_ontology_processor():
744
"""Test OntologyProcessor initialization and ontology retrieval."""
845
ontology_name = "envo"

0 commit comments

Comments
 (0)