""" Mirrors this repository (plus the models from the GitHub Release) into a Hugging Face Docker Space. Run by .github/workflows/deploy-hf.yml. Environment: HF_TOKEN (required) Hugging Face WRITE token APP_PASSWORD (required) password for the app login page, 8+ characters HF_SPACE (optional) "user/name"; default "/truscan" HF_HARDWARE (optional) "" (free CPU - needs HF PRO) | "cpu-upgrade" | "t4-small" | "l4x1" """ import os import shutil import sys from huggingface_hub import HfApi ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) STAGE = os.path.join(os.path.dirname(ROOT), "_hf_stage") SKIP_DIRS = {".git", ".github", "python", "cache", ".venv", ".venv_nonescape", "wheels", "nonescape_repo", "__pycache__", ".model_cache", ".plate_training_data"} SKIP_EXT = {".bat", ".vbs", ".lnk", ".pyc"} def stage(gpu): if os.path.exists(STAGE): shutil.rmtree(STAGE) def ignore(d, names): return [n for n in names if n in SKIP_DIRS or os.path.splitext(n)[1].lower() in SKIP_EXT or n in (".env", "README.md") and os.path.abspath(d) == ROOT] shutil.copytree(ROOT, STAGE, ignore=ignore) shutil.copy(os.path.join(ROOT, "server", "Dockerfile.gpu" if gpu else "Dockerfile"), os.path.join(STAGE, "Dockerfile")) shutil.copy(os.path.join(ROOT, "server", "README.md"), os.path.join(STAGE, "README.md")) _ALIASES = {"t4": "t4-small", "gpu": "t4-small", "t4small": "t4-small", "l4": "l4x1", "cpu": "cpu-upgrade", "cpuupgrade": "cpu-upgrade", "a10g": "a10g-small", "free": "", "none": ""} def _normalize_hw(raw): """Forgives typing slips in the HF_HARDWARE variable: capitals, spaces, quotes, underscores, invisible characters pasted from a phone.""" v = "".join(ch for ch in (raw or "") if ch.isprintable()).strip().strip("'\"`").strip().lower() v = v.replace("_", "-").replace(" ", "") v = _ALIASES.get(v.replace("-", ""), _ALIASES.get(v, v)) if v and raw.strip() != v: print(f"HF_HARDWARE '{raw!r}' -> '{v}'") return v def main(): token = os.environ.get("HF_TOKEN", "").strip() password = os.environ.get("APP_PASSWORD", "").strip() if not token: sys.exit("HF_TOKEN secret is not set.") # The app has its own login page (works inside the huggingface.co page # too). On Hugging Face it refuses to run without APP_PASSWORD, so the # Space stays protected whether it is public or private. if len(password) < 8: sys.exit("APP_PASSWORD secret is missing or shorter than 8 characters.\n" "GitHub repo -> Settings -> Secrets and variables -> Actions -> New repository secret:\n" " Name: APP_PASSWORD Value: a password of at least 8 characters.") hw = _normalize_hw(os.environ.get("HF_HARDWARE", "")) api = HfApi(token=token) space = os.environ.get("HF_SPACE", "").strip() or f"{api.whoami()['name']}/truscan" print(f"Space: {space} hardware: {hw or 'cpu-basic (free)'}") # Hugging Face no longer allows Docker Spaces on the free CPU unless the # account has PRO or billing. So the Space is created DIRECTLY on the # chosen hardware (HF_HARDWARE) instead of free CPU first. try: api.create_repo(space, repo_type="space", space_sdk="docker", private=True, exist_ok=True, space_hardware=hw or None, space_sleep_time=900 if hw else None) except Exception as e: msg = str(e) if "PRO" in msg or "billing" in msg.lower() or "cpu-basic" in msg: sys.exit("Hugging Face needs either:\n" " - a PRO subscription (then the free CPU works), or\n" " - a payment method (Settings -> Billing) AND the GitHub variable HF_HARDWARE,\n" " e.g. cpu-upgrade (cheap CPU) or t4-small (GPU).\n" f"Original error: {msg}") raise api.add_space_secret(space, "APP_PASSWORD", password) print("App password: ON") # Result feedback is mirrored to a PRIVATE dataset repo (the Space disk # is wiped on every restart). Only results are stored, never files. fb_repo = f"{space.split('/')[0]}/truscan-feedback" try: api.create_repo(fb_repo, repo_type="dataset", private=True, exist_ok=True) api.add_space_secret(space, "FEEDBACK_TOKEN", token) api.add_space_variable(space, "FEEDBACK_REPO", fb_repo) print(f"Feedback log: private dataset {fb_repo}") except Exception as e: print(f"Feedback log mirror not configured: {e}") rf_key = os.environ.get("ROBOFLOW_API_KEY", "").strip() if rf_key: api.add_space_secret(space, "ROBOFLOW_API_KEY", rf_key) print("Roboflow plate model: ON") stage(gpu=hw.startswith(("t4", "l4", "a10", "a100", "l40", "h1", "h2"))) api.upload_folder(folder_path=STAGE, repo_id=space, repo_type="space", commit_message=f"Deploy {os.environ.get('GITHUB_SHA', '')[:7]}", delete_patterns=["*.py", "**/*.py", "templates/**", "server/**", "Dockerfile"]) if hw: try: api.request_space_hardware(space, hw, sleep_time=900) api.set_space_sleep_time(space, 900) # sleep after 15 idle min -> no charge print(f"Requested {hw}; sleeps after 15 idle minutes.") except Exception as e: print(f"::warning::Could not switch to {hw}: {e}. Add a payment method on Hugging Face " f"(Settings -> Billing) or pick the hardware in the Space settings.") print(f"Done: https://huggingface.co/spaces/{space}") if __name__ == "__main__": main()