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.
@@ -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/
@@ -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.
@@ -0,0 +1,3 @@
1
+ # trrack
2
+
3
+ Branching tree-based provenance tracking for Python. Pure model, no UI deps.
@@ -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}