mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
refactor(export): consolidate duplicate Turtle/N-Triples literal escapers (#1221)
* refactor(export): consolidate duplicate Turtle/N-Triples literal escapers _escape_literal (module-level) and RDFSerializer._escape_turtle_literal did identical work in the same order (backslash, double-quote, newline, CR, tab). Drop the newer static helper added in #1148 and route all call sites through _escape_literal instead. Behaviour no-op. Closes #1218. * fix(export): handle datetime/None temporal bounds safely in OWL-Time _escape_literal is str-only, so routing datetime or None temporal bounds through it raised AttributeError during Turtle export. Stringify non-str bounds (plain f-string semantics) before escaping, and render None as an empty bound. Add regression tests for datetime bounds and end-only intervals. Addresses Qodo high-priority finding #2 on #1221. * fix(export): use isoformat for datetime temporal bounds str() on a datetime drops the ISO-8601 T separator, producing a lexically invalid xsd:dateTimeStamp. Use isoformat() when available; strengthen the test to assert the exact T-separated form. ---------
This commit is contained in:
@@ -193,6 +193,25 @@ def _escape_literal(value: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _escape_temporal_literal(value: Any) -> str:
|
||||
"""Escape a temporal bound for a Turtle ``dateTimeStamp`` literal.
|
||||
|
||||
Bounds are normally strings, but callers may hand us a ``datetime`` or
|
||||
``None``. ``_escape_literal`` is str-only, so stringify non-str values
|
||||
first instead of calling ``.replace()`` on them; ``None`` yields an empty
|
||||
bound rather than crashing. Datetimes must use ISO 8601 so the
|
||||
``xsd:dateTimeStamp`` ``T`` separator is preserved — ``str()`` yields a
|
||||
space ("00:00:00+00:00"), which is a lexically invalid timestamp.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return _escape_literal(value)
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
#: Turtle/N-Triples IRIREF grammar excludes these unescaped between `<` and
|
||||
#: `>`: control characters, space, and <>"{}|^`\. An IRI-valued metadata
|
||||
#: value (currently only sem:sourceUri, from the caller-controlled "uri"
|
||||
@@ -738,22 +757,6 @@ class RDFSerializer:
|
||||
# node to signal that valid_until is OPEN/unbounded. This keeps the
|
||||
# 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:
|
||||
"""
|
||||
Serialize RDF to Turtle format.
|
||||
@@ -823,7 +826,7 @@ class RDFSerializer:
|
||||
|
||||
clauses = [
|
||||
f"a <{self._as_turtle_iri(entity_type, merged_namespaces)}>",
|
||||
f'semantica:text "{self._escape_turtle_literal(text)}"',
|
||||
f'semantica:text "{_escape_literal(text)}"',
|
||||
]
|
||||
if confidence is None:
|
||||
self.logger.warning(
|
||||
@@ -1015,7 +1018,7 @@ class RDFSerializer:
|
||||
lines.append(f" time:hasEnd <{end_id}> .")
|
||||
lines.append(f"<{end_id}> a time:Instant ;")
|
||||
lines.append(
|
||||
f' time:inXSDDateTimeStamp "{self._escape_turtle_literal(until_val)}"^^xsd:dateTimeStamp .'
|
||||
f' time:inXSDDateTimeStamp "{_escape_temporal_literal(until_val)}"^^xsd:dateTimeStamp .'
|
||||
)
|
||||
else:
|
||||
lines[-1] = (
|
||||
@@ -1024,7 +1027,7 @@ class RDFSerializer:
|
||||
|
||||
lines.append(f"<{begin_id}> a time:Instant ;")
|
||||
lines.append(
|
||||
f' time:inXSDDateTimeStamp "{self._escape_turtle_literal(from_val)}"^^xsd:dateTimeStamp .'
|
||||
f' time:inXSDDateTimeStamp "{_escape_temporal_literal(from_val)}"^^xsd:dateTimeStamp .'
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
@@ -1321,7 +1324,7 @@ class RDFSerializer:
|
||||
# Text property
|
||||
text = entity.get("text") or entity.get("label", "")
|
||||
if text:
|
||||
safe_text = self._escape_turtle_literal(text)
|
||||
safe_text = _escape_literal(text)
|
||||
lines.append(
|
||||
f'{subject} {expand_uri("semantica:text")} "{safe_text}" .'
|
||||
)
|
||||
|
||||
@@ -187,3 +187,59 @@ def test_the_reified_type_matches_the_direct_triples_predicate():
|
||||
|
||||
assert set(graph.objects(node, URIRef(NS + "type"))) == {Literal(EMPLOYS)}
|
||||
assert (URIRef(E1), URIRef(EMPLOYS), URIRef(E2)) in graph
|
||||
|
||||
|
||||
# ── Qodo review: temporal bounds are str-only at the escape helper ─────────
|
||||
|
||||
def test_datetime_bounds_do_not_crash_the_turtle_export():
|
||||
"""_escape_literal is str-only; datetime bounds must be stringified, not
|
||||
run through .replace(). Regression for Qodo high-priority finding #2 on
|
||||
PR #1221.
|
||||
|
||||
Also asserts the lexical form: xsd:dateTimeStamp requires an ISO 8601 "T"
|
||||
separator (e.g. 2024-01-01T00:00:00+00:00). plain str() emits a space
|
||||
("2024-01-01 00:00:00+00:00"), which is format-invalid; isoformat() fixes
|
||||
it. Regression for the maintainer review on PR #1221."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
kg = {
|
||||
"entities": [dict(e) for e in KG["entities"]],
|
||||
"relationships": [
|
||||
{
|
||||
"source_id": E1,
|
||||
"target_id": E2,
|
||||
"type": EMPLOYS,
|
||||
"valid_from": datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
"valid_until": datetime(2025, 1, 1, tzinfo=timezone.utc),
|
||||
}
|
||||
],
|
||||
}
|
||||
turtle = RDFSerializer().serialize_to_turtle(kg, include_temporal=True)
|
||||
graph = Graph()
|
||||
graph.parse(data=turtle, format="turtle")
|
||||
stamps = {
|
||||
str(o) for o in graph.objects(None, URIRef(TIME + "inXSDDateTimeStamp"))
|
||||
}
|
||||
assert len(stamps) == 2, stamps
|
||||
assert "2024-01-01T00:00:00+00:00" in stamps, stamps
|
||||
assert "2025-01-01T00:00:00+00:00" in stamps, stamps
|
||||
|
||||
|
||||
def test_end_only_interval_does_not_crash_the_turtle_export():
|
||||
"""A valid_until bound with no valid_from passes None as from_val; it must
|
||||
not be handed to the str-only escaper. Regression for Qodo finding #2."""
|
||||
kg = {
|
||||
"entities": [dict(e) for e in KG["entities"]],
|
||||
"relationships": [
|
||||
{
|
||||
"source_id": E1,
|
||||
"target_id": E2,
|
||||
"type": EMPLOYS,
|
||||
"valid_until": "2025-01-01T00:00:00Z",
|
||||
}
|
||||
],
|
||||
}
|
||||
turtle = RDFSerializer().serialize_to_turtle(kg, include_temporal=True)
|
||||
graph = Graph()
|
||||
graph.parse(data=turtle, format="turtle")
|
||||
assert list(graph.subjects(RDF.type, URIRef(TIME + "Instant")))
|
||||
|
||||
Reference in New Issue
Block a user