KarelWintersky ревизій цього gist 2 days ago. До ревизії
1 file changed, 428 insertions
download_hf_model.py(файл створено)
| @@ -0,0 +1,428 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | """ | |
| 3 | + | Download all files from a HuggingFace model repository with retries, | |
| 4 | + | resume support, and integrity verification. | |
| 5 | + | ||
| 6 | + | Usage: | |
| 7 | + | python3 download_hf_model.py <repo_id> [repo_id ...] | |
| 8 | + | ||
| 9 | + | Examples: | |
| 10 | + | python3 download_hf_model.py ai-sage/GigaAM-Multilingual | |
| 11 | + | python3 download_hf_model.py meta-llama/Llama-3-8B-Instruct | |
| 12 | + | ||
| 13 | + | Each repo is downloaded into a directory named by replacing '/' with '.' | |
| 14 | + | in the repo_id (e.g. ai-sage.GigaAM-Multilingual). | |
| 15 | + | ||
| 16 | + | Adapted from download_lilith_q8.py. | |
| 17 | + | """ | |
| 18 | + | ||
| 19 | + | import argparse | |
| 20 | + | import hashlib | |
| 21 | + | import importlib | |
| 22 | + | import os | |
| 23 | + | import subprocess | |
| 24 | + | import sys | |
| 25 | + | import tempfile | |
| 26 | + | import time | |
| 27 | + | from pathlib import Path | |
| 28 | + | ||
| 29 | + | MAX_RETRIES = 10 | |
| 30 | + | RETRY_DELAY_BASE = 15 # seconds, doubles each retry | |
| 31 | + | ||
| 32 | + | BASE_DIR = Path(__file__).resolve().parent | |
| 33 | + | RUNTIME_DIR = BASE_DIR / ".hf_download_runtime" | |
| 34 | + | HF_HOME_DIR = RUNTIME_DIR / "hf-home" | |
| 35 | + | HF_HUB_CACHE_DIR = HF_HOME_DIR / "hub" | |
| 36 | + | HF_ASSETS_CACHE_DIR = HF_HOME_DIR / "assets" | |
| 37 | + | XDG_CACHE_HOME_DIR = RUNTIME_DIR / "xdg-cache" | |
| 38 | + | PIP_CACHE_DIR = RUNTIME_DIR / "pip-cache" | |
| 39 | + | PYTHON_VENDOR_DIR = RUNTIME_DIR / "python-vendor" | |
| 40 | + | TMP_DIR = RUNTIME_DIR / "tmp" | |
| 41 | + | ||
| 42 | + | ||
| 43 | + | def 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 | + | ||
| 72 | + | prepare_runtime_dirs() | |
| 73 | + | ||
| 74 | + | ||
| 75 | + | def 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 | + | ||
| 88 | + | def 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 | + | ||
| 106 | + | try: | |
| 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 | |
| 114 | + | except 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 | + | ||
| 125 | + | def 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 | + | ||
| 140 | + | TOKEN = load_token() | |
| 141 | + | if not TOKEN: | |
| 142 | + | print("HF token not found. Proceeding without authentication (repo may be public).") | |
| 143 | + | else: | |
| 144 | + | print(f"Using HF token: {TOKEN[:8]}...") | |
| 145 | + | ||
| 146 | + | ||
| 147 | + | def repo_to_dirname(repo_id: str) -> str: | |
| 148 | + | return repo_id.replace("/", ".") | |
| 149 | + | ||
| 150 | + | ||
| 151 | + | def get_dir_size(path: Path) -> int: | |
| 152 | + | return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) | |
| 153 | + | ||
| 154 | + | ||
| 155 | + | def 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 | + | ||
| 164 | + | def 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 | + | ||
| 174 | + | def 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 | + | ||
| 190 | + | def 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 | + | ||
| 211 | + | def 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 | + | ||
| 316 | + | def 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 | + | ||
| 395 | + | def 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 | + | ||
| 427 | + | if __name__ == "__main__": | |
| 428 | + | main() | |
Новіше
Пізніше