data-steward 0.6.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.
@@ -0,0 +1,3 @@
1
+ """data-steward: the lab's dataset catalogue (`stew`)."""
2
+
3
+ __version__ = "0.6.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,99 @@
1
+ """File checksums: a manifest of every file in a dataset with its size, mtime
2
+ and hash, and a fingerprint of the whole dataset built from it.
3
+
4
+ Full hashes read every byte (BLAKE2b). Quick hashes read only the first and
5
+ last MiB plus the size, which is enough to spot copies and most changes in a
6
+ fraction of the time; fingerprints say which kind they are ("full:" or "quick:")
7
+ and only like are compared with like."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import os
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ CHUNK = 4 << 20
18
+ EDGE = 1 << 20
19
+
20
+
21
+ def file_hash(path: Path, quick: bool) -> str:
22
+ h = hashlib.blake2b(digest_size=20)
23
+ size = path.stat().st_size
24
+ with path.open("rb") as f:
25
+ if quick:
26
+ h.update(size.to_bytes(8, "little"))
27
+ h.update(f.read(EDGE))
28
+ if size > 2 * EDGE:
29
+ f.seek(size - EDGE)
30
+ h.update(f.read(EDGE))
31
+ else:
32
+ while chunk := f.read(CHUNK):
33
+ h.update(chunk)
34
+ return ("quick:" if quick else "full:") + h.hexdigest()
35
+
36
+
37
+ def list_files(root: Path) -> list[tuple[str, Path]]:
38
+ """(relative path, path) for every regular file; a single-file dataset has
39
+ relative path "" so copies under other names still match."""
40
+ if root.is_file():
41
+ return [("", root)]
42
+ out = []
43
+ for d, dirs, files in os.walk(root):
44
+ dirs.sort()
45
+ for f in sorted(files):
46
+ p = Path(d) / f
47
+ if not p.is_symlink() and not f.endswith(".part"):
48
+ out.append((str(p.relative_to(root)), p))
49
+ return out
50
+
51
+
52
+ def manifest(root: Path, quick: bool, jobs: int = 4, progress=None) -> list[tuple[str, int, float, str]]:
53
+ """[(relpath, size, mtime, hash)] for every file under `root`."""
54
+ files = list_files(root)
55
+
56
+ def one(item):
57
+ rel, p = item
58
+ st = p.stat()
59
+ h = file_hash(p, quick)
60
+ if progress:
61
+ progress(st.st_size)
62
+ return rel, st.st_size, st.st_mtime, h
63
+
64
+ with ThreadPoolExecutor(max_workers=max(1, jobs)) as pool:
65
+ return list(pool.map(one, files))
66
+
67
+
68
+ def fingerprint(entries) -> str:
69
+ """One hash for the whole dataset, from its file names, sizes and hashes."""
70
+ quick = any(e[3].startswith("quick:") for e in entries)
71
+ h = hashlib.blake2b(digest_size=16)
72
+ for rel, size, _mtime, fh in sorted(entries):
73
+ h.update(f"{rel}\0{size}\0{fh}\n".encode())
74
+ return ("quick:" if quick else "full:") + h.hexdigest()
75
+
76
+
77
+ @dataclass
78
+ class Diff:
79
+ changed: list[str]
80
+ missing: list[str]
81
+ added: list[str]
82
+
83
+ @property
84
+ def ok(self) -> bool:
85
+ return not (self.changed or self.missing or self.added)
86
+
87
+ def summary(self) -> str:
88
+ parts = []
89
+ for label, items in (("changed", self.changed), ("missing", self.missing), ("new", self.added)):
90
+ if items:
91
+ shown = ", ".join(i or "(the file)" for i in items[:5]) + (" ..." if len(items) > 5 else "")
92
+ parts.append(f"{len(items)} {label}: {shown}")
93
+ return "; ".join(parts) or "unchanged"
94
+
95
+
96
+ def compare(old: dict[str, dict], new: list[tuple]) -> Diff:
97
+ now = {e[0]: e for e in new}
98
+ changed = sorted(r for r in now.keys() & old.keys() if (now[r][1], now[r][3]) != (old[r]["size"], old[r]["hash"]))
99
+ return Diff(changed, sorted(old.keys() - now.keys()), sorted(now.keys() - old.keys()))