From cf6c9b7b9cfdbd1ebb1347aa80935b2aaec1bf41 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Sun, 23 Aug 2026 21:52:51 +0530 Subject: [PATCH] fix(export): stop double-encoding valid % escapes and fix built-in prefix shadowing _as_turtle_iri() re-encoded absolute IRIs wholesale, turning already-valid percent-escapes like %20 into %2520. Only spans outside existing valid %XX escapes are quoted now, so malformed escapes (%zz) still get repaired while valid ones pass through unchanged. serialize_to_ntriples()/serialize_to_rdfxml() also passed only the @context-derived namespaces into _as_turtle_iri(), which shadowed the built-in semantica:/rdf:/rdfs:/owl: prefixes entirely whenever any @context was present. _as_turtle_iri() now always merges the built-ins with whatever namespaces the caller passes. --- semantica/export/rdf_exporter.py | 45 +++++++++++++++-- tests/export/test_rdf_exporter_turtle_iris.py | 49 +++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/semantica/export/rdf_exporter.py b/semantica/export/rdf_exporter.py index f68bb120..32a09895 100644 --- a/semantica/export/rdf_exporter.py +++ b/semantica/export/rdf_exporter.py @@ -29,6 +29,7 @@ Author: Semantica Contributors License: MIT """ +import re from pathlib import Path from decimal import Decimal, InvalidOperation from html import escape as xml_escape @@ -403,6 +404,30 @@ class RDFSerializer: _OWL_TIME_NS = "http://www.w3.org/2006/time#" _SEMANTICA_NS = "https://semantica.dev/ns#" + # Matches an already-valid percent-escape so it can be passed through + # unchanged instead of being re-encoded into e.g. %2520. + _PERCENT_ESCAPE_RE = re.compile(r"%[0-9A-Fa-f]{2}") + + @classmethod + def _quote_preserving_escapes(cls, value: str, safe: str) -> str: + """quote() that leaves existing valid %XX escapes untouched. + + Blanket-quoting an absolute IRI double-encodes any percent-escape it + already carries (%20 -> %2520), which changes the identity of every + previously-valid IRI containing one. Only the spans between existing + valid escapes are quoted; a bare '%' that isn't part of a valid + escape (e.g. "%zz") still gets encoded to %25, keeping the malformed + case handled. + """ + parts = [] + pos = 0 + for match in cls._PERCENT_ESCAPE_RE.finditer(value): + parts.append(quote(value[pos : match.start()], safe=safe)) + parts.append(match.group(0)) + pos = match.end() + parts.append(quote(value[pos:], safe=safe)) + return "".join(parts) + def _as_turtle_iri( self, value: Any, namespaces: Optional[Dict[str, str]] = None ) -> str: @@ -414,14 +439,28 @@ class RDFSerializer: parsed = urlsplit("") if parsed.scheme: prefix, separator, local_name = value.partition(":") - namespace = (namespaces or self.namespace_manager.namespaces).get(prefix) + # Built-in namespaces (semantica:, rdf:, rdfs:, owl:, ...) must + # always be resolvable, not only when the caller passes no + # namespaces of its own — otherwise a value like "semantica:Foo" + # resolves fine with no @context but stops resolving the moment + # any @context is present, since callers pass extract_namespaces() + # (context-only) here without merging in the built-ins. + effective_namespaces = { + **self.namespace_manager.namespaces, + **(namespaces or {}), + } + namespace = effective_namespaces.get(prefix) if namespace and separator: - return quote(namespace + local_name, safe=":/?#[]@!$&'()*+,;=") + return self._quote_preserving_escapes( + namespace + local_name, safe=":/?#[]@!$&'()*+,;=" + ) # A scheme with at least two characters is an absolute IRI, # including opaque forms such as mailto:foo and isbn:0451450523. # Keep one-character schemes as the existing Windows drive-path case. if len(prefix) >= 2: - return quote(value, safe=":/?#[]@!$&'()*+,;=") + return self._quote_preserving_escapes( + value, safe=":/?#[]@!$&'()*+,;=" + ) return self._SEMANTICA_NS + quote(value, safe="") # Design decision — TemporalBound.OPEN in RDF: diff --git a/tests/export/test_rdf_exporter_turtle_iris.py b/tests/export/test_rdf_exporter_turtle_iris.py index 33473fb5..bc197a08 100644 --- a/tests/export/test_rdf_exporter_turtle_iris.py +++ b/tests/export/test_rdf_exporter_turtle_iris.py @@ -182,3 +182,52 @@ def test_ntriples_normalizes_resource_iris(): assert URIRef("https://semantica.dev/ns#Acme%20Corp") in parsed.all_nodes() assert URIRef("mailto:foo") in parsed.all_nodes() + + +def test_turtle_preserves_existing_valid_percent_escapes(): + """A pre-encoded absolute IRI keeps its escape, instead of %20 -> %2520.""" + turtle = RDFExporter().export_to_rdf( + { + "entities": [ + { + "id": "https://example.org/entities/path%20name", + "type": "PERSON", + } + ], + "relationships": [], + }, + format="turtle", + ) + parsed = Graph().parse(data=turtle, format="turtle") + + assert ( + URIRef("https://example.org/entities/path%20name"), + RDF.type, + URIRef("https://semantica.dev/ns#PERSON"), + ) in parsed + assert "%2520" not in turtle + + +def test_ntriples_and_rdfxml_expand_builtin_prefixes_alongside_context(): + """A user @context must not shadow built-in prefixes like semantica:.""" + data = { + "@context": {"ex": "https://example.org/"}, + "entities": [{"id": "ORG", "type": "semantica:Entity"}], + "relationships": [], + } + + ntriples = RDFExporter().export_to_rdf(data, format="ntriples") + nt_parsed = Graph().parse(data=ntriples, format="nt") + assert ( + URIRef("https://semantica.dev/ns#ORG"), + RDF.type, + URIRef("https://semantica.dev/ns#Entity"), + ) in nt_parsed + + rdfxml = RDFExporter().export_to_rdf(data, format="rdfxml") + xml_parsed = Graph().parse(data=rdfxml, format="xml") + assert ( + URIRef("https://semantica.dev/ns#ORG"), + RDF.type, + URIRef("https://semantica.dev/ns#Entity"), + ) in xml_parsed