File size: 6,776 Bytes
abf77e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.

"""Fork (duplicate) a Hugging Face Space using the Hub API."""

from __future__ import annotations

from typing import Annotated

import typer
from huggingface_hub import HfApi, login, whoami

from .._cli_utils import console

app = typer.Typer(
    help="Fork (duplicate) an OpenEnv environment on Hugging Face to your account"
)


def _parse_key_value(s: str) -> tuple[str, str]:
    """Parse KEY=VALUE string. Raises BadParameter if no '='."""
    if "=" not in s:
        raise typer.BadParameter(
            f"Expected KEY=VALUE format, got: {s!r}. "
            "Use --set-env KEY=VALUE or --set-secret KEY=VALUE"
        )
    key, _, value = s.partition("=")
    key = key.strip()
    if not key:
        raise typer.BadParameter(f"Empty key in: {s!r}")
    return key, value.strip()


def _ensure_hf_authenticated() -> str:
    """Ensure user is authenticated with Hugging Face. Returns username."""
    try:
        user_info = whoami()
        if isinstance(user_info, dict):
            username = (
                user_info.get("name")
                or user_info.get("fullname")
                or user_info.get("username")
            )
        else:
            username = (
                getattr(user_info, "name", None)
                or getattr(user_info, "fullname", None)
                or getattr(user_info, "username", None)
            )
        if not username:
            raise ValueError("Could not extract username from whoami response")
        console.print(f"[bold green]✓[/bold green] Authenticated as: {username}")
        return username
    except Exception:
        console.print(
            "[bold yellow]Not authenticated with Hugging Face. Please login...[/bold yellow]"
        )
        try:
            login()
            user_info = whoami()
            if isinstance(user_info, dict):
                username = (
                    user_info.get("name")
                    or user_info.get("fullname")
                    or user_info.get("username")
                )
            else:
                username = (
                    getattr(user_info, "name", None)
                    or getattr(user_info, "fullname", None)
                    or getattr(user_info, "username", None)
                )
            if not username:
                raise ValueError("Could not extract username from whoami response")
            console.print(f"[bold green]✓[/bold green] Authenticated as: {username}")
            return username
        except Exception as e:
            raise typer.BadParameter(
                f"Hugging Face authentication failed: {e}. Please run login manually."
            ) from e


@app.command()
def fork(
    source_space: Annotated[
        str,
        typer.Argument(
            help="Source Space ID in format 'owner/space-name' (e.g. org/my-openenv-space)"
        ),
    ],
    repo_id: Annotated[
        str | None,
        typer.Option(
            "--repo-id",
            "-r",
            help="Target repo ID for the fork (default: created under your account with same name)",
        ),
    ] = None,
    private: Annotated[
        bool,
        typer.Option("--private", help="Create the forked Space as private"),
    ] = False,
    set_env: Annotated[
        list[str],
        typer.Option(
            "--set-env",
            "-e",
            help="Set Space variable (public). Can be repeated. Format: KEY=VALUE",
        ),
    ] = [],
    set_secret: Annotated[
        list[str],
        typer.Option(
            "--set-secret",
            "--secret",
            "-s",
            help="Set Space secret. Can be repeated. Format: KEY=VALUE",
        ),
    ] = [],
    hardware: Annotated[
        str | None,
        typer.Option(
            "--hardware",
            "-H",
            help="Request hardware (e.g. t4-medium, cpu-basic). See Hub docs for options.",
        ),
    ] = None,
) -> None:
    """
    Fork (duplicate) a Hugging Face Space to your account using the Hub API.

    Uses the Hugging Face duplicate_space API. You can set environment variables
    and secrets, and request hardware/storage/sleep time at creation time.

    Examples:
        $ openenv fork owner/source-space
        $ openenv fork owner/source-space --private
        $ openenv fork owner/source-space --repo-id myuser/my-fork
        $ openenv fork owner/source-space --set-env MODEL_ID=user/model --set-secret HF_TOKEN=hf_xxx
        $ openenv fork owner/source-space --hardware t4-medium
    """
    if "/" not in source_space or source_space.count("/") != 1:
        raise typer.BadParameter(
            f"Invalid source Space ID: {source_space!r}. Expected format: 'owner/space-name'"
        )

    _ensure_hf_authenticated()
    api = HfApi()

    # Build kwargs for duplicate_space (only pass what we have)
    dup_kwargs: dict = {
        "from_id": source_space,
        "private": private,
    }
    if set_env:
        dup_kwargs["variables"] = [
            {"key": k, "value": v} for k, v in (_parse_key_value(x) for x in set_env)
        ]
    if set_secret:
        dup_kwargs["secrets"] = [
            {"key": k, "value": v} for k, v in (_parse_key_value(x) for x in set_secret)
        ]
    # HF API requires hardware when duplicating; default to free cpu-basic
    dup_kwargs["hardware"] = hardware if hardware is not None else "cpu-basic"
    if repo_id is not None:
        if "/" not in repo_id or repo_id.count("/") != 1:
            raise typer.BadParameter(
                f"Invalid --repo-id: {repo_id!r}. Expected format: 'username/repo-name'"
            )
        dup_kwargs["to_id"] = repo_id

    console.print(f"[bold cyan]Forking Space {source_space}...[/bold cyan]")
    try:
        result = api.duplicate_space(**dup_kwargs)
    except Exception as e:
        console.print(f"[bold red]✗[/bold red] Fork failed: {e}")
        raise typer.Exit(1) from e

    # result is RepoUrl (str-like) or similar; get repo_id for display
    if hasattr(result, "repo_id"):
        new_repo_id = result.repo_id
    elif isinstance(result, str):
        # URL like https://huggingface.co/spaces/owner/name -> owner/name
        if "/spaces/" in result:
            new_repo_id = result.split("/spaces/")[-1].rstrip("/")
        else:
            new_repo_id = result
    else:
        new_repo_id = getattr(result, "repo_id", str(result))

    console.print("[bold green]✓[/bold green] Space forked successfully")
    console.print(
        f"[bold]Space URL:[/bold] https://huggingface.co/spaces/{new_repo_id}"
    )