parsa-hke's picture
Negative-Space Reader demo: ZeroGPU bundle (app/space_bundle.py, project commit fc6e4a9)
7103d1c verified
Raw History Blame Contribute Delete
14.8 kB
"""Move data and artefacts between this repository and a private Hugging Face dataset repo (plan §0.6: the Mac and
Colab share one store).
python -m experiments.hub_sync push --bundles data,embeddings,cache,packs,runs
python -m experiments.hub_sync pull --bundles data,embeddings,cache,packs,runs
python -m experiments.hub_sync status --bundles data,embeddings,cache,packs,runs # read-only comparison
Bundles are defined in configs/base.yaml (`hub.bundles`): include/exclude patterns relative to the repository root
(fnmatch, `*` crosses folders), or `archive: <folder>` for a folder of many small files, which travels as one
tar.gz under archives/. Pull never replaces a local file that differs from the hub's copy and was modified after
the hub's last commit of it (it is kept and reported; push it). Push uploads only files whose content differs from the hub's copy (git blob sha1 for small
files, sha256 for LFS files), in commits of bounded size, and never deletes anything on the hub. runs/log.csv is
merged rather than overwritten (push_log / pull_log): the hub keeps the union of every machine's rows, and each
machine appends the rows it lacks. Credentials come from the logged-in Hugging Face client (HF_TOKEN or `hf auth
login`); none is read or printed here. Symlinked folders (a git worktree) are followed.
"""
from __future__ import annotations
import argparse
import csv
import fnmatch
import hashlib
import io
import os
import tarfile
import time
from pathlib import Path
from typing import Any
REPO = Path(__file__).resolve().parent.parent
LOG_IN_HUB = "logs/runs_log.csv"
MAX_COMMIT_BYTES = 512 * 1024 ** 2 # a failed upload (network timeout) loses at most one such chunk
MAX_COMMIT_FILES = 500
COMMIT_RETRIES = 4
RETRY_WAIT_S = 30.0
JUNK_FILES = {".DS_Store", "Thumbs.db"}
def git_blob_sha1_bytes(data: bytes) -> str:
return hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest()
def _file_hashes(path: Path) -> tuple[str, str]:
"""(git blob sha1, sha256) of a file, streamed."""
size = path.stat().st_size
g, s = hashlib.sha1(b"blob %d\x00" % size), hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
g.update(chunk)
s.update(chunk)
return g.hexdigest(), s.hexdigest()
def _matches(rel: str, patterns: list[str]) -> bool:
return any(fnmatch.fnmatch(rel, p) or fnmatch.fnmatch(rel, p.rstrip("/*") + "/*") for p in patterns)
def select(root: Path, bundle: dict[str, Any]) -> list[str]:
"""Repository-relative paths (posix) of the bundle's files, following symlinked folders."""
include, exclude = bundle.get("include", []), bundle.get("exclude", [])
out = []
for dirpath, dirnames, filenames in os.walk(root, followlinks=True):
rel_dir = Path(dirpath).relative_to(root).as_posix()
dirnames[:] = [d for d in dirnames if d not in (".git", ".venv", "__pycache__")
and not _matches(f"{rel_dir}/{d}".lstrip("./"), exclude)]
for name in filenames:
if name in JUNK_FILES:
continue
rel = f"{rel_dir}/{name}".lstrip("./") if rel_dir != "." else name
if _matches(rel, include) and not _matches(rel, exclude):
out.append(rel)
return sorted(out)
def _remote(api: Any, repo_id: str) -> dict[str, Any]:
try:
return {e.path: e for e in api.list_repo_tree(repo_id, repo_type="dataset", recursive=True, expand=True)
if hasattr(e, "size")}
except Exception: # an empty repo has no tree yet
return {}
def _same(path: Path, r: Any) -> bool:
"""A local file has the content of the hub's entry (git blob sha1 for small files, sha256 for LFS files)."""
if r.size != path.stat().st_size:
return False
g, s = _file_hashes(path)
return getattr(r, "blob_id", None) == g or bool(getattr(r, "lfs", None) and r.lfs.sha256 == s)
def _literal(rel: str) -> str:
"""An fnmatch pattern matching exactly this path (snapshot_download filters with fnmatch)."""
return "".join(f"[{c}]" if c in "*?[" else c for c in rel)
def newer_local(root: Path, remote: dict[str, Any], bundle: dict[str, Any]) -> list[str]:
"""Bundle files on the hub whose local copy differs and was modified after the hub's last commit of the file: a
pull must not replace them (2026-10-20: a push during a running job put a partial file on the hub, and a later
pull replaced the finished local file with it). Push them instead. An older local copy is replaced as before."""
include, exclude = bundle.get("include", []), bundle.get("exclude", [])
keep = []
for rel, r in sorted(remote.items()):
p = root / rel
if not (_matches(rel, include) and not _matches(rel, exclude)) or not p.is_file():
continue
date = getattr(getattr(r, "last_commit", None), "date", None)
if date is not None and p.stat().st_mtime > date.timestamp() and not _same(p, r):
keep.append(rel)
return keep
def _archive(folder: Path) -> bytes:
import gzip
buf = io.BytesIO()
# gzip writes the current time into its header; mtime=0 keeps the archive byte-identical for identical files
with gzip.GzipFile(fileobj=buf, mode="wb", compresslevel=6, mtime=0) as gz, tarfile.open(fileobj=gz, mode="w") as tar:
for p in sorted(folder.rglob("*")):
if p.is_file():
info = tar.gettarinfo(str(p), arcname=p.relative_to(folder).as_posix())
info.mtime, info.uid, info.gid, info.uname, info.gname = 0, 0, 0, "", "" # same bytes for the same files
with open(p, "rb") as f:
tar.addfile(info, f)
return buf.getvalue()
def _plan(api: Any, repo_id: str, root: Path, bundles: dict[str, Any], names: list[str]
) -> tuple[list[tuple[str, Any, int, bool]], int]:
"""Files whose content differs from the hub's copy: [(path in repo, local source, size, missing on hub)], and the
number of files that already match. Archives are staged under .cache/hub_sync so the hub's chunked upload works."""
remote = _remote(api, repo_id)
todo: list[tuple[str, Any, int, bool]] = []
matched = 0
for name in names:
b = bundles[name]
if "archive" in b:
folder = root / b["archive"]
if not folder.is_dir():
continue
data = _archive(folder)
path = f"archives/{Path(b['archive']).name}.tar.gz"
r = remote.get(path)
if r is None or getattr(r, "blob_id", None) != git_blob_sha1_bytes(data) and not (
getattr(r, "lfs", None) and r.lfs.sha256 == hashlib.sha256(data).hexdigest()):
staged = root / ".cache" / "hub_sync" / path # a file, so the hub's chunked upload can be used
staged.parent.mkdir(parents=True, exist_ok=True)
staged.write_bytes(data)
todo.append((path, str(staged), len(data), r is None))
else:
matched += 1
continue
for rel in select(root, b):
p = (root / rel).resolve()
r = remote.get(rel)
if r is not None and _same(p, r):
matched += 1
continue
todo.append((rel, str(p), p.stat().st_size, r is None))
return todo, matched
def status(api: Any, repo_id: str, root: Path, bundles: dict[str, Any], names: list[str]) -> dict[str, Any]:
"""Read-only: which local bundle files differ from (or are missing on) the hub. Nothing is uploaded."""
todo, matched = _plan(api, repo_id, root, bundles, names)
return {"matched": matched, "differs": len(todo), "missing_on_hub": sum(m for *_, m in todo),
"bytes": sum(t[2] for t in todo), "files": [t[0] for t in todo]}
def push(api: Any, repo_id: str, root: Path, bundles: dict[str, Any], names: list[str]) -> dict[str, Any]:
from huggingface_hub import CommitOperationAdd
api.create_repo(repo_id, repo_type="dataset", private=True, exist_ok=True)
todo, _ = _plan(api, repo_id, root, bundles, names)
ops = [CommitOperationAdd(path_in_repo=rel, path_or_fileobj=src) for rel, src, _, _ in todo]
sizes = [size for _, _, size, _ in todo]
print(f"[hub_sync] plan: {len(ops)} files, {sum(sizes) / 1e9:.2f} GB to upload", flush=True)
commits, start = 0, 0
while start < len(ops): # bounded commits: a failed upload loses one chunk, not everything
end, total = start, 0
while end < len(ops) and end - start < MAX_COMMIT_FILES and (total + sizes[end] <= MAX_COMMIT_BYTES or end == start):
total += sizes[end]
end += 1
for attempt in range(COMMIT_RETRIES + 1): # network timeouts happen on long uploads: retry the chunk
try:
api.create_commit(repo_id=repo_id, repo_type="dataset", operations=ops[start:end],
commit_message=f"sync {','.join(names)}: {end - start} files")
break
except Exception as e:
if attempt == COMMIT_RETRIES:
raise
wait = RETRY_WAIT_S * 2 ** attempt
print(f"[hub_sync] commit of {end - start} files failed ({type(e).__name__}); retry {attempt + 1}/"
f"{COMMIT_RETRIES} in {wait:.0f}s", flush=True)
time.sleep(wait)
commits += 1
print(f"[hub_sync] committed {end}/{len(ops)} files", flush=True)
start = end
return {"uploaded": len(ops), "bytes": sum(sizes), "commits": commits}
def _read_log(text: str) -> tuple[list[str], list[dict[str, str]]]:
r = csv.DictReader(io.StringIO(text))
return list(r.fieldnames or []), list(r)
def _key(row: dict[str, str]) -> tuple[str, str]:
return row.get("timestamp", ""), row.get("run_id", "")
def push_log(api: Any, repo_id: str, root: Path) -> int:
"""Union of the hub's run log and this machine's runs/log.csv, written back to the hub; returns rows added."""
from huggingface_hub import CommitOperationAdd
local_path = root / "runs" / "log.csv"
if not local_path.is_file():
return 0
fields, local = _read_log(local_path.read_text(encoding="utf-8"))
hub_fields, hub = fields, []
try:
hub_fields, hub = _read_log(Path(api.hf_hub_download(repo_id, LOG_IN_HUB, repo_type="dataset",
local_dir=root / ".cache" / "hub_sync")).read_text())
except Exception:
pass
have = {_key(r) for r in hub}
new = [r for r in local if _key(r) not in have]
if not new and hub:
return 0
rows = sorted(hub + new, key=_key)
buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=hub_fields or fields)
w.writeheader()
w.writerows(rows)
api.create_commit(repo_id=repo_id, repo_type="dataset", commit_message=f"run log: +{len(new)} rows",
operations=[CommitOperationAdd(path_in_repo=LOG_IN_HUB,
path_or_fileobj=io.BytesIO(buf.getvalue().encode()))])
return len(new)
def pull_log(api: Any, repo_id: str, root: Path) -> int:
"""Append to runs/log.csv the hub's rows this machine lacks (never rewrites existing rows); returns rows added."""
try:
path = Path(api.hf_hub_download(repo_id, LOG_IN_HUB, repo_type="dataset", local_dir=root / ".cache" / "hub_sync"))
except Exception:
return 0
_, hub = _read_log(path.read_text(encoding="utf-8"))
local_path = root / "runs" / "log.csv"
fields, local = _read_log(local_path.read_text(encoding="utf-8"))
have = {_key(r) for r in local}
new = [r for r in hub if _key(r) not in have]
if new:
with open(local_path, "a", newline="", encoding="utf-8") as f:
csv.DictWriter(f, fieldnames=fields, extrasaction="ignore").writerows(new)
return len(new)
def _snapshot(repo_id: str, root: Path, include: list[str], exclude: list[str]) -> None:
from huggingface_hub import snapshot_download
snapshot_download(repo_id, repo_type="dataset", local_dir=root, allow_patterns=include,
ignore_patterns=exclude or None)
def pull(api: Any, repo_id: str, root: Path, bundles: dict[str, Any], names: list[str]) -> dict[str, Any]:
"""Download the bundles. A local file newer than the hub's differing copy is kept and reported (newer_local)."""
pulled, kept = [], []
remote = None
for name in names:
b = bundles[name]
if "archive" in b:
path = f"archives/{Path(b['archive']).name}.tar.gz"
local = Path(api.hf_hub_download(repo_id, path, repo_type="dataset", local_dir=root / ".cache" / "hub_sync"))
dest = root / b["archive"]
dest.mkdir(parents=True, exist_ok=True)
with tarfile.open(local, mode="r:gz") as tar:
tar.extractall(dest, filter="data") # the "data" filter refuses absolute paths and links out of dest
pulled.append(name)
continue
remote = _remote(api, repo_id) if remote is None else remote
keep = newer_local(root, remote, b)
kept += keep
_snapshot(repo_id, root, list(b.get("include", [])), list(b.get("exclude", [])) + [_literal(k) for k in keep])
pulled.append(name)
if kept:
print(f"[hub_sync] kept {len(kept)} local files newer than the hub's copy (push them): {kept[:20]}", flush=True)
return {"pulled": pulled, "kept_local": kept}
def main() -> None:
import yaml
from huggingface_hub import HfApi
ap = argparse.ArgumentParser()
ap.add_argument("action", choices=["push", "pull", "status"])
ap.add_argument("--bundles", default="data,embeddings,cache,packs,runs")
ap.add_argument("--config", default="configs/base.yaml")
args = ap.parse_args()
hub = yaml.safe_load((REPO / args.config).read_text(encoding="utf-8"))["hub"]
names = [n.strip() for n in args.bundles.split(",") if n.strip()]
api = HfApi()
if args.action == "status":
s = status(api, hub["repo"], REPO, hub["bundles"], names)
print({k: v for k, v in s.items() if k != "files"}, flush=True)
for f in s["files"][:50]:
print(" differs:", f)
elif args.action == "push":
print(push(api, hub["repo"], REPO, hub["bundles"], names), flush=True)
if "runs" in names:
print({"log_rows_added_to_hub": push_log(api, hub["repo"], REPO)})
else:
print(pull(api, hub["repo"], REPO, hub["bundles"], names), flush=True)
if "runs" in names:
print({"log_rows_added_here": pull_log(api, hub["repo"], REPO)})
if __name__ == "__main__":
main()