trrack 0.0.1a1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trrack-0.0.1a1/.gitignore +39 -0
- trrack-0.0.1a1/PKG-INFO +22 -0
- trrack-0.0.1a1/README.md +3 -0
- trrack-0.0.1a1/pyproject.toml +30 -0
- trrack-0.0.1a1/src/trrack/__init__.py +15 -0
- trrack-0.0.1a1/src/trrack/graph.py +165 -0
- trrack-0.0.1a1/src/trrack/persist.py +74 -0
- trrack-0.0.1a1/tests/test_graph.py +147 -0
- trrack-0.0.1a1/tests/test_persist.py +30 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
wheels/
|
|
9
|
+
|
|
10
|
+
# uv / virtual environments
|
|
11
|
+
.venv/
|
|
12
|
+
|
|
13
|
+
# Test / coverage / type caches
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.mypy_cache/
|
|
16
|
+
.ruff_cache/
|
|
17
|
+
.coverage
|
|
18
|
+
htmlcov/
|
|
19
|
+
|
|
20
|
+
# Node / pnpm
|
|
21
|
+
node_modules/
|
|
22
|
+
*.tsbuildinfo
|
|
23
|
+
npm-debug.log*
|
|
24
|
+
pnpm-debug.log*
|
|
25
|
+
|
|
26
|
+
# Built frontend bundle (shipped in the wheel via hatch artifacts, not committed)
|
|
27
|
+
packages/trrack-widget/src/trrack_widget/static/
|
|
28
|
+
|
|
29
|
+
# marimo generated session snapshots / layouts
|
|
30
|
+
__marimo__/
|
|
31
|
+
|
|
32
|
+
# Editors / OS
|
|
33
|
+
.vscode/
|
|
34
|
+
.idea/
|
|
35
|
+
.DS_Store
|
|
36
|
+
*.swp
|
|
37
|
+
|
|
38
|
+
# Agent working dir
|
|
39
|
+
.context/
|
trrack-0.0.1a1/PKG-INFO
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trrack
|
|
3
|
+
Version: 0.0.1a1
|
|
4
|
+
Summary: Branching tree-based provenance tracking for Python.
|
|
5
|
+
Project-URL: Homepage, https://github.com/kirangadhave/trrackpy
|
|
6
|
+
Project-URL: Repository, https://github.com/kirangadhave/trrackpy
|
|
7
|
+
Author: Kiran Gadhave
|
|
8
|
+
License-Expression: BSD-3-Clause
|
|
9
|
+
Keywords: history,provenance,time-travel,trrack,undo
|
|
10
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# trrack
|
|
21
|
+
|
|
22
|
+
Branching tree-based provenance tracking for Python. Pure model, no UI deps.
|
trrack-0.0.1a1/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "trrack"
|
|
7
|
+
version = "0.0.1a1"
|
|
8
|
+
description = "Branching tree-based provenance tracking for Python."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = []
|
|
12
|
+
license = "BSD-3-Clause"
|
|
13
|
+
authors = [{ name = "Kiran Gadhave" }]
|
|
14
|
+
keywords = ["provenance", "history", "undo", "time-travel", "trrack"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"License :: OSI Approved :: BSD License",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/kirangadhave/trrackpy"
|
|
27
|
+
Repository = "https://github.com/kirangadhave/trrackpy"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/trrack"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Branching tree-based provenance tracking for Python.
|
|
2
|
+
|
|
3
|
+
Pure model with no UI dependencies: a :class:`ProvenanceGraph` of full-state
|
|
4
|
+
snapshots, plus an optional :class:`Store` seam for persistence.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import version
|
|
10
|
+
|
|
11
|
+
from .graph import Node, ProvenanceGraph
|
|
12
|
+
from .persist import JsonFileStore, Store
|
|
13
|
+
|
|
14
|
+
__all__ = ["JsonFileStore", "Node", "ProvenanceGraph", "Store"]
|
|
15
|
+
__version__ = version("trrack")
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""The provenance graph model: full-state snapshot nodes and navigation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .persist import read_json, write_json_atomic
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Node:
|
|
17
|
+
"""A single full-state snapshot and its position in the graph."""
|
|
18
|
+
|
|
19
|
+
id: str
|
|
20
|
+
parent: str | None
|
|
21
|
+
children: list[str]
|
|
22
|
+
created_at: str
|
|
23
|
+
label: str
|
|
24
|
+
state: dict
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProvenanceGraph:
|
|
28
|
+
"""A branching graph of full-state snapshots.
|
|
29
|
+
|
|
30
|
+
Pure model: no widget or marimo imports. Timestamps are supplied by the
|
|
31
|
+
caller via ``now`` so the model performs no I/O and stays deterministic.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
root_state: dict,
|
|
37
|
+
*,
|
|
38
|
+
now: str,
|
|
39
|
+
root_id: str | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Create the graph with a single root holding ``root_state``."""
|
|
42
|
+
rid = root_id or uuid.uuid4().hex
|
|
43
|
+
root = Node(
|
|
44
|
+
id=rid,
|
|
45
|
+
parent=None,
|
|
46
|
+
children=[],
|
|
47
|
+
created_at=now,
|
|
48
|
+
label="initial",
|
|
49
|
+
state=dict(root_state),
|
|
50
|
+
)
|
|
51
|
+
self.nodes: dict[str, Node] = {rid: root}
|
|
52
|
+
self.root_id = rid
|
|
53
|
+
self.current_id = rid
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def current(self) -> Node:
|
|
57
|
+
"""The node the graph is currently positioned at."""
|
|
58
|
+
return self.nodes[self.current_id]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def root(self) -> Node:
|
|
62
|
+
"""The root node the graph started from."""
|
|
63
|
+
return self.nodes[self.root_id]
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def is_at_root(self) -> bool:
|
|
67
|
+
"""Whether the current node is the root (nothing to undo)."""
|
|
68
|
+
return self.current.parent is None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def is_at_latest(self) -> bool:
|
|
72
|
+
"""Whether the current node is a leaf (nothing to redo)."""
|
|
73
|
+
return not self.current.children
|
|
74
|
+
|
|
75
|
+
def commit(
|
|
76
|
+
self,
|
|
77
|
+
state: dict,
|
|
78
|
+
*,
|
|
79
|
+
now: str,
|
|
80
|
+
label: str | None = None,
|
|
81
|
+
node_id: str | None = None,
|
|
82
|
+
) -> Node:
|
|
83
|
+
"""Append ``state`` as a child of the current node and move there."""
|
|
84
|
+
nid = node_id or uuid.uuid4().hex
|
|
85
|
+
parent = self.current
|
|
86
|
+
resolved_label = (
|
|
87
|
+
label if label is not None else self._auto_label(parent.state, state)
|
|
88
|
+
)
|
|
89
|
+
node = Node(
|
|
90
|
+
id=nid,
|
|
91
|
+
parent=parent.id,
|
|
92
|
+
children=[],
|
|
93
|
+
created_at=now,
|
|
94
|
+
label=resolved_label,
|
|
95
|
+
state=dict(state),
|
|
96
|
+
)
|
|
97
|
+
self.nodes[nid] = node
|
|
98
|
+
parent.children.append(nid)
|
|
99
|
+
self.current_id = nid
|
|
100
|
+
return node
|
|
101
|
+
|
|
102
|
+
def undo(self) -> Node | None:
|
|
103
|
+
"""Move to the parent of the current node, or ``None`` at the root."""
|
|
104
|
+
node = self.current
|
|
105
|
+
if node.parent is None:
|
|
106
|
+
return None
|
|
107
|
+
self.current_id = node.parent
|
|
108
|
+
return self.current
|
|
109
|
+
|
|
110
|
+
def redo(self) -> Node | None:
|
|
111
|
+
"""Move to the newest child of the current node, or ``None`` at a leaf."""
|
|
112
|
+
node = self.current
|
|
113
|
+
if not node.children:
|
|
114
|
+
return None
|
|
115
|
+
self.current_id = node.children[-1] # newest child (append order)
|
|
116
|
+
return self.current
|
|
117
|
+
|
|
118
|
+
def go_to(self, node_id: str) -> Node:
|
|
119
|
+
"""Jump to ``node_id``; raises ``KeyError`` if it is unknown."""
|
|
120
|
+
if node_id not in self.nodes:
|
|
121
|
+
raise KeyError(node_id)
|
|
122
|
+
self.current_id = node_id
|
|
123
|
+
return self.current
|
|
124
|
+
|
|
125
|
+
def relabel(self, text: str, node_id: str | None = None) -> None:
|
|
126
|
+
"""Set the label of ``node_id`` (default: the current node) to ``text``."""
|
|
127
|
+
self.nodes[node_id or self.current_id].label = text
|
|
128
|
+
|
|
129
|
+
def to_dict(self) -> dict:
|
|
130
|
+
"""Serialize the whole graph to plain JSON-compatible data."""
|
|
131
|
+
return {
|
|
132
|
+
"nodes": {nid: asdict(node) for nid, node in self.nodes.items()},
|
|
133
|
+
"root_id": self.root_id,
|
|
134
|
+
"current_id": self.current_id,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_dict(cls, data: dict) -> ProvenanceGraph:
|
|
139
|
+
"""Reconstruct a graph from :meth:`to_dict` output."""
|
|
140
|
+
tree = cls.__new__(cls)
|
|
141
|
+
tree.nodes = {nid: Node(**nd) for nid, nd in data["nodes"].items()}
|
|
142
|
+
tree.root_id = data["root_id"]
|
|
143
|
+
tree.current_id = data["current_id"]
|
|
144
|
+
return tree
|
|
145
|
+
|
|
146
|
+
def save(self, path: str | os.PathLike[str]) -> None:
|
|
147
|
+
"""Write the serialized graph to ``path`` atomically."""
|
|
148
|
+
write_json_atomic(path, self.to_dict())
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def load(cls, path: str | os.PathLike[str]) -> ProvenanceGraph | None:
|
|
152
|
+
"""Load a graph from ``path``, or return ``None`` if it is absent."""
|
|
153
|
+
data = read_json(path)
|
|
154
|
+
if data is None:
|
|
155
|
+
return None
|
|
156
|
+
return cls.from_dict(data)
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _auto_label(old: dict, new: dict) -> str:
|
|
160
|
+
changes = []
|
|
161
|
+
for key in sorted(set(old) | set(new)):
|
|
162
|
+
before, after = old.get(key), new.get(key)
|
|
163
|
+
if before != after:
|
|
164
|
+
changes.append(f"{key}: {before} → {after}")
|
|
165
|
+
return ", ".join(changes) if changes else "(no change)"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""JSON persistence for a full-snapshot ProvenanceGraph.
|
|
2
|
+
|
|
3
|
+
A ``Store`` is the single extension seam: anything with ``save(dict)`` and
|
|
4
|
+
``load() -> dict | None`` can back persistence, so a future backend (a database,
|
|
5
|
+
browser storage, a remote service) is a drop-in without a plugin registry.
|
|
6
|
+
``JsonFileStore`` is the only implementation today.
|
|
7
|
+
|
|
8
|
+
v1 serializes the entire graph on every save: cost is O(total nodes x snapshot
|
|
9
|
+
size). That is imperceptible at interactive scale (tens-hundreds of small-state
|
|
10
|
+
nodes) and only matters with very large graphs or large per-node state. The
|
|
11
|
+
scaling answer, when needed, is a diff-node model -- store patches between nodes
|
|
12
|
+
with periodic full-state checkpoints, so per-save cost tracks changed state
|
|
13
|
+
rather than total state. Out of scope here; tracked as a future option.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import pathlib
|
|
20
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
import os
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@runtime_checkable
|
|
27
|
+
class Store(Protocol):
|
|
28
|
+
"""A persistence backend for a serialized provenance payload."""
|
|
29
|
+
|
|
30
|
+
def save(self, data: dict) -> None:
|
|
31
|
+
"""Persist ``data``, replacing any previously saved payload."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
def load(self) -> dict | None:
|
|
35
|
+
"""Return the saved payload, or ``None`` if nothing is stored."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_json_atomic(path: str | os.PathLike[str], data: dict) -> None:
|
|
40
|
+
"""Write ``data`` as JSON, replacing ``path`` atomically.
|
|
41
|
+
|
|
42
|
+
The write goes to a sibling temp file first and is then moved into place
|
|
43
|
+
with ``Path.replace`` so a crash mid-write can never leave a half-written,
|
|
44
|
+
unparsable file at ``path``.
|
|
45
|
+
"""
|
|
46
|
+
path = pathlib.Path(path)
|
|
47
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
49
|
+
tmp.write_text(json.dumps(data), encoding="utf-8")
|
|
50
|
+
tmp.replace(path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_json(path: str | os.PathLike[str]) -> dict | None:
|
|
54
|
+
"""Return the parsed JSON at ``path``, or ``None`` if it does not exist."""
|
|
55
|
+
path = pathlib.Path(path)
|
|
56
|
+
if not path.exists():
|
|
57
|
+
return None
|
|
58
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class JsonFileStore:
|
|
62
|
+
"""A ``Store`` that round-trips the payload through one JSON file."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, path: str | os.PathLike[str]) -> None:
|
|
65
|
+
"""Back persistence with the JSON file at ``path``."""
|
|
66
|
+
self._path = path
|
|
67
|
+
|
|
68
|
+
def save(self, data: dict) -> None:
|
|
69
|
+
"""Write ``data`` to the file, atomically replacing prior contents."""
|
|
70
|
+
write_json_atomic(self._path, data)
|
|
71
|
+
|
|
72
|
+
def load(self) -> dict | None:
|
|
73
|
+
"""Return the file's parsed payload, or ``None`` if it is absent."""
|
|
74
|
+
return read_json(self._path)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from trrack.graph import Node, ProvenanceGraph
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_root_node_created_from_state():
|
|
9
|
+
tree = ProvenanceGraph({"count": 0}, now="t0", root_id="r")
|
|
10
|
+
root = tree.root
|
|
11
|
+
assert isinstance(root, Node)
|
|
12
|
+
assert root.id == "r"
|
|
13
|
+
assert root.parent is None
|
|
14
|
+
assert root.children == []
|
|
15
|
+
assert root.created_at == "t0"
|
|
16
|
+
assert root.label == "initial"
|
|
17
|
+
assert root.state == {"count": 0}
|
|
18
|
+
assert tree.current is root
|
|
19
|
+
assert tree.current_id == "r"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_commit_appends_child_and_advances_current():
|
|
23
|
+
tree = ProvenanceGraph({"count": 0}, now="t0", root_id="r")
|
|
24
|
+
node = tree.commit({"count": 1}, now="t1", node_id="a")
|
|
25
|
+
assert node.id == "a"
|
|
26
|
+
assert node.parent == "r"
|
|
27
|
+
assert node.state == {"count": 1}
|
|
28
|
+
assert tree.root.children == ["a"]
|
|
29
|
+
assert tree.current is node
|
|
30
|
+
assert node.label == "count: 0 → 1"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_auto_label_multiple_traits_sorted():
|
|
34
|
+
tree = ProvenanceGraph({"a": 1, "b": 1}, now="t0", root_id="r")
|
|
35
|
+
node = tree.commit({"a": 2, "b": 3}, now="t1")
|
|
36
|
+
assert node.label == "a: 1 → 2, b: 1 → 3"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_explicit_label_overrides_auto():
|
|
40
|
+
tree = ProvenanceGraph({"count": 0}, now="t0", root_id="r")
|
|
41
|
+
node = tree.commit({"count": 1}, now="t1", label="mine")
|
|
42
|
+
assert node.label == "mine"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_undo_moves_to_parent_and_stops_at_root():
|
|
46
|
+
tree = ProvenanceGraph({"count": 0}, now="t0", root_id="r")
|
|
47
|
+
tree.commit({"count": 1}, now="t1", node_id="a")
|
|
48
|
+
parent = tree.undo()
|
|
49
|
+
assert parent is not None
|
|
50
|
+
assert parent.id == "r"
|
|
51
|
+
assert tree.undo() is None
|
|
52
|
+
assert tree.current_id == "r"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_redo_follows_newest_child():
|
|
56
|
+
tree = ProvenanceGraph({"count": 0}, now="t0", root_id="r")
|
|
57
|
+
tree.commit({"count": 1}, now="t1", node_id="a")
|
|
58
|
+
tree.undo()
|
|
59
|
+
tree.commit({"count": 2}, now="t2", node_id="b") # sibling of "a"
|
|
60
|
+
tree.undo() # back to root
|
|
61
|
+
newest = tree.redo()
|
|
62
|
+
assert newest is not None
|
|
63
|
+
assert newest.id == "b" # newest child wins
|
|
64
|
+
assert tree.redo() is None # "b" is a leaf
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_commit_after_undo_creates_branch():
|
|
68
|
+
tree = ProvenanceGraph({"count": 0}, now="t0", root_id="r")
|
|
69
|
+
tree.commit({"count": 1}, now="t1", node_id="a")
|
|
70
|
+
tree.undo()
|
|
71
|
+
tree.commit({"count": 9}, now="t2", node_id="b")
|
|
72
|
+
assert sorted(tree.root.children) == ["a", "b"]
|
|
73
|
+
assert tree.nodes["a"].parent == "r"
|
|
74
|
+
assert tree.nodes["b"].parent == "r"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_go_to_sets_current():
|
|
78
|
+
tree = ProvenanceGraph({"count": 0}, now="t0", root_id="r")
|
|
79
|
+
tree.commit({"count": 1}, now="t1", node_id="a")
|
|
80
|
+
assert tree.go_to("r").id == "r"
|
|
81
|
+
assert tree.current_id == "r"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_go_to_unknown_raises():
|
|
85
|
+
tree = ProvenanceGraph({"count": 0}, now="t0", root_id="r")
|
|
86
|
+
with pytest.raises(KeyError):
|
|
87
|
+
tree.go_to("nope")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_relabel_current_and_explicit():
|
|
91
|
+
tree = ProvenanceGraph({"count": 0}, now="t0", root_id="r")
|
|
92
|
+
tree.commit({"count": 1}, now="t1", node_id="a")
|
|
93
|
+
tree.relabel("renamed")
|
|
94
|
+
assert tree.nodes["a"].label == "renamed"
|
|
95
|
+
tree.relabel("root!", node_id="r")
|
|
96
|
+
assert tree.nodes["r"].label == "root!"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_to_dict_from_dict_round_trip():
|
|
100
|
+
tree = ProvenanceGraph({"count": 0}, now="t0", root_id="r")
|
|
101
|
+
tree.commit({"count": 1}, now="t1", node_id="a")
|
|
102
|
+
tree.undo()
|
|
103
|
+
tree.commit({"count": 9}, now="t2", node_id="b")
|
|
104
|
+
tree.go_to("a")
|
|
105
|
+
|
|
106
|
+
d = tree.to_dict()
|
|
107
|
+
assert d["root_id"] == "r"
|
|
108
|
+
assert d["current_id"] == "a"
|
|
109
|
+
assert set(d["nodes"]) == {"r", "a", "b"}
|
|
110
|
+
|
|
111
|
+
restored = ProvenanceGraph.from_dict(d)
|
|
112
|
+
assert restored.root_id == "r"
|
|
113
|
+
assert restored.current_id == "a"
|
|
114
|
+
assert sorted(restored.root.children) == ["a", "b"]
|
|
115
|
+
assert restored.nodes["b"].state == {"count": 9}
|
|
116
|
+
assert restored.to_dict() == d
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_is_at_root_and_is_at_latest():
|
|
120
|
+
tree = ProvenanceGraph({"count": 0}, now="t0", root_id="r")
|
|
121
|
+
assert tree.is_at_root
|
|
122
|
+
assert tree.is_at_latest
|
|
123
|
+
|
|
124
|
+
tree.commit({"count": 1}, now="t1", node_id="a")
|
|
125
|
+
assert not tree.is_at_root
|
|
126
|
+
assert tree.is_at_latest
|
|
127
|
+
|
|
128
|
+
tree.undo()
|
|
129
|
+
assert tree.is_at_root
|
|
130
|
+
assert not tree.is_at_latest
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_save_and_load_round_trip(tmp_path):
|
|
134
|
+
tree = ProvenanceGraph({"count": 0}, now="t0", root_id="r")
|
|
135
|
+
tree.commit({"count": 1}, now="t1", node_id="a")
|
|
136
|
+
tree.undo()
|
|
137
|
+
path = tmp_path / "graph.json"
|
|
138
|
+
|
|
139
|
+
tree.save(path)
|
|
140
|
+
restored = ProvenanceGraph.load(path)
|
|
141
|
+
|
|
142
|
+
assert restored is not None
|
|
143
|
+
assert restored.to_dict() == tree.to_dict()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_load_missing_returns_none(tmp_path):
|
|
147
|
+
assert ProvenanceGraph.load(tmp_path / "absent.json") is None
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from trrack.persist import JsonFileStore, read_json, write_json_atomic
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_read_json_missing_returns_none(tmp_path):
|
|
9
|
+
assert read_json(tmp_path / "absent.json") is None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_write_then_read_round_trip(tmp_path):
|
|
13
|
+
path = tmp_path / "state.json"
|
|
14
|
+
write_json_atomic(path, {"count": 1})
|
|
15
|
+
assert read_json(path) == {"count": 1}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_overwrite_leaves_valid_json_and_no_temp_file(tmp_path):
|
|
19
|
+
path = tmp_path / "state.json"
|
|
20
|
+
write_json_atomic(path, {"count": 1})
|
|
21
|
+
write_json_atomic(path, {"count": 2})
|
|
22
|
+
assert json.loads(path.read_text(encoding="utf-8")) == {"count": 2}
|
|
23
|
+
assert list(path.parent.iterdir()) == [path]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_json_file_store_round_trip(tmp_path):
|
|
27
|
+
store = JsonFileStore(tmp_path / "state.json")
|
|
28
|
+
assert store.load() is None
|
|
29
|
+
store.save({"count": 7})
|
|
30
|
+
assert store.load() == {"count": 7}
|