← Files sync. labsARCHIVED FILE
skills/batch-lipsync/scripts/sync_batch.py
26.9 KB · Nov 9, 2026 · 00:11 UTC
#!/usr/bin/env python3
"""Run many sync. lipsync generations from a CSV manifest, safely and resumably.
Usage:
sync_batch.py estimate MANIFEST.csv [--model sync-3]
sync_batch.py run MANIFEST.csv [--out DIR] [--concurrency N] [--model sync-3] [--only ID,ID] [--retry-failed]
sync_batch.py submit-batch MANIFEST.csv [--dry-run] [--webhook-url URL] [--model sync-3]
sync_batch.py batch-status MANIFEST.csv [--wait] [--out DIR]
Manifest columns: id, video or image, audio or (script + voice_id), optional sync_mode and model.
Values can be local paths, https URLs or sync. asset IDs. Needs SYNC_API_KEY.
State lives next to the manifest in MANIFEST.csv.state.json: uploaded asset IDs, one
idempotency key and request fingerprint per row, generation IDs and results. Re-running a
command resumes from it: completed rows are skipped, submitted rows are polled instead of
resubmitted, and a row whose inputs changed after submission is refused. Only one command
runs per manifest at a time. Results are also written to results.csv.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
import uuid
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
API = os.environ.get("SYNC_API_BASE", "https://api.sync.so").rstrip("/")
UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
TERMINAL = {"COMPLETED", "FAILED", "REJECTED"}
SYNC_MODES = {"bounce", "loop", "cut_off", "silence", "remap"}
MIME = {
".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm", ".avi": "video/x-msvideo",
".wav": "audio/wav", ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
".ogg": "audio/ogg", ".flac": "audio/flac",
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp",
}
WORDS_PER_SECOND = 2.5
MAX_TRANSIENT_RETRIES = 6
# The API protects an idempotency key for 7 days. Past that, a replay without a known
# generation ID could start a second generation, so it needs a human check first.
REPLAY_WINDOW_SECONDS = 6 * 24 * 3600
class ApiError(Exception):
def __init__(self, status: int, body, headers=None):
self.status, self.body, self.headers = status, body, headers or {}
self.code = body.get("errorCode") if isinstance(body, dict) else None
message = body.get("message") if isinstance(body, dict) else body
super().__init__(f"HTTP {status} {self.code or ''}: {message}".replace(" ", " "))
@property
def json(self) -> dict:
return self.body if isinstance(self.body, dict) else {}
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Never follow redirects with the API key attached; signed URLs are fetched without it."""
def redirect_request(self, *args, **kwargs):
return None
_api_opener = urllib.request.build_opener(_NoRedirect)
def api_raw(method: str, path: str, body=None, headers=None, timeout=70):
"""Call the sync. API and return (status, headers, bytes). Redirects are returned, not followed."""
key = os.environ.get("SYNC_API_KEY")
if not key:
sys.exit("SYNC_API_KEY is not set. Create a key at https://sync.so/settings/api-keys.")
data = json.dumps(body).encode() if body is not None else None
url = path if path.startswith("http") else f"{API}{path}"
req = urllib.request.Request(url, data=data, method=method)
req.add_header("x-api-key", key)
if data is not None:
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with _api_opener.open(req, timeout=timeout) as resp:
return resp.status, dict(resp.headers), resp.read()
except urllib.error.HTTPError as err:
if err.code in (301, 302, 303, 307, 308):
return err.code, dict(err.headers), b""
raw = err.read().decode(errors="replace")
try:
parsed = json.loads(raw)
except ValueError:
parsed = raw
raise ApiError(err.code, parsed, dict(err.headers)) from None
def api(method: str, path: str, body=None, headers=None, timeout=70):
_, _, raw = api_raw(method, path, body, headers, timeout)
return json.loads(raw) if raw else {}
def fetch(url: str, dest: Path | None = None):
"""GET a signed or public URL, without the API key."""
with urllib.request.urlopen(url, timeout=300) as resp:
if dest is None:
return resp.read()
tmp = dest.with_name(f"{dest.name}.{os.getpid()}.part")
with open(tmp, "wb") as fh:
shutil.copyfileobj(resp, fh)
tmp.replace(dest)
return None
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
class ManifestLock:
"""One command per manifest at a time, across processes."""
def __init__(self, manifest: Path):
self.path = manifest.with_name(manifest.name + ".lock")
self.fh = None
def __enter__(self):
self.fh = open(self.path, "a+")
try:
import fcntl
fcntl.flock(self.fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except ImportError: # Windows: fall back to an exclusive lock file
self.fh.close()
try:
self.fh = os.fdopen(os.open(str(self.path) + ".win", os.O_CREAT | os.O_EXCL | os.O_WRONLY), "w")
except FileExistsError:
sys.exit(f"Another sync_batch.py command is already running on this manifest ({self.path}.win).")
except OSError:
sys.exit("Another sync_batch.py command is already running on this manifest. Wait for it to finish.")
return self
def __exit__(self, *exc):
win = Path(str(self.path) + ".win")
if self.fh:
self.fh.close()
if win.exists():
win.unlink()
class State:
def __init__(self, manifest: Path):
self.path = manifest.with_name(manifest.name + ".state.json")
self.lock = threading.RLock()
self.data = {"run": uuid.uuid4().hex[:12], "assets": {}, "rows": {}, "batch": None}
if self.path.exists():
self.data.update(json.loads(self.path.read_text()))
def row(self, row_id: str) -> dict:
with self.lock:
return dict(self.data["rows"].get(row_id, {}))
def update(self, row_id: str, **fields):
with self.lock:
self.data["rows"].setdefault(row_id, {}).update(fields)
self.save()
def save(self):
with self.lock:
tmp = self.path.with_name(f"{self.path.name}.{os.getpid()}.tmp")
tmp.write_text(json.dumps(self.data, indent=2))
tmp.replace(self.path)
def batch_unresolved(self) -> bool:
batch = self.data.get("batch") or {}
return bool(batch.get("submitting") or (batch.get("id") and batch.get("status") not in ("COMPLETED", "FAILED")))
def read_manifest(path: Path) -> list[dict]:
with open(path, newline="", encoding="utf-8-sig") as fh:
rows = [{(k or "").strip().lower(): (v or "").strip() for k, v in r.items()} for r in csv.DictReader(fh)]
problems, seen = [], set()
for n, r in enumerate(rows, start=2):
rid = r.get("id", "")
if not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", rid):
problems.append(f"line {n}: id must be 1-80 letters, digits, '_' or '-'")
if rid in seen:
problems.append(f"line {n}: duplicate id {rid}")
seen.add(rid)
if bool(r.get("video")) == bool(r.get("image")):
problems.append(f"line {n} ({rid}): set exactly one of video or image")
if bool(r.get("audio")) == bool(r.get("script")):
problems.append(f"line {n} ({rid}): set exactly one of audio or script")
if r.get("script") and not r.get("voice_id"):
problems.append(f"line {n} ({rid}): script needs voice_id")
if r.get("sync_mode") and r["sync_mode"] not in SYNC_MODES:
problems.append(f"line {n} ({rid}): sync_mode must be one of {', '.join(sorted(SYNC_MODES))}")
for col in ("video", "image", "audio"):
v = r.get(col)
if v and not (v.startswith("https://") or UUID_RE.match(v) or (path.parent / Path(v).expanduser()).is_file()):
problems.append(f"line {n} ({rid}): {col} {v!r} is not a file, https URL or asset ID")
if not rows:
problems.append("the manifest has no rows")
if problems:
sys.exit("Fix the manifest first:\n " + "\n ".join(problems))
return rows
def local_path(value: str, manifest_dir: Path) -> Path | None:
if value.startswith("https://") or UUID_RE.match(value):
return None
return (manifest_dir / Path(value).expanduser()).resolve()
def fingerprint(row: dict, args, manifest_dir: Path) -> str:
"""Identity of what a row asks for, including the bytes of local files."""
parts = {"model": row.get("model") or args.model, "sync_mode": row.get("sync_mode", ""),
"script": row.get("script", ""), "voice_id": row.get("voice_id", "")}
for col in ("video", "image", "audio"):
value = row.get(col, "")
path = local_path(value, manifest_dir) if value else None
parts[col] = f"sha256:{sha256_file(path)}" if path else value
return hashlib.sha256(json.dumps(parts, sort_keys=True).encode()).hexdigest()
def check_unchanged(rows: list[dict], args, state: State, manifest_dir: Path) -> dict:
"""Refuse rows whose inputs changed after they were submitted; returns id -> fingerprint."""
prints, changed = {}, []
for row in rows:
prints[row["id"]] = fp = fingerprint(row, args, manifest_dir)
saved = state.row(row["id"])
if saved.get("fingerprint") and saved["fingerprint"] != fp and (saved.get("key") or saved.get("generationId")):
changed.append(row["id"])
if changed:
sys.exit("These rows changed after they were submitted: " + ", ".join(changed) +
".\nGive each changed row a new id (a new paid generation), or restore its original inputs.")
return prints
def resolve_media(value: str, kind: str, state: State, manifest_dir: Path) -> dict:
"""Return {"url": ...} or {"assetId": ...}, uploading each local file only once."""
path = local_path(value, manifest_dir)
if path is None:
return {"url": value} if value.startswith("https://") else {"assetId": value}
with state.lock: # one upload per file content, even when rows share it
cache_key = f"sha256:{sha256_file(path)}"
cached = state.data["assets"].get(cache_key)
if cached:
return {"assetId": cached}
content_type = MIME.get(path.suffix.lower())
if not content_type:
sys.exit(f"Unsupported file type: {path}")
size = path.stat().st_size
presign = api("POST", "/v2/assets/upload", {"fileName": path.name, "contentType": content_type, "size": size})
with open(path, "rb") as fh:
put = urllib.request.Request(presign["uploadUrl"], data=fh.read(), method="PUT")
put.add_header("Content-Type", content_type)
urllib.request.urlopen(put, timeout=600).read()
asset = api("POST", "/v2/assets", {"url": presign["url"], "type": kind.upper(), "name": path.name})
state.data["assets"][cache_key] = asset["id"]
state.save()
return {"assetId": asset["id"]}
def payload_for(row: dict, args, state: State, manifest_dir: Path) -> dict:
visual = "video" if row.get("video") else "image"
inputs = [{"type": visual, **resolve_media(row[visual], visual, state, manifest_dir)}]
if row.get("audio"):
inputs.append({"type": "audio", **resolve_media(row["audio"], "audio", state, manifest_dir)})
else:
inputs.append({"type": "text", "provider": {"name": "elevenlabs", "voiceId": row["voice_id"], "script": row["script"]}})
body = {"model": row.get("model") or args.model, "input": inputs, "outputFileName": row["id"]}
if row.get("sync_mode") and visual == "video":
body["options"] = {"sync_mode": row["sync_mode"]}
return body
def media_seconds(value: str, manifest_dir: Path):
if UUID_RE.match(value):
try:
return api("GET", f"/v2/assets/{value}").get("durationSeconds")
except ApiError:
return None
if not shutil.which("ffprobe"):
return None
path = local_path(value, manifest_dir)
out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(path or value)],
capture_output=True, text=True)
try:
return float(out.stdout.strip())
except ValueError:
return None
def billable_seconds(row: dict, manifest_dir: Path):
"""The output length sync. bills: the speech, adjusted for cut_off and silence on video."""
speech = (max(1.0, len(row["script"].split()) / WORDS_PER_SECOND) if row.get("script")
else media_seconds(row["audio"], manifest_dir))
if not speech:
return None
mode = row.get("sync_mode")
if row.get("video") and mode in ("cut_off", "silence"):
video = media_seconds(row["video"], manifest_dir)
if not video:
return None
return min(speech, video) if mode == "cut_off" else max(speech, video)
return speech
def cmd_estimate(args):
manifest = Path(args.manifest)
rows = read_manifest(manifest)
total, unpriced, approx = 0.0, [], False
print(f"{'id':<28} {'seconds':>8} {'usd':>9}")
for row in rows:
seconds = billable_seconds(row, manifest.parent)
cost = None
if seconds:
est = api("POST", "/v2/generations/estimate", {"model": row.get("model") or args.model, "duration": round(seconds, 2)})
cost = est.get("estimatedGenerationCost")
if not isinstance(cost, (int, float)):
unpriced.append(row["id"])
print(f"{row['id']:<28} {seconds or '?':>8} {'?':>9}")
continue
total += cost
approx = approx or bool(row.get("script"))
print(f"{row['id']:<28} {seconds:>8.1f} {cost:>9.2f}")
note = " Rows with a script are approximate, and speech synthesis is billed separately." if approx else ""
if unpriced:
print(f"\nKnown subtotal ${total:.2f} for {len(rows) - len(unpriced)} of {len(rows)} rows.{note}")
print(f"Couldn't price {len(unpriced)} rows (install ffprobe, or check them by hand): {', '.join(unpriced)}")
sys.exit(2)
print(f"\n{len(rows)} rows, estimated total ${total:.2f}.{note}")
class Slots:
"""A concurrency limit that shrinks to the plan's real limit when the API reports it."""
def __init__(self, limit: int):
self.limit, self.active, self.cond = max(1, limit), 0, threading.Condition()
def acquire(self):
with self.cond:
while self.active >= self.limit:
self.cond.wait()
self.active += 1
def release(self):
with self.cond:
self.active -= 1
self.cond.notify_all()
def learn(self, limit):
if isinstance(limit, int) and limit > 0:
with self.cond:
self.limit = min(self.limit, limit)
def submit(body: dict, key: str, slots: Slots) -> dict:
"""POST /v2/generate with a fixed idempotency key; every retry reuses it."""
attempt = 0
while True:
try:
return api("POST", "/v2/generate", body, {"Idempotency-Key": key})
except ApiError as err:
info = err.json
if err.status == 429:
slots.learn(info.get("concurrencyLimit"))
time.sleep(float(info.get("retryAfterSeconds") or err.headers.get("Retry-After") or 20))
continue
if err.status == 409 and err.code == "IDEMPOTENCY_IN_PROGRESS":
time.sleep(2)
continue
if info.get("generationId") and err.status in (409, 503):
return {"id": info["generationId"], "status": "PENDING"}
attempt += 1
if (err.status >= 500 or err.status == 408) and attempt <= MAX_TRANSIENT_RETRIES:
time.sleep(float(err.headers.get("Retry-After") or min(60, 2 ** attempt)))
continue
raise
except (urllib.error.URLError, TimeoutError, ConnectionError):
attempt += 1
if attempt > MAX_TRANSIENT_RETRIES:
raise
time.sleep(min(60, 2 ** attempt))
def wait_for(gen_id: str) -> dict:
while True:
try:
gen = api("GET", f"/v2/generate/{gen_id}?wait=true&timeout=10")
except (urllib.error.URLError, TimeoutError, ConnectionError):
time.sleep(5)
continue
except ApiError as err:
if err.status == 429 or err.status >= 500:
time.sleep(10)
continue
raise
if gen.get("status") in TERMINAL:
return gen
time.sleep(3)
def download(rid: str, url: str, out: str | None, state: State):
if not out or not url:
return
dest = Path(out).resolve() / f"{rid}.mp4"
if dest.exists():
return
dest.parent.mkdir(parents=True, exist_ok=True)
fetch(url, dest)
state.update(rid, file=str(dest))
def process_row(row, args, state: State, slots: Slots, manifest_dir: Path, fp: str):
rid = row["id"]
saved = state.row(rid)
if saved.get("status") == "COMPLETED" and saved.get("outputUrl"):
download(rid, saved["outputUrl"], args.out, state)
print(f"{rid}: already completed", flush=True)
return
retry = saved.get("status") in {"FAILED", "REJECTED"} and args.retry_failed
if saved.get("status") in {"FAILED", "REJECTED"} and not retry:
print(f"{rid}: {saved['status']} earlier ({saved.get('error')}); pass --retry-failed to try again", flush=True)
return
key = saved.get("key") or f"{state.data['run']}-{rid}"[:128]
try:
if saved.get("generationId") and not retry:
gen = wait_for(saved["generationId"])
else:
submitted_at = saved.get("submittedAt")
if submitted_at and not saved.get("generationId") and time.time() - submitted_at > REPLAY_WINDOW_SECONDS:
print(f"{rid}: submitted {int((time.time() - submitted_at) / 86400)} days ago with no confirmed generation. "
f"Check GET /v2/generations for it before retrying; skipped.", flush=True)
return
body = payload_for(row, args, state, manifest_dir)
# Save the key, request identity and time before the request, so a crash can't lose them.
state.update(rid, key=key, fingerprint=fp, mode="single", submittedAt=submitted_at or time.time())
slots.acquire()
try:
# After a confirmed failure the same key starts a fresh attempt, per the API's idempotency rules.
created = submit(body, key, slots)
state.update(rid, generationId=created["id"], status=created.get("status", "PENDING"), error=None)
gen = wait_for(created["id"])
finally:
slots.release()
state.update(rid, status=gen["status"], outputUrl=gen.get("outputUrl"), error=gen.get("error") or gen.get("errorCode"))
if gen["status"] == "COMPLETED":
download(rid, gen.get("outputUrl"), args.out, state)
suffix = "" if gen["status"] == "COMPLETED" else f" ({gen.get('error') or gen.get('errorCode')})"
print(f"{rid}: {gen['status']}{suffix}", flush=True)
except ApiError as err:
state.update(rid, status="FAILED", error=str(err))
print(f"{rid}: FAILED ({err})", flush=True)
def write_results(manifest: Path, rows: list[dict], state: State):
out = manifest.with_name("results.csv")
with open(out, "w", newline="") as fh:
w = csv.writer(fh)
w.writerow(["id", "status", "generation_id", "output_url", "file", "error"])
for row in rows:
s = state.data["rows"].get(row["id"], {})
w.writerow([row["id"], s.get("status", "NOT_STARTED"), s.get("generationId") or "",
s.get("outputUrl") or "", s.get("file", ""), s.get("error") or ""])
statuses = [state.data["rows"].get(r["id"], {}).get("status") for r in rows]
print(f"\n{statuses.count('COMPLETED')}/{len(rows)} completed, "
f"{sum(s in ('FAILED', 'REJECTED') for s in statuses)} failed. Results: {out}")
def cmd_run(args):
manifest = Path(args.manifest)
all_rows = read_manifest(manifest)
rows = all_rows
if args.only:
wanted = {x.strip() for x in args.only.split(",") if x.strip()}
rows = [r for r in all_rows if r["id"] in wanted]
missing = wanted - {r["id"] for r in rows}
if missing:
sys.exit(f"Unknown row ids: {', '.join(sorted(missing))}")
state = State(manifest)
if state.batch_unresolved():
in_batch = [r["id"] for r in rows if state.row(r["id"]).get("mode") == "batch"]
if (state.data["batch"] or {}).get("submitting") or in_batch:
sys.exit("A batch for this manifest is still running or its submission is unconfirmed. "
"Run batch-status first, so its rows aren't submitted twice.")
prints = check_unchanged(rows, args, state, manifest.parent)
state.save()
slots = Slots(args.concurrency)
with ThreadPoolExecutor(max_workers=max(1, args.concurrency)) as pool:
for future in [pool.submit(process_row, r, args, state, slots, manifest.parent, prints[r["id"]]) for r in rows]:
future.result()
write_results(manifest, all_rows, state)
def cmd_submit_batch(args):
manifest = Path(args.manifest)
rows = read_manifest(manifest)
state = State(manifest)
batch = state.data.get("batch") or {}
if batch.get("submitting") and not args.dry_run:
sys.exit("A previous submit may have reached sync. without a reply. List your batches with GET /v2/batch "
"and check for it before submitting again; then remove the 'batch' entry from the state file.")
if state.batch_unresolved() and not args.dry_run:
sys.exit(f"Batch {batch['id']} is still running. Use batch-status.")
prints = check_unchanged(rows, args, state, manifest.parent)
# Rows already submitted on their own (a pilot, or an earlier run) stay out of the batch.
pending = [r for r in rows if not state.row(r["id"]).get("generationId") and state.row(r["id"]).get("status") != "COMPLETED"]
skipped = len(rows) - len(pending)
if not pending:
sys.exit("Every row was already submitted. Use run to follow them up.")
body = {"rows": [{"request_id": r["id"], "endpoint": "/v2/generate",
"payload": payload_for(r, args, state, manifest.parent)} for r in pending]}
if args.webhook_url:
body["webhook_url"] = args.webhook_url
if args.dry_run:
body["dry_run"] = True
print(json.dumps(api("POST", "/v2/batch", body)) + (f" ({skipped} rows already submitted were left out)" if skipped else ""))
return
state.data["batch"] = {"submitting": True, "rows": [r["id"] for r in pending], "submittedAt": time.time()}
for r in pending:
state.data["rows"].setdefault(r["id"], {}).update(mode="batch", fingerprint=prints[r["id"]])
state.save()
created = api("POST", "/v2/batch", body)
state.data["batch"] = {"id": created["id"], "status": created.get("status"), "rows": [r["id"] for r in pending]}
state.save()
print(f"Batch {created['id']} submitted with {len(pending)} rows"
+ (f" ({skipped} already submitted rows left out)" if skipped else "") + ". Check on it with batch-status.")
def cmd_batch_status(args):
manifest = Path(args.manifest)
rows = read_manifest(manifest)
state = State(manifest)
batch_id = (state.data.get("batch") or {}).get("id")
if not batch_id:
sys.exit("No confirmed batch in the state file. Run submit-batch first, or reconcile with GET /v2/batch.")
while True:
batch = api("GET", f"/v2/batch/{batch_id}")
print(f"{batch_id}: {batch.get('status')} {json.dumps(batch.get('metrics') or {})}", flush=True)
if batch.get("status") in {"COMPLETED", "FAILED"} or not args.wait:
break
time.sleep(args.interval)
state.data["batch"]["status"] = batch.get("status")
state.save()
if batch.get("status") != "COMPLETED" or not batch.get("outputUrl"):
return
# The results URL answers with a redirect to a signed file; fetch that without the API key.
status, headers, raw = api_raw("GET", batch["outputUrl"])
text = fetch(headers.get("Location") or headers.get("location")).decode() if 300 <= status < 400 else raw.decode()
for line in filter(None, (ln.strip() for ln in text.splitlines())):
rec = json.loads(line)
rid, resp, error = rec["request_id"], rec.get("response") or {}, rec.get("error")
state.update(rid, status=rec.get("status"), generationId=resp.get("id"),
outputUrl=resp.get("output_url") or resp.get("outputUrl"),
error=error.get("message") if isinstance(error, dict) else error)
if rec.get("status") == "COMPLETED":
download(rid, state.row(rid).get("outputUrl"), args.out, state)
write_results(manifest, rows, state)
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="command", required=True)
for name in ("estimate", "run", "submit-batch", "batch-status"):
p = sub.add_parser(name)
p.add_argument("manifest")
p.add_argument("--model", default="sync-3")
if name in ("run", "batch-status"):
p.add_argument("--out", help="download finished videos into this directory")
if name == "run":
p.add_argument("--concurrency", type=int, default=1,
help="generations in flight; set it to the plan's generationLimits.concurrency")
p.add_argument("--only", help="comma-separated row ids, for a pilot run")
p.add_argument("--retry-failed", action="store_true", help="start new attempts for FAILED or REJECTED rows")
if name == "submit-batch":
p.add_argument("--dry-run", action="store_true", help="validate only; creates nothing")
p.add_argument("--webhook-url")
if name == "batch-status":
p.add_argument("--wait", action="store_true", help="keep checking until the batch finishes")
p.add_argument("--interval", type=float, default=60, help="seconds between checks with --wait")
args = parser.parse_args()
commands = {"estimate": cmd_estimate, "run": cmd_run, "submit-batch": cmd_submit_batch, "batch-status": cmd_batch_status}
if args.command == "estimate":
commands[args.command](args)
return
with ManifestLock(Path(args.manifest)):
commands[args.command](args)
if __name__ == "__main__":
main()
SHA-256: b4c01decdcdc3eb8be8f5535f379ed41faa2b4e687d56ac4fe1859b33651359c