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 1/6] 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 55a5cf7f0..195391c48 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 000000000..ab029940a --- /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 b80e5b9fd..72fd2d6a5 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 df2bfa5a3..9c2b23b77 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 56c89bb62..e5621b194 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 7abda4fb4..212e99a1e 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 000000000..bf33d113a --- /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 2/6] 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 bf33d113a..d8f41aec0 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 3/6] 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 9c2b23b77..a2921625a 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 e5621b194..56c89bb62 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 212e99a1e..7abda4fb4 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 d8f41aec0..6dcb961d2 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 4/6] 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 a2921625a..da5fb15e3 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 6dcb961d2..73415bc31 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 5/6] 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 ab029940a..533ffe5f9 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 c0f8a3aef..509b64846 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 da5fb15e3..86bdbb76b 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 73415bc31..e1fa76d46 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 6/6] 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 86bdbb76b..519f32033 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 e1fa76d46..4bb44b502 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: