mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-13 04:04:09 +00:00
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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user