Team Ai
Apppublic

openenv/echo_env

sourceHugging Faceupdated 1d agoView on Hugging Face
6likes
push.py887 linesDownload Raw Back to commands
1# SPDX-License-Identifier: BSD-3-Clause2 3"""Push an OpenEnv environment to Hugging Face Spaces."""4 5from __future__ import annotations6 7import shutil8import sys9import tempfile10from fnmatch import fnmatch11from pathlib import Path12from typing import Annotated13 14import typer15import yaml16from huggingface_hub import HfApi, login, whoami17 18from .._cli_utils import _extract_hf_username, console, validate_env_structure19 20app = typer.Typer(help="Push an OpenEnv environment to Hugging Face Spaces")21 22 23DEFAULT_PUSH_IGNORE_PATTERNS = [".*", "__pycache__", "*.pyc"]24 25 26def _format_kv_entry_for_error(entry: str, *, flag: str) -> str:27    """Redact secret values from CLI parse errors."""28    if flag == "--secret":29        return "<redacted>"30    return repr(entry)31 32 33def _parse_kv_pairs(raw_pairs: list[str], *, flag: str) -> dict[str, str]:34    """Parse a list of 'KEY=VALUE' strings into a dict.35 36    Splits only on the first '=', so values can contain '='. Later entries37    override earlier ones with the same key (repeated CLI flags still work).38    """39    parsed: dict[str, str] = {}40    for entry in raw_pairs:41        entry_display = _format_kv_entry_for_error(entry, flag=flag)42        if "=" not in entry:43            raise typer.BadParameter(44                f"Invalid {flag} format: {entry_display}. Expected KEY=VALUE."45            )46        key, value = entry.split("=", 1)47        key = key.strip()48        if not key:49            raise typer.BadParameter(50                f"Invalid {flag} format: {entry_display}. Key cannot be empty."51            )52        parsed[key] = value53    return parsed54 55 56def _apply_space_variables_and_secrets(57    repo_id: str,58    variables: dict[str, str],59    secrets: dict[str, str],60    api: HfApi,61) -> None:62    """Configure Space-level variables and secrets on a deployed repo.63 64    Idempotent: add_space_variable/secret with the same key overwrites.65    Secret values are never logged — only the key is printed.66    """67    for key, value in variables.items():68        try:69            api.add_space_variable(repo_id=repo_id, key=key, value=value)70        except Exception as e:71            console.print(f"[bold red]✗[/bold red] Failed to set variable {key}: {e}")72            raise typer.Exit(1) from e73        console.print(f"[bold green]✓[/bold green] Set variable {key} on {repo_id}")74    for key, value in secrets.items():75        try:76            api.add_space_secret(repo_id=repo_id, key=key, value=value)77        except Exception as e:78            console.print(f"[bold red]✗[/bold red] Failed to set secret {key}: {e}")79            raise typer.Exit(1) from e80        console.print(f"[bold green]✓[/bold green] Set secret {key} on {repo_id}")81 82 83def _path_matches_pattern(relative_path: Path, pattern: str) -> bool:84    """Return True if a relative path matches an exclude pattern."""85    normalized_pattern = pattern.strip()86    if normalized_pattern.startswith("!"):87        return False88 89    while normalized_pattern.startswith("./"):90        normalized_pattern = normalized_pattern[2:]91 92    if normalized_pattern.startswith("/"):93        normalized_pattern = normalized_pattern[1:]94 95    if not normalized_pattern:96        return False97 98    posix_path = relative_path.as_posix()99    pattern_candidates = [normalized_pattern]100    if normalized_pattern.startswith("**/"):101        # Gitignore-style "**/" can also match directly at the root.102        pattern_candidates.append(normalized_pattern[3:])103 104    # Support directory patterns such as "artifacts/" and "**/outputs/".105    if normalized_pattern.endswith("/"):106        dir_pattern_candidates: list[str] = []107        for candidate in pattern_candidates:108            base = candidate.rstrip("/")109            if not base:110                continue111            dir_pattern_candidates.extend([base, f"{base}/*"])112 113        return any(114            fnmatch(posix_path, candidate) for candidate in dir_pattern_candidates115        )116 117    # Match both full relative path and basename for convenience.118    return any(119        fnmatch(posix_path, candidate) for candidate in pattern_candidates120    ) or any(fnmatch(relative_path.name, candidate) for candidate in pattern_candidates)121 122 123def _should_exclude_path(relative_path: Path, ignore_patterns: list[str]) -> bool:124    """Return True when the path should be excluded from staging/upload."""125    return any(126        _path_matches_pattern(relative_path, pattern) for pattern in ignore_patterns127    )128 129 130def _read_ignore_file(ignore_path: Path) -> tuple[list[str], int]:131    """Read ignore patterns from a file and return (patterns, ignored_negations)."""132    patterns: list[str] = []133    ignored_negations = 0134 135    for line in ignore_path.read_text().splitlines():136        stripped = line.strip()137        if not stripped or stripped.startswith("#"):138            continue139        if stripped.startswith("!"):140            ignored_negations += 1141            continue142        patterns.append(stripped)143 144    return patterns, ignored_negations145 146 147def _load_ignore_patterns(env_dir: Path, exclude_file: str | None) -> list[str]:148    """Load ignore patterns from defaults and an optional ignore file."""149    patterns = list(DEFAULT_PUSH_IGNORE_PATTERNS)150    ignored_negations = 0151 152    def _merge_ignore_file(ignore_path: Path, *, source_label: str) -> None:153        nonlocal ignored_negations154        file_patterns, skipped_negations = _read_ignore_file(ignore_path)155        patterns.extend(file_patterns)156        ignored_negations += skipped_negations157        console.print(158            f"[bold green]✓[/bold green] Loaded {len(file_patterns)} ignore patterns from {source_label}: {ignore_path}"159        )160 161    # Optional source: explicit exclude file from CLI.162    if exclude_file:163        ignore_path = Path(exclude_file)164        if not ignore_path.is_absolute():165            ignore_path = env_dir / ignore_path166        ignore_path = ignore_path.resolve()167 168        if not ignore_path.exists() or not ignore_path.is_file():169            raise typer.BadParameter(170                f"Exclude file not found or not a file: {ignore_path}"171            )172 173        _merge_ignore_file(ignore_path, source_label="--exclude")174 175    # Keep stable order while removing duplicates.176    patterns = list(dict.fromkeys(patterns))177 178    if ignored_negations > 0:179        console.print(180            f"[bold yellow]⚠[/bold yellow] Skipped {ignored_negations} negated ignore patterns ('!') because negation is not supported for push excludes"181        )182 183    return patterns184 185 186def _copytree_ignore_factory(env_dir: Path, ignore_patterns: list[str]):187    """Build a shutil.copytree ignore callback from path-based patterns."""188 189    def _ignore(path: str, names: list[str]) -> set[str]:190        current_dir = Path(path)191        ignored: set[str] = set()192 193        for name in names:194            candidate = current_dir / name195            try:196                relative_path = candidate.relative_to(env_dir)197            except ValueError:198                # candidate is not under env_dir (e.g. symlink or199                # copytree root differs from env_dir); skip filtering.200                continue201            if _should_exclude_path(relative_path, ignore_patterns):202                ignored.add(name)203 204        return ignored205 206    return _ignore207 208 209def _validate_openenv_directory(directory: Path) -> tuple[str, dict]:210    """211    Validate that the directory is an OpenEnv environment.212 213    Returns:214        `tuple` of `(env_name, manifest_data)`.215    """216    # Use the comprehensive validation function217    try:218        warnings = validate_env_structure(directory)219        for warning in warnings:220            console.print(f"[bold yellow]⚠[/bold yellow] {warning}")221    except FileNotFoundError as e:222        raise typer.BadParameter(f"Invalid OpenEnv environment structure: {e}") from e223 224    # Load and validate manifest225    manifest_path = directory / "openenv.yaml"226    try:227        with open(manifest_path, "r") as f:228            manifest = yaml.safe_load(f)229    except Exception as e:230        raise typer.BadParameter(f"Failed to parse openenv.yaml: {e}") from e231 232    if not isinstance(manifest, dict):233        raise typer.BadParameter("openenv.yaml must be a YAML dictionary")234 235    env_name = manifest.get("name")236    if not env_name:237        raise typer.BadParameter("openenv.yaml must contain a 'name' field")238 239    return env_name, manifest240 241 242def _rewrite_dockerfile_for_space(243    dockerfile_content: str,244    *,245    base_image: str | None,246    enable_interface: bool,247) -> tuple[str, list[str]]:248    """Rewrite deployment Dockerfile content and return printed change labels."""249    lines = dockerfile_content.split("\n")250    new_lines = []251    cmd_found = False252    base_image_updated = False253    web_interface_env_exists = "ENABLE_WEB_INTERFACE" in dockerfile_content254    last_instruction = None255 256    for line in lines:257        stripped = line.strip()258        token = stripped.split(maxsplit=1)[0] if stripped else ""259        current_instruction = token.upper()260 261        is_healthcheck_continuation = last_instruction == "HEALTHCHECK"262 263        # Update base image if specified264        if base_image and stripped.startswith("FROM") and not base_image_updated:265            new_lines.append(f"FROM {base_image}")266            base_image_updated = True267            last_instruction = "FROM"268            continue269 270        if (271            stripped.startswith("CMD")272            and not cmd_found273            and not web_interface_env_exists274            and enable_interface275            and not is_healthcheck_continuation276        ):277            new_lines.append("ENV ENABLE_WEB_INTERFACE=true")278            cmd_found = True279 280        new_lines.append(line)281 282        if current_instruction:283            last_instruction = current_instruction284 285    if not cmd_found and not web_interface_env_exists and enable_interface:286        new_lines.append("ENV ENABLE_WEB_INTERFACE=true")287 288    if base_image and not base_image_updated:289        new_lines.insert(0, f"FROM {base_image}")290 291    changes = []292    if base_image and base_image_updated:293        changes.append("updated base image")294    if enable_interface and not web_interface_env_exists:295        changes.append("enabled web interface")296 297    return "\n".join(new_lines), changes298 299 300def _get_hf_username() -> str:301    """Return the authenticated Hugging Face username from whoami()."""302    username = _extract_hf_username(whoami())303    if not username:304        raise ValueError("Could not extract username from whoami response")305 306    console.print(f"[bold green]✓[/bold green] Authenticated as: {username}")307    return username308 309 310def _ensure_hf_authenticated() -> str:311    """312    Ensure user is authenticated with Hugging Face.313 314    Returns:315        `str`: username of the authenticated user.316    """317    try:318        return _get_hf_username()319    except Exception:320        # Not authenticated, prompt for login321        console.print(322            "[bold yellow]Not authenticated with Hugging Face. Please login...[/bold yellow]"323        )324 325        try:326            login()327            return _get_hf_username()328        except Exception as e:329            raise typer.BadParameter(330                f"Hugging Face authentication failed: {e}. Please run login manually."331            ) from e332 333 334def _prepare_staging_directory(335    env_dir: Path,336    env_name: str,337    staging_dir: Path,338    ignore_patterns: list[str],339    base_image: str | None = None,340    enable_interface: bool = True,341) -> None:342    """343    Prepare files for deployment.344 345    This includes:346    - Copying necessary files347    - Modifying Dockerfile to optionally enable web interface and update base image348    - Ensuring README has proper HF frontmatter (if interface enabled)349    """350    # Create staging directory structure351    staging_dir.mkdir(parents=True, exist_ok=True)352 353    # Copy all files from env directory354    copy_ignore = _copytree_ignore_factory(env_dir, ignore_patterns)355    for item in env_dir.iterdir():356        relative_path = item.relative_to(env_dir)357        if _should_exclude_path(relative_path, ignore_patterns):358            continue359 360        dest = staging_dir / item.name361        if item.is_dir():362            shutil.copytree(item, dest, dirs_exist_ok=True, ignore=copy_ignore)363        else:364            shutil.copy2(item, dest)365 366    # Dockerfile must be at repo root for Hugging Face. Prefer root if present367    # (it was copied there); otherwise move server/Dockerfile to root.368    dockerfile_server_path = staging_dir / "server" / "Dockerfile"369    dockerfile_root_path = staging_dir / "Dockerfile"370    dockerfile_path: Path | None = None371 372    if dockerfile_root_path.exists():373        dockerfile_path = dockerfile_root_path374    elif dockerfile_server_path.exists():375        dockerfile_server_path.rename(dockerfile_root_path)376        console.print(377            "[bold cyan]Moved Dockerfile to repository root for deployment[/bold cyan]"378        )379        dockerfile_path = dockerfile_root_path380 381    # Modify Dockerfile to optionally enable web interface and update base image382    if dockerfile_path and dockerfile_path.exists():383        dockerfile_content = dockerfile_path.read_text()384        dockerfile_content, changes = _rewrite_dockerfile_for_space(385            dockerfile_content,386            base_image=base_image,387            enable_interface=enable_interface,388        )389        dockerfile_path.write_text(dockerfile_content)390        if changes:391            console.print(392                f"[bold green]✓[/bold green] Updated Dockerfile: {', '.join(changes)}"393            )394    else:395        console.print(396            "[bold yellow]⚠[/bold yellow] No Dockerfile at server/ or repo root"397        )398 399    # Ensure README has proper HF frontmatter (only if interface enabled)400    if enable_interface:401        readme_path = staging_dir / "README.md"402        if readme_path.exists():403            readme_content = readme_path.read_text()404            if "base_path: /web" not in readme_content:405                # Check if frontmatter exists406                if readme_content.startswith("---"):407                    # Add base_path to existing frontmatter408                    lines = readme_content.split("\n")409                    new_lines = []410                    _in_frontmatter = True411                    for i, line in enumerate(lines):412                        new_lines.append(line)413                        if line.strip() == "---" and i > 0:414                            # End of frontmatter, add base_path before this line415                            if "base_path:" not in "\n".join(new_lines):416                                new_lines.insert(-1, "base_path: /web")417                            _in_frontmatter = False418                    readme_path.write_text("\n".join(new_lines))419                else:420                    # No frontmatter, add it421                    frontmatter = f"""---422title: {env_name.replace("_", " ").title()} Environment Server423emoji: 🔊424colorFrom: blue425colorTo: indigo426sdk: docker427pinned: false428app_port: 8000429base_path: /web430tags:431  - openenv432---433 434"""435                    readme_path.write_text(frontmatter + readme_content)436                console.print(437                    "[bold green]✓[/bold green] Updated README with HF Space frontmatter"438                )439        else:440            console.print("[bold yellow]⚠[/bold yellow] No README.md found")441 442 443def _create_hf_space(444    repo_id: str,445    api: HfApi,446    private: bool = False,447    hardware: str | None = None,448) -> None:449    """Create a Hugging Face Space if it doesn't exist."""450    console.print(f"[bold cyan]Creating/verifying space: {repo_id}[/bold cyan]")451 452    try:453        create_kwargs: dict = {454            "repo_id": repo_id,455            "repo_type": "space",456            "space_sdk": "docker",457            "private": private,458            "exist_ok": True,459        }460        if hardware is not None:461            create_kwargs["space_hardware"] = hardware462        api.create_repo(**create_kwargs)463        console.print(f"[bold green]✓[/bold green] Space {repo_id} is ready")464    except Exception as e:465        # Space might already exist, which is okay with exist_ok=True466        # But if there's another error, log it467        console.print(f"[bold yellow]⚠[/bold yellow] Space creation: {e}")468 469 470def _upload_to_hf_space(471    repo_id: str,472    staging_dir: Path,473    api: HfApi,474    ignore_patterns: list[str],475    private: bool = False,476    create_pr: bool = False,477    commit_message: str | None = None,478) -> None:479    """Upload files to Hugging Face Space."""480    if create_pr:481        console.print(482            f"[bold cyan]Uploading files to {repo_id} (will open a Pull Request)...[/bold cyan]"483        )484    else:485        console.print(f"[bold cyan]Uploading files to {repo_id}...[/bold cyan]")486 487    upload_kwargs: dict = {488        "folder_path": str(staging_dir),489        "repo_id": repo_id,490        "repo_type": "space",491        "create_pr": create_pr,492        "ignore_patterns": ignore_patterns,493    }494    if commit_message:495        upload_kwargs["commit_message"] = commit_message496 497    try:498        result = api.upload_folder(**upload_kwargs)499        console.print("[bold green]✓[/bold green] Upload completed successfully")500        if create_pr and result is not None and hasattr(result, "pr_url"):501            console.print(f"[bold]Pull request:[/bold] {result.pr_url}")502        console.print(503            f"[bold]Space URL:[/bold] https://huggingface.co/spaces/{repo_id}"504        )505    except Exception as e:506        console.print(f"[bold red]✗[/bold red] Upload failed: {e}")507        raise typer.Exit(1) from e508 509 510@app.command()511def push(512    directory: Annotated[513        str | None,514        typer.Argument(515            help="Directory containing the OpenEnv environment (default: current directory)"516        ),517    ] = None,518    repo_id: Annotated[519        str | None,520        typer.Option(521            "--repo-id",522            "-r",523            help="Repository ID as 'repo_name' or 'namespace/repo_name'. Defaults to 'username/env-name' from openenv.yaml.",524        ),525    ] = None,526    base_image: Annotated[527        str | None,528        typer.Option(529            "--base-image",530            "-b",531            help="Base Docker image to use (overrides Dockerfile FROM)",532        ),533    ] = None,534    interface: Annotated[535        bool,536        typer.Option(537            "--interface",538            help="Enable web interface (default: True if no registry specified)",539        ),540    ] = None,541    no_interface: Annotated[542        bool,543        typer.Option(544            "--no-interface",545            help="Disable web interface",546        ),547    ] = False,548    registry: Annotated[549        str | None,550        typer.Option(551            "--registry",552            help="Custom registry URL (e.g., docker.io/username). Disables web interface by default.",553        ),554    ] = None,555    private: Annotated[556        bool,557        typer.Option(558            "--private",559            help="Deploy the space as private",560        ),561    ] = False,562    create_pr: Annotated[563        bool,564        typer.Option(565            "--create-pr",566            help="Create a Pull Request instead of pushing to the default branch",567        ),568    ] = False,569    exclude: Annotated[570        str | None,571        typer.Option(572            "--exclude",573            help="Optional additional ignore file with newline-separated glob patterns to exclude from Hugging Face uploads",574        ),575    ] = None,576    hardware: Annotated[577        str | None,578        typer.Option(579            "--hardware",580            "-H",581            help="Request hardware for Hugging Face Space (e.g. t4-medium, cpu-basic). See HF docs for options.",582        ),583    ] = None,584    count: Annotated[585        int,586        typer.Option(587            "--count",588            "-n",589            help="Number of Space instances to deploy. Each gets a numeric suffix (e.g. env-1, env-2).",590            min=1,591        ),592    ] = 1,593    env_vars: Annotated[594        list[str] | None,595        typer.Option(596            "--env-var",597            "-e",598            help="Public Space variable as KEY=VALUE (repeatable). Overrides matching keys from openenv.yaml variables:.",599        ),600    ] = None,601    secrets: Annotated[602        list[str] | None,603        typer.Option(604            "--secret",605            help="Private Space secret as KEY=VALUE (repeatable). Value is never logged.",606        ),607    ] = None,608) -> None:609    """610    Push an OpenEnv environment to Hugging Face Spaces or a custom Docker registry.611 612    This command:613    1. Validates that the directory is an OpenEnv environment (openenv.yaml present)614    2. Builds and pushes to Hugging Face Spaces or custom Docker registry615    3. Optionally enables web interface for deployment616 617    The web interface is enabled by default when pushing to HuggingFace Spaces,618    but disabled by default when pushing to a custom Docker registry.619 620    Examples:621 622        ```bash623        # Push to HuggingFace Spaces from current directory (web interface enabled)624        $ cd my_env625        $ openenv push626 627        # Push to HuggingFace repo and open a Pull Request628        $ openenv push my-org/my-env --create-pr629        $ openenv push --repo-id my-org/my-env --create-pr630 631        # Push to HuggingFace without web interface632        $ openenv push --no-interface633 634        # Push to Docker Hub635        $ openenv push --registry docker.io/myuser636 637        # Push to GitHub Container Registry638        $ openenv push --registry ghcr.io/myorg639 640        # Push to custom registry with web interface641        $ openenv push --registry myregistry.io/path1/path2 --interface642 643        # Push to specific HuggingFace repo644        $ openenv push --repo-id my-org/my-env645 646        # Push privately with custom base image647        $ openenv push --private --base-image ghcr.io/huggingface/openenv-base:latest648 649        # Push with GPU hardware650        $ openenv push --hardware t4-medium651 652        # Set a public Space variable (overrides openenv.yaml variables:)653        $ openenv push -e OPENSPIEL_GAME=tic_tac_toe -e MAX_STEPS=100654 655        # Set a private Space secret (value never logged)656        $ openenv push --secret OPENAI_API_KEY=sk-...657        ```658    """659    # Validate --count flag combinations660    if count > 1 and registry:661        console.print(662            "[bold red]Error:[/bold red] --count cannot be used with --registry",663        )664        raise typer.Exit(1)665 666    if count > 1 and create_pr:667        console.print(668            "[bold red]Error:[/bold red] --count cannot be used with --create-pr",669        )670        raise typer.Exit(1)671 672    # Handle interface flag logic673    if no_interface and interface:674        console.print(675            "[bold red]Error:[/bold red] Cannot specify both --interface and --no-interface",676            file=sys.stderr,677        )678        raise typer.Exit(1)679 680    # Determine if web interface should be enabled681    if no_interface:682        enable_interface = False683    elif interface is not None:684        enable_interface = interface685    elif registry is not None:686        # Custom registry: disable interface by default687        enable_interface = False688    else:689        # HuggingFace: enable interface by default690        enable_interface = True691 692    # Determine directory693    if directory:694        env_dir = Path(directory).resolve()695    else:696        env_dir = Path.cwd().resolve()697 698    if not env_dir.exists() or not env_dir.is_dir():699        raise typer.BadParameter(f"Directory does not exist: {env_dir}")700 701    # Check for openenv.yaml to confirm this is an environment directory702    openenv_yaml = env_dir / "openenv.yaml"703    if not openenv_yaml.exists():704        console.print(705            f"[bold red]Error:[/bold red] Not an OpenEnv environment directory (missing openenv.yaml): {env_dir}",706        )707        console.print(708            "[yellow]Hint:[/yellow] Run this command from the environment root directory",709        )710        raise typer.Exit(1)711 712    # Validate OpenEnv environment713    console.print(714        f"[bold cyan]Validating OpenEnv environment in {env_dir}...[/bold cyan]"715    )716    env_name, manifest = _validate_openenv_directory(env_dir)717    console.print(f"[bold green]✓[/bold green] Found OpenEnv environment: {env_name}")718 719    # Parse and merge Space variables (yaml < CLI) and secrets (CLI only).720    yaml_variables_raw = manifest.get("variables")721    if yaml_variables_raw is None:722        yaml_variables_raw = {}723    elif not isinstance(yaml_variables_raw, dict):724        raise typer.BadParameter(725            "openenv.yaml 'variables' must be a mapping of KEY: value"726        )727    if registry and (env_vars or secrets):728        raise typer.BadParameter(729            "--env-var/--secret cannot be used with --registry because custom registry pushes do not configure Hugging Face Space settings"730        )731    if create_pr and (env_vars or secrets):732        raise typer.BadParameter(733            "--env-var/--secret cannot be used with --create-pr because Space settings are only applied to the live Space after merge"734        )735    merged_variables: dict[str, str] = {736        str(k): str(v) for k, v in yaml_variables_raw.items()737    }738    cli_variables = _parse_kv_pairs(env_vars or [], flag="--env-var")739    merged_variables.update(cli_variables)740    cli_secrets = _parse_kv_pairs(secrets or [], flag="--secret")741    if registry and merged_variables:742        console.print(743            "[bold yellow]⚠[/bold yellow] openenv.yaml variables: are only applied to Hugging Face Spaces and will be ignored with --registry"744        )745    if create_pr and merged_variables:746        console.print(747            "[bold yellow]⚠[/bold yellow] openenv.yaml variables: are not applied when using --create-pr; configure them after the PR is merged"748        )749 750    # Handle custom registry push751    if registry:752        console.print("[bold cyan]Preparing to push to custom registry...[/bold cyan]")753        if enable_interface:754            console.print("[bold cyan]Web interface will be enabled[/bold cyan]")755 756        # Import build functions757        from .build import _build_docker_image, _push_docker_image758 759        # Prepare build args for custom registry deployment760        build_args = {}761        if enable_interface:762            build_args["ENABLE_WEB_INTERFACE"] = "true"763 764        # Build Docker image from the environment directory765        tag = f"{registry}/{env_name}"766        console.print(f"[bold cyan]Building Docker image: {tag}[/bold cyan]")767 768        success = _build_docker_image(769            env_path=env_dir,770            tag=tag,771            build_args=build_args if build_args else None,772        )773 774        if not success:775            console.print("[bold red]✗ Docker build failed[/bold red]")776            raise typer.Exit(1)777 778        console.print("[bold green]✓ Docker build successful[/bold green]")779 780        # Push to registry781        console.print(f"[bold cyan]Pushing to registry: {registry}[/bold cyan]")782 783        success = _push_docker_image(784            tag, registry=None785        )  # Tag already includes registry786 787        if not success:788            console.print("[bold red]✗ Docker push failed[/bold red]")789            raise typer.Exit(1)790 791        console.print("\n[bold green]✓ Deployment complete![/bold green]")792        console.print(f"[bold]Image:[/bold] {tag}")793        return794 795    ignore_patterns = _load_ignore_patterns(env_dir, exclude)796 797    # Ensure authentication for HuggingFace798    username = _ensure_hf_authenticated()799 800    # Determine repo_id801    if not repo_id:802        repo_id = f"{username}/{env_name}"803 804    if repo_id.count("/") > 1:805        raise typer.BadParameter(806            f"Invalid repo-id format: {repo_id!r}. Repo id must be in the form 'repo_name' or 'namespace/repo_name'."807        )808    if "/" not in repo_id:809        repo_id = f"{username}/{repo_id}"810 811    # Initialize Hugging Face API812    api = HfApi()813 814    # Prepare staging directory815    deployment_type = (816        "with web interface" if enable_interface else "without web interface"817    )818    console.print(819        f"[bold cyan]Preparing files for Hugging Face deployment ({deployment_type})...[/bold cyan]"820    )821    with tempfile.TemporaryDirectory() as tmpdir:822        staging_dir = Path(tmpdir) / "staging"823        _prepare_staging_directory(824            env_dir,825            env_name,826            staging_dir,827            ignore_patterns=ignore_patterns,828            base_image=base_image,829            enable_interface=enable_interface,830        )831 832        if count > 1:833            base_repo_id = repo_id834            for i in range(1, count + 1):835                instance_repo_id = f"{base_repo_id}-{i}"836                console.print(837                    f"\n[bold cyan][{i}/{count}] Deploying {instance_repo_id}...[/bold cyan]"838                )839                _create_hf_space(840                    instance_repo_id, api, private=private, hardware=hardware841                )842                _upload_to_hf_space(843                    instance_repo_id,844                    staging_dir,845                    api,846                    private=private,847                    create_pr=False,848                    ignore_patterns=ignore_patterns,849                )850                _apply_space_variables_and_secrets(851                    instance_repo_id, merged_variables, cli_secrets, api852                )853            console.print(854                f"\n[bold green]✓ All {count} instances deployed![/bold green]"855            )856            for i in range(1, count + 1):857                console.print(858                    f"Visit instance {i}: https://huggingface.co/spaces/{base_repo_id}-{i}"859                )860        else:861            # Create/verify space (no-op if exists; needed when pushing to own new repo)862            if not create_pr:863                _create_hf_space(repo_id, api, private=private, hardware=hardware)864            # When create_pr we rely on upload_folder to create branch and PR865 866            # Upload files867            _upload_to_hf_space(868                repo_id,869                staging_dir,870                api,871                private=private,872                create_pr=create_pr,873                ignore_patterns=ignore_patterns,874            )875 876            # Skip variable/secret configuration in PR mode since the target877            # repo's live environment should only change when the PR is merged.878            if not create_pr:879                _apply_space_variables_and_secrets(880                    repo_id, merged_variables, cli_secrets, api881                )882 883            console.print("\n[bold green]✓ Deployment complete![/bold green]")884            console.print(885                f"Visit your space at: https://huggingface.co/spaces/{repo_id}"886            )887