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/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()