Add download progress tracking and byte formatting utilities
- Introduced `fmt_bytes` function for human-readable byte size formatting. - Added `DownloadProgress` class to track and display download progress with speed and completion percentage. - Updated `download` functions in `civitai_fetch.py` and `llamacpp_fetch.py` to utilize progress tracking. - Enhanced `ollama_pull.py` to support streaming progress updates during model pulls. - Updated tests to validate new formatting and progress tracking functionalities, ensuring accurate output and user feedback.
This commit is contained in:
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -14,6 +15,68 @@ JOBS_PATH = Path("/tmp/gpu-rent-civitai-jobs.json")
|
|||||||
MARKER = Path("/mnt/swarm_data/.gpu-rent-models-seeded")
|
MARKER = Path("/mnt/swarm_data/.gpu-rent-models-seeded")
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_bytes(n: float) -> str:
|
||||||
|
n = float(n)
|
||||||
|
for unit, div in (("GB", 1024**3), ("MB", 1024**2), ("KB", 1024), ("B", 1)):
|
||||||
|
if n >= div or unit == "B":
|
||||||
|
if unit == "B":
|
||||||
|
return f"{int(n)}B"
|
||||||
|
return f"{n / div:.1f}{unit}"
|
||||||
|
return f"{n:.0f}B"
|
||||||
|
|
||||||
|
|
||||||
|
def progress_line(
|
||||||
|
label: str,
|
||||||
|
done: int,
|
||||||
|
total: int | None,
|
||||||
|
speed: float,
|
||||||
|
*,
|
||||||
|
width: int = 22,
|
||||||
|
) -> str:
|
||||||
|
"""One SSH-safe progress line (newline, not \\r)."""
|
||||||
|
if total and total > 0:
|
||||||
|
pct = min(100.0, 100.0 * done / total)
|
||||||
|
filled = int(width * done / total)
|
||||||
|
filled = min(width, max(0, filled))
|
||||||
|
bar = "#" * filled + "-" * (width - filled)
|
||||||
|
return (
|
||||||
|
f"{label} [{bar}] {pct:5.1f}% "
|
||||||
|
f"{fmt_bytes(done)}/{fmt_bytes(total)} {fmt_bytes(speed)}/s"
|
||||||
|
)
|
||||||
|
return f"{label} {fmt_bytes(done)} {fmt_bytes(speed)}/s"
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadProgress:
|
||||||
|
"""Print size + speed about once per second (SSH readline-friendly)."""
|
||||||
|
|
||||||
|
def __init__(self, label: str, total: int | None) -> None:
|
||||||
|
self.label = label
|
||||||
|
self.total = total if total and total > 0 else None
|
||||||
|
self.done = 0
|
||||||
|
self.t0 = time.monotonic()
|
||||||
|
self.last_print = 0.0
|
||||||
|
|
||||||
|
def add(self, n: int) -> None:
|
||||||
|
self.done += n
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self.last_print < 1.0 and not (
|
||||||
|
self.total is not None and self.done >= self.total
|
||||||
|
):
|
||||||
|
return
|
||||||
|
self.last_print = now
|
||||||
|
self._emit()
|
||||||
|
|
||||||
|
def finish(self) -> None:
|
||||||
|
self._emit()
|
||||||
|
|
||||||
|
def _emit(self) -> None:
|
||||||
|
elapsed = max(time.monotonic() - self.t0, 0.001)
|
||||||
|
print(
|
||||||
|
progress_line(self.label, self.done, self.total, self.done / elapsed),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def sha256_path(path: Path) -> str:
|
def sha256_path(path: Path) -> str:
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
with path.open("rb") as fh:
|
with path.open("rb") as fh:
|
||||||
@@ -38,7 +101,7 @@ def should_skip(dest: Path, expect_sha: str) -> tuple[bool, str]:
|
|||||||
return False, "sha не совпал — перекачиваю"
|
return False, "sha не совпал — перекачиваю"
|
||||||
|
|
||||||
|
|
||||||
def download(url: str, dest: Path, token: str) -> None:
|
def download(url: str, dest: Path, token: str, *, label: str) -> None:
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
partial = dest.with_suffix(dest.suffix + ".partial")
|
partial = dest.with_suffix(dest.suffix + ".partial")
|
||||||
|
|
||||||
@@ -60,11 +123,19 @@ def download(url: str, dest: Path, token: str) -> None:
|
|||||||
url, headers={"Authorization": f"Bearer {token}", "User-Agent": "gpu-rent/0.1"}
|
url, headers={"Authorization": f"Bearer {token}", "User-Agent": "gpu-rent/0.1"}
|
||||||
)
|
)
|
||||||
with opener.open(req, timeout=600) as response, partial.open("wb") as out:
|
with opener.open(req, timeout=600) as response, partial.open("wb") as out:
|
||||||
|
total = response.headers.get("Content-Length")
|
||||||
|
try:
|
||||||
|
total_n = int(total) if total else None
|
||||||
|
except ValueError:
|
||||||
|
total_n = None
|
||||||
|
prog = DownloadProgress(label, total_n)
|
||||||
while True:
|
while True:
|
||||||
chunk = response.read(1024 * 1024)
|
chunk = response.read(1024 * 1024)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
out.write(chunk)
|
out.write(chunk)
|
||||||
|
prog.add(len(chunk))
|
||||||
|
prog.finish()
|
||||||
partial.replace(dest)
|
partial.replace(dest)
|
||||||
|
|
||||||
|
|
||||||
@@ -85,7 +156,8 @@ def main() -> int:
|
|||||||
skip, reason = should_skip(dest, expect)
|
skip, reason = should_skip(dest, expect)
|
||||||
if skip:
|
if skip:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
print(f"{prefix} {reason}: {dest.name}")
|
size = dest.stat().st_size if dest.is_file() else 0
|
||||||
|
print(f"{prefix} {reason}: {dest.name} ({fmt_bytes(size)})")
|
||||||
# refresh sidecars even on skip
|
# refresh sidecars even on skip
|
||||||
for extra_name, extra_text in (job.get("sidecars") or {}).items():
|
for extra_name, extra_text in (job.get("sidecars") or {}).items():
|
||||||
extra = dest.parent / extra_name
|
extra = dest.parent / extra_name
|
||||||
@@ -95,8 +167,8 @@ def main() -> int:
|
|||||||
if reason:
|
if reason:
|
||||||
print(f"{prefix} {reason}: {dest.name}")
|
print(f"{prefix} {reason}: {dest.name}")
|
||||||
try:
|
try:
|
||||||
print(f"{prefix} качаю: {dest.name}")
|
print(f"{prefix} качаю: {dest.name}", flush=True)
|
||||||
download(job["url"], dest, token)
|
download(job["url"], dest, token, label=f"{prefix} {dest.name}")
|
||||||
downloaded += 1
|
downloaded += 1
|
||||||
if expect:
|
if expect:
|
||||||
got = sha256_path(dest).lower()
|
got = sha256_path(dest).lower()
|
||||||
@@ -106,6 +178,7 @@ def main() -> int:
|
|||||||
for extra_name, extra_text in (job.get("sidecars") or {}).items():
|
for extra_name, extra_text in (job.get("sidecars") or {}).items():
|
||||||
extra = dest.parent / extra_name
|
extra = dest.parent / extra_name
|
||||||
extra.write_text(extra_text, encoding="utf-8")
|
extra.write_text(extra_text, encoding="utf-8")
|
||||||
|
print(f"{prefix} ok {dest.name} ({fmt_bytes(dest.stat().st_size)})")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failed += 1
|
failed += 1
|
||||||
print(f"{prefix} FAIL {dest.name}: {exc}", file=sys.stderr)
|
print(f"{prefix} FAIL {dest.name}: {exc}", file=sys.stderr)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -14,6 +15,85 @@ MODELS_DIR = Path("/mnt/swarm_data/llamacpp/models")
|
|||||||
TOKEN_FILE = Path("/tmp/gpu-rent-hf.token")
|
TOKEN_FILE = Path("/tmp/gpu-rent-hf.token")
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_bytes(n: float) -> str:
|
||||||
|
n = float(n)
|
||||||
|
for unit, div in (("GB", 1024**3), ("MB", 1024**2), ("KB", 1024), ("B", 1)):
|
||||||
|
if n >= div or unit == "B":
|
||||||
|
if unit == "B":
|
||||||
|
return f"{int(n)}B"
|
||||||
|
return f"{n / div:.1f}{unit}"
|
||||||
|
return f"{n:.0f}B"
|
||||||
|
|
||||||
|
|
||||||
|
def progress_line(
|
||||||
|
label: str,
|
||||||
|
done: int,
|
||||||
|
total: int | None,
|
||||||
|
speed: float,
|
||||||
|
*,
|
||||||
|
width: int = 22,
|
||||||
|
) -> str:
|
||||||
|
if total and total > 0:
|
||||||
|
pct = min(100.0, 100.0 * done / total)
|
||||||
|
filled = int(width * done / total)
|
||||||
|
filled = min(width, max(0, filled))
|
||||||
|
bar = "#" * filled + "-" * (width - filled)
|
||||||
|
return (
|
||||||
|
f"{label} [{bar}] {pct:5.1f}% "
|
||||||
|
f"{fmt_bytes(done)}/{fmt_bytes(total)} {fmt_bytes(speed)}/s"
|
||||||
|
)
|
||||||
|
return f"{label} {fmt_bytes(done)} {fmt_bytes(speed)}/s"
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadProgress:
|
||||||
|
def __init__(self, label: str, total: int | None) -> None:
|
||||||
|
self.label = label
|
||||||
|
self.total = total if total and total > 0 else None
|
||||||
|
self.done = 0
|
||||||
|
self.t0 = time.monotonic()
|
||||||
|
self.last_print = 0.0
|
||||||
|
|
||||||
|
def add(self, n: int) -> None:
|
||||||
|
self.done += n
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self.last_print < 1.0 and not (
|
||||||
|
self.total is not None and self.done >= self.total
|
||||||
|
):
|
||||||
|
return
|
||||||
|
self.last_print = now
|
||||||
|
self._emit()
|
||||||
|
|
||||||
|
def finish(self) -> None:
|
||||||
|
self._emit()
|
||||||
|
|
||||||
|
def _emit(self) -> None:
|
||||||
|
elapsed = max(time.monotonic() - self.t0, 0.001)
|
||||||
|
print(
|
||||||
|
progress_line(self.label, self.done, self.total, self.done / elapsed),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download(url: str, dest: Path, headers: dict[str, str], *, label: str) -> None:
|
||||||
|
partial = dest.with_suffix(dest.suffix + ".partial")
|
||||||
|
req = urllib.request.Request(url, headers=headers)
|
||||||
|
with urllib.request.urlopen(req, timeout=600) as resp, partial.open("wb") as out:
|
||||||
|
cl = resp.headers.get("Content-Length")
|
||||||
|
try:
|
||||||
|
total_n = int(cl) if cl else None
|
||||||
|
except ValueError:
|
||||||
|
total_n = None
|
||||||
|
prog = DownloadProgress(label, total_n)
|
||||||
|
while True:
|
||||||
|
chunk = resp.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
out.write(chunk)
|
||||||
|
prog.add(len(chunk))
|
||||||
|
prog.finish()
|
||||||
|
partial.replace(dest)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
if TOKEN_FILE.is_file():
|
if TOKEN_FILE.is_file():
|
||||||
try:
|
try:
|
||||||
@@ -43,29 +123,22 @@ def main() -> int:
|
|||||||
if not name:
|
if not name:
|
||||||
name = url.rstrip("/").rsplit("/", 1)[-1] or "model.gguf"
|
name = url.rstrip("/").rsplit("/", 1)[-1] or "model.gguf"
|
||||||
dest = MODELS_DIR / name
|
dest = MODELS_DIR / name
|
||||||
|
prefix = f"[{i}/{len(jobs)}]"
|
||||||
if dest.is_file() and dest.stat().st_size > 1_000_000:
|
if dest.is_file() and dest.stat().st_size > 1_000_000:
|
||||||
print(f"[{i}/{len(jobs)}] уже есть {name} ({dest.stat().st_size} bytes)")
|
print(f"{prefix} уже есть {name} ({fmt_bytes(dest.stat().st_size)})")
|
||||||
continue
|
continue
|
||||||
print(f"[{i}/{len(jobs)}] download {name}")
|
print(f"{prefix} качаю {name}", flush=True)
|
||||||
partial = dest.with_suffix(dest.suffix + ".partial")
|
|
||||||
headers = {"User-Agent": "gpu-rent/1"}
|
headers = {"User-Agent": "gpu-rent/1"}
|
||||||
if token:
|
if token:
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
req = urllib.request.Request(url, headers=headers)
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=600) as resp, partial.open("wb") as out:
|
download(url, dest, headers, label=f"{prefix} {name}")
|
||||||
while True:
|
print(f"{prefix} ok {name} ({fmt_bytes(dest.stat().st_size)})")
|
||||||
chunk = resp.read(1024 * 1024)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
out.write(chunk)
|
|
||||||
partial.replace(dest)
|
|
||||||
print(f"ok {name} ({dest.stat().st_size} bytes)")
|
|
||||||
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError) as exc:
|
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError) as exc:
|
||||||
failed += 1
|
failed += 1
|
||||||
print(f"FAIL {name}: {exc}", file=sys.stderr)
|
print(f"FAIL {name}: {exc}", file=sys.stderr)
|
||||||
try:
|
try:
|
||||||
partial.unlink(missing_ok=True)
|
dest.with_suffix(dest.suffix + ".partial").unlink(missing_ok=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
if failed:
|
if failed:
|
||||||
|
|||||||
@@ -1,15 +1,43 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Pull Ollama models from a JSON list. Stdlib only. Runs on the VM."""
|
"""Pull Ollama models from a JSON list. Stdlib only. Runs on the VM.
|
||||||
|
|
||||||
|
Uses POST /api/pull with stream JSON for completed/total + speed lines.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
JOBS = Path("/tmp/gpu-rent-ollama-models.json")
|
JOBS = Path("/tmp/gpu-rent-ollama-models.json")
|
||||||
MARKER = Path("/mnt/swarm_data/.gpu-rent-ollama-pulling")
|
MARKER = Path("/mnt/swarm_data/.gpu-rent-ollama-pulling")
|
||||||
|
OLLAMA = "http://127.0.0.1:11434"
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_bytes(n: float) -> str:
|
||||||
|
n = float(n)
|
||||||
|
for unit, div in (("GB", 1024**3), ("MB", 1024**2), ("KB", 1024), ("B", 1)):
|
||||||
|
if n >= div or unit == "B":
|
||||||
|
if unit == "B":
|
||||||
|
return f"{int(n)}B"
|
||||||
|
return f"{n / div:.1f}{unit}"
|
||||||
|
return f"{n:.0f}B"
|
||||||
|
|
||||||
|
|
||||||
|
def progress_line(label: str, done: int, total: int | None, speed: float, *, width: int = 22) -> str:
|
||||||
|
if total and total > 0:
|
||||||
|
pct = min(100.0, 100.0 * done / total)
|
||||||
|
filled = min(width, max(0, int(width * done / total)))
|
||||||
|
bar = "#" * filled + "-" * (width - filled)
|
||||||
|
return (
|
||||||
|
f"{label} [{bar}] {pct:5.1f}% "
|
||||||
|
f"{fmt_bytes(done)}/{fmt_bytes(total)} {fmt_bytes(speed)}/s"
|
||||||
|
)
|
||||||
|
return f"{label} {fmt_bytes(done)} {fmt_bytes(speed)}/s"
|
||||||
|
|
||||||
|
|
||||||
def listed() -> set[str]:
|
def listed() -> set[str]:
|
||||||
@@ -32,7 +60,6 @@ def already_have(have: set[str], wanted: str) -> bool:
|
|||||||
"""Exact tag match only — qwen2.5:3b must not satisfy qwen2.5:7b."""
|
"""Exact tag match only — qwen2.5:3b must not satisfy qwen2.5:7b."""
|
||||||
if wanted in have:
|
if wanted in have:
|
||||||
return True
|
return True
|
||||||
# ollama list sometimes omits :latest
|
|
||||||
if ":" not in wanted and f"{wanted}:latest" in have:
|
if ":" not in wanted and f"{wanted}:latest" in have:
|
||||||
return True
|
return True
|
||||||
if wanted.endswith(":latest") and wanted.rsplit(":", 1)[0] in have:
|
if wanted.endswith(":latest") and wanted.rsplit(":", 1)[0] in have:
|
||||||
@@ -40,6 +67,45 @@ def already_have(have: set[str], wanted: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def pull_stream(name: str, label: str) -> None:
|
||||||
|
body = json.dumps({"name": name, "stream": True}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{OLLAMA}/api/pull",
|
||||||
|
data=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
t0 = time.monotonic()
|
||||||
|
last_print = 0.0
|
||||||
|
with urllib.request.urlopen(req, timeout=7200) as resp:
|
||||||
|
while True:
|
||||||
|
raw = resp.readline()
|
||||||
|
if not raw:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
ev = json.loads(raw.decode("utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if ev.get("error"):
|
||||||
|
raise RuntimeError(str(ev["error"]))
|
||||||
|
status = str(ev.get("status") or "")
|
||||||
|
total = int(ev.get("total") or 0) or None
|
||||||
|
done = int(ev.get("completed") or 0)
|
||||||
|
now = time.monotonic()
|
||||||
|
if total and done:
|
||||||
|
if now - last_print >= 1.0 or done >= total:
|
||||||
|
elapsed = max(now - t0, 0.001)
|
||||||
|
speed = done / elapsed
|
||||||
|
print(progress_line(label, done, total, speed), flush=True)
|
||||||
|
last_print = now
|
||||||
|
elif status and status not in {"success"} and now - last_print >= 2.0:
|
||||||
|
print(f"{label} {status}", flush=True)
|
||||||
|
last_print = now
|
||||||
|
if status == "success":
|
||||||
|
break
|
||||||
|
print(f"{label} ok", flush=True)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
if not JOBS.is_file():
|
if not JOBS.is_file():
|
||||||
print("no jobs file", file=sys.stderr)
|
print("no jobs file", file=sys.stderr)
|
||||||
@@ -57,14 +123,15 @@ def main() -> int:
|
|||||||
name = str(name).strip()
|
name = str(name).strip()
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
|
prefix = f"[{i}/{len(models)}]"
|
||||||
if already_have(have, name):
|
if already_have(have, name):
|
||||||
print(f"[{i}/{len(models)}] уже есть {name}")
|
print(f"{prefix} уже есть {name}")
|
||||||
continue
|
continue
|
||||||
print(f"[{i}/{len(models)}] ollama pull {name}")
|
print(f"{prefix} ollama pull {name}", flush=True)
|
||||||
try:
|
try:
|
||||||
subprocess.check_call(["ollama", "pull", name])
|
pull_stream(name, label=f"{prefix} {name}")
|
||||||
have.add(name)
|
have.add(name)
|
||||||
except subprocess.CalledProcessError as exc:
|
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError, RuntimeError) as exc:
|
||||||
failed += 1
|
failed += 1
|
||||||
print(f"FAIL pull {name}: {exc}", file=sys.stderr)
|
print(f"FAIL pull {name}: {exc}", file=sys.stderr)
|
||||||
finally:
|
finally:
|
||||||
@@ -76,4 +143,4 @@ def main() -> int:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
raise SystemExit(main())
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from gpu_rent.remote.civitai_fetch import should_skip
|
from gpu_rent.remote.civitai_fetch import fmt_bytes, progress_line, should_skip
|
||||||
|
|
||||||
|
|
||||||
def test_should_skip_missing(tmp_path: Path):
|
def test_should_skip_missing(tmp_path: Path):
|
||||||
@@ -33,3 +33,24 @@ def test_should_not_skip_bad_sha(tmp_path: Path):
|
|||||||
skip, reason = should_skip(dest, "deadbeef" * 8)
|
skip, reason = should_skip(dest, "deadbeef" * 8)
|
||||||
assert skip is False
|
assert skip is False
|
||||||
assert "перекачиваю" in reason
|
assert "перекачиваю" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_fmt_bytes():
|
||||||
|
assert fmt_bytes(500) == "500B"
|
||||||
|
assert "KB" in fmt_bytes(2048)
|
||||||
|
assert "MB" in fmt_bytes(5 * 1024 * 1024)
|
||||||
|
assert "GB" in fmt_bytes(2 * 1024**3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_line_with_total():
|
||||||
|
line = progress_line("file", 512 * 1024 * 1024, 1024 * 1024 * 1024, 10 * 1024 * 1024, width=10)
|
||||||
|
assert "[" in line and "]" in line
|
||||||
|
assert "50.0%" in line
|
||||||
|
assert "/s" in line
|
||||||
|
assert "file" in line
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_line_unknown_total():
|
||||||
|
line = progress_line("file", 1024 * 1024, None, 100_000)
|
||||||
|
assert "%" not in line
|
||||||
|
assert "/s" in line
|
||||||
|
|||||||
Reference in New Issue
Block a user