Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion .claude/rules/ontology-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ onto.get_abox_axioms() # assertions/individual-level
onto.equivalent_classes_axioms(cls); onto.general_class_axioms()
onto.data_property_domain_axioms(prop); onto.data_property_range_axioms(prop)
onto.object_property_domain_axioms(prop); onto.object_property_range_axioms(prop)
onto.annotation_assertion_axioms(entity) # rdfs:label/rdfs:comment/etc. on entity (or its IRI directly); SyncOntology and RDFLibOntology only
```

Add/remove axioms with `onto.add_axiom([...])` / `onto.remove_axiom(axiom)`. Common axiom types
Expand Down Expand Up @@ -73,7 +74,7 @@ save_owl_class_expressions(expressions=[expr1, expr2], path="predictions.owl",

- Use full IRIs or `IRI.create(namespace, remainder)` when constructing entities — never bare strings
- `SyncOntology` is preferred when you need Java-backed reasoning; `RDFLibOntology` is preferred for pure-Python read/write use (no JVM/owlready2), but only between *named* entities -- `Ontology` (owlready2-backed) is legacy, being phased out (#205)
- `RDFLibOntology.add_axiom()`/`remove_axiom()`/`general_class_axioms()` raise `NotImplementedError` on axioms involving complex (blank-node) class/property expressions or general class axioms, and on a handful of axiom types not yet covered (e.g. `OWLSameIndividualAxiom`, `OWLAnnotationAssertionAxiom`) -- the error message states what's supported. `RDFLibOntology(iri, load=False)` creates a blank ontology at the given IRI
- `RDFLibOntology.add_axiom()`/`remove_axiom()`/`general_class_axioms()` raise `NotImplementedError` on axioms involving complex (blank-node) class/property expressions or general class axioms, and on a handful of axiom types not yet covered (e.g. `OWLSameIndividualAxiom`, `OWLAnnotationAssertionAxiom`) -- the error message states what's supported. Annotations are still read-only there: `annotation_assertion_axioms()` works, but `add_axiom(OWLAnnotationAssertionAxiom(...))` is not yet implemented. `RDFLibOntology(iri, load=False)` creates a blank ontology at the given IRI
- `create_ontology` paths need a valid file URI scheme (e.g. `"file:/path.owl"`)
- Don't pass `with_owlapi=True` unless Java/OWLAPI interop is explicitly needed — it starts a JVM (see `.claude/rules/owlapi-swrl.md`)
- `owlready2` is an optional install extra (`pip install owlapy[owlready2]`), not a hard dependency (#205) — constructing an `Ontology` (or a `StructuralReasoner`) without it installed raises a clear `ImportError`; `SyncOntology`/`RDFLibOntology` are unaffected either way
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- `SyncOntology.annotation_assertion_axioms(entity)` and `RDFLibOntology.annotation_assertion_axioms(entity)`, for retrieving an entity's annotations (e.g. `rdfs:label`/`rdfs:comment`) directly through owlapy's Python API instead of dropping down to the OWLAPI Java object or loading the ontology a second time with rdflib -- `get_tbox_axioms()`/`get_abox_axioms()` never surfaced annotation assertions, since OWLAPI itself doesn't categorize them as TBox/ABox/RBox. `RDFLibOntology`'s implementation recognizes the well-known annotation predicates (`rdfs:label`, `rdfs:comment`, `rdfs:seeAlso`, `rdfs:isDefinedBy`, `owl:versionInfo`, `owl:deprecated`, `owl:priorVersion`, `owl:backwardCompatibleWith`, `owl:incompatibleWith`) plus any predicate explicitly declared `rdf:type owl:AnnotationProperty` in the loaded graph (#268)
- Python-side type checks for `owlapy.iri.IRI` (`__init__`/`create`, converting bare `-O`-strippable `assert`s to clear `TypeError`/`ValueError`), `owlapy.owl_individual.OWLAnonymousIndividual` (`node_id`), and `owlapy.owl_ontology.OWLOntologyID` (`ontology_iri`/`version_iri`) -- a broader sweep beyond #271's explicitly-tracked scope, covering the same silent-Python-success-then-opaque-JVM-failure pattern for the identity type nearly every OWL entity is built from (#271)
- Python-side type checks for `owlapy.swrl` (`Variable`/`IVariable`/`DVariable`, `ClassAtom`, `DataRangeAtom`, `ObjectPropertyAtom`, `DataPropertyAtom`, `SameAsAtom`, `DifferentFromAtom`, `BuiltInAtom`, `Rule`), the last unvalidated Python-Java mapping flagged in #271. Also fixed a latent bug in `Atom.from_string`'s built-in-predicate parsing branch where a misplaced `return` inside the arg-conversion loop silently left every argument but the first as a raw, unconverted string, and non-variable arguments were wrapped as `OWLNamedIndividual` instead of `OWLLiteral` (#271)
- Python-side type checks for `owlapy.owl_data_ranges`'s `OWLNaryDataRange`/`OWLDataComplementOf`, deliberately left out of the two PRs above: operands are validated against `OWLPropertyRange` (the common base of `OWLDataRange` and `OWLClassExpression`) rather than `OWLDataRange` itself, since `owlapy.utils.nnf.NNF` deliberately reuses these constructors to wrap data-side class expressions (e.g. `OWLDataSomeValuesFrom`) during negation. This closes out the remaining scope of #271 (all Python-Java mappings now have Python-side validation)
Expand Down
60 changes: 59 additions & 1 deletion owlapy/owl_ontology.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
)
from owlapy.iri import IRI
from owlapy.owl_axiom import (
OWLAnnotation,
OWLAnnotationAssertionAxiom,
OWLAnnotationProperty,
OWLAsymmetricObjectPropertyAxiom,
Expand Down Expand Up @@ -93,7 +94,7 @@
from owlapy.owl_datatype import OWLDatatype
from owlapy.owl_individual import OWLIndividual, OWLNamedIndividual
from owlapy.owl_literal import BooleanOWLDatatype, DateOWLDatatype, DateTimeOWLDatatype, DoubleOWLDatatype, DurationOWLDatatype, IntegerOWLDatatype, OWLLiteral, StringOWLDatatype, TopOWLDatatype
from owlapy.owl_object import OWLObject
from owlapy.owl_object import OWLEntity, OWLObject
from owlapy.owl_property import OWLDataProperty, OWLDataPropertyExpression, OWLObjectInverseOf, OWLObjectProperty, OWLObjectPropertyExpression, OWLProperty, OWLPropertyExpression
from owlapy.static_funcs import startJVM
from owlapy.vocab import OWLFacet
Expand Down Expand Up @@ -1387,6 +1388,19 @@ def object_property_domain_axioms(self, property: OWLObjectProperty) -> Iterable
def object_property_range_axioms(self, property: OWLObjectProperty) -> Iterable[OWLObjectPropertyRangeAxiom]:
return self.mapper.map_(self.owlapi_ontology.getObjectPropertyRangeAxioms(self.mapper.map_(property)))

def annotation_assertion_axioms(self, entity: Union[OWLEntity, IRI]) -> Iterable[OWLAnnotationAssertionAxiom]:
"""Gets the annotation assertion axioms about the given entity in this ontology, e.g. its
`rdfs:label`/`rdfs:comment` or any other `owl:AnnotationProperty` assertion.

Args:
entity: The entity (or its IRI directly) to get annotation assertions for.

Returns:
Annotation assertion axioms whose subject is the given entity's IRI.
"""
subject_iri = entity if isinstance(entity, IRI) else entity.iri
return self.mapper.map_(self.owlapi_ontology.getAnnotationAssertionAxioms(self.mapper.map_(subject_iri)))

def _get_imports_enum(self, include_imports_closure: bool):
# noinspection PyUnresolvedReferences
from org.semanticweb.owlapi.model.parameters import Imports
Expand Down Expand Up @@ -2035,6 +2049,16 @@ def _(axiom: OWLDataPropertyCharacteristicAxiom, ontology) -> None:
_remove_property_characteristic(axiom, ontology)


# Annotation properties every OWL ontology may use without declaring them locally as
# `owl:AnnotationProperty` -- RDFLibOntology.annotation_assertion_axioms() also honours any
# predicate explicitly declared as such in the loaded graph.
_WELL_KNOWN_ANNOTATION_PREDICATES = frozenset((
rdflib.RDFS.label, rdflib.RDFS.comment, rdflib.RDFS.seeAlso, rdflib.RDFS.isDefinedBy,
rdflib.OWL.versionInfo, rdflib.OWL.deprecated, rdflib.OWL.priorVersion,
rdflib.OWL.backwardCompatibleWith, rdflib.OWL.incompatibleWith,
))


class RDFLibOntology(AbstractOWLOntology):

def __init__(self, path: Union[str, IRI], load: bool = True):
Expand All @@ -2047,13 +2071,15 @@ def __init__(self, path: Union[str, IRI], load: bool = True):
self.str_owl_individuals = [x.n3()[1:-1] for x in self.rdflib_graph.subjects(rdflib.RDF.type, rdflib.OWL.NamedIndividual) if not isinstance(x, rdflib.term.BNode)]
self.str_owl_object_properties = [x.n3()[1:-1] for x in self.rdflib_graph.subjects(rdflib.RDF.type, rdflib.OWL.ObjectProperty) if not isinstance(x, rdflib.term.BNode)]
self.str_owl_data_properties = [x.n3()[1:-1] for x in self.rdflib_graph.subjects(rdflib.RDF.type, rdflib.OWL.DatatypeProperty) if not isinstance(x, rdflib.term.BNode)]
self.str_owl_annotation_properties = [x.n3()[1:-1] for x in self.rdflib_graph.subjects(rdflib.RDF.type, rdflib.OWL.AnnotationProperty) if not isinstance(x, rdflib.term.BNode)]
else: # create a blank ontology; `path`/`self._path` is treated as the new ontology's IRI
self.rdflib_graph = rdflib.Graph()
self.rdflib_graph.add((rdflib.URIRef(self._path), rdflib.RDF.type, rdflib.OWL.Ontology))
self.str_owl_classes = []
self.str_owl_individuals = []
self.str_owl_object_properties = []
self.str_owl_data_properties = []
self.str_owl_annotation_properties = []

def __len__(self) -> int:
return len(self.rdflib_graph)
Expand Down Expand Up @@ -2139,6 +2165,38 @@ def get_abox_axioms_between_individuals(self) -> Iterable[OWLObjectPropertyAsser
def get_abox_axioms_between_individuals_and_classes(self) -> Iterable[OWLClassAssertionAxiom]:
return [axiom for axiom in self.get_abox_axioms() if isinstance(axiom, OWLClassAssertionAxiom)]

def annotation_assertion_axioms(self, entity: Union[OWLEntity, IRI]) -> Iterable[OWLAnnotationAssertionAxiom]:
"""Gets the annotation assertion axioms about the given entity in this ontology, e.g. its
`rdfs:label`/`rdfs:comment`.

Recognizes the well-known annotation predicates (`rdfs:label`, `rdfs:comment`, `rdfs:seeAlso`,
`rdfs:isDefinedBy`, `owl:versionInfo`, `owl:deprecated`, `owl:priorVersion`,
`owl:backwardCompatibleWith`, `owl:incompatibleWith`) plus any predicate explicitly declared
`rdf:type owl:AnnotationProperty` in the loaded graph. A blank-node annotation value (an
anonymous individual) is skipped, matching how blank nodes are handled elsewhere in this class.

Args:
entity: The entity (or its IRI directly) to get annotation assertions for.

Returns:
Annotation assertion axioms whose subject is the given entity's IRI.
"""
subject_iri = entity if isinstance(entity, IRI) else entity.iri
subject_ref = rdflib.URIRef(subject_iri.str)
annotation_predicates = _WELL_KNOWN_ANNOTATION_PREDICATES | {rdflib.URIRef(s) for s in self.str_owl_annotation_properties}
results = []
for (_, p, o) in self.rdflib_graph.triples((subject_ref, None, None)):
if p not in annotation_predicates:
continue
if isinstance(o, rdflib.term.Literal):
value = _rdflib_literal_to_owl_literal(o)
elif isinstance(o, rdflib.term.URIRef):
value = IRI.create(o.n3()[1:-1])
else:
continue
results.append(OWLAnnotationAssertionAxiom(subject_iri, OWLAnnotation(OWLAnnotationProperty(p.n3()[1:-1]), value)))
return results

def equivalent_classes_axioms(self, c: OWLClass) -> Iterable[OWLEquivalentClassesAxiom]:
c_uri = rdflib.URIRef(c.str)
seen = set()
Expand Down
71 changes: 71 additions & 0 deletions tests/test_rdflib_ontology.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,77 @@ def test_get_tbox_axioms_ignores_annotation_predicates(basic_kg_path):
assert not any("A person is an Agent." in str(a) for a in axioms)


@pytest.fixture
def annotated_kg_path(tmp_path):
"""Student carries a well-known predicate (rdfs:label), a custom predicate explicitly declared
owl:AnnotationProperty (ex:priority), an IRI-valued annotation (rdfs:seeAlso), and a blank-node
annotation value that must be silently skipped rather than crashing."""
g = Graph()
student = URIRef(NS + "Student")
person = URIRef(NS + "Person")
priority = URIRef(NS + "priority")

g.add((student, RDF.type, OWL.Class))
g.add((student, RDFS.label, Literal("Student")))
g.add((student, RDFS.seeAlso, person))
g.add((priority, RDF.type, OWL.AnnotationProperty))
g.add((student, priority, Literal("high")))
g.add((student, URIRef(NS + "hasNote"), Literal("ignored", lang=None))) # undeclared predicate -> ignored
bnode_note = rdflib.BNode()
g.add((student, URIRef(NS + "hasBlankNote"), bnode_note))
g.add((bnode_note, RDF.type, URIRef(NS + "Note")))

path = str(tmp_path / "annotated.owl")
_write_graph(path, g)
return path


def test_annotation_assertion_axioms_well_known_predicate(basic_kg_path):
onto = RDFLibOntology(basic_kg_path)
axioms = list(onto.annotation_assertion_axioms(OWLClass(NS + "Person")))
assert len(axioms) == 1
assert axioms[0].get_property().str == str(RDFS.comment)
assert axioms[0].get_value() == OWLLiteral("A person is an Agent.")


def test_annotation_assertion_axioms_accepts_iri_directly(basic_kg_path):
onto = RDFLibOntology(basic_kg_path)
axioms = list(onto.annotation_assertion_axioms(IRI.create(NS, "Person")))
assert len(axioms) == 1
assert axioms[0].get_value() == OWLLiteral("A person is an Agent.")


def test_annotation_assertion_axioms_empty_for_entity_without_annotations(basic_kg_path):
onto = RDFLibOntology(basic_kg_path)
assert list(onto.annotation_assertion_axioms(OWLClass(NS + "Agent"))) == []


def test_annotation_assertion_axioms_custom_declared_annotation_property(annotated_kg_path):
onto = RDFLibOntology(annotated_kg_path)
axioms = list(onto.annotation_assertion_axioms(OWLClass(NS + "Student")))
priority_axioms = [a for a in axioms if a.get_property().str == NS + "priority"]
assert len(priority_axioms) == 1
assert priority_axioms[0].get_value() == OWLLiteral("high")


def test_annotation_assertion_axioms_iri_valued_annotation(annotated_kg_path):
onto = RDFLibOntology(annotated_kg_path)
axioms = list(onto.annotation_assertion_axioms(OWLClass(NS + "Student")))
see_also_axioms = [a for a in axioms if a.get_property().str == str(RDFS.seeAlso)]
assert len(see_also_axioms) == 1
assert see_also_axioms[0].get_value() == IRI.create(NS, "Person")


def test_annotation_assertion_axioms_ignores_undeclared_predicate_and_blank_node_value(annotated_kg_path):
onto = RDFLibOntology(annotated_kg_path)
axioms = list(onto.annotation_assertion_axioms(OWLClass(NS + "Student")))
properties = {a.get_property().str for a in axioms}
assert NS + "hasNote" not in properties
assert NS + "hasBlankNote" not in properties
# only the three recognized predicates should have made it through
assert properties == {str(RDFS.label), str(RDFS.seeAlso), NS + "priority"}


def test_get_abox_axioms_class_assertion_and_object_property(basic_kg_path):
onto = RDFLibOntology(basic_kg_path)
axioms = onto.get_abox_axioms()
Expand Down
62 changes: 62 additions & 0 deletions tests/test_sync_ontology_annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Unit tests for SyncOntology.annotation_assertion_axioms() (#268).

Retrieving an entity's rdfs:label/rdfs:comment (or any other annotation) previously required
either bypassing owlapy's Python abstraction to call the OWLAPI Java object directly, or
loading the ontology a second time with rdflib -- get_tbox_axioms()/get_abox_axioms() never
included annotation assertions, since OWLAPI itself doesn't categorize them as TBox/ABox/RBox.
"""
from rdflib import OWL, RDF, RDFS, Graph, Literal, URIRef

from owlapy.class_expression import OWLClass
from owlapy.iri import IRI
from owlapy.owl_literal import OWLLiteral
from owlapy.owl_ontology import SyncOntology

NS = "http://example.com/sync_annotation_test#"


def _write_ttl(path, graph):
graph.serialize(destination=path, format="turtle")


def _annotated_ontology_path(tmp_path):
g = Graph()
student = URIRef(NS + "Student")
person = URIRef(NS + "Person")
g.add((URIRef("http://example.com/sync_annotation_test"), RDF.type, OWL.Ontology))
g.add((student, RDF.type, OWL.Class))
g.add((person, RDF.type, OWL.Class))
g.add((student, RDFS.label, Literal("Student")))
g.add((student, RDFS.comment, Literal("A person enrolled in a course of study.")))
g.add((student, RDFS.seeAlso, person))

path = str(tmp_path / "annotated.ttl")
_write_ttl(path, g)
return path


def test_annotation_assertion_axioms_returns_label_and_comment(tmp_path):
onto = SyncOntology(_annotated_ontology_path(tmp_path))
axioms = list(onto.annotation_assertion_axioms(OWLClass(NS + "Student")))
values_by_property = {a.get_property().str: a.get_value() for a in axioms}
assert values_by_property[str(RDFS.label)] == OWLLiteral("Student")
assert values_by_property[str(RDFS.comment)] == OWLLiteral("A person enrolled in a course of study.")


def test_annotation_assertion_axioms_iri_valued_annotation(tmp_path):
onto = SyncOntology(_annotated_ontology_path(tmp_path))
axioms = list(onto.annotation_assertion_axioms(OWLClass(NS + "Student")))
see_also = next(a for a in axioms if a.get_property().str == str(RDFS.seeAlso))
assert see_also.get_value() == IRI.create(NS, "Person")


def test_annotation_assertion_axioms_accepts_iri_directly(tmp_path):
onto = SyncOntology(_annotated_ontology_path(tmp_path))
by_entity = list(onto.annotation_assertion_axioms(OWLClass(NS + "Student")))
by_iri = list(onto.annotation_assertion_axioms(IRI.create(NS, "Student")))
assert {a.get_property().str for a in by_entity} == {a.get_property().str for a in by_iri}


def test_annotation_assertion_axioms_empty_for_entity_without_annotations(tmp_path):
onto = SyncOntology(_annotated_ontology_path(tmp_path))
assert list(onto.annotation_assertion_axioms(OWLClass(NS + "Person"))) == []
Loading