From f163ca24a54df6716fff0196d8c745d926f33ddf Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Fri, 17 Apr 2026 15:02:46 +0530 Subject: [PATCH] fix(export): fix OWLExporter Turtle invalid syntax and silent data-property omission (#478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _ttl_block() helper to accumulate all predicate-object pairs before writing, producing a single valid Turtle subject block terminated by one period — eliminates the bug where rdfs:subClassOf / domain / range were appended after a closed '.' block - Add missing data_properties loop to _export_owl_turtle so owl:DatatypeProperty declarations are no longer silently dropped - Add _escape_ttl_str() to escape quotes, backslashes, newlines, carriage returns, and tabs inside Turtle string literals (rdfs:label, rdfs:comment, owl:versionInfo) - Unify optional-field null checks to consistent x = prop.get(); if x: pattern - Add 43 tests in tests/export/test_owl_exporter.py covering syntax validity, data properties, string escaping, null handling, and header output - Update CHANGELOG.md with [Unreleased] entry Closes #478 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 7 + semantica/export/owl_exporter.py | 103 +++--- tests/export/test_owl_exporter.py | 549 ++++++++++++++++++++++++++++++ 3 files changed, 619 insertions(+), 40 deletions(-) create mode 100644 tests/export/test_owl_exporter.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fa1f43e8..70a10a06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **Fix: `OWLExporter._export_owl_turtle` invalid Turtle syntax and silent data-property omission** (closes #478 by @KaifAhmad1): + - **Bug 1 — Invalid Turtle syntax**: `_export_owl_turtle` unconditionally wrote `rdfs:label` with a closing period (`.`), then appended `rdfs:subClassOf`, `rdfs:domain`, and `rdfs:range` triples after the closed block. Any RDF parser would reject the output. Fixed by introducing `_ttl_block(subject_uri, rdf_type, predicates)` — all predicate-object pairs for a subject are accumulated first, then joined with ` ;\n ` and terminated with a single ` .`, producing valid Turtle in all cases. + - **Bug 2 — Data properties silently dropped**: `_export_owl_turtle` had loops for `classes` and `object_properties` but no loop for `data_properties`, so all `owl:DatatypeProperty` declarations were silently omitted. Added the missing loop, mirroring the existing object-property loop. + - **String escaping**: User-provided strings (`name`, `description`, `comment`, version) were embedded directly into Turtle string literals without escaping. A class named `John"s Class` or a comment containing a backslash or newline produced unparseable output. Added `_escape_ttl_str()` static method (escapes `"`, `\`, `\n`, `\r`, `\t`) applied at every `rdfs:label`, `rdfs:comment`, and `owl:versionInfo` site. + - **Null-check consistency**: All optional field reads now use `x = prop.get("field"); if x:` uniformly — eliminates the mixed pattern of `.get()` guards followed by direct `[]` access. + - 43 tests added in `tests/export/test_owl_exporter.py` across five suites: `TestTurtleSyntaxValidity` (5), `TestDataPropertiesInTurtle` (8), `TestTurtleHeader` (4), `TestTurtleStringEscaping` (16), `TestNullFieldHandling` (7), plus `TestObjectPropertyListDomainRange` (2) and `TestEquivalentClass` (1). + - **Enhancement: Node distance semantics in path responses** (closes #472 by @KaifAhmad1): `PathResponse` now surfaces two new first-class fields — `hop_count: int` (equal to `len(path) - 1`; `0` for self-paths) and `distance_band: str` — so callers no longer need to count hops or implement band classification themselves. Four bands are defined: `"direct"` (0–1 hops), `"near"` (2–3), `"mid-range"` (4–6), `"distant"` (7+). The classification function `classify_path_distance()` lives in `semantica/utils/helpers.py` as the single source of truth; both the Explorer route and the visualizer import from it. `KGVisualizer.visualize_network()` gains an optional `highlight_path: list[str]` parameter: when provided, path edges are rendered as a separate orange trace with opacity and stroke width scaled to the distance band (direct: 1.0 / 4 px → distant: 0.35 / 1.5 px), while non-path edges render at reduced opacity underneath. Edge direction is respected — only the forward pairs `(A, B)` along the path are matched; reverse back-edges in directed graphs are not incorrectly highlighted. A logger warning is emitted when any node ID in `highlight_path` has no layout position, surfacing silent no-op mismatches. Frontend `PathResponse` type in `GraphInspectorPanel.tsx` and `GraphWorkspaceShell.tsx` extended with `hop_count: number` and `distance_band: "direct" | "near" | "mid-range" | "distant"`. All changes are additive; no existing fields removed. 10 new tests: 2 API-level (`test_response_includes_hop_count_and_distance_band`, `test_one_hop_path_is_direct`) and 8 unit tests covering all four band boundaries (0, 1, 2, 3, 4, 6, 7, 20 hops). - **Enhancement: Bidirectional path finding in Knowledge Explorer** (closes #469 by @KaifAhmad1): Path queries in the Explorer were direction-sensitive — querying B→A when only the edge A→B existed always returned no result, because `PathFinder._get_neighbors()` called `graph.neighbors(node)` which on a `nx.DiGraph` yields only successors. Added a `directed: bool = True` parameter to `bfs_shortest_path()` and `dijkstra_shortest_path()`. When `directed=False` a lightweight undirected view is built via `graph.to_undirected()` for the traversal pass only; the original directed edges are preserved and returned in the response. A `_make_undirected_view()` helper encapsulates the conversion and falls back safely for non-NetworkX graph types. The `/api/graph/node/{id}/path` route exposes the parameter as a query string flag (`?directed=false`); `PathResponse` gains a `directed: bool` field that echoes the mode used. Default is `True`, so all existing callers are unaffected. The route also gained an empty-path 404 guard — previously a traversal that found no path returned `200` with `path: []` instead of `404`. 21 new tests: 12 unit tests in `TestBidirectionalPathFinding` (`tests/kg/test_path_finder.py`) and 9 API-level tests in `TestBidirectionalPathRoute` (`tests/explorer/test_explorer_api.py`). diff --git a/semantica/export/owl_exporter.py b/semantica/export/owl_exporter.py index 7ef02f11..5f123f0c 100644 --- a/semantica/export/owl_exporter.py +++ b/semantica/export/owl_exporter.py @@ -328,6 +328,18 @@ class OWLExporter: lines.append("") return "\n".join(lines) + @staticmethod + def _escape_ttl_str(value: str) -> str: + """Escape a string value for safe embedding in a Turtle string literal.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + + def _ttl_block(self, subject_uri: str, rdf_type: str, predicates: List[str]) -> str: + """Build a valid Turtle subject block from accumulated predicate strings.""" + stmt = f"<{subject_uri}> a {rdf_type}" + for pred in predicates: + stmt += f" ;\n {pred}" + return stmt + " ." + def _export_owl_turtle(self, ontology: Dict[str, Any], **options) -> str: """ Export ontology to OWL Turtle format. @@ -342,6 +354,7 @@ class OWLExporter: Returns: String containing OWL Turtle serialization """ + esc = self._escape_ttl_str ontology_uri = ontology.get("uri") or self.ontology_uri ontology_name = ontology.get("name", "SemanticaOntology") version = ontology.get("version") or self.version @@ -357,63 +370,73 @@ class OWLExporter: lines.append("") # Ontology declaration - lines.append(f"<{ontology_uri}> a owl:Ontology ;") - lines.append(f' rdfs:label "{ontology_name}" ;') - lines.append(f' owl:versionInfo "{version}" .') - if ontology.get("description"): - lines.append(f' rdfs:comment "{ontology.get("description")}" ;') + onto_predicates = [ + f'rdfs:label "{esc(ontology_name)}"', + f'owl:versionInfo "{esc(version)}"', + ] + description = ontology.get("description") + if description: + onto_predicates.append(f'rdfs:comment "{esc(description)}"') + lines.append(self._ttl_block(ontology_uri, "owl:Ontology", onto_predicates)) lines.append("") # Classes - classes = ontology.get("classes", []) - for cls in classes: + for cls in ontology.get("classes", []): class_uri = cls.get("uri") or cls.get("id", "") class_name = cls.get("name") or cls.get("label", "") - - lines.append(f"<{class_uri}> a owl:Class ;") - lines.append(f' rdfs:label "{class_name}" .') - - if cls.get("comment"): - lines.append(f' rdfs:comment "{cls.get("comment")}" ;') - - if cls.get("subClassOf"): - parent = cls.get("subClassOf") - lines.append(f" rdfs:subClassOf <{parent}> ;") - - # Remove trailing semicolon and add period - if lines[-1].endswith(" ;"): - lines[-1] = lines[-1].rstrip(" ;") + " ." - else: - lines.append(" .") + predicates = [f'rdfs:label "{esc(class_name)}"'] + comment = cls.get("comment") + 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}>") + lines.append(self._ttl_block(class_uri, "owl:Class", predicates)) lines.append("") # Object properties - object_properties = ontology.get("object_properties", []) - for prop in object_properties: + for prop in ontology.get("object_properties", []): prop_uri = prop.get("uri") or prop.get("id", "") prop_name = prop.get("name") or prop.get("label", "") - - lines.append(f"<{prop_uri}> a owl:ObjectProperty ;") - lines.append(f' rdfs:label "{prop_name}" .') - - if prop.get("domain"): - domain = prop.get("domain") + predicates = [f'rdfs:label "{esc(prop_name)}"'] + comment = prop.get("comment") + if comment: + predicates.append(f'rdfs:comment "{esc(comment)}"') + domain = prop.get("domain") + if domain: if isinstance(domain, list): for d in domain: - lines.append(f" rdfs:domain <{d}> ;") + predicates.append(f"rdfs:domain <{d}>") else: - lines.append(f" rdfs:domain <{domain}> ;") - - if prop.get("range"): - range_val = prop.get("range") + predicates.append(f"rdfs:domain <{domain}>") + range_val = prop.get("range") + if range_val: if isinstance(range_val, list): for r in range_val: - lines.append(f" rdfs:range <{r}> ;") + predicates.append(f"rdfs:range <{r}>") else: - lines.append(f" rdfs:range <{range_val}> ;") + predicates.append(f"rdfs:range <{range_val}>") + lines.append(self._ttl_block(prop_uri, "owl:ObjectProperty", predicates)) + lines.append("") - if lines[-1].endswith(" ;"): - lines[-1] = lines[-1].rstrip(" ;") + " ." + # Data properties + for prop in ontology.get("data_properties", []): + prop_uri = prop.get("uri") or prop.get("id", "") + prop_name = prop.get("name") or prop.get("label", "") + predicates = [f'rdfs:label "{esc(prop_name)}"'] + comment = prop.get("comment") + 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}") + lines.append(self._ttl_block(prop_uri, "owl:DatatypeProperty", predicates)) lines.append("") return "\n".join(lines) diff --git a/tests/export/test_owl_exporter.py b/tests/export/test_owl_exporter.py new file mode 100644 index 00000000..14a5dc71 --- /dev/null +++ b/tests/export/test_owl_exporter.py @@ -0,0 +1,549 @@ +"""Tests for OWLExporter._export_owl_turtle fixes (issue #478). + +Bug 1: invalid Turtle when subClassOf/domain/range present (predicates appended + after a closing period). +Bug 2: data_properties silently dropped from Turtle output. +""" + +import pytest +from semantica.export import OWLExporter + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def exporter(): + return OWLExporter() + + +@pytest.fixture +def full_ontology(): + return { + "uri": "http://example.org/onto", + "name": "TestOntology", + "description": "A test ontology", + "classes": [ + { + "uri": "http://example.org/Person", + "name": "Person", + }, + { + "uri": "http://example.org/Employee", + "name": "Employee", + "comment": "A person who is employed", + "subClassOf": "http://example.org/Person", + }, + { + "uri": "http://example.org/Manager", + "name": "Manager", + "subClassOf": "http://example.org/Employee", + "equivalentClass": "http://example.org/Supervisor", + }, + ], + "object_properties": [ + { + "uri": "http://example.org/worksFor", + "name": "worksFor", + "domain": "http://example.org/Employee", + "range": "http://example.org/Company", + }, + { + "uri": "http://example.org/manages", + "name": "manages", + "comment": "manages a team", + "domain": ["http://example.org/Manager"], + "range": ["http://example.org/Employee"], + }, + ], + "data_properties": [ + { + "uri": "http://example.org/hasAge", + "name": "hasAge", + "domain": "http://example.org/Person", + "range": "integer", + }, + { + "uri": "http://example.org/hasName", + "name": "hasName", + "comment": "full name", + "domain": "http://example.org/Person", + "range": "string", + }, + ], + } + + +# --------------------------------------------------------------------------- +# Bug 1 — valid Turtle syntax +# --------------------------------------------------------------------------- + +class TestTurtleSyntaxValidity: + """Every subject block must have exactly one closing period at the end.""" + + def _blocks(self, turtle: str) -> list[str]: + """Split output into non-empty logical blocks (separated by blank lines).""" + return [b.strip() for b in turtle.split("\n\n") if b.strip()] + + def test_no_triple_after_period(self, exporter, full_ontology): + """No predicate line may appear after a line that ends with ' .'.""" + turtle = exporter._export_owl_turtle(full_ontology) + lines = turtle.splitlines() + for i, line in enumerate(lines): + stripped = line.rstrip() + if stripped.endswith(" .") and i + 1 < len(lines): + next_line = lines[i + 1].strip() + # next non-blank line must not be a predicate continuation + if next_line: + assert not next_line.startswith("rdfs:"), ( + f"Predicate continuation after closing '.' at line {i + 1}: " + f"{lines[i]!r} → {lines[i + 1]!r}" + ) + + def test_each_subject_block_ends_with_period(self, exporter, full_ontology): + """Every subject block (class / property declaration) ends with exactly one '.'.""" + turtle = exporter._export_owl_turtle(full_ontology) + blocks = self._blocks(turtle) + # skip the @prefix lines block and ontology declaration + subject_blocks = [b for b in blocks if b.startswith("" in turtle + # The subClassOf line must NOT come after a closing period + lines = turtle.splitlines() + for i, ln in enumerate(lines): + if "rdfs:subClassOf" in ln: + # Search backwards for the closest period-terminated line + for prev in reversed(lines[:i]): + prev_s = prev.rstrip() + if prev_s: + assert not prev_s.endswith(" ."), ( + "rdfs:subClassOf appeared after a closed block" + ) + break + + def test_object_property_with_domain_range_is_valid(self, exporter): + ontology = { + "uri": "http://example.org/onto", + "name": "T", + "classes": [], + "object_properties": [ + { + "uri": "http://example.org/worksFor", + "name": "worksFor", + "domain": "http://example.org/Employee", + "range": "http://example.org/Company", + } + ], + "data_properties": [], + } + turtle = exporter._export_owl_turtle(ontology) + assert "rdfs:domain " in turtle + assert "rdfs:range " in turtle + lines = turtle.splitlines() + for i, ln in enumerate(lines): + if "rdfs:domain" in ln or "rdfs:range" in ln: + for prev in reversed(lines[:i]): + prev_s = prev.rstrip() + if prev_s: + assert not prev_s.endswith(" ."), ( + "domain/range appeared after a closed block" + ) + break + + def test_class_with_comment_subclassof_both_present(self, exporter): + ontology = { + "uri": "http://example.org/onto", + "name": "T", + "classes": [ + { + "uri": "http://example.org/X", + "name": "X", + "comment": "some comment", + "subClassOf": "http://example.org/Y", + } + ], + "object_properties": [], + "data_properties": [], + } + turtle = exporter._export_owl_turtle(ontology) + assert 'rdfs:comment "some comment"' in turtle + assert "rdfs:subClassOf " in turtle + # block must end with single period + block = [b for b in turtle.split("\n\n") if "owl:Class" in b][0].strip() + assert block.endswith(".") + assert block.count("\n.") == 0 # no bare period-only lines + + +# --------------------------------------------------------------------------- +# Bug 2 — data properties present in Turtle output +# --------------------------------------------------------------------------- + +class TestDataPropertiesInTurtle: + + def test_data_property_declared_as_datatypeproperty(self, exporter, full_ontology): + turtle = exporter._export_owl_turtle(full_ontology) + assert "owl:DatatypeProperty" in turtle + + def test_data_property_uri_present(self, exporter, full_ontology): + turtle = exporter._export_owl_turtle(full_ontology) + assert "" in turtle + assert "" in turtle + + def test_data_property_label(self, exporter, full_ontology): + turtle = exporter._export_owl_turtle(full_ontology) + assert 'rdfs:label "hasAge"' in turtle + assert 'rdfs:label "hasName"' in turtle + + def test_data_property_domain(self, exporter, full_ontology): + turtle = exporter._export_owl_turtle(full_ontology) + assert "rdfs:domain " in turtle + + def test_data_property_range_uses_xsd_prefix(self, exporter, full_ontology): + turtle = exporter._export_owl_turtle(full_ontology) + assert "rdfs:range xsd:integer" in turtle + assert "rdfs:range xsd:string" in turtle + + def test_data_property_comment(self, exporter, full_ontology): + turtle = exporter._export_owl_turtle(full_ontology) + assert 'rdfs:comment "full name"' in turtle + + def test_data_properties_not_in_turtle_was_bug(self, exporter): + """Regression: data_properties were silently dropped before the fix.""" + ontology = { + "uri": "http://example.org/onto", + "name": "T", + "classes": [], + "object_properties": [], + "data_properties": [ + { + "uri": "http://example.org/birthDate", + "name": "birthDate", + "range": "date", + } + ], + } + turtle = exporter._export_owl_turtle(ontology) + assert "owl:DatatypeProperty" in turtle, ( + "Data properties must appear in Turtle output (was silently dropped)" + ) + assert "" in turtle + assert "rdfs:range xsd:date" in turtle + + def test_data_property_block_ends_with_period(self, exporter, full_ontology): + turtle = exporter._export_owl_turtle(full_ontology) + blocks = [b.strip() for b in turtle.split("\n\n") if "owl:DatatypeProperty" in b] + assert blocks, "Expected at least one DatatypeProperty block" + for block in blocks: + assert block.endswith("."), f"DatatypeProperty block missing closing '.': {block!r}" + + +# --------------------------------------------------------------------------- +# Namespace and ontology header +# --------------------------------------------------------------------------- + +class TestTurtleHeader: + + def test_prefix_declarations(self, exporter, full_ontology): + turtle = exporter._export_owl_turtle(full_ontology) + assert "@prefix rdf:" in turtle + assert "@prefix rdfs:" in turtle + assert "@prefix owl:" in turtle + assert "@prefix xsd:" in turtle + + def test_ontology_declaration(self, exporter, full_ontology): + turtle = exporter._export_owl_turtle(full_ontology) + assert "a owl:Ontology" in turtle + assert 'rdfs:label "TestOntology"' in turtle + assert 'owl:versionInfo "1.0"' in turtle + + def test_ontology_description_included(self, exporter, full_ontology): + turtle = exporter._export_owl_turtle(full_ontology) + assert 'rdfs:comment "A test ontology"' in turtle + + def test_ontology_without_description(self, exporter): + ontology = {"uri": "http://example.org/onto", "name": "NoDesc", + "classes": [], "object_properties": [], "data_properties": []} + turtle = exporter._export_owl_turtle(ontology) + assert "rdfs:comment" not in turtle + + +# --------------------------------------------------------------------------- +# Object properties — list domain/range +# --------------------------------------------------------------------------- + +class TestObjectPropertyListDomainRange: + + def test_list_domain(self, exporter): + ontology = { + "uri": "http://example.org/onto", "name": "T", + "classes": [], + "object_properties": [ + { + "uri": "http://example.org/p", + "name": "p", + "domain": ["http://example.org/A", "http://example.org/B"], + } + ], + "data_properties": [], + } + turtle = exporter._export_owl_turtle(ontology) + assert "rdfs:domain " in turtle + assert "rdfs:domain " in turtle + + def test_list_range(self, exporter): + ontology = { + "uri": "http://example.org/onto", "name": "T", + "classes": [], + "object_properties": [ + { + "uri": "http://example.org/p", + "name": "p", + "range": ["http://example.org/X", "http://example.org/Y"], + } + ], + "data_properties": [], + } + turtle = exporter._export_owl_turtle(ontology) + assert "rdfs:range " in turtle + assert "rdfs:range " in turtle + + +# --------------------------------------------------------------------------- +# equivalentClass support (also tested under Bug 1 guard) +# --------------------------------------------------------------------------- + +class TestEquivalentClass: + + def test_equivalent_class_in_turtle(self, exporter): + ontology = { + "uri": "http://example.org/onto", "name": "T", + "classes": [ + { + "uri": "http://example.org/Manager", + "name": "Manager", + "equivalentClass": "http://example.org/Supervisor", + } + ], + "object_properties": [], + "data_properties": [], + } + turtle = exporter._export_owl_turtle(ontology) + assert "owl:equivalentClass " in turtle + block = [b for b in turtle.split("\n\n") if "owl:Class" in b][0].strip() + assert block.endswith(".") + + +# --------------------------------------------------------------------------- +# String escaping in Turtle literals (issue #478 review — escape_001) +# --------------------------------------------------------------------------- + +class TestTurtleStringEscaping: + """User-provided strings must be escaped before embedding in Turtle literals.""" + + def _onto(self, **kwargs): + base = {"uri": "http://example.org/onto", "name": "T", + "classes": [], "object_properties": [], "data_properties": []} + base.update(kwargs) + return base + + def test_escape_ttl_str_double_quote(self, exporter): + assert exporter._escape_ttl_str('say "hello"') == r'say \"hello\"' + + def test_escape_ttl_str_backslash(self, exporter): + assert exporter._escape_ttl_str("C:\\path") == "C:\\\\path" + + def test_escape_ttl_str_newline(self, exporter): + assert exporter._escape_ttl_str("line1\nline2") == "line1\\nline2" + + def test_escape_ttl_str_carriage_return(self, exporter): + assert exporter._escape_ttl_str("a\rb") == "a\\rb" + + def test_escape_ttl_str_tab(self, exporter): + assert exporter._escape_ttl_str("col1\tcol2") == "col1\\tcol2" + + def test_escape_ttl_str_combined(self, exporter): + raw = 'back\\slash and "quote"\nnewline' + escaped = exporter._escape_ttl_str(raw) + assert '\\"' in escaped + assert "\\\\" in escaped + assert "\\n" in escaped + + def test_ontology_name_with_quote_is_escaped(self, exporter): + ontology = self._onto(name='John"s Ontology') + turtle = exporter._export_owl_turtle(ontology) + assert 'rdfs:label "John\\"s Ontology"' in turtle + assert 'rdfs:label "John"s Ontology"' not in turtle + + def test_ontology_description_with_quote_is_escaped(self, exporter): + ontology = self._onto(description='Describes "things"') + turtle = exporter._export_owl_turtle(ontology) + assert 'rdfs:comment "Describes \\"things\\""' in turtle + + def test_class_name_with_quote_is_escaped(self, exporter): + ontology = self._onto(classes=[{ + "uri": "http://example.org/C", + "name": 'My "Special" Class', + }]) + turtle = exporter._export_owl_turtle(ontology) + assert r'rdfs:label "My \"Special\" Class"' in turtle + + def test_class_comment_with_backslash_is_escaped(self, exporter): + ontology = self._onto(classes=[{ + "uri": "http://example.org/C", + "name": "C", + "comment": "path is C:\\Users", + }]) + turtle = exporter._export_owl_turtle(ontology) + assert r'rdfs:comment "path is C:\\Users"' in turtle + + def test_class_comment_with_newline_is_escaped(self, exporter): + ontology = self._onto(classes=[{ + "uri": "http://example.org/C", + "name": "C", + "comment": "line1\nline2", + }]) + turtle = exporter._export_owl_turtle(ontology) + assert r'rdfs:comment "line1\nline2"' in turtle + + def test_object_property_name_with_quote_is_escaped(self, exporter): + ontology = self._onto(object_properties=[{ + "uri": "http://example.org/p", + "name": 'has"Value', + }]) + turtle = exporter._export_owl_turtle(ontology) + assert r'rdfs:label "has\"Value"' in turtle + + def test_object_property_comment_with_quote_is_escaped(self, exporter): + ontology = self._onto(object_properties=[{ + "uri": "http://example.org/p", + "name": "p", + "comment": 'links "A" to "B"', + }]) + turtle = exporter._export_owl_turtle(ontology) + assert r'rdfs:comment "links \"A\" to \"B\""' in turtle + + def test_data_property_name_with_quote_is_escaped(self, exporter): + ontology = self._onto(data_properties=[{ + "uri": "http://example.org/dp", + "name": 'the "name" prop', + "range": "string", + }]) + turtle = exporter._export_owl_turtle(ontology) + assert r'rdfs:label "the \"name\" prop"' in turtle + + def test_data_property_comment_with_quote_is_escaped(self, exporter): + ontology = self._onto(data_properties=[{ + "uri": "http://example.org/dp", + "name": "dp", + "comment": 'see "spec" §3', + "range": "string", + }]) + turtle = exporter._export_owl_turtle(ontology) + assert r'rdfs:comment "see \"spec\" §3"' in turtle + + def test_plain_strings_unchanged(self, exporter): + """Strings without special chars must pass through unchanged.""" + ontology = self._onto( + name="MyOntology", + classes=[{"uri": "http://example.org/C", "name": "SafeName"}], + ) + turtle = exporter._export_owl_turtle(ontology) + assert 'rdfs:label "MyOntology"' in turtle + assert 'rdfs:label "SafeName"' in turtle + + +# --------------------------------------------------------------------------- +# Null / missing optional fields — no KeyError raised (review null_check_001-3) +# --------------------------------------------------------------------------- + +class TestNullFieldHandling: + """Optional fields absent from dicts must not raise KeyError.""" + + def _onto(self, **kwargs): + base = {"uri": "http://example.org/onto", "name": "T", + "classes": [], "object_properties": [], "data_properties": []} + base.update(kwargs) + return base + + def test_class_no_optional_fields(self, exporter): + ontology = self._onto(classes=[{"uri": "http://example.org/C", "name": "C"}]) + turtle = exporter._export_owl_turtle(ontology) + assert "owl:Class" in turtle + + def test_object_property_no_domain_no_range(self, exporter): + ontology = self._onto(object_properties=[{ + "uri": "http://example.org/p", "name": "p" + }]) + turtle = exporter._export_owl_turtle(ontology) + assert "owl:ObjectProperty" in turtle + assert "rdfs:domain" not in turtle + assert "rdfs:range" not in turtle + + def test_data_property_no_domain_no_range(self, exporter): + ontology = self._onto(data_properties=[{ + "uri": "http://example.org/dp", "name": "dp" + }]) + turtle = exporter._export_owl_turtle(ontology) + assert "owl:DatatypeProperty" in turtle + assert "rdfs:domain" not in turtle + assert "rdfs:range" not in turtle + + def test_data_property_none_domain(self, exporter): + """Explicit None value for domain must not raise KeyError.""" + ontology = self._onto(data_properties=[{ + "uri": "http://example.org/dp", "name": "dp", + "domain": None, "range": "string", + }]) + turtle = exporter._export_owl_turtle(ontology) + assert "rdfs:domain" not in turtle + + def test_data_property_none_range(self, exporter): + """Explicit None value for range must not raise KeyError.""" + ontology = self._onto(data_properties=[{ + "uri": "http://example.org/dp", "name": "dp", + "domain": "http://example.org/C", "range": None, + }]) + turtle = exporter._export_owl_turtle(ontology) + assert "rdfs:range" not in turtle + + def test_object_property_none_domain(self, exporter): + ontology = self._onto(object_properties=[{ + "uri": "http://example.org/p", "name": "p", + "domain": None, "range": "http://example.org/X", + }]) + turtle = exporter._export_owl_turtle(ontology) + assert "rdfs:domain" not in turtle + + def test_object_property_none_range(self, exporter): + ontology = self._onto(object_properties=[{ + "uri": "http://example.org/p", "name": "p", + "domain": "http://example.org/A", "range": None, + }]) + turtle = exporter._export_owl_turtle(ontology) + assert "rdfs:range" not in turtle