Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
912 lines
37 KiB
Python
912 lines
37 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass, replace
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.error import HTTPError
|
|
from urllib.parse import quote
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_SPECS_DIR = REPO_ROOT / "model_specs"
|
|
|
|
|
|
class ManagerError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class InstallCancelled(ManagerError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PackageRecord:
|
|
family: str
|
|
id: str
|
|
display_name: str
|
|
target_directory: str
|
|
format: str
|
|
precision: str
|
|
files: tuple[str, ...]
|
|
strip_prefix: str
|
|
download: dict[str, Any]
|
|
default: bool
|
|
source_overridden: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RemoteFileInfo:
|
|
size: int | None
|
|
revision: str
|
|
etag: str
|
|
|
|
|
|
def huggingface_token() -> str | None:
|
|
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
|
if token:
|
|
return token.strip()
|
|
token_path = Path.home() / ".cache" / "huggingface" / "token"
|
|
if token_path.is_file():
|
|
cached = token_path.read_text(encoding="utf-8").strip()
|
|
if cached:
|
|
return cached
|
|
return None
|
|
|
|
|
|
def hf_endpoint() -> str:
|
|
"""Base URL for Hugging Face Hub requests.
|
|
|
|
Honors the standard HF_ENDPOINT environment variable (e.g.
|
|
HF_ENDPOINT=https://hf-mirror.com for the hf-mirror mirror). Falls back to
|
|
https://huggingface.co. Empty values and trailing slashes are tolerated.
|
|
"""
|
|
endpoint = os.environ.get("HF_ENDPOINT", "").strip().rstrip("/")
|
|
return endpoint or "https://huggingface.co"
|
|
|
|
|
|
def ms_endpoint() -> str:
|
|
"""Base URL for ModelScope requests.
|
|
|
|
Honors AUDIOCPP_MS_BASE_URL, mirroring the native C++ package manager.
|
|
Falls back to https://www.modelscope.cn. Empty values and trailing slashes
|
|
are tolerated.
|
|
"""
|
|
endpoint = os.environ.get("AUDIOCPP_MS_BASE_URL", "").strip().rstrip("/")
|
|
return endpoint or "https://www.modelscope.cn"
|
|
|
|
|
|
def modelscope_token() -> str | None:
|
|
token = os.environ.get("AUDIOCPP_MS_TOKEN", "").strip()
|
|
return token or None
|
|
|
|
|
|
def http_headers(source: str = "huggingface") -> dict[str, str]:
|
|
# Auth is provider-scoped: HF requests may carry the HF token, ModelScope
|
|
# requests carry only AUDIOCPP_MS_TOKEN, so one provider's credential is
|
|
# never sent to the other provider's host.
|
|
headers = {"User-Agent": "audio.cpp model_manager_v2.py"}
|
|
token = modelscope_token() if source == "modelscope" else huggingface_token()
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
return headers
|
|
|
|
|
|
def quote_repo_path(value: str) -> str:
|
|
return "/".join(quote(part, safe="") for part in value.split("/"))
|
|
|
|
|
|
def validate_relative_path(value: str, label: str) -> Path:
|
|
path = Path(value)
|
|
if path.is_absolute() or any(part == ".." for part in path.parts):
|
|
raise ManagerError(f"{label} must be a safe relative path: {value}")
|
|
if not value or value == ".":
|
|
raise ManagerError(f"{label} must not be empty")
|
|
return path
|
|
|
|
|
|
def stripped_path(remote_path: str, strip_prefix: str) -> Path:
|
|
result = remote_path
|
|
if strip_prefix:
|
|
prefix = strip_prefix.rstrip("/")
|
|
if result == prefix:
|
|
raise ManagerError(f"strip_prefix removes the whole file path: {remote_path}")
|
|
if not result.startswith(prefix + "/"):
|
|
raise ManagerError(f"file path does not start with strip_prefix '{strip_prefix}': {remote_path}")
|
|
result = result[len(prefix) + 1 :]
|
|
return validate_relative_path(result, "local file path")
|
|
|
|
|
|
def load_specs(specs_dir: Path) -> list[dict[str, Any]]:
|
|
specs: list[dict[str, Any]] = []
|
|
for path in sorted(specs_dir.glob("*.json")):
|
|
with path.open("r", encoding="utf-8") as handle:
|
|
spec = json.load(handle)
|
|
spec["_spec_path"] = str(path)
|
|
specs.append(spec)
|
|
return specs
|
|
|
|
|
|
def merged_download(spec: dict[str, Any], package: dict[str, Any]) -> dict[str, Any]:
|
|
defaults = spec.get("package_defaults", {}).get("download", {})
|
|
download = dict(defaults)
|
|
download.update(package.get("download", {}))
|
|
return download
|
|
|
|
|
|
def package_kind(package: PackageRecord) -> str:
|
|
return str(package.download.get("kind", ""))
|
|
|
|
|
|
def package_source(package: PackageRecord) -> str:
|
|
"""Download provider for a package: 'modelscope' or 'huggingface'."""
|
|
return "modelscope" if package_kind(package) == "modelscope_snapshot" else "huggingface"
|
|
|
|
|
|
def package_revision(package: PackageRecord) -> str:
|
|
"""Kind-aware revision default: master on ModelScope, main on Hugging Face."""
|
|
revision = str(package.download.get("revision", "")).strip()
|
|
if revision:
|
|
return revision
|
|
return "master" if package_kind(package) == "modelscope_snapshot" else "main"
|
|
|
|
|
|
def apply_source_override(record: PackageRecord, source_repo: str | None) -> PackageRecord:
|
|
"""Redirect a package's download to ModelScope (--source modelscope).
|
|
|
|
The repo comes from --source-repo when given, otherwise the spec's own
|
|
repo is reused on ModelScope. An unset or 'main' spec revision becomes
|
|
ModelScope's default branch ('master', via the kind-aware default); any
|
|
other explicit revision passes through unchanged.
|
|
"""
|
|
download = dict(record.download)
|
|
download["kind"] = "modelscope_snapshot"
|
|
if source_repo:
|
|
download["repo"] = source_repo
|
|
revision = str(download.get("revision", "")).strip()
|
|
if not revision or revision == "main":
|
|
download.pop("revision", None)
|
|
return replace(record, download=download, source_overridden=True)
|
|
|
|
|
|
def source_override_hint(package: PackageRecord) -> str:
|
|
if package.source_overridden:
|
|
return "; the spec repo may not exist on ModelScope, name it with --source-repo <namespace/name>"
|
|
return ""
|
|
|
|
|
|
def flatten_packages(specs: list[dict[str, Any]]) -> list[PackageRecord]:
|
|
records: list[PackageRecord] = []
|
|
for spec in specs:
|
|
family = spec["family"]
|
|
for package in spec.get("packages", []):
|
|
records.append(
|
|
PackageRecord(
|
|
family=family,
|
|
id=package["id"],
|
|
display_name=package["display_name"],
|
|
target_directory=package["target_directory"],
|
|
format=package["format"],
|
|
precision=package["precision"],
|
|
files=tuple(package["files"]),
|
|
strip_prefix=package.get("strip_prefix", ""),
|
|
download=merged_download(spec, package),
|
|
default=bool(package.get("default", False)),
|
|
)
|
|
)
|
|
return records
|
|
|
|
|
|
def select_package(records: list[PackageRecord], args: argparse.Namespace) -> PackageRecord:
|
|
format_filter = getattr(args, "format", None)
|
|
precision_filter = getattr(args, "precision", None)
|
|
exact = [record for record in records if record.id == args.package]
|
|
if exact:
|
|
if format_filter or precision_filter:
|
|
raise ManagerError("format/precision filters are only used when selecting by family")
|
|
return exact[0]
|
|
|
|
family = [record for record in records if record.family == args.package]
|
|
if not family:
|
|
raise ManagerError(f"unknown package or family: {args.package}")
|
|
candidates = family
|
|
if format_filter:
|
|
candidates = [record for record in candidates if record.format == format_filter]
|
|
if precision_filter:
|
|
candidates = [record for record in candidates if record.precision == precision_filter]
|
|
if not candidates:
|
|
raise ManagerError(f"no package for family '{args.package}' matches the requested filters")
|
|
default_candidates = [record for record in candidates if record.default]
|
|
if len(default_candidates) == 1:
|
|
return default_candidates[0]
|
|
if len(candidates) == 1:
|
|
return candidates[0]
|
|
ids = ", ".join(record.id for record in candidates)
|
|
raise ManagerError(f"ambiguous family '{args.package}', choose one package id: {ids}")
|
|
|
|
|
|
def hf_url(repo: str, revision: str, remote_path: str) -> str:
|
|
return f"{hf_endpoint()}/{repo}/resolve/{quote(revision, safe='')}/{quote_repo_path(remote_path)}"
|
|
|
|
|
|
def ms_url(repo: str, revision: str, remote_path: str) -> str:
|
|
return f"{ms_endpoint()}/models/{repo}/resolve/{quote(revision, safe='')}/{quote_repo_path(remote_path)}"
|
|
|
|
|
|
def ms_files_url(repo: str, revision: str) -> str:
|
|
return f"{ms_endpoint()}/api/v1/models/{repo}/repo/files?Revision={quote(revision, safe='')}&Recursive=true"
|
|
|
|
|
|
def download_url(package: PackageRecord, remote_path: str) -> str:
|
|
repo = package.download["repo"]
|
|
revision = package_revision(package)
|
|
if package_kind(package) == "modelscope_snapshot":
|
|
return ms_url(repo, revision, remote_path)
|
|
return hf_url(repo, revision, remote_path)
|
|
|
|
|
|
# ModelScope resolve HEAD responses carry no Content-Length or ETag, so
|
|
# per-file size and checksum come from the repo file-list API instead. The
|
|
# listing is fetched once per repo+revision and shared through this cache; an
|
|
# empty entry means the listing was unavailable and HEAD fallback applies.
|
|
_MS_LISTING_CACHE: dict[tuple[str, str, str], dict[str, RemoteFileInfo]] = {}
|
|
_MS_LISTING_LOCK = threading.Lock()
|
|
|
|
|
|
def ms_repo_listing(repo: str, revision: str) -> dict[str, RemoteFileInfo]:
|
|
key = (ms_endpoint(), repo, revision)
|
|
with _MS_LISTING_LOCK:
|
|
cached = _MS_LISTING_CACHE.get(key)
|
|
if cached is not None:
|
|
return cached
|
|
listing: dict[str, RemoteFileInfo] = {}
|
|
try:
|
|
request = Request(ms_files_url(repo, revision), headers=http_headers("modelscope"))
|
|
with urlopen(request, timeout=60) as response:
|
|
payload = json.loads(response.read().decode("utf-8"))
|
|
if payload.get("Code") != 200:
|
|
raise ManagerError(f"ModelScope repo listing failed for {repo}: {payload.get('Message', 'unknown error')}")
|
|
files = payload.get("Data", {}).get("Files")
|
|
if not isinstance(files, list):
|
|
raise ManagerError(f"ModelScope repo listing has no file list: {repo}")
|
|
for item in files:
|
|
if not isinstance(item, dict) or item.get("Type") != "blob":
|
|
continue
|
|
path = item.get("Path")
|
|
if not path:
|
|
continue
|
|
size = item.get("Size")
|
|
listing[str(path)] = RemoteFileInfo(
|
|
size=int(size) if size is not None else None,
|
|
revision="",
|
|
etag=str(item.get("Sha256") or ""),
|
|
)
|
|
except Exception:
|
|
listing = {}
|
|
with _MS_LISTING_LOCK:
|
|
_MS_LISTING_CACHE[key] = listing
|
|
return listing
|
|
|
|
|
|
def check_ms_remote_file(package: PackageRecord, remote_path: str) -> RemoteFileInfo:
|
|
repo = package.download["repo"]
|
|
revision = package_revision(package)
|
|
listing = ms_repo_listing(repo, revision)
|
|
info = listing.get(remote_path)
|
|
if info is not None:
|
|
return info
|
|
if listing:
|
|
raise ManagerError(
|
|
f"remote file is not accessible: {repo}/{remote_path} (not in the ModelScope repo listing)"
|
|
f"{source_override_hint(package)}"
|
|
)
|
|
# The listing API is unreachable; fall back to a HEAD on the resolve URL,
|
|
# which reports the file checksum as X-Linked-Etag but no size.
|
|
request = Request(ms_url(repo, revision, remote_path), headers=http_headers("modelscope"), method="HEAD")
|
|
try:
|
|
with urlopen(request, timeout=60) as response:
|
|
return RemoteFileInfo(
|
|
size=None,
|
|
revision="",
|
|
etag=response.headers.get("X-Linked-Etag", "").strip('"'),
|
|
)
|
|
except HTTPError as error:
|
|
raise ManagerError(
|
|
f"remote file is not accessible: {repo}/{remote_path} ({error.code})"
|
|
f"{source_override_hint(package)}"
|
|
) from error
|
|
|
|
|
|
def check_remote_file(package: PackageRecord, remote_path: str) -> RemoteFileInfo:
|
|
if package_kind(package) == "modelscope_snapshot":
|
|
return check_ms_remote_file(package, remote_path)
|
|
repo = package.download["repo"]
|
|
revision = package_revision(package)
|
|
request = Request(hf_url(repo, revision, remote_path), headers=http_headers(), method="HEAD")
|
|
try:
|
|
with urlopen(request, timeout=60) as response:
|
|
size = response.headers.get("Content-Length")
|
|
return RemoteFileInfo(
|
|
size=int(size) if size else None,
|
|
revision=response.headers.get("X-Repo-Commit", ""),
|
|
etag=response.headers.get("ETag", "").strip('"'),
|
|
)
|
|
except HTTPError as error:
|
|
if package.download.get("gated") is True and error.code in (401, 403):
|
|
return RemoteFileInfo(size=None, revision="", etag="")
|
|
raise ManagerError(f"remote file is not accessible: {repo}/{remote_path} ({error.code})") from error
|
|
|
|
|
|
def cancellation_requested(cancel_file: Path | None) -> bool:
|
|
return cancel_file is not None and cancel_file.is_file()
|
|
|
|
|
|
def download_file(
|
|
package: PackageRecord,
|
|
remote_path: str,
|
|
output_path: Path,
|
|
progress=None,
|
|
cancel_file: Path | None = None,
|
|
) -> None:
|
|
repo = package.download["repo"]
|
|
request = Request(download_url(package, remote_path), headers=http_headers(package_source(package)))
|
|
try:
|
|
with urlopen(request, timeout=300) as response:
|
|
expected_header = response.headers.get("Content-Length")
|
|
expected = int(expected_header) if expected_header is not None else None
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
total = 0
|
|
last_report = 0
|
|
with output_path.open("wb") as handle:
|
|
while True:
|
|
if cancellation_requested(cancel_file):
|
|
raise InstallCancelled("download cancelled by user")
|
|
chunk = response.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
handle.write(chunk)
|
|
total += len(chunk)
|
|
if progress is not None and total - last_report >= 8 * 1024 * 1024:
|
|
progress(total, expected)
|
|
last_report = total
|
|
if progress is not None:
|
|
progress(total, expected)
|
|
if cancellation_requested(cancel_file):
|
|
raise InstallCancelled("download cancelled by user")
|
|
if expected is not None and total != expected:
|
|
raise ManagerError(f"downloaded size mismatch for {output_path}: {total} != {expected}")
|
|
except HTTPError as error:
|
|
if package.download.get("gated") is True and error.code in (401, 403):
|
|
raise ManagerError(
|
|
f"{repo}/{remote_path} requires accepted Hugging Face access and a valid HF token"
|
|
) from error
|
|
if package_kind(package) == "modelscope_snapshot" and error.code in (401, 403):
|
|
raise ManagerError(f"{repo}/{remote_path} requires ModelScope access to this repo") from error
|
|
raise ManagerError(
|
|
f"failed to download {repo}/{remote_path}: HTTP {error.code}{source_override_hint(package)}"
|
|
) from error
|
|
|
|
|
|
def ensure_snapshot_package(package: PackageRecord) -> None:
|
|
kind = package.download.get("kind")
|
|
if kind not in ("huggingface_snapshot", "modelscope_snapshot"):
|
|
raise ManagerError(
|
|
f"{package.id} uses download kind '{kind}'. model_manager_v2 only installs "
|
|
"huggingface_snapshot and modelscope_snapshot packages; "
|
|
"use tools/model_manager.py for legacy composite or converter installs."
|
|
)
|
|
if not package.download.get("repo"):
|
|
raise ManagerError(f"{package.id} has no remote repo for download kind '{kind}'")
|
|
|
|
|
|
def package_manifest_path(package: PackageRecord, models_root: Path) -> Path:
|
|
target_dir = validate_relative_path(package.target_directory, "target_directory")
|
|
return models_root / target_dir / f".audiocpp-package-{package.id}.json"
|
|
|
|
|
|
def read_package_manifest(package: PackageRecord, models_root: Path | None) -> dict[str, Any] | None:
|
|
if models_root is None:
|
|
return None
|
|
path = package_manifest_path(package, models_root)
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
return value if isinstance(value, dict) else None
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def write_package_manifest(
|
|
package: PackageRecord,
|
|
models_root: Path,
|
|
resolved_revision: str,
|
|
remote_files: dict[str, RemoteFileInfo],
|
|
) -> None:
|
|
path = package_manifest_path(package, models_root)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
payload = {
|
|
"schema_version": 1,
|
|
"package_id": package.id,
|
|
"repo": package.download.get("repo", ""),
|
|
"requested_revision": package_revision(package),
|
|
"resolved_revision": resolved_revision,
|
|
"installed_at_unix": int(time.time()),
|
|
"files": {
|
|
name: {"size": info.size, "etag": info.etag}
|
|
for name, info in remote_files.items()
|
|
},
|
|
}
|
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
temporary.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
temporary.replace(path)
|
|
|
|
|
|
def reusable_package_outputs(
|
|
package: PackageRecord,
|
|
records: list[PackageRecord],
|
|
models_root: Path,
|
|
) -> set[Path]:
|
|
"""Return files safely shared with another completely installed package."""
|
|
target_dir = validate_relative_path(package.target_directory, "target_directory")
|
|
final_dir = models_root / target_dir
|
|
requested = {
|
|
(remote, final_dir / stripped_path(remote, package.strip_prefix))
|
|
for remote in package.files
|
|
}
|
|
reusable: set[Path] = set()
|
|
for other in records:
|
|
if other.id == package.id or other.download != package.download:
|
|
continue
|
|
if not package_is_installed(other, models_root):
|
|
continue
|
|
other_dir = models_root / validate_relative_path(other.target_directory, "target_directory")
|
|
for remote in other.files:
|
|
output = other_dir / stripped_path(remote, other.strip_prefix)
|
|
if (remote, output) in requested:
|
|
reusable.add(output)
|
|
return reusable
|
|
|
|
|
|
def install_package(package: PackageRecord, records: list[PackageRecord], args: argparse.Namespace) -> None:
|
|
ensure_snapshot_package(package)
|
|
target_dir = validate_relative_path(package.target_directory, "target_directory")
|
|
models_root = Path(args.models_root)
|
|
final_dir = models_root / target_dir
|
|
plan = [(remote, final_dir / stripped_path(remote, package.strip_prefix)) for remote in package.files]
|
|
full_plan = list(plan)
|
|
|
|
print(f"selected {package.id} ({package.family})")
|
|
print(f"repo {package.download['repo']}@{package_revision(package)}")
|
|
print(f"target {final_dir}")
|
|
for remote, output in plan:
|
|
if args.check:
|
|
info = check_remote_file(package, remote)
|
|
if info.size is None and package.download.get("gated") is True:
|
|
suffix = " gated_access_required"
|
|
else:
|
|
suffix = f" size={info.size}" if info.size is not None else ""
|
|
print(f"check {remote}{suffix}")
|
|
else:
|
|
print(f"file remote={remote} local={output.relative_to(models_root)}")
|
|
|
|
if args.dry_run or args.check:
|
|
return
|
|
existing_outputs = [output for _remote, output in plan if output.exists()]
|
|
if existing_outputs and not args.overwrite:
|
|
if len(existing_outputs) == len(plan) and all(output.is_file() for output in existing_outputs):
|
|
print(f"already installed {package.id} -> {final_dir}")
|
|
return
|
|
reusable = reusable_package_outputs(package, records, models_root)
|
|
conflicts = [output for output in existing_outputs if output not in reusable]
|
|
if conflicts:
|
|
raise ManagerError(f"some package files already exist in: {final_dir} (use --overwrite)")
|
|
plan = [(remote, output) for remote, output in plan if output not in reusable]
|
|
models_root.mkdir(parents=True, exist_ok=True)
|
|
staging = Path(tempfile.mkdtemp(prefix=f".{package.target_directory.replace('/', '_')}.", dir=models_root))
|
|
cancel_file = Path(args.cancel_file) if getattr(args, "cancel_file", "") else None
|
|
try:
|
|
if cancellation_requested(cancel_file):
|
|
raise InstallCancelled("download cancelled by user")
|
|
progress_enabled = bool(getattr(args, "progress", False))
|
|
remote_files: dict[str, RemoteFileInfo] = {}
|
|
if progress_enabled:
|
|
remote_files = {remote: check_remote_file(package, remote) for remote, _output in plan}
|
|
elif plan:
|
|
remote_files[plan[0][0]] = check_remote_file(package, plan[0][0])
|
|
expected_sizes = [remote_files[remote].size for remote, _output in plan] if progress_enabled else []
|
|
total_expected = sum(size for size in expected_sizes if size is not None)
|
|
if any(size is None for size in expected_sizes):
|
|
total_expected = 0
|
|
completed = 0
|
|
|
|
def emit_progress(downloaded: int) -> None:
|
|
print(
|
|
f"AUDIOCPP_PROGRESS downloaded={downloaded} total={total_expected}",
|
|
flush=True,
|
|
)
|
|
|
|
if progress_enabled:
|
|
emit_progress(0)
|
|
for remote, output in plan:
|
|
destination = staging / output.relative_to(final_dir)
|
|
if progress_enabled:
|
|
download_file(
|
|
package,
|
|
remote,
|
|
destination,
|
|
lambda file_bytes, _expected, base=completed: emit_progress(base + file_bytes),
|
|
cancel_file,
|
|
)
|
|
completed += destination.stat().st_size
|
|
emit_progress(completed)
|
|
else:
|
|
download_file(package, remote, destination, cancel_file=cancel_file)
|
|
if cancellation_requested(cancel_file):
|
|
raise InstallCancelled("download cancelled by user")
|
|
# Record every package-owned file, including sidecars reused from a
|
|
# sibling precision. Version checks compare these per-file ETags and
|
|
# must not depend on a repository-wide revision.
|
|
for remote, _output in full_plan:
|
|
if remote not in remote_files:
|
|
remote_files[remote] = check_remote_file(package, remote)
|
|
if not final_dir.exists():
|
|
final_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
staging.rename(final_dir)
|
|
else:
|
|
# Precision variants commonly share one target directory. Merge the
|
|
# fully downloaded staging tree so Q8 and F16/BF16 can coexist. An
|
|
# overwrite replaces only this package's files, preserving siblings.
|
|
for staged_file in sorted(path for path in staging.rglob("*") if path.is_file()):
|
|
relative = staged_file.relative_to(staging)
|
|
destination = final_dir / relative
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
if destination.exists():
|
|
if not args.overwrite:
|
|
raise ManagerError(f"package file already exists: {destination} (use --overwrite)")
|
|
destination.unlink()
|
|
staged_file.replace(destination)
|
|
shutil.rmtree(staging)
|
|
resolved_revision = next(
|
|
(info.revision for info in remote_files.values() if info.revision),
|
|
package_revision(package),
|
|
)
|
|
write_package_manifest(package, models_root, resolved_revision, remote_files)
|
|
except Exception:
|
|
shutil.rmtree(staging, ignore_errors=True)
|
|
raise
|
|
print(f"installed {package.id} -> {final_dir}")
|
|
|
|
|
|
def uninstall_package(package: PackageRecord, records: list[PackageRecord], args: argparse.Namespace) -> None:
|
|
target_dir = validate_relative_path(package.target_directory, "target_directory")
|
|
models_root = Path(args.models_root)
|
|
final_dir = models_root / target_dir
|
|
package_files = [final_dir / stripped_path(remote, package.strip_prefix) for remote in package.files]
|
|
manifest_path = package_manifest_path(package, models_root)
|
|
existing = [path for path in package_files if path.is_file() or path.is_symlink()]
|
|
if not existing:
|
|
raise ManagerError(f"package is not installed: {package.id}")
|
|
|
|
reusable = reusable_package_outputs(package, records, models_root)
|
|
for path in existing:
|
|
if path not in reusable:
|
|
path.unlink()
|
|
manifest_path.unlink(missing_ok=True)
|
|
|
|
# Remove only directories made empty by this package. Other precision
|
|
# variants and unrelated files in the shared target remain untouched.
|
|
cleanup: set[Path] = set()
|
|
for path in package_files:
|
|
parent = path.parent
|
|
while parent != final_dir.parent:
|
|
cleanup.add(parent)
|
|
parent = parent.parent
|
|
for directory in sorted(cleanup, key=lambda path: len(path.parts), reverse=True):
|
|
try:
|
|
directory.rmdir()
|
|
except OSError:
|
|
pass
|
|
print(f"removed {package.id} -> {final_dir}")
|
|
|
|
|
|
def clean_partial_package(package: PackageRecord, args: argparse.Namespace) -> None:
|
|
models_root = Path(args.models_root).resolve()
|
|
models_root.mkdir(parents=True, exist_ok=True)
|
|
prefix = f".{package.target_directory.replace('/', '_')}."
|
|
removed = 0
|
|
for candidate in models_root.glob(prefix + "*"):
|
|
resolved = candidate.resolve()
|
|
try:
|
|
resolved.relative_to(models_root)
|
|
except ValueError as error:
|
|
raise ManagerError(f"refusing to clean staging path outside models root: {resolved}") from error
|
|
if candidate.is_dir():
|
|
shutil.rmtree(candidate)
|
|
removed += 1
|
|
print(f"cleaned {removed} partial download director{'y' if removed == 1 else 'ies'} for {package.id}")
|
|
|
|
|
|
def command_list(records: list[PackageRecord], args: argparse.Namespace) -> None:
|
|
rows = [
|
|
{
|
|
"family": record.family,
|
|
"id": record.id,
|
|
"display_name": record.display_name,
|
|
"format": record.format,
|
|
"precision": record.precision,
|
|
"default": record.default,
|
|
"target_directory": record.target_directory,
|
|
"repo": record.download.get("repo", ""),
|
|
}
|
|
for record in records
|
|
]
|
|
if args.json:
|
|
print(json.dumps(rows, indent=2, ensure_ascii=False))
|
|
return
|
|
for row in rows:
|
|
default = " default" if row["default"] else ""
|
|
print(f"{row['id']:<44} {row['family']:<24} {row['format']:<11} {row['precision']:<8}{default}")
|
|
|
|
|
|
def command_info(records: list[PackageRecord], args: argparse.Namespace) -> None:
|
|
package = select_package(records, args)
|
|
row = {
|
|
"family": package.family,
|
|
"id": package.id,
|
|
"display_name": package.display_name,
|
|
"format": package.format,
|
|
"precision": package.precision,
|
|
"default": package.default,
|
|
"target_directory": package.target_directory,
|
|
"files": list(package.files),
|
|
"strip_prefix": package.strip_prefix,
|
|
"download": package.download,
|
|
}
|
|
if args.json:
|
|
print(json.dumps(row, indent=2, ensure_ascii=False))
|
|
return
|
|
print(f"id: {package.id}")
|
|
print(f"family: {package.family}")
|
|
print(f"name: {package.display_name}")
|
|
print(f"format: {package.format}")
|
|
print(f"precision: {package.precision}")
|
|
print(f"target: {package.target_directory}")
|
|
print(f"download: {package.download.get('kind')} {package.download.get('repo', '')}")
|
|
for remote in package.files:
|
|
print(f"file: {remote}")
|
|
|
|
|
|
def package_is_installed(package: PackageRecord, models_root: Path | None) -> bool:
|
|
if models_root is None:
|
|
return False
|
|
target_dir = validate_relative_path(package.target_directory, "target_directory")
|
|
final_dir = models_root / target_dir
|
|
if not final_dir.is_dir():
|
|
return False
|
|
return bool(package.files) and all(
|
|
(final_dir / stripped_path(remote_path, package.strip_prefix)).is_file()
|
|
for remote_path in package.files
|
|
)
|
|
|
|
|
|
def package_version_state(
|
|
installed: bool,
|
|
manifest: dict[str, Any] | None,
|
|
remote_files: dict[str, RemoteFileInfo],
|
|
) -> str:
|
|
"""Compare only files owned by this package, not the repository commit.
|
|
|
|
Hugging Face's X-Repo-Commit changes whenever *any* file in a repository is
|
|
uploaded. Several audio.cpp packages share one repository, so comparing
|
|
that repository-wide revision incorrectly marked every installed package
|
|
as outdated after an unrelated model upload. Per-file ETags identify the
|
|
actual package payload and avoid that false positive.
|
|
"""
|
|
if not installed:
|
|
return "not_installed"
|
|
manifest_files = manifest.get("files") if isinstance(manifest, dict) else None
|
|
if not isinstance(manifest_files, dict):
|
|
return "unknown"
|
|
|
|
compared = 0
|
|
for remote_path, stored in manifest_files.items():
|
|
if not isinstance(stored, dict):
|
|
continue
|
|
local_etag = str(stored.get("etag", "")).strip('"')
|
|
remote_etag = remote_files.get(remote_path, RemoteFileInfo(None, "", "")).etag.strip('"')
|
|
if not local_etag or not remote_etag:
|
|
continue
|
|
compared += 1
|
|
if local_etag != remote_etag:
|
|
return "update_available"
|
|
return "up_to_date" if compared else "unknown"
|
|
|
|
|
|
def package_size_record(package: PackageRecord, models_root: Path | None = None) -> dict[str, Any]:
|
|
installed = package_is_installed(package, models_root)
|
|
try:
|
|
ensure_snapshot_package(package)
|
|
total = 0
|
|
unknown = False
|
|
remote_revision = ""
|
|
remote_files: dict[str, RemoteFileInfo] = {}
|
|
for remote_path in package.files:
|
|
info = check_remote_file(package, remote_path)
|
|
remote_files[remote_path] = info
|
|
if info.size is None:
|
|
unknown = True
|
|
else:
|
|
total += info.size
|
|
if not remote_revision and info.revision:
|
|
remote_revision = info.revision
|
|
manifest = read_package_manifest(package, models_root)
|
|
local_revision = str(manifest.get("resolved_revision", "")) if manifest else ""
|
|
version_state = package_version_state(installed, manifest, remote_files)
|
|
state = "gated" if unknown and package.download.get("gated") is True else "unknown" if unknown else "ok"
|
|
return {
|
|
"id": package.id,
|
|
"size_bytes": None if unknown else total,
|
|
"state": state,
|
|
"message": "Hugging Face access and a valid token are required" if state == "gated" else "",
|
|
"installed": installed,
|
|
"version_state": version_state,
|
|
"local_revision": local_revision,
|
|
"remote_revision": remote_revision,
|
|
}
|
|
except ManagerError as error:
|
|
return {
|
|
"id": package.id,
|
|
"size_bytes": None,
|
|
"state": "error",
|
|
"message": str(error),
|
|
"installed": installed,
|
|
"version_state": "unknown" if installed else "not_installed",
|
|
"local_revision": "",
|
|
"remote_revision": "",
|
|
}
|
|
|
|
|
|
def command_sizes(records: list[PackageRecord], args: argparse.Namespace) -> None:
|
|
selected = records
|
|
if args.package:
|
|
requested = set(args.package)
|
|
selected = [record for record in records if record.id in requested]
|
|
missing = sorted(requested - {record.id for record in selected})
|
|
if missing:
|
|
raise ManagerError(f"unknown package ids: {', '.join(missing)}")
|
|
models_root = Path(args.models_root) if args.models_root else None
|
|
with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
|
|
rows = list(pool.map(lambda package: package_size_record(package, models_root), selected))
|
|
print(json.dumps(rows, ensure_ascii=False))
|
|
|
|
|
|
def command_installed(records: list[PackageRecord], args: argparse.Namespace) -> None:
|
|
models_root = Path(args.models_root)
|
|
rows = [
|
|
{
|
|
"id": package.id,
|
|
"size_bytes": None,
|
|
"state": "pending",
|
|
"message": "",
|
|
"installed": package_is_installed(package, models_root),
|
|
"version_state": "unknown" if package_is_installed(package, models_root) else "not_installed",
|
|
"local_revision": "",
|
|
"remote_revision": "",
|
|
}
|
|
for package in records
|
|
]
|
|
print(json.dumps(rows, ensure_ascii=False))
|
|
|
|
|
|
def add_source_arguments(parser: argparse.ArgumentParser) -> None:
|
|
parser.add_argument(
|
|
"--source",
|
|
choices=["huggingface", "modelscope"],
|
|
default="huggingface",
|
|
help="download source override; 'modelscope' downloads from ModelScope even when the "
|
|
"spec says huggingface_snapshot (an unset or 'main' revision becomes 'master'). "
|
|
"Manifest etags are source-specific, so cross-source checks may report updates",
|
|
)
|
|
parser.add_argument(
|
|
"--source-repo",
|
|
help="ModelScope repo (namespace/name) for --source modelscope; defaults to the spec's repo",
|
|
)
|
|
|
|
|
|
def make_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description="Install audio.cpp model packages from model_specs/*.json.")
|
|
parser.add_argument("--specs-dir", default=str(DEFAULT_SPECS_DIR), help="directory containing model spec JSON files")
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
list_parser = sub.add_parser("list", help="list installable packages")
|
|
list_parser.add_argument("--json", action="store_true")
|
|
|
|
info_parser = sub.add_parser("info", help="show one package or family default")
|
|
info_parser.add_argument("package")
|
|
info_parser.add_argument("--format")
|
|
info_parser.add_argument("--precision")
|
|
info_parser.add_argument("--json", action="store_true")
|
|
|
|
sizes_parser = sub.add_parser("sizes", help="check package download sizes without downloading files")
|
|
sizes_parser.add_argument("package", nargs="*", help="optional package ids; default checks every package")
|
|
sizes_parser.add_argument("--jobs", type=int, default=12, help="parallel metadata checks")
|
|
sizes_parser.add_argument("--models-root", help="also report packages whose required files are installed")
|
|
sizes_parser.add_argument("--json", action="store_true", help="retained for command symmetry; output is JSON")
|
|
add_source_arguments(sizes_parser)
|
|
|
|
installed_parser = sub.add_parser("installed", help="report locally installed packages without network access")
|
|
installed_parser.add_argument("--models-root", default="models")
|
|
installed_parser.add_argument("--json", action="store_true", help="retained for command symmetry; output is JSON")
|
|
|
|
uninstall_parser = sub.add_parser("uninstall", help="remove only the files belonging to one installed package")
|
|
uninstall_parser.add_argument("package", help="package id or family")
|
|
uninstall_parser.add_argument("--format")
|
|
uninstall_parser.add_argument("--precision")
|
|
uninstall_parser.add_argument("--models-root", default="models")
|
|
|
|
clean_parser = sub.add_parser("clean-partial", help="remove abandoned staging downloads for one package")
|
|
clean_parser.add_argument("package", help="package id or family")
|
|
clean_parser.add_argument("--models-root", default="models")
|
|
|
|
install_parser = sub.add_parser("install", help="install one Hugging Face or ModelScope snapshot package")
|
|
install_parser.add_argument("package", help="package id or family")
|
|
install_parser.add_argument("--format")
|
|
install_parser.add_argument("--precision")
|
|
install_parser.add_argument("--models-root", default="models")
|
|
install_parser.add_argument("--overwrite", action="store_true")
|
|
install_parser.add_argument("--cancel-file", default="", help=argparse.SUPPRESS)
|
|
install_parser.add_argument("--dry-run", action="store_true")
|
|
install_parser.add_argument("--check", action="store_true", help="check remote files without downloading")
|
|
install_parser.add_argument(
|
|
"--progress",
|
|
action="store_true",
|
|
help="emit machine-readable AUDIOCPP_PROGRESS lines while downloading",
|
|
)
|
|
add_source_arguments(install_parser)
|
|
return parser
|
|
|
|
|
|
def main() -> int:
|
|
parser = make_parser()
|
|
args = parser.parse_args()
|
|
source = getattr(args, "source", "huggingface")
|
|
source_repo = getattr(args, "source_repo", None)
|
|
if source_repo and source != "modelscope":
|
|
parser.error("--source-repo requires --source modelscope")
|
|
try:
|
|
records = flatten_packages(load_specs(Path(args.specs_dir)))
|
|
if source == "modelscope":
|
|
records = [apply_source_override(record, source_repo) for record in records]
|
|
if args.command == "list":
|
|
command_list(records, args)
|
|
elif args.command == "info":
|
|
command_info(records, args)
|
|
elif args.command == "sizes":
|
|
command_sizes(records, args)
|
|
elif args.command == "installed":
|
|
command_installed(records, args)
|
|
elif args.command == "uninstall":
|
|
uninstall_package(select_package(records, args), records, args)
|
|
elif args.command == "clean-partial":
|
|
clean_partial_package(select_package(records, args), args)
|
|
elif args.command == "install":
|
|
install_package(select_package(records, args), records, args)
|
|
else:
|
|
raise ManagerError(f"unknown command: {args.command}")
|
|
return 0
|
|
except ManagerError as error:
|
|
print(f"error: {error}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|