Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 16 additions & 48 deletions src/diffable_rdf/turtle.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
logger = logging.getLogger(__name__)


from rdflib import RDF # noqa: E402 (kept beside the serializer that uses it)
from rdflib.plugins.serializers.turtle import TurtleSerializer # noqa: E402


Expand Down Expand Up @@ -123,52 +122,19 @@ def _wl_signatures(



class _SharingAwareTurtleSerializer(TurtleSerializer):
"""Turtle serializer that only uses ``( … )`` for lists nothing else points into.

rdflib's :meth:`isValidList` checks that every cell of an ``rdf:List`` carries exactly
two predicates, but not how many statements point *into* the chain. When a cell is
referenced from more than one place, the inline collection form consumes it and the other
reference is left dangling, with no ``rdf:first``/``rdf:rest`` of its own::

_:tail rdf:first "b" ; rdf:rest rdf:nil .
_:l1 rdf:first "a" ; rdf:rest _:tail .
ex:s1 sh:in _:l1 .
ex:s2 sh:in _:tail .

serialized as::

ex:s1 sh:in ( "a" "b" ) .
ex:s2 sh:in _:tail . # _:tail is never defined -> the list is lost

A shared *head* is corrupted differently: the collection is written inline at every
reference, so re-parsing yields one private copy per reference and the triple count grows.
Either way the output does not round-trip, which defeats the point of a canonical form.

Requiring that every cell in the chain has exactly one inbound reference keeps the
readable ``( … )`` form for the overwhelmingly common private list, and falls back to
explicit ``rdf:first``/``rdf:rest`` statements exactly where sharing makes it unsafe.
"""

def isValidList(self, l_: "Node") -> bool:
if not super().isValidList(l_):
return False
node = l_
while node and node != RDF.nil:
# A cell of a private list is pointed at once: by the statement that introduces
# the list, or by its predecessor's rdf:rest. More than that means sharing.
if sum(1 for _ in self.store.subject_predicates(node)) > 1:
return False
node = self.store.value(node, RDF.rest)
return True


class _NoCollectionTurtleSerializer(TurtleSerializer):
"""Turtle serializer that never uses ``( … )`` collection syntax.

The fallback for graphs where the inline form cannot represent the collections faithfully.
Explicit ``rdf:first``/``rdf:rest`` statements are always correct, if less readable, so this
guarantees a round-trip at the cost of verbosity - and only for the graphs that need it.
Whether it can depends on how the serializer decides to use ``( … )``, which is rdflib's
decision to make and not this library's to second-guess; what belongs here is checking the
result. An inline collection is written out at one reference only, so a chain that anything
else points into is either detached from those references or copied once per reference, and
in both cases the text says something the graph does not.

Explicit ``rdf:first``/``rdf:rest`` statements can express any arrangement of cells, shared
or not, so this is always faithful — at the cost of verbosity, and only for the graphs that
need it.
"""

def isValidList(self, l_: "Node") -> bool:
Expand Down Expand Up @@ -318,11 +284,13 @@ def _round_trips(text: str) -> bool:
return len(reparsed) == len(result_graph) and isomorphic(reparsed, result_graph)

# A canonical form that does not round-trip is worse than none: it silently rewrites the
# graph. rdflib decides where to use inline ``( … )`` collection syntax with a heuristic
# that does not account for statements pointing into a collection, so for some graphs the
# inline form detaches cells or duplicates them. Verify, and fall back to explicit
# rdf:first/rdf:rest statements - always faithful - for the graphs where it does.
text = _render(_SharingAwareTurtleSerializer)
# graph. Where inline ``( … )`` collection syntax is safe is rdflib's decision, and it is
# the one place this pipeline cannot verify by construction, because it depends on how many
# statements point into a chain rather than on anything the graph says locally. So take
# rdflib's output and check it, rather than predicting it: on the graphs where the inline
# form detaches or duplicates cells, fall back to explicit rdf:first/rdf:rest statements,
# which can express any arrangement of cells.
text = _render(TurtleSerializer)
if not _round_trips(text):
text = _render(_NoCollectionTurtleSerializer)
if not _round_trips(text):
Expand Down
Loading