From d76bff9ab0e90634fc074812ca0ce5287fa3742f Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 26 Aug 2026 21:40:57 +0800 Subject: [PATCH] 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. --------- --- semantica/export/rdf_exporter.py | 43 +++++++++-------- tests/export/test_owl_time_reachability.py | 56 ++++++++++++++++++++++ 2 files changed, 79 insertions(+), 20 deletions(-) diff --git a/semantica/export/rdf_exporter.py b/semantica/export/rdf_exporter.py index 68e28bd3..7a290b0c 100644 --- a/semantica/export/rdf_exporter.py +++ b/semantica/export/rdf_exporter.py @@ -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}" .' ) diff --git a/tests/export/test_owl_time_reachability.py b/tests/export/test_owl_time_reachability.py index 92473390..e83339f7 100644 --- a/tests/export/test_owl_time_reachability.py +++ b/tests/export/test_owl_time_reachability.py @@ -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")))