From c30ec14858f2244906571dc7cd05d3de07d0611c Mon Sep 17 00:00:00 2001 From: FABIOTESS Date: Wed, 19 Aug 2026 17:11:05 +0100 Subject: [PATCH] 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. --- semantica/export/owl_exporter.py | 386 ++++++++++++++---- semantica/ontology/ontology_generator.py | 21 +- .../test_owl_exporter_generator_schema.py | 181 ++++++++ 3 files changed, 491 insertions(+), 97 deletions(-) create mode 100644 tests/export/test_owl_exporter_generator_schema.py 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