Compare commits

...
1 Commits
Author SHA1 Message Date
KaifAhmad1andClaude Sonnet 4.6 f163ca24a5 fix(export): fix OWLExporter Turtle invalid syntax and silent data-property omission (#478)
- 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 <noreply@anthropic.com>
2026-04-17 15:02:46 +05:30
3 changed files with 619 additions and 40 deletions
+7
View File
@@ -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"` (01 hops), `"near"` (23), `"mid-range"` (46), `"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`).
+63 -40
View File
@@ -328,6 +328,18 @@ class OWLExporter:
lines.append("</rdf:RDF>")
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)
+549
View File
@@ -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("<http://")]
for block in subject_blocks:
assert block.endswith("."), f"Block does not end with '.': {block!r}"
# Must not have a bare '.' on an interior line
interior_lines = block.splitlines()[:-1]
for ln in interior_lines:
assert not ln.rstrip().endswith(" ."), (
f"Premature closing period inside block: {ln!r}"
)
def test_class_with_subclassof_is_valid(self, exporter):
ontology = {
"uri": "http://example.org/onto",
"name": "T",
"classes": [
{
"uri": "http://example.org/Employee",
"name": "Employee",
"subClassOf": "http://example.org/Person",
}
],
"object_properties": [],
"data_properties": [],
}
turtle = exporter._export_owl_turtle(ontology)
# Must contain both predicates in the same block
assert 'rdfs:label "Employee"' in turtle
assert "rdfs:subClassOf <http://example.org/Person>" 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 <http://example.org/Employee>" in turtle
assert "rdfs:range <http://example.org/Company>" 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 <http://example.org/Y>" 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 "<http://example.org/hasAge>" in turtle
assert "<http://example.org/hasName>" 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 <http://example.org/Person>" 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 "<http://example.org/birthDate>" 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 <http://example.org/A>" in turtle
assert "rdfs:domain <http://example.org/B>" 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 <http://example.org/X>" in turtle
assert "rdfs:range <http://example.org/Y>" 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 <http://example.org/Supervisor>" 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