Team Ai
Apppublic

openenv/coding_env

sourceHugging Faceupdated 3mo agoView on Hugging Face
21likes
build.py462 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"""Build Docker images for OpenEnv environments."""8 9from __future__ import annotations10 11import shutil12import subprocess13import sys14import tempfile15from pathlib import Path16from typing import Annotated17 18import typer19 20from .._cli_utils import console21 22app = typer.Typer(help="Build Docker images for OpenEnv environments")23 24 25def _detect_build_context(env_path: Path) -> tuple[str, Path, Path | None]:26    """27    Detect whether we're building a standalone or in-repo environment.28 29    Returns:30        tuple: (build_mode, build_context_path, repo_root)31            - build_mode: "standalone" or "in-repo"32            - build_context_path: Path to use as Docker build context33            - repo_root: Path to repo root (None for standalone)34    """35    # Ensure env_path is absolute for proper comparison36    env_path = env_path.absolute()37 38    # Check if we're in a git repository39    current = env_path40    repo_root = None41 42    # Walk up to find .git directory43    for parent in [current] + list(current.parents):44        if (parent / ".git").exists():45            repo_root = parent46            break47 48    if repo_root is None:49        # Not in a git repo = standalone50        return "standalone", env_path, None51 52    # Check if environment is under envs/ (in-repo pattern)53    try:54        rel_path = env_path.relative_to(repo_root)55        rel_str = str(rel_path)56        if (57            rel_str.startswith("envs/")58            or rel_str.startswith("envs\\")59            or rel_str.startswith("envs/")60        ):61            # In-repo environment62            return "in-repo", repo_root, repo_root63    except ValueError:64        pass65 66    # Otherwise, it's standalone (environment outside repo structure)67    return "standalone", env_path, None68 69 70def _prepare_standalone_build(env_path: Path, temp_dir: Path) -> Path:71    """72    Prepare a standalone environment for building.73 74    For standalone builds:75    1. Copy environment to temp directory76    2. Ensure pyproject.toml depends on openenv77 78    Returns:79        Path to the prepared build directory80    """81    console.print("[cyan]Preparing standalone build...[/cyan]")82 83    # Copy environment to temp directory84    build_dir = temp_dir / env_path.name85    shutil.copytree(env_path, build_dir, symlinks=True)86 87    console.print(f"[cyan]Copied environment to:[/cyan] {build_dir}")88 89    # Check if pyproject.toml has openenv dependency90    pyproject_path = build_dir / "pyproject.toml"91    if pyproject_path.exists():92        with open(pyproject_path, "rb") as f:93            try:94                import tomli95 96                pyproject = tomli.load(f)97                deps = pyproject.get("project", {}).get("dependencies", [])98 99                # Check if openenv dependency is declared100                has_openenv = any(dep.startswith("openenv") for dep in deps)101 102                if not has_openenv:103                    console.print(104                        "[yellow]Warning:[/yellow] pyproject.toml doesn't list the openenv dependency",105                    )106                    console.print(107                        "[yellow]You may need to add:[/yellow] openenv>=0.2.0",108                    )109            except ImportError:110                console.print(111                    "[yellow]Warning:[/yellow] tomli not available, skipping dependency check",112                )113 114    return build_dir115 116 117def _prepare_inrepo_build(env_path: Path, repo_root: Path, temp_dir: Path) -> Path:118    """119    Prepare an in-repo environment for building.120 121    For in-repo builds:122    1. Create temp directory with environment and core123    2. Set up structure that matches expected layout124 125    Returns:126        Path to the prepared build directory127    """128    console.print("[cyan]Preparing in-repo build...[/cyan]")129 130    # Copy environment to temp directory131    build_dir = temp_dir / env_path.name132    shutil.copytree(env_path, build_dir, symlinks=True)133 134    # Copy OpenEnv package metadata + sources to temp directory.135    # Keep the src/ layout since pyproject.toml uses package-dir = {"" = "src"}.136    package_src = repo_root / "src" / "openenv"137    package_dest = build_dir / "openenv"138    if package_src.exists():139        package_dest.mkdir(parents=True, exist_ok=True)140        shutil.copytree(package_src, package_dest / "src" / "openenv", symlinks=True)141 142        for filename in ("pyproject.toml", "README.md"):143            src_file = repo_root / filename144            if src_file.exists():145                shutil.copy2(src_file, package_dest / filename)146 147        console.print(f"[cyan]Copied OpenEnv package to:[/cyan] {package_dest}")148 149        # Update pyproject.toml to reference local OpenEnv copy150        pyproject_path = build_dir / "pyproject.toml"151        if pyproject_path.exists():152            with open(pyproject_path, "rb") as f:153                try:154                    import tomli155 156                    pyproject = tomli.load(f)157                    deps = pyproject.get("project", {}).get("dependencies", [])158 159                    # Replace openenv/openenv-core with local reference160                    new_deps = []161                    for dep in deps:162                        if (163                            dep.startswith("openenv-core")164                            or dep.startswith("openenv_core")165                            or dep.startswith("openenv")166                        ):167                            # Skip - we'll use local core168                            continue169                        new_deps.append(dep)170 171                    # Write back with local core reference172                    pyproject["project"]["dependencies"] = new_deps + [173                        "openenv-core @ file:///app/env/openenv"174                    ]175 176                    # Write updated pyproject.toml177                    with open(pyproject_path, "wb") as out_f:178                        import tomli_w179 180                        tomli_w.dump(pyproject, out_f)181 182                    console.print(183                        "[cyan]Updated pyproject.toml to use local core[/cyan]"184                    )185 186                    # Remove old lockfile since dependencies changed187                    lockfile = build_dir / "uv.lock"188                    if lockfile.exists():189                        lockfile.unlink()190                        console.print("[cyan]Removed outdated uv.lock[/cyan]")191 192                except ImportError:193                    console.print(194                        "[yellow]Warning:[/yellow] tomli/tomli_w not available, using pyproject.toml as-is",195                    )196    else:197        console.print(198            "[yellow]Warning:[/yellow] OpenEnv package not found, building without it"199        )200 201    console.print(f"[cyan]Build directory prepared:[/cyan] {build_dir}")202    return build_dir203 204 205def _run_command(206    cmd: list[str],207    cwd: Path | None = None,208    check: bool = True,209) -> subprocess.CompletedProcess:210    """Run a shell command and handle errors."""211    console.print(f"[bold cyan]Running:[/bold cyan] {' '.join(cmd)}")212    try:213        result = subprocess.run(214            cmd, cwd=cwd, check=check, capture_output=True, text=True215        )216        if result.stdout:217            console.print(result.stdout)218        if result.stderr:219            print(result.stderr, file=sys.stderr)220        return result221    except subprocess.CalledProcessError as e:222        print(f"Error running command: {e}", file=sys.stderr)223        if e.stdout:224            console.print(e.stdout)225        if e.stderr:226            print(e.stderr, file=sys.stderr)227        if check:228            raise typer.Exit(1) from e229        return e230 231 232def _build_docker_image(233    env_path: Path,234    tag: str | None = None,235    context_path: Path | None = None,236    dockerfile: Path | None = None,237    build_args: dict[str, str] | None = None,238    no_cache: bool = False,239) -> bool:240    """Build Docker image for the environment with smart context detection."""241 242    # Detect build context (standalone vs in-repo)243    build_mode, detected_context, repo_root = _detect_build_context(env_path)244 245    console.print(f"[bold cyan]Build mode detected:[/bold cyan] {build_mode}")246 247    # Use detected context unless explicitly overridden248    if context_path is None:249        context_path = detected_context250 251    # Create temporary build directory252    with tempfile.TemporaryDirectory() as temp_dir_str:253        temp_dir = Path(temp_dir_str)254 255        # Prepare build directory based on mode256        if build_mode == "standalone":257            build_dir = _prepare_standalone_build(env_path, temp_dir)258        else:  # in-repo259            build_dir = _prepare_inrepo_build(env_path, repo_root, temp_dir)260 261        # Determine Dockerfile path262        if dockerfile is None:263            # Look for Dockerfile in server/ subdirectory264            dockerfile = build_dir / "server" / "Dockerfile"265            if not dockerfile.exists():266                # Fallback to root of build directory267                dockerfile = build_dir / "Dockerfile"268 269        if not dockerfile.exists():270            console.print(271                f"[bold red]Error:[/bold red] Dockerfile not found at {dockerfile}",272            )273            return False274 275        # Generate tag if not provided276        if tag is None:277            env_name = env_path.name278            if env_name.endswith("_env"):279                env_name = env_name[:-4]280            tag = f"openenv-{env_name}"281 282        console.print(f"[bold cyan]Building Docker image:[/bold cyan] {tag}")283        console.print(f"[bold cyan]Build context:[/bold cyan] {build_dir}")284        console.print(f"[bold cyan]Dockerfile:[/bold cyan] {dockerfile}")285 286        # Prepare build args287        if build_args is None:288            build_args = {}289 290        # Add build mode and env name to build args291        build_args["BUILD_MODE"] = build_mode292        build_args["ENV_NAME"] = env_path.name.replace("_env", "")293 294        # Build Docker command295        cmd = ["docker", "build", "-t", tag, "-f", str(dockerfile)]296 297        if no_cache:298            cmd.append("--no-cache")299 300        for key, value in build_args.items():301            cmd.extend(["--build-arg", f"{key}={value}"])302 303        cmd.append(str(build_dir))304 305        result = _run_command(cmd, check=False)306        return result.returncode == 0307 308 309def _push_docker_image(tag: str, registry: str | None = None) -> bool:310    """Push Docker image to registry."""311    if registry:312        full_tag = f"{registry}/{tag}"313        console.print(f"[bold cyan]Tagging image as {full_tag}[/bold cyan]")314        _run_command(["docker", "tag", tag, full_tag])315        tag = full_tag316 317    console.print(f"[bold cyan]Pushing image:[/bold cyan] {tag}")318    result = _run_command(["docker", "push", tag], check=False)319    return result.returncode == 0320 321 322@app.command()323def build(324    env_path: Annotated[325        str | None,326        typer.Argument(327            help="Path to the environment directory (default: current directory)"328        ),329    ] = None,330    tag: Annotated[331        str | None,332        typer.Option(333            "--tag",334            "-t",335            help="Docker image tag (default: openenv-<env_name>)",336        ),337    ] = None,338    context: Annotated[339        str | None,340        typer.Option(341            "--context",342            "-c",343            help="Build context path (default: <env_path>/server)",344        ),345    ] = None,346    dockerfile: Annotated[347        str | None,348        typer.Option(349            "--dockerfile",350            "-f",351            help="Path to Dockerfile (default: <context>/Dockerfile)",352        ),353    ] = None,354    no_cache: Annotated[355        bool,356        typer.Option(357            "--no-cache",358            help="Build without using cache",359        ),360    ] = False,361    build_arg: Annotated[362        list[str] | None,363        typer.Option(364            "--build-arg",365            help="Build arguments (can be used multiple times, format: KEY=VALUE)",366        ),367    ] = None,368) -> None:369    """370    Build Docker images for OpenEnv environments.371 372    This command builds Docker images using the environment's pyproject.toml373    and uv for dependency management. Run from the environment root directory.374 375    Examples:376        # Build from environment root (recommended)377        $ cd my_env378        $ openenv build379 380        # Build with custom tag381        $ openenv build -t my-custom-tag382 383        # Build without cache384        $ openenv build --no-cache385 386        # Build with custom build arguments387        $ openenv build --build-arg VERSION=1.0 --build-arg ENV=prod388 389        # Build from different directory390        $ openenv build envs/echo_env391    """392    # Determine environment path (default to current directory)393    if env_path is None:394        env_path_obj = Path.cwd()395    else:396        env_path_obj = Path(env_path)397 398    # Validate environment path399    if not env_path_obj.exists():400        print(401            f"Error: Environment path does not exist: {env_path_obj}",402            file=sys.stderr,403        )404        raise typer.Exit(1)405 406    if not env_path_obj.is_dir():407        print(408            f"Error: Environment path is not a directory: {env_path_obj}",409            file=sys.stderr,410        )411        raise typer.Exit(1)412 413    # Check for openenv.yaml to confirm this is an environment directory414    openenv_yaml = env_path_obj / "openenv.yaml"415    if not openenv_yaml.exists():416        print(417            f"Error: Not an OpenEnv environment directory (missing openenv.yaml): {env_path_obj}",418            file=sys.stderr,419        )420        print(421            "Hint: Run this command from the environment root directory or specify the path",422            file=sys.stderr,423        )424        raise typer.Exit(1)425 426    console.print(f"[bold]Building Docker image for:[/bold] {env_path_obj.name}")427    console.print("=" * 60)428 429    # Parse build args430    build_args = {}431    if build_arg:432        for arg in build_arg:433            if "=" in arg:434                key, value = arg.split("=", 1)435                build_args[key] = value436            else:437                print(438                    f"Warning: Invalid build arg format: {arg}",439                    file=sys.stderr,440                )441 442    # Convert string paths to Path objects443    context_path_obj = Path(context) if context else None444    dockerfile_path_obj = Path(dockerfile) if dockerfile else None445 446    # Build Docker image447    success = _build_docker_image(448        env_path=env_path_obj,449        tag=tag,450        context_path=context_path_obj,451        dockerfile=dockerfile_path_obj,452        build_args=build_args if build_args else None,453        no_cache=no_cache,454    )455 456    if not success:457        print("✗ Docker build failed", file=sys.stderr)458        raise typer.Exit(1)459 460    console.print("[bold green]✓ Docker build successful[/bold green]")461    console.print("\n[bold green]Done![/bold green]")462