Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 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
413efe0
Merge branch 'main' into feat/cli-openenv-serve
false200 Jul 8, 2026
26cbf79
Merge branch 'main' into feat/cli-openenv-serve
false200 Jul 20, 2026
0cca197
fix: refresh local serve and default to loopback
burtenshaw Oct 19, 2026
3c608da
fix: defer server import for docs
burtenshaw Oct 19, 2026
b6a9a69
test: isolate serve runtime import
burtenshaw Oct 19, 2026
dc0fe69
fix: scope serve import paths
burtenshaw Oct 19, 2026
79dcabd
fix: disambiguate serve package paths
burtenshaw Oct 19, 2026
8864fbc
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
e752491
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
cb78ef1
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
547be27
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
3380bd8
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
9804786
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
981e627
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
e0d525b
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
702d6ea
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
0e558f8
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
a2fe830
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
eb2b11c
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
6a2766e
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
943ddd4
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
884d424
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
2534e3a
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 26, 2026
b480df0
Merge branch 'main' into feat/cli-openenv-serve
burtenshaw Oct 27, 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ openenv validate my_env --level static --skip-build # quick check against the
openenv push my_env # deploy it to Hugging Face Spaces
```

For local development, run `openenv serve my_env` after installing its dependencies. This runs on the host and binds to `127.0.0.1`; use Docker or Spaces for isolated training and deployment.

See [Your First Environment](https://huggingface.co/docs/openenv/guides/first-environment) and [Deploying an Environment](https://huggingface.co/docs/openenv/getting_started/environment-builder). `openenv import` wraps an existing environment from ORS/OpenReward or Verifiers.

## Environments
Expand Down
7 changes: 4 additions & 3 deletions src/openenv/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,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="Run an environment on the host for local development (no container isolation)",
)(serve.serve)
app.command(
name="fork",
help="Fork (duplicate) a Hugging Face Space to your account",
Expand Down
179 changes: 122 additions & 57 deletions src/openenv/cli/commands/serve.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
# 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 yaml

from .._cli_utils import console
from .._cli_utils import console, validate_env_structure

app = typer.Typer(help="Serve OpenEnv environments locally")


@app.command()
def serve(
env_path: Annotated[
str | None,
Expand All @@ -23,68 +23,133 @@ 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"),
] = "0.0.0.0",
typer.Option("--host", help="Host interface to bind (local development only)"),
] = "127.0.0.1",
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 on the host for local development.

Planned functionality:
- Run environment server locally without Docker
- Support multiple deployment modes (local, notebook, cluster)
Comment thread
burtenshaw marked this conversation as resolved.
- Auto-reload for development
- Integration with environment's [project.scripts] entry point
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. Package-qualified app
paths also support the environment package and the repo's ``envs`` package.

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
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")
import uvicorn

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()
env_root = str(env_path_obj)
app_dir = env_path_obj
module_name = app_spec.split(":", 1)[0]
# Prefer a module inside the environment, even when its package name matches
# the environment directory (for example, /work/server/server/app.py).
local_module = env_path_obj.joinpath(*module_name.split("."))
if not (
local_module.with_suffix(".py").is_file()
or (local_module / "__init__.py").is_file()
):
if env_path_obj.parent.name == "envs" and module_name.startswith(
f"envs.{env_path_obj.name}."
):
app_dir = env_path_obj.parent.parent
elif module_name.startswith(f"{env_path_obj.name}."):
app_dir = env_path_obj.parent

try:
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=str(app_dir),
)
Comment thread
cursor[bot] marked this conversation as resolved.
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
Loading
Loading