Skip to content

Commit 85f5fc9

Browse files
author
Dylan Huang
committed
browser_utils.py
1 parent acb168f commit 85f5fc9

File tree

5 files changed

+288
-16
lines changed

5 files changed

+288
-16
lines changed

.vscode/launch.json

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,10 @@
2525
}
2626
},
2727
{
28-
"name": "Python: Debug Module",
28+
"name": "Python: Debug Logs Server",
2929
"type": "python",
3030
"request": "launch",
31-
"module": "eval_protocol",
32-
"console": "integratedTerminal",
33-
"justMyCode": false,
34-
"env": {
35-
"PYTHONPATH": "${workspaceFolder}"
36-
}
37-
},
38-
{
39-
"name": "Python: Debug Logs Server (Uvicorn)",
40-
"type": "python",
41-
"request": "launch",
42-
"module": "uvicorn",
43-
"args": ["eval_protocol.utils.logs_server:app", "--reload"],
31+
"module": "eval_protocol.utils.logs_server",
4432
"console": "integratedTerminal",
4533
"justMyCode": false,
4634
"env": {

eval_protocol/pytest/evaluation_test.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@
6262
run_tasks_with_eval_progress,
6363
run_tasks_with_run_progress,
6464
)
65-
from eval_protocol.utils.show_results_url import store_local_ui_results_url
65+
from eval_protocol.utils.show_results_url import store_local_ui_results_url, generate_invocation_filter_url
66+
from eval_protocol.utils.browser_utils import is_logs_server_running, open_browser_tab
6667

6768
from ..common_utils import load_jsonl
6869

@@ -80,6 +81,7 @@ def evaluation_test(
8081
rollout_processor_kwargs: RolloutProcessorInputParam | None = None,
8182
aggregation_method: AggregationMethod = "mean",
8283
passed_threshold: EvaluationThreshold | float | EvaluationThresholdDict | None = None,
84+
disable_browser_open: bool = False,
8385
num_runs: int = 1,
8486
filtered_row_ids: Sequence[str] | None = None,
8587
max_dataset_rows: int | None = None,
@@ -246,10 +248,29 @@ def create_wrapper_with_signature() -> Callable[[], None]:
246248
else:
247249
invocation_id = generate_id()
248250

251+
# Track whether we've opened browser for this invocation
252+
browser_opened_for_invocation = False
253+
249254
async def wrapper_body(**kwargs: Unpack[ParameterizedTestKwargs]) -> None:
255+
nonlocal browser_opened_for_invocation
256+
250257
# Store URL for viewing results (after all postprocessing is complete)
251258
store_local_ui_results_url(invocation_id)
252259

260+
# Auto-open browser if server is running and not disabled (only once per invocation)
261+
if (
262+
not browser_opened_for_invocation
263+
and not disable_browser_open
264+
and os.environ.get("EP_DISABLE_AUTO_BROWSER") is None
265+
):
266+
is_running, port = is_logs_server_running()
267+
if is_running:
268+
# Generate URL for table view with invocation filter
269+
base_url = f"http://localhost:{port}" if port else "http://localhost:8000"
270+
table_url = generate_invocation_filter_url(invocation_id, f"{base_url}/table")
271+
open_browser_tab(table_url)
272+
browser_opened_for_invocation = True
273+
253274
eval_metadata = None
254275

255276
all_results: list[list[EvaluationRow]] = [[] for _ in range(num_runs)]
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""
2+
Browser utilities for auto-opening evaluation results in the local UI.
3+
"""
4+
5+
import json
6+
import os
7+
import threading
8+
import time
9+
import webbrowser
10+
from pathlib import Path
11+
from typing import Tuple, Optional
12+
13+
try:
14+
import psutil
15+
16+
PSUTIL_AVAILABLE = True
17+
except ImportError:
18+
PSUTIL_AVAILABLE = False
19+
20+
21+
def _get_pid_file_path() -> Path:
22+
"""Get the path to the logs server PID file."""
23+
from eval_protocol.directory_utils import find_eval_protocol_dir
24+
25+
return Path(find_eval_protocol_dir()) / "logs_server.pid"
26+
27+
28+
def write_pid_file(pid: int, port: int) -> None:
29+
"""
30+
Write the server PID and port to a file for external processes to check.
31+
32+
Args:
33+
pid: The process ID of the logs server
34+
port: The port the server is running on
35+
"""
36+
try:
37+
pid_file = _get_pid_file_path()
38+
39+
data = {"pid": pid, "port": port}
40+
41+
with open(pid_file, "w") as f:
42+
json.dump(data, f)
43+
44+
# Use print instead of logger to avoid circular imports
45+
print(f"Wrote PID file: {pid_file} with PID {pid} and port {port}")
46+
except Exception as e:
47+
print(f"Warning: Failed to write PID file: {e}")
48+
49+
50+
def is_logs_server_running() -> Tuple[bool, Optional[int]]:
51+
"""
52+
Check if the logs server is running by reading the PID file and verifying the process.
53+
54+
Returns:
55+
Tuple of (is_running, port) where:
56+
- is_running: True if server is running, False otherwise
57+
- port: The port the server is running on, or None if not running
58+
"""
59+
if not PSUTIL_AVAILABLE:
60+
return False, None
61+
62+
pid_file = _get_pid_file_path()
63+
if not pid_file.exists():
64+
return False, None
65+
66+
try:
67+
with open(pid_file, "r") as f:
68+
data = json.load(f)
69+
pid = data.get("pid")
70+
port = data.get("port")
71+
except (json.JSONDecodeError, KeyError, FileNotFoundError):
72+
return False, None
73+
74+
if pid is None:
75+
return False, None
76+
77+
try:
78+
# Check if the process is still running
79+
process = psutil.Process(pid)
80+
if not process.is_running():
81+
return False, None
82+
83+
# Optionally verify it's listening on the expected port
84+
if port is not None:
85+
try:
86+
connections = process.net_connections()
87+
for conn in connections:
88+
if conn.laddr.port == port and conn.status == "LISTEN":
89+
return True, port
90+
except (psutil.AccessDenied, psutil.NoSuchProcess):
91+
# If we can't check connections, assume it's running if process exists
92+
pass
93+
94+
return True, port
95+
except (psutil.NoSuchProcess, psutil.AccessDenied):
96+
return False, None
97+
98+
99+
def open_browser_tab(url: str, delay: float = 0.5) -> None:
100+
"""
101+
Open a URL in a new browser tab with an optional delay.
102+
103+
Args:
104+
url: The URL to open
105+
delay: Delay in seconds before opening browser (default: 0.5)
106+
"""
107+
108+
def _open():
109+
time.sleep(delay) # Give the server time to start
110+
webbrowser.open_new_tab(url)
111+
112+
thread = threading.Thread(target=_open)
113+
thread.daemon = True
114+
thread.start()

eval_protocol/utils/logs_server.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import time
77
from datetime import datetime
88
from contextlib import asynccontextmanager
9+
from pathlib import Path
910
from queue import Queue
1011
from typing import TYPE_CHECKING, Any, Dict, List, Optional
1112

@@ -23,6 +24,7 @@
2324
from eval_protocol.log_utils.elasticsearch_client import ElasticsearchClient
2425
from eval_protocol.types.remote_rollout_processor import ElasticsearchConfig
2526
from eval_protocol.utils.logs_models import LogEntry, LogsResponse
27+
from eval_protocol.utils.browser_utils import write_pid_file
2628

2729
if TYPE_CHECKING:
2830
from eval_protocol.models import EvaluationRow
@@ -378,7 +380,7 @@ def __init__(
378380
event_bus.subscribe(self._handle_event)
379381
logger.debug("[LOGS_SERVER_INIT] Successfully subscribed to event bus")
380382

381-
logger.info(f"[LOGS_SERVER_INIT] LogsServer initialized on {host}:{port}")
383+
logger.info(f"[LOGS_SERVER_INIT] LogsServer initialized on {self.host}:{self.port}")
382384

383385
def _setup_websocket_routes(self):
384386
"""Set up WebSocket routes for real-time communication."""
@@ -541,6 +543,12 @@ async def run_async(self):
541543
)
542544

543545
server = uvicorn.Server(config)
546+
547+
# Write PID file after server is configured but before serving
548+
logger.debug(f"[LOGS_SERVER_RUN_ASYNC] Writing PID file for port {self.port}")
549+
write_pid_file(os.getpid(), self.port)
550+
logger.debug(f"[LOGS_SERVER_RUN_ASYNC] Successfully wrote PID file for port {self.port}")
551+
544552
await server.serve()
545553

546554
except KeyboardInterrupt:

tests/test_show_results_url.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
from unittest.mock import patch, MagicMock
77
import pytest
88

9+
try:
10+
import psutil
11+
12+
PSUTIL_AVAILABLE = True
13+
except ImportError:
14+
PSUTIL_AVAILABLE = False
15+
916
from eval_protocol.utils.show_results_url import (
1017
is_server_running,
1118
generate_invocation_filter_url,
@@ -193,3 +200,137 @@ def test_full_workflow_stores_urls(self, mock_store):
193200
assert "table" in call_args[2]
194201
assert "integration-test" in call_args[1]
195202
assert "integration-test" in call_args[2]
203+
204+
205+
class TestBrowserUtilities:
206+
"""Test browser utility functions."""
207+
208+
def test_get_pid_file_path(self):
209+
"""Test PID file path generation."""
210+
from eval_protocol.utils.browser_utils import _get_pid_file_path
211+
from eval_protocol.directory_utils import find_eval_protocol_dir
212+
from pathlib import Path
213+
214+
pid_file = _get_pid_file_path()
215+
expected = Path(find_eval_protocol_dir()) / "logs_server.pid"
216+
assert pid_file == expected
217+
218+
def test_is_logs_server_running_no_pid_file(self, tmp_path, monkeypatch):
219+
"""Test server detection when PID file doesn't exist."""
220+
from eval_protocol.utils.browser_utils import is_logs_server_running
221+
222+
# Mock the PID file path to a non-existent file
223+
monkeypatch.setattr(
224+
"eval_protocol.utils.browser_utils._get_pid_file_path", lambda: tmp_path / "nonexistent.pid"
225+
)
226+
227+
is_running, port = is_logs_server_running()
228+
assert not is_running
229+
assert port is None
230+
231+
def test_is_logs_server_running_invalid_pid_file(self, tmp_path, monkeypatch):
232+
"""Test server detection with invalid PID file content."""
233+
from eval_protocol.utils.browser_utils import is_logs_server_running
234+
235+
# Create invalid PID file
236+
pid_file = tmp_path / "invalid.pid"
237+
pid_file.write_text("invalid json")
238+
monkeypatch.setattr("eval_protocol.utils.browser_utils._get_pid_file_path", lambda: pid_file)
239+
240+
is_running, port = is_logs_server_running()
241+
assert not is_running
242+
assert port is None
243+
244+
def test_is_logs_server_running_missing_pid_key(self, tmp_path, monkeypatch):
245+
"""Test server detection with PID file missing required keys."""
246+
from eval_protocol.utils.browser_utils import is_logs_server_running
247+
import json
248+
249+
# Create PID file with missing pid key
250+
pid_file = tmp_path / "missing_pid.pid"
251+
pid_file.write_text(json.dumps({"port": 8000}))
252+
monkeypatch.setattr("eval_protocol.utils.browser_utils._get_pid_file_path", lambda: pid_file)
253+
254+
is_running, port = is_logs_server_running()
255+
assert not is_running
256+
assert port is None
257+
258+
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
259+
def test_is_logs_server_running_nonexistent_process(self, tmp_path, monkeypatch):
260+
"""Test server detection with PID file pointing to non-existent process."""
261+
from eval_protocol.utils.browser_utils import is_logs_server_running
262+
import json
263+
264+
# Create PID file with non-existent PID
265+
pid_file = tmp_path / "nonexistent_process.pid"
266+
pid_file.write_text(json.dumps({"pid": 999999, "port": 8000}))
267+
monkeypatch.setattr("eval_protocol.utils.browser_utils._get_pid_file_path", lambda: pid_file)
268+
269+
is_running, port = is_logs_server_running()
270+
assert not is_running
271+
assert port is None
272+
273+
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
274+
def test_is_logs_server_running_current_process(self, tmp_path, monkeypatch):
275+
"""Test server detection with PID file pointing to current process."""
276+
from eval_protocol.utils.browser_utils import is_logs_server_running
277+
import json
278+
import os
279+
280+
# Create PID file with current process PID
281+
pid_file = tmp_path / "current_process.pid"
282+
pid_file.write_text(json.dumps({"pid": os.getpid(), "port": 8000}))
283+
monkeypatch.setattr("eval_protocol.utils.browser_utils._get_pid_file_path", lambda: pid_file)
284+
285+
is_running, port = is_logs_server_running()
286+
assert is_running
287+
assert port == 8000
288+
289+
def test_open_browser_tab(self, monkeypatch):
290+
"""Test browser tab opening."""
291+
from eval_protocol.utils.browser_utils import open_browser_tab
292+
293+
opened_urls = []
294+
295+
def mock_open_new_tab(url):
296+
opened_urls.append(url)
297+
298+
monkeypatch.setattr("webbrowser.open_new_tab", mock_open_new_tab)
299+
300+
# Test with delay
301+
open_browser_tab("http://example.com", delay=0.01)
302+
303+
# Wait a bit for the thread to execute
304+
import time
305+
306+
time.sleep(0.02)
307+
308+
assert len(opened_urls) == 1
309+
assert opened_urls[0] == "http://example.com"
310+
311+
312+
class TestLogsServerPidFile:
313+
"""Test logs server PID file functionality."""
314+
315+
def test_write_pid_file(self, tmp_path, monkeypatch):
316+
"""Test PID file writing."""
317+
from eval_protocol.utils.browser_utils import write_pid_file
318+
import json
319+
320+
# Mock the find_eval_protocol_dir function
321+
monkeypatch.setattr("eval_protocol.directory_utils.find_eval_protocol_dir", lambda: str(tmp_path))
322+
323+
# Test writing PID file
324+
write_pid_file(12345, 8000)
325+
326+
# Check that PID file was created
327+
pid_file = tmp_path / "logs_server.pid"
328+
assert pid_file.exists()
329+
330+
# Check content
331+
with open(pid_file, "r") as f:
332+
data = json.load(f)
333+
assert "pid" in data
334+
assert "port" in data
335+
assert data["port"] == 8000
336+
assert data["pid"] == 12345

0 commit comments

Comments
 (0)