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.
- data_steward/__init__.py +3 -0
- data_steward/__main__.py +5 -0
- data_steward/checksum.py +99 -0
- data_steward/cli.py +2280 -0
- data_steward/config.py +173 -0
- data_steward/contents.py +553 -0
- data_steward/db.py +690 -0
- data_steward/detect.py +368 -0
- data_steward/fetch.py +121 -0
- data_steward/provenance.py +97 -0
- data_steward/sources.py +533 -0
- data_steward/util.py +99 -0
- data_steward/vocab.py +209 -0
- data_steward/web.py +276 -0
- data_steward-0.6.0.dist-info/METADATA +347 -0
- data_steward-0.6.0.dist-info/RECORD +20 -0
- data_steward-0.6.0.dist-info/WHEEL +5 -0
- data_steward-0.6.0.dist-info/entry_points.txt +2 -0
- data_steward-0.6.0.dist-info/licenses/LICENSE +21 -0
- data_steward-0.6.0.dist-info/top_level.txt +1 -0
data_steward/__init__.py
ADDED
data_steward/__main__.py
ADDED
data_steward/checksum.py
ADDED
|
@@ -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()))
|