datavault-cli 1.0.0__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.
row_tracker.py ADDED
@@ -0,0 +1,201 @@
1
+ import csv
2
+ import hashlib
3
+ import json
4
+ import os
5
+
6
+ ROWLOG_DIR = ".datavault/rowlogs"
7
+ HISTORY_FILE = ".datavault/history.json"
8
+
9
+
10
+ def hash_row(row_dict):
11
+ row_str = json.dumps(row_dict, sort_keys=True, ensure_ascii=False)
12
+ return hashlib.sha256(row_str.encode()).hexdigest()[:16]
13
+
14
+
15
+ def parse_csv_rows(filepath):
16
+ try:
17
+ with open(filepath, "r", encoding="utf-8", newline="") as f:
18
+ reader = csv.DictReader(f)
19
+ headers = reader.fieldnames or []
20
+ rows = [dict(row) for row in reader]
21
+ return headers, rows
22
+ except Exception:
23
+ return [], []
24
+
25
+
26
+ def build_row_snapshot(filepath):
27
+ headers, rows = parse_csv_rows(filepath)
28
+ if not rows:
29
+ return {}, headers
30
+ snapshot = {}
31
+ for i, row in enumerate(rows):
32
+ row_hash = hash_row(row)
33
+ snapshot[row_hash] = {"position": i, "data": row, "hash": row_hash}
34
+ return snapshot, headers
35
+
36
+
37
+ def save_row_snapshot(filename, version_id, snapshot, headers):
38
+ os.makedirs(ROWLOG_DIR, exist_ok=True)
39
+ safe_name = filename.replace("/", "_").replace("\\", "_")
40
+ path = os.path.join(ROWLOG_DIR, f"{safe_name}__{version_id}.json")
41
+ with open(path, "w", encoding="utf-8") as f:
42
+ json.dump({"headers": headers, "rows": snapshot}, f, indent=2)
43
+
44
+
45
+ def load_row_snapshot(filename, version_id):
46
+ safe_name = filename.replace("/", "_").replace("\\", "_")
47
+ path = os.path.join(ROWLOG_DIR, f"{safe_name}__{version_id}.json")
48
+ if not os.path.exists(path):
49
+ return None, None
50
+ with open(path, "r", encoding="utf-8") as f:
51
+ data = json.load(f)
52
+ return data.get("rows", {}), data.get("headers", [])
53
+
54
+
55
+ def snapshot_on_commit(filepath, version_id):
56
+ if not filepath.lower().endswith(".csv"):
57
+ return False
58
+ filename = os.path.basename(filepath)
59
+ snapshot, headers = build_row_snapshot(filepath)
60
+ if snapshot:
61
+ save_row_snapshot(filename, version_id, snapshot, headers)
62
+ return True
63
+ return False
64
+
65
+
66
+ def row_diff(filepath, version_a, version_b):
67
+ filename = os.path.basename(filepath)
68
+ snap_a, headers = load_row_snapshot(filename, version_a)
69
+ snap_b, _ = load_row_snapshot(filename, version_b)
70
+
71
+ if snap_a is None:
72
+ return False, f"No row snapshot for {version_a}. Re-commit to generate one."
73
+ if snap_b is None:
74
+ return False, f"No row snapshot for {version_b}. Re-commit to generate one."
75
+
76
+ hashes_a = set(snap_a.keys())
77
+ hashes_b = set(snap_b.keys())
78
+ added = hashes_b - hashes_a
79
+ deleted = hashes_a - hashes_b
80
+ moved = [(h, snap_a[h]["position"], snap_b[h]["position"])
81
+ for h in hashes_a & hashes_b
82
+ if snap_a[h]["position"] != snap_b[h]["position"]]
83
+
84
+ lines = [
85
+ f"\nRow-level diff: '{filename}' {version_a} → {version_b}",
86
+ "─" * 55,
87
+ f" Rows added: {len(added)}",
88
+ f" Rows deleted: {len(deleted)}",
89
+ f" Rows moved: {len(moved)}",
90
+ f" Unchanged: {len(hashes_a & hashes_b) - len(moved)}",
91
+ ""
92
+ ]
93
+ if added:
94
+ lines.append(" ADDED rows:")
95
+ for h in list(added)[:10]:
96
+ lines.append(f" + {dict(list(snap_b[h]['data'].items())[:4])}")
97
+ if len(added) > 10:
98
+ lines.append(f" ... and {len(added)-10} more")
99
+ if deleted:
100
+ lines.append("\n DELETED rows:")
101
+ for h in list(deleted)[:10]:
102
+ lines.append(f" - {dict(list(snap_a[h]['data'].items())[:4])}")
103
+ if len(deleted) > 10:
104
+ lines.append(f" ... and {len(deleted)-10} more")
105
+ if moved:
106
+ lines.append("\n MOVED rows:")
107
+ for h, pos_a, pos_b in moved[:5]:
108
+ lines.append(f" ~ row {pos_a+1} → row {pos_b+1}: {dict(list(snap_a[h]['data'].items())[:2])}")
109
+
110
+ return True, "\n".join(lines)
111
+
112
+
113
+ def row_history(filepath, row_identifier_col, row_identifier_val):
114
+ filename = os.path.basename(filepath)
115
+ if not os.path.exists(HISTORY_FILE):
116
+ return False, "No DataVault project found."
117
+
118
+ with open(HISTORY_FILE, "r") as f:
119
+ history = json.load(f)
120
+
121
+ if filename not in history.get("files", {}):
122
+ return False, f"'{filename}' is not tracked."
123
+
124
+ versions = history["files"][filename]["versions"]
125
+ timeline = []
126
+
127
+ for v in versions:
128
+ snapshot, _ = load_row_snapshot(filename, v["version_id"])
129
+ if snapshot is None:
130
+ continue
131
+ found = next(
132
+ (row_data for row_data in snapshot.values()
133
+ if row_data["data"].get(row_identifier_col) == str(row_identifier_val)),
134
+ None
135
+ )
136
+ timeline.append({"version_id": v["version_id"], "timestamp": v["timestamp"], "message": v["message"], "row": found})
137
+
138
+ if not any(t["row"] for t in timeline):
139
+ return False, f"No row found where {row_identifier_col}={row_identifier_val}\nCheck your CSV headers."
140
+
141
+ lines = [f"\nRow history: {filename} where {row_identifier_col}={row_identifier_val}", "─" * 55]
142
+ prev_row = None
143
+
144
+ for entry in timeline:
145
+ ts = entry["timestamp"][:19].replace("T", " ")
146
+ row = entry["row"]
147
+ lines.append(f"\n {entry['version_id']} | {ts}")
148
+ if row is None:
149
+ lines.append(f" ✗ Row not present in this version")
150
+ else:
151
+ lines.append(f" Message: {entry['message']}")
152
+ if prev_row and prev_row != row["data"]:
153
+ changed = {k: f"{prev_row.get(k,'—')} → v" for k, v in row["data"].items() if row["data"].get(k) != prev_row.get(k)}
154
+ lines.append(f" Changes: {changed}" if changed else f" Position moved: row {row['position']+1}")
155
+ elif prev_row is None:
156
+ lines.append(f" First appearance: {row['data']}")
157
+ prev_row = row["data"]
158
+
159
+ return True, "\n".join(lines)
160
+
161
+
162
+ def row_stats(filepath):
163
+ filename = os.path.basename(filepath)
164
+ if not os.path.exists(HISTORY_FILE):
165
+ return False, "No DataVault project found."
166
+
167
+ with open(HISTORY_FILE, "r") as f:
168
+ history = json.load(f)
169
+
170
+ if filename not in history.get("files", {}):
171
+ return False, f"'{filename}' is not tracked."
172
+
173
+ versions = history["files"][filename]["versions"]
174
+ lines = [f"\nRow statistics: '{filename}'\n" + "─"*50]
175
+ prev_snapshot = None
176
+ total_added = 0
177
+ total_deleted = 0
178
+
179
+ for v in versions:
180
+ snapshot, _ = load_row_snapshot(filename, v["version_id"])
181
+ if snapshot is None:
182
+ lines.append(f"\n {v['version_id']}: no row snapshot available")
183
+ continue
184
+ ts = v["timestamp"][:19].replace("T", " ")
185
+ if prev_snapshot is not None:
186
+ added = len(set(snapshot.keys()) - set(prev_snapshot.keys()))
187
+ deleted = len(set(prev_snapshot.keys()) - set(snapshot.keys()))
188
+ total_added += added
189
+ total_deleted += deleted
190
+ change_str = f"+{added} added, -{deleted} deleted"
191
+ else:
192
+ change_str = "initial snapshot"
193
+ lines.append(f"\n {v['version_id']} | {ts}")
194
+ lines.append(f" {len(snapshot)} rows total | {change_str}")
195
+ lines.append(f" \"{v['message']}\"")
196
+ prev_snapshot = snapshot
197
+
198
+ lines.append(f"\n{'─'*50}")
199
+ lines.append(f" Total rows added across all versions: {total_added}")
200
+ lines.append(f" Total rows deleted across all versions: {total_deleted}")
201
+ return True, "\n".join(lines)
signing.py ADDED
@@ -0,0 +1,166 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ from datetime import datetime
5
+
6
+ HISTORY_FILE = ".datavault/history.json"
7
+ SIGNING_FILE = ".datavault/signatures.json"
8
+ ROLES = ["reviewer", "approver", "owner", "witness"]
9
+
10
+
11
+ def sign_version(filepath, version_id, signer_name, role="reviewer"):
12
+ if not os.path.exists(HISTORY_FILE):
13
+ return False, "No DataVault project found."
14
+ if role not in ROLES:
15
+ return False, f"Invalid role '{role}'. Choose from: {', '.join(ROLES)}"
16
+
17
+ with open(HISTORY_FILE, "r") as f:
18
+ history = json.load(f)
19
+
20
+ filename = os.path.basename(filepath)
21
+ if filename not in history.get("files", {}):
22
+ return False, f"'{filename}' is not tracked."
23
+
24
+ versions = history["files"][filename]["versions"]
25
+ target = next((v for v in versions if v["version_id"] == version_id), None)
26
+ if not target:
27
+ available = ", ".join(v["version_id"] for v in versions)
28
+ return False, f"Version '{version_id}' not found. Available: {available}"
29
+
30
+ signed_at = datetime.now().isoformat()
31
+ signature_input = f"{signer_name}|{role}|{target['hash']}|{version_id}|{signed_at}"
32
+ signature_hash = hashlib.sha256(signature_input.encode()).hexdigest()[:32]
33
+
34
+ sigs = _load_signatures()
35
+ if filename not in sigs:
36
+ sigs[filename] = {}
37
+ if version_id not in sigs[filename]:
38
+ sigs[filename][version_id] = []
39
+
40
+ if any(s["signer"] == signer_name and s["role"] == role for s in sigs[filename][version_id]):
41
+ return False, f"'{signer_name}' has already signed {version_id} as {role}."
42
+
43
+ sigs[filename][version_id].append({
44
+ "signer": signer_name,
45
+ "role": role,
46
+ "signed_at": signed_at,
47
+ "file_hash": target["hash"],
48
+ "signature_hash": signature_hash,
49
+ "version_id": version_id
50
+ })
51
+ _save_signatures(sigs)
52
+
53
+ return True, (
54
+ f"Signed '{filename}' {version_id}\n"
55
+ f" Signer: {signer_name} ({role})\n"
56
+ f" File hash: {target['hash'][:24]}…\n"
57
+ f" Sig hash: {signature_hash[:24]}…\n"
58
+ f" At: {signed_at[:19].replace('T',' ')}\n"
59
+ f"\n Signature is cryptographically tied to {version_id}.\n"
60
+ f" If the file changes, this signature becomes invalid."
61
+ )
62
+
63
+
64
+ def verify_signatures(filepath, version_id):
65
+ if not os.path.exists(HISTORY_FILE):
66
+ return False, "No DataVault project found."
67
+
68
+ with open(HISTORY_FILE, "r") as f:
69
+ history = json.load(f)
70
+
71
+ filename = os.path.basename(filepath)
72
+ if filename not in history.get("files", {}):
73
+ return False, f"'{filename}' is not tracked."
74
+
75
+ versions = history["files"][filename]["versions"]
76
+ target = next((v for v in versions if v["version_id"] == version_id), None)
77
+ if not target:
78
+ return False, f"Version '{version_id}' not found."
79
+
80
+ sigs = _load_signatures()
81
+ version_sigs = sigs.get(filename, {}).get(version_id, [])
82
+ if not version_sigs:
83
+ return True, f"No signatures for {version_id} yet."
84
+
85
+ lines = [f"\nSignatures for '{filename}' {version_id}:\n" + "─"*55]
86
+ all_valid = True
87
+
88
+ for sig in version_sigs:
89
+ expected_input = f"{sig['signer']}|{sig['role']}|{sig['file_hash']}|{sig['version_id']}|{sig['signed_at']}"
90
+ expected_hash = hashlib.sha256(expected_input.encode()).hexdigest()[:32]
91
+ sig_valid = (expected_hash == sig["signature_hash"])
92
+ hash_valid = (sig["file_hash"] == target["hash"])
93
+
94
+ if sig_valid and hash_valid:
95
+ ts = sig["signed_at"][:19].replace("T", " ")
96
+ lines.append(f"\n ✓ {sig['signer']} ({sig['role']})\n Signed: {ts}\n Sig: {sig['signature_hash'][:24]}…")
97
+ elif not hash_valid:
98
+ all_valid = False
99
+ lines.append(f"\n ✗ {sig['signer']} ({sig['role']}) — INVALID\n File was modified after signing.\n Signed hash: {sig['file_hash'][:24]}…\n Current hash: {target['hash'][:24]}…")
100
+ else:
101
+ all_valid = False
102
+ lines.append(f"\n ✗ {sig['signer']} ({sig['role']}) — SIGNATURE TAMPERED\n The signature record itself was modified.")
103
+
104
+ lines.append(f"\n{'─'*55}")
105
+ lines.append(f" {'✓' if all_valid else '✗'} {'All' if all_valid else 'Some'} {len(version_sigs)} signature(s) {'valid' if all_valid else 'invalid — see above'}")
106
+ return all_valid, "\n".join(lines)
107
+
108
+
109
+ def approval_status(filepath, version_id, require_approver=True, require_reviewer=True):
110
+ if not os.path.exists(HISTORY_FILE):
111
+ return False, "No DataVault project found."
112
+
113
+ filename = os.path.basename(filepath)
114
+ sigs = _load_signatures()
115
+ version_sigs = sigs.get(filename, {}).get(version_id, [])
116
+
117
+ has_reviewer = any(s["role"] == "reviewer" for s in version_sigs)
118
+ has_approver = any(s["role"] == "approver" for s in version_sigs)
119
+ has_owner = any(s["role"] == "owner" for s in version_sigs)
120
+
121
+ missing = []
122
+ if require_reviewer and not has_reviewer: missing.append("reviewer")
123
+ if require_approver and not has_approver: missing.append("approver")
124
+ approved = len(missing) == 0 and len(version_sigs) > 0
125
+
126
+ lines = [f"\nApproval status: '{filename}' {version_id}\n" + "─"*55]
127
+ lines.append(f"\n Reviewer: {'✓' if has_reviewer else '✗ missing'}")
128
+ lines.append(f" Approver: {'✓' if has_approver else '✗ missing'}")
129
+ lines.append(f" Owner: {'✓' if has_owner else '— (optional)'}")
130
+ lines.append(f"\n Total signatures: {len(version_sigs)}")
131
+ if approved:
132
+ lines.append(f"\n ✓ APPROVED — ready for use in AI training")
133
+ else:
134
+ lines.append(f"\n ✗ NOT APPROVED — missing: {', '.join(missing)}")
135
+ lines.append(f" Run: python datavault.py sign {filepath} {version_id} <name> <role>")
136
+
137
+ return approved, "\n".join(lines)
138
+
139
+
140
+ def list_all_signatures(filepath):
141
+ filename = os.path.basename(filepath)
142
+ sigs = _load_signatures()
143
+ file_sigs = sigs.get(filename, {})
144
+ if not file_sigs:
145
+ return True, f"No signatures found for '{filename}'."
146
+
147
+ lines = [f"\nAll signatures for '{filename}':\n" + "─"*55]
148
+ for version_id, version_sigs in file_sigs.items():
149
+ lines.append(f"\n {version_id} ({len(version_sigs)} signature(s)):")
150
+ for sig in version_sigs:
151
+ ts = sig["signed_at"][:19].replace("T", " ")
152
+ lines.append(f" • {sig['signer']} ({sig['role']}) — {ts}")
153
+ return True, "\n".join(lines)
154
+
155
+
156
+ def _load_signatures():
157
+ if os.path.exists(SIGNING_FILE):
158
+ with open(SIGNING_FILE, "r") as f:
159
+ return json.load(f)
160
+ return {}
161
+
162
+
163
+ def _save_signatures(data):
164
+ os.makedirs(os.path.dirname(SIGNING_FILE), exist_ok=True)
165
+ with open(SIGNING_FILE, "w") as f:
166
+ json.dump(data, f, indent=2)