openenv/echo_env
6
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 