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 @@ -234,6 +234,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
```

See [`envs/README.md`](envs/README.md) for a complete guide on building environments.
Expand Down
25 changes: 25 additions & 0 deletions envs/echo_env/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# 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.

"""
Echo MCP action/observation types (re-exported from ``mcp_types``).

Echo does not define env-local Pydantic models; it delegates to MCP types.
"""

from openenv.core.env_server.mcp_types import (
CallToolAction,
CallToolObservation,
ListToolsAction,
ListToolsObservation,
)

__all__ = [
"CallToolAction",
"CallToolObservation",
"ListToolsAction",
"ListToolsObservation",
]
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
2 changes: 1 addition & 1 deletion src/openenv/cli/_cli_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def validate_env_structure(env_dir: Path, strict: bool = False) -> List[str]:
"openenv.yaml",
"__init__.py",
"client.py",
"models.py",
"models.py", # presence only; MCP-only envs may re-export mcp_types
"README.md",
]

Expand Down
184 changes: 128 additions & 56 deletions src/openenv/cli/commands/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,30 @@
# 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 uvicorn
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 +36,131 @@ 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.

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
Run the environment FastAPI app with uvicorn.

For now, use Docker-based serving:
1. Build the environment: openenv build
2. Run the container: docker run -p 8000:8000 <image-name>
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``.

Or use uv directly:
uv run --project . server --port 8000
For production or training, use Docker (``openenv build``) — this command runs
on the host for local development only.
"""
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."
)

console.print("[bold]Alternative approaches:[/bold]\n")

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"
env_path_obj = (
Path.cwd().resolve() if env_path is None else Path(env_path).resolve()
)

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)
if not env_path_obj.exists():
typer.echo(f"Error: Path does not exist: {env_path_obj}", err=True)
raise typer.Exit(1)

if not env_path_obj.is_dir():
typer.echo(f"Error: Path is not a directory: {env_path_obj}", err=True)
raise typer.Exit(1)

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})"
)

raw_port = port if port is not None else manifest.get("port", 8000)
try:
listen_port = int(raw_port)
except (TypeError, ValueError) as exc:
raise typer.BadParameter(
f"Invalid port {raw_port!r}; expected an integer"
) from exc
if not (1 <= listen_port <= 65535):
raise typer.BadParameter(
f"Invalid port {listen_port}; expected a value between 1 and 65535"
)

original_path = list(sys.path)
prev_cwd = os.getcwd()
prev_pythonpath = os.environ.get("PYTHONPATH")
env_root = str(env_path_obj.resolve())

try:
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)
existing = os.environ.get("PYTHONPATH", "")
os.environ["PYTHONPATH"] = (
repo_src_str if not existing else f"{repo_src_str}{os.pathsep}{existing}"
)
if env_root not in sys.path:
sys.path.insert(0, env_root)
os.chdir(env_root)

console.print(
f"[bold green]Serving[/bold green] [cyan]{app_spec}[/cyan] on "
f"[bold]http://{host}:{listen_port}/[/bold] (cwd: {env_root})"
)

try:
uvicorn.run(
app_spec,
host=host,
port=listen_port,
reload=reload,
app_dir=env_root,
)
except OSError as exc:
typer.echo(f"Error: {exc}", err=True)
raise typer.Exit(1) from exc
finally:
try:
os.chdir(prev_cwd)
except OSError:
pass
sys.path[:] = original_path
if prev_pythonpath is None:
os.environ.pop("PYTHONPATH", None)
else:
os.environ["PYTHONPATH"] = prev_pythonpath
Loading