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.
vault_core.py ADDED
@@ -0,0 +1,424 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import shutil
5
+ from datetime import datetime
6
+
7
+ VAULT_DIR = ".datavault"
8
+ VERSIONS_DIR = ".datavault/versions"
9
+ HISTORY_FILE = ".datavault/history.json"
10
+ META_FILE = ".datavault/meta.json"
11
+
12
+
13
+ def hash_file(filepath):
14
+ sha256 = hashlib.sha256()
15
+ try:
16
+ with open(filepath, "rb") as f:
17
+ while chunk := f.read(65536):
18
+ sha256.update(chunk)
19
+ return sha256.hexdigest()
20
+ except FileNotFoundError:
21
+ return None
22
+
23
+
24
+ def hash_string(text):
25
+ return hashlib.sha256(text.encode()).hexdigest()[:12]
26
+
27
+
28
+ def init_project(project_name):
29
+ if os.path.exists(VAULT_DIR):
30
+ return False, "Project already initialized."
31
+ os.makedirs(VERSIONS_DIR)
32
+ meta = {
33
+ "project_name": project_name,
34
+ "created_at": datetime.now().isoformat(),
35
+ "created_by": os.getenv("USERNAME") or os.getenv("USER") or "unknown"
36
+ }
37
+ _write_json(META_FILE, meta)
38
+ _write_json(HISTORY_FILE, {"files": {}})
39
+ return True, f"Initialized DataVault project '{project_name}'"
40
+
41
+
42
+ def add_file(filepath, message):
43
+ _check_initialized()
44
+ if not os.path.exists(filepath):
45
+ return False, f"File not found: {filepath}"
46
+ history = _read_history()
47
+ filename = os.path.basename(filepath)
48
+ if filename in history["files"]:
49
+ return False, f"'{filename}' is already tracked. Use 'commit' to log changes."
50
+ file_hash = hash_file(filepath)
51
+ version_id = "v1"
52
+ version_path = _version_path(filename, version_id)
53
+ shutil.copy2(filepath, version_path)
54
+ history["files"][filename] = {
55
+ "versions": [{
56
+ "version_id": version_id,
57
+ "hash": file_hash,
58
+ "message": message,
59
+ "timestamp": datetime.now().isoformat(),
60
+ "author": _get_author(),
61
+ "size_bytes": os.path.getsize(filepath)
62
+ }]
63
+ }
64
+ _write_history(history)
65
+ return True, (
66
+ f"Now tracking '{filename}'\n"
67
+ f" Version: {version_id}\n"
68
+ f" Hash: {file_hash[:16]}...\n"
69
+ f" Message: {message}"
70
+ )
71
+
72
+
73
+ def commit_file(filepath, message):
74
+ _check_initialized()
75
+ if not os.path.exists(filepath):
76
+ return False, f"File not found: {filepath}"
77
+ history = _read_history()
78
+ filename = os.path.basename(filepath)
79
+ if filename not in history["files"]:
80
+ return False, f"'{filename}' is not tracked yet. Use 'add' first."
81
+ versions = history["files"][filename]["versions"]
82
+ last_version = versions[-1]
83
+ current_hash = hash_file(filepath)
84
+ if current_hash == last_version["hash"]:
85
+ return False, f"No changes detected in '{filename}'. File is identical to last version."
86
+ version_id = f"v{len(versions) + 1}"
87
+ version_path = _version_path(filename, version_id)
88
+ shutil.copy2(filepath, version_path)
89
+ versions.append({
90
+ "version_id": version_id,
91
+ "hash": current_hash,
92
+ "message": message,
93
+ "timestamp": datetime.now().isoformat(),
94
+ "author": _get_author(),
95
+ "size_bytes": os.path.getsize(filepath),
96
+ "prev_hash": last_version["hash"]
97
+ })
98
+ _write_history(history)
99
+ return True, (
100
+ f"Committed new version of '{filename}'\n"
101
+ f" Version: {version_id}\n"
102
+ f" Hash: {current_hash[:16]}...\n"
103
+ f" Previous: {last_version['hash'][:16]}...\n"
104
+ f" Message: {message}"
105
+ )
106
+
107
+
108
+ def get_log(filepath):
109
+ _check_initialized()
110
+ history = _read_history()
111
+ filename = os.path.basename(filepath)
112
+ if filename not in history["files"]:
113
+ return False, f"'{filename}' is not tracked."
114
+ versions = history["files"][filename]["versions"]
115
+ lines = [f"\nHistory of '{filename}' ({len(versions)} version(s))\n" + "─"*50]
116
+ for v in reversed(versions):
117
+ timestamp = v["timestamp"][:19].replace("T", " ")
118
+ lines.append(
119
+ f"\n {v['version_id']} | {timestamp}\n"
120
+ f" Author: {v['author']}\n"
121
+ f" Message: {v['message']}\n"
122
+ f" Hash: {v['hash'][:32]}...\n"
123
+ f" Size: {_format_size(v['size_bytes'])}"
124
+ )
125
+ return True, "\n".join(lines)
126
+
127
+
128
+ def list_files():
129
+ _check_initialized()
130
+ history = _read_history()
131
+ if not history["files"]:
132
+ return True, "No files tracked yet. Use 'add' to start tracking a file."
133
+ lines = ["\nTracked files:\n" + "─"*50]
134
+ for filename, data in history["files"].items():
135
+ versions = data["versions"]
136
+ latest = versions[-1]
137
+ timestamp = latest["timestamp"][:19].replace("T", " ")
138
+ if os.path.exists(filename):
139
+ status = "✓ up to date" if hash_file(filename) == latest["hash"] else "⚠ modified — run commit"
140
+ else:
141
+ status = "✗ file missing"
142
+ lines.append(
143
+ f"\n {filename}\n"
144
+ f" {len(versions)} version(s) | latest: {latest['version_id']} | {timestamp}\n"
145
+ f" Status: {status}"
146
+ )
147
+ return True, "\n".join(lines)
148
+
149
+
150
+ def verify_file(filepath):
151
+ _check_initialized()
152
+ history = _read_history()
153
+ filename = os.path.basename(filepath)
154
+ if filename not in history["files"]:
155
+ return False, f"'{filename}' is not tracked."
156
+ if not os.path.exists(filepath):
157
+ return False, f"File not found: {filepath}"
158
+ latest = history["files"][filename]["versions"][-1]
159
+ current_hash = hash_file(filepath)
160
+ stored_hash = latest["hash"]
161
+ if current_hash == stored_hash:
162
+ return True, (
163
+ f"\n✓ VERIFIED — '{filename}' is authentic\n"
164
+ f" Current hash: {current_hash[:32]}...\n"
165
+ f" Stored hash: {stored_hash[:32]}...\n"
166
+ f" Last commit: {latest['timestamp'][:19].replace('T',' ')}\n"
167
+ f" By: {latest['author']}\n"
168
+ f" Message: {latest['message']}"
169
+ )
170
+ return False, (
171
+ f"\n✗ TAMPERED — '{filename}' has been modified outside DataVault\n"
172
+ f" Current hash: {current_hash[:32]}...\n"
173
+ f" Expected hash: {stored_hash[:32]}...\n"
174
+ f"\n This file cannot be trusted. Use 'checkout' to restore\n"
175
+ f" the last verified version, or 'commit' to log the change."
176
+ )
177
+
178
+
179
+ def checkout_version(filepath, version_id):
180
+ _check_initialized()
181
+ history = _read_history()
182
+ filename = os.path.basename(filepath)
183
+ if filename not in history["files"]:
184
+ return False, f"'{filename}' is not tracked."
185
+ versions = history["files"][filename]["versions"]
186
+ target = next((v for v in versions if v["version_id"] == version_id), None)
187
+ if not target:
188
+ available = ", ".join(v["version_id"] for v in versions)
189
+ return False, f"Version '{version_id}' not found. Available: {available}"
190
+ shutil.copy2(_version_path(filename, version_id), filepath)
191
+ return True, (
192
+ f"Restored '{filename}' to {version_id}\n"
193
+ f" From: {target['timestamp'][:19].replace('T',' ')}\n"
194
+ f" Message: {target['message']}\n"
195
+ f" Hash: {target['hash'][:32]}..."
196
+ )
197
+
198
+
199
+ def diff_versions(filepath, version_a, version_b):
200
+ import difflib
201
+ _check_initialized()
202
+ history = _read_history()
203
+ filename = os.path.basename(filepath)
204
+ if filename not in history["files"]:
205
+ return False, f"'{filename}' is not tracked."
206
+ versions = history["files"][filename]["versions"]
207
+ va = next((v for v in versions if v["version_id"] == version_a), None)
208
+ vb = next((v for v in versions if v["version_id"] == version_b), None)
209
+ if not va:
210
+ return False, f"Version '{version_a}' not found."
211
+ if not vb:
212
+ return False, f"Version '{version_b}' not found."
213
+ try:
214
+ with open(_version_path(filename, version_a), "r", encoding="utf-8") as f:
215
+ lines_a = f.readlines()
216
+ with open(_version_path(filename, version_b), "r", encoding="utf-8") as f:
217
+ lines_b = f.readlines()
218
+ except Exception:
219
+ return False, "Could not read file versions (only text/CSV files supported for diff)"
220
+ diff = list(difflib.unified_diff(
221
+ lines_a, lines_b,
222
+ fromfile=f"{filename} ({version_a})",
223
+ tofile=f"{filename} ({version_b})",
224
+ lineterm=""
225
+ ))
226
+ if not diff:
227
+ return True, f"No differences between {version_a} and {version_b}"
228
+ lines = [f"\nDiff: {filename} {version_a} → {version_b}\n" + "─"*50]
229
+ for line in diff[:100]:
230
+ if line.startswith("+"):
231
+ lines.append(f" + {line[1:]}")
232
+ elif line.startswith("-"):
233
+ lines.append(f" - {line[1:]}")
234
+ elif line.startswith("@@"):
235
+ lines.append(f"\n {line}")
236
+ if len(diff) > 100:
237
+ lines.append(f"\n ... and {len(diff)-100} more lines")
238
+ return True, "\n".join(lines)
239
+
240
+
241
+ def export_pdf(output_path="datavault_report.pdf"):
242
+ _check_initialized()
243
+ history = _read_history()
244
+ meta = _read_json(META_FILE)
245
+ if not history["files"]:
246
+ return False, "No tracked files to export."
247
+ html_path = output_path.replace(".pdf", ".html")
248
+ _generate_html_report(history, meta, html_path)
249
+ return True, (
250
+ f"Report exported to: {html_path}\n"
251
+ f" Open in browser → File → Print → Save as PDF\n"
252
+ f" Files tracked: {len(history['files'])}\n"
253
+ f" Total versions: {sum(len(d['versions']) for d in history['files'].values())}"
254
+ )
255
+
256
+
257
+ def _generate_html_report(history, meta, output_path):
258
+ now = datetime.now().strftime("%Y-%m-%d %H:%M")
259
+ proj_name = meta.get("project_name", "DataVault Project")
260
+ created_at = meta.get("created_at", "")[:19].replace("T", " ")
261
+ created_by = meta.get("created_by", "unknown")
262
+ file_sections = []
263
+ for filename, data in history["files"].items():
264
+ versions = data["versions"]
265
+ rows = []
266
+ for v in reversed(versions):
267
+ timestamp = v["timestamp"][:19].replace("T", " ")
268
+ tags = v.get("tags", [])
269
+ tag_html = "".join(f'<span class="tag">{t["message"]}</span>' for t in tags) if tags else ""
270
+ rows.append(f"""
271
+ <tr>
272
+ <td><strong>{v["version_id"]}</strong></td>
273
+ <td>{timestamp}</td>
274
+ <td>{v.get("author", "—")}</td>
275
+ <td>{v["message"]}</td>
276
+ <td class="hash">{v["hash"][:24]}…</td>
277
+ <td>{_format_size(v["size_bytes"])}</td>
278
+ <td>{tag_html or "—"}</td>
279
+ </tr>""")
280
+ file_sections.append(f"""
281
+ <div class="file-section">
282
+ <div class="file-header">
283
+ <span class="file-name">{filename}</span>
284
+ <span class="version-count">{len(versions)} version(s)</span>
285
+ </div>
286
+ <table>
287
+ <thead>
288
+ <tr>
289
+ <th>Version</th><th>Timestamp</th><th>Author</th>
290
+ <th>Message</th><th>Hash</th><th>Size</th><th>Tags</th>
291
+ </tr>
292
+ </thead>
293
+ <tbody>{"".join(rows)}</tbody>
294
+ </table>
295
+ </div>""")
296
+ html = f"""<!DOCTYPE html>
297
+ <html lang="en">
298
+ <head>
299
+ <meta charset="UTF-8">
300
+ <title>DataVault Report — {proj_name}</title>
301
+ <style>
302
+ *{{margin:0;padding:0;box-sizing:border-box}}
303
+ body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13px;color:#1a1a1a;background:#fff;padding:40px}}
304
+ .header{{border-bottom:2px solid #1a1a1a;padding-bottom:20px;margin-bottom:32px}}
305
+ .header h1{{font-size:22px;font-weight:600;margin-bottom:6px}}
306
+ .meta{{color:#555;font-size:12px;display:flex;gap:24px;margin-top:8px}}
307
+ .file-section{{margin-bottom:36px}}
308
+ .file-header{{display:flex;align-items:baseline;gap:12px;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid #e0e0e0}}
309
+ .file-name{{font-size:15px;font-weight:600}}
310
+ .version-count{{font-size:12px;color:#888}}
311
+ table{{width:100%;border-collapse:collapse;font-size:12px}}
312
+ th{{background:#f5f5f5;padding:8px 10px;text-align:left;font-weight:500;color:#444;border-bottom:1px solid #ddd}}
313
+ td{{padding:7px 10px;border-bottom:1px solid #f0f0f0;vertical-align:top}}
314
+ tr:last-child td{{border-bottom:none}}
315
+ .hash{{font-family:'Courier New',monospace;font-size:11px;color:#666}}
316
+ .tag{{display:inline-block;background:#e8f4e8;color:#2d6a2d;padding:2px 8px;border-radius:10px;font-size:11px;margin:1px 2px;border:1px solid #b8d8b8}}
317
+ .footer{{margin-top:40px;padding-top:16px;border-top:1px solid #e0e0e0;color:#999;font-size:11px;display:flex;justify-content:space-between}}
318
+ .integrity-banner{{background:#e8f4e8;border:1px solid #b8d8b8;border-radius:6px;padding:12px 16px;margin-bottom:28px;font-size:12px;color:#2d6a2d}}
319
+ @media print{{body{{padding:20px}}.file-section{{page-break-inside:avoid}}}}
320
+ </style>
321
+ </head>
322
+ <body>
323
+ <div class="header">
324
+ <h1>DataVault Provenance Report</h1>
325
+ <div style="font-size:15px;color:#444;margin-top:4px">{proj_name}</div>
326
+ <div class="meta">
327
+ <span>Created by: {created_by}</span>
328
+ <span>Project started: {created_at}</span>
329
+ <span>Report generated: {now}</span>
330
+ <span>Files tracked: {len(history["files"])}</span>
331
+ </div>
332
+ </div>
333
+ <div class="integrity-banner">
334
+ ✓ Every version listed below is cryptographically fingerprinted with SHA-256.
335
+ Any modification after commit is detectable via <code>datavault verify</code>.
336
+ </div>
337
+ {"".join(file_sections)}
338
+ <div class="footer">
339
+ <span>Generated by DataVault</span>
340
+ <span>{now}</span>
341
+ </div>
342
+ </body>
343
+ </html>"""
344
+ with open(output_path, "w", encoding="utf-8") as f:
345
+ f.write(html)
346
+
347
+
348
+ def tag_version(filepath, version_id, tag_message):
349
+ _check_initialized()
350
+ history = _read_history()
351
+ filename = os.path.basename(filepath)
352
+ if filename not in history["files"]:
353
+ return False, f"'{filename}' is not tracked."
354
+ versions = history["files"][filename]["versions"]
355
+ target = next((v for v in versions if v["version_id"] == version_id), None)
356
+ if not target:
357
+ available = ", ".join(v["version_id"] for v in versions)
358
+ return False, f"Version '{version_id}' not found. Available: {available}"
359
+ if "tags" not in target:
360
+ target["tags"] = []
361
+ target["tags"].append({
362
+ "message": tag_message,
363
+ "tagged_at": datetime.now().isoformat(),
364
+ "tagged_by": _get_author()
365
+ })
366
+ _write_history(history)
367
+ return True, (
368
+ f"Tagged '{filename}' {version_id}\n"
369
+ f" Tag: {tag_message}\n"
370
+ f" By: {_get_author()}"
371
+ )
372
+
373
+
374
+ def list_tags(filepath):
375
+ _check_initialized()
376
+ history = _read_history()
377
+ filename = os.path.basename(filepath)
378
+ if filename not in history["files"]:
379
+ return False, f"'{filename}' is not tracked."
380
+ versions = history["files"][filename]["versions"]
381
+ tagged = [(v, t) for v in versions for t in v.get("tags", [])]
382
+ if not tagged:
383
+ return True, f"No tags found for '{filename}'. Use 'tag' to add one."
384
+ lines = [f"\nTags for '{filename}':\n" + "─"*50]
385
+ for version, tag in tagged:
386
+ tagged_at = tag["tagged_at"][:19].replace("T", " ")
387
+ lines.append(
388
+ f"\n {version['version_id']} → {tag['message']}\n"
389
+ f" Tagged by {tag['tagged_by']} on {tagged_at}"
390
+ )
391
+ return True, "\n".join(lines)
392
+
393
+
394
+ def _check_initialized():
395
+ if not os.path.exists(VAULT_DIR):
396
+ raise SystemExit("No DataVault project found. Run 'datavault init <name>' first.")
397
+
398
+ def _read_history():
399
+ return _read_json(HISTORY_FILE)
400
+
401
+ def _write_history(data):
402
+ _write_json(HISTORY_FILE, data)
403
+
404
+ def _version_path(filename, version_id):
405
+ safe_name = filename.replace("/", "_").replace("\\", "_")
406
+ return os.path.join(VERSIONS_DIR, f"{safe_name}__{version_id}")
407
+
408
+ def _get_author():
409
+ return os.getenv("USERNAME") or os.getenv("USER") or "unknown"
410
+
411
+ def _format_size(bytes_count):
412
+ if bytes_count < 1024:
413
+ return f"{bytes_count} B"
414
+ elif bytes_count < 1024 * 1024:
415
+ return f"{bytes_count/1024:.1f} KB"
416
+ return f"{bytes_count/1024/1024:.1f} MB"
417
+
418
+ def _read_json(path):
419
+ with open(path, "r", encoding="utf-8") as f:
420
+ return json.load(f)
421
+
422
+ def _write_json(path, data):
423
+ with open(path, "w", encoding="utf-8") as f:
424
+ json.dump(data, f, indent=2, ensure_ascii=False)