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")))