download_hf_model.py
· 14 KiB · Python
Surowy
#!/usr/bin/env python3
"""
Download all files from a HuggingFace model repository with retries,
resume support, and integrity verification.
Usage:
python3 download_hf_model.py <repo_id> [repo_id ...]
Examples:
python3 download_hf_model.py ai-sage/GigaAM-Multilingual
python3 download_hf_model.py meta-llama/Llama-3-8B-Instruct
Each repo is downloaded into a directory named by replacing '/' with '.'
in the repo_id (e.g. ai-sage.GigaAM-Multilingual).
Adapted from download_lilith_q8.py.
"""
import argparse
import hashlib
import importlib
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
MAX_RETRIES = 10
RETRY_DELAY_BASE = 15 # seconds, doubles each retry
BASE_DIR = Path(__file__).resolve().parent
RUNTIME_DIR = BASE_DIR / ".hf_download_runtime"
HF_HOME_DIR = RUNTIME_DIR / "hf-home"
HF_HUB_CACHE_DIR = HF_HOME_DIR / "hub"
HF_ASSETS_CACHE_DIR = HF_HOME_DIR / "assets"
XDG_CACHE_HOME_DIR = RUNTIME_DIR / "xdg-cache"
PIP_CACHE_DIR = RUNTIME_DIR / "pip-cache"
PYTHON_VENDOR_DIR = RUNTIME_DIR / "python-vendor"
TMP_DIR = RUNTIME_DIR / "tmp"
def prepare_runtime_dirs() -> None:
for path in (
RUNTIME_DIR,
HF_HOME_DIR,
HF_HUB_CACHE_DIR,
HF_ASSETS_CACHE_DIR,
XDG_CACHE_HOME_DIR,
PIP_CACHE_DIR,
PYTHON_VENDOR_DIR,
TMP_DIR,
):
path.mkdir(parents=True, exist_ok=True)
os.environ["HF_HOME"] = str(HF_HOME_DIR)
os.environ["HF_HUB_CACHE"] = str(HF_HUB_CACHE_DIR)
os.environ["HF_ASSETS_CACHE"] = str(HF_ASSETS_CACHE_DIR)
os.environ["XDG_CACHE_HOME"] = str(XDG_CACHE_HOME_DIR)
os.environ["PIP_CACHE_DIR"] = str(PIP_CACHE_DIR)
os.environ["HF_HUB_DISABLE_XET"] = "1"
os.environ["HF_XET_RECONSTRUCT_WRITE_SEQUENTIALLY"] = "1"
os.environ["TMPDIR"] = str(TMP_DIR)
os.environ["TMP"] = str(TMP_DIR)
os.environ["TEMP"] = str(TMP_DIR)
tempfile.tempdir = str(TMP_DIR)
if str(PYTHON_VENDOR_DIR) not in sys.path:
sys.path.insert(0, str(PYTHON_VENDOR_DIR))
prepare_runtime_dirs()
def parse_major_minor(version: str) -> tuple[int, int]:
parts = []
for chunk in version.split("."):
digits = "".join(ch for ch in chunk if ch.isdigit())
if digits:
parts.append(int(digits))
if len(parts) == 2:
break
while len(parts) < 2:
parts.append(0)
return parts[0], parts[1]
def install_huggingface_hub() -> None:
print("Installing newer huggingface_hub into the local runtime dir...")
subprocess.check_call(
[
sys.executable,
"-m",
"pip",
"install",
"--upgrade",
"--target",
str(PYTHON_VENDOR_DIR),
"--cache-dir",
str(PIP_CACHE_DIR),
"huggingface_hub>=1.0,<2",
]
)
try:
import huggingface_hub as _huggingface_hub
if parse_major_minor(_huggingface_hub.__version__) < (1, 0):
raise ImportError(f"Found old huggingface_hub {_huggingface_hub.__version__}")
from huggingface_hub import HfApi, hf_hub_url
from huggingface_hub.utils import RepositoryNotFoundError
except Exception:
install_huggingface_hub()
importlib.invalidate_caches()
for name in list(sys.modules):
if name == "huggingface_hub" or name.startswith("huggingface_hub."):
del sys.modules[name]
from huggingface_hub import HfApi, hf_hub_url
from huggingface_hub.utils import RepositoryNotFoundError
def load_token() -> str | None:
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
if token:
return token
token_files = [
HF_HOME_DIR / "token",
Path.home() / ".cache" / "huggingface" / "token",
]
for token_file in token_files:
if token_file.exists():
return token_file.read_text().strip()
return None
TOKEN = load_token()
if not TOKEN:
print("HF token not found. Proceeding without authentication (repo may be public).")
else:
print(f"Using HF token: {TOKEN[:8]}...")
def repo_to_dirname(repo_id: str) -> str:
return repo_id.replace("/", ".")
def get_dir_size(path: Path) -> int:
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
def expected_size_for(sibling) -> int | None:
size = getattr(sibling, "size", None)
if size is not None:
return size
if sibling.lfs and sibling.lfs.get("size") is not None:
return sibling.lfs["size"]
return None
def format_size(n: int | None) -> str:
if n is None:
return "unknown size"
if n >= 1e9:
return f"{n / 1e9:.2f} GB"
if n >= 1e6:
return f"{n / 1e6:.2f} MB"
return f"{n / 1e3:.2f} KB"
def quarantine_corrupt_files(paths: list[Path], download_dir: Path) -> None:
if not paths:
return
quarantine_dir = download_dir / ".corrupt"
quarantine_dir.mkdir(exist_ok=True)
stamp = int(time.time())
for path in paths:
target = quarantine_dir / f"{path.name}.{stamp}.corrupt"
suffix = 1
while target.exists():
target = quarantine_dir / f"{path.name}.{stamp}.{suffix}.corrupt"
suffix += 1
path.replace(target)
print(f"Moved corrupt file aside: {target}")
def download_file(rel_path: str, dest: Path, repo_id: str) -> None:
url = hf_hub_url(repo_id=repo_id, filename=rel_path)
cmd = [
"curl",
"-L",
"--fail",
"--show-error",
"--connect-timeout",
"30",
"--progress-bar",
"-C",
"-",
"-o",
str(dest),
url,
]
if TOKEN:
cmd[1:1] = ["-H", f"Authorization: Bearer {TOKEN}"]
subprocess.check_call(cmd)
def download_repo_with_retries(repo_id: str, download_dir: Path) -> Path:
"""Download all files from a repo with curl, resume, and exponential backoff."""
for attempt in range(1, MAX_RETRIES + 1):
try:
print(f"\n{'=' * 60}")
print(f"Download attempt {attempt}/{MAX_RETRIES}")
print(f"{'=' * 60}")
api = HfApi(token=TOKEN)
repo_info = api.repo_info(repo_id=repo_id, token=TOKEN, files_metadata=True)
siblings = [s for s in (repo_info.siblings or []) if s.rfilename]
if attempt == 1:
# Disk space check
needed = 0
for s in siblings:
exp = expected_size_for(s)
dest = download_dir / s.rfilename
have = dest.stat().st_size if dest.exists() else 0
if exp is not None:
needed += max(exp - have, 0)
try:
stat = os.statvfs(download_dir)
free = stat.f_bavail * stat.f_frsize
print(f"Disk check: need ~{format_size(needed)} more, free ~{format_size(free)} on destination.")
if needed > free:
print("WARNING: not enough free space for the remaining download.")
except OSError:
pass
siblings = sorted(
siblings,
key=lambda s: expected_size_for(s) or 0,
reverse=True,
)
size_before = get_dir_size(download_dir) if download_dir.exists() else 0
t0 = time.time()
for index, sibling in enumerate(siblings, start=1):
rel_path = sibling.rfilename
dest = download_dir / rel_path
dest.parent.mkdir(parents=True, exist_ok=True)
expected_size = expected_size_for(sibling)
current_size = dest.stat().st_size if dest.exists() else 0
if expected_size is not None and dest.exists() and current_size == expected_size:
print(
f"[{index}/{len(siblings)}] Already present: {rel_path} "
f"({format_size(expected_size)})"
)
continue
if expected_size is not None and dest.exists() and current_size > expected_size:
quarantine_corrupt_files([dest], download_dir)
current_size = 0
if current_size > 0:
print(
f"[{index}/{len(siblings)}] Resuming: {rel_path} "
f"from {format_size(current_size)} / {format_size(expected_size)}"
)
else:
print(f"[{index}/{len(siblings)}] Downloading: {rel_path} ({format_size(expected_size)})")
download_file(rel_path, dest, repo_id)
if expected_size is not None:
actual_size = dest.stat().st_size
if actual_size != expected_size:
raise ValueError(
f"Size mismatch after download for {rel_path}: "
f"expected {expected_size}, got {actual_size}"
)
elapsed = time.time() - t0
size_after = get_dir_size(download_dir)
downloaded = max(size_after - size_before, 0)
speed = downloaded / elapsed if elapsed > 0 else 0
print(f"\nDownload complete: {download_dir}")
print(f" Downloaded: {format_size(downloaded)} in {elapsed:.0f}s")
print(f" Avg speed: {speed / 1e6:.1f} MB/s")
return download_dir
except KeyboardInterrupt:
print("\nInterrupted by user.")
sys.exit(1)
except (RepositoryNotFoundError, FileNotFoundError) as exc:
print(f"\nFatal error: {exc}")
sys.exit(1)
except Exception as exc:
delay = RETRY_DELAY_BASE * (2 ** (attempt - 1))
print(f"\nError on attempt {attempt}: {exc}")
if attempt < MAX_RETRIES:
print(f"Retrying in {delay}s...")
time.sleep(delay)
else:
print("All retries exhausted.")
sys.exit(1)
def verify_integrity(repo_id: str, download_dir: Path) -> tuple[bool, list[Path]]:
"""Verify all downloaded files against repo metadata."""
print(f"\n{'=' * 60}")
print("Verifying file integrity...")
print(f"{'=' * 60}")
api = HfApi(token=TOKEN)
try:
repo_info = api.repo_info(repo_id=repo_id, token=TOKEN, files_metadata=True)
except Exception as exc:
print(f"Could not fetch repo metadata for verification: {exc}")
return False, []
siblings = [s for s in (repo_info.siblings or []) if s.rfilename]
errors = []
checked = 0
corrupt_files: list[Path] = []
for sibling in siblings:
if not sibling.rfilename:
continue
file_path = download_dir / sibling.rfilename
if not file_path.exists():
errors.append(f"MISSING: {sibling.rfilename}")
continue
expected_sha = None
hash_type = None
if sibling.lfs and sibling.lfs.get("sha256"):
expected_sha = sibling.lfs["sha256"]
hash_type = "sha256"
elif hasattr(sibling, "blob_id") and sibling.blob_id:
expected_sha = sibling.blob_id
hash_type = "git-sha1"
else:
continue
checked += 1
size = file_path.stat().st_size
print(f" Checking {sibling.rfilename} ({format_size(size)})...", end=" ", flush=True)
if hash_type == "sha256":
digest = hashlib.sha256()
with open(file_path, "rb") as handle:
while chunk := handle.read(8 * 1024 * 1024):
digest.update(chunk)
actual = digest.hexdigest()
else:
digest = hashlib.sha1()
digest.update(f"blob {size}\0".encode())
with open(file_path, "rb") as handle:
while chunk := handle.read(8 * 1024 * 1024):
digest.update(chunk)
actual = digest.hexdigest()
if actual == expected_sha:
print("OK")
else:
print("MISMATCH")
corrupt_files.append(file_path)
errors.append(
f"HASH MISMATCH: {sibling.rfilename}\n"
f" expected: {expected_sha}\n"
f" actual: {actual}"
)
print(f"\nChecked {checked} file(s).")
if errors:
print("\nIntegrity errors:")
for err in errors:
print(f" - {err}")
return False, corrupt_files
print("All files verified successfully.")
return True, []
def main() -> None:
parser = argparse.ArgumentParser(
description="Download files from HuggingFace model repositories with retry and verification."
)
parser.add_argument(
"repos",
nargs="+",
help="HuggingFace repo IDs to download (e.g. ai-sage/GigaAM-Multilingual)",
)
args = parser.parse_args()
for repo_id in args.repos:
dirname = repo_to_dirname(repo_id)
download_dir = BASE_DIR / dirname
download_dir.mkdir(parents=True, exist_ok=True)
print(f"\n{'#' * 60}")
print(f"# Repo: {repo_id}")
print(f"# Target: {download_dir}")
print(f"{'#' * 60}")
download_repo_with_retries(repo_id, download_dir)
ok, corrupt_files = verify_integrity(repo_id, download_dir)
if not ok:
quarantine_corrupt_files(corrupt_files, download_dir)
print(f"\nIntegrity check FAILED for {repo_id}. Re-run the script to resume.")
sys.exit(1)
print(f"\nDone. Model saved to: {download_dir}")
if __name__ == "__main__":
main()
| 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() |
| 429 |