| |
| """ |
| 创建/更新 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 <username/space-name> [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()) |
|
|