Team Ai
Apppublic

openenv/atari_env

sourceHugging Faceupdated 6mo agoView on Hugging Face
3likes
push.py719 linesDownload Raw Back to commands
1# Copyright (c) Meta Platforms, Inc. and affiliates.2# All rights reserved.3#4# This source code is licensed under the BSD-style license found in the5# LICENSE file in the root directory of this source tree.6 7"""Push an OpenEnv environment to Hugging Face Spaces."""8 9from __future__ import annotations10 11import shutil12import sys13import tempfile14from fnmatch import fnmatch15from pathlib import Path16from typing import Annotated17 18import typer19import yaml20from huggingface_hub import HfApi, login, whoami21 22from .._cli_utils import console, validate_env_structure23 24app = typer.Typer(help="Push an OpenEnv environment to Hugging Face Spaces")25 26 27DEFAULT_PUSH_IGNORE_PATTERNS = [".*", "__pycache__", "*.pyc"]28 29 30def _path_matches_pattern(relative_path: Path, pattern: str) -> bool:31    """Return True if a relative path matches an exclude pattern."""32    normalized_pattern = pattern.strip()33    if normalized_pattern.startswith("!"):34        return False35 36    while normalized_pattern.startswith("./"):37        normalized_pattern = normalized_pattern[2:]38 39    if normalized_pattern.startswith("/"):40        normalized_pattern = normalized_pattern[1:]41 42    if not normalized_pattern:43        return False44 45    posix_path = relative_path.as_posix()46    pattern_candidates = [normalized_pattern]47    if normalized_pattern.startswith("**/"):48        # Gitignore-style "**/" can also match directly at the root.49        pattern_candidates.append(normalized_pattern[3:])50 51    # Support directory patterns such as "artifacts/" and "**/outputs/".52    if normalized_pattern.endswith("/"):53        dir_pattern_candidates: list[str] = []54        for candidate in pattern_candidates:55            base = candidate.rstrip("/")56            if not base:57                continue58            dir_pattern_candidates.extend([base, f"{base}/*"])59 60        return any(61            fnmatch(posix_path, candidate) for candidate in dir_pattern_candidates62        )63 64    # Match both full relative path and basename for convenience.65    return any(66        fnmatch(posix_path, candidate) for candidate in pattern_candidates67    ) or any(fnmatch(relative_path.name, candidate) for candidate in pattern_candidates)68 69 70def _should_exclude_path(relative_path: Path, ignore_patterns: list[str]) -> bool:71    """Return True when the path should be excluded from staging/upload."""72    return any(73        _path_matches_pattern(relative_path, pattern) for pattern in ignore_patterns74    )75 76 77def _read_ignore_file(ignore_path: Path) -> tuple[list[str], int]:78    """Read ignore patterns from a file and return (patterns, ignored_negations)."""79    patterns: list[str] = []80    ignored_negations = 081 82    for line in ignore_path.read_text().splitlines():83        stripped = line.strip()84        if not stripped or stripped.startswith("#"):85            continue86        if stripped.startswith("!"):87            ignored_negations += 188            continue89        patterns.append(stripped)90 91    return patterns, ignored_negations92 93 94def _load_ignore_patterns(env_dir: Path, exclude_file: str | None) -> list[str]:95    """Load ignore patterns from defaults and an optional ignore file."""96    patterns = list(DEFAULT_PUSH_IGNORE_PATTERNS)97    ignored_negations = 098 99    def _merge_ignore_file(ignore_path: Path, *, source_label: str) -> None:100        nonlocal ignored_negations101        file_patterns, skipped_negations = _read_ignore_file(ignore_path)102        patterns.extend(file_patterns)103        ignored_negations += skipped_negations104        console.print(105            f"[bold green]✓[/bold green] Loaded {len(file_patterns)} ignore patterns from {source_label}: {ignore_path}"106        )107 108    # Optional source: explicit exclude file from CLI.109    if exclude_file:110        ignore_path = Path(exclude_file)111        if not ignore_path.is_absolute():112            ignore_path = env_dir / ignore_path113        ignore_path = ignore_path.resolve()114 115        if not ignore_path.exists() or not ignore_path.is_file():116            raise typer.BadParameter(117                f"Exclude file not found or not a file: {ignore_path}"118            )119 120        _merge_ignore_file(ignore_path, source_label="--exclude")121 122    # Keep stable order while removing duplicates.123    patterns = list(dict.fromkeys(patterns))124 125    if ignored_negations > 0:126        console.print(127            f"[bold yellow]⚠[/bold yellow] Skipped {ignored_negations} negated ignore patterns ('!') because negation is not supported for push excludes"128        )129 130    return patterns131 132 133def _copytree_ignore_factory(env_dir: Path, ignore_patterns: list[str]):134    """Build a shutil.copytree ignore callback from path-based patterns."""135 136    def _ignore(path: str, names: list[str]) -> set[str]:137        current_dir = Path(path)138        ignored: set[str] = set()139 140        for name in names:141            candidate = current_dir / name142            try:143                relative_path = candidate.relative_to(env_dir)144            except ValueError:145                # candidate is not under env_dir (e.g. symlink or146                # copytree root differs from env_dir); skip filtering.147                continue148            if _should_exclude_path(relative_path, ignore_patterns):149                ignored.add(name)150 151        return ignored152 153    return _ignore154 155 156def _validate_openenv_directory(directory: Path) -> tuple[str, dict]:157    """158    Validate that the directory is an OpenEnv environment.159 160    Returns:161        Tuple of (env_name, manifest_data)162    """163    # Use the comprehensive validation function164    try:165        warnings = validate_env_structure(directory)166        for warning in warnings:167            console.print(f"[bold yellow]⚠[/bold yellow] {warning}")168    except FileNotFoundError as e:169        raise typer.BadParameter(f"Invalid OpenEnv environment structure: {e}") from e170 171    # Load and validate manifest172    manifest_path = directory / "openenv.yaml"173    try:174        with open(manifest_path, "r") as f:175            manifest = yaml.safe_load(f)176    except Exception as e:177        raise typer.BadParameter(f"Failed to parse openenv.yaml: {e}") from e178 179    if not isinstance(manifest, dict):180        raise typer.BadParameter("openenv.yaml must be a YAML dictionary")181 182    env_name = manifest.get("name")183    if not env_name:184        raise typer.BadParameter("openenv.yaml must contain a 'name' field")185 186    return env_name, manifest187 188 189def _ensure_hf_authenticated() -> str:190    """191    Ensure user is authenticated with Hugging Face.192 193    Returns:194        Username of authenticated user195    """196    try:197        # Try to get current user198        user_info = whoami()199        # Handle both dict and object return types200        if isinstance(user_info, dict):201            username = (202                user_info.get("name")203                or user_info.get("fullname")204                or user_info.get("username")205            )206        else:207            # If it's an object, try to get name attribute208            username = (209                getattr(user_info, "name", None)210                or getattr(user_info, "fullname", None)211                or getattr(user_info, "username", None)212            )213 214        if not username:215            raise ValueError("Could not extract username from whoami response")216 217        console.print(f"[bold green]✓[/bold green] Authenticated as: {username}")218        return username219    except Exception:220        # Not authenticated, prompt for login221        console.print(222            "[bold yellow]Not authenticated with Hugging Face. Please login...[/bold yellow]"223        )224 225        try:226            login()227            # Verify login worked228            user_info = whoami()229            # Handle both dict and object return types230            if isinstance(user_info, dict):231                username = (232                    user_info.get("name")233                    or user_info.get("fullname")234                    or user_info.get("username")235                )236            else:237                username = (238                    getattr(user_info, "name", None)239                    or getattr(user_info, "fullname", None)240                    or getattr(user_info, "username", None)241                )242 243            if not username:244                raise ValueError("Could not extract username from whoami response")245 246            console.print(f"[bold green]✓[/bold green] Authenticated as: {username}")247            return username248        except Exception as e:249            raise typer.BadParameter(250                f"Hugging Face authentication failed: {e}. Please run login manually."251            ) from e252 253 254def _prepare_staging_directory(255    env_dir: Path,256    env_name: str,257    staging_dir: Path,258    ignore_patterns: list[str],259    base_image: str | None = None,260    enable_interface: bool = True,261) -> None:262    """263    Prepare files for deployment.264 265    This includes:266    - Copying necessary files267    - Modifying Dockerfile to optionally enable web interface and update base image268    - Ensuring README has proper HF frontmatter (if interface enabled)269    """270    # Create staging directory structure271    staging_dir.mkdir(parents=True, exist_ok=True)272 273    # Copy all files from env directory274    copy_ignore = _copytree_ignore_factory(env_dir, ignore_patterns)275    for item in env_dir.iterdir():276        relative_path = item.relative_to(env_dir)277        if _should_exclude_path(relative_path, ignore_patterns):278            continue279 280        dest = staging_dir / item.name281        if item.is_dir():282            shutil.copytree(item, dest, dirs_exist_ok=True, ignore=copy_ignore)283        else:284            shutil.copy2(item, dest)285 286    # Dockerfile must be at repo root for Hugging Face. Prefer root if present287    # (it was copied there); otherwise move server/Dockerfile to root.288    dockerfile_server_path = staging_dir / "server" / "Dockerfile"289    dockerfile_root_path = staging_dir / "Dockerfile"290    dockerfile_path: Path | None = None291 292    if dockerfile_root_path.exists():293        dockerfile_path = dockerfile_root_path294    elif dockerfile_server_path.exists():295        dockerfile_server_path.rename(dockerfile_root_path)296        console.print(297            "[bold cyan]Moved Dockerfile to repository root for deployment[/bold cyan]"298        )299        dockerfile_path = dockerfile_root_path300 301    # Modify Dockerfile to optionally enable web interface and update base image302    if dockerfile_path and dockerfile_path.exists():303        dockerfile_content = dockerfile_path.read_text()304        lines = dockerfile_content.split("\n")305        new_lines = []306        cmd_found = False307        base_image_updated = False308        web_interface_env_exists = "ENABLE_WEB_INTERFACE" in dockerfile_content309        last_instruction = None310 311        for line in lines:312            stripped = line.strip()313            token = stripped.split(maxsplit=1)[0] if stripped else ""314            current_instruction = token.upper()315 316            is_healthcheck_continuation = last_instruction == "HEALTHCHECK"317 318            # Update base image if specified319            if base_image and stripped.startswith("FROM") and not base_image_updated:320                new_lines.append(f"FROM {base_image}")321                base_image_updated = True322                last_instruction = "FROM"323                continue324 325            if (326                stripped.startswith("CMD")327                and not cmd_found328                and not web_interface_env_exists329                and enable_interface330                and not is_healthcheck_continuation331            ):332                new_lines.append("ENV ENABLE_WEB_INTERFACE=true")333                cmd_found = True334 335            new_lines.append(line)336 337            if current_instruction:338                last_instruction = current_instruction339 340        if not cmd_found and not web_interface_env_exists and enable_interface:341            new_lines.append("ENV ENABLE_WEB_INTERFACE=true")342 343        if base_image and not base_image_updated:344            new_lines.insert(0, f"FROM {base_image}")345 346        dockerfile_path.write_text("\n".join(new_lines))347 348        changes = []349        if base_image and base_image_updated:350            changes.append("updated base image")351        if enable_interface and not web_interface_env_exists:352            changes.append("enabled web interface")353        if changes:354            console.print(355                f"[bold green]✓[/bold green] Updated Dockerfile: {', '.join(changes)}"356            )357    else:358        console.print(359            "[bold yellow]⚠[/bold yellow] No Dockerfile at server/ or repo root"360        )361 362    # Ensure README has proper HF frontmatter (only if interface enabled)363    if enable_interface:364        readme_path = staging_dir / "README.md"365        if readme_path.exists():366            readme_content = readme_path.read_text()367            if "base_path: /web" not in readme_content:368                # Check if frontmatter exists369                if readme_content.startswith("---"):370                    # Add base_path to existing frontmatter371                    lines = readme_content.split("\n")372                    new_lines = []373                    _in_frontmatter = True374                    for i, line in enumerate(lines):375                        new_lines.append(line)376                        if line.strip() == "---" and i > 0:377                            # End of frontmatter, add base_path before this line378                            if "base_path:" not in "\n".join(new_lines):379                                new_lines.insert(-1, "base_path: /web")380                            _in_frontmatter = False381                    readme_path.write_text("\n".join(new_lines))382                else:383                    # No frontmatter, add it384                    frontmatter = f"""---385title: {env_name.replace("_", " ").title()} Environment Server386emoji: 🔊387colorFrom: '#00C9FF'388colorTo: '#1B2845'389sdk: docker390pinned: false391app_port: 8000392base_path: /web393tags:394  - openenv395---396 397"""398                    readme_path.write_text(frontmatter + readme_content)399                console.print(400                    "[bold green]✓[/bold green] Updated README with HF Space frontmatter"401                )402        else:403            console.print("[bold yellow]⚠[/bold yellow] No README.md found")404 405 406def _create_hf_space(407    repo_id: str,408    api: HfApi,409    private: bool = False,410) -> None:411    """Create a Hugging Face Space if it doesn't exist."""412    console.print(f"[bold cyan]Creating/verifying space: {repo_id}[/bold cyan]")413 414    try:415        api.create_repo(416            repo_id=repo_id,417            repo_type="space",418            space_sdk="docker",419            private=private,420            exist_ok=True,421        )422        console.print(f"[bold green]✓[/bold green] Space {repo_id} is ready")423    except Exception as e:424        # Space might already exist, which is okay with exist_ok=True425        # But if there's another error, log it426        console.print(f"[bold yellow]⚠[/bold yellow] Space creation: {e}")427 428 429def _upload_to_hf_space(430    repo_id: str,431    staging_dir: Path,432    api: HfApi,433    ignore_patterns: list[str],434    private: bool = False,435    create_pr: bool = False,436    commit_message: str | None = None,437) -> None:438    """Upload files to Hugging Face Space."""439    if create_pr:440        console.print(441            f"[bold cyan]Uploading files to {repo_id} (will open a Pull Request)...[/bold cyan]"442        )443    else:444        console.print(f"[bold cyan]Uploading files to {repo_id}...[/bold cyan]")445 446    upload_kwargs: dict = {447        "folder_path": str(staging_dir),448        "repo_id": repo_id,449        "repo_type": "space",450        "create_pr": create_pr,451        "ignore_patterns": ignore_patterns,452    }453    if commit_message:454        upload_kwargs["commit_message"] = commit_message455 456    try:457        result = api.upload_folder(**upload_kwargs)458        console.print("[bold green]✓[/bold green] Upload completed successfully")459        if create_pr and result is not None and hasattr(result, "pr_url"):460            console.print(f"[bold]Pull request:[/bold] {result.pr_url}")461        console.print(462            f"[bold]Space URL:[/bold] https://huggingface.co/spaces/{repo_id}"463        )464    except Exception as e:465        console.print(f"[bold red]✗[/bold red] Upload failed: {e}")466        raise typer.Exit(1) from e467 468 469@app.command()470def push(471    directory: Annotated[472        str | None,473        typer.Argument(474            help="Directory containing the OpenEnv environment (default: current directory)"475        ),476    ] = None,477    repo_id: Annotated[478        str | None,479        typer.Option(480            "--repo-id",481            "-r",482            help="Repository ID in format 'username/repo-name' (defaults to 'username/env-name' from openenv.yaml)",483        ),484    ] = None,485    base_image: Annotated[486        str | None,487        typer.Option(488            "--base-image",489            "-b",490            help="Base Docker image to use (overrides Dockerfile FROM)",491        ),492    ] = None,493    interface: Annotated[494        bool,495        typer.Option(496            "--interface",497            help="Enable web interface (default: True if no registry specified)",498        ),499    ] = None,500    no_interface: Annotated[501        bool,502        typer.Option(503            "--no-interface",504            help="Disable web interface",505        ),506    ] = False,507    registry: Annotated[508        str | None,509        typer.Option(510            "--registry",511            help="Custom registry URL (e.g., docker.io/username). Disables web interface by default.",512        ),513    ] = None,514    private: Annotated[515        bool,516        typer.Option(517            "--private",518            help="Deploy the space as private",519        ),520    ] = False,521    create_pr: Annotated[522        bool,523        typer.Option(524            "--create-pr",525            help="Create a Pull Request instead of pushing to the default branch",526        ),527    ] = False,528    exclude: Annotated[529        str | None,530        typer.Option(531            "--exclude",532            help="Optional additional ignore file with newline-separated glob patterns to exclude from Hugging Face uploads",533        ),534    ] = None,535) -> None:536    """537    Push an OpenEnv environment to Hugging Face Spaces or a custom Docker registry.538 539    This command:540    1. Validates that the directory is an OpenEnv environment (openenv.yaml present)541    2. Builds and pushes to Hugging Face Spaces or custom Docker registry542    3. Optionally enables web interface for deployment543 544    The web interface is enabled by default when pushing to HuggingFace Spaces,545    but disabled by default when pushing to a custom Docker registry.546 547    Examples:548        # Push to HuggingFace Spaces from current directory (web interface enabled)549        $ cd my_env550        $ openenv push551 552        # Push to HuggingFace repo and open a Pull Request553        $ openenv push my-org/my-env --create-pr554        $ openenv push --repo-id my-org/my-env --create-pr555 556        # Push to HuggingFace without web interface557        $ openenv push --no-interface558 559        # Push to Docker Hub560        $ openenv push --registry docker.io/myuser561 562        # Push to GitHub Container Registry563        $ openenv push --registry ghcr.io/myorg564 565        # Push to custom registry with web interface566        $ openenv push --registry myregistry.io/path1/path2 --interface567 568        # Push to specific HuggingFace repo569        $ openenv push --repo-id my-org/my-env570 571        # Push privately with custom base image572        $ openenv push --private --base-image ghcr.io/meta-pytorch/openenv-base:latest573    """574    # Handle interface flag logic575    if no_interface and interface:576        console.print(577            "[bold red]Error:[/bold red] Cannot specify both --interface and --no-interface",578            file=sys.stderr,579        )580        raise typer.Exit(1)581 582    # Determine if web interface should be enabled583    if no_interface:584        enable_interface = False585    elif interface is not None:586        enable_interface = interface587    elif registry is not None:588        # Custom registry: disable interface by default589        enable_interface = False590    else:591        # HuggingFace: enable interface by default592        enable_interface = True593 594    # Determine directory595    if directory:596        env_dir = Path(directory).resolve()597    else:598        env_dir = Path.cwd().resolve()599 600    if not env_dir.exists() or not env_dir.is_dir():601        raise typer.BadParameter(f"Directory does not exist: {env_dir}")602 603    # Check for openenv.yaml to confirm this is an environment directory604    openenv_yaml = env_dir / "openenv.yaml"605    if not openenv_yaml.exists():606        console.print(607            f"[bold red]Error:[/bold red] Not an OpenEnv environment directory (missing openenv.yaml): {env_dir}",608        )609        console.print(610            "[yellow]Hint:[/yellow] Run this command from the environment root directory",611        )612        raise typer.Exit(1)613 614    # Validate OpenEnv environment615    console.print(616        f"[bold cyan]Validating OpenEnv environment in {env_dir}...[/bold cyan]"617    )618    env_name, manifest = _validate_openenv_directory(env_dir)619    console.print(f"[bold green]✓[/bold green] Found OpenEnv environment: {env_name}")620 621    # Handle custom registry push622    if registry:623        console.print("[bold cyan]Preparing to push to custom registry...[/bold cyan]")624        if enable_interface:625            console.print("[bold cyan]Web interface will be enabled[/bold cyan]")626 627        # Import build functions628        from .build import _build_docker_image, _push_docker_image629 630        # Prepare build args for custom registry deployment631        build_args = {}632        if enable_interface:633            build_args["ENABLE_WEB_INTERFACE"] = "true"634 635        # Build Docker image from the environment directory636        tag = f"{registry}/{env_name}"637        console.print(f"[bold cyan]Building Docker image: {tag}[/bold cyan]")638 639        success = _build_docker_image(640            env_path=env_dir,641            tag=tag,642            build_args=build_args if build_args else None,643        )644 645        if not success:646            console.print("[bold red]✗ Docker build failed[/bold red]")647            raise typer.Exit(1)648 649        console.print("[bold green]✓ Docker build successful[/bold green]")650 651        # Push to registry652        console.print(f"[bold cyan]Pushing to registry: {registry}[/bold cyan]")653 654        success = _push_docker_image(655            tag, registry=None656        )  # Tag already includes registry657 658        if not success:659            console.print("[bold red]✗ Docker push failed[/bold red]")660            raise typer.Exit(1)661 662        console.print("\n[bold green]✓ Deployment complete![/bold green]")663        console.print(f"[bold]Image:[/bold] {tag}")664        return665 666    ignore_patterns = _load_ignore_patterns(env_dir, exclude)667 668    # Ensure authentication for HuggingFace669    username = _ensure_hf_authenticated()670 671    # Determine repo_id672    if not repo_id:673        repo_id = f"{username}/{env_name}"674 675    # Validate repo_id format676    if "/" not in repo_id or repo_id.count("/") != 1:677        raise typer.BadParameter(678            f"Invalid repo-id format: {repo_id}. Expected format: 'username/repo-name'"679        )680 681    # Initialize Hugging Face API682    api = HfApi()683 684    # Prepare staging directory685    deployment_type = (686        "with web interface" if enable_interface else "without web interface"687    )688    console.print(689        f"[bold cyan]Preparing files for Hugging Face deployment ({deployment_type})...[/bold cyan]"690    )691    with tempfile.TemporaryDirectory() as tmpdir:692        staging_dir = Path(tmpdir) / "staging"693        _prepare_staging_directory(694            env_dir,695            env_name,696            staging_dir,697            ignore_patterns=ignore_patterns,698            base_image=base_image,699            enable_interface=enable_interface,700        )701 702        # Create/verify space (no-op if exists; needed when pushing to own new repo)703        if not create_pr:704            _create_hf_space(repo_id, api, private=private)705        # When create_pr we rely on upload_folder to create branch and PR706 707        # Upload files708        _upload_to_hf_space(709            repo_id,710            staging_dir,711            api,712            private=private,713            create_pr=create_pr,714            ignore_patterns=ignore_patterns,715        )716 717        console.print("\n[bold green]✓ Deployment complete![/bold green]")718        console.print(f"Visit your space at: https://huggingface.co/spaces/{repo_id}")719