fix(export): escape Turtle/N-Triples string literals (closes #1098) (#1148)

* fix(export): escape Turtle/N-Triples string literals (fixes #1098)

Add RDFSerializer._escape_turtle_literal and apply it to the semantica:text
literal in serialize_to_turtle and the N-Triples text triple. Backslash,
double quote, newline, CR, and tab are escaped per the RDF 1.1 Turtle
STRING_LITERAL_QUOTE grammar, so entity text containing quotes or control
characters no longer emits invalid Turtle/N-Triples.

N-Triples previously escaped only quotes and newlines; now it also handles
backslashes and tabs via the shared escaper.

* fix(export): escape OWL-Time timestamp literals in Turtle output

Addresses Qodo finding on #1148: the OWL-Time branch of
serialize_to_turtle interpolated from_val/until_val directly into quoted
literals. Apply _escape_turtle_literal there too so timestamps containing
quotes, backslashes, or control characters cannot produce invalid Turtle.

* chore: remove stray local files (AGENTS.md, evals superpowers docs) from PR branch

---------
This commit is contained in:
Kevin Zhang
2026-08-25 20:57:57 +05:00
committed by GitHub
parent 50468f9c90
commit 551b94c524
2 changed files with 127 additions and 4 deletions
+20 -4
View File
@@ -738,6 +738,22 @@ class RDFSerializer:
# node to signal that valid_until is OPEN/unbounded. This keeps the # node to signal that valid_until is OPEN/unbounded. This keeps the
# interval well-formed while remaining human- and machine-readable. # interval well-formed while remaining human- and machine-readable.
@staticmethod
def _escape_turtle_literal(value: str) -> str:
"""Escape a string value for safe embedding in a Turtle string literal.
Backslash must be escaped first, then the double quote and the
recognized control characters (newline, carriage return, tab), per the
RDF 1.1 Turtle grammar for STRING_LITERAL_QUOTE.
"""
return (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
def serialize_to_turtle(self, rdf_data: Dict[str, Any], **options) -> str: def serialize_to_turtle(self, rdf_data: Dict[str, Any], **options) -> str:
""" """
Serialize RDF to Turtle format. Serialize RDF to Turtle format.
@@ -807,7 +823,7 @@ class RDFSerializer:
clauses = [ clauses = [
f"a <{self._as_turtle_iri(entity_type, merged_namespaces)}>", f"a <{self._as_turtle_iri(entity_type, merged_namespaces)}>",
f'semantica:text "{text}"', f'semantica:text "{self._escape_turtle_literal(text)}"',
] ]
if confidence is None: if confidence is None:
self.logger.warning( self.logger.warning(
@@ -999,7 +1015,7 @@ class RDFSerializer:
lines.append(f" time:hasEnd <{end_id}> .") lines.append(f" time:hasEnd <{end_id}> .")
lines.append(f"<{end_id}> a time:Instant ;") lines.append(f"<{end_id}> a time:Instant ;")
lines.append( lines.append(
f' time:inXSDDateTimeStamp "{until_val}"^^xsd:dateTimeStamp .' f' time:inXSDDateTimeStamp "{self._escape_turtle_literal(until_val)}"^^xsd:dateTimeStamp .'
) )
else: else:
lines[-1] = ( lines[-1] = (
@@ -1008,7 +1024,7 @@ class RDFSerializer:
lines.append(f"<{begin_id}> a time:Instant ;") lines.append(f"<{begin_id}> a time:Instant ;")
lines.append( lines.append(
f' time:inXSDDateTimeStamp "{from_val}"^^xsd:dateTimeStamp .' f' time:inXSDDateTimeStamp "{self._escape_turtle_literal(from_val)}"^^xsd:dateTimeStamp .'
) )
lines.append("") lines.append("")
@@ -1305,7 +1321,7 @@ class RDFSerializer:
# Text property # Text property
text = entity.get("text") or entity.get("label", "") text = entity.get("text") or entity.get("label", "")
if text: if text:
safe_text = text.replace('"', '\\"').replace("\n", "\\n") safe_text = self._escape_turtle_literal(text)
lines.append( lines.append(
f'{subject} {expand_uri("semantica:text")} "{safe_text}" .' f'{subject} {expand_uri("semantica:text")} "{safe_text}" .'
) )
+107
View File
@@ -0,0 +1,107 @@
"""Regression tests for #1098: Turtle/N-Triples literal escaping."""
import pytest
from semantica.export.rdf_exporter import RDFExporter, RDFSerializer
@pytest.fixture
def serializer():
return RDFSerializer()
class TestTurtleLiteralEscaping:
def test_quote_in_text_is_escaped(self, serializer):
kg = {
"entities": [{"id": "e1", "text": 'He said "hello"', "type": "ORG"}],
"relationships": [],
}
turtle = serializer.serialize_to_turtle(kg)
assert '"He said \\"hello\\""' in turtle
def test_backslash_in_text_is_escaped(self, serializer):
kg = {
"entities": [{"id": "e1", "text": r"path\to\file", "type": "ORG"}],
"relationships": [],
}
turtle = serializer.serialize_to_turtle(kg)
assert r"path\\to\\file" in turtle
def test_newline_in_text_is_escaped(self, serializer):
kg = {
"entities": [{"id": "e1", "text": "line1\nline2", "type": "ORG"}],
"relationships": [],
}
turtle = serializer.serialize_to_turtle(kg)
assert "line1\\nline2" in turtle
def test_tab_in_text_is_escaped(self, serializer):
kg = {
"entities": [{"id": "e1", "text": "a\tb", "type": "ORG"}],
"relationships": [],
}
turtle = serializer.serialize_to_turtle(kg)
assert "a\\tb" in turtle
def test_plain_text_unchanged(self, serializer):
kg = {
"entities": [{"id": "e1", "text": "Apple Inc.", "type": "ORG"}],
"relationships": [],
}
turtle = serializer.serialize_to_turtle(kg)
assert 'semantica:text "Apple Inc."' in turtle
class TestNTriplesLiteralEscaping:
def test_quote_in_text_is_escaped(self, serializer):
kg = {
"entities": [{"id": "e1", "text": 'He said "hello"', "type": "ORG"}],
"relationships": [],
}
ntriples = serializer.serialize_to_ntriples(kg)
assert '\\"hello\\"' in ntriples
def test_backslash_in_text_is_escaped(self, serializer):
kg = {
"entities": [{"id": "e1", "text": r"path\to\file", "type": "ORG"}],
"relationships": [],
}
ntriples = serializer.serialize_to_ntriples(kg)
assert r"path\\to\\file" in ntriples
def test_newline_in_text_is_escaped(self, serializer):
kg = {
"entities": [{"id": "e1", "text": "line1\nline2", "type": "ORG"}],
"relationships": [],
}
ntriples = serializer.serialize_to_ntriples(kg)
assert "line1\\nline2" in ntriples
def test_tab_in_text_is_escaped(self, serializer):
kg = {
"entities": [{"id": "e1", "text": "a\tb", "type": "ORG"}],
"relationships": [],
}
ntriples = serializer.serialize_to_ntriples(kg)
assert "a\\tb" in ntriples
class TestOWLTimeLiteralEscaping:
"""Timestamp literals in OWL-Time turtle output must also be escaped."""
def test_owl_time_timestamps_are_escaped(self):
exporter = RDFExporter()
kg = {
"entities": [],
"relationships": [
{
"id": "r1",
"source_id": "a",
"target_id": "b",
"type": "works_for",
"valid_from": "2020-01-01T00:00:00Z",
"valid_until": None,
}
],
}
turtle = exporter.export_to_rdf(kg, format="turtle", include_temporal=True)
assert 'time:inXSDDateTimeStamp "2020-01-01T00:00:00Z"' in turtle