File size: 5,933 Bytes
6462856 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | """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())
|