diff --git a/semantica/export/owl_exporter.py b/semantica/export/owl_exporter.py index 3c604d59..6770ddeb 100644 --- a/semantica/export/owl_exporter.py +++ b/semantica/export/owl_exporter.py @@ -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' ') - lines.append(f" {ontology_name}") - lines.append(f" {version}") + lines.append(f' ') + lines.append(f" {esc_xml(ontology_name)}") + lines.append(f" {esc_xml(version)}") if ontology.get("description"): lines.append( - f' {ontology.get("description")}' + f' {esc_xml(ontology.get("description"))}' ) lines.append(" ") 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' ') - lines.append(f" {class_name}") + lines.append(f' ') + lines.append(f" {esc_xml(class_name)}") - if cls.get("comment"): - lines.append(f' {cls.get("comment")}') + comment = cls.get("comment") or cls.get("description") + if comment: + lines.append(f" {esc_xml(comment)}") # Subclass relationships - if cls.get("subClassOf"): - parent = cls.get("subClassOf") - lines.append(f' ') + 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' ' + ) # Equivalent classes - if cls.get("equivalentClass"): - equiv = cls.get("equivalentClass") - lines.append(f' ') + 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' ' + ) lines.append(" ") 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' ') - lines.append(f" {prop_name}") + lines.append(f' ') + lines.append(f" {esc_xml(prop_name)}") - if prop.get("comment"): - lines.append(f' {prop.get("comment")}') + comment = prop.get("comment") or prop.get("description") + if comment: + lines.append(f" {esc_xml(comment)}") - # Domain - if prop.get("domain"): - domain = prop.get("domain") - if isinstance(domain, list): - for d in domain: - lines.append(f' ') - else: - lines.append(f' ') + 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' ' + ) - # Range - if prop.get("range"): - range_val = prop.get("range") - if isinstance(range_val, list): - for r in range_val: - lines.append(f' ') - else: - lines.append(f' ') + 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' ' + ) lines.append(" ") 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' ') - lines.append(f" {prop_name}") + lines.append(f' ') + lines.append(f" {esc_xml(prop_name)}") - if prop.get("comment"): - lines.append(f' {prop.get("comment")}') + comment = prop.get("comment") or prop.get("description") + if comment: + lines.append(f" {esc_xml(comment)}") - # Domain - if prop.get("domain"): - domain = prop.get("domain") - lines.append(f' ') + 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' ' + ) - # Range - if prop.get("range"): - range_type = prop.get("range", "xsd:string") - lines.append( - f' ' - ) + for range_val in _as_list(prop.get("range")): + range_iri = self._resolve_datatype_iri(range_val) + if range_iri: + lines.append( + f' ' + ) lines.append(" ") lines.append("") @@ -335,6 +365,161 @@ class OWLExporter: lines.append("") 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("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + @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("") diff --git a/semantica/ontology/ontology_generator.py b/semantica/ontology/ontology_generator.py index 80851f63..db5ae460 100644 --- a/semantica/ontology/ontology_generator.py +++ b/semantica/ontology/ontology_generator.py @@ -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 diff --git a/tests/export/test_owl_exporter_generator_schema.py b/tests/export/test_owl_exporter_generator_schema.py new file mode 100644 index 00000000..db65ca70 --- /dev/null +++ b/tests/export/test_owl_exporter_generator_schema.py @@ -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