From 5d3a664a4ac4578d41157cf93ebc059a1e12f251 Mon Sep 17 00:00:00 2001 From: jdsika Date: Wed, 14 Oct 2026 20:59:31 +0200 Subject: [PATCH] fix(rdf): fix canonicalizer defects in base IRIs, fallback and JSON-LD The comparison with diffable-rdf recorded defects in linkml_runtime.utils.rdf_canonicalize. Fix them in the module itself: - Declare a base IRI without its fragment, as RFC 3986 section 5.1 uses it. A base ending in "#" made rdflib read relative references back as different IRIs. On the rdflib fallback, keep the base only if the output reads back with the same absolute IRIs, because rdflib relativizes by string prefix. - Refuse N-Triples and N-Quads with ValueError for graphs pyoxigraph rejects. Their N-Triples is what pyoxigraph's parser just refused; N-Triples 1.1 section 2.2 admits only absolute IRIs. This removes the fallback's line sort, which split literals on U+2028. - On the fallback, hand rdflib the statements in sorted order and name unbound namespaces in sorted order, so Turtle, RDF/XML and JSON-LD no longer vary with the hash seed. - Serialize JSON-LD through pyoxigraph like the other formats, indented. rdflib's JSON-LD writer relativized IRIs against a base without declaring it, so they resolved against the reader's location. - Write TriG on the fallback as Turtle, a TriG document with every triple in the default graph, where the pyoxigraph path puts them. rdflib named the graph after a random blank node. - End every output with exactly one newline. - Refuse a Dataset or ConjunctiveGraph instead of flattening it. rdflib 7.6 and earlier still write an rdf:List cell shared by two lists once per list on the fallback; that test is a strict xfail until rdflib releases its fix. Also replace mock_black_import with the general mock_missing_import fixture. Signed-off-by: jdsika --- .../linkml_runtime/utils/rdf_canonicalize.py | 153 ++++++++++++--- tests/conftest.py | 23 +-- .../test_generators/test_pydanticgen.py | 3 +- .../test_utils/test_rdf_canonicalize.py | 176 ++++++++++++++---- 4 files changed, 267 insertions(+), 88 deletions(-) diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py index bc06a969b8..3866147b0d 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py @@ -14,10 +14,14 @@ 2. **Non-standard RDF**: Graphs with literal predicates (e.g. SHACL annotation mode) or relative IRIs (e.g. the metamodel's - ``bibo:status ``) are rejected by pyoxigraph. This function - falls back to rdflib's serializer for such graphs, but still - canonicalizes blank-node labels (and sorts line-oriented formats) so - the fallback output remains deterministic across processes. + ``bibo:status ``) are rejected by pyoxigraph. For Turtle, + N3, TriG, RDF/XML and JSON-LD this function falls back to rdflib's + serializer, but still canonicalizes blank-node labels and hands rdflib + the statements in a fixed order, so the fallback output remains + deterministic across processes. N-Triples and N-Quads can express + neither kind of term, so those formats raise ``ValueError`` instead. + rdflib 7.6 and earlier write an ``rdf:List`` cell shared by two lists + once per list on this path. 3. **Numeric short forms**: pyoxigraph uses Turtle short forms for ``xsd:integer`` (``42``), ``xsd:boolean`` (``true``), and @@ -37,8 +41,10 @@ """ import io +import json import re import warnings +from collections.abc import Iterator import pyoxigraph as ox import rdflib @@ -69,16 +75,61 @@ class RDFCanonicalizationWarning(UserWarning): "rdf/xml": ox.RdfFormat.RDF_XML, "trig": ox.RdfFormat.TRIG, "n3": ox.RdfFormat.N3, + "json-ld": ox.RdfFormat.JSON_LD, } # Formats that support prefix declarations. _PREFIX_FORMATS = frozenset({ox.RdfFormat.TURTLE, ox.RdfFormat.TRIG, ox.RdfFormat.N3, ox.RdfFormat.RDF_XML}) -# Line-oriented formats (one triple/quad per line) whose fallback output must -# be sorted, because rdflib does not emit them in a stable order even after -# blank-node canonicalization. +# Line-oriented formats, which have no syntax for the terms pyoxigraph +# rejects: N-Triples 1.1 section 2.2 admits only absolute IRIs, and a +# predicate must be an IRI. _LINE_ORIENTED_FORMATS = frozenset({"nt", "ntriples", "n-triples", "nt11", "nquads", "n-quads"}) +# An absolute IRI begins with a scheme (RFC 3986 section 3.1). +_ABSOLUTE_IRI = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:") + + +class _SortedGraph(rdflib.Graph): + """A graph that yields its triples in a fixed order. + + rdflib's serializers write statements, and name unbound namespaces + ``ns1``, ``ns2``, ..., in the order the store yields triples, which follows + set iteration and so differs between processes. + """ + + def triples(self, triple: tuple) -> Iterator[tuple]: + """Yield the triples matching ``triple``, sorted by the N3 form of their terms.""" + yield from sorted(super().triples(triple), key=lambda found: tuple(term.n3() for term in found)) + + +def _iris(graph: rdflib.Graph) -> set[str]: + """Return the IRIs ``graph`` uses as subject, predicate or object.""" + return {str(term) for triple in graph for term in triple if isinstance(term, rdflib.URIRef)} + + +def _is_readable_rdf(graph: rdflib.Graph) -> bool: + """Whether every subject is an IRI or blank node and every predicate an IRI. + + No RDF syntax can express a literal subject or a literal predicate, so + output written for such a graph cannot be read back. + """ + return all(isinstance(s, rdflib.URIRef | rdflib.BNode) and isinstance(p, rdflib.URIRef) for s, p, _ in graph) + + +def _absolute_iris_survive(graph: rdflib.Graph, serialized: str, output_format: str) -> bool: + """Whether re-reading ``serialized`` yields every absolute IRI of ``graph``. + + rdflib relativizes an IRI against the base by removing the base as a string + prefix, which is not the inverse of RFC 3986 section 5.2.2: under a base + ending in ``#`` or inside a path segment, the reference it writes resolves + to a different IRI. Resolving the output back is therefore the check. + Relative IRIs of ``graph`` are left out, because on re-reading they resolve + against the base, which is what the base is for. + """ + expected = {iri for iri in _iris(graph) if _ABSOLUTE_IRI.match(iri)} + return expected <= _iris(rdflib.Graph().parse(data=serialized, format=output_format)) + def _deterministic_fallback_serialize(graph: rdflib.Graph, output_format: str) -> str: """Serialize a graph that pyoxigraph cannot canonicalize, deterministically. @@ -87,39 +138,65 @@ def _deterministic_fallback_serialize(graph: rdflib.Graph, output_format: str) - containing relative IRIs (e.g. the metamodel's ``bibo:status ``) or literal predicates (SHACL annotation mode). A plain ``graph.serialize()`` for such graphs is *not* reproducible across - processes: rdflib assigns blank-node labels non-deterministically, so the - structure and grouping of the output varies run to run. + processes: rdflib assigns blank-node labels non-deterministically, and + writes statements in the order its store yields them. To degrade gracefully instead of silently emitting non-deterministic output, we canonicalize blank-node labels with rdflib's own isomorphism-based canonicalization (:func:`rdflib.compare.to_canonical_graph`, - which uses a content-derived hash, not run-local ids) and preserve the - original prefix and base bindings that the canonical graph drops. For - line-oriented formats we additionally sort the serialized lines, since - rdflib does not emit N-Triples/N-Quads in a stable order. + which uses a content-derived hash, not run-local ids), hand the statements + to rdflib in sorted order, and preserve the original prefix bindings that + the canonical graph drops. Relative IRIs are preserved verbatim (not resolved against the base): the goal is deterministic output, and silently rewriting ```` into an absolute IRI would mask what is really a data problem in the source graph. + The base is kept unless the output would then read back with a different + absolute IRI, in which case it is dropped with a warning. :param graph: The rdflib Graph that pyoxigraph could not parse. - :param output_format: Target serialization format (e.g. ``"turtle"``, ``"nt"``). + :param output_format: Target serialization format (e.g. ``"turtle"``). :return: Deterministic string serialization of the graph. """ - canonical = to_canonical_graph(graph) + canonical = _SortedGraph() + canonical += to_canonical_graph(graph) # to_canonical_graph builds a fresh graph without the source's namespace - # bindings; rebind them so the output does not fall back to rdflib's - # non-deterministic auto-generated ``ns1:``/``ns2:`` prefixes. + # bindings; rebind them so the output keeps the source's prefixes. for prefix, namespace in graph.namespace_manager.namespaces(): canonical.namespace_manager.bind(prefix, namespace, replace=True) + if output_format.lower() == "xml": + # rdflib's RDF/XML serializer names unbound namespaces while iterating + # a set of predicates; name them first, in sorted order. + for predicate in sorted({p for p in canonical.predicates() if isinstance(p, rdflib.URIRef)}): + canonical.namespace_manager.compute_qname_strict(predicate) + # A Turtle document is a TriG document whose triples are all in the default + # graph (TriG section 2.1), where the pyoxigraph path puts them; rdflib's TriG + # serializer would instead name the graph after a random blank node. + rdflib_format = "turtle" if output_format.lower() == "trig" else output_format canonical.base = graph.base - serialized = canonical.serialize(format=output_format) - if output_format.lower() in _LINE_ORIENTED_FORMATS: - lines = [line for line in serialized.splitlines() if line.strip()] - return "\n".join(sorted(lines)) + "\n" + serialized = canonical.serialize(format=rdflib_format) + if graph.base and _is_readable_rdf(graph) and not _absolute_iris_survive(graph, serialized, rdflib_format): + warnings.warn( + f"Writing the graph's base IRI {str(graph.base)!r} into {output_format!r} output " + "would change IRIs that rdflib relativizes against it; serializing without it.", + RDFCanonicalizationWarning, + stacklevel=3, + ) + canonical.base = None + serialized = canonical.serialize(format=rdflib_format) + if output_format.lower() == "json-ld": + # rdflib writes node objects in set order; a JSON-LD array conveys no + # order unless it is a list (JSON-LD 1.1 section 4.3). + nodes = sorted(json.loads(serialized), key=lambda node: json.dumps(node, sort_keys=True)) + serialized = json.dumps(nodes, indent=2, ensure_ascii=False) return serialized +def _with_single_trailing_newline(text: str) -> str: + """End ``text`` with exactly one newline, whichever serializer wrote it.""" + return text.rstrip("\n") + "\n" + + # Characters that may appear escaped in a Turtle PN_LOCAL via PN_LOCAL_ESC. _PN_LOCAL_ESC_UNESCAPE = re.compile(r"\\([_~.\-!$&'()*+,;=/?#@%])") @@ -294,7 +371,10 @@ def canonicalize_rdf_graph( The graph is transferred to pyoxigraph via N-Triples, canonicalized with RDFC-1.0, sorted, and serialized back to the requested format. Prefix bindings from the rdflib Graph are preserved in the output - for formats that support them (Turtle, TriG, N3, RDF/XML). + for formats that support them (Turtle, TriG, N3, RDF/XML). JSON-LD is + written in expanded form and indented. A base IRI is declared without + its fragment, which RFC 3986 section 5.1 strips before resolving + against a base. Every output ends with exactly one newline. Falls back to plain rdflib serialization for unsupported formats or graphs containing non-standard RDF (e.g. literal predicates). @@ -306,8 +386,17 @@ def canonicalize_rdf_graph( non-standard RDF fallback warns when it cannot apply these labels. See https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control :return: Deterministic string serialization of the graph. + :raises TypeError: If ``graph`` can hold named graphs (a ``Dataset`` or + ``ConjunctiveGraph``), whose graph names one document cannot carry. + :raises ValueError: If N-Triples or N-Quads are requested for non-standard RDF. :raises ImportError: If diff-stable labels are requested without diffable-rdf. """ + if graph.context_aware: + raise TypeError( + f"canonicalize_rdf_graph serializes a single graph, but got a {type(graph).__name__}, " + "which holds named graphs; serialize each graph separately." + ) + if diff_stable: try: from diffable_rdf import wl_relabel_quads @@ -325,7 +414,7 @@ def canonicalize_rdf_graph( RDFCanonicalizationWarning, stacklevel=2, ) - return graph.serialize(format=output_format) + return _with_single_trailing_newline(graph.serialize(format=output_format)) # 1. Transfer rdflib graph to pyoxigraph via N-Triples. nt_data = graph.serialize(format="nt") @@ -336,7 +425,14 @@ def canonicalize_rdf_graph( # (e.g. literal predicates from annotations) that pyoxigraph rejects. try: triples = list(ox.parse(io.BytesIO(nt_bytes), format=ox.RdfFormat.N_TRIPLES)) - except SyntaxError: + except SyntaxError as exc: + # rdflib's N-Triples serialization of this graph is exactly what + # pyoxigraph's N-Triples parser just rejected. + if output_format.lower() in _LINE_ORIENTED_FORMATS: + raise ValueError( + f"The graph contains terms that {output_format!r} cannot express, such as a " + f"relative IRI or a literal predicate ({exc}); choose a format such as 'turtle'." + ) from exc warnings.warn( "Graph contains non-standard RDF (e.g. relative IRIs or literal predicates) " "that pyoxigraph cannot parse; falling back to rdflib. Output is still " @@ -355,7 +451,7 @@ def canonicalize_rdf_graph( RDFCanonicalizationWarning, stacklevel=2, ) - return _deterministic_fallback_serialize(graph, output_format) + return _with_single_trailing_newline(_deterministic_fallback_serialize(graph, output_format)) dataset = ox.Dataset() for triple in triples: @@ -413,7 +509,7 @@ def canonicalize_rdf_graph( sorted_triples, format=ox_format, prefixes=prefixes, - base_iri=base_iri, + base_iri=base_iri.partition("#")[0] if base_iri else None, ) except ValueError as e: # pyoxigraph 0.5.x reports rejected prefix IRIs with a message @@ -438,4 +534,7 @@ def canonicalize_rdf_graph( result = result_bytes.decode("utf-8") if ox_format in _PREFIX_FORMATS and used_prefixes: result = _expand_trailing_dot_curies(result, used_prefixes) - return result + if ox_format == ox.RdfFormat.JSON_LD: + # pyoxigraph writes JSON-LD on one line, so any edit would rewrite all of it. + result = json.dumps(json.loads(result), indent=2, ensure_ascii=False) + return _with_single_trailing_newline(result) diff --git a/tests/conftest.py b/tests/conftest.py index ba13d6f764..1087c3c79f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -422,8 +422,7 @@ class MockImportErrorFinder(MetaPathFinder): """ Fake like we don't have a module when we really do. - see the ``mock_missing_import`` fixture to hide any module, or ``mock_black_import`` - for a single-purpose example. + see the ``mock_missing_import`` fixture. .. note:: @@ -477,26 +476,6 @@ def hide(module: str) -> None: sys.meta_path.remove(finder) -@pytest.fixture(scope="function") -def mock_black_import(): - """ - Pretend like we don't have black even if we do - """ - removed = {} - for k, v in sys.modules.items(): - if k.startswith("black"): - removed[k] = v - for k in removed.keys(): - del sys.modules[k] - meta_finder = MockImportErrorFinder("black") - sys.meta_path.insert(0, meta_finder) - - yield removed - - sys.modules.update(removed) - sys.meta_path.remove(meta_finder) - - @pytest.fixture(autouse=True) def reset_warnings(): """Reset warnings for each test.""" diff --git a/tests/linkml/test_generators/test_pydanticgen.py b/tests/linkml/test_generators/test_pydanticgen.py index 0621f5c333..853509ebbd 100644 --- a/tests/linkml/test_generators/test_pydanticgen.py +++ b/tests/linkml/test_generators/test_pydanticgen.py @@ -2409,7 +2409,7 @@ def test_template_black(array_complex): @pytest.mark.xdist_group("serial") -def test_template_noblack(array_complex, mock_black_import): +def test_template_noblack(array_complex, mock_missing_import): """ When we don't have black, we should still be able to render templates normally @@ -2418,6 +2418,7 @@ def test_template_noblack(array_complex, mock_black_import): keep this test last in the module because it messed up the objects we have imported previously :) """ + mock_missing_import("black") # ensure our mock worked with pytest.raises(ImportError): diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py index 8da48fa8b3..e13e0b8cd5 100644 --- a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py @@ -1,17 +1,20 @@ """Tests for deterministic RDF serialization via pyoxigraph RDFC-1.0.""" import difflib +import json import os import re import subprocess import sys import textwrap +import warnings from pathlib import Path import pyoxigraph as ox import pytest import rdflib -from rdflib import BNode, Graph, Literal, Namespace, URIRef +from packaging.version import Version +from rdflib import BNode, Dataset, Graph, Literal, Namespace, URIRef from rdflib.namespace import OWL, RDF, RDFS, SH, XSD from linkml.generators.owlgen import OwlSchemaGenerator @@ -45,12 +48,13 @@ def test_determinism(): assert all(r == results[0] for r in results), "Output was not deterministic across runs" -def test_round_trip_isomorphic(): +@pytest.mark.parametrize("output_format", ["turtle", "json-ld"]) +def test_round_trip_isomorphic(output_format): """Re-parsing canonicalized output produces an isomorphic graph.""" g = _make_graph_with_bnodes() - ttl = canonicalize_rdf_graph(g, output_format="turtle") + serialized = canonicalize_rdf_graph(g, output_format=output_format) g2 = Graph() - g2.parse(data=ttl, format="turtle") + g2.parse(data=serialized, format=output_format) assert rdflib.compare.isomorphic(g, g2), "Round-tripped graph is not isomorphic to original" @@ -331,7 +335,7 @@ def test_unsupported_format_warns_and_falls_back(): g.bind("ex", "http://example.com/") g.add((URIRef("http://example.com/a"), RDF.type, URIRef("http://example.com/Thing"))) with pytest.warns(RDFCanonicalizationWarning, match="does not support format"): - result = canonicalize_rdf_graph(g, output_format="json-ld") + result = canonicalize_rdf_graph(g, output_format="pretty-xml") # The fallback should still produce valid output in the requested format. assert result @@ -462,58 +466,154 @@ def test_fallback_preserves_relative_iri(): assert "" in result -@pytest.mark.parametrize("output_format", ["turtle", "nt"]) -def test_fallback_is_deterministic_across_processes(output_format): - """The rdflib fallback produces byte-identical output across processes. +def _forcing_fallback(graph: Graph) -> Graph: + """Add a relative IRI, which pyoxigraph rejects, so ``graph`` takes the rdflib fallback.""" + graph.add((URIRef("testing"), URIRef("http://example.com/p"), Literal("forces-fallback"))) + return graph + - A graph containing a relative IRI (````) forces the pyoxigraph - parse to fail, exercising the fallback path. The graph also contains - several blank nodes: a plain ``graph.serialize()`` would label them - non-deterministically, so this test would fail without the blank-node - canonicalization in ``_deterministic_fallback_serialize``. Two - subprocesses with different ``PYTHONHASHSEED`` values must agree byte for - byte. +_ACROSS_PROCESSES = textwrap.dedent( """ - program = textwrap.dedent( - f""" - from rdflib import BNode, Graph, Literal, URIRef - from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph + import json, warnings + from rdflib import BNode, Graph, Literal, URIRef + from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph + warnings.simplefilter("ignore") + + def build(force_fallback): g = Graph() g.bind("ex", "http://example.com/") - # Relative IRI -> pyoxigraph SyntaxError -> rdflib fallback. - g.add(( - URIRef("http://example.com/s"), - URIRef("http://purl.org/ontology/bibo/status"), - URIRef("testing"), - )) - for i in range(6): + if force_fallback: + g.add((URIRef("testing"), URIRef("http://example.com/p"), Literal("forces-fallback"))) + for i in range(12): bn = BNode() - g.add((URIRef("http://example.com/s"), URIRef("http://example.com/has"), bn)) - g.add((bn, URIRef("http://example.com/q"), Literal(f"bn_{{i}}"))) + g.add((URIRef(f"http://example.com/s{i:02d}"), URIRef("http://example.com/has"), bn)) + g.add((bn, URIRef("http://example.com/q"), Literal(f"bn_{i}"))) + # Namespaces without a bound prefix, which rdflib names ns1, ns2, ... + for host in "abcdefgh": + g.add((URIRef(f"http://{host}.example/s"), URIRef(f"http://{host}.example/p"), URIRef(f"http://{host}.example/o"))) + return g - print(canonicalize_rdf_graph(g, output_format={output_format!r}), end="") - """ - ) + cases = [(fmt, True) for fmt in ("turtle", "n3", "trig", "xml", "json-ld")] + [("json-ld", False)] + print(json.dumps({f"{fmt}-{fallback}": canonicalize_rdf_graph(build(fallback), fmt) for fmt, fallback in cases})) + """ +) - def run(seed: str) -> str: + +def test_output_is_deterministic_across_processes(): + """The same graph serializes to the same bytes under any hash seed. + + The rdflib fallback is forced with a relative IRI. rdflib labels blank + nodes, writes statements, and names unbound namespaces in orders that + follow set iteration, which ``PYTHONHASHSEED`` changes; JSON-LD is checked + on both paths. + """ + + def run(seed: str) -> dict[str, str]: # Inherit the real environment and vary only the hash seed. Replacing it # wholesale drops SystemRoot on Windows, which breaks winsock init. env = {**os.environ, "PYTHONHASHSEED": seed} result = subprocess.run( - [sys.executable, "-c", program], + [sys.executable, "-c", _ACROSS_PROCESSES], check=True, capture_output=True, text=True, + encoding="utf-8", env=env, ) - return result.stdout + return json.loads(result.stdout) - out_a = run("0") - out_b = run("42") - assert out_a == out_b, ( - "Fallback output differs across PYTHONHASHSEED values; blank-node canonicalization may be missing" + outputs = [run(seed) for seed in ("0", "1", "42", "999")] + for case in outputs[0]: + assert len({output[case] for output in outputs}) == 1, f"{case} output differs across PYTHONHASHSEED values" + + +@pytest.mark.parametrize("output_format", ["turtle", "n3", "trig", "xml", "json-ld"]) +@pytest.mark.parametrize("force_fallback", [False, True], ids=["pyoxigraph", "fallback"]) +@pytest.mark.parametrize("base", ["http://ex.org/d#", "http://ex.org/"]) +def test_an_iri_written_against_a_base_reads_back_unchanged(base, force_fallback, output_format): + """Writing IRIs relative to a base must not change them. + + RFC 3986 section 5.1 uses a base without its fragment, but rdflib's Turtle + parser resolves ``<#a>`` against ``http://ex.org/d#`` to + ``http://ex.org/d##a``. On the fallback, rdflib relativizes by string + prefix, and its JSON-LD serializer relativizes without declaring the base. + """ + triple = (URIRef("http://ex.org/d#a"), URIRef("http://example.com/p"), Literal("v")) + graph = Graph(base=base) + graph.add(triple) + if force_fallback: + _forcing_fallback(graph) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RDFCanonicalizationWarning) + serialized = canonicalize_rdf_graph(graph, output_format=output_format) + reread = Dataset().parse(data=serialized, format=output_format) + assert triple in {quad[:3] for quad in reread.quads()} + + +def test_base_survives_the_fallback(): + """A base that rdflib relativizes against correctly stays in fallback output.""" + graph = _forcing_fallback(Graph(base="http://example.org/default/")) + with pytest.warns(RDFCanonicalizationWarning, match="non-standard RDF"): + serialized = canonicalize_rdf_graph(graph) + assert "@base ." in serialized + + +@pytest.mark.xfail( + Version(rdflib.__version__) < Version("7.7.0a0"), + reason="rdflib 7.6 and earlier write a shared rdf:List cell once per list", + strict=True, +) +def test_a_shared_rdf_list_tail_is_not_duplicated(): + """Two lists sharing a tail must not gain triples on the fallback.""" + graph = Graph() + tail = BNode() + graph.add((tail, RDF.first, Literal("shared"))) + graph.add((tail, RDF.rest, RDF.nil)) + for name in ("l1", "l2"): + head = BNode() + graph.add((URIRef(f"http://example.com/{name}"), URIRef("http://example.com/list"), head)) + graph.add((head, RDF.first, Literal(name))) + graph.add((head, RDF.rest, tail)) + _forcing_fallback(graph) + with pytest.warns(RDFCanonicalizationWarning, match="non-standard RDF"): + serialized = canonicalize_rdf_graph(graph) + assert len(Graph().parse(data=serialized, format="turtle")) == len(graph) + + +@pytest.mark.parametrize("output_format", ["nt", "nquads"]) +@pytest.mark.parametrize( + "triple", + [ + (URIRef("testing"), URIRef("http://example.com/p"), Literal("v")), + (URIRef("http://example.com/s"), Literal("not_a_predicate"), Literal("v")), + ], + ids=["relative-iri", "literal-predicate"], +) +def test_line_formats_refuse_terms_they_cannot_express(output_format, triple): + """N-Triples 1.1 section 2.2 admits only absolute IRIs, and a predicate must be an IRI.""" + graph = Graph() + graph.add(triple) + with pytest.raises(ValueError, match="cannot express"): + canonicalize_rdf_graph(graph, output_format=output_format) + + +def test_every_format_ends_with_exactly_one_newline(): + """A committed file should not depend on which format wrote it.""" + graph = _make_graph_with_bnodes() + for output_format in ("turtle", "nt", "xml", "trig", "n3", "json-ld"): + serialized = canonicalize_rdf_graph(graph, output_format=output_format) + assert serialized.endswith("\n") and not serialized.endswith("\n\n"), output_format + + +def test_a_graph_holding_named_graphs_is_refused(): + """One document cannot carry graph names, so a Dataset is refused rather than flattened.""" + dataset = Dataset() + dataset.graph(URIRef("http://example.com/g1")).add( + (URIRef("http://example.com/a"), URIRef("http://example.com/p"), Literal("in-g1")) ) + with pytest.raises(TypeError, match="named graphs"): + canonicalize_rdf_graph(dataset) def _shapes_graph(count: int, extra: bool = False) -> Graph: