pyvegh 0.8.0__tar.gz → 0.9.0__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.
Files changed (28) hide show
  1. {pyvegh-0.8.0 → pyvegh-0.9.0}/Cargo.lock +1 -1
  2. {pyvegh-0.8.0 → pyvegh-0.9.0}/Cargo.toml +1 -1
  3. {pyvegh-0.8.0 → pyvegh-0.9.0}/PKG-INFO +23 -1
  4. {pyvegh-0.8.0 → pyvegh-0.9.0}/README.md +22 -0
  5. {pyvegh-0.8.0 → pyvegh-0.9.0}/pyproject.toml +1 -1
  6. {pyvegh-0.8.0 → pyvegh-0.9.0}/python/vegh/analytics.py +2 -2
  7. {pyvegh-0.8.0 → pyvegh-0.9.0}/python/vegh/cli.py +2 -1
  8. {pyvegh-0.8.0 → pyvegh-0.9.0}/python/vegh/cli_commands.py +112 -28
  9. {pyvegh-0.8.0 → pyvegh-0.9.0}/python/vegh/cli_helpers.py +2 -2
  10. {pyvegh-0.8.0 → pyvegh-0.9.0}/python/vegh/cli_hooks.py +3 -2
  11. {pyvegh-0.8.0 → pyvegh-0.9.0}/python/vegh/cli_main.py +4 -31
  12. {pyvegh-0.8.0 → pyvegh-0.9.0}/python/vegh/jsonc.py +1 -1
  13. {pyvegh-0.8.0 → pyvegh-0.9.0}/src/core.rs +5 -3
  14. {pyvegh-0.8.0 → pyvegh-0.9.0}/src/lib.rs +20 -3
  15. {pyvegh-0.8.0 → pyvegh-0.9.0}/.github/workflows/ci.yml +0 -0
  16. {pyvegh-0.8.0 → pyvegh-0.9.0}/.github/workflows/release.yml +0 -0
  17. {pyvegh-0.8.0 → pyvegh-0.9.0}/.github/workflows/rust-clippy.yml +0 -0
  18. {pyvegh-0.8.0 → pyvegh-0.9.0}/.gitignore +0 -0
  19. {pyvegh-0.8.0 → pyvegh-0.9.0}/LICENSE +0 -0
  20. {pyvegh-0.8.0 → pyvegh-0.9.0}/python/vegh/__init__.py +0 -0
  21. {pyvegh-0.8.0 → pyvegh-0.9.0}/python/vegh/cli_config.py +0 -0
  22. {pyvegh-0.8.0 → pyvegh-0.9.0}/python/vegh/cli_repo.py +0 -0
  23. {pyvegh-0.8.0 → pyvegh-0.9.0}/python/vegh/config.jsonc +0 -0
  24. {pyvegh-0.8.0 → pyvegh-0.9.0}/src/hash.rs +0 -0
  25. {pyvegh-0.8.0 → pyvegh-0.9.0}/src/storage.rs +0 -0
  26. {pyvegh-0.8.0 → pyvegh-0.9.0}/tests/integration_test.sh +0 -0
  27. {pyvegh-0.8.0 → pyvegh-0.9.0}/tests/test_smoke.py +0 -0
  28. {pyvegh-0.8.0 → pyvegh-0.9.0}/uv.lock +0 -0
@@ -540,7 +540,7 @@ dependencies = [
540
540
 
541
541
  [[package]]
542
542
  name = "pyvegh"
543
- version = "0.8.0"
543
+ version = "0.9.0"
544
544
  dependencies = [
545
545
  "anyhow",
546
546
  "bincode",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "pyvegh"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  edition = "2024"
5
5
  authors = ["CodeTease"]
6
6
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyvegh
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Classifier: Programming Language :: Python :: 3.10
5
5
  Classifier: Programming Language :: Python :: 3.11
6
6
  Classifier: Programming Language :: Python :: 3.12
@@ -82,6 +82,16 @@ vegh config list
82
82
  vegh config reset
83
83
  ```
84
84
 
85
+ **Advanced:** You can also configure custom `audit` patterns in `~/.vegh/config.json`:
86
+ ```json
87
+ {
88
+ "audit": {
89
+ "patterns": ["custom_secret\\.key", ".*\\.private"],
90
+ "keywords": ["MY_API_KEY", "INTERNAL_TOKEN"]
91
+ }
92
+ }
93
+ ```
94
+
85
95
  ### 2\. Create Snapshot
86
96
 
87
97
  Pack a directory into a highly compressed snapshot.
@@ -132,6 +142,9 @@ Clean up old snapshots to free disk space.
132
142
  # Keep only the 5 most recent snapshots in the current directory
133
143
  vegh prune --keep 5
134
144
 
145
+ # Delete snapshots older than 30 days (but always keep the 5 most recent)
146
+ vegh prune --older-than 30 --keep 5
147
+
135
148
  # Force clean without confirmation (useful for CI/CD)
136
149
  vegh prune --keep 1 --force
137
150
  ```
@@ -171,6 +184,7 @@ vegh cat backup.vegh src/main.rs
171
184
  vegh cat backup.vegh image.png --raw > extracted_image.png
172
185
 
173
186
  # Compare snapshot with a directory
187
+ # (Automatically performs Blake3 Hash comparison if file sizes match)
174
188
  vegh diff backup.vegh ./current-project
175
189
  ```
176
190
 
@@ -202,6 +216,14 @@ Create a `.veghhooks.json` in your workspace.
202
216
  }
203
217
  ```
204
218
 
219
+ ### 12\. Audit
220
+
221
+ Scan a snapshot for sensitive filenames and secrets.
222
+
223
+ ```shell
224
+ vegh audit backup.vegh
225
+ ```
226
+
205
227
  ## Library Usage
206
228
 
207
229
  You can also use PyVegh as a library in your own Python scripts:
@@ -54,6 +54,16 @@ vegh config list
54
54
  vegh config reset
55
55
  ```
56
56
 
57
+ **Advanced:** You can also configure custom `audit` patterns in `~/.vegh/config.json`:
58
+ ```json
59
+ {
60
+ "audit": {
61
+ "patterns": ["custom_secret\\.key", ".*\\.private"],
62
+ "keywords": ["MY_API_KEY", "INTERNAL_TOKEN"]
63
+ }
64
+ }
65
+ ```
66
+
57
67
  ### 2\. Create Snapshot
58
68
 
59
69
  Pack a directory into a highly compressed snapshot.
@@ -104,6 +114,9 @@ Clean up old snapshots to free disk space.
104
114
  # Keep only the 5 most recent snapshots in the current directory
105
115
  vegh prune --keep 5
106
116
 
117
+ # Delete snapshots older than 30 days (but always keep the 5 most recent)
118
+ vegh prune --older-than 30 --keep 5
119
+
107
120
  # Force clean without confirmation (useful for CI/CD)
108
121
  vegh prune --keep 1 --force
109
122
  ```
@@ -143,6 +156,7 @@ vegh cat backup.vegh src/main.rs
143
156
  vegh cat backup.vegh image.png --raw > extracted_image.png
144
157
 
145
158
  # Compare snapshot with a directory
159
+ # (Automatically performs Blake3 Hash comparison if file sizes match)
146
160
  vegh diff backup.vegh ./current-project
147
161
  ```
148
162
 
@@ -174,6 +188,14 @@ Create a `.veghhooks.json` in your workspace.
174
188
  }
175
189
  ```
176
190
 
191
+ ### 12\. Audit
192
+
193
+ Scan a snapshot for sensitive filenames and secrets.
194
+
195
+ ```shell
196
+ vegh audit backup.vegh
197
+ ```
198
+
177
199
  ## Library Usage
178
200
 
179
201
  You can also use PyVegh as a library in your own Python scripts:
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "pyvegh"
7
- version = "0.8.0"
7
+ version = "0.9.0"
8
8
  description = "Python bindings for Vegh - The Snapshot Tool."
9
9
  authors = [{name = "CodeTease"}]
10
10
  readme = "README.md"
@@ -120,9 +120,9 @@ def calculate_sloc(file_path: str) -> int:
120
120
  # Check if file is binary
121
121
  with open(file_path, "rb") as f:
122
122
  chunk = f.read(512)
123
- if b'\x00' in chunk:
123
+ if b"\x00" in chunk:
124
124
  return 0
125
-
125
+
126
126
  # Read file with error handling
127
127
  with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
128
128
  content = f.read()
@@ -1,6 +1,7 @@
1
1
  from .cli_main import app
2
+
2
3
  # noqa: F401 to expose app at package level
3
- from . import cli_commands
4
+ from . import cli_commands # noqa: F401
4
5
 
5
6
  if __name__ == "__main__":
6
7
  app()
@@ -14,7 +14,8 @@ from rich.table import Table
14
14
  from rich.panel import Panel
15
15
  from rich.prompt import Prompt, Confirm
16
16
 
17
- from .cli_main import app, create_snap, dry_run_snap
17
+ from .cli_main import app
18
+ from ._core import create_snap, dry_run_snap
18
19
  from .cli_helpers import (
19
20
  console,
20
21
  format_bytes,
@@ -44,6 +45,7 @@ from ._core import (
44
45
  get_context_xml,
45
46
  search_snap,
46
47
  read_snapshot_text,
48
+ hash_file,
47
49
  )
48
50
  from .analytics import render_dashboard, scan_sloc, calculate_sloc, count_sloc_from_text
49
51
 
@@ -56,6 +58,9 @@ def prune(
56
58
  keep: int = typer.Option(
57
59
  5, "--keep", "-k", help="Number of recent snapshots to keep"
58
60
  ),
61
+ older_than: Optional[int] = typer.Option(
62
+ None, "--older-than", help="Delete snapshots older than X days"
63
+ ),
59
64
  force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
60
65
  ):
61
66
  """Clean up old snapshots, keeping only the most recent ones."""
@@ -67,18 +72,36 @@ def prune(
67
72
  target_dir.glob("*.vegh"), key=lambda f: f.stat().st_mtime, reverse=True
68
73
  )
69
74
 
70
- if len(snapshots) <= keep:
75
+ delete_list = []
76
+
77
+ if older_than is not None:
78
+ cutoff = time.time() - (older_than * 86400)
79
+ # Identify files older than cutoff
80
+ time_candidates = [s for s in snapshots if s.stat().st_mtime < cutoff]
81
+
82
+ # Ensure we keep at least 'keep' snapshots (the most recent ones)
83
+ safe_set = set(snapshots[:keep])
84
+ delete_list = [s for s in time_candidates if s not in safe_set]
85
+
71
86
  console.print(
72
- f"[green]No cleanup needed. Found {len(snapshots)} snapshots (Keep: {keep}).[/green]"
87
+ f"[cyan]Policy: Delete older than {older_than} days (except top {keep}).[/cyan]"
73
88
  )
74
- return
89
+ else:
90
+ if len(snapshots) <= keep:
91
+ console.print(
92
+ f"[green]No cleanup needed. Found {len(snapshots)} snapshots (Keep: {keep}).[/green]"
93
+ )
94
+ return
75
95
 
76
- keep_list = snapshots[:keep]
77
- delete_list = snapshots[keep:]
96
+ delete_list = snapshots[keep:]
78
97
 
79
- console.print(
80
- f"[bold cyan]Found {len(snapshots)} snapshots. Keeping {len(keep_list)} most recent.[/bold cyan]"
81
- )
98
+ console.print(
99
+ f"[bold cyan]Found {len(snapshots)} snapshots. Keeping {keep} most recent.[/bold cyan]"
100
+ )
101
+
102
+ if not delete_list:
103
+ console.print("[green]No snapshots match deletion criteria.[/green]")
104
+ return
82
105
 
83
106
  table = Table(title="Snapshots to Delete")
84
107
  table.add_column("File", style="red")
@@ -336,15 +359,20 @@ def diff(
336
359
  repo_path, source_name = ensure_repo(repo, branch, offline)
337
360
  source_name = f"Repo: {source_name}"
338
361
  snap_list = dry_run_snap(str(repo_path))
339
- snap_map = {Path(p).as_posix(): s for p, s in snap_list}
362
+ snap_map = {
363
+ Path(p).as_posix(): {"size": s, "hash": None} for p, s in snap_list
364
+ }
340
365
  elif file:
341
366
  if not file.exists():
342
367
  console.print(f"[red]File '{file}' not found.[/red]")
343
368
  raise typer.Exit(1)
344
369
  source_name = f"Snap: {file.name}"
345
370
  snap_files = list_files_details(str(file))
371
+ # list_files_details now returns (path, size, hash)
346
372
  snap_map = {
347
- Path(p).as_posix(): s for p, s in snap_files if p != ".vegh.json"
373
+ Path(p).as_posix(): {"size": s, "hash": h}
374
+ for p, s, h in snap_files
375
+ if p != ".vegh.json"
348
376
  }
349
377
  else:
350
378
  console.print(
@@ -355,11 +383,15 @@ def diff(
355
383
  if target_is_snap:
356
384
  target_files = list_files_details(str(target))
357
385
  local_files = {
358
- Path(p).as_posix(): s for p, s in target_files if p != ".vegh.json"
386
+ Path(p).as_posix(): {"size": s, "hash": h}
387
+ for p, s, h in target_files
388
+ if p != ".vegh.json"
359
389
  }
360
390
  else:
361
391
  local_list = dry_run_snap(str(target))
362
- local_files = {Path(p).as_posix(): s for p, s in local_list}
392
+ local_files = {
393
+ Path(p).as_posix(): {"size": s, "hash": None} for p, s in local_list
394
+ }
363
395
  except Exception as e:
364
396
  console.print(f"[red]Error:[/red] {e}")
365
397
  raise typer.Exit(1)
@@ -376,11 +408,48 @@ def diff(
376
408
  in_loc = path in local_files
377
409
 
378
410
  if in_src and in_loc:
379
- if snap_map[path] != local_files[path]:
411
+ src_info = snap_map[path]
412
+ loc_info = local_files[path]
413
+ src_size = src_info["size"]
414
+ loc_size = loc_info["size"]
415
+
416
+ modified = False
417
+ details = ""
418
+
419
+ if src_size != loc_size:
420
+ modified = True
421
+ details = f"Size: {format_bytes(src_size)} -> {format_bytes(loc_size)}"
422
+ else:
423
+ # Same size, check content via Hash
424
+ src_hash = src_info.get("hash")
425
+
426
+ if src_hash: # Only if source is snapshot (or has hash)
427
+ loc_hash = loc_info.get("hash")
428
+
429
+ if loc_hash:
430
+ # Target is also snapshot, compare hashes directly
431
+ if src_hash != loc_hash:
432
+ modified = True
433
+ details = "Content Changed (Hash mismatch)"
434
+ elif not target_is_snap:
435
+ # Target is local directory, compute hash on demand
436
+ try:
437
+ full_local_path = target / path
438
+ if full_local_path.exists():
439
+ computed = hash_file(str(full_local_path))
440
+ if computed != src_hash:
441
+ modified = True
442
+ details = "Content Changed (Hash mismatch)"
443
+ except Exception:
444
+ # If hashing fails (permissions etc), assume unmodified or warn?
445
+ # For now, if we can't read it, we rely on size (which matched).
446
+ pass
447
+
448
+ if modified:
380
449
  table.add_row(
381
450
  path,
382
451
  "[yellow]MODIFIED[/yellow]",
383
- f"Size: {format_bytes(snap_map[path])} -> {format_bytes(local_files[path])}",
452
+ details,
384
453
  )
385
454
  changes = True
386
455
  elif in_src and not in_loc:
@@ -417,6 +486,24 @@ def audit(
417
486
 
418
487
  console.print(f"[bold cyan]Auditing {file.name}...[/bold cyan]")
419
488
 
489
+ # Load custom audit config
490
+ cfg = load_config()
491
+ audit_cfg = cfg.get("audit", {})
492
+ custom_patterns = audit_cfg.get("patterns", [])
493
+ custom_keywords = audit_cfg.get("keywords", [])
494
+
495
+ final_patterns = SENSITIVE_PATTERNS + custom_patterns
496
+
497
+ secret_keywords = [
498
+ "PASSWORD",
499
+ "SECRET_KEY",
500
+ "TOKEN",
501
+ "API_KEY",
502
+ "ACCESS_KEY",
503
+ "PRIVATE_KEY",
504
+ ]
505
+ final_keywords = secret_keywords + custom_keywords
506
+
420
507
  risks = []
421
508
 
422
509
  try:
@@ -424,9 +511,14 @@ def audit(
424
511
 
425
512
  # 1. Filename Scan
426
513
  for path in files:
427
- for pattern in SENSITIVE_PATTERNS:
428
- if re.search(pattern, path, re.IGNORECASE):
429
- risks.append((path, "Filename Match", f"Pattern: {pattern}"))
514
+ for pattern in final_patterns:
515
+ try:
516
+ if re.search(pattern, path, re.IGNORECASE):
517
+ risks.append((path, "Filename Match", f"Pattern: {pattern}"))
518
+ except re.error:
519
+ console.print(
520
+ f"[yellow]Warning: Invalid regex pattern '{pattern}' in config[/yellow]"
521
+ )
430
522
 
431
523
  # 2. Content Scan (Config files only)
432
524
  # Scan for common secrets inside textual config files
@@ -440,14 +532,6 @@ def audit(
440
532
  ".ini",
441
533
  ".xml",
442
534
  }
443
- secret_keywords = [
444
- "PASSWORD",
445
- "SECRET_KEY",
446
- "TOKEN",
447
- "API_KEY",
448
- "ACCESS_KEY",
449
- "PRIVATE_KEY",
450
- ]
451
535
 
452
536
  for path in files:
453
537
  p = Path(path)
@@ -457,7 +541,7 @@ def audit(
457
541
  content_bytes = cat_file(str(file), path)
458
542
  try:
459
543
  content = bytes(content_bytes).decode("utf-8")
460
- for keyword in secret_keywords:
544
+ for keyword in final_keywords:
461
545
  if keyword in content:
462
546
  risks.append(
463
547
  (path, "Content Match", f"Found keyword: {keyword}")
@@ -504,7 +588,7 @@ def doctor(
504
588
  console.print("Config: [dim]Not configured[/dim]")
505
589
 
506
590
  try:
507
- from . import _core
591
+ from . import _core # noqa: F401
508
592
 
509
593
  console.print("Rust Core: [green]Loaded[/green]")
510
594
  except ImportError:
@@ -35,7 +35,7 @@ def load_config() -> Dict:
35
35
  try:
36
36
  with open(CONFIG_FILE, "r") as f:
37
37
  return json.load(f)
38
- except:
38
+ except Exception:
39
39
  pass
40
40
  return {}
41
41
 
@@ -65,7 +65,7 @@ def get_dir_size(path: Path) -> int:
65
65
  for entry in path.rglob("*"):
66
66
  if entry.is_file():
67
67
  total += entry.stat().st_size
68
- except:
68
+ except Exception:
69
69
  pass
70
70
  return total
71
71
 
@@ -22,8 +22,9 @@ def execute_hooks(commands: List[str], hook_name: str) -> bool:
22
22
  return True
23
23
 
24
24
  # Just a friendly warning for the unsuspecting user
25
- console.print(f"[bold yellow]⚠ Running {hook_name} hooks from project config...[/bold yellow]")
26
-
25
+ console.print(
26
+ f"[bold yellow]⚠ Running {hook_name} hooks from project config...[/bold yellow]"
27
+ )
27
28
 
28
29
  console.print(f"[bold magenta]>>> HOOK: {hook_name}[/bold magenta]")
29
30
  env = os.environ.copy()
@@ -3,6 +3,9 @@ from rich.console import Console
3
3
 
4
4
  import typer
5
5
 
6
+ # Add sub-apps
7
+ from .cli_config import config_app
8
+
6
9
  # Try to import package version metadata (Modern Pythonic way)
7
10
  try:
8
11
  from importlib.metadata import version as get_package_version, PackageNotFoundError
@@ -13,38 +16,11 @@ except ImportError:
13
16
 
14
17
  # Import core functionality
15
18
  try:
16
- from ._core import (
17
- create_snap,
18
- dry_run_snap,
19
- restore_snap,
20
- check_integrity,
21
- list_files,
22
- get_metadata,
23
- count_locs,
24
- scan_locs_dir,
25
- cat_file,
26
- list_files_details,
27
- get_context_xml,
28
- search_snap,
29
- read_snapshot_text,
30
- )
19
+ from . import _core # noqa: F401
31
20
  except ImportError:
32
21
  print("Error: Rust core missing. Run 'maturin develop'!")
33
22
  exit(1)
34
23
 
35
- # Import Analytics module
36
- try:
37
- from .analytics import (
38
- render_dashboard,
39
- scan_sloc,
40
- calculate_sloc,
41
- count_sloc_from_text,
42
- )
43
- except ImportError:
44
- render_dashboard = None
45
- scan_sloc = None
46
- calculate_sloc = None
47
-
48
24
  # Define context settings to enable '-h' alongside '--help'
49
25
  CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
50
26
 
@@ -92,7 +68,4 @@ def main(
92
68
  pass
93
69
 
94
70
 
95
- # Add sub-apps
96
- from .cli_config import config_app
97
-
98
71
  app.add_typer(config_app, name="config")
@@ -1,6 +1,6 @@
1
1
  # A simple JSONC parser, written to avoid extra dependencies.
2
2
  # It removes comments from JSONC strings and parses the result as JSON.
3
- # Use internally only, so we probably don't need advance parsing features.
3
+ # Use internally only, so we probably don't need advance parsing features.
4
4
 
5
5
  import json
6
6
  import re
@@ -39,7 +39,7 @@ pub struct VeghMetadata {
39
39
 
40
40
  // Pipeline Messages
41
41
  enum WorkerResult {
42
- Processed(ProcessedMessage),
42
+ Processed(Box<ProcessedMessage>),
43
43
  Error(String),
44
44
  }
45
45
 
@@ -66,6 +66,7 @@ enum DataAction {
66
66
 
67
67
  // --- Main Packing Logic ---
68
68
 
69
+ #[allow(clippy::too_many_arguments)]
69
70
  pub fn create_snap_logic(
70
71
  source: &Path,
71
72
  output: &Path,
@@ -355,7 +356,7 @@ pub fn create_snap_logic(
355
356
 
356
357
  match process_res {
357
358
  Ok(msg) => {
358
- let _ = tx.send(WorkerResult::Processed(msg));
359
+ let _ = tx.send(WorkerResult::Processed(Box::new(msg)));
359
360
  }
360
361
  Err(e) => {
361
362
  let _ = tx.send(WorkerResult::Error(e.to_string()));
@@ -384,7 +385,8 @@ pub fn create_snap_logic(
384
385
  eprintln!("Error: {}", e);
385
386
  }
386
387
  }
387
- WorkerResult::Processed(pm) => {
388
+ WorkerResult::Processed(pm_box) => {
389
+ let pm = *pm_box;
388
390
  if pm.is_cached_hit {
389
391
  cache_hit_count += 1;
390
392
  }
@@ -119,6 +119,7 @@ fn read_snapshot_text(file_path: String) -> PyResult<Vec<(String, String)>> {
119
119
 
120
120
  #[pyfunction]
121
121
  #[pyo3(signature = (source, output, level=3, comment=None, include=None, exclude=None, no_cache=false, verbose=true))]
122
+ #[allow(clippy::too_many_arguments)]
122
123
  fn create_snap(
123
124
  source: String,
124
125
  output: String,
@@ -528,7 +529,7 @@ fn scan_locs_dir(source: String, exclude: Option<Vec<String>>) -> PyResult<Vec<(
528
529
  }
529
530
 
530
531
  #[pyfunction]
531
- fn list_files_details(file_path: String) -> PyResult<Vec<(String, u64)>> {
532
+ fn list_files_details(file_path: String) -> PyResult<Vec<(String, u64, String)>> {
532
533
  let file = File::open(&file_path).map_err(|e| PyIOError::new_err(e.to_string()))?;
533
534
  let decoder = zstd::stream::read::Decoder::new(file).unwrap();
534
535
  let mut archive = tar::Archive::new(decoder);
@@ -548,7 +549,7 @@ fn list_files_details(file_path: String) -> PyResult<Vec<(String, u64)>> {
548
549
  return Ok(manifest
549
550
  .entries
550
551
  .into_iter()
551
- .map(|en| (en.path, en.size))
552
+ .map(|en| (en.path, en.size, en.hash))
552
553
  .collect());
553
554
  }
554
555
  }
@@ -557,13 +558,28 @@ fn list_files_details(file_path: String) -> PyResult<Vec<(String, u64)>> {
557
558
  && path_str != ".vegh.json"
558
559
  && path_str != "manifest.json"
559
560
  {
560
- results.push((path_str, size));
561
+ results.push((path_str, size, String::new()));
561
562
  }
562
563
  }
563
564
  }
564
565
  Ok(results)
565
566
  }
566
567
 
568
+ #[pyfunction]
569
+ fn hash_file(file_path: String) -> PyResult<String> {
570
+ let file = File::open(&file_path).map_err(|e| PyIOError::new_err(e.to_string()))?;
571
+ let mut hasher = blake3::Hasher::new();
572
+
573
+ if let Ok(mmap) = unsafe { memmap2::MmapOptions::new().map(&file) } {
574
+ hasher.update_rayon(&mmap);
575
+ } else {
576
+ let mut f = File::open(&file_path).map_err(|e| PyIOError::new_err(e.to_string()))?;
577
+ std::io::copy(&mut f, &mut hasher).map_err(|e| PyIOError::new_err(e.to_string()))?;
578
+ }
579
+
580
+ Ok(hasher.finalize().to_hex().to_string())
581
+ }
582
+
567
583
  #[pymodule]
568
584
  #[pyo3(name = "_core")]
569
585
  fn pyvegh_core(m: &Bound<'_, PyModule>) -> PyResult<()> {
@@ -580,5 +596,6 @@ fn pyvegh_core(m: &Bound<'_, PyModule>) -> PyResult<()> {
580
596
  m.add_function(wrap_pyfunction!(search_snap, m)?)?;
581
597
  m.add_function(wrap_pyfunction!(count_locs, m)?)?;
582
598
  m.add_function(wrap_pyfunction!(read_snapshot_text, m)?)?;
599
+ m.add_function(wrap_pyfunction!(hash_file, m)?)?;
583
600
  Ok(())
584
601
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes