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/hub.py
ADDED
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
from unittest.mock import patch
|
|
17
|
+
|
|
18
|
+
from .checksums import (
|
|
19
|
+
FileHashes,
|
|
20
|
+
HashingWriter,
|
|
21
|
+
checksum_row_from_hashes,
|
|
22
|
+
file_hashes,
|
|
23
|
+
hash_file_prefix,
|
|
24
|
+
load_manifest,
|
|
25
|
+
record_is_current,
|
|
26
|
+
write_manifest,
|
|
27
|
+
)
|
|
28
|
+
from .config import (
|
|
29
|
+
Config,
|
|
30
|
+
TOKEN_SETUP_HINT,
|
|
31
|
+
apply_hf_environment,
|
|
32
|
+
archive_runtime_tmp_path,
|
|
33
|
+
hf_token_available,
|
|
34
|
+
)
|
|
35
|
+
from .progress import DEFAULT_STALL_RETRIES, ProgressRecorder
|
|
36
|
+
from .verify import metadata_blob_id, metadata_lfs_sha256, metadata_path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class HubFile:
|
|
41
|
+
path: str
|
|
42
|
+
size: int | None
|
|
43
|
+
lfs_sha256: str | None = None
|
|
44
|
+
blob_id: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class HubSnapshot:
|
|
49
|
+
repo_id: str
|
|
50
|
+
repo_type: str
|
|
51
|
+
requested_revision: str
|
|
52
|
+
resolved_commit: str
|
|
53
|
+
files: list[HubFile]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DownloadIntegrityError(RuntimeError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class StallTimeoutError(TimeoutError):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
SNAPSHOT_PLAN_DIR = ".model-mirror"
|
|
65
|
+
SNAPSHOT_PLAN_FILE = "snapshot.json"
|
|
66
|
+
SNAPSHOT_PLAN_SCHEMA = "model-mirror-snapshot"
|
|
67
|
+
SNAPSHOT_PLAN_VERSION = 1
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HuggingFaceHub:
|
|
71
|
+
def __init__(self, config: Config):
|
|
72
|
+
self.config = config
|
|
73
|
+
self._warned_missing_token = False
|
|
74
|
+
|
|
75
|
+
def files(self, repo_id: str, repo_type: str, revision: str) -> list[HubFile]:
|
|
76
|
+
return self.snapshot(repo_id, repo_type, revision).files
|
|
77
|
+
|
|
78
|
+
def snapshot(self, repo_id: str, repo_type: str, revision: str) -> HubSnapshot:
|
|
79
|
+
with patch.dict(os.environ, self._environment(), clear=False):
|
|
80
|
+
from huggingface_hub import HfApi
|
|
81
|
+
|
|
82
|
+
api = HfApi()
|
|
83
|
+
info = api.repo_info(repo_id, repo_type=repo_type, revision=revision, files_metadata=True)
|
|
84
|
+
return HubSnapshot(
|
|
85
|
+
repo_id=repo_id,
|
|
86
|
+
repo_type=repo_type,
|
|
87
|
+
requested_revision=revision,
|
|
88
|
+
resolved_commit=getattr(info, "sha", revision),
|
|
89
|
+
files=[self._file_from_sibling(sibling) for sibling in getattr(info, "siblings", []) or []],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def snapshot_download(
|
|
93
|
+
self,
|
|
94
|
+
repo_id: str,
|
|
95
|
+
repo_type: str,
|
|
96
|
+
revision: str,
|
|
97
|
+
local_dir: Path,
|
|
98
|
+
allow_patterns: list[str] | None = None,
|
|
99
|
+
stall_timeout_seconds: int | None = None,
|
|
100
|
+
) -> Path:
|
|
101
|
+
local_dir.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
snapshot = self.snapshot(repo_id, repo_type, revision)
|
|
103
|
+
return self.download_snapshot(
|
|
104
|
+
snapshot,
|
|
105
|
+
local_dir,
|
|
106
|
+
allow_patterns=allow_patterns,
|
|
107
|
+
stall_timeout_seconds=stall_timeout_seconds,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def download_snapshot(
|
|
111
|
+
self,
|
|
112
|
+
snapshot: HubSnapshot,
|
|
113
|
+
local_dir: Path,
|
|
114
|
+
allow_patterns: list[str] | None = None,
|
|
115
|
+
stall_timeout_seconds: int | None = None,
|
|
116
|
+
) -> Path:
|
|
117
|
+
local_dir.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
staging_dir = download_staging_dir(
|
|
119
|
+
self.config,
|
|
120
|
+
snapshot.repo_id,
|
|
121
|
+
snapshot.repo_type,
|
|
122
|
+
snapshot.resolved_commit,
|
|
123
|
+
allow_patterns,
|
|
124
|
+
)
|
|
125
|
+
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
prune_incomplete_downloads(staging_dir)
|
|
127
|
+
env = download_environment(self._environment(), staging_dir)
|
|
128
|
+
with patch.dict(os.environ, env, clear=False):
|
|
129
|
+
stream_snapshot(
|
|
130
|
+
snapshot,
|
|
131
|
+
local_dir,
|
|
132
|
+
staging_dir,
|
|
133
|
+
allow_patterns=allow_patterns,
|
|
134
|
+
download_workers=self.config.download_workers,
|
|
135
|
+
stall_timeout_seconds=(
|
|
136
|
+
self.config.stall_timeout_seconds if stall_timeout_seconds is None else stall_timeout_seconds
|
|
137
|
+
),
|
|
138
|
+
stall_retries=self.config.stall_retries,
|
|
139
|
+
)
|
|
140
|
+
shutil.rmtree(staging_dir)
|
|
141
|
+
return local_dir
|
|
142
|
+
|
|
143
|
+
def _environment(self) -> dict[str, str]:
|
|
144
|
+
env = apply_hf_environment(self.config)
|
|
145
|
+
if not hf_token_available(env) and not self._warned_missing_token:
|
|
146
|
+
print(
|
|
147
|
+
"warning: no Hugging Face token found; private or gated repositories may fail "
|
|
148
|
+
"and unauthenticated access can affect throughput. Configure a token file with: "
|
|
149
|
+
f"{TOKEN_SETUP_HINT} (or export HF_TOKEN).",
|
|
150
|
+
file=sys.stderr,
|
|
151
|
+
)
|
|
152
|
+
self._warned_missing_token = True
|
|
153
|
+
return env
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _file_from_sibling(sibling) -> HubFile:
|
|
157
|
+
lfs = getattr(sibling, "lfs", None)
|
|
158
|
+
lfs_sha256 = getattr(lfs, "sha256", None) if lfs is not None else None
|
|
159
|
+
return HubFile(
|
|
160
|
+
path=getattr(sibling, "rfilename"),
|
|
161
|
+
size=getattr(sibling, "size", None),
|
|
162
|
+
lfs_sha256=lfs_sha256,
|
|
163
|
+
blob_id=getattr(sibling, "blob_id", None),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def get_snapshot(hub, repo_id: str, repo_type: str, revision: str) -> HubSnapshot:
|
|
168
|
+
if hasattr(hub, "snapshot"):
|
|
169
|
+
return hub.snapshot(repo_id, repo_type, revision)
|
|
170
|
+
files = hub.files(repo_id, repo_type, revision)
|
|
171
|
+
return HubSnapshot(
|
|
172
|
+
repo_id=repo_id,
|
|
173
|
+
repo_type=repo_type,
|
|
174
|
+
requested_revision=revision,
|
|
175
|
+
resolved_commit=revision,
|
|
176
|
+
files=files,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def snapshot_plan_path(root: Path) -> Path:
|
|
181
|
+
return root / SNAPSHOT_PLAN_DIR / SNAPSHOT_PLAN_FILE
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def write_snapshot_plan(root: Path, snapshot: HubSnapshot) -> Path:
|
|
185
|
+
path = snapshot_plan_path(root)
|
|
186
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
payload = {
|
|
188
|
+
"schema": SNAPSHOT_PLAN_SCHEMA,
|
|
189
|
+
"version": SNAPSHOT_PLAN_VERSION,
|
|
190
|
+
"repo_id": snapshot.repo_id,
|
|
191
|
+
"repo_type": snapshot.repo_type,
|
|
192
|
+
"requested_revision": snapshot.requested_revision,
|
|
193
|
+
"resolved_commit": snapshot.resolved_commit,
|
|
194
|
+
"files": [
|
|
195
|
+
{
|
|
196
|
+
"path": item.path,
|
|
197
|
+
"size": item.size,
|
|
198
|
+
"lfs_sha256": getattr(item, "lfs_sha256", None),
|
|
199
|
+
"blob_id": getattr(item, "blob_id", None),
|
|
200
|
+
}
|
|
201
|
+
for item in snapshot.files
|
|
202
|
+
],
|
|
203
|
+
}
|
|
204
|
+
tmp = path.with_name(f"{path.name}.tmp")
|
|
205
|
+
tmp.write_text(json.dumps(payload, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
|
206
|
+
tmp.replace(path)
|
|
207
|
+
return path
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def read_snapshot_plan(root: Path) -> HubSnapshot | None:
|
|
211
|
+
path = snapshot_plan_path(root)
|
|
212
|
+
if not path.exists():
|
|
213
|
+
return None
|
|
214
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
215
|
+
if data.get("schema") != SNAPSHOT_PLAN_SCHEMA:
|
|
216
|
+
raise ValueError(f"Unsupported snapshot plan schema in {path}: {data.get('schema')}")
|
|
217
|
+
if data.get("version") != SNAPSHOT_PLAN_VERSION:
|
|
218
|
+
raise ValueError(f"Unsupported snapshot plan version in {path}: {data.get('version')}")
|
|
219
|
+
return HubSnapshot(
|
|
220
|
+
repo_id=str(data["repo_id"]),
|
|
221
|
+
repo_type=str(data["repo_type"]),
|
|
222
|
+
requested_revision=str(data["requested_revision"]),
|
|
223
|
+
resolved_commit=str(data["resolved_commit"]),
|
|
224
|
+
files=[
|
|
225
|
+
HubFile(
|
|
226
|
+
path=str(item["path"]),
|
|
227
|
+
size=item.get("size"),
|
|
228
|
+
lfs_sha256=item.get("lfs_sha256"),
|
|
229
|
+
blob_id=item.get("blob_id"),
|
|
230
|
+
)
|
|
231
|
+
for item in data.get("files", [])
|
|
232
|
+
],
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def compatible_snapshot_plan(
|
|
237
|
+
root: Path,
|
|
238
|
+
*,
|
|
239
|
+
repo_id: str,
|
|
240
|
+
repo_type: str,
|
|
241
|
+
requested_revision: str,
|
|
242
|
+
) -> HubSnapshot | None:
|
|
243
|
+
snapshot = read_snapshot_plan(root)
|
|
244
|
+
if snapshot is None:
|
|
245
|
+
return None
|
|
246
|
+
if snapshot.repo_id != repo_id:
|
|
247
|
+
return None
|
|
248
|
+
if snapshot.repo_type != repo_type:
|
|
249
|
+
return None
|
|
250
|
+
if snapshot.requested_revision != requested_revision:
|
|
251
|
+
return None
|
|
252
|
+
return snapshot
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def prune_incomplete_downloads(local_dir: Path) -> int:
|
|
256
|
+
download_dir = local_dir / ".cache" / "huggingface" / "download"
|
|
257
|
+
if not download_dir.exists():
|
|
258
|
+
return 0
|
|
259
|
+
removed = 0
|
|
260
|
+
for path in sorted(download_dir.rglob("*.incomplete")):
|
|
261
|
+
if not path.is_file():
|
|
262
|
+
continue
|
|
263
|
+
path.unlink()
|
|
264
|
+
removed += 1
|
|
265
|
+
return removed
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def stream_snapshot(
|
|
269
|
+
snapshot: HubSnapshot,
|
|
270
|
+
local_dir: Path,
|
|
271
|
+
staging_dir: Path,
|
|
272
|
+
*,
|
|
273
|
+
allow_patterns: list[str] | None,
|
|
274
|
+
download_workers: int = 1,
|
|
275
|
+
stall_timeout_seconds: int = 600,
|
|
276
|
+
stall_retries: int = DEFAULT_STALL_RETRIES,
|
|
277
|
+
) -> None:
|
|
278
|
+
manifest = load_manifest(local_dir)
|
|
279
|
+
selected_files = filtered_snapshot_files(snapshot.files, allow_patterns)
|
|
280
|
+
coverage_recorder = torrent_coverage_recorder(local_dir, snapshot)
|
|
281
|
+
progress_recorder = ProgressRecorder(local_dir)
|
|
282
|
+
work = []
|
|
283
|
+
for item in selected_files:
|
|
284
|
+
destination = local_dir / item.path
|
|
285
|
+
if verified_manifest_record(local_dir, destination, item, manifest):
|
|
286
|
+
continue
|
|
287
|
+
work.append(item)
|
|
288
|
+
|
|
289
|
+
if not work:
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
workers = max(1, download_workers)
|
|
293
|
+
if workers == 1:
|
|
294
|
+
for item in work:
|
|
295
|
+
row = stream_snapshot_file(
|
|
296
|
+
snapshot,
|
|
297
|
+
local_dir,
|
|
298
|
+
staging_dir,
|
|
299
|
+
item,
|
|
300
|
+
progress_recorder=progress_recorder,
|
|
301
|
+
coverage_recorder=coverage_recorder,
|
|
302
|
+
stall_timeout_seconds=stall_timeout_seconds,
|
|
303
|
+
stall_retries=stall_retries,
|
|
304
|
+
)
|
|
305
|
+
manifest[row["path"]] = row
|
|
306
|
+
write_manifest(local_dir, manifest)
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
310
|
+
futures = [
|
|
311
|
+
executor.submit(
|
|
312
|
+
stream_snapshot_file,
|
|
313
|
+
snapshot,
|
|
314
|
+
local_dir,
|
|
315
|
+
staging_dir,
|
|
316
|
+
item,
|
|
317
|
+
progress_recorder=progress_recorder,
|
|
318
|
+
coverage_recorder=coverage_recorder,
|
|
319
|
+
stall_timeout_seconds=stall_timeout_seconds,
|
|
320
|
+
stall_retries=stall_retries,
|
|
321
|
+
)
|
|
322
|
+
for item in work
|
|
323
|
+
]
|
|
324
|
+
errors = []
|
|
325
|
+
for future in as_completed(futures):
|
|
326
|
+
try:
|
|
327
|
+
row = future.result()
|
|
328
|
+
except Exception as exc:
|
|
329
|
+
errors.append(exc)
|
|
330
|
+
continue
|
|
331
|
+
manifest[row["path"]] = row
|
|
332
|
+
write_manifest(local_dir, manifest)
|
|
333
|
+
if errors:
|
|
334
|
+
raise errors[0]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def stream_snapshot_file(
|
|
338
|
+
snapshot: HubSnapshot,
|
|
339
|
+
local_dir: Path,
|
|
340
|
+
staging_dir: Path,
|
|
341
|
+
item: HubFile,
|
|
342
|
+
*,
|
|
343
|
+
progress_recorder: ProgressRecorder,
|
|
344
|
+
coverage_recorder=None,
|
|
345
|
+
stall_timeout_seconds: int,
|
|
346
|
+
stall_retries: int,
|
|
347
|
+
) -> dict:
|
|
348
|
+
destination = local_dir / item.path
|
|
349
|
+
progress = progress_recorder.track(item.path, total=item.size, stage="starting")
|
|
350
|
+
completed = False
|
|
351
|
+
try:
|
|
352
|
+
if destination.exists() and destination.is_file():
|
|
353
|
+
accumulator = coverage_recorder.accumulator(item) if coverage_recorder is not None else None
|
|
354
|
+
existing = hash_existing_file(
|
|
355
|
+
local_dir,
|
|
356
|
+
destination,
|
|
357
|
+
item,
|
|
358
|
+
progress=progress,
|
|
359
|
+
torrent_accumulator=accumulator,
|
|
360
|
+
)
|
|
361
|
+
if existing is not None:
|
|
362
|
+
row, hashes = existing
|
|
363
|
+
record_torrent_coverage(coverage_recorder, item, destination, hashes, accumulator)
|
|
364
|
+
completed = True
|
|
365
|
+
return row
|
|
366
|
+
destination.unlink()
|
|
367
|
+
|
|
368
|
+
accumulator = coverage_recorder.accumulator(item) if coverage_recorder is not None else None
|
|
369
|
+
staged = promote_legacy_staged_file(
|
|
370
|
+
local_dir,
|
|
371
|
+
staging_dir,
|
|
372
|
+
item,
|
|
373
|
+
progress=progress,
|
|
374
|
+
torrent_accumulator=accumulator,
|
|
375
|
+
)
|
|
376
|
+
if staged is not None:
|
|
377
|
+
row, hashes = staged
|
|
378
|
+
record_torrent_coverage(coverage_recorder, item, destination, hashes, accumulator)
|
|
379
|
+
completed = True
|
|
380
|
+
return row
|
|
381
|
+
|
|
382
|
+
accumulator = coverage_recorder.accumulator(item) if coverage_recorder is not None else None
|
|
383
|
+
hashes = stream_file_to_path(
|
|
384
|
+
snapshot,
|
|
385
|
+
item,
|
|
386
|
+
destination,
|
|
387
|
+
torrent_accumulator=accumulator,
|
|
388
|
+
progress=progress,
|
|
389
|
+
stall_timeout_seconds=stall_timeout_seconds,
|
|
390
|
+
stall_retries=stall_retries,
|
|
391
|
+
)
|
|
392
|
+
record_torrent_coverage(coverage_recorder, item, destination, hashes, accumulator)
|
|
393
|
+
completed = True
|
|
394
|
+
return checksum_row_from_hashes(local_dir, destination, hashes)
|
|
395
|
+
finally:
|
|
396
|
+
if completed:
|
|
397
|
+
progress.finish()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def filtered_snapshot_files(files: list[HubFile], allow_patterns: list[str] | None) -> list[HubFile]:
|
|
401
|
+
if allow_patterns is None:
|
|
402
|
+
return list(files)
|
|
403
|
+
patterns = list(allow_patterns)
|
|
404
|
+
return [item for item in files if any(fnmatch.fnmatchcase(item.path, pattern) for pattern in patterns)]
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def verified_manifest_record(
|
|
408
|
+
root: Path,
|
|
409
|
+
path: Path,
|
|
410
|
+
item: HubFile,
|
|
411
|
+
manifest: dict[str, dict],
|
|
412
|
+
) -> bool:
|
|
413
|
+
if not path.exists() or not path.is_file():
|
|
414
|
+
return False
|
|
415
|
+
expected_size = require_expected_size(item)
|
|
416
|
+
stat = path.stat()
|
|
417
|
+
if stat.st_size != expected_size:
|
|
418
|
+
return False
|
|
419
|
+
rel = path.relative_to(root).as_posix()
|
|
420
|
+
row = manifest.get(rel)
|
|
421
|
+
if not record_is_current(row, stat.st_size, stat.st_mtime_ns):
|
|
422
|
+
return False
|
|
423
|
+
return manifest_hash_matches(item, row)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def manifest_hash_matches(item: HubFile, row: dict | None) -> bool:
|
|
427
|
+
if row is None:
|
|
428
|
+
return False
|
|
429
|
+
if item.lfs_sha256 is not None:
|
|
430
|
+
return row.get("sha256") == item.lfs_sha256
|
|
431
|
+
if item.blob_id is not None:
|
|
432
|
+
return row.get("git_blob_sha1") == item.blob_id
|
|
433
|
+
return False
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def hash_existing_file(
|
|
437
|
+
root: Path,
|
|
438
|
+
path: Path,
|
|
439
|
+
item: HubFile,
|
|
440
|
+
*,
|
|
441
|
+
progress,
|
|
442
|
+
torrent_accumulator=None,
|
|
443
|
+
) -> tuple[dict, FileHashes] | None:
|
|
444
|
+
progress.update(0, stage="hashing-final", force=True)
|
|
445
|
+
hashes = file_hashes(
|
|
446
|
+
path,
|
|
447
|
+
accumulators=(torrent_accumulator,) if torrent_accumulator is not None else (),
|
|
448
|
+
on_progress=lambda done: progress.update(done, stage="hashing-final"),
|
|
449
|
+
)
|
|
450
|
+
try:
|
|
451
|
+
ensure_hashes_match(item, path, hashes)
|
|
452
|
+
except DownloadIntegrityError:
|
|
453
|
+
return None
|
|
454
|
+
return checksum_row_from_hashes(root, path, hashes), hashes
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def promote_legacy_staged_file(
|
|
458
|
+
root: Path,
|
|
459
|
+
staging_dir: Path,
|
|
460
|
+
item: HubFile,
|
|
461
|
+
*,
|
|
462
|
+
progress,
|
|
463
|
+
torrent_accumulator=None,
|
|
464
|
+
) -> tuple[dict, FileHashes] | None:
|
|
465
|
+
staged_path = staging_dir / item.path
|
|
466
|
+
if not staged_path.exists() or not staged_path.is_file():
|
|
467
|
+
return None
|
|
468
|
+
progress.update(0, stage="hashing-staged", force=True)
|
|
469
|
+
hashes = file_hashes(
|
|
470
|
+
staged_path,
|
|
471
|
+
accumulators=(torrent_accumulator,) if torrent_accumulator is not None else (),
|
|
472
|
+
on_progress=lambda done: progress.update(done, stage="hashing-staged"),
|
|
473
|
+
)
|
|
474
|
+
try:
|
|
475
|
+
ensure_hashes_match(item, staged_path, hashes)
|
|
476
|
+
except DownloadIntegrityError:
|
|
477
|
+
staged_path.unlink()
|
|
478
|
+
return None
|
|
479
|
+
destination = root / item.path
|
|
480
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
481
|
+
progress.update(staged_path.stat().st_size, stage="promoting-staged", force=True)
|
|
482
|
+
staged_path.replace(destination)
|
|
483
|
+
return checksum_row_from_hashes(root, destination, hashes), hashes
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def stream_file_to_path(
|
|
487
|
+
snapshot: HubSnapshot,
|
|
488
|
+
item: HubFile,
|
|
489
|
+
destination: Path,
|
|
490
|
+
*,
|
|
491
|
+
torrent_accumulator=None,
|
|
492
|
+
progress=None,
|
|
493
|
+
stall_timeout_seconds: int = 600,
|
|
494
|
+
stall_retries: int = DEFAULT_STALL_RETRIES,
|
|
495
|
+
) -> FileHashes:
|
|
496
|
+
integrity_retry_used = False
|
|
497
|
+
stall_retry_count = 0
|
|
498
|
+
allow_resume = True
|
|
499
|
+
while True:
|
|
500
|
+
if torrent_accumulator is not None:
|
|
501
|
+
torrent_accumulator.reset()
|
|
502
|
+
try:
|
|
503
|
+
return stream_file_to_path_once(
|
|
504
|
+
snapshot,
|
|
505
|
+
item,
|
|
506
|
+
destination,
|
|
507
|
+
allow_resume=allow_resume,
|
|
508
|
+
torrent_accumulator=torrent_accumulator,
|
|
509
|
+
progress=progress,
|
|
510
|
+
stall_timeout_seconds=stall_timeout_seconds,
|
|
511
|
+
)
|
|
512
|
+
except StallTimeoutError:
|
|
513
|
+
if stall_retry_count >= stall_retries:
|
|
514
|
+
raise
|
|
515
|
+
stall_retry_count += 1
|
|
516
|
+
allow_resume = True
|
|
517
|
+
partial = incomplete_path_for(destination)
|
|
518
|
+
current_size = partial.stat().st_size if partial.exists() else 0
|
|
519
|
+
if progress is not None:
|
|
520
|
+
progress.update(current_size, stage=f"retrying-stall-{stall_retry_count}", force=True)
|
|
521
|
+
continue
|
|
522
|
+
except DownloadIntegrityError:
|
|
523
|
+
if integrity_retry_used:
|
|
524
|
+
raise
|
|
525
|
+
integrity_retry_used = True
|
|
526
|
+
allow_resume = False
|
|
527
|
+
incomplete_path_for(destination).unlink(missing_ok=True)
|
|
528
|
+
destination.unlink(missing_ok=True)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def stream_file_to_path_once(
|
|
532
|
+
snapshot: HubSnapshot,
|
|
533
|
+
item: HubFile,
|
|
534
|
+
destination: Path,
|
|
535
|
+
*,
|
|
536
|
+
allow_resume: bool,
|
|
537
|
+
torrent_accumulator=None,
|
|
538
|
+
progress=None,
|
|
539
|
+
stall_timeout_seconds: int = 600,
|
|
540
|
+
) -> FileHashes:
|
|
541
|
+
expected_size = require_expected_size(item)
|
|
542
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
543
|
+
tmp_path = incomplete_path_for(destination)
|
|
544
|
+
if not allow_resume:
|
|
545
|
+
tmp_path.unlink(missing_ok=True)
|
|
546
|
+
resume_size = resumable_prefix_size(tmp_path, expected_size) if allow_resume else 0
|
|
547
|
+
hash_state = (
|
|
548
|
+
hash_file_prefix(
|
|
549
|
+
tmp_path,
|
|
550
|
+
total_size=expected_size,
|
|
551
|
+
prefix_size=resume_size,
|
|
552
|
+
accumulators=(torrent_accumulator,) if torrent_accumulator is not None else (),
|
|
553
|
+
on_progress=(
|
|
554
|
+
(lambda done: progress.update(done, stage="hashing-partial")) if progress is not None else None
|
|
555
|
+
),
|
|
556
|
+
)
|
|
557
|
+
if resume_size
|
|
558
|
+
else None
|
|
559
|
+
)
|
|
560
|
+
if resume_size == expected_size:
|
|
561
|
+
if progress is not None:
|
|
562
|
+
progress.update(resume_size, stage="verifying-partial", force=True)
|
|
563
|
+
hashes = (
|
|
564
|
+
hash_state.hashes
|
|
565
|
+
if hash_state is not None
|
|
566
|
+
else file_hashes(
|
|
567
|
+
tmp_path,
|
|
568
|
+
accumulators=(torrent_accumulator,) if torrent_accumulator is not None else (),
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
ensure_hashes_match(item, tmp_path, hashes)
|
|
572
|
+
tmp_path.replace(destination)
|
|
573
|
+
return hashes
|
|
574
|
+
try:
|
|
575
|
+
metadata, headers, url_to_download = hf_transport_metadata(snapshot, item)
|
|
576
|
+
if metadata.size is not None and metadata.size != expected_size:
|
|
577
|
+
raise DownloadIntegrityError(
|
|
578
|
+
f"size metadata mismatch for {item.path}: repo_info={expected_size} head={metadata.size}"
|
|
579
|
+
)
|
|
580
|
+
mode = "r+b" if tmp_path.exists() else "wb"
|
|
581
|
+
with tmp_path.open(mode) as raw:
|
|
582
|
+
raw.seek(resume_size)
|
|
583
|
+
if progress is not None:
|
|
584
|
+
progress.update(resume_size, stage="downloading", force=True)
|
|
585
|
+
writer = HashingWriter(
|
|
586
|
+
raw,
|
|
587
|
+
expected_size=expected_size,
|
|
588
|
+
hash_state=hash_state,
|
|
589
|
+
accumulators=(torrent_accumulator,) if torrent_accumulator is not None else (),
|
|
590
|
+
on_progress=(
|
|
591
|
+
(lambda done: progress.update(done, stage="downloading")) if progress is not None else None
|
|
592
|
+
),
|
|
593
|
+
)
|
|
594
|
+
if metadata.xet_file_data is not None:
|
|
595
|
+
stream_xet_file(
|
|
596
|
+
metadata.xet_file_data,
|
|
597
|
+
headers,
|
|
598
|
+
writer,
|
|
599
|
+
expected_size,
|
|
600
|
+
item.path,
|
|
601
|
+
resume_size,
|
|
602
|
+
stall_timeout_seconds=stall_timeout_seconds,
|
|
603
|
+
)
|
|
604
|
+
else:
|
|
605
|
+
stream_http_file(
|
|
606
|
+
url_to_download,
|
|
607
|
+
headers,
|
|
608
|
+
writer,
|
|
609
|
+
expected_size,
|
|
610
|
+
item.path,
|
|
611
|
+
resume_size,
|
|
612
|
+
stall_timeout_seconds=stall_timeout_seconds,
|
|
613
|
+
)
|
|
614
|
+
raw.flush()
|
|
615
|
+
os.fsync(raw.fileno())
|
|
616
|
+
hashes = writer.hashes
|
|
617
|
+
if tmp_path.stat().st_size != expected_size:
|
|
618
|
+
raise DownloadIntegrityError(
|
|
619
|
+
f"downloaded size mismatch for {item.path}: expected {expected_size}, got {tmp_path.stat().st_size}"
|
|
620
|
+
)
|
|
621
|
+
if progress is not None:
|
|
622
|
+
progress.update(expected_size, stage="verifying", force=True)
|
|
623
|
+
ensure_hashes_match(item, tmp_path, hashes)
|
|
624
|
+
tmp_path.replace(destination)
|
|
625
|
+
return hashes
|
|
626
|
+
except Exception as exc:
|
|
627
|
+
if not allow_resume and not isinstance(exc, StallTimeoutError):
|
|
628
|
+
tmp_path.unlink(missing_ok=True)
|
|
629
|
+
raise
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def torrent_coverage_recorder(root: Path, snapshot: HubSnapshot):
|
|
633
|
+
if not snapshot.files:
|
|
634
|
+
return None
|
|
635
|
+
for item in snapshot.files:
|
|
636
|
+
if item.size is None or (item.lfs_sha256 is None and item.blob_id is None):
|
|
637
|
+
return None
|
|
638
|
+
from .torrent_coverage import TorrentCoverageRecorder
|
|
639
|
+
|
|
640
|
+
return TorrentCoverageRecorder(root, snapshot)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def record_torrent_coverage(coverage_recorder, item, path: Path, hashes: FileHashes, accumulator) -> None:
|
|
644
|
+
if coverage_recorder is None or accumulator is None:
|
|
645
|
+
return
|
|
646
|
+
if accumulator.bytes_hashed != path.stat().st_size:
|
|
647
|
+
accumulator.reset()
|
|
648
|
+
fallback_hashes = file_hashes(path, accumulators=(accumulator,))
|
|
649
|
+
if fallback_hashes != hashes:
|
|
650
|
+
raise DownloadIntegrityError(f"streamed hashes changed before torrent coverage for {item.path}")
|
|
651
|
+
coverage_recorder.record(item, path, hashes, accumulator.finalize())
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def incomplete_path_for(destination: Path) -> Path:
|
|
655
|
+
return destination.with_name(f"{destination.name}.incomplete")
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def resumable_prefix_size(path: Path, expected_size: int) -> int:
|
|
659
|
+
if not path.exists() or not path.is_file():
|
|
660
|
+
return 0
|
|
661
|
+
size = path.stat().st_size
|
|
662
|
+
if size > expected_size:
|
|
663
|
+
path.unlink()
|
|
664
|
+
return 0
|
|
665
|
+
return size
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def hf_transport_metadata(snapshot: HubSnapshot, item: HubFile):
|
|
669
|
+
from huggingface_hub import hf_hub_url
|
|
670
|
+
from huggingface_hub.file_download import get_hf_file_metadata
|
|
671
|
+
from huggingface_hub.utils import build_hf_headers
|
|
672
|
+
|
|
673
|
+
headers = build_hf_headers()
|
|
674
|
+
url = hf_hub_url(
|
|
675
|
+
snapshot.repo_id,
|
|
676
|
+
item.path,
|
|
677
|
+
repo_type=snapshot.repo_type,
|
|
678
|
+
revision=snapshot.resolved_commit,
|
|
679
|
+
)
|
|
680
|
+
metadata = get_hf_file_metadata(url=url, headers=headers, retry_on_errors=True)
|
|
681
|
+
if metadata.commit_hash is not None and metadata.commit_hash != snapshot.resolved_commit:
|
|
682
|
+
raise DownloadIntegrityError(
|
|
683
|
+
f"commit metadata mismatch for {item.path}: expected {snapshot.resolved_commit}, got {metadata.commit_hash}"
|
|
684
|
+
)
|
|
685
|
+
url_to_download = metadata.location
|
|
686
|
+
if metadata.xet_file_data is None and url != metadata.location:
|
|
687
|
+
if urlparse(url).netloc != urlparse(metadata.location).netloc:
|
|
688
|
+
headers = dict(headers)
|
|
689
|
+
headers.pop("authorization", None)
|
|
690
|
+
return metadata, headers, url_to_download
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def stream_xet_file(
|
|
694
|
+
xet_file_data,
|
|
695
|
+
headers: dict[str, str],
|
|
696
|
+
writer: HashingWriter,
|
|
697
|
+
expected_size: int,
|
|
698
|
+
rel: str,
|
|
699
|
+
resume_size: int,
|
|
700
|
+
*,
|
|
701
|
+
stall_timeout_seconds: int,
|
|
702
|
+
) -> None:
|
|
703
|
+
from hf_xet import XetFileInfo
|
|
704
|
+
from huggingface_hub.utils._xet import abort_xet_session, get_xet_session, xet_headers_without_auth
|
|
705
|
+
from tqdm.auto import tqdm
|
|
706
|
+
|
|
707
|
+
session = get_xet_session()
|
|
708
|
+
xet_headers = xet_headers_without_auth(headers)
|
|
709
|
+
watchdog = StallWatchdog(
|
|
710
|
+
stall_timeout_seconds,
|
|
711
|
+
rel,
|
|
712
|
+
abort_callback=abort_xet_session if stall_timeout_seconds > 0 else None,
|
|
713
|
+
)
|
|
714
|
+
try:
|
|
715
|
+
with watchdog:
|
|
716
|
+
group = session.new_download_stream_group(
|
|
717
|
+
token_refresh_url=xet_file_data.refresh_route,
|
|
718
|
+
token_refresh_headers=headers,
|
|
719
|
+
custom_headers=xet_headers,
|
|
720
|
+
)
|
|
721
|
+
stream = group.download_stream(XetFileInfo(xet_file_data.file_hash, expected_size), start=resume_size)
|
|
722
|
+
display = rel if len(rel) <= 40 else f"(…){rel[-40:]}"
|
|
723
|
+
with tqdm(
|
|
724
|
+
total=expected_size,
|
|
725
|
+
initial=resume_size,
|
|
726
|
+
unit="B",
|
|
727
|
+
unit_scale=True,
|
|
728
|
+
desc=display,
|
|
729
|
+
leave=False,
|
|
730
|
+
) as progress:
|
|
731
|
+
for chunk in stream:
|
|
732
|
+
if chunk:
|
|
733
|
+
writer.write(chunk)
|
|
734
|
+
watchdog.progress()
|
|
735
|
+
progress.update(len(chunk))
|
|
736
|
+
watchdog.raise_if_stalled()
|
|
737
|
+
except KeyboardInterrupt:
|
|
738
|
+
abort_xet_session()
|
|
739
|
+
raise
|
|
740
|
+
except Exception as exc:
|
|
741
|
+
if watchdog.stalled:
|
|
742
|
+
raise StallTimeoutError(f"stalled download for {rel} after {stall_timeout_seconds}s") from exc
|
|
743
|
+
raise
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def stream_http_file(
|
|
747
|
+
url: str,
|
|
748
|
+
headers: dict[str, str],
|
|
749
|
+
writer: HashingWriter,
|
|
750
|
+
expected_size: int,
|
|
751
|
+
rel: str,
|
|
752
|
+
resume_size: int,
|
|
753
|
+
*,
|
|
754
|
+
stall_timeout_seconds: int,
|
|
755
|
+
) -> None:
|
|
756
|
+
from huggingface_hub.file_download import http_get
|
|
757
|
+
from huggingface_hub import constants
|
|
758
|
+
|
|
759
|
+
previous_timeout = constants.HF_HUB_DOWNLOAD_TIMEOUT
|
|
760
|
+
if stall_timeout_seconds > 0:
|
|
761
|
+
constants.HF_HUB_DOWNLOAD_TIMEOUT = stall_timeout_seconds
|
|
762
|
+
try:
|
|
763
|
+
http_get(
|
|
764
|
+
url=url,
|
|
765
|
+
temp_file=writer,
|
|
766
|
+
resume_size=resume_size,
|
|
767
|
+
headers=headers,
|
|
768
|
+
expected_size=expected_size,
|
|
769
|
+
displayed_filename=rel,
|
|
770
|
+
_nb_retries=0 if stall_timeout_seconds > 0 else 5,
|
|
771
|
+
)
|
|
772
|
+
except Exception as exc:
|
|
773
|
+
if stall_timeout_seconds > 0 and is_timeout_exception(exc):
|
|
774
|
+
raise StallTimeoutError(f"stalled HTTP download for {rel} after {stall_timeout_seconds}s") from exc
|
|
775
|
+
raise
|
|
776
|
+
finally:
|
|
777
|
+
constants.HF_HUB_DOWNLOAD_TIMEOUT = previous_timeout
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def is_timeout_exception(exc: Exception) -> bool:
|
|
781
|
+
return isinstance(exc, TimeoutError) or exc.__class__.__name__ in {"Timeout", "ReadTimeout", "ReadTimeoutError"}
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
class StallWatchdog:
|
|
785
|
+
def __init__(self, timeout_seconds: int, rel: str, *, abort_callback=None):
|
|
786
|
+
self.timeout_seconds = timeout_seconds
|
|
787
|
+
self.rel = rel
|
|
788
|
+
self.abort_callback = abort_callback
|
|
789
|
+
self.stalled = False
|
|
790
|
+
self._last_progress = time.monotonic()
|
|
791
|
+
self._stop = threading.Event()
|
|
792
|
+
self._thread: threading.Thread | None = None
|
|
793
|
+
|
|
794
|
+
def __enter__(self):
|
|
795
|
+
if self.timeout_seconds > 0:
|
|
796
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
797
|
+
self._thread.start()
|
|
798
|
+
return self
|
|
799
|
+
|
|
800
|
+
def __exit__(self, exc_type, exc, tb):
|
|
801
|
+
self._stop.set()
|
|
802
|
+
if self._thread is not None:
|
|
803
|
+
self._thread.join(timeout=1)
|
|
804
|
+
return False
|
|
805
|
+
|
|
806
|
+
def progress(self) -> None:
|
|
807
|
+
self._last_progress = time.monotonic()
|
|
808
|
+
|
|
809
|
+
def raise_if_stalled(self) -> None:
|
|
810
|
+
if self.stalled:
|
|
811
|
+
raise StallTimeoutError(f"stalled download for {self.rel} after {self.timeout_seconds}s")
|
|
812
|
+
|
|
813
|
+
def _run(self) -> None:
|
|
814
|
+
while not self._stop.wait(min(1.0, max(0.01, self.timeout_seconds / 10))):
|
|
815
|
+
if time.monotonic() - self._last_progress < self.timeout_seconds:
|
|
816
|
+
continue
|
|
817
|
+
self.stalled = True
|
|
818
|
+
if self.abort_callback is not None:
|
|
819
|
+
self.abort_callback()
|
|
820
|
+
return
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def ensure_hashes_match(item: HubFile, path: Path, hashes: FileHashes) -> None:
|
|
824
|
+
expected_size = require_expected_size(item)
|
|
825
|
+
actual_size = path.stat().st_size
|
|
826
|
+
if actual_size != expected_size:
|
|
827
|
+
raise DownloadIntegrityError(f"size mismatch for {item.path}: expected {expected_size}, got {actual_size}")
|
|
828
|
+
if item.lfs_sha256 is not None:
|
|
829
|
+
if hashes.sha256 != item.lfs_sha256:
|
|
830
|
+
raise DownloadIntegrityError(f"sha256 mismatch for {item.path}")
|
|
831
|
+
return
|
|
832
|
+
if item.blob_id is not None:
|
|
833
|
+
if hashes.git_blob_sha1 != item.blob_id:
|
|
834
|
+
raise DownloadIntegrityError(f"git blob hash mismatch for {item.path}")
|
|
835
|
+
return
|
|
836
|
+
raise DownloadIntegrityError(f"missing remote hash metadata for {item.path}")
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def require_expected_size(item: HubFile) -> int:
|
|
840
|
+
if item.size is None:
|
|
841
|
+
raise DownloadIntegrityError(f"missing size metadata for {item.path}")
|
|
842
|
+
return item.size
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def cached_manifest_verifies(root: Path, metadata) -> bool:
|
|
846
|
+
manifest = load_manifest(root)
|
|
847
|
+
if not manifest:
|
|
848
|
+
return False
|
|
849
|
+
for item in metadata:
|
|
850
|
+
rel = metadata_path(item)
|
|
851
|
+
path = root / rel
|
|
852
|
+
if not path.exists() or not path.is_file():
|
|
853
|
+
return False
|
|
854
|
+
stat = path.stat()
|
|
855
|
+
row = manifest.get(rel)
|
|
856
|
+
if not record_is_current(row, stat.st_size, stat.st_mtime_ns):
|
|
857
|
+
return False
|
|
858
|
+
expected_lfs_hash = metadata_lfs_sha256(item)
|
|
859
|
+
if expected_lfs_hash is not None and row.get("sha256") != expected_lfs_hash:
|
|
860
|
+
return False
|
|
861
|
+
expected_blob_id = metadata_blob_id(item)
|
|
862
|
+
if expected_lfs_hash is None and expected_blob_id is not None and row.get("git_blob_sha1") != expected_blob_id:
|
|
863
|
+
return False
|
|
864
|
+
return True
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def download_staging_dir(
|
|
868
|
+
config: Config,
|
|
869
|
+
repo_id: str,
|
|
870
|
+
repo_type: str,
|
|
871
|
+
revision: str,
|
|
872
|
+
allow_patterns: list[str] | None,
|
|
873
|
+
) -> Path:
|
|
874
|
+
tmp_root = archive_runtime_tmp_path(config)
|
|
875
|
+
scope = "all" if allow_patterns is None else "allow-" + stable_digest("\n".join(sorted(allow_patterns)))[:16]
|
|
876
|
+
slug = safe_slug(f"{repo_type}-{repo_id}-{revision}-{scope}")
|
|
877
|
+
identity = "\n".join((repo_type, repo_id, revision, scope))
|
|
878
|
+
return tmp_root / "downloads" / f"{slug}-{stable_digest(identity)[:16]}"
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def download_environment(env: dict[str, str], staging_dir: Path) -> dict[str, str]:
|
|
882
|
+
cache_dir = staging_dir / ".cache"
|
|
883
|
+
tmp_dir = staging_dir / ".tmp"
|
|
884
|
+
scoped = dict(env)
|
|
885
|
+
scoped["HF_HOME"] = str(cache_dir / "home")
|
|
886
|
+
scoped["HF_HUB_CACHE"] = str(cache_dir / "hub")
|
|
887
|
+
scoped["HF_ASSETS_CACHE"] = str(cache_dir / "assets")
|
|
888
|
+
scoped["HF_XET_CACHE"] = str(cache_dir / "xet")
|
|
889
|
+
scoped["TRANSFORMERS_CACHE"] = str(cache_dir / "transformers")
|
|
890
|
+
scoped["XDG_CACHE_HOME"] = str(cache_dir / "xdg")
|
|
891
|
+
scoped["TMPDIR"] = str(tmp_dir)
|
|
892
|
+
return scoped
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def safe_slug(value: str) -> str:
|
|
896
|
+
slug = re.sub(r"[^A-Za-z0-9._=-]+", "-", value).strip(".-")
|
|
897
|
+
return slug[:96] or "download"
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def stable_digest(value: str) -> str:
|
|
901
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|