model-mirror-cli 0.2.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.
- model_mirror/__init__.py +7 -0
- model_mirror/audit.py +214 -0
- model_mirror/checksums.py +371 -0
- model_mirror/cli.py +2436 -0
- model_mirror/config.py +300 -0
- model_mirror/hub.py +901 -0
- model_mirror/lock.py +119 -0
- model_mirror/mirror.py +201 -0
- model_mirror/progress.py +260 -0
- model_mirror/removal.py +164 -0
- model_mirror/repair.py +399 -0
- model_mirror/state.py +150 -0
- model_mirror/torrent.py +453 -0
- model_mirror/torrent_coverage.py +400 -0
- model_mirror/torrent_hashes.py +168 -0
- model_mirror/torrent_import.py +620 -0
- model_mirror/torrent_publication.py +582 -0
- model_mirror/torrent_seed.py +228 -0
- model_mirror/verify.py +153 -0
- model_mirror_cli-0.2.0.dist-info/METADATA +550 -0
- model_mirror_cli-0.2.0.dist-info/RECORD +24 -0
- model_mirror_cli-0.2.0.dist-info/WHEEL +4 -0
- model_mirror_cli-0.2.0.dist-info/entry_points.txt +2 -0
- model_mirror_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
model_mirror/__init__.py
ADDED
model_mirror/audit.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
DTYPE_SIZES = {
|
|
9
|
+
"BOOL": 1,
|
|
10
|
+
"U8": 1,
|
|
11
|
+
"I8": 1,
|
|
12
|
+
"F8_E4M3": 1,
|
|
13
|
+
"F8_E5M2": 1,
|
|
14
|
+
"U16": 2,
|
|
15
|
+
"I16": 2,
|
|
16
|
+
"F16": 2,
|
|
17
|
+
"BF16": 2,
|
|
18
|
+
"U32": 4,
|
|
19
|
+
"I32": 4,
|
|
20
|
+
"F32": 4,
|
|
21
|
+
"U64": 8,
|
|
22
|
+
"I64": 8,
|
|
23
|
+
"F64": 8,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class SafetensorsValidation:
|
|
29
|
+
tensor_count: int
|
|
30
|
+
tensor_bytes: int
|
|
31
|
+
tensor_names: set[str]
|
|
32
|
+
warnings: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class AuditResult:
|
|
37
|
+
checked_safetensors: int = 0
|
|
38
|
+
indexed_tensors: int = 0
|
|
39
|
+
missing_files: list[str] = field(default_factory=list)
|
|
40
|
+
failures: list[str] = field(default_factory=list)
|
|
41
|
+
warnings: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def ok(self) -> bool:
|
|
45
|
+
return not self.missing_files and not self.failures
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def product(values: list[int]) -> int:
|
|
49
|
+
result = 1
|
|
50
|
+
for value in values:
|
|
51
|
+
result *= value
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_json(path: Path) -> dict:
|
|
56
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
57
|
+
return json.load(handle)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_safetensors_header(path: Path) -> tuple[dict, int, int]:
|
|
61
|
+
with path.open("rb") as handle:
|
|
62
|
+
size_bytes = handle.read(8)
|
|
63
|
+
if len(size_bytes) != 8:
|
|
64
|
+
raise ValueError("file is too small to contain a safetensors header")
|
|
65
|
+
header_size = int.from_bytes(size_bytes, "little")
|
|
66
|
+
if header_size <= 0:
|
|
67
|
+
raise ValueError(f"invalid safetensors header size: {header_size}")
|
|
68
|
+
header_bytes = handle.read(header_size)
|
|
69
|
+
if len(header_bytes) != header_size:
|
|
70
|
+
raise ValueError("file ended before the safetensors header was complete")
|
|
71
|
+
return json.loads(header_bytes), header_size, path.stat().st_size
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_safetensors_file(path: Path) -> SafetensorsValidation:
|
|
75
|
+
warnings = []
|
|
76
|
+
header, header_size, file_size = read_safetensors_header(path)
|
|
77
|
+
data_size = file_size - 8 - header_size
|
|
78
|
+
if data_size < 0:
|
|
79
|
+
raise ValueError("file is smaller than its declared safetensors header")
|
|
80
|
+
|
|
81
|
+
tensors = {key: value for key, value in header.items() if key != "__metadata__"}
|
|
82
|
+
intervals = []
|
|
83
|
+
tensor_bytes = 0
|
|
84
|
+
for tensor_name, spec in tensors.items():
|
|
85
|
+
if not isinstance(spec, dict):
|
|
86
|
+
raise ValueError(f"tensor {tensor_name!r} has invalid metadata")
|
|
87
|
+
dtype = spec.get("dtype")
|
|
88
|
+
shape = spec.get("shape")
|
|
89
|
+
offsets = spec.get("data_offsets")
|
|
90
|
+
if not isinstance(shape, list) or not all(isinstance(item, int) and item >= 0 for item in shape):
|
|
91
|
+
raise ValueError(f"tensor {tensor_name!r} has invalid shape")
|
|
92
|
+
if not isinstance(offsets, list) or len(offsets) != 2:
|
|
93
|
+
raise ValueError(f"tensor {tensor_name!r} has invalid data_offsets")
|
|
94
|
+
start, end = offsets
|
|
95
|
+
if not isinstance(start, int) or not isinstance(end, int) or start < 0 or end < start:
|
|
96
|
+
raise ValueError(f"tensor {tensor_name!r} has invalid byte range")
|
|
97
|
+
if end > data_size:
|
|
98
|
+
raise ValueError(f"tensor {tensor_name!r} extends beyond file data")
|
|
99
|
+
|
|
100
|
+
observed_bytes = end - start
|
|
101
|
+
item_size = DTYPE_SIZES.get(dtype)
|
|
102
|
+
if item_size is None:
|
|
103
|
+
warnings.append(f"{path.name}: tensor {tensor_name!r} has unknown dtype {dtype!r}")
|
|
104
|
+
else:
|
|
105
|
+
expected_bytes = product(shape) * item_size
|
|
106
|
+
if observed_bytes != expected_bytes:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"tensor {tensor_name!r} byte size mismatch: "
|
|
109
|
+
f"offsets={observed_bytes} shape*dtype={expected_bytes}"
|
|
110
|
+
)
|
|
111
|
+
tensor_bytes += observed_bytes
|
|
112
|
+
intervals.append((start, end, tensor_name))
|
|
113
|
+
|
|
114
|
+
intervals.sort()
|
|
115
|
+
previous_end = 0
|
|
116
|
+
for start, end, tensor_name in intervals:
|
|
117
|
+
if start < previous_end:
|
|
118
|
+
raise ValueError(f"tensor {tensor_name!r} overlaps previous tensor data")
|
|
119
|
+
if start > previous_end:
|
|
120
|
+
warnings.append(f"{path.name}: gap before tensor {tensor_name!r}")
|
|
121
|
+
previous_end = end
|
|
122
|
+
if previous_end < data_size:
|
|
123
|
+
warnings.append(f"{path.name}: {data_size - previous_end} trailing byte(s) after tensor data")
|
|
124
|
+
|
|
125
|
+
return SafetensorsValidation(
|
|
126
|
+
tensor_count=len(tensors),
|
|
127
|
+
tensor_bytes=tensor_bytes,
|
|
128
|
+
tensor_names=set(tensors),
|
|
129
|
+
warnings=warnings,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def audit_model(
|
|
134
|
+
root: Path,
|
|
135
|
+
*,
|
|
136
|
+
skip_transformers: bool = False,
|
|
137
|
+
trust_remote_code: bool = False,
|
|
138
|
+
strict: bool = False,
|
|
139
|
+
require_config: bool = True,
|
|
140
|
+
) -> AuditResult:
|
|
141
|
+
result = AuditResult()
|
|
142
|
+
config_path = root / "config.json"
|
|
143
|
+
if not config_path.exists():
|
|
144
|
+
if require_config:
|
|
145
|
+
result.missing_files.append("config.json")
|
|
146
|
+
else:
|
|
147
|
+
try:
|
|
148
|
+
read_json(config_path)
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
result.failures.append(f"config.json: {exc}")
|
|
151
|
+
|
|
152
|
+
if not skip_transformers:
|
|
153
|
+
try:
|
|
154
|
+
from transformers import AutoConfig, AutoTokenizer
|
|
155
|
+
|
|
156
|
+
AutoConfig.from_pretrained(root, local_files_only=True, trust_remote_code=trust_remote_code)
|
|
157
|
+
try:
|
|
158
|
+
AutoTokenizer.from_pretrained(root, local_files_only=True, trust_remote_code=trust_remote_code)
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
result.warnings.append(f"AutoTokenizer metadata load failed: {exc}")
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
result.warnings.append(f"Transformers metadata audit skipped/failed: {exc}")
|
|
163
|
+
|
|
164
|
+
indexed_tensors: dict[str, str] = {}
|
|
165
|
+
indexed_files: set[str] = set()
|
|
166
|
+
for index_path in sorted(root.glob("*.safetensors.index.json")):
|
|
167
|
+
try:
|
|
168
|
+
index = read_json(index_path)
|
|
169
|
+
weight_map = index.get("weight_map")
|
|
170
|
+
if not isinstance(weight_map, dict) or not weight_map:
|
|
171
|
+
raise ValueError("missing or empty weight_map")
|
|
172
|
+
for tensor_name, filename in weight_map.items():
|
|
173
|
+
if not isinstance(filename, str):
|
|
174
|
+
raise ValueError(f"weight_map entry for {tensor_name!r} is not a file name")
|
|
175
|
+
indexed_tensors[tensor_name] = filename
|
|
176
|
+
indexed_files.add(filename)
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
result.failures.append(f"{index_path.name}: {exc}")
|
|
179
|
+
|
|
180
|
+
result.indexed_tensors = len(indexed_tensors)
|
|
181
|
+
for filename in sorted(indexed_files):
|
|
182
|
+
if not (root / filename).exists():
|
|
183
|
+
result.missing_files.append(filename)
|
|
184
|
+
|
|
185
|
+
actual_tensor_names = set()
|
|
186
|
+
for path in sorted(root.glob("*.safetensors")):
|
|
187
|
+
try:
|
|
188
|
+
validation = validate_safetensors_file(path)
|
|
189
|
+
result.checked_safetensors += 1
|
|
190
|
+
result.warnings.extend(validation.warnings)
|
|
191
|
+
actual_tensor_names.update(validation.tensor_names)
|
|
192
|
+
|
|
193
|
+
if indexed_tensors:
|
|
194
|
+
expected_for_shard = {
|
|
195
|
+
tensor for tensor, filename in indexed_tensors.items() if filename == path.name
|
|
196
|
+
}
|
|
197
|
+
missing = expected_for_shard - validation.tensor_names
|
|
198
|
+
extra = validation.tensor_names - expected_for_shard
|
|
199
|
+
if missing:
|
|
200
|
+
result.failures.append(f"{path.name}: {len(missing)} indexed tensor(s) missing")
|
|
201
|
+
if extra:
|
|
202
|
+
message = f"{path.name}: {len(extra)} unindexed tensor(s)"
|
|
203
|
+
if strict:
|
|
204
|
+
result.failures.append(message)
|
|
205
|
+
else:
|
|
206
|
+
result.warnings.append(message)
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
result.failures.append(f"{path.name}: {exc}")
|
|
209
|
+
|
|
210
|
+
missing_from_headers = set(indexed_tensors) - actual_tensor_names
|
|
211
|
+
if missing_from_headers:
|
|
212
|
+
result.failures.append(f"{len(missing_from_headers)} indexed tensor(s) missing from safetensors headers")
|
|
213
|
+
|
|
214
|
+
return result
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable, Protocol
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
CHECKSUMS = ".checksums"
|
|
12
|
+
MANIFEST = ".manifest"
|
|
13
|
+
MANIFEST_SCHEMA = "model-mirror-manifest"
|
|
14
|
+
MANIFEST_VERSION = 1
|
|
15
|
+
SKIP_DIRS = {".model-mirror", ".cache", ".archive"}
|
|
16
|
+
SKIP_FILES = {
|
|
17
|
+
CHECKSUMS,
|
|
18
|
+
MANIFEST,
|
|
19
|
+
".checksums.tmp",
|
|
20
|
+
".manifest.tmp",
|
|
21
|
+
".verification",
|
|
22
|
+
".verification.lock",
|
|
23
|
+
".verification.tmp",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class ChecksumResult:
|
|
29
|
+
checked: int = 0
|
|
30
|
+
missing: list[str] = field(default_factory=list)
|
|
31
|
+
failures: list[str] = field(default_factory=list)
|
|
32
|
+
extras: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def ok(self) -> bool:
|
|
36
|
+
return not self.missing and not self.failures and not self.extras
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class ChecksumWriteResult:
|
|
41
|
+
total: int = 0
|
|
42
|
+
hashed: int = 0
|
|
43
|
+
skipped: int = 0
|
|
44
|
+
removed: int = 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class FileHashes:
|
|
49
|
+
sha256: str
|
|
50
|
+
git_blob_sha1: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(slots=True)
|
|
54
|
+
class FileHashState:
|
|
55
|
+
sha256: object
|
|
56
|
+
git_blob_sha1: object
|
|
57
|
+
bytes_hashed: int = 0
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def hashes(self) -> FileHashes:
|
|
61
|
+
return FileHashes(sha256=self.sha256.hexdigest(), git_blob_sha1=self.git_blob_sha1.hexdigest())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class StreamingAccumulator(Protocol):
|
|
65
|
+
def update(self, data: bytes) -> None: ...
|
|
66
|
+
|
|
67
|
+
def reset(self) -> None: ...
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HashingWriter:
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
handle,
|
|
74
|
+
*,
|
|
75
|
+
expected_size: int,
|
|
76
|
+
hash_state: FileHashState | None = None,
|
|
77
|
+
accumulators: tuple[StreamingAccumulator, ...] = (),
|
|
78
|
+
on_progress: Callable[[int], None] | None = None,
|
|
79
|
+
):
|
|
80
|
+
self._handle = handle
|
|
81
|
+
self._expected_size = expected_size
|
|
82
|
+
self._hash_state = hash_state or new_hash_state(expected_size)
|
|
83
|
+
self._accumulators = accumulators
|
|
84
|
+
self._on_progress = on_progress
|
|
85
|
+
|
|
86
|
+
def write(self, data: bytes) -> int:
|
|
87
|
+
written = self._handle.write(data)
|
|
88
|
+
chunk = data if written == len(data) else data[:written]
|
|
89
|
+
self._hash_state.sha256.update(chunk)
|
|
90
|
+
self._hash_state.git_blob_sha1.update(chunk)
|
|
91
|
+
for accumulator in self._accumulators:
|
|
92
|
+
accumulator.update(chunk)
|
|
93
|
+
self._hash_state.bytes_hashed += written
|
|
94
|
+
if self._on_progress is not None:
|
|
95
|
+
self._on_progress(self._hash_state.bytes_hashed)
|
|
96
|
+
return written
|
|
97
|
+
|
|
98
|
+
def tell(self) -> int:
|
|
99
|
+
return self._handle.tell()
|
|
100
|
+
|
|
101
|
+
def seek(self, offset: int, whence: int = 0) -> int:
|
|
102
|
+
return self._handle.seek(offset, whence)
|
|
103
|
+
|
|
104
|
+
def truncate(self, size: int | None = None) -> int:
|
|
105
|
+
truncated = self._handle.truncate(size)
|
|
106
|
+
if truncated == 0:
|
|
107
|
+
self._hash_state = new_hash_state(self._expected_size)
|
|
108
|
+
for accumulator in self._accumulators:
|
|
109
|
+
accumulator.reset()
|
|
110
|
+
elif truncated != self._hash_state.bytes_hashed:
|
|
111
|
+
raise OSError("cannot preserve streaming hash state after non-zero truncate")
|
|
112
|
+
return truncated
|
|
113
|
+
|
|
114
|
+
def flush(self) -> None:
|
|
115
|
+
self._handle.flush()
|
|
116
|
+
|
|
117
|
+
def fileno(self) -> int:
|
|
118
|
+
return self._handle.fileno()
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def hashes(self) -> FileHashes:
|
|
122
|
+
return self._hash_state.hashes
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def new_hash_state(expected_size: int) -> FileHashState:
|
|
126
|
+
sha256 = hashlib.sha256()
|
|
127
|
+
git_blob_sha1 = hashlib.sha1()
|
|
128
|
+
git_blob_sha1.update(f"blob {expected_size}\0".encode("ascii"))
|
|
129
|
+
return FileHashState(sha256=sha256, git_blob_sha1=git_blob_sha1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def hash_file_prefix(
|
|
133
|
+
path: Path,
|
|
134
|
+
*,
|
|
135
|
+
total_size: int,
|
|
136
|
+
prefix_size: int,
|
|
137
|
+
accumulators: tuple[StreamingAccumulator, ...] = (),
|
|
138
|
+
on_progress: Callable[[int], None] | None = None,
|
|
139
|
+
) -> FileHashState:
|
|
140
|
+
state = new_hash_state(total_size)
|
|
141
|
+
remaining = prefix_size
|
|
142
|
+
with path.open("rb") as handle:
|
|
143
|
+
while remaining > 0:
|
|
144
|
+
chunk = handle.read(min(16 * 1024 * 1024, remaining))
|
|
145
|
+
if not chunk:
|
|
146
|
+
raise OSError(f"short read while hashing prefix: {path}")
|
|
147
|
+
state.sha256.update(chunk)
|
|
148
|
+
state.git_blob_sha1.update(chunk)
|
|
149
|
+
for accumulator in accumulators:
|
|
150
|
+
accumulator.update(chunk)
|
|
151
|
+
state.bytes_hashed += len(chunk)
|
|
152
|
+
if on_progress is not None:
|
|
153
|
+
on_progress(state.bytes_hashed)
|
|
154
|
+
remaining -= len(chunk)
|
|
155
|
+
return state
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def iter_payload_files(root: Path):
|
|
159
|
+
for path in sorted(root.rglob("*")):
|
|
160
|
+
if not path.is_file():
|
|
161
|
+
continue
|
|
162
|
+
rel = path.relative_to(root)
|
|
163
|
+
if rel.parts and rel.parts[0] in SKIP_DIRS:
|
|
164
|
+
continue
|
|
165
|
+
if rel.as_posix() in SKIP_FILES:
|
|
166
|
+
continue
|
|
167
|
+
yield path
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def file_hashes(
|
|
171
|
+
path: Path,
|
|
172
|
+
*,
|
|
173
|
+
accumulators: tuple[StreamingAccumulator, ...] = (),
|
|
174
|
+
on_progress: Callable[[int], None] | None = None,
|
|
175
|
+
) -> FileHashes:
|
|
176
|
+
stat = path.stat()
|
|
177
|
+
digest = hashlib.sha256()
|
|
178
|
+
blob_digest = hashlib.sha1()
|
|
179
|
+
blob_digest.update(f"blob {stat.st_size}\0".encode("ascii"))
|
|
180
|
+
bytes_hashed = 0
|
|
181
|
+
with path.open("rb") as handle:
|
|
182
|
+
for chunk in iter(lambda: handle.read(16 * 1024 * 1024), b""):
|
|
183
|
+
digest.update(chunk)
|
|
184
|
+
blob_digest.update(chunk)
|
|
185
|
+
for accumulator in accumulators:
|
|
186
|
+
accumulator.update(chunk)
|
|
187
|
+
bytes_hashed += len(chunk)
|
|
188
|
+
if on_progress is not None:
|
|
189
|
+
on_progress(bytes_hashed)
|
|
190
|
+
return FileHashes(sha256=digest.hexdigest(), git_blob_sha1=blob_digest.hexdigest())
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def write_checksums(root: Path, *, max_workers: int = 1) -> ChecksumWriteResult:
|
|
194
|
+
manifest = load_manifest(root)
|
|
195
|
+
payload_files = list(iter_payload_files(root))
|
|
196
|
+
current_paths = {path.relative_to(root).as_posix() for path in payload_files}
|
|
197
|
+
result = ChecksumWriteResult(total=len(payload_files))
|
|
198
|
+
|
|
199
|
+
for rel in sorted(set(manifest) - current_paths):
|
|
200
|
+
manifest.pop(rel, None)
|
|
201
|
+
result.removed += 1
|
|
202
|
+
|
|
203
|
+
work: list[Path] = []
|
|
204
|
+
for path in payload_files:
|
|
205
|
+
rel = path.relative_to(root).as_posix()
|
|
206
|
+
stat = path.stat()
|
|
207
|
+
if record_is_current(manifest.get(rel), stat.st_size, stat.st_mtime_ns):
|
|
208
|
+
result.skipped += 1
|
|
209
|
+
continue
|
|
210
|
+
work.append(path)
|
|
211
|
+
|
|
212
|
+
if result.removed and not work:
|
|
213
|
+
write_manifest(root, manifest)
|
|
214
|
+
if not work:
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
workers = max(1, max_workers)
|
|
218
|
+
if workers == 1:
|
|
219
|
+
for path in work:
|
|
220
|
+
row = checksum_row(root, path)
|
|
221
|
+
manifest[row["path"]] = row
|
|
222
|
+
result.hashed += 1
|
|
223
|
+
write_manifest(root, manifest)
|
|
224
|
+
return result
|
|
225
|
+
|
|
226
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
227
|
+
futures = [executor.submit(checksum_row, root, path) for path in work]
|
|
228
|
+
for future in as_completed(futures):
|
|
229
|
+
row = future.result()
|
|
230
|
+
manifest[row["path"]] = row
|
|
231
|
+
result.hashed += 1
|
|
232
|
+
write_manifest(root, manifest)
|
|
233
|
+
return result
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def record_is_current(row: dict | None, size: int, mtime_ns: int) -> bool:
|
|
237
|
+
if row is None:
|
|
238
|
+
return False
|
|
239
|
+
return (
|
|
240
|
+
row.get("size") == size
|
|
241
|
+
and row.get("mtime_ns") == mtime_ns
|
|
242
|
+
and bool(row.get("sha256"))
|
|
243
|
+
and bool(row.get("git_blob_sha1"))
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def checksum_row(root: Path, path: Path) -> dict:
|
|
248
|
+
hashes = file_hashes(path)
|
|
249
|
+
return checksum_row_from_hashes(root, path, hashes)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def checksum_row_from_hashes(root: Path, path: Path, hashes: FileHashes) -> dict:
|
|
253
|
+
stat = path.stat()
|
|
254
|
+
return {
|
|
255
|
+
"path": path.relative_to(root).as_posix(),
|
|
256
|
+
"sha256": hashes.sha256,
|
|
257
|
+
"git_blob_sha1": hashes.git_blob_sha1,
|
|
258
|
+
"size": stat.st_size,
|
|
259
|
+
"mtime_ns": stat.st_mtime_ns,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def write_manifest(root: Path, manifest: dict[str, dict]) -> None:
|
|
264
|
+
manifest_tmp = root / f"{MANIFEST}.tmp"
|
|
265
|
+
with manifest_tmp.open("w", encoding="utf-8", newline="\n") as handle:
|
|
266
|
+
handle.write(json.dumps({"schema": MANIFEST_SCHEMA, "version": MANIFEST_VERSION}, sort_keys=True) + "\n")
|
|
267
|
+
for rel in sorted(manifest):
|
|
268
|
+
handle.write(json.dumps(manifest[rel], sort_keys=True) + "\n")
|
|
269
|
+
manifest_tmp.replace(root / MANIFEST)
|
|
270
|
+
remove_obsolete_checksum_files(root)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def remove_obsolete_checksum_files(root: Path) -> None:
|
|
274
|
+
for path in (root / CHECKSUMS, root / f"{CHECKSUMS}.tmp"):
|
|
275
|
+
path.unlink(missing_ok=True)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def update_checksums(root: Path, paths: list[str], *, max_workers: int = 1) -> ChecksumWriteResult:
|
|
279
|
+
manifest = load_manifest(root)
|
|
280
|
+
result = ChecksumWriteResult(total=len(paths))
|
|
281
|
+
work: list[Path] = []
|
|
282
|
+
for rel in paths:
|
|
283
|
+
path = root / rel
|
|
284
|
+
if not path.exists() or not path.is_file():
|
|
285
|
+
if manifest.pop(rel, None) is not None:
|
|
286
|
+
result.removed += 1
|
|
287
|
+
continue
|
|
288
|
+
work.append(path)
|
|
289
|
+
if result.removed and not work:
|
|
290
|
+
write_manifest(root, manifest)
|
|
291
|
+
if not work:
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
workers = max(1, max_workers)
|
|
295
|
+
if workers == 1:
|
|
296
|
+
for path in work:
|
|
297
|
+
row = checksum_row(root, path)
|
|
298
|
+
manifest[row["path"]] = row
|
|
299
|
+
result.hashed += 1
|
|
300
|
+
write_manifest(root, manifest)
|
|
301
|
+
return result
|
|
302
|
+
|
|
303
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
304
|
+
futures = [executor.submit(checksum_row, root, path) for path in work]
|
|
305
|
+
for future in as_completed(futures):
|
|
306
|
+
row = future.result()
|
|
307
|
+
manifest[row["path"]] = row
|
|
308
|
+
result.hashed += 1
|
|
309
|
+
write_manifest(root, manifest)
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def load_manifest(root: Path) -> dict[str, dict]:
|
|
314
|
+
manifest: dict[str, dict] = {}
|
|
315
|
+
manifest_path = root / MANIFEST
|
|
316
|
+
if manifest_path.exists():
|
|
317
|
+
with manifest_path.open("r", encoding="utf-8") as handle:
|
|
318
|
+
saw_record = False
|
|
319
|
+
for line_number, line in enumerate(handle, 1):
|
|
320
|
+
if not line.strip():
|
|
321
|
+
continue
|
|
322
|
+
try:
|
|
323
|
+
row = json.loads(line)
|
|
324
|
+
except Exception as exc:
|
|
325
|
+
raise ValueError(f"Malformed line {line_number} in {manifest_path}") from exc
|
|
326
|
+
if not saw_record:
|
|
327
|
+
if not row.get("schema"):
|
|
328
|
+
raise ValueError(f"Manifest missing header: {manifest_path}")
|
|
329
|
+
validate_manifest_header(row, manifest_path)
|
|
330
|
+
saw_record = True
|
|
331
|
+
continue
|
|
332
|
+
saw_record = True
|
|
333
|
+
try:
|
|
334
|
+
manifest[row["path"]] = row
|
|
335
|
+
except KeyError as exc:
|
|
336
|
+
raise ValueError(f"Malformed line {line_number} in {manifest_path}") from exc
|
|
337
|
+
return manifest
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def validate_manifest_header(row: dict, path: Path) -> None:
|
|
341
|
+
if row.get("schema") != MANIFEST_SCHEMA:
|
|
342
|
+
raise ValueError(f"Unsupported manifest schema in {path}: {row.get('schema')}")
|
|
343
|
+
if row.get("version") != MANIFEST_VERSION:
|
|
344
|
+
raise ValueError(f"Unsupported manifest version in {path}: {row.get('version')}")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def verify_checksums(root: Path, strict: bool = False) -> ChecksumResult:
|
|
348
|
+
manifest = load_manifest(root)
|
|
349
|
+
if not manifest:
|
|
350
|
+
return ChecksumResult()
|
|
351
|
+
|
|
352
|
+
result = ChecksumResult()
|
|
353
|
+
for rel, row in manifest.items():
|
|
354
|
+
path = root / rel
|
|
355
|
+
if not path.exists():
|
|
356
|
+
result.missing.append(rel)
|
|
357
|
+
continue
|
|
358
|
+
result.checked += 1
|
|
359
|
+
hashes = file_hashes(path)
|
|
360
|
+
if hashes.sha256 != row.get("sha256") or hashes.git_blob_sha1 != row.get("git_blob_sha1"):
|
|
361
|
+
result.failures.append(rel)
|
|
362
|
+
|
|
363
|
+
if strict:
|
|
364
|
+
tracked = set(manifest)
|
|
365
|
+
result.extras = [
|
|
366
|
+
path.relative_to(root).as_posix()
|
|
367
|
+
for path in iter_payload_files(root)
|
|
368
|
+
if path.relative_to(root).as_posix() not in tracked
|
|
369
|
+
]
|
|
370
|
+
|
|
371
|
+
return result
|