diff --git a/README.md b/README.md index 0fd3e1ebb..47f99a412 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,9 @@ uv pip install -e . # Run server locally without Docker uv run server --host 0.0.0.0 --port 8000 + +# Or use the OpenEnv CLI from the environment directory (reads openenv.yaml) +openenv serve . --host 0.0.0.0 --port 8000 ``` **Benefits:** diff --git a/envs/echo_env/models.py b/envs/echo_env/models.py new file mode 100644 index 000000000..ab029940a --- /dev/null +++ b/envs/echo_env/models.py @@ -0,0 +1,12 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Stub for the required ``models.py`` layout file. + +Echo reuses ``CallToolAction`` / ``CallToolObservation`` from +``openenv.core.env_server.mcp_types`` rather than defining env-local models here. +""" diff --git a/src/openenv/cli/__main__.py b/src/openenv/cli/__main__.py index b80e5b9fd..72fd2d6a5 100644 --- a/src/openenv/cli/__main__.py +++ b/src/openenv/cli/__main__.py @@ -44,9 +44,10 @@ name="push", help="Push an OpenEnv environment to Hugging Face Spaces or custom registry", )(push.push) -app.command(name="serve", help="Serve environments locally (TODO: Phase 4)")( - serve.serve -) +app.command( + name="serve", + help="Serve an environment locally with uvicorn using openenv.yaml", +)(serve.serve) app.command( name="fork", help="Fork (duplicate) a Hugging Face Space to your account", diff --git a/src/openenv/cli/commands/serve.py b/src/openenv/cli/commands/serve.py index df2bfa5a3..9c2b23b77 100644 --- a/src/openenv/cli/commands/serve.py +++ b/src/openenv/cli/commands/serve.py @@ -4,21 +4,29 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -"""Serve OpenEnv environments locally (TO BE IMPLEMENTED).""" +"""Serve an OpenEnv environment locally (uvicorn, from ``openenv.yaml``).""" from __future__ import annotations +import os +import sys from pathlib import Path from typing import Annotated import typer +import yaml -from .._cli_utils import console +from .._cli_utils import console, validate_env_structure -app = typer.Typer(help="Serve OpenEnv environments locally") + +def _find_repo_src_for_openenv(env_dir: Path) -> Path | None: + """Return ``/src`` when ``env_dir`` is under an OpenEnv clone (for ``import openenv``).""" + for parent in [env_dir, *env_dir.parents]: + if (parent / "src" / "openenv").is_dir(): + return parent / "src" + return None -@app.command() def serve( env_path: Annotated[ str | None, @@ -27,68 +35,90 @@ def serve( ), ] = None, port: Annotated[ - int, - typer.Option("--port", "-p", help="Port to serve on"), - ] = 8000, + int | None, + typer.Option( + "--port", + "-p", + help="Port to bind (default: ``port`` in openenv.yaml, else 8000)", + ), + ] = None, host: Annotated[ str, - typer.Option("--host", help="Host to bind to"), + typer.Option("--host", help="Host interface to bind"), ] = "0.0.0.0", reload: Annotated[ bool, - typer.Option("--reload", help="Enable auto-reload on code changes"), + typer.Option("--reload", help="Enable autoreload (development)"), ] = False, ) -> None: """ - Serve an OpenEnv environment locally. - - TODO: This command is currently not implemented and has been deferred for later. + Run the environment FastAPI app with uvicorn. - Planned functionality: - - Run environment server locally without Docker - - Support multiple deployment modes (local, notebook, cluster) - - Auto-reload for development - - Integration with environment's [project.scripts] entry point - - For now, use Docker-based serving: - 1. Build the environment: openenv build - 2. Run the container: docker run -p 8000:8000 - - Or use uv directly: - uv run --project . server --port 8000 + Uses ``openenv.yaml`` fields ``app`` (e.g. ``server.app:app``), ``port``, and + ``runtime`` (must be ``fastapi``). Matches ``uv run --project . server`` layout: + the environment directory is the working directory and on ``sys.path``. """ - console.print("[bold yellow]⚠ This command is not yet implemented[/bold yellow]\n") - - console.print( - "The [bold cyan]openenv serve[/bold cyan] command has been deferred for later." + try: + import uvicorn + except ImportError as exc: # pragma: no cover + raise typer.BadParameter( + "uvicorn is required for `openenv serve`. Install openenv-core with default dependencies." + ) from exc + + env_path_obj = ( + Path.cwd().resolve() if env_path is None else Path(env_path).resolve() ) - console.print("[bold]Alternative approaches:[/bold]\n") + try: + validate_env_structure(env_path_obj) + except FileNotFoundError as exc: + raise typer.BadParameter(f"Not a valid OpenEnv environment: {exc}") from exc + + manifest_path = env_path_obj / "openenv.yaml" + try: + with manifest_path.open("r", encoding="utf-8") as handle: + manifest = yaml.safe_load(handle) + except OSError as exc: + raise typer.BadParameter(f"Failed to read openenv.yaml: {exc}") from exc + except yaml.YAMLError as exc: + raise typer.BadParameter(f"Invalid YAML in openenv.yaml: {exc}") from exc + + if not isinstance(manifest, dict): + raise typer.BadParameter("openenv.yaml must be a YAML dictionary") + + app_spec = manifest.get("app") + if not app_spec or not isinstance(app_spec, str): + raise typer.BadParameter( + "openenv.yaml must contain a string 'app' field (e.g. server.app:app)" + ) + if ":" not in app_spec: + raise typer.BadParameter( + f"openenv.yaml 'app' must look like 'module.path:attribute', got {app_spec!r}" + ) + + runtime = str(manifest.get("runtime", "fastapi")).lower() + if runtime != "fastapi": + raise typer.BadParameter( + f"openenv serve only supports runtime 'fastapi' (got {runtime!r})" + ) + + listen_port = int(port if port is not None else manifest.get("port", 8000)) + + repo_src = _find_repo_src_for_openenv(env_path_obj) + if repo_src is not None: + repo_src_str = str(repo_src.resolve()) + if repo_src_str not in sys.path: + sys.path.insert(0, repo_src_str) + + env_root = str(env_path_obj.resolve()) + if env_root not in sys.path: + sys.path.insert(0, env_root) + + os.chdir(env_root) - console.print("[cyan]Option 1: Docker-based serving (recommended)[/cyan]") - console.print(" 1. Build the environment:") - console.print(" [dim]$ openenv build[/dim]") - console.print(" 2. Run the Docker container:") console.print( - f" [dim]$ docker run -p {port}:{port} openenv-:latest[/dim]\n" + f"[bold green]Serving[/bold green] [cyan]{app_spec}[/cyan] on " + f"[bold]http://{host}:{listen_port}/[/bold] (cwd: {env_root})" ) - console.print("[cyan]Option 2: Direct execution with uv[/cyan]") - - # Determine environment path - if env_path is None: - env_path_obj = Path.cwd() - else: - env_path_obj = Path(env_path) - - # Check for openenv.yaml - openenv_yaml = env_path_obj / "openenv.yaml" - if openenv_yaml.exists(): - console.print(" From your environment directory:") - console.print(f" [dim]$ cd {env_path_obj}[/dim]") - console.print(f" [dim]$ uv run --project . server --port {port}[/dim]\n") - else: - console.print(" From an environment directory with pyproject.toml:") - console.print(f" [dim]$ uv run --project . server --port {port}[/dim]\n") - - raise typer.Exit(0) + uvicorn.run(app_spec, host=host, port=listen_port, reload=reload) diff --git a/tests/envs/test_grid_world.py b/tests/envs/test_grid_world.py index 56c89bb62..e5621b194 100644 --- a/tests/envs/test_grid_world.py +++ b/tests/envs/test_grid_world.py @@ -5,8 +5,6 @@ # LICENSE file in the root directory of this source tree. import pytest - -# Import your client and models DIRECTLY from envs.grid_world_env.client import GridWorldEnv from envs.grid_world_env.models import GridWorldAction, MoveAction diff --git a/tests/envs/test_julia_env.py b/tests/envs/test_julia_env.py index 7abda4fb4..212e99a1e 100644 --- a/tests/envs/test_julia_env.py +++ b/tests/envs/test_julia_env.py @@ -84,8 +84,6 @@ class TestJuliaClientImport: def test_import_client(self): """Test that JuliaEnv client can be imported.""" from julia_env import JuliaEnv - - # Verify it's an EnvClient subclass from openenv.core.env_client import EnvClient assert issubclass(JuliaEnv, EnvClient) @@ -111,8 +109,6 @@ class TestJuliaServerImport: def test_import_codeact_env(self): """Test that JuliaCodeActEnv can be imported.""" from julia_env.server import JuliaCodeActEnv - - # Verify it's an Environment subclass from openenv.core.env_server.interfaces import Environment assert issubclass(JuliaCodeActEnv, Environment) diff --git a/tests/test_cli/test_serve.py b/tests/test_cli/test_serve.py new file mode 100644 index 000000000..d8f41aec0 --- /dev/null +++ b/tests/test_cli/test_serve.py @@ -0,0 +1,157 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Tests for ``openenv serve``.""" + +from __future__ import annotations + +import os +import socket +import subprocess +import sys +import time +from pathlib import Path +from unittest.mock import patch + +import pytest +import requests +from openenv.cli.__main__ import app +from typer.testing import CliRunner + + +REPO_ROOT = Path(__file__).resolve().parents[2] +ECHO_ENV = REPO_ROOT / "envs" / "echo_env" +runner = CliRunner() + + +@pytest.fixture(autouse=True) +def _restore_cwd_and_syspath() -> None: + """``serve`` mutates cwd and ``sys.path``; CliRunner runs in-process.""" + cwd = os.getcwd() + path = list(sys.path) + yield + try: + os.chdir(cwd) + except OSError: + pass + sys.path[:] = path + + +def _pick_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return int(s.getsockname()[1]) + + +def test_serve_calls_uvicorn_with_echo_manifest() -> None: + with patch("uvicorn.run") as mock_run: + result = runner.invoke( + app, + [ + "serve", + str(ECHO_ENV), + "--port", + "9911", + "--host", + "127.0.0.1", + ], + env={**os.environ, "PYTHONPATH": str(REPO_ROOT / "src")}, + ) + assert result.exit_code == 0, result.stdout + mock_run.assert_called_once() + (app_arg,), kwargs = mock_run.call_args + assert app_arg == "server.app:app" + assert kwargs["host"] == "127.0.0.1" + assert kwargs["port"] == 9911 + assert kwargs["reload"] is False + + +def test_serve_rejects_invalid_env_dir() -> None: + result = runner.invoke( + app, + ["serve", str(REPO_ROOT / "nonexistent_env_dir_xyz")], + env={**os.environ, "PYTHONPATH": str(REPO_ROOT / "src")}, + ) + assert result.exit_code != 0 + + +def test_serve_uses_manifest_port_when_omitted() -> None: + with patch("uvicorn.run") as mock_run: + result = runner.invoke( + app, + ["serve", str(ECHO_ENV), "--host", "127.0.0.1"], + env={**os.environ, "PYTHONPATH": str(REPO_ROOT / "src")}, + ) + assert result.exit_code == 0, result.stdout + _, kwargs = mock_run.call_args + assert kwargs["port"] == 8000 + + +@pytest.mark.integration +def test_serve_echo_env_health_subprocess() -> None: + port = _pick_free_port() + env = os.environ.copy() + env["PYTHONPATH"] = str(REPO_ROOT / "src") + cmd = [ + sys.executable, + "-m", + "openenv.cli", + "serve", + str(ECHO_ENV), + "--port", + str(port), + "--host", + "127.0.0.1", + ] + proc = subprocess.Popen( + cmd, + cwd=str(REPO_ROOT), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + ) + try: + deadline = time.time() + 60.0 + last_exc: Exception | None = None + ok = False + while time.time() < deadline: + try: + r = requests.get(f"http://127.0.0.1:{port}/health", timeout=2.0) + if r.status_code == 200: + ok = True + break + except Exception as exc: + last_exc = exc + if proc.poll() is not None: + out = proc.stdout.read() if proc.stdout else "" + pytest.fail( + f"serve process exited early (code={proc.returncode}): {out}" + ) + time.sleep(0.4) + if not ok: + # Stop the server before reading the pipe; a live Popen can block on + # stdout.read() indefinitely (Greptile P1). + proc.terminate() + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + proc.kill() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + out = proc.stdout.read() if proc.stdout else "" + pytest.fail(f"/health never OK (last error={last_exc!r}): {out}") + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + proc.kill()