bigears / make_space.py
DGring's picture
Upload folder using huggingface_hub
6462856 verified
Raw History Blame Contribute Delete
5.93 kB
"""Assemble the deployable Hugging Face Space directory.
The Space is a *self-contained* git repository: Hugging Face clones it, runs
``pip install -r requirements.txt``, and starts ``app.py``. It has no access to
this repo, so the ``soloscribe`` package has to travel with it. This script
vendors it — a plain directory copy of ``soloscribe/`` into ``space/``, minus
``webapp/`` (the desktop FastAPI UI, which the Space replaces) and the usual
build litter.
.venv/bin/python space/make_space.py # copy the package in
.venv/bin/python space/make_space.py --check # is the copy current?
REDEPLOYING AFTER A CHANGE TO soloscribe/: rerun this script, then commit and
push the Space repo. The vendored copy is a snapshot, not a symlink, so a
change to ``soloscribe/transcribe.py`` reaches the Space only once this has run
again. ``--check`` exits non-zero when the snapshot has drifted, which is the
cheap way to find out before pushing rather than after.
Hand-written files in ``space/`` (app.py, requirements.txt, packages.txt,
README.md, this script) are never touched — the script only ever writes inside
``space/soloscribe/``.
"""
from __future__ import annotations
import argparse
import filecmp
import shutil
import sys
from pathlib import Path
SPACE_DIR = Path(__file__).resolve().parent
REPO_ROOT = SPACE_DIR.parent
SOURCE_PKG = REPO_ROOT / "soloscribe"
VENDORED_PKG = SPACE_DIR / "soloscribe"
# webapp/ is the desktop UI (FastAPI + static files); app.py is its replacement
# and pulling it along would drag fastapi and uvicorn into the Space's
# requirements for no reason. Nothing outside webapp/ imports it — verified by
# grepping the package for "webapp".
EXCLUDE_DIRS = {"webapp", "__pycache__", ".pytest_cache"}
EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
# Everything the Space needs that this script does NOT generate. Listed so a
# missing file is caught here rather than by a failed build on the platform.
REQUIRED_FILES = ("app.py", "requirements.txt", "packages.txt", "README.md")
def _wanted(root: Path) -> list[Path]:
"""Every file under `root` that belongs in the Space, as relative paths."""
out: list[Path] = []
for path in sorted(root.rglob("*")):
rel = path.relative_to(root)
if any(part in EXCLUDE_DIRS for part in rel.parts):
continue
if path.is_file() and path.suffix not in EXCLUDE_SUFFIXES:
out.append(rel)
return out
def _copy(verbose: bool = True) -> list[Path]:
if VENDORED_PKG.exists():
shutil.rmtree(VENDORED_PKG)
files = _wanted(SOURCE_PKG)
for rel in files:
dest = VENDORED_PKG / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(SOURCE_PKG / rel, dest)
if verbose:
total = sum((VENDORED_PKG / r).stat().st_size for r in files)
print(f"vendored {len(files)} files ({total / 1024:.0f} KiB) into {VENDORED_PKG}")
for rel in files:
print(f" soloscribe/{rel}")
return files
def _prune_litter() -> list[Path]:
"""Drop __pycache__ and friends from anywhere under space/.
Running the app or the tests locally leaves bytecode caches inside the very
directory that gets pushed, and `hf upload` sends what it is given. This
runs on every pass so the directory is clean at the moment it is uploaded,
rather than relying on whoever pushes to remember an --exclude flag.
"""
dropped: list[Path] = []
for path in sorted(SPACE_DIR.rglob("*"), reverse=True):
if path.is_dir() and path.name in {"__pycache__", ".pytest_cache"}:
shutil.rmtree(path)
dropped.append(path.relative_to(SPACE_DIR))
return dropped
def _check() -> int:
"""Exit 0 when the vendored copy matches the package it was made from."""
if not VENDORED_PKG.exists():
print(f"MISSING: {VENDORED_PKG} — run this script without --check", file=sys.stderr)
return 1
want = set(_wanted(SOURCE_PKG))
have = set(_wanted(VENDORED_PKG))
problems: list[str] = []
for rel in sorted(want - have):
problems.append(f"missing from the Space: soloscribe/{rel}")
for rel in sorted(have - want):
problems.append(f"stale, not in the source: soloscribe/{rel}")
for rel in sorted(want & have):
if not filecmp.cmp(SOURCE_PKG / rel, VENDORED_PKG / rel, shallow=False):
problems.append(f"out of date: soloscribe/{rel}")
if problems:
print("The vendored package has drifted from soloscribe/:", file=sys.stderr)
for line in problems:
print(f" {line}", file=sys.stderr)
print("\nRerun: .venv/bin/python space/make_space.py", file=sys.stderr)
return 1
print(f"vendored copy is current ({len(want)} files)")
return 0
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument(
"--check",
action="store_true",
help="verify the vendored copy is current; do not write anything",
)
ap.add_argument("--quiet", action="store_true", help="print only the summary line")
args = ap.parse_args(argv)
if not SOURCE_PKG.is_dir():
print(f"cannot find the package at {SOURCE_PKG}", file=sys.stderr)
return 2
missing = [f for f in REQUIRED_FILES if not (SPACE_DIR / f).is_file()]
if missing:
print(
"these hand-written Space files are missing: " + ", ".join(missing),
file=sys.stderr,
)
return 2
if args.check:
return _check()
_copy(verbose=not args.quiet)
for rel in _prune_litter():
if not args.quiet:
print(f" removed {rel}")
print(f"\nSpace directory ready: {SPACE_DIR}")
print("Push this directory to the Space repo — see space/README.md.")
return 0
if __name__ == "__main__":
raise SystemExit(main())