Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
12 changes: 12 additions & 0 deletions envs/echo_env/models.py
Original file line number Diff line number Diff line change
@@ -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.
"""
7 changes: 4 additions & 3 deletions src/openenv/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
136 changes: 83 additions & 53 deletions src/openenv/cli/commands/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<repo>/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,
Expand All @@ -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 <image-name>

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)
Comment on lines +107 to +117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Global process-state mutation breaks in-process test isolation

sys.path.insert and os.chdir are irreversible global side effects. Because typer.testing.CliRunner runs the command in the same process (not a subprocess), every unit test that calls runner.invoke with serve will permanently move the test runner's CWD to env_root and prepend entries to sys.path. Tests that depend on the original CWD or a clean path (including subsequent tests in this very module) can silently break or import the wrong module version. The cleanest fix is to exec a child process so all global-state mutations are isolated, or add an autouse fixture that saves and restores os.getcwd() and sys.path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/openenv/cli/commands/serve.py
Line: 107-117

Comment:
**Global process-state mutation breaks in-process test isolation**

`sys.path.insert` and `os.chdir` are irreversible global side effects. Because `typer.testing.CliRunner` runs the command in the *same* process (not a subprocess), every unit test that calls `runner.invoke` with `serve` will permanently move the test runner's CWD to `env_root` and prepend entries to `sys.path`. Tests that depend on the original CWD or a clean path (including subsequent tests in this very module) can silently break or import the wrong module version. The cleanest fix is to exec a child process so all global-state mutations are isolated, or add an autouse fixture that saves and restores `os.getcwd()` and `sys.path`.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b56360c commit.


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-<env-name>: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)
2 changes: 0 additions & 2 deletions tests/envs/test_grid_world.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 0 additions & 4 deletions tests/envs/test_julia_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
157 changes: 157 additions & 0 deletions tests/test_cli/test_serve.py
Original file line number Diff line number Diff line change
@@ -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}")
Comment on lines +137 to +150

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 proc.stdout.read() deadlocks when the server never becomes healthy

When the 60-second deadline expires with ok = False, line 125 calls proc.stdout.read() on a pipe connected to a still-running process. Because nothing has terminated the subprocess yet (proc.terminate() is in the finally block, which only executes after control leaves the try body), the read blocks indefinitely — pytest.fail is never reached and neither is the finally cleanup. Terminate the process before reading its output.

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/test_cli/test_serve.py
Line: 124-126

Comment:
**`proc.stdout.read()` deadlocks when the server never becomes healthy**

When the 60-second deadline expires with `ok = False`, line 125 calls `proc.stdout.read()` on a pipe connected to a still-running process. Because nothing has terminated the subprocess yet (`proc.terminate()` is in the `finally` block, which only executes *after* control leaves the `try` body), the read blocks indefinitely — `pytest.fail` is never reached and neither is the `finally` cleanup. Terminate the process before reading its output.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b56360c commit

finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
proc.kill()