openenv/echo_env
6
1# SPDX-License-Identifier: BSD-3-Clause2 3"""Fork (duplicate) a Hugging Face Space using the Hub API."""4 5from __future__ import annotations6 7from typing import Annotated8 9import typer10from huggingface_hub import HfApi, login, whoami11 12from .._cli_utils import _extract_hf_username, console13 14app = typer.Typer(15 help="Fork (duplicate) an OpenEnv environment on Hugging Face to your account"16)17 18 19def _parse_key_value(s: str) -> tuple[str, str]:20 """Parse KEY=VALUE string. Raises BadParameter if no '='."""21 if "=" not in s:22 raise typer.BadParameter(23 f"Expected KEY=VALUE format, got: {s!r}. "24 "Use --set-env KEY=VALUE or --set-secret KEY=VALUE"25 )26 key, _, value = s.partition("=")27 key = key.strip()28 if not key:29 raise typer.BadParameter(f"Empty key in: {s!r}")30 return key, value.strip()31 32 33def _ensure_hf_authenticated() -> str:34 """Ensure user is authenticated with Hugging Face. Returns username."""35 try:36 username = _extract_hf_username(whoami())37 if not username:38 raise ValueError("Could not extract username from whoami response")39 console.print(f"[bold green]✓[/bold green] Authenticated as: {username}")40 return username41 except Exception:42 console.print(43 "[bold yellow]Not authenticated with Hugging Face. Please login...[/bold yellow]"44 )45 try:46 login()47 username = _extract_hf_username(whoami())48 if not username:49 raise ValueError("Could not extract username from whoami response")50 console.print(f"[bold green]✓[/bold green] Authenticated as: {username}")51 return username52 except Exception as e:53 raise typer.BadParameter(54 f"Hugging Face authentication failed: {e}. Please run login manually."55 ) from e56 57 58@app.command()59def fork(60 source_space: Annotated[61 str,62 typer.Argument(63 help="Source Space ID in format 'owner/space-name' (e.g. org/my-openenv-space)"64 ),65 ],66 repo_id: Annotated[67 str | None,68 typer.Option(69 "--repo-id",70 "-r",71 help="Target repo ID for the fork (default: created under your account with same name)",72 ),73 ] = None,74 private: Annotated[75 bool,76 typer.Option("--private", help="Create the forked Space as private"),77 ] = False,78 set_env: Annotated[79 list[str],80 typer.Option(81 "--set-env",82 "-e",83 help="Set Space variable (public). Can be repeated. Format: KEY=VALUE",84 ),85 ] = [],86 set_secret: Annotated[87 list[str],88 typer.Option(89 "--set-secret",90 "--secret",91 "-s",92 help="Set Space secret. Can be repeated. Format: KEY=VALUE",93 ),94 ] = [],95 hardware: Annotated[96 str | None,97 typer.Option(98 "--hardware",99 "-H",100 help="Request hardware (e.g. t4-medium, cpu-basic). See Hub docs for options.",101 ),102 ] = None,103) -> None:104 """105 Fork (duplicate) a Hugging Face Space to your account using the Hub API.106 107 Uses the Hugging Face duplicate_space API. You can set environment variables108 and secrets, and request hardware/storage/sleep time at creation time.109 110 Examples:111 112 ```bash113 $ openenv fork owner/source-space114 $ openenv fork owner/source-space --private115 $ openenv fork owner/source-space --repo-id myuser/my-fork116 $ openenv fork owner/source-space --set-env MODEL_ID=user/model --set-secret HF_TOKEN=hf_xxx117 $ openenv fork owner/source-space --hardware t4-medium118 ```119 """120 if "/" not in source_space or source_space.count("/") != 1:121 raise typer.BadParameter(122 f"Invalid source Space ID: {source_space!r}. Expected format: 'owner/space-name'"123 )124 125 _ensure_hf_authenticated()126 api = HfApi()127 128 # Build kwargs for duplicate_space (only pass what we have)129 dup_kwargs: dict = {130 "from_id": source_space,131 "private": private,132 }133 if set_env:134 dup_kwargs["variables"] = [135 {"key": k, "value": v} for k, v in (_parse_key_value(x) for x in set_env)136 ]137 if set_secret:138 dup_kwargs["secrets"] = [139 {"key": k, "value": v} for k, v in (_parse_key_value(x) for x in set_secret)140 ]141 # HF API requires hardware when duplicating; default to free cpu-basic142 dup_kwargs["hardware"] = hardware if hardware is not None else "cpu-basic"143 if repo_id is not None:144 if "/" not in repo_id or repo_id.count("/") != 1:145 raise typer.BadParameter(146 f"Invalid --repo-id: {repo_id!r}. Expected format: 'username/repo-name'"147 )148 dup_kwargs["to_id"] = repo_id149 150 console.print(f"[bold cyan]Forking Space {source_space}...[/bold cyan]")151 try:152 result = api.duplicate_space(**dup_kwargs)153 except Exception as e:154 console.print(f"[bold red]✗[/bold red] Fork failed: {e}")155 raise typer.Exit(1) from e156 157 # result is RepoUrl (str-like) or similar; get repo_id for display158 if hasattr(result, "repo_id"):159 new_repo_id = result.repo_id160 elif isinstance(result, str):161 # URL like https://huggingface.co/spaces/owner/name -> owner/name162 if "/spaces/" in result:163 new_repo_id = result.split("/spaces/")[-1].rstrip("/")164 else:165 new_repo_id = result166 else:167 new_repo_id = getattr(result, "repo_id", str(result))168 169 console.print("[bold green]✓[/bold green] Space forked successfully")170 console.print(171 f"[bold]Space URL:[/bold] https://huggingface.co/spaces/{new_repo_id}"172 )173 