from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, List, Optional
from urllib.request import Request, urlopen
from .logging_config import get_logger
from .proto import plugin_pb2
logger = get_logger(__name__)
DOWNLOAD_WEIGHTS_OP = "download_weights"
WEIGHTS_STATUS_QUERY = "weights_status"
_USER_AGENT = "foldit-runner/0.1.0"
_CHUNK = 1 << 16 _PROGRESS_STRIDE = 4 << 20
class WeightDownloadCancelled(Exception):
@dataclass(frozen=True)
class WeightSpec:
url: str
subdir: str
name: str
@property
def label(self) -> str:
return f"{self.subdir}/{self.name}" if self.subdir else self.name
def dest_path(cache_dir: str, spec: WeightSpec) -> str:
return str(Path(cache_dir) / spec.subdir / spec.name)
def download_weights_op_spec() -> "plugin_pb2.PluginOp":
return plugin_pb2.PluginOp(
id=DOWNLOAD_WEIGHTS_OP,
display_name="Download weights",
description=(
"Fetch this plugin's model weights into its local assets "
"directory. Multi-GB; progress is reported while it runs."
),
kind=plugin_pb2.OP_KIND_STREAM,
creates_entities=False,
)
def weights_status_query_spec() -> "plugin_pb2.PluginQuery":
return plugin_pb2.PluginQuery(
id=WEIGHTS_STATUS_QUERY,
display_name="Weights status",
description="Report whether this plugin's model weights are present.",
)
def missing_specs(cache_dir: str, specs: List[WeightSpec]) -> List[WeightSpec]:
return [s for s in specs if not os.path.exists(dest_path(cache_dir, s))]
def status_payload(present: List[str], missing: List[str]) -> bytes:
return json.dumps(
{"ready": not missing, "present": present, "missing": missing}
).encode("utf-8")
def status_json(cache_dir: str, specs: List[WeightSpec]) -> bytes:
present, missing = [], []
for spec in specs:
bucket = present if os.path.exists(dest_path(cache_dir, spec)) else missing
bucket.append(spec.label)
return status_payload(present, missing)
def download_specs(
cache_dir: str,
specs: List[WeightSpec],
on_progress: Optional[Callable[[float, str], None]] = None,
should_cancel: Optional[Callable[[], bool]] = None,
) -> None:
todo = missing_specs(cache_dir, specs)
if not todo:
if on_progress is not None:
on_progress(1.0, "weights already present")
return
total = len(todo)
for index, spec in enumerate(todo):
if should_cancel is not None and should_cancel():
raise WeightDownloadCancelled()
def file_progress(file_frac: float, done_mb: float, total_mb: float) -> None:
if on_progress is None:
return
overall = (index + file_frac) / total
size = f"{done_mb:.0f}/{total_mb:.0f} MB" if total_mb else f"{done_mb:.0f} MB"
on_progress(overall, f"{spec.label} ({index + 1}/{total}) {size}")
logger.info("Downloading %s -> %s", spec.url, spec.label)
_download_one(
spec.url,
dest_path(cache_dir, spec),
file_progress,
should_cancel,
)
if on_progress is not None:
on_progress(1.0, "download complete")
def _download_one(
url: str,
dest: str,
on_file_progress: Callable[[float, float, float], None],
should_cancel: Optional[Callable[[], bool]],
) -> None:
dest_dir = os.path.dirname(dest)
if dest_dir:
os.makedirs(dest_dir, exist_ok=True)
partial = dest + ".partial"
req = Request(url, headers={"User-Agent": _USER_AGENT})
try:
with urlopen(req, timeout=60) as response, open(partial, "wb") as out:
total_bytes = int(response.headers.get("Content-Length", 0))
total_mb = total_bytes / (1024 * 1024)
downloaded = 0
since_report = 0
while True:
if should_cancel is not None and should_cancel():
raise WeightDownloadCancelled()
chunk = response.read(_CHUNK)
if not chunk:
break
out.write(chunk)
downloaded += len(chunk)
since_report += len(chunk)
if since_report >= _PROGRESS_STRIDE:
since_report = 0
frac = downloaded / total_bytes if total_bytes else 0.0
on_file_progress(frac, downloaded / (1024 * 1024), total_mb)
on_file_progress(1.0, downloaded / (1024 * 1024), total_mb)
os.replace(partial, dest)
except BaseException:
try:
os.remove(partial)
except OSError:
pass
raise