#!/usr/bin/env python3 """ 创建/更新 Hugging Face Docker Space,并自动写入运行所需的 Secrets / Variables。 用法: export HF_TOKEN=hf_xxx export HF_SPACE_ID=username/sub2api export DATABASE_HOST=... export DATABASE_PORT=5432 export DATABASE_USER=... export DATABASE_PASSWORD=... export DATABASE_DBNAME=postgres export DATABASE_SSLMODE=require uv run --with huggingface_hub==1.9.0 python deploy_space.py "$HF_SPACE_ID" . """ from __future__ import annotations import json import os import secrets import string import sys import time from pathlib import Path import shutil import tempfile from huggingface_hub import HfApi def getenv_first(*names: str, default: str | None = None) -> str | None: for name in names: value = os.getenv(name) if value: return value return default def bool_env(name: str, default: bool = False) -> bool: value = os.getenv(name) if value is None: return default return value.strip().lower() in {"1", "true", "yes", "y", "on"} def random_password(length: int = 24) -> str: alphabet = string.ascii_letters + string.digits + "-_" return "".join(secrets.choice(alphabet) for _ in range(length)) def collect_runtime_config() -> tuple[dict[str, str], dict[str, str], dict[str, str]]: secrets_map = { "DATABASE_HOST": getenv_first("DATABASE_HOST", "SUPABASE_DB_HOST"), "DATABASE_PORT": getenv_first("DATABASE_PORT", "SUPABASE_DB_PORT", default="5432"), "DATABASE_USER": getenv_first("DATABASE_USER", "SUPABASE_DB_USER"), "DATABASE_PASSWORD": getenv_first("DATABASE_PASSWORD", "SUPABASE_DB_PASSWORD"), "DATABASE_DBNAME": getenv_first("DATABASE_DBNAME", "SUPABASE_DBNAME", default="postgres"), "DATABASE_SSLMODE": getenv_first("DATABASE_SSLMODE", default="require"), "ADMIN_EMAIL": getenv_first("ADMIN_EMAIL", default="admin@sub2api.local"), "ADMIN_PASSWORD": getenv_first("ADMIN_PASSWORD"), "JWT_SECRET": getenv_first("JWT_SECRET"), } generated: dict[str, str] = {} if not secrets_map["ADMIN_PASSWORD"]: secrets_map["ADMIN_PASSWORD"] = random_password(20) generated["ADMIN_PASSWORD"] = secrets_map["ADMIN_PASSWORD"] if not secrets_map["JWT_SECRET"]: secrets_map["JWT_SECRET"] = secrets.token_hex(32) generated["JWT_SECRET"] = secrets_map["JWT_SECRET"] variables_map = { "TZ": getenv_first("TZ", default="Asia/Shanghai"), "RUN_MODE": getenv_first("RUN_MODE", default="standard"), "SERVER_MODE": getenv_first("SERVER_MODE", default="release"), "AUTO_SETUP": getenv_first("AUTO_SETUP", default="true"), } missing = { key: value for key, value in secrets_map.items() if key in { "DATABASE_HOST", "DATABASE_PORT", "DATABASE_USER", "DATABASE_PASSWORD", "DATABASE_DBNAME", } and not value } if missing: raise SystemExit( "Missing required environment values: " + ", ".join(sorted(missing)) ) return secrets_map, variables_map, generated def upload_bundle_if_present(api: HfApi, repo_id: str, folder_path: Path) -> None: bundle_path = folder_path / "bundle.tar.gz" if not bundle_path.exists(): return if not hasattr(api, "upload_large_folder"): api.upload_file( repo_id=repo_id, repo_type="space", path_in_repo="bundle.tar.gz", path_or_fileobj=str(bundle_path), commit_message="Upload runtime bundle", ) return with tempfile.TemporaryDirectory(prefix="hf-space-bundle-upload-") as tmp_dir: upload_dir = Path(tmp_dir) shutil.copy2(bundle_path, upload_dir / "bundle.tar.gz") original_create_repo = api.create_repo def create_repo_with_space_sdk(*args, **kwargs): kwargs.setdefault("space_sdk", "docker") kwargs.setdefault("exist_ok", True) return original_create_repo(*args, **kwargs) api.create_repo = create_repo_with_space_sdk try: api.upload_large_folder( repo_id=repo_id, folder_path=str(upload_dir), repo_type="space", revision="main", num_workers=1, print_report=True, print_report_every=10, ) finally: api.create_repo = original_create_repo def upload_space(api: HfApi, repo_id: str, folder_path: Path, private: bool) -> None: api.create_repo( repo_id=repo_id, repo_type="space", space_sdk="docker", private=private, exist_ok=True, ) api.upload_folder( repo_id=repo_id, repo_type="space", folder_path=str(folder_path), ignore_patterns=[ ".git", ".DS_Store", "__pycache__", ".venv", "*.pyc", ".env", "bundle.tar.gz", ], commit_message="Deploy Hugging Face Docker Space wrapper", ) upload_bundle_if_present(api, repo_id, folder_path) def sync_runtime_config( api: HfApi, repo_id: str, secrets_map: dict[str, str], variables_map: dict[str, str], ) -> None: for key, value in secrets_map.items(): if value: api.add_space_secret(repo_id=repo_id, key=key, value=value) for key, value in variables_map.items(): if value: api.add_space_variable(repo_id=repo_id, key=key, value=value) def wait_for_runtime(api: HfApi, repo_id: str, attempts: int = 30) -> None: for attempt in range(1, attempts + 1): runtime = api.get_space_runtime(repo_id=repo_id) stage = getattr(runtime, "stage", None) hardware = getattr(runtime, "hardware", None) requested_hardware = getattr(runtime, "requested_hardware", None) print( json.dumps( { "attempt": attempt, "stage": stage, "hardware": hardware, "requested_hardware": requested_hardware, }, ensure_ascii=False, ) ) if stage in {"RUNNING", "RUNNING_BUILDING"}: return time.sleep(10) def main() -> int: if len(sys.argv) < 2: print("Usage: deploy_space.py [folder]", file=sys.stderr) return 2 repo_id = sys.argv[1] folder = Path(sys.argv[2] if len(sys.argv) >= 3 else ".").resolve() token = getenv_first("HF_TOKEN", "HUGGINGFACE_TOKEN") if not token: raise SystemExit("HF_TOKEN is required") private = bool_env("HF_SPACE_PRIVATE", default=False) api = HfApi(token=token) secrets_map, variables_map, generated = collect_runtime_config() print(f"Uploading {folder} -> https://huggingface.co/spaces/{repo_id}") upload_space(api, repo_id, folder, private=private) sync_runtime_config(api, repo_id, secrets_map, variables_map) print("Generated secrets (please save them):") if generated: for key, value in generated.items(): print(f"{key}={value}") else: print("(none)") print("Waiting for runtime status...") wait_for_runtime(api, repo_id) print(f"Space URL: https://huggingface.co/spaces/{repo_id}") print(f"Embed URL: https://{repo_id.replace('/', '-')}.hf.space") return 0 if __name__ == "__main__": raise SystemExit(main())