From 273b2f4867b748a295e0d53a06c0b3d11f0d35bc Mon Sep 17 00:00:00 2001 From: jdsika Date: Tue, 4 Aug 2026 23:10:00 +0200 Subject: [PATCH 1/2] fix: guarantee the canonical form round-trips when RDF collections are shared deterministic_turtle could silently rewrite a graph. rdflib's Turtle serializer decides where to use inline ( ... ) collection syntax with isValidList, which checks that every cell of an rdf:List carries exactly two predicates but not how many statements point *into* the chain. Two consequences, both silent. A cell referenced from elsewhere is consumed by the inline form and the other reference is left undefined, so a list is simply lost: _: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 . became ex:s1 sh:in ( "a" "b" ) . ex:s2 sh:in _:tail . # _:tail is never defined A shared head is written inline at every reference instead, so re-parsing yields one private copy per reference and the triple count grows. On a real SHACL shapes graph of 4963 triples - 81 sh:in constraints over 51 collections, 16 of them multiply referenced - every pass added 76 rdf:first and 76 rdf:rest triples and canonicalization never reached a fixed point. Reported as issue #1. Serialization is now verified rather than trusted. The output is re-parsed and compared with the graph it was produced from; a graph whose collections the inline form cannot represent falls back to explicit rdf:first/rdf:rest statements, which are always faithful. Two serializers support that: one that declines inline syntax for a chain any other statement points into, and one that declines it entirely. If neither round-trips, the call raises rather than returning a file that does not say what it was given. RDFC-1.0 and the Weisfeiler-Lehman relabelling were not at fault: on the graph above both preserve all 4963 triples and map 276 blank nodes to 276 distinct labels. The defect was entirely in the final serialization. Effect on that graph: 4963 triples in, 4963 out, no orphaned cells, fixed point on the first pass, isomorphic to the input, and all 81 sh:in constraints still resolving to their 452 literals. tests/test_canonicalization_properties.py asserts the four promises the library makes, over 40 seeded graphs containing blank node cycles, nested collections, shared heads, shared interior cells and the full range of literal forms: P1 lossless re-parsing the output is isomorphic to the input P2 idempotent canonicalizing the output reproduces it byte for byte P3 label-independent inputs differing only in blank node ids give equal bytes P4 order-independent insertion order does not affect the output P3 is what makes the form canonical and had no coverage before. Comparison for P1 is modulo RDF 1.1 literal identity - "a"^^xsd:string and "a" are the same literal (Concepts, Sec. 3.3) and numeric lexical forms may differ - with triple counts asserted separately so that normalisation cannot mask loss. Alongside the properties: six shared-collection arrangements each checked for exact round-trip, idempotence, label-independence and absence of dangling references; a cell-count check that catches loss and duplication in one assertion; a ten-pass drift check; a graph shaped like the real-world failure, with a dozen lists sharing tails; and a check that the collection-free fallback is faithful on its own, since it is what guarantees the round trip. Against the unfixed library the suite fails 20 of 188, spread across P1, P2 and the targeted cases. With the fix, 188 pass. Signed-off-by: jdsika --- src/diffable_rdf/turtle.py | 86 ++++- tests/test_canonicalization_properties.py | 420 ++++++++++++++++++++++ 2 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 tests/test_canonicalization_properties.py diff --git a/src/diffable_rdf/turtle.py b/src/diffable_rdf/turtle.py index 103246d..c7e1cff 100644 --- a/src/diffable_rdf/turtle.py +++ b/src/diffable_rdf/turtle.py @@ -18,6 +18,10 @@ 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 + + def _wl_signatures( quads: list, iterations: int = 4, @@ -119,6 +123,58 @@ 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. + """ + + def isValidList(self, l_: "Node") -> bool: + return False + + def deterministic_turtle(graph: "RdfGraph") -> str: """Serialize an RDF graph to Turtle with deterministic output ordering. @@ -247,7 +303,35 @@ def _to_rdflib(term): # rdflib's Turtle serializer always emits a trailing double newline; # normalize to a single newline for consistent file endings. - return result_graph.serialize(format="turtle").rstrip("\n") + "\n" + import io + + from rdflib.compare import isomorphic + + def _render(serializer_class) -> str: + buffer = io.BytesIO() + serializer_class(result_graph).serialize(buffer, encoding="utf-8") + return buffer.getvalue().decode("utf-8").rstrip("\n") + "\n" + + def _round_trips(text: str) -> bool: + reparsed = Graph(bind_namespaces="none") + reparsed.parse(data=text, format="turtle") + 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) + if not _round_trips(text): + text = _render(_NoCollectionTurtleSerializer) + if not _round_trips(text): + raise ValueError( + "canonical serialization does not round-trip even without collection syntax; " + f"{len(result_graph)} triples in. This is a bug in diffable-rdf: please report " + "it with the input graph." + ) + return text diff --git a/tests/test_canonicalization_properties.py b/tests/test_canonicalization_properties.py new file mode 100644 index 0000000..d13e440 --- /dev/null +++ b/tests/test_canonicalization_properties.py @@ -0,0 +1,420 @@ +"""Property tests for the canonicalization contract. + +``deterministic_turtle`` makes four promises. Everything a consumer does with it — committing +generated RDF, diffing it, verifying it in CI — depends on all four holding together, and the +failure mode when one breaks is silent: a file that looks fine and no longer says what it said. + +=== ============================================================================ +P1 **Lossless.** Parsing the output yields a graph isomorphic to the input. +P2 **Idempotent.** Canonicalizing the output reproduces it byte for byte. +P3 **Label-independent.** Two inputs differing only in blank node identifiers + produce identical bytes. This is what makes the form *canonical*. +P4 **Order-independent.** The order triples were added in does not affect output. +=== ============================================================================ + +The graphs are produced by a seeded generator rather than by hand, because the defect that +motivated this module — a list cell referenced from two places, which rdflib's Turtle +serializer inlines and thereby detaches (issue #1) — is the kind of shape nobody writes on +purpose. Seeds are fixed, so a failure is reproducible; the seed is reported in the assertion +message. +""" + +from __future__ import annotations + +import random + +import pytest +from rdflib import BNode, Graph, Literal, Namespace, URIRef +from rdflib.compare import isomorphic +from rdflib.namespace import RDF, XSD + +from diffable_rdf import deterministic_turtle + +EX = Namespace("http://example.org/") + +#: Enough seeds to exercise the generator's shape space without making the suite slow. +SEEDS = list(range(40)) + + +# ───────────────────────────────────────────────────────────────────────────── +# graph generation +# ───────────────────────────────────────────────────────────────────────────── + + +def _literal(rng: random.Random) -> Literal: + kind = rng.choice(("plain", "string", "int", "double", "bool", "lang")) + if kind == "plain": + return Literal(rng.choice(("red", "green", "", "with space", 'quote"inside'))) + if kind == "string": + return Literal(rng.choice(("a", "b")), datatype=XSD.string) + if kind == "int": + return Literal(rng.randint(-3, 3)) + if kind == "double": + return Literal(float(rng.randint(0, 3)), datatype=XSD.double) + if kind == "bool": + return Literal(rng.choice((True, False))) + return Literal(rng.choice(("hello", "bonjour")), lang=rng.choice(("en", "fr"))) + + +def _add_list(graph: Graph, rng: random.Random, values: list) -> BNode: + """Add an rdf:List and return its head, cell by cell so cells can be reused.""" + head = RDF.nil + for value in reversed(values): + cell = BNode() + graph.add((cell, RDF.first, value)) + graph.add((cell, RDF.rest, head)) + head = cell + return head + + +def _random_graph(seed: int) -> Graph: + """A small graph covering the shapes that have historically broken canonicalization.""" + rng = random.Random(seed) + graph = Graph() + graph.bind("ex", EX) + + subjects = [EX[f"s{i}"] for i in range(rng.randint(1, 4))] + for subject in subjects: + graph.add((subject, RDF.type, EX.Thing)) + for _ in range(rng.randint(1, 3)): + graph.add((subject, EX[f"p{rng.randint(0, 2)}"], _literal(rng))) + + # plain blank nodes, sometimes nested, sometimes referenced twice + anon = [BNode() for _ in range(rng.randint(0, 3))] + for node in anon: + graph.add((node, EX.label, _literal(rng))) + for _ in range(rng.randint(1, 2)): + graph.add((rng.choice(subjects), EX.has, node)) + if len(anon) >= 2: + graph.add((anon[0], EX.next, anon[1])) + if rng.random() < 0.3: + # a cycle between blank nodes: RDFC-1.0 must still terminate + graph.add((anon[1], EX.next, anon[0])) + + # RDF lists, in the four arrangements that matter + lists: list[BNode] = [] + for _ in range(rng.randint(0, 3)): + values = [_literal(rng) for _ in range(rng.randint(1, 3))] + head = _add_list(graph, rng, values) + if head != RDF.nil: + lists.append(head) + graph.add((rng.choice(subjects), EX.items, head)) + + if lists: + head = rng.choice(lists) + if rng.random() < 0.5: + # shared head: several statements point at the same list + graph.add((rng.choice(subjects), EX.alsoItems, head)) + if rng.random() < 0.5: + # shared interior cell: a statement points into the middle of a chain. This is the + # shape that silently lost data before the fix for issue #1. + interior = graph.value(head, RDF.rest) + if interior is not None and interior != RDF.nil: + graph.add((rng.choice(subjects), EX.tail, interior)) + if rng.random() < 0.3: + # a list whose member is itself a list + graph.add((rng.choice(subjects), EX.nested, _add_list(graph, rng, [head]))) + + # the empty list, which is an IRI rather than a blank node + if rng.random() < 0.3: + graph.add((rng.choice(subjects), EX.empty, RDF.nil)) + + return graph + + +def _rdf11_literals(graph: Graph) -> Graph: + """The graph with literals reduced to RDF 1.1 identity. + + Two normalisations, both required for a fair comparison rather than to paper over a defect: + + * ``"a"^^xsd:string`` and ``"a"`` are the *same* literal in RDF 1.1 (Concepts, Sec. 3.3), + and pyoxigraph follows that, so the canonical form never writes the explicit datatype. + rdflib keeps them as distinct terms, so an isomorphism check would report a difference + that does not exist. + * numeric literals may come back in a different but equivalent lexical form - ``1.0`` and + ``1.0E0`` are the same ``xsd:double`` - because Turtle permits short forms. + + Anything else is compared exactly. + """ + out = Graph() + + def norm(term): + if not isinstance(term, Literal): + return term + if term.datatype == XSD.string: + return Literal(str(term)) + if term.datatype in (XSD.double, XSD.decimal, XSD.integer, XSD.float) and term.value is not None: + return Literal(term.value, datatype=term.datatype) + return term + + for s_, p_, o_ in graph: + out.add((s_, p_, norm(o_))) + return out + + +def _relabelled(graph: Graph) -> Graph: + """The same graph with every blank node given a different identifier.""" + mapping: dict[BNode, BNode] = {} + + def remap(term): + if isinstance(term, BNode): + return mapping.setdefault(term, BNode()) + return term + + out = Graph() + for prefix, namespace in graph.namespaces(): + out.bind(prefix, namespace) + for s, p, o in graph: + out.add((remap(s), p, remap(o))) + return out + + +def _reordered(graph: Graph, seed: int) -> Graph: + """The same graph with triples inserted in a different order.""" + triples = list(graph) + random.Random(seed).shuffle(triples) + out = Graph() + for prefix, namespace in graph.namespaces(): + out.bind(prefix, namespace) + for triple in triples: + out.add(triple) + return out + + +# ───────────────────────────────────────────────────────────────────────────── +# the four properties +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("seed", SEEDS) +def test_p1_canonical_output_is_lossless(seed: int) -> None: + graph = _random_graph(seed) + reparsed = Graph() + reparsed.parse(data=deterministic_turtle(graph), format="turtle") + assert len(reparsed) == len(graph), ( + f"seed {seed}: triple count changed, {len(graph)} in, {len(reparsed)} out" + ) + assert isomorphic(_rdf11_literals(reparsed), _rdf11_literals(graph)), ( + f"seed {seed}: canonical output is not isomorphic to its input " + f"({len(graph)} triples in, {len(reparsed)} out)" + ) + + +@pytest.mark.parametrize("seed", SEEDS) +def test_p2_canonicalization_is_idempotent(seed: int) -> None: + graph = _random_graph(seed) + once = deterministic_turtle(graph) + reparsed = Graph() + reparsed.parse(data=once, format="turtle") + twice = deterministic_turtle(reparsed) + assert twice == once, ( + f"seed {seed}: canonicalizing the canonical form changed it " + f"({len(once)} bytes -> {len(twice)} bytes)" + ) + + +@pytest.mark.parametrize("seed", SEEDS) +def test_p3_output_does_not_depend_on_blank_node_labels(seed: int) -> None: + graph = _random_graph(seed) + assert deterministic_turtle(graph) == deterministic_turtle(_relabelled(graph)), ( + f"seed {seed}: relabelling blank nodes changed the canonical output, so the form is " + "not canonical" + ) + + +@pytest.mark.parametrize("seed", SEEDS) +def test_p4_output_does_not_depend_on_insertion_order(seed: int) -> None: + graph = _random_graph(seed) + assert deterministic_turtle(graph) == deterministic_turtle(_reordered(graph, seed + 1)), ( + f"seed {seed}: inserting the same triples in a different order changed the output" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# targeted regressions for issue #1 +# ───────────────────────────────────────────────────────────────────────────── + +SHARING_CASES = { + "private_list": """ + ex:s1 ex:items ( "a" "b" ) . + """, + "shared_head_two_refs": """ + _:l rdf:first "a" ; rdf:rest rdf:nil . + ex:s1 ex:items _:l . + ex:s2 ex:items _:l . + """, + "shared_head_five_refs": """ + _:l rdf:first "a" ; rdf:rest rdf:nil . + ex:s1 ex:items _:l . ex:s2 ex:items _:l . ex:s3 ex:items _:l . + ex:s4 ex:items _:l . ex:s5 ex:items _:l . + """, + "shared_interior_cell": """ + _:t rdf:first "b" ; rdf:rest rdf:nil . + _:l rdf:first "a" ; rdf:rest _:t . + ex:s1 ex:items _:l . + ex:s2 ex:items _:t . + """, + "two_lists_sharing_a_tail": """ + _:t rdf:first "z" ; rdf:rest rdf:nil . + _:a rdf:first "a" ; rdf:rest _:t . + _:b rdf:first "b" ; rdf:rest _:t . + ex:s1 ex:items _:a . + ex:s2 ex:items _:b . + """, + "list_containing_a_shared_list": """ + _:inner rdf:first "x" ; rdf:rest rdf:nil . + _:outer rdf:first _:inner ; rdf:rest rdf:nil . + ex:s1 ex:items _:outer . + ex:s2 ex:items _:inner . + """, +} + +PREAMBLE = ( + "@prefix ex: .\n" + "@prefix rdf: .\n" +) + + +@pytest.mark.parametrize("name", sorted(SHARING_CASES)) +def test_shared_list_structures_survive_canonicalization(name: str) -> None: + """Every arrangement of shared rdf:List cells must round-trip exactly. + + Before the fix for issue #1, ``shared_interior_cell`` lost a list entirely - the cell was + consumed by an inline ``( … )`` and the second reference was left undefined - while + ``shared_head_*`` duplicated the list once per reference, so the triple count grew on every + pass and canonicalization never reached a fixed point. + """ + graph = Graph() + graph.parse(data=PREAMBLE + SHARING_CASES[name], format="turtle") + + once = deterministic_turtle(graph) + reparsed = Graph() + reparsed.parse(data=once, format="turtle") + + assert len(reparsed) == len(graph), f"{name}: triple count changed" + assert isomorphic(reparsed, graph), f"{name}: not isomorphic to the input" + assert deterministic_turtle(reparsed) == once, f"{name}: not idempotent" + assert deterministic_turtle(_relabelled(graph)) == once, f"{name}: depends on bnode labels" + + +@pytest.mark.parametrize("name", sorted(SHARING_CASES)) +def test_no_dangling_blank_node_references(name: str) -> None: + """No statement may point at a blank node the output never defines. + + This is the specific corruption behind issue #1: ``ex:s2 ex:items _:t`` survived while + ``_:t``'s own ``rdf:first``/``rdf:rest`` did not, so the constraint silently referred to + nothing. + """ + graph = Graph() + graph.parse(data=PREAMBLE + SHARING_CASES[name], format="turtle") + + reparsed = Graph() + reparsed.parse(data=deterministic_turtle(graph), format="turtle") + + defined = {s for s in reparsed.subjects() if isinstance(s, BNode)} + referenced = {o for o in reparsed.objects() if isinstance(o, BNode)} + assert not (referenced - defined), ( + f"{name}: {len(referenced - defined)} blank node(s) are referenced but never defined" + ) + + +def test_list_cells_are_neither_lost_nor_duplicated() -> None: + """The number of rdf:List cells is preserved exactly. + + Counting cells catches both directions of the issue #1 failure in one assertion: inlining a + shared head duplicates cells, and inlining a shared interior cell detaches them. + """ + graph = Graph() + graph.parse( + data=PREAMBLE + SHARING_CASES["two_lists_sharing_a_tail"] + SHARING_CASES["shared_head_five_refs"], + format="turtle", + ) + before = len(list(graph.triples((None, RDF.first, None)))) + + reparsed = Graph() + reparsed.parse(data=deterministic_turtle(graph), format="turtle") + after = len(list(reparsed.triples((None, RDF.first, None)))) + + assert after == before, f"rdf:List cells changed from {before} to {after}" + + +def test_repeated_canonicalization_reaches_a_fixed_point() -> None: + """Ten passes over a graph full of shared lists must not drift. + + The original symptom of issue #1 was unbounded growth: 76 further ``rdf:first`` and 76 + further ``rdf:rest`` triples on every pass, with no fixed point. + """ + graph = Graph() + graph.parse(data=PREAMBLE + "".join(SHARING_CASES.values()), format="turtle") + + current = deterministic_turtle(graph) + for iteration in range(10): + reparsed = Graph() + reparsed.parse(data=current, format="turtle") + nxt = deterministic_turtle(reparsed) + assert nxt == current, f"output changed on pass {iteration + 2}" + current = nxt + +def _many_lists_sharing_tails(count: int = 12) -> Graph: + """Many short lists whose tails are structurally identical, some multiply referenced. + + This is the shape of a real SHACL shapes graph produced from an ontology with dozens of + enumerations: several ``sh:in`` constraints share a list, and lists that end in the same + value share their final cell. It is the arrangement that defeated the first attempt at + fixing issue #1 - a sharing-aware collection heuristic was not sufficient, because rdflib + chooses where to inline by a traversal that depends on blank node ordering. + """ + graph = Graph() + graph.bind("ex", EX) + tail = BNode() + graph.add((tail, RDF.first, Literal("common"))) + graph.add((tail, RDF.rest, RDF.nil)) + for index in range(count): + head = BNode() + graph.add((head, RDF.first, Literal(f"v{index % 3}"))) + graph.add((head, RDF.rest, tail)) + graph.add((EX[f"shape{index}"], EX.items, head)) + if index % 4 == 0: + # the same list referenced from a second shape + graph.add((EX[f"other{index}"], EX.items, head)) + graph.add((EX.tailUser, EX.items, tail)) + return graph + + +def test_many_lists_sharing_tails_round_trip_exactly() -> None: + graph = _many_lists_sharing_tails() + before_cells = len(list(graph.triples((None, RDF.first, None)))) + + once = deterministic_turtle(graph) + reparsed = Graph() + reparsed.parse(data=once, format="turtle") + + assert len(reparsed) == len(graph), f"{len(graph)} triples in, {len(reparsed)} out" + assert len(list(reparsed.triples((None, RDF.first, None)))) == before_cells + assert isomorphic(reparsed, graph) + assert deterministic_turtle(reparsed) == once, "not idempotent" + assert deterministic_turtle(_relabelled(graph)) == once, "depends on bnode labels" + + defined = {s for s in reparsed.subjects() if isinstance(s, BNode)} + referenced = {o for o in reparsed.objects() if isinstance(o, BNode)} + assert not (referenced - defined), "dangling blank node reference" + + +def test_collection_free_serializer_is_faithful_for_every_sharing_case() -> None: + """The fallback must be correct on its own, since it is what guarantees the round trip.""" + import io + + from diffable_rdf.turtle import _NoCollectionTurtleSerializer + + for name, body in sorted(SHARING_CASES.items()): + graph = Graph() + graph.parse(data=PREAMBLE + body, format="turtle") + + buffer = io.BytesIO() + _NoCollectionTurtleSerializer(graph).serialize(buffer, encoding="utf-8") + reparsed = Graph() + reparsed.parse(data=buffer.getvalue().decode("utf-8"), format="turtle") + + assert len(reparsed) == len(graph), f"{name}: triple count changed" + assert isomorphic(reparsed, graph), f"{name}: not isomorphic" + assert "( " not in buffer.getvalue().decode("utf-8"), f"{name}: used collection syntax" From 6efdc7aa7b24c6d303b2601eb6abe733b2ecd346 Mon Sep 17 00:00:00 2001 From: jdsika Date: Wed, 5 Aug 2026 07:46:51 +0200 Subject: [PATCH 2/2] chore: release 0.0.2 Contains the collection round-trip fix, so a consumer can pin a released version rather than depending on unreleased source. Consumers that canonicalize RDF containing rdf:List structures should upgrade: 0.0.1 could silently drop or duplicate collections. Signed-off-by: jdsika --- src/diffable_rdf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/diffable_rdf/__init__.py b/src/diffable_rdf/__init__.py index 0a966e6..69e057b 100644 --- a/src/diffable_rdf/__init__.py +++ b/src/diffable_rdf/__init__.py @@ -21,4 +21,4 @@ "__version__", ] -__version__ = "0.0.1" +__version__ = "0.0.2"