File size: 6,188 Bytes
3c763c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
#!/usr/bin/env python3
"""一键将当前前端发布到 Hugging Face Space(static/gradio/docker)."""

from __future__ import annotations

import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Iterable

HF_ENDPOINT = "https://huggingface.co"
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_PUBLISH_FILES = ["index.html", "styles.css", "app.js", "README.md"]


class DeployError(RuntimeError):
    """部署失败错误。"""


def api_request(path: str, token: str, method: str = "GET", payload: dict | None = None) -> dict:
    url = f"{HF_ENDPOINT}{path}"
    data = None
    headers = {"Authorization": f"Bearer {token}"}
    if payload is not None:
        headers["Content-Type"] = "application/json"
        data = json.dumps(payload).encode("utf-8")

    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            body = resp.read().decode("utf-8")
            return json.loads(body) if body else {}
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8", errors="ignore")
        raise DeployError(f"{method} {path} 失败: {exc.code} {body}") from exc


def run(cmd: list[str], cwd: str | None = None) -> None:
    subprocess.run(cmd, check=True, cwd=cwd)


def get_namespace(token: str) -> str:
    whoami = api_request("/api/whoami-v2", token)
    name = whoami.get("name")
    if not name:
        raise DeployError("无法从 /api/whoami-v2 获取用户名")
    return name


def build_create_repo_payload(space_name: str, organization: str | None, private: bool, sdk: str) -> dict:
    return {
        "type": "space",
        "name": space_name,
        "organization": organization,
        "private": private,
        "sdk": sdk,
    }


def resolve_repo_id(token: str, space_name: str, organization: str | None, private: bool, sdk: str) -> str:
    namespace = organization or get_namespace(token)

    payload = build_create_repo_payload(
        space_name=space_name,
        organization=organization,
        private=private,
        sdk=sdk,
    )

    try:
        api_request("/api/repos/create", token, method="POST", payload=payload)
        print(f"✅ 已创建 Space: {namespace}/{space_name}")
    except DeployError as exc:
        msg = str(exc).lower()
        if "409" in msg or "already exists" in msg:
            print(f"ℹ️ Space 已存在,继续发布: {namespace}/{space_name}")
        else:
            raise

    return f"{namespace}/{space_name}"


def resolve_publish_files(extra_files: Iterable[str] | None = None) -> list[str]:
    files = list(DEFAULT_PUBLISH_FILES)
    if extra_files:
        for item in extra_files:
            if item and item not in files:
                files.append(item)
    return files


def publish(
    repo_id: str,
    token: str,
    publish_files: list[str],
    message: str,
    force_push: bool,
    branch: str,
) -> None:
    with tempfile.TemporaryDirectory(prefix="hf-space-") as tmp:
        for rel in publish_files:
            src = PROJECT_ROOT / rel
            if not src.exists():
                raise FileNotFoundError(f"缺少发布文件: {src}")
            dst = Path(tmp) / rel
            dst.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(src, dst)

        run(["git", "init"], cwd=tmp)
        run(["git", "checkout", "-b", branch], cwd=tmp)
        run(["git", "config", "user.name", "my-ai-coder"], cwd=tmp)
        run(["git", "config", "user.email", "bot@local"], cwd=tmp)
        run(["git", "add", "."], cwd=tmp)
        run(["git", "commit", "-m", message], cwd=tmp)

        safe_token = urllib.parse.quote(token, safe="")
        remote = f"https://user:{safe_token}@huggingface.co/spaces/{repo_id}"
        run(["git", "remote", "add", "origin", remote], cwd=tmp)

        push_cmd = ["git", "push", "-u", "origin", branch]
        if force_push:
            push_cmd.append("--force")
        run(push_cmd, cwd=tmp)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="一键发布到 Hugging Face Space")
    parser.add_argument("--space", required=True, help="Space 名称,例如 my-omni-ai-studio")
    parser.add_argument("--token", default=None, help="HF Token,默认读取 HF_TOKEN 或 HUGGING_FACE_HUB_TOKEN")
    parser.add_argument("--org", default=None, help="组织名(可选),不填则发布到个人账号")
    parser.add_argument("--private", action="store_true", help="创建私有 Space")
    parser.add_argument("--sdk", default="static", choices=["static", "gradio", "docker"], help="Space SDK 类型")
    parser.add_argument("--branch", default="main", help="目标分支名,默认 main")
    parser.add_argument("--message", default="Deploy My Omni AI Studio", help="部署提交信息")
    parser.add_argument("--include", action="append", default=[], help="额外发布文件(可多次传入)")
    parser.add_argument("--no-force", action="store_true", help="关闭默认 --force 推送")
    return parser.parse_args()


def resolve_token(token_arg: str | None) -> str | None:
    if token_arg:
        return token_arg
    return os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN")


def main() -> int:
    args = parse_args()
    token = resolve_token(args.token)
    if not token:
        print("❌ 缺少 Token,请通过 --token 或 HF_TOKEN/HUGGING_FACE_HUB_TOKEN 提供。", file=sys.stderr)
        return 1

    publish_files = resolve_publish_files(args.include)
    repo_id = resolve_repo_id(token, args.space, args.org, args.private, args.sdk)
    publish(
        repo_id=repo_id,
        token=token,
        publish_files=publish_files,
        message=args.message,
        force_push=not args.no_force,
        branch=args.branch,
    )
    print(f"🚀 发布完成: https://huggingface.co/spaces/{repo_id}")
    print(f"📦 发布文件: {', '.join(publish_files)}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())