trrack 0.0.1a1__py3-none-any.whl
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/__init__.py +15 -0
- trrack/graph.py +165 -0
- trrack/persist.py +74 -0
- trrack-0.0.1a1.dist-info/METADATA +22 -0
- trrack-0.0.1a1.dist-info/RECORD +6 -0
- trrack-0.0.1a1.dist-info/WHEEL +4 -0
trrack/__init__.py
ADDED
|
@@ -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")
|
trrack/graph.py
ADDED
|
@@ -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)"
|
trrack/persist.py
ADDED
|
@@ -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,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,6 @@
|
|
|
1
|
+
trrack/__init__.py,sha256=VMJGW8zTL4AHNdars1qVsE6as0ZrwS_ntF7fVNQlZHI,460
|
|
2
|
+
trrack/graph.py,sha256=Hl2--xPmXRZRpyOVi6G2P8dTYi4s1PfdZQn_qSYtt7M,5124
|
|
3
|
+
trrack/persist.py,sha256=S6Dfg5Vd0dQu99cQJo-BloMk0sWdHYOldDeSGyl7418,2704
|
|
4
|
+
trrack-0.0.1a1.dist-info/METADATA,sha256=PKcxj6m7k_LxQv-YweoGX8B6qfAP-gS4_WB42Bop-Nc,843
|
|
5
|
+
trrack-0.0.1a1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
6
|
+
trrack-0.0.1a1.dist-info/RECORD,,
|