fix(export): read the ontology shape the generator actually emits (#1103)

OWLExporter read `object_properties` and `data_properties`, while
OntologyGenerator emits one combined `properties` list tagged with
type/@type. Every generated property was therefore dropped, and a
generated ontology exported as classes alone.

Class IRIs were worse. ClassInferrer writes `"uri": None` when it is
given no namespace manager, so the stage 3 guard `if "uri" not in cls`
never fired: the key is present, only its value is missing. The exporter
then interpolated the empty string into `<>`, which is a relative IRI
that resolves against the parser's base. Under rdflib that base is the
current working directory, so a two-class ontology parsed as one subject
carrying two rdfs:label values, and the identity of that subject changed
with the directory the export ran from. Oxigraph rejects the same file
outright with "No scheme found in an absolute IRI".

Changes:

- Accept both dict shapes. `_split_properties` classifies the combined
  `properties` list by type/@type and merges it with any explicit
  `object_properties` and `data_properties`.
- Resolve class and property IRIs through `_term_iri`, falling back from
  uri to iri to id to a name joined onto the ontology base. A term with
  none of those is skipped with a warning rather than emitted as `<>`.
- Resolve domain and range references through the class index, so a bare
  name such as "Person" lands on the IRI that class was exported under
  instead of staying relative.
- Resolve data property ranges properly. "string", "xsd:string" and a
  full IRI now all give one well formed datatype. The previous
  `rdfs:range xsd:{range}` produced `xsd:xsd:string` for generator output,
  which no parser accepts. Turtle keeps the compact xsd: form the module
  already used.
- Fix the two `not in` guards in the generator so a present-but-None uri
  is minted, and mint an absolute IRI rather than assigning a bare name.
- Escape XML text and attribute values, which were interpolated raw, so a
  label containing & or < no longer breaks the document.

Turtle and RDF/XML now serialise the same 25 triples for the same
ontology, and both are accepted by rdflib and by Oxigraph.

10 regression tests in tests/export/test_owl_exporter_generator_schema.py,
driven by a real OntologyGenerator run and asserting on the parsed graph
rather than on serialised text. All 10 fail on the parent commit. The
export and ontology suites pass at 231 tests.
This commit is contained in:
FABIOTESS
2026-08-19 17:11:05 +01:00
parent 2a303cf4da
commit c30ec14858
3 changed files with 491 additions and 97 deletions
+296 -90
View File
@@ -22,6 +22,7 @@ Author: Semantica Contributors
License: MIT
"""
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
@@ -225,6 +226,7 @@ class OWLExporter:
Returns:
String containing OWL-XML serialization
"""
esc_xml = self._escape_xml
ontology_uri = ontology.get("uri") or self.ontology_uri
ontology_name = ontology.get("name", "SemanticaOntology")
version = ontology.get("version") or self.version
@@ -237,97 +239,125 @@ class OWLExporter:
lines.append("")
# Ontology declaration
lines.append(f' <owl:Ontology rdf:about="{ontology_uri}">')
lines.append(f" <rdfs:label>{ontology_name}</rdfs:label>")
lines.append(f" <owl:versionInfo>{version}</owl:versionInfo>")
lines.append(f' <owl:Ontology rdf:about="{esc_xml(ontology_uri)}">')
lines.append(f" <rdfs:label>{esc_xml(ontology_name)}</rdfs:label>")
lines.append(f" <owl:versionInfo>{esc_xml(version)}</owl:versionInfo>")
if ontology.get("description"):
lines.append(
f' <rdfs:comment>{ontology.get("description")}</rdfs:comment>'
f' <rdfs:comment>{esc_xml(ontology.get("description"))}</rdfs:comment>'
)
lines.append(" </owl:Ontology>")
lines.append("")
class_index = self._class_iri_index(ontology, ontology_uri)
object_properties, data_properties = self._split_properties(ontology)
def _as_list(value):
if value is None:
return []
return value if isinstance(value, list) else [value]
# Classes
classes = ontology.get("classes", [])
for cls in classes:
class_uri = cls.get("uri") or cls.get("id", "")
for cls in ontology.get("classes", []) or []:
if not isinstance(cls, dict):
continue
class_uri = self._term_iri(cls, ontology_uri)
if not class_uri:
self.logger.warning(
"Skipping a class with no name, uri or id: it would serialise "
"as an empty rdf:about"
)
continue
class_name = cls.get("name") or cls.get("label", "")
lines.append(f' <owl:Class rdf:about="{class_uri}">')
lines.append(f" <rdfs:label>{class_name}</rdfs:label>")
lines.append(f' <owl:Class rdf:about="{esc_xml(class_uri)}">')
lines.append(f" <rdfs:label>{esc_xml(class_name)}</rdfs:label>")
if cls.get("comment"):
lines.append(f' <rdfs:comment>{cls.get("comment")}</rdfs:comment>')
comment = cls.get("comment") or cls.get("description")
if comment:
lines.append(f" <rdfs:comment>{esc_xml(comment)}</rdfs:comment>")
# Subclass relationships
if cls.get("subClassOf"):
parent = cls.get("subClassOf")
lines.append(f' <rdfs:subClassOf rdf:resource="{parent}"/>')
for parent in _as_list(cls.get("subClassOf") or cls.get("parent")):
parent_iri = self._resolve_class_ref(parent, ontology_uri, class_index)
if parent_iri:
lines.append(
f' <rdfs:subClassOf rdf:resource="{esc_xml(parent_iri)}"/>'
)
# Equivalent classes
if cls.get("equivalentClass"):
equiv = cls.get("equivalentClass")
lines.append(f' <owl:equivalentClass rdf:resource="{equiv}"/>')
for equiv in _as_list(cls.get("equivalentClass")):
equiv_iri = self._resolve_class_ref(equiv, ontology_uri, class_index)
if equiv_iri:
lines.append(
f' <owl:equivalentClass rdf:resource="{esc_xml(equiv_iri)}"/>'
)
lines.append(" </owl:Class>")
lines.append("")
# Object properties
object_properties = ontology.get("object_properties", [])
for prop in object_properties:
prop_uri = prop.get("uri") or prop.get("id", "")
prop_uri = self._term_iri(prop, ontology_uri)
if not prop_uri:
self.logger.warning(
"Skipping an object property with no name, uri or id"
)
continue
prop_name = prop.get("name") or prop.get("label", "")
lines.append(f' <owl:ObjectProperty rdf:about="{prop_uri}">')
lines.append(f" <rdfs:label>{prop_name}</rdfs:label>")
lines.append(f' <owl:ObjectProperty rdf:about="{esc_xml(prop_uri)}">')
lines.append(f" <rdfs:label>{esc_xml(prop_name)}</rdfs:label>")
if prop.get("comment"):
lines.append(f' <rdfs:comment>{prop.get("comment")}</rdfs:comment>')
comment = prop.get("comment") or prop.get("description")
if comment:
lines.append(f" <rdfs:comment>{esc_xml(comment)}</rdfs:comment>")
# Domain
if prop.get("domain"):
domain = prop.get("domain")
if isinstance(domain, list):
for d in domain:
lines.append(f' <rdfs:domain rdf:resource="{d}"/>')
else:
lines.append(f' <rdfs:domain rdf:resource="{domain}"/>')
for domain in _as_list(prop.get("domain")):
domain_iri = self._resolve_class_ref(domain, ontology_uri, class_index)
if domain_iri:
lines.append(
f' <rdfs:domain rdf:resource="{esc_xml(domain_iri)}"/>'
)
# Range
if prop.get("range"):
range_val = prop.get("range")
if isinstance(range_val, list):
for r in range_val:
lines.append(f' <rdfs:range rdf:resource="{r}"/>')
else:
lines.append(f' <rdfs:range rdf:resource="{range_val}"/>')
for range_val in _as_list(prop.get("range")):
range_iri = self._resolve_class_ref(range_val, ontology_uri, class_index)
if range_iri:
lines.append(
f' <rdfs:range rdf:resource="{esc_xml(range_iri)}"/>'
)
lines.append(" </owl:ObjectProperty>")
lines.append("")
# Data properties
data_properties = ontology.get("data_properties", [])
for prop in data_properties:
prop_uri = prop.get("uri") or prop.get("id", "")
prop_uri = self._term_iri(prop, ontology_uri)
if not prop_uri:
self.logger.warning("Skipping a data property with no name, uri or id")
continue
prop_name = prop.get("name") or prop.get("label", "")
lines.append(f' <owl:DatatypeProperty rdf:about="{prop_uri}">')
lines.append(f" <rdfs:label>{prop_name}</rdfs:label>")
lines.append(f' <owl:DatatypeProperty rdf:about="{esc_xml(prop_uri)}">')
lines.append(f" <rdfs:label>{esc_xml(prop_name)}</rdfs:label>")
if prop.get("comment"):
lines.append(f' <rdfs:comment>{prop.get("comment")}</rdfs:comment>')
comment = prop.get("comment") or prop.get("description")
if comment:
lines.append(f" <rdfs:comment>{esc_xml(comment)}</rdfs:comment>")
# Domain
if prop.get("domain"):
domain = prop.get("domain")
lines.append(f' <rdfs:domain rdf:resource="{domain}"/>')
for domain in _as_list(prop.get("domain")):
domain_iri = self._resolve_class_ref(domain, ontology_uri, class_index)
if domain_iri:
lines.append(
f' <rdfs:domain rdf:resource="{esc_xml(domain_iri)}"/>'
)
# Range
if prop.get("range"):
range_type = prop.get("range", "xsd:string")
lines.append(
f' <rdfs:range rdf:resource="http://www.w3.org/2001/XMLSchema#{range_type}"/>'
)
for range_val in _as_list(prop.get("range")):
range_iri = self._resolve_datatype_iri(range_val)
if range_iri:
lines.append(
f' <rdfs:range rdf:resource="{esc_xml(range_iri)}"/>'
)
lines.append(" </owl:DatatypeProperty>")
lines.append("")
@@ -335,6 +365,161 @@ class OWLExporter:
lines.append("</rdf:RDF>")
return "\n".join(lines)
# ── Ontology-dict normalisation ───────────────────────────────────────────
#
# OntologyGenerator emits a single `properties` list tagged with
# type/@type, while hand-authored ontologies use `object_properties` and
# `data_properties`. Both shapes are accepted; everything below works from
# the normalised view so the two cannot drift apart again (#1103).
_XSD_NS = "http://www.w3.org/2001/XMLSchema#"
@staticmethod
def _is_absolute_iri(value: str) -> bool:
return bool(re.match(r"^[A-Za-z][A-Za-z0-9+.\-]*:", value))
@classmethod
def _join_iri(cls, base: str, local: str) -> str:
"""Append a local name to a base IRI, respecting hash and slash bases."""
if not base:
return ""
separator = "" if base.endswith(("#", "/", ":")) else "#"
return f"{base}{separator}{local}"
@classmethod
def _term_iri(cls, term: Dict[str, Any], base: str) -> str:
"""
Resolve the IRI of a class or property.
Returns "" when the term carries nothing usable, so the caller can skip
it. Interpolating an empty string into <> silently resolves against the
parser's base — under rdflib that is the current working directory — and
collapses every such term onto one subject.
"""
for key in ("uri", "iri", "id"):
value = term.get(key)
if isinstance(value, str) and value.strip():
value = value.strip()
return value if cls._is_absolute_iri(value) else cls._join_iri(base, value)
name = term.get("name") or term.get("label")
if isinstance(name, str) and name.strip():
return cls._join_iri(base, name.strip())
return ""
@classmethod
def _class_iri_index(cls, ontology: Dict[str, Any], base: str) -> Dict[str, str]:
"""Map class name and label to the IRI that class is actually exported under."""
index: Dict[str, str] = {}
for class_def in ontology.get("classes", []) or []:
if not isinstance(class_def, dict):
continue
iri = cls._term_iri(class_def, base)
if not iri:
continue
for key in (class_def.get("name"), class_def.get("label")):
if isinstance(key, str) and key.strip():
index.setdefault(key.strip(), iri)
return index
@classmethod
def _resolve_class_ref(cls, value: Any, base: str, index: Dict[str, str]) -> str:
"""
Resolve a domain/range reference to an absolute IRI.
The generator writes bare class names here. Looking the name up in the
class index first means a reference always lands on the IRI that class
was exported under, rather than on a re-derived guess.
"""
if not isinstance(value, str) or not value.strip():
return ""
value = value.strip()
if cls._is_absolute_iri(value):
return value
if value in index:
return index[value]
if ":" in value: # a prefixed name we cannot expand
return ""
return cls._join_iri(base, value)
@classmethod
def _resolve_datatype_iri(cls, value: Any) -> str:
"""
Resolve a data property range to an absolute datatype IRI.
Accepts "string", "xsd:string" and a full IRI alike. The previous
`xsd:{range}` interpolation doubled the prefix whenever the generator
had already written "xsd:string".
"""
if not isinstance(value, str) or not value.strip():
return ""
value = value.strip()
if value.startswith(("xsd:", "XSD:")):
return cls._XSD_NS + value.split(":", 1)[1]
if cls._is_absolute_iri(value):
return value
return cls._XSD_NS + value
@classmethod
def _ttl_datatype_ref(cls, value: Any) -> str:
"""
Render a data property range for Turtle.
XSD datatypes are written with the xsd: prefix the header already
declares; anything else is written as a full IRI. Both are the same
term, this only keeps the compact style the module was written in.
"""
iri = cls._resolve_datatype_iri(value)
if not iri:
return ""
if iri.startswith(cls._XSD_NS):
return f"xsd:{iri[len(cls._XSD_NS):]}"
return f"<{iri}>"
@classmethod
def _split_properties(
cls, ontology: Dict[str, Any]
) -> "tuple[List[Dict[str, Any]], List[Dict[str, Any]]]":
"""
Return (object_properties, data_properties) across both dict shapes.
A property listed under an explicit key keeps that key's kind. A
property from the generator's combined `properties` list is classified
by its own type/@type, defaulting to a data property.
"""
object_props: List[Dict[str, Any]] = []
data_props: List[Dict[str, Any]] = []
for prop in ontology.get("object_properties", []) or []:
if isinstance(prop, dict):
object_props.append(prop)
for prop in ontology.get("data_properties", []) or []:
if isinstance(prop, dict):
data_props.append(prop)
for prop in ontology.get("properties", []) or []:
if not isinstance(prop, dict):
continue
kind = str(prop.get("type") or "").strip().lower()
owl_type = str(prop.get("@type") or "").strip().lower()
if kind in ("object", "objectproperty") or owl_type.endswith("objectproperty"):
object_props.append(prop)
else:
data_props.append(prop)
return object_props, data_props
@staticmethod
def _escape_xml(value: Any) -> str:
"""Escape a value for safe embedding in XML text or an attribute value."""
return (
str(value)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
@staticmethod
def _escape_ttl_str(value: str) -> str:
"""Escape a string value for safe embedding in a Turtle string literal."""
@@ -387,62 +572,83 @@ class OWLExporter:
lines.append(self._ttl_block(ontology_uri, "owl:Ontology", onto_predicates))
lines.append("")
class_index = self._class_iri_index(ontology, ontology_uri)
object_properties, data_properties = self._split_properties(ontology)
def _as_list(value):
if value is None:
return []
return value if isinstance(value, list) else [value]
# Classes
for cls in ontology.get("classes", []):
class_uri = cls.get("uri") or cls.get("id", "")
for cls in ontology.get("classes", []) or []:
if not isinstance(cls, dict):
continue
class_uri = self._term_iri(cls, ontology_uri)
if not class_uri:
self.logger.warning(
"Skipping a class with no name, uri or id: it would serialise as <>"
)
continue
class_name = cls.get("name") or cls.get("label", "")
predicates = [f'rdfs:label "{esc(class_name)}"']
comment = cls.get("comment")
comment = cls.get("comment") or cls.get("description")
if comment:
predicates.append(f'rdfs:comment "{esc(comment)}"')
sub_class = cls.get("subClassOf")
if sub_class:
predicates.append(f"rdfs:subClassOf <{sub_class}>")
equiv = cls.get("equivalentClass")
if equiv:
predicates.append(f"owl:equivalentClass <{equiv}>")
for parent in _as_list(cls.get("subClassOf") or cls.get("parent")):
parent_iri = self._resolve_class_ref(parent, ontology_uri, class_index)
if parent_iri:
predicates.append(f"rdfs:subClassOf <{parent_iri}>")
for equiv in _as_list(cls.get("equivalentClass")):
equiv_iri = self._resolve_class_ref(equiv, ontology_uri, class_index)
if equiv_iri:
predicates.append(f"owl:equivalentClass <{equiv_iri}>")
lines.append(self._ttl_block(class_uri, "owl:Class", predicates))
lines.append("")
# Object properties
for prop in ontology.get("object_properties", []):
prop_uri = prop.get("uri") or prop.get("id", "")
for prop in object_properties:
prop_uri = self._term_iri(prop, ontology_uri)
if not prop_uri:
self.logger.warning(
"Skipping an object property with no name, uri or id"
)
continue
prop_name = prop.get("name") or prop.get("label", "")
predicates = [f'rdfs:label "{esc(prop_name)}"']
comment = prop.get("comment")
comment = prop.get("comment") or prop.get("description")
if comment:
predicates.append(f'rdfs:comment "{esc(comment)}"')
domain = prop.get("domain")
if domain:
if isinstance(domain, list):
for d in domain:
predicates.append(f"rdfs:domain <{d}>")
else:
predicates.append(f"rdfs:domain <{domain}>")
range_val = prop.get("range")
if range_val:
if isinstance(range_val, list):
for r in range_val:
predicates.append(f"rdfs:range <{r}>")
else:
predicates.append(f"rdfs:range <{range_val}>")
for domain in _as_list(prop.get("domain")):
domain_iri = self._resolve_class_ref(domain, ontology_uri, class_index)
if domain_iri:
predicates.append(f"rdfs:domain <{domain_iri}>")
for range_val in _as_list(prop.get("range")):
range_iri = self._resolve_class_ref(range_val, ontology_uri, class_index)
if range_iri:
predicates.append(f"rdfs:range <{range_iri}>")
lines.append(self._ttl_block(prop_uri, "owl:ObjectProperty", predicates))
lines.append("")
# Data properties
for prop in ontology.get("data_properties", []):
prop_uri = prop.get("uri") or prop.get("id", "")
for prop in data_properties:
prop_uri = self._term_iri(prop, ontology_uri)
if not prop_uri:
self.logger.warning("Skipping a data property with no name, uri or id")
continue
prop_name = prop.get("name") or prop.get("label", "")
predicates = [f'rdfs:label "{esc(prop_name)}"']
comment = prop.get("comment")
comment = prop.get("comment") or prop.get("description")
if comment:
predicates.append(f'rdfs:comment "{esc(comment)}"')
domain = prop.get("domain")
if domain:
predicates.append(f"rdfs:domain <{domain}>")
range_type = prop.get("range")
if range_type:
predicates.append(f"rdfs:range xsd:{range_type}")
for domain in _as_list(prop.get("domain")):
domain_iri = self._resolve_class_ref(domain, ontology_uri, class_index)
if domain_iri:
predicates.append(f"rdfs:domain <{domain_iri}>")
for range_val in _as_list(prop.get("range")):
range_ref = self._ttl_datatype_ref(range_val)
if range_ref:
predicates.append(f"rdfs:range {range_ref}")
lines.append(self._ttl_block(prop_uri, "owl:DatatypeProperty", predicates))
lines.append("")
+14 -7
View File
@@ -438,10 +438,13 @@ class OntologyGenerator:
entities=entities, relationships=relationships, classes=classes, **prop_options
)
# Add types to classes
# Add types to classes.
# ClassInferrer sets "uri": None when it was given no namespace manager,
# so the key is present and a `not in` guard never fires: every class
# then reached the exporters with no IRI at all (#1103).
for cls in classes:
cls["@type"] = "owl:Class"
if "uri" not in cls:
if not cls.get("uri"):
cls["uri"] = self.namespace_manager.generate_class_iri(cls["name"])
# Add types to properties
@@ -451,7 +454,7 @@ class OntologyGenerator:
else:
prop["@type"] = "owl:DatatypeProperty"
if "uri" not in prop:
if not prop.get("uri"):
prop["uri"] = self.namespace_manager.generate_property_iri(prop["name"])
return {
@@ -692,12 +695,16 @@ class OntologyOptimizer:
Returns:
Improved ontology
"""
# Ensure all classes have required fields
# Ensure all classes have required fields. Both guards used `not in`,
# which misses a key that is present and None, and the URI fallback
# assigned a bare class name where an absolute IRI is required (#1103).
classes = ontology.get("classes", [])
for cls in classes:
if "uri" not in cls:
cls["uri"] = cls.get("name", "Entity")
if "label" not in cls:
if not cls.get("uri"):
cls["uri"] = self.namespace_manager.generate_class_iri(
cls.get("name", "Entity")
)
if not cls.get("label"):
cls["label"] = cls.get("name", "Entity")
# Ensure all properties have domains and ranges
@@ -0,0 +1,181 @@
"""
Regression tests for #1103.
OWLExporter read `object_properties` / `data_properties` while OntologyGenerator
emits a single `properties` list, so every generated property was dropped. Class
IRIs arrived as None and were interpolated into `<>`, collapsing every class onto
the empty relative IRI, so an ontology of N classes serialised as one node
carrying N labels.
These tests drive the exporter with what the generator actually produces, and
assert on the parsed graph rather than on the serialised text.
"""
import pytest
rdflib = pytest.importorskip("rdflib")
from rdflib import Graph, RDF, RDFS, OWL, URIRef, Literal # noqa: E402
from semantica.export.owl_exporter import OWLExporter # noqa: E402
from semantica.ontology.ontology_generator import OntologyGenerator # noqa: E402
XSD = "http://www.w3.org/2001/XMLSchema#"
@pytest.fixture(scope="module")
def generated_ontology():
"""A real OntologyGenerator run, not a hand-written stand-in."""
data = {
"entities": [
{"type": "Person", "name": "John", "age": 30},
{"type": "Person", "name": "Jane", "age": 25},
{"type": "Organization", "name": "Acme"},
{"type": "Organization", "name": "Globex"},
],
"relationships": [
{"source": "John", "target": "Acme", "type": "works_at"},
{"source": "Jane", "target": "Globex", "type": "works_at"},
],
}
return OntologyGenerator().generate_ontology(data)
@pytest.fixture(scope="module")
def turtle_graph(generated_ontology):
ttl = OWLExporter()._export_owl_turtle(generated_ontology)
graph = Graph()
graph.parse(data=ttl, format="turtle")
return graph
@pytest.fixture(scope="module")
def xml_graph(generated_ontology):
xml = OWLExporter()._export_owl_xml(generated_ontology)
graph = Graph()
graph.parse(data=xml, format="xml")
return graph
def test_generator_mints_class_iris(generated_ontology):
"""The `uri` key is present with a None value, so a `not in` guard misses it."""
classes = generated_ontology["classes"]
assert classes, "fixture produced no classes"
for cls in classes:
assert cls.get("uri"), f"class {cls.get('name')!r} has no URI: {cls.get('uri')!r}"
assert str(cls["uri"]).startswith("http"), cls["uri"]
def test_every_class_is_a_distinct_absolute_iri(turtle_graph, generated_ontology):
subjects = set(turtle_graph.subjects(RDF.type, OWL.Class))
assert len(subjects) == len(generated_ontology["classes"])
for subject in subjects:
assert isinstance(subject, URIRef)
assert str(subject) != "", "class collapsed onto the empty relative IRI"
assert str(subject).startswith("http"), subject
def test_class_labels_are_not_stacked_on_one_node(turtle_graph):
"""Two classes must not share a subject and pile up two rdfs:label values."""
for subject in set(turtle_graph.subjects(RDF.type, OWL.Class)):
labels = list(turtle_graph.objects(subject, RDFS.label))
assert len(labels) == 1, f"{subject} carries {len(labels)} labels: {labels}"
def test_no_generated_property_is_dropped(turtle_graph, generated_ontology):
declared = set(turtle_graph.subjects(RDF.type, OWL.ObjectProperty)) | set(
turtle_graph.subjects(RDF.type, OWL.DatatypeProperty)
)
expected = {URIRef(p["uri"]) for p in generated_ontology["properties"]}
assert expected, "fixture produced no properties"
assert expected <= declared, f"dropped: {expected - declared}"
def test_properties_keep_their_owl_type(turtle_graph, generated_ontology):
by_uri = {p["uri"]: p for p in generated_ontology["properties"]}
for uri, prop in by_uri.items():
expected = OWL.ObjectProperty if prop["type"] == "object" else OWL.DatatypeProperty
assert (URIRef(uri), RDF.type, expected) in turtle_graph, (
f"{prop['name']} ({prop['type']}) is not typed {expected}"
)
def test_object_property_domain_and_range_are_class_iris(turtle_graph, generated_ontology):
"""The generator emits bare class names; they must resolve, not stay relative."""
class_iris = {URIRef(c["uri"]) for c in generated_ontology["classes"]}
obj_props = [p for p in generated_ontology["properties"] if p["type"] == "object"]
assert obj_props, "fixture produced no object properties"
for prop in obj_props:
subject = URIRef(prop["uri"])
for predicate in (RDFS.domain, RDFS.range):
values = list(turtle_graph.objects(subject, predicate))
assert values, f"{prop['name']} has no {predicate}"
for value in values:
assert value in class_iris, f"{prop['name']} {predicate} = {value!r}"
def test_data_property_range_is_a_single_well_formed_xsd_iri(turtle_graph, generated_ontology):
"""`rdfs:range xsd:{range}` doubled the prefix when range was already 'xsd:string'."""
data_props = [p for p in generated_ontology["properties"] if p["type"] != "object"]
assert data_props, "fixture produced no data properties"
for prop in data_props:
ranges = list(turtle_graph.objects(URIRef(prop["uri"]), RDFS.range))
assert ranges, f"{prop['name']} has no range"
for value in ranges:
assert str(value).startswith(XSD), f"{prop['name']} range = {value!r}"
assert "xsd:" not in str(value), f"doubled prefix: {value!r}"
def test_xml_and_turtle_describe_the_same_ontology(turtle_graph, xml_graph):
"""The two serialisations of one ontology must not be different graphs."""
def summary(graph):
return {
"classes": set(graph.subjects(RDF.type, OWL.Class)),
"object_properties": set(graph.subjects(RDF.type, OWL.ObjectProperty)),
"data_properties": set(graph.subjects(RDF.type, OWL.DatatypeProperty)),
}
assert summary(turtle_graph) == summary(xml_graph)
def test_explicit_object_and_data_property_keys_still_work():
"""The pre-existing hand-authored shape must keep working."""
ontology = {
"uri": "https://example.org/onto/",
"name": "Hand",
"classes": [{"uri": "https://example.org/onto/Person", "name": "Person"}],
"object_properties": [
{
"uri": "https://example.org/onto/knows",
"name": "knows",
"domain": "https://example.org/onto/Person",
"range": "https://example.org/onto/Person",
}
],
"data_properties": [
{"uri": "https://example.org/onto/age", "name": "age", "range": "integer"}
],
}
graph = Graph()
graph.parse(data=OWLExporter()._export_owl_turtle(ontology), format="turtle")
assert (URIRef("https://example.org/onto/knows"), RDF.type, OWL.ObjectProperty) in graph
assert (URIRef("https://example.org/onto/age"), RDF.type, OWL.DatatypeProperty) in graph
assert (
URIRef("https://example.org/onto/age"),
RDFS.range,
URIRef(XSD + "integer"),
) in graph
def test_a_class_without_any_identifier_is_skipped_not_emitted_as_empty():
"""An unusable class must not become `<>` and swallow the document IRI."""
ontology = {
"uri": "https://example.org/onto/",
"name": "Partial",
"classes": [{"comment": "no name, no uri, no id"}],
}
graph = Graph()
graph.parse(data=OWLExporter()._export_owl_turtle(ontology), format="turtle")
assert set(graph.subjects(RDF.type, OWL.Class)) == set()
assert (URIRef("https://example.org/onto/"), RDF.type, OWL.Ontology) in graph