mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
- 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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
a88300d74f
commit
892ff4b4a7
@@ -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`).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user