-
Notifications
You must be signed in to change notification settings - Fork 390
feat(cli): add openenv serve for local uvicorn #668
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| """ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| # 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() | ||
|
|
||
|
|
||
| @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)) | ||
| 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: | ||
| # 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}") | ||
|
Comment on lines
+137
to
+150
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the 60-second deadline expires with Prompt To Fix With AIThis is a comment left during a code review.
Path: tests/test_cli/test_serve.py
Line: 124-126
Comment:
**`proc.stdout.read()` deadlocks when the server never becomes healthy**
When the 60-second deadline expires with `ok = False`, line 125 calls `proc.stdout.read()` on a pipe connected to a still-running process. Because nothing has terminated the subprocess yet (`proc.terminate()` is in the `finally` block, which only executes *after* control leaves the `try` body), the read blocks indefinitely — `pytest.fail` is never reached and neither is the `finally` cleanup. Terminate the process before reading its output.
How can I resolve this? If you propose a fix, please make it concise.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in b56360c commit |
||
| finally: | ||
| if proc.poll() is None: | ||
| proc.terminate() | ||
| try: | ||
| proc.wait(timeout=15) | ||
| except subprocess.TimeoutExpired: | ||
| proc.kill() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sys.path.insertandos.chdirare irreversible global side effects. Becausetyper.testing.CliRunnerruns the command in the same process (not a subprocess), every unit test that callsrunner.invokewithservewill permanently move the test runner's CWD toenv_rootand prepend entries tosys.path. Tests that depend on the original CWD or a clean path (including subsequent tests in this very module) can silently break or import the wrong module version. The cleanest fix is to exec a child process so all global-state mutations are isolated, or add an autouse fixture that saves and restoresos.getcwd()andsys.path.Prompt To Fix With AI
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in b56360c commit.