From 7427ba6d91a1958824246238eb8bc003f5d563fc Mon Sep 17 00:00:00 2001 From: CodeLoopdroid <214800619+CodeLoopdroid@users.noreply.github.com> Date: Tue, 12 May 2026 01:03:59 +0530 Subject: [PATCH 01/12] feat(cli): add openenv serve for local uvicorn Related to #613 - Load app/host/port from openenv.yaml and run uvicorn with env cwd on sys.path - Prepend repo src when the env path sits under an OpenEnv clone - Register serve on the Typer CLI; document usage in README - Add echo_env models.py stub required by validate_env_structure - Add tests for serve (mocked uvicorn + echo /health subprocess) --- README.md | 3 + envs/echo_env/models.py | 12 +++ src/openenv/cli/__main__.py | 7 +- src/openenv/cli/commands/serve.py | 136 ++++++++++++++++++------------ tests/envs/test_grid_world.py | 2 - tests/envs/test_julia_env.py | 4 - tests/test_cli/test_serve.py | 132 +++++++++++++++++++++++++++++ 7 files changed, 234 insertions(+), 62 deletions(-) create mode 100644 envs/echo_env/models.py create mode 100644 tests/test_cli/test_serve.py diff --git a/README.md b/README.md index 55a5cf7f06..195391c481 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/envs/echo_env/models.py b/envs/echo_env/models.py new file mode 100644 index 0000000000..ab029940a9 --- /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 b80e5b9fd9..72fd2d6a5b 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 df2bfa5a34..9c2b23b77e 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 56c89bb62b..e5621b1942 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 7abda4fb4b..212e99a1e3 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 0000000000..bf33d113ae --- /dev/null +++ b/tests/test_cli/test_serve.py @@ -0,0 +1,132 @@ +# 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() + + +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: + out = proc.stdout.read() if proc.stdout else "" + pytest.fail(f"/health never OK (last error={last_exc!r}): {out}") + finally: + proc.terminate() + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + proc.kill() From d8600cda97192ddc8706468725f16c5088eee78b Mon Sep 17 00:00:00 2001 From: CodeLoopdroid <214800619+CodeLoopdroid@users.noreply.github.com> Date: Tue, 12 May 2026 01:17:59 +0530 Subject: [PATCH 02/12] test(cli): fix serve test isolation and subprocess stdout deadlock Related to #613 - Autouse fixture restores cwd and sys.path after in-process CliRunner invokes - Terminate subprocess before reading stdout when /health deadline exceeded (Greptile P1) --- tests/test_cli/test_serve.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/test_cli/test_serve.py b/tests/test_cli/test_serve.py index bf33d113ae..d8f41aec03 100644 --- a/tests/test_cli/test_serve.py +++ b/tests/test_cli/test_serve.py @@ -27,6 +27,19 @@ 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)) @@ -122,10 +135,22 @@ def test_serve_echo_env_health_subprocess() -> None: ) 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: - proc.terminate() + if proc.poll() is None: + proc.terminate() try: proc.wait(timeout=15) except subprocess.TimeoutExpired: From 4a0d00c1f91fec5334fba8700ee43a9c1e5b10ed Mon Sep 17 00:00:00 2001 From: false200 <214800619+false200@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:22:06 +0530 Subject: [PATCH 03/12] fix(cli): address serve PR review (port guard, test import, drop unrelated diffs) Related to #613 --- src/openenv/cli/commands/serve.py | 11 ++++++++++- tests/envs/test_grid_world.py | 2 ++ tests/envs/test_julia_env.py | 4 ++++ tests/test_cli/test_serve.py | 2 +- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/openenv/cli/commands/serve.py b/src/openenv/cli/commands/serve.py index 9c2b23b77e..a2921625a7 100644 --- a/src/openenv/cli/commands/serve.py +++ b/src/openenv/cli/commands/serve.py @@ -57,6 +57,9 @@ def serve( 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``. + + For production or training, use Docker (``openenv build``) — this command runs + on the host for local development only. """ try: import uvicorn @@ -102,7 +105,13 @@ def serve( f"openenv serve only supports runtime 'fastapi' (got {runtime!r})" ) - listen_port = int(port if port is not None else manifest.get("port", 8000)) + 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 repo_src = _find_repo_src_for_openenv(env_path_obj) if repo_src is not None: diff --git a/tests/envs/test_grid_world.py b/tests/envs/test_grid_world.py index e5621b1942..56c89bb62b 100644 --- a/tests/envs/test_grid_world.py +++ b/tests/envs/test_grid_world.py @@ -5,6 +5,8 @@ # 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 212e99a1e3..7abda4fb4b 100644 --- a/tests/envs/test_julia_env.py +++ b/tests/envs/test_julia_env.py @@ -84,6 +84,8 @@ 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) @@ -109,6 +111,8 @@ 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 index d8f41aec03..6dcb961d28 100644 --- a/tests/test_cli/test_serve.py +++ b/tests/test_cli/test_serve.py @@ -17,7 +17,6 @@ from unittest.mock import patch import pytest -import requests from openenv.cli.__main__ import app from typer.testing import CliRunner @@ -92,6 +91,7 @@ def test_serve_uses_manifest_port_when_omitted() -> None: @pytest.mark.integration def test_serve_echo_env_health_subprocess() -> None: + requests = pytest.importorskip("requests") port = _pick_free_port() env = os.environ.copy() env["PYTHONPATH"] = str(REPO_ROOT / "src") From a9b18ac3ccebc92b6de1a1cb82a499f701ee5bcc Mon Sep 17 00:00:00 2001 From: false200 <214800619+false200@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:46:05 +0530 Subject: [PATCH 04/12] fix(cli): port range, uvicorn msg, cwd restore, fixture annotation --- src/openenv/cli/commands/serve.py | 23 +++++++++++++++++------ tests/test_cli/test_serve.py | 2 +- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/openenv/cli/commands/serve.py b/src/openenv/cli/commands/serve.py index a2921625a7..da5fb15e31 100644 --- a/src/openenv/cli/commands/serve.py +++ b/src/openenv/cli/commands/serve.py @@ -65,7 +65,7 @@ def serve( import uvicorn except ImportError as exc: # pragma: no cover raise typer.BadParameter( - "uvicorn is required for `openenv serve`. Install openenv-core with default dependencies." + "uvicorn is not installed. Run: pip install 'uvicorn>=0.24.0'" ) from exc env_path_obj = ( @@ -112,6 +112,10 @@ def serve( 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" + ) repo_src = _find_repo_src_for_openenv(env_path_obj) if repo_src is not None: @@ -123,11 +127,18 @@ def serve( if env_root not in sys.path: sys.path.insert(0, env_root) + prev_cwd = os.getcwd() 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: + console.print( + f"[bold green]Serving[/bold green] [cyan]{app_spec}[/cyan] on " + f"[bold]http://{host}:{listen_port}/[/bold] (cwd: {env_root})" + ) - uvicorn.run(app_spec, host=host, port=listen_port, reload=reload) + uvicorn.run(app_spec, host=host, port=listen_port, reload=reload) + finally: + try: + os.chdir(prev_cwd) + except OSError: + pass diff --git a/tests/test_cli/test_serve.py b/tests/test_cli/test_serve.py index 6dcb961d28..73415bc31a 100644 --- a/tests/test_cli/test_serve.py +++ b/tests/test_cli/test_serve.py @@ -27,7 +27,7 @@ @pytest.fixture(autouse=True) -def _restore_cwd_and_syspath() -> None: +def _restore_cwd_and_syspath(): """``serve`` mutates cwd and ``sys.path``; CliRunner runs in-process.""" cwd = os.getcwd() path = list(sys.path) From 31e5c151655d8283c97e638a779c4f5a4878abb8 Mon Sep 17 00:00:00 2001 From: false200 <214800619+false200@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:24:02 +0530 Subject: [PATCH 05/12] fix(cli): serve error handling, sys.path restore, echo models re-exports --- envs/echo_env/models.py | 19 ++++++++++++++++--- src/openenv/cli/_cli_utils.py | 2 +- src/openenv/cli/commands/serve.py | 16 ++++++++++++---- tests/test_cli/test_serve.py | 3 --- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/envs/echo_env/models.py b/envs/echo_env/models.py index ab029940a9..533ffe5f90 100644 --- a/envs/echo_env/models.py +++ b/envs/echo_env/models.py @@ -5,8 +5,21 @@ # LICENSE file in the root directory of this source tree. """ -Stub for the required ``models.py`` layout file. +Echo MCP action/observation types (re-exported from ``mcp_types``). -Echo reuses ``CallToolAction`` / ``CallToolObservation`` from -``openenv.core.env_server.mcp_types`` rather than defining env-local models here. +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", +] diff --git a/src/openenv/cli/_cli_utils.py b/src/openenv/cli/_cli_utils.py index c0f8a3aefc..509b648468 100644 --- a/src/openenv/cli/_cli_utils.py +++ b/src/openenv/cli/_cli_utils.py @@ -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", ] diff --git a/src/openenv/cli/commands/serve.py b/src/openenv/cli/commands/serve.py index da5fb15e31..86bdbb76b1 100644 --- a/src/openenv/cli/commands/serve.py +++ b/src/openenv/cli/commands/serve.py @@ -64,9 +64,11 @@ def serve( try: import uvicorn except ImportError as exc: # pragma: no cover - raise typer.BadParameter( - "uvicorn is not installed. Run: pip install 'uvicorn>=0.24.0'" - ) from exc + typer.echo( + "Error: uvicorn is not installed. Run: pip install 'uvicorn>=0.24.0'", + err=True, + ) + raise typer.Exit(1) from exc env_path_obj = ( Path.cwd().resolve() if env_path is None else Path(env_path).resolve() @@ -117,6 +119,7 @@ def serve( f"Invalid port {listen_port}; expected a value between 1 and 65535" ) + original_path = list(sys.path) repo_src = _find_repo_src_for_openenv(env_path_obj) if repo_src is not None: repo_src_str = str(repo_src.resolve()) @@ -136,9 +139,14 @@ def serve( f"[bold]http://{host}:{listen_port}/[/bold] (cwd: {env_root})" ) - uvicorn.run(app_spec, host=host, port=listen_port, reload=reload) + try: + uvicorn.run(app_spec, host=host, port=listen_port, reload=reload) + 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 diff --git a/tests/test_cli/test_serve.py b/tests/test_cli/test_serve.py index 73415bc31a..e1fa76d463 100644 --- a/tests/test_cli/test_serve.py +++ b/tests/test_cli/test_serve.py @@ -57,7 +57,6 @@ def test_serve_calls_uvicorn_with_echo_manifest() -> None: "--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() @@ -72,7 +71,6 @@ 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 @@ -82,7 +80,6 @@ def test_serve_uses_manifest_port_when_omitted() -> None: 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 From 3648c1e45a8685f0c7d538dbb3fd15f658ba8a99 Mon Sep 17 00:00:00 2001 From: false200 <214800619+false200@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:53:49 +0530 Subject: [PATCH 06/12] fix(cli): scope sys.path restore in serve try/finally --- src/openenv/cli/commands/serve.py | 31 +++++++++++-------------------- tests/test_cli/test_serve.py | 3 ++- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/openenv/cli/commands/serve.py b/src/openenv/cli/commands/serve.py index 86bdbb76b1..519f320332 100644 --- a/src/openenv/cli/commands/serve.py +++ b/src/openenv/cli/commands/serve.py @@ -14,6 +14,7 @@ from typing import Annotated import typer +import uvicorn import yaml from .._cli_utils import console, validate_env_structure @@ -61,15 +62,6 @@ def serve( For production or training, use Docker (``openenv build``) — this command runs on the host for local development only. """ - try: - import uvicorn - except ImportError as exc: # pragma: no cover - typer.echo( - "Error: uvicorn is not installed. Run: pip install 'uvicorn>=0.24.0'", - err=True, - ) - raise typer.Exit(1) from exc - env_path_obj = ( Path.cwd().resolve() if env_path is None else Path(env_path).resolve() ) @@ -120,20 +112,19 @@ def serve( ) original_path = list(sys.path) - 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) - prev_cwd = os.getcwd() - os.chdir(env_root) + 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) + 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})" diff --git a/tests/test_cli/test_serve.py b/tests/test_cli/test_serve.py index e1fa76d463..4bb44b502c 100644 --- a/tests/test_cli/test_serve.py +++ b/tests/test_cli/test_serve.py @@ -142,7 +142,8 @@ def test_serve_echo_env_health_subprocess() -> None: try: proc.wait(timeout=5) except subprocess.TimeoutExpired: - pass + if proc.stdout: + proc.stdout.close() out = proc.stdout.read() if proc.stdout else "" pytest.fail(f"/health never OK (last error={last_exc!r}): {out}") finally: From c955d73ff12f7ff7cd6076022b12b1e562b50a90 Mon Sep 17 00:00:00 2001 From: false200 <214800619+false200@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:56:36 +0530 Subject: [PATCH 07/12] fix(cli): serve reload app_dir, path guards, test stdout read --- src/openenv/cli/commands/serve.py | 25 ++++++++++++++++++++++++- tests/test_cli/test_serve.py | 7 ++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/openenv/cli/commands/serve.py b/src/openenv/cli/commands/serve.py index 519f320332..4de0c039c6 100644 --- a/src/openenv/cli/commands/serve.py +++ b/src/openenv/cli/commands/serve.py @@ -66,6 +66,14 @@ def serve( Path.cwd().resolve() if env_path is None else Path(env_path).resolve() ) + 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: @@ -113,6 +121,7 @@ def serve( original_path = list(sys.path) prev_cwd = os.getcwd() + prev_pythonpath = os.environ.get("PYTHONPATH") env_root = str(env_path_obj.resolve()) try: @@ -121,6 +130,10 @@ def serve( 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) @@ -131,7 +144,13 @@ def serve( ) try: - uvicorn.run(app_spec, host=host, port=listen_port, reload=reload) + 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 @@ -141,3 +160,7 @@ def serve( except OSError: pass sys.path[:] = original_path + if prev_pythonpath is None: + os.environ.pop("PYTHONPATH", None) + else: + os.environ["PYTHONPATH"] = prev_pythonpath diff --git a/tests/test_cli/test_serve.py b/tests/test_cli/test_serve.py index 4bb44b502c..3121b5f761 100644 --- a/tests/test_cli/test_serve.py +++ b/tests/test_cli/test_serve.py @@ -144,7 +144,12 @@ def test_serve_echo_env_health_subprocess() -> None: except subprocess.TimeoutExpired: if proc.stdout: proc.stdout.close() - out = proc.stdout.read() if proc.stdout else "" + out = "" + try: + if proc.stdout and not proc.stdout.closed: + out = proc.stdout.read() + except (OSError, ValueError): + pass pytest.fail(f"/health never OK (last error={last_exc!r}): {out}") finally: if proc.poll() is None: From cccc0391ce70e8ff106e2dcaae8a9431bdbad01e Mon Sep 17 00:00:00 2001 From: false200 <214800619+false200@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:35:27 +0530 Subject: [PATCH 08/12] fix(cli): add repo root to serve path for envs.* app imports --- src/openenv/cli/commands/serve.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/openenv/cli/commands/serve.py b/src/openenv/cli/commands/serve.py index 4de0c039c6..c2ac4886be 100644 --- a/src/openenv/cli/commands/serve.py +++ b/src/openenv/cli/commands/serve.py @@ -128,11 +128,15 @@ def serve( 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"] = ( - repo_src_str if not existing else f"{repo_src_str}{os.pathsep}{existing}" + prefix if not existing else f"{prefix}{os.pathsep}{existing}" ) if env_root not in sys.path: sys.path.insert(0, env_root) From 3c608da10e7ede22ea65670941b7c5e2fb5c84e9 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Mon, 19 Oct 2026 14:27:51 -0700 Subject: [PATCH 09/12] fix: defer server import for docs --- src/openenv/cli/commands/serve.py | 3 ++- tests/test_cli/test_serve.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/openenv/cli/commands/serve.py b/src/openenv/cli/commands/serve.py index 685eeb721c..a0158e56b4 100644 --- a/src/openenv/cli/commands/serve.py +++ b/src/openenv/cli/commands/serve.py @@ -10,7 +10,6 @@ from typing import Annotated import typer -import uvicorn import yaml from .._cli_utils import console, validate_env_structure @@ -58,6 +57,8 @@ def serve( For production or training, use Docker (``openenv build``) — this command runs on the host for local development only. """ + import uvicorn + env_path_obj = ( Path.cwd().resolve() if env_path is None else Path(env_path).resolve() ) diff --git a/tests/test_cli/test_serve.py b/tests/test_cli/test_serve.py index 9328928b54..36ff0467ee 100644 --- a/tests/test_cli/test_serve.py +++ b/tests/test_cli/test_serve.py @@ -158,3 +158,21 @@ def test_serve_restores_process_state_after_uvicorn_error(monkeypatch): assert "cannot bind" in result.output assert (os.getcwd(), sys.path) == before assert "PYTHONPATH" not in os.environ + + +def test_cli_commands_import_without_starting_the_server_runtime(): + code = """ +import sys +sys.modules['uvicorn'] = None +from openenv.cli.commands.init import init +assert callable(init) +""" + result = subprocess.run( + [sys.executable, "-c", code], + cwd=REPO_ROOT, + env={**os.environ, "PYTHONPATH": str(REPO_ROOT / "src")}, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr From b6a9a6924dfc993b78f3ef9cc5999f9f3f8b2178 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Mon, 19 Oct 2026 14:28:26 -0700 Subject: [PATCH 10/12] test: isolate serve runtime import --- tests/test_cli/test_serve.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_cli/test_serve.py b/tests/test_cli/test_serve.py index 36ff0467ee..53baacf88e 100644 --- a/tests/test_cli/test_serve.py +++ b/tests/test_cli/test_serve.py @@ -160,12 +160,18 @@ def test_serve_restores_process_state_after_uvicorn_error(monkeypatch): assert "PYTHONPATH" not in os.environ -def test_cli_commands_import_without_starting_the_server_runtime(): +def test_serve_module_import_does_not_load_uvicorn(): code = """ +import importlib.util import sys +from pathlib import Path sys.modules['uvicorn'] = None -from openenv.cli.commands.init import init -assert callable(init) +spec = importlib.util.spec_from_file_location( + 'openenv.cli.commands.serve', Path('src/openenv/cli/commands/serve.py') +) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +assert callable(module.serve) """ result = subprocess.run( [sys.executable, "-c", code], From dc0fe690f4ff611a43dbd0caac9a6e844fe30bef Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Mon, 19 Oct 2026 14:35:15 -0700 Subject: [PATCH 11/12] fix: scope serve import paths --- src/openenv/cli/commands/serve.py | 43 ++++++--------------- tests/test_cli/test_serve.py | 62 ++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 32 deletions(-) diff --git a/src/openenv/cli/commands/serve.py b/src/openenv/cli/commands/serve.py index a0158e56b4..a7b694e4f7 100644 --- a/src/openenv/cli/commands/serve.py +++ b/src/openenv/cli/commands/serve.py @@ -15,14 +15,6 @@ from .._cli_utils import console, validate_env_structure -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 - - def serve( env_path: Annotated[ str | None, @@ -52,7 +44,8 @@ def serve( 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``. + the environment directory is the working directory. Package-qualified app + paths also support the environment package and the repo's ``envs`` package. For production or training, use Docker (``openenv build``) — this command runs on the host for local development only. @@ -118,25 +111,17 @@ def serve( original_path = list(sys.path) prev_cwd = os.getcwd() - prev_pythonpath = os.environ.get("PYTHONPATH") - env_root = str(env_path_obj.resolve()) + env_root = str(env_path_obj) + app_dir = env_path_obj + module_name = app_spec.split(":", 1)[0] + if module_name.startswith(f"{env_path_obj.name}."): + app_dir = env_path_obj.parent + elif env_path_obj.parent.name == "envs" and module_name.startswith( + f"envs.{env_path_obj.name}." + ): + app_dir = env_path_obj.parent.parent 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) os.chdir(env_root) console.print( @@ -150,7 +135,7 @@ def serve( host=host, port=listen_port, reload=reload, - app_dir=env_root, + app_dir=str(app_dir), ) except OSError as exc: typer.echo(f"Error: {exc}", err=True) @@ -161,7 +146,3 @@ def serve( except OSError: pass sys.path[:] = original_path - if prev_pythonpath is None: - os.environ.pop("PYTHONPATH", None) - else: - os.environ["PYTHONPATH"] = prev_pythonpath diff --git a/tests/test_cli/test_serve.py b/tests/test_cli/test_serve.py index 53baacf88e..92785d2d3f 100644 --- a/tests/test_cli/test_serve.py +++ b/tests/test_cli/test_serve.py @@ -77,6 +77,39 @@ def test_serve_uses_manifest_port_when_omitted() -> None: @pytest.mark.integration @pytest.mark.parametrize("reload", [False, True]) def test_serve_echo_env_health_subprocess(reload) -> None: + _serve_health_subprocess(ECHO_ENV, reload) + + +@pytest.mark.integration +@pytest.mark.parametrize("reload", [False, True]) +@pytest.mark.parametrize( + "app_spec", + ["server.app:app", "sample_env.server.app:app", "envs.sample_env.server.app:app"], +) +def test_serve_manifest_import_layouts(tmp_path, app_spec, reload): + env_dir = tmp_path / "envs" / "sample_env" + (env_dir / "server").mkdir(parents=True) + for name in ( + "__init__.py", + "client.py", + "README.md", + "pyproject.toml", + "server/__init__.py", + "server/Dockerfile", + ): + (env_dir / name).touch() + (env_dir / "openenv.yaml").write_text(f"app: {app_spec}\nruntime: fastapi\n") + (env_dir / "server/app.py").write_text( + "from fastapi import FastAPI\n" + "from pathlib import Path\n" + "app = FastAPI()\n" + "@app.get('/health')\n" + "def health(): return {'cwd': str(Path.cwd())}\n" + ) + assert _serve_health_subprocess(env_dir, reload) == {"cwd": str(env_dir)} + + +def _serve_health_subprocess(env_dir, reload): requests = pytest.importorskip("requests") port = _pick_free_port() env = os.environ.copy() @@ -86,7 +119,7 @@ def test_serve_echo_env_health_subprocess(reload) -> None: "-m", "openenv.cli", "serve", - str(ECHO_ENV), + str(env_dir), "--port", str(port), "--host", @@ -114,6 +147,7 @@ def test_serve_echo_env_health_subprocess(reload) -> None: r = requests.get(f"http://127.0.0.1:{port}/health", timeout=2.0) if r.status_code == 200: ok = True + response = r.json() break except Exception as exc: last_exc = exc @@ -136,6 +170,32 @@ def test_serve_echo_env_health_subprocess(reload) -> None: proc.kill() proc.wait(timeout=5) output.close() + return response + + +def test_serve_does_not_add_unrelated_ancestor_source_paths(tmp_path, monkeypatch): + (tmp_path / "src/openenv").mkdir(parents=True) + env_dir = tmp_path / "work/sample_env" + env_dir.mkdir(parents=True) + for name in ( + "__init__.py", + "client.py", + "README.md", + "pyproject.toml", + "Dockerfile", + ): + (env_dir / name).touch() + (env_dir / "openenv.yaml").write_text("app: server.app:app\n") + monkeypatch.setenv("PYTHONPATH", "existing-python-path") + + def inspect_paths(*args, **kwargs): + assert str(tmp_path) not in sys.path + assert str(tmp_path / "src") not in sys.path + assert os.environ["PYTHONPATH"] == "existing-python-path" + + with patch("uvicorn.run", side_effect=inspect_paths): + result = runner.invoke(app, ["serve", str(env_dir)]) + assert result.exit_code == 0, result.exception def test_serve_defaults_to_loopback_and_restores_process_state(monkeypatch): From 79dcabd79051aead2f0dfe724ff165be6002650f Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Mon, 19 Oct 2026 14:43:53 -0700 Subject: [PATCH 12/12] fix: disambiguate serve package paths --- src/openenv/cli/commands/serve.py | 17 ++++++++++++----- tests/test_cli/test_serve.py | 17 +++++++++++++---- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/openenv/cli/commands/serve.py b/src/openenv/cli/commands/serve.py index a7b694e4f7..c2d0e32438 100644 --- a/src/openenv/cli/commands/serve.py +++ b/src/openenv/cli/commands/serve.py @@ -114,12 +114,19 @@ def serve( env_root = str(env_path_obj) app_dir = env_path_obj module_name = app_spec.split(":", 1)[0] - if module_name.startswith(f"{env_path_obj.name}."): - app_dir = env_path_obj.parent - elif env_path_obj.parent.name == "envs" and module_name.startswith( - f"envs.{env_path_obj.name}." + # 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() ): - app_dir = env_path_obj.parent.parent + 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) diff --git a/tests/test_cli/test_serve.py b/tests/test_cli/test_serve.py index 92785d2d3f..845b4fbdcf 100644 --- a/tests/test_cli/test_serve.py +++ b/tests/test_cli/test_serve.py @@ -83,11 +83,20 @@ def test_serve_echo_env_health_subprocess(reload) -> None: @pytest.mark.integration @pytest.mark.parametrize("reload", [False, True]) @pytest.mark.parametrize( - "app_spec", - ["server.app:app", "sample_env.server.app:app", "envs.sample_env.server.app:app"], + ("env_name", "app_spec"), + [ + ("sample_env", "server.app:app"), + ("sample_env", "sample_env.server.app:app"), + ("sample_env", "envs.sample_env.server.app:app"), + ("server", "server.app:app"), + ("server", "server.server.app:app"), + ("server", "envs.server.server.app:app"), + ("envs", "envs.server.app:app"), + ("envs", "envs.envs.server.app:app"), + ], ) -def test_serve_manifest_import_layouts(tmp_path, app_spec, reload): - env_dir = tmp_path / "envs" / "sample_env" +def test_serve_manifest_import_layouts(tmp_path, env_name, app_spec, reload): + env_dir = tmp_path / "envs" / env_name (env_dir / "server").mkdir(parents=True) for name in ( "__init__.py",