Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """Deploy the Inspector to *any* Hugging Face Space — including your own. | |
| This is the committed, parameterized cousin of the maintainer-only | |
| ``scripts/deploy/upload_inspector.py`` (which is hardcoded to the canonical dev/prod | |
| Spaces). Contributors use this to push to a personal Space created by | |
| ``bootstrap_dev_env.py``. | |
| Usage:: | |
| # Deploy to your personal Space (created by bootstrap_dev_env.py) | |
| python scripts/deploy/deploy_space.py <your-hf-user>/quranic-inspector-<name> | |
| # Stage only, don't upload (inspect the tree that would ship) | |
| python scripts/deploy/deploy_space.py <id> --dry-run | |
| What it does: | |
| 1. Builds the frontend (``inspector/frontend`` → ``dist/``) unless | |
| ``--skip-build``. | |
| 2. Stages the Space-shaped tree (Dockerfile, ``inspector/`` code + built | |
| ``dist/``, ``qua_shared/``, the four linguistic ``data/*.json`` files, | |
| a ``README.md`` with the HF Space frontmatter). | |
| 3. ``create_repo`` (idempotent) → ``upload_folder`` → factory ``restart_space``. | |
| The README frontmatter sets ``hf_oauth: true`` so Hugging Face auto-injects | |
| ``OAUTH_CLIENT_ID`` / ``OAUTH_CLIENT_SECRET`` — you never register an OAuth | |
| app yourself. The only secrets you set on the Space are your own ``HF_TOKEN`` | |
| (bucket access) and an auto-generated ``INSPECTOR_SESSION_SECRET``; both are | |
| handled by ``bootstrap_dev_env.py``. | |
| Auth: reads ``HF_TOKEN`` (or ``INSPECTOR_HF_TOKEN``) from the environment or | |
| the repo-root ``.env``. | |
| Idempotent: re-runs upload only changed files (the Hub diffs by content hash). | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| from pathlib import Path | |
| # Windows consoles default to cp1252; force UTF-8 so output never crashes. | |
| for _stream in (sys.stdout, sys.stderr): | |
| try: | |
| _stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined] | |
| except Exception: | |
| pass | |
| # Put the repo root on sys.path so ``qua_shared._env`` resolves whether this | |
| # is launched as a module or a file. | |
| _REPO_ROOT = Path(__file__).resolve().parents[2] | |
| if str(_REPO_ROOT) not in sys.path: | |
| sys.path.insert(0, str(_REPO_ROOT)) | |
| from qua_shared._env import load_repo_env, repo_root # noqa: E402 | |
| # README frontmatter shipped to the Space. ``hf_oauth: true`` is load-bearing: | |
| # it makes HF inject the OAuth client credentials the app reads at runtime. | |
| SPACE_README = """--- | |
| title: {title} | |
| emoji: 🎙️ | |
| colorFrom: yellow | |
| colorTo: indigo | |
| short_description: Visualize, edit & verify Qur'anic recitation timestamps | |
| sdk: docker | |
| app_port: 7860 | |
| pinned: false | |
| hf_oauth: true | |
| hf_oauth_expiration_minutes: 480 | |
| --- | |
| Personal dev deployment of the Quranic Universal Audio Inspector. | |
| Built from a contributor branch — not the production site. | |
| """ | |
| def _run(cmd: list[str], cwd: Path | None = None) -> None: | |
| print(f" $ {' '.join(cmd)}") | |
| subprocess.run(cmd, cwd=cwd, check=True) | |
| def _build_frontend(repo: Path) -> None: | |
| fe = repo / "inspector" / "frontend" | |
| if not (fe / "node_modules").is_dir(): | |
| _run(["npm", "ci"], cwd=fe) | |
| _run(["npm", "run", "build"], cwd=fe) | |
| def _stage(repo: Path, stage_root: Path, *, title: str) -> None: | |
| (stage_root / "README.md").write_text(SPACE_README.format(title=title), encoding="utf-8") | |
| # Dockerfile + .dockerignore sit at the Space root. | |
| shutil.copy2(repo / "inspector" / "Dockerfile", stage_root / "Dockerfile") | |
| shutil.copy2(repo / ".dockerignore", stage_root / ".dockerignore") | |
| # inspector/ code tree (drop test + build caches; keep built dist/). | |
| shutil.copytree( | |
| repo / "inspector", | |
| stage_root / "inspector", | |
| ignore=shutil.ignore_patterns( | |
| "__pycache__", | |
| "*.pyc", | |
| "tests", | |
| ".pytest_cache", | |
| ".mypy_cache", | |
| "node_modules", | |
| ".vite", | |
| ".bucket", | |
| ), | |
| ) | |
| # qua_shared/ + qua_jobs/ ship — the runtime imports ``qua_shared.*`` and | |
| # ``services/admin/jobs`` stages ``qua_jobs/`` to the job bucket on launch. | |
| # Top-level CLIs under scripts/ are not needed inside the Space. | |
| for pkg in ("qua_shared", "qua_jobs"): | |
| shutil.copytree( | |
| repo / pkg, | |
| stage_root / pkg, | |
| ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), | |
| ) | |
| # The four linguistic JSON files — the entire on-disk data surface in a | |
| # deployed Space (everything else lives in the bucket). | |
| data_dst = stage_root / "data" | |
| data_dst.mkdir() | |
| for name in ( | |
| "surah_info.json", | |
| "qpc_hafs.json", | |
| "digital_khatt_v2_script.json", | |
| "phoneme_sub_costs.json", | |
| ): | |
| shutil.copy2(repo / "data" / name, data_dst / name) | |
| def _commit_sha(repo: Path) -> str: | |
| try: | |
| out = subprocess.run( | |
| ["git", "rev-parse", "--short", "HEAD"], | |
| cwd=repo, | |
| check=True, | |
| capture_output=True, | |
| text=True, | |
| ) | |
| return out.stdout.strip() | |
| except Exception: | |
| return "unknown" | |
| def _validate_space_id(space_id: str) -> str: | |
| if space_id.count("/") != 1 or not all(space_id.split("/")): | |
| raise SystemExit(f"space id must be '<owner>/<name>', got {space_id!r}") | |
| return space_id | |
| def main(argv: list[str] | None = None) -> int: | |
| p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) | |
| p.add_argument("space", help="Target Space id, e.g. you/quranic-inspector-foo") | |
| p.add_argument( | |
| "--public", action="store_true", help="Create the Space public (default: private)." | |
| ) | |
| p.add_argument( | |
| "--skip-build", action="store_true", help="Skip `npm run build`; ship the existing dist/." | |
| ) | |
| p.add_argument("--no-restart", action="store_true", help="Don't factory-reboot after upload.") | |
| p.add_argument( | |
| "--dry-run", | |
| action="store_true", | |
| help="Stage but don't create/upload. Prints the stage path.", | |
| ) | |
| args = p.parse_args(argv) | |
| space_id = _validate_space_id(args.space) | |
| load_repo_env() | |
| token = os.environ.get("INSPECTOR_HF_TOKEN") or os.environ.get("HF_TOKEN") | |
| if not token and not args.dry_run: | |
| print( | |
| "ERROR: HF_TOKEN / INSPECTOR_HF_TOKEN missing. Add it to .env at " | |
| "the repo root or export it.", | |
| file=sys.stderr, | |
| ) | |
| return 2 | |
| repo = repo_root() | |
| title = f"Inspector ({space_id.split('/')[1]})" | |
| print(f"==> Building Inspector for Space {space_id}") | |
| if not args.skip_build: | |
| _build_frontend(repo) | |
| else: | |
| print(" (skip-build) using existing dist/") | |
| if not (repo / "inspector" / "frontend" / "dist" / "index.html").exists(): | |
| print( | |
| "ERROR: inspector/frontend/dist/index.html missing — build the " | |
| "frontend first (drop --skip-build).", | |
| file=sys.stderr, | |
| ) | |
| return 2 | |
| sha = _commit_sha(repo) | |
| commit_msg = f"deploy: inspector @ {sha}" | |
| import tempfile | |
| with tempfile.TemporaryDirectory(prefix="inspector-stage-") as tmp: | |
| stage_root = Path(tmp) | |
| print(f"==> Staging Space tree in {stage_root}") | |
| _stage(repo, stage_root, title=title) | |
| if args.dry_run: | |
| keep = repo / ".local" / "inspector-stage" / space_id.replace("/", "__") | |
| keep.parent.mkdir(parents=True, exist_ok=True) | |
| if keep.exists(): | |
| shutil.rmtree(keep) | |
| shutil.copytree(stage_root, keep) | |
| print(f"==> Dry run; staged tree copied to {keep}") | |
| return 0 | |
| from huggingface_hub import HfApi | |
| api = HfApi(token=token) | |
| print(f"==> Ensuring Space {space_id} exists (private={not args.public})") | |
| api.create_repo( | |
| repo_id=space_id, | |
| repo_type="space", | |
| space_sdk="docker", | |
| private=not args.public, | |
| exist_ok=True, | |
| ) | |
| print(f"==> Uploading to {space_id}") | |
| api.upload_folder( | |
| folder_path=str(stage_root), | |
| repo_id=space_id, | |
| repo_type="space", | |
| commit_message=commit_msg, | |
| delete_patterns="*", | |
| ) | |
| if not args.no_restart: | |
| # Code-only pushes don't change the Dockerfile hash, so HF won't | |
| # rebuild automatically — force a factory reboot. | |
| api.restart_space(repo_id=space_id, factory_reboot=True) | |
| print(f"==> Done. Space: https://huggingface.co/spaces/{space_id}") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |