Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7427ba6
feat(cli): add openenv serve for local uvicorn
false200 May 11, 2026
d8600cd
test(cli): fix serve test isolation and subprocess stdout deadlock
false200 May 11, 2026
4a0d00c
fix(cli): address serve PR review (port guard, test import, drop unre…
false200 Jun 14, 2026
a9b18ac
fix(cli): port range, uvicorn msg, cwd restore, fixture annotation
false200 Jun 14, 2026
31e5c15
fix(cli): serve error handling, sys.path restore, echo models re-exports
false200 Jun 15, 2026
3648c1e
fix(cli): scope sys.path restore in serve try/finally
false200 Jun 15, 2026
06465d3
Merge branch 'main' into feat/cli-openenv-serve
false200 Jun 15, 2026
c9d6d01
Merge branch 'main' into feat/cli-openenv-serve
false200 Jun 15, 2026
0aaf932
Merge branch 'main' into feat/cli-openenv-serve
false200 Jun 16, 2026
1a56d71
Merge branch 'main' into feat/cli-openenv-serve
false200 Jun 18, 2026
043e085
Merge branch 'main' into feat/cli-openenv-serve
false200 Jun 21, 2026
c955d73
fix(cli): serve reload app_dir, path guards, test stdout read
false200 Jun 21, 2026
451d86e
Merge branch 'main' into feat/cli-openenv-serve
false200 Jun 22, 2026
a1796ff
Merge branch 'main' into feat/cli-openenv-serve
false200 Jul 1, 2026
cccc039
fix(cli): add repo root to serve path for envs.* app imports
false200 Jul 1, 2026
4a7f45f
Merge branch 'main' into feat/cli-openenv-serve
false200 Jul 1, 2026
5407133
Merge branch 'main' into feat/cli-openenv-serve
false200 Jul 1, 2026
b47d0f8
Merge branch 'main' into feat/cli-openenv-serve
false200 Jul 1, 2026
55dd6fe
Merge branch 'main' into feat/cli-openenv-serve
false200 Jul 1, 2026
d298e94
Merge branch 'main' into feat/cli-openenv-serve
false200 Jul 1, 2026
9c8c76c
Merge branch 'main' into feat/cli-openenv-serve
false200 Jul 1, 2026
ac39b43
Merge branch 'main' into feat/cli-openenv-serve
false200 Jul 2, 2026
5470c25
Merge branch 'main' into feat/cli-openenv-serve
false200 Jul 3, 2026
8256807
Merge branch 'main' into feat/cli-openenv-serve
false200 Jul 3, 2026
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 @@ -236,6 +236,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 @@ -50,7 +50,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
188 changes: 132 additions & 56 deletions src/openenv/cli/commands/serve.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
# SPDX-License-Identifier: BSD-3-Clause

"""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 @@ -23,68 +32,135 @@ 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(1)
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())
repo_root_str = str(repo_src.parent.resolve())
if repo_root_str not in sys.path:
sys.path.insert(0, repo_root_str)
if repo_src_str not in sys.path:
sys.path.insert(0, repo_src_str)
existing = os.environ.get("PYTHONPATH", "")
prefix = f"{repo_root_str}{os.pathsep}{repo_src_str}"
os.environ["PYTHONPATH"] = (
prefix if not existing else f"{prefix}{os.pathsep}{existing}"
)
if env_root not in sys.path:
sys.path.insert(0, env_root)
Comment thread
cursor[bot] marked this conversation as resolved.
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