Zuletzt aktiv 2 days ago

download_hf_model.py Originalformat
1#!/usr/bin/env python3
2"""
3Download all files from a HuggingFace model repository with retries,
4resume support, and integrity verification.
5
6Usage:
7 python3 download_hf_model.py <repo_id> [repo_id ...]
8
9Examples:
10 python3 download_hf_model.py ai-sage/GigaAM-Multilingual
11 python3 download_hf_model.py meta-llama/Llama-3-8B-Instruct
12
13Each repo is downloaded into a directory named by replacing '/' with '.'
14in the repo_id (e.g. ai-sage.GigaAM-Multilingual).
15
16Adapted from download_lilith_q8.py.
17"""
18
19import argparse
20import hashlib
21import importlib
22import os
23import subprocess
24import sys
25import tempfile
26import time
27from pathlib import Path
28
29MAX_RETRIES = 10
30RETRY_DELAY_BASE = 15 # seconds, doubles each retry
31
32BASE_DIR = Path(__file__).resolve().parent
33RUNTIME_DIR = BASE_DIR / ".hf_download_runtime"
34HF_HOME_DIR = RUNTIME_DIR / "hf-home"
35HF_HUB_CACHE_DIR = HF_HOME_DIR / "hub"
36HF_ASSETS_CACHE_DIR = HF_HOME_DIR / "assets"
37XDG_CACHE_HOME_DIR = RUNTIME_DIR / "xdg-cache"
38PIP_CACHE_DIR = RUNTIME_DIR / "pip-cache"
39PYTHON_VENDOR_DIR = RUNTIME_DIR / "python-vendor"
40TMP_DIR = RUNTIME_DIR / "tmp"
41
42
43def prepare_runtime_dirs() -> None:
44 for path in (
45 RUNTIME_DIR,
46 HF_HOME_DIR,
47 HF_HUB_CACHE_DIR,
48 HF_ASSETS_CACHE_DIR,
49 XDG_CACHE_HOME_DIR,
50 PIP_CACHE_DIR,
51 PYTHON_VENDOR_DIR,
52 TMP_DIR,
53 ):
54 path.mkdir(parents=True, exist_ok=True)
55
56 os.environ["HF_HOME"] = str(HF_HOME_DIR)
57 os.environ["HF_HUB_CACHE"] = str(HF_HUB_CACHE_DIR)
58 os.environ["HF_ASSETS_CACHE"] = str(HF_ASSETS_CACHE_DIR)
59 os.environ["XDG_CACHE_HOME"] = str(XDG_CACHE_HOME_DIR)
60 os.environ["PIP_CACHE_DIR"] = str(PIP_CACHE_DIR)
61 os.environ["HF_HUB_DISABLE_XET"] = "1"
62 os.environ["HF_XET_RECONSTRUCT_WRITE_SEQUENTIALLY"] = "1"
63 os.environ["TMPDIR"] = str(TMP_DIR)
64 os.environ["TMP"] = str(TMP_DIR)
65 os.environ["TEMP"] = str(TMP_DIR)
66 tempfile.tempdir = str(TMP_DIR)
67
68 if str(PYTHON_VENDOR_DIR) not in sys.path:
69 sys.path.insert(0, str(PYTHON_VENDOR_DIR))
70
71
72prepare_runtime_dirs()
73
74
75def parse_major_minor(version: str) -> tuple[int, int]:
76 parts = []
77 for chunk in version.split("."):
78 digits = "".join(ch for ch in chunk if ch.isdigit())
79 if digits:
80 parts.append(int(digits))
81 if len(parts) == 2:
82 break
83 while len(parts) < 2:
84 parts.append(0)
85 return parts[0], parts[1]
86
87
88def install_huggingface_hub() -> None:
89 print("Installing newer huggingface_hub into the local runtime dir...")
90 subprocess.check_call(
91 [
92 sys.executable,
93 "-m",
94 "pip",
95 "install",
96 "--upgrade",
97 "--target",
98 str(PYTHON_VENDOR_DIR),
99 "--cache-dir",
100 str(PIP_CACHE_DIR),
101 "huggingface_hub>=1.0,<2",
102 ]
103 )
104
105
106try:
107 import huggingface_hub as _huggingface_hub
108
109 if parse_major_minor(_huggingface_hub.__version__) < (1, 0):
110 raise ImportError(f"Found old huggingface_hub {_huggingface_hub.__version__}")
111
112 from huggingface_hub import HfApi, hf_hub_url
113 from huggingface_hub.utils import RepositoryNotFoundError
114except Exception:
115 install_huggingface_hub()
116 importlib.invalidate_caches()
117 for name in list(sys.modules):
118 if name == "huggingface_hub" or name.startswith("huggingface_hub."):
119 del sys.modules[name]
120
121 from huggingface_hub import HfApi, hf_hub_url
122 from huggingface_hub.utils import RepositoryNotFoundError
123
124
125def load_token() -> str | None:
126 token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
127 if token:
128 return token
129
130 token_files = [
131 HF_HOME_DIR / "token",
132 Path.home() / ".cache" / "huggingface" / "token",
133 ]
134 for token_file in token_files:
135 if token_file.exists():
136 return token_file.read_text().strip()
137 return None
138
139
140TOKEN = load_token()
141if not TOKEN:
142 print("HF token not found. Proceeding without authentication (repo may be public).")
143else:
144 print(f"Using HF token: {TOKEN[:8]}...")
145
146
147def repo_to_dirname(repo_id: str) -> str:
148 return repo_id.replace("/", ".")
149
150
151def get_dir_size(path: Path) -> int:
152 return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
153
154
155def expected_size_for(sibling) -> int | None:
156 size = getattr(sibling, "size", None)
157 if size is not None:
158 return size
159 if sibling.lfs and sibling.lfs.get("size") is not None:
160 return sibling.lfs["size"]
161 return None
162
163
164def format_size(n: int | None) -> str:
165 if n is None:
166 return "unknown size"
167 if n >= 1e9:
168 return f"{n / 1e9:.2f} GB"
169 if n >= 1e6:
170 return f"{n / 1e6:.2f} MB"
171 return f"{n / 1e3:.2f} KB"
172
173
174def quarantine_corrupt_files(paths: list[Path], download_dir: Path) -> None:
175 if not paths:
176 return
177 quarantine_dir = download_dir / ".corrupt"
178 quarantine_dir.mkdir(exist_ok=True)
179 stamp = int(time.time())
180 for path in paths:
181 target = quarantine_dir / f"{path.name}.{stamp}.corrupt"
182 suffix = 1
183 while target.exists():
184 target = quarantine_dir / f"{path.name}.{stamp}.{suffix}.corrupt"
185 suffix += 1
186 path.replace(target)
187 print(f"Moved corrupt file aside: {target}")
188
189
190def download_file(rel_path: str, dest: Path, repo_id: str) -> None:
191 url = hf_hub_url(repo_id=repo_id, filename=rel_path)
192 cmd = [
193 "curl",
194 "-L",
195 "--fail",
196 "--show-error",
197 "--connect-timeout",
198 "30",
199 "--progress-bar",
200 "-C",
201 "-",
202 "-o",
203 str(dest),
204 url,
205 ]
206 if TOKEN:
207 cmd[1:1] = ["-H", f"Authorization: Bearer {TOKEN}"]
208 subprocess.check_call(cmd)
209
210
211def download_repo_with_retries(repo_id: str, download_dir: Path) -> Path:
212 """Download all files from a repo with curl, resume, and exponential backoff."""
213 for attempt in range(1, MAX_RETRIES + 1):
214 try:
215 print(f"\n{'=' * 60}")
216 print(f"Download attempt {attempt}/{MAX_RETRIES}")
217 print(f"{'=' * 60}")
218
219 api = HfApi(token=TOKEN)
220 repo_info = api.repo_info(repo_id=repo_id, token=TOKEN, files_metadata=True)
221 siblings = [s for s in (repo_info.siblings or []) if s.rfilename]
222
223 if attempt == 1:
224 # Disk space check
225 needed = 0
226 for s in siblings:
227 exp = expected_size_for(s)
228 dest = download_dir / s.rfilename
229 have = dest.stat().st_size if dest.exists() else 0
230 if exp is not None:
231 needed += max(exp - have, 0)
232 try:
233 stat = os.statvfs(download_dir)
234 free = stat.f_bavail * stat.f_frsize
235 print(f"Disk check: need ~{format_size(needed)} more, free ~{format_size(free)} on destination.")
236 if needed > free:
237 print("WARNING: not enough free space for the remaining download.")
238 except OSError:
239 pass
240
241 siblings = sorted(
242 siblings,
243 key=lambda s: expected_size_for(s) or 0,
244 reverse=True,
245 )
246
247 size_before = get_dir_size(download_dir) if download_dir.exists() else 0
248 t0 = time.time()
249
250 for index, sibling in enumerate(siblings, start=1):
251 rel_path = sibling.rfilename
252 dest = download_dir / rel_path
253 dest.parent.mkdir(parents=True, exist_ok=True)
254
255 expected_size = expected_size_for(sibling)
256 current_size = dest.stat().st_size if dest.exists() else 0
257
258 if expected_size is not None and dest.exists() and current_size == expected_size:
259 print(
260 f"[{index}/{len(siblings)}] Already present: {rel_path} "
261 f"({format_size(expected_size)})"
262 )
263 continue
264
265 if expected_size is not None and dest.exists() and current_size > expected_size:
266 quarantine_corrupt_files([dest], download_dir)
267 current_size = 0
268
269 if current_size > 0:
270 print(
271 f"[{index}/{len(siblings)}] Resuming: {rel_path} "
272 f"from {format_size(current_size)} / {format_size(expected_size)}"
273 )
274 else:
275 print(f"[{index}/{len(siblings)}] Downloading: {rel_path} ({format_size(expected_size)})")
276
277 download_file(rel_path, dest, repo_id)
278
279 if expected_size is not None:
280 actual_size = dest.stat().st_size
281 if actual_size != expected_size:
282 raise ValueError(
283 f"Size mismatch after download for {rel_path}: "
284 f"expected {expected_size}, got {actual_size}"
285 )
286
287 elapsed = time.time() - t0
288 size_after = get_dir_size(download_dir)
289 downloaded = max(size_after - size_before, 0)
290 speed = downloaded / elapsed if elapsed > 0 else 0
291
292 print(f"\nDownload complete: {download_dir}")
293 print(f" Downloaded: {format_size(downloaded)} in {elapsed:.0f}s")
294 print(f" Avg speed: {speed / 1e6:.1f} MB/s")
295 return download_dir
296
297 except KeyboardInterrupt:
298 print("\nInterrupted by user.")
299 sys.exit(1)
300
301 except (RepositoryNotFoundError, FileNotFoundError) as exc:
302 print(f"\nFatal error: {exc}")
303 sys.exit(1)
304
305 except Exception as exc:
306 delay = RETRY_DELAY_BASE * (2 ** (attempt - 1))
307 print(f"\nError on attempt {attempt}: {exc}")
308 if attempt < MAX_RETRIES:
309 print(f"Retrying in {delay}s...")
310 time.sleep(delay)
311 else:
312 print("All retries exhausted.")
313 sys.exit(1)
314
315
316def verify_integrity(repo_id: str, download_dir: Path) -> tuple[bool, list[Path]]:
317 """Verify all downloaded files against repo metadata."""
318 print(f"\n{'=' * 60}")
319 print("Verifying file integrity...")
320 print(f"{'=' * 60}")
321
322 api = HfApi(token=TOKEN)
323 try:
324 repo_info = api.repo_info(repo_id=repo_id, token=TOKEN, files_metadata=True)
325 except Exception as exc:
326 print(f"Could not fetch repo metadata for verification: {exc}")
327 return False, []
328
329 siblings = [s for s in (repo_info.siblings or []) if s.rfilename]
330 errors = []
331 checked = 0
332 corrupt_files: list[Path] = []
333
334 for sibling in siblings:
335 if not sibling.rfilename:
336 continue
337
338 file_path = download_dir / sibling.rfilename
339 if not file_path.exists():
340 errors.append(f"MISSING: {sibling.rfilename}")
341 continue
342
343 expected_sha = None
344 hash_type = None
345 if sibling.lfs and sibling.lfs.get("sha256"):
346 expected_sha = sibling.lfs["sha256"]
347 hash_type = "sha256"
348 elif hasattr(sibling, "blob_id") and sibling.blob_id:
349 expected_sha = sibling.blob_id
350 hash_type = "git-sha1"
351 else:
352 continue
353
354 checked += 1
355 size = file_path.stat().st_size
356 print(f" Checking {sibling.rfilename} ({format_size(size)})...", end=" ", flush=True)
357
358 if hash_type == "sha256":
359 digest = hashlib.sha256()
360 with open(file_path, "rb") as handle:
361 while chunk := handle.read(8 * 1024 * 1024):
362 digest.update(chunk)
363 actual = digest.hexdigest()
364 else:
365 digest = hashlib.sha1()
366 digest.update(f"blob {size}\0".encode())
367 with open(file_path, "rb") as handle:
368 while chunk := handle.read(8 * 1024 * 1024):
369 digest.update(chunk)
370 actual = digest.hexdigest()
371
372 if actual == expected_sha:
373 print("OK")
374 else:
375 print("MISMATCH")
376 corrupt_files.append(file_path)
377 errors.append(
378 f"HASH MISMATCH: {sibling.rfilename}\n"
379 f" expected: {expected_sha}\n"
380 f" actual: {actual}"
381 )
382
383 print(f"\nChecked {checked} file(s).")
384
385 if errors:
386 print("\nIntegrity errors:")
387 for err in errors:
388 print(f" - {err}")
389 return False, corrupt_files
390
391 print("All files verified successfully.")
392 return True, []
393
394
395def main() -> None:
396 parser = argparse.ArgumentParser(
397 description="Download files from HuggingFace model repositories with retry and verification."
398 )
399 parser.add_argument(
400 "repos",
401 nargs="+",
402 help="HuggingFace repo IDs to download (e.g. ai-sage/GigaAM-Multilingual)",
403 )
404 args = parser.parse_args()
405
406 for repo_id in args.repos:
407 dirname = repo_to_dirname(repo_id)
408 download_dir = BASE_DIR / dirname
409 download_dir.mkdir(parents=True, exist_ok=True)
410
411 print(f"\n{'#' * 60}")
412 print(f"# Repo: {repo_id}")
413 print(f"# Target: {download_dir}")
414 print(f"{'#' * 60}")
415
416 download_repo_with_retries(repo_id, download_dir)
417
418 ok, corrupt_files = verify_integrity(repo_id, download_dir)
419 if not ok:
420 quarantine_corrupt_files(corrupt_files, download_dir)
421 print(f"\nIntegrity check FAILED for {repo_id}. Re-run the script to resume.")
422 sys.exit(1)
423
424 print(f"\nDone. Model saved to: {download_dir}")
425
426
427if __name__ == "__main__":
428 main()
429