Skip to content

Commit 817a288

Browse files
Bump pydantic (#1593)
Co-authored-by: openhands <[email protected]>
1 parent 1fbf867 commit 817a288

File tree

4 files changed

+128
-248
lines changed

4 files changed

+128
-248
lines changed

openhands-sdk/openhands/sdk/workspace/remote/async_remote_workspace.py

Lines changed: 5 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
import asyncio
2-
import json
31
from collections.abc import Generator
42
from pathlib import Path
53
from typing import Any
64

75
import httpx
86
from pydantic import PrivateAttr
9-
from websockets import connect
107

118
from openhands.sdk.git.models import GitChange, GitDiff
129
from openhands.sdk.workspace.models import CommandResult, FileOperationResult
@@ -64,8 +61,8 @@ async def execute_command(
6461
) -> CommandResult:
6562
"""Execute a bash command on the remote system.
6663
67-
This method conneccts a websocket client, sends a bash command, and
68-
then waits for the output until the command completes.
64+
This method starts a bash command via the remote agent server API,
65+
then polls for the output until the command completes.
6966
7067
Args:
7168
command: The bash command to execute
@@ -75,66 +72,9 @@ async def execute_command(
7572
Returns:
7673
CommandResult: Result with stdout, stderr, exit_code, and other metadata
7774
"""
78-
try:
79-
result = await asyncio.wait_for(
80-
self._execute_command(command, cwd, timeout), timeout=timeout
81-
)
82-
return result
83-
except TimeoutError:
84-
return CommandResult(
85-
command=command,
86-
exit_code=-1,
87-
stdout="",
88-
stderr="",
89-
timeout_occurred=True,
90-
)
91-
92-
async def _execute_command(
93-
self,
94-
command: str,
95-
cwd: str | Path | None,
96-
timeout: float,
97-
):
98-
# Convert http(s) scheme to ws(s) for websocket connection
99-
ws_host = self.host.replace("https://", "wss://").replace("http://", "ws://")
100-
url = f"{ws_host}/sockets/bash-events?session_api_key={self.api_key}"
101-
async with connect(url) as websocket:
102-
payload = {
103-
"command": command,
104-
"timeout": int(timeout),
105-
}
106-
if cwd:
107-
payload["cwd"] = str(cwd)
108-
await websocket.send(json.dumps(payload))
109-
command_id: str | None = None
110-
stdout_parts: list[str] = []
111-
stderr_parts: list[str] = []
112-
exit_code: int | None = None
113-
while exit_code is None:
114-
data = await websocket.recv()
115-
event = json.loads(data)
116-
if event.get("kind") == "BashCommand":
117-
if command_id is None and event.get("command") == command:
118-
command_id = event.get("id")
119-
if event.get("kind") == "BashOutput":
120-
if event.get("command_id") == command_id:
121-
if event.get("stdout"):
122-
stdout_parts.append(event.get("stdout"))
123-
if event.get("stderr"):
124-
stderr_parts.append(event.get("stderr"))
125-
exit_code = event.get("exit_code")
126-
127-
# Combine all output parts
128-
stdout = "".join(stdout_parts)
129-
stderr = "".join(stderr_parts)
130-
131-
return CommandResult(
132-
command=command,
133-
exit_code=exit_code,
134-
stdout=stdout,
135-
stderr=stderr,
136-
timeout_occurred=exit_code == -1 and "timed out" in stderr,
137-
)
75+
generator = self._execute_command_generator(command, cwd, timeout)
76+
result = await self._execute(generator)
77+
return result
13878

13979
async def file_upload(
14080
self,

openhands-sdk/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ dependencies = [
99
"fastmcp>=2.11.3",
1010
"httpx>=0.27.0",
1111
"litellm>=1.80.10",
12-
"pydantic>=2.11.7",
12+
"pydantic>=2.12.5",
1313
"python-frontmatter>=1.1.0",
1414
"python-json-logger>=3.3.0",
1515
"tenacity>=9.1.2",

tests/sdk/workspace/remote/test_async_remote_workspace.py

Lines changed: 55 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Unit tests for AsyncRemoteWorkspace class."""
22

33
import asyncio
4-
import json
54
from pathlib import Path
65
from unittest.mock import AsyncMock, Mock, patch
76

@@ -104,59 +103,32 @@ def test_generator():
104103

105104

106105
@pytest.mark.asyncio
107-
async def test_async_execute_command():
108-
"""Test execute_command method with websocket mocking."""
106+
@patch(
107+
"openhands.sdk.workspace.remote.async_remote_workspace.AsyncRemoteWorkspace._execute"
108+
)
109+
async def test_async_execute_command(mock_execute):
110+
"""Test execute_command method calls _execute with correct generator."""
109111
workspace = AsyncRemoteWorkspace(
110-
host="http://localhost:8000", api_key="test-key", working_dir="workspace"
112+
host="http://localhost:8000", working_dir="workspace"
111113
)
112114

113-
# Create mock websocket
114-
mock_websocket = AsyncMock()
115-
mock_websocket.send = AsyncMock()
116-
# Simulate server responses: BashCommand event followed by BashOutput event
117-
mock_websocket.recv = AsyncMock(
118-
side_effect=[
119-
json.dumps({"kind": "BashCommand", "command": "echo hello", "id": "cmd-1"}),
120-
json.dumps(
121-
{
122-
"kind": "BashOutput",
123-
"command_id": "cmd-1",
124-
"stdout": "hello\n",
125-
"stderr": "",
126-
"exit_code": 0,
127-
}
128-
),
129-
]
115+
expected_result = CommandResult(
116+
command="echo hello",
117+
exit_code=0,
118+
stdout="hello\n",
119+
stderr="",
120+
timeout_occurred=False,
130121
)
122+
mock_execute.return_value = expected_result
131123

132-
mock_connect = AsyncMock()
133-
mock_connect.__aenter__.return_value = mock_websocket
134-
mock_connect.__aexit__.return_value = None
135-
136-
with patch(
137-
"openhands.sdk.workspace.remote.async_remote_workspace.connect",
138-
return_value=mock_connect,
139-
) as patched_connect:
140-
result = await workspace.execute_command("echo hello", cwd="/tmp", timeout=30.0)
141-
142-
assert result.command == "echo hello"
143-
assert result.exit_code == 0
144-
assert result.stdout == "hello\n"
145-
assert result.stderr == ""
146-
assert result.timeout_occurred is False
124+
result = await workspace.execute_command("echo hello", cwd="/tmp", timeout=30.0)
147125

148-
# Verify connect was called with ws:// scheme (converted from http://)
149-
patched_connect.assert_called_once()
150-
ws_url = patched_connect.call_args[0][0]
151-
assert ws_url.startswith("ws://")
152-
assert "localhost:8000/sockets/bash-events" in ws_url
126+
assert result == expected_result
127+
mock_execute.assert_called_once()
153128

154-
# Verify websocket was called with correct payload
155-
mock_websocket.send.assert_called_once()
156-
sent_payload = json.loads(mock_websocket.send.call_args[0][0])
157-
assert sent_payload["command"] == "echo hello"
158-
assert sent_payload["timeout"] == 30
159-
assert sent_payload["cwd"] == "/tmp"
129+
# Verify the generator was created correctly
130+
generator_arg = mock_execute.call_args[0][0]
131+
assert hasattr(generator_arg, "__next__")
160132

161133

162134
@pytest.mark.asyncio
@@ -217,56 +189,25 @@ async def test_async_file_download(mock_execute):
217189

218190
@pytest.mark.asyncio
219191
async def test_async_execute_command_with_path_objects():
220-
"""Test execute_command works with Path objects for cwd and https:// to wss://."""
192+
"""Test execute_command works with Path objects for cwd."""
221193
workspace = AsyncRemoteWorkspace(
222-
host="https://example.com", api_key="test-key", working_dir="workspace"
223-
)
224-
225-
# Create mock websocket
226-
mock_websocket = AsyncMock()
227-
mock_websocket.send = AsyncMock()
228-
mock_websocket.recv = AsyncMock(
229-
side_effect=[
230-
json.dumps({"kind": "BashCommand", "command": "ls", "id": "cmd-2"}),
231-
json.dumps(
232-
{
233-
"kind": "BashOutput",
234-
"command_id": "cmd-2",
235-
"stdout": "file1.txt\n",
236-
"stderr": "",
237-
"exit_code": 0,
238-
}
239-
),
240-
]
194+
host="http://localhost:8000", working_dir="workspace"
241195
)
242196

243-
mock_connect = AsyncMock()
244-
mock_connect.__aenter__.return_value = mock_websocket
245-
mock_connect.__aexit__.return_value = None
197+
with patch.object(workspace, "_execute") as mock_execute:
198+
expected_result = CommandResult(
199+
command="ls",
200+
exit_code=0,
201+
stdout="file1.txt\n",
202+
stderr="",
203+
timeout_occurred=False,
204+
)
205+
mock_execute.return_value = expected_result
246206

247-
with patch(
248-
"openhands.sdk.workspace.remote.async_remote_workspace.connect",
249-
return_value=mock_connect,
250-
) as patched_connect:
251207
result = await workspace.execute_command("ls", cwd=Path("/tmp/test"))
252208

253-
assert result.command == "ls"
254-
assert result.exit_code == 0
255-
assert result.stdout == "file1.txt\n"
256-
assert result.stderr == ""
257-
assert result.timeout_occurred is False
258-
259-
# Verify connect was called with wss:// scheme (converted from https://)
260-
patched_connect.assert_called_once()
261-
ws_url = patched_connect.call_args[0][0]
262-
assert ws_url.startswith("wss://")
263-
assert "example.com/sockets/bash-events" in ws_url
264-
265-
# Verify Path object was converted to string in the payload
266-
mock_websocket.send.assert_called_once()
267-
sent_payload = json.loads(mock_websocket.send.call_args[0][0])
268-
assert sent_payload["command"] == "ls"
269-
assert sent_payload["cwd"] == "/tmp/test" # Path is converted to string
209+
assert result == expected_result
210+
mock_execute.assert_called_once()
270211

271212

272213
@pytest.mark.asyncio
@@ -399,33 +340,29 @@ async def test_async_concurrent_operations():
399340
host="http://localhost:8000", working_dir="workspace"
400341
)
401342

402-
# Mock different results for different operations
403-
command_result = CommandResult(
404-
command="echo test",
405-
exit_code=0,
406-
stdout="test\n",
407-
stderr="",
408-
timeout_occurred=False,
409-
)
410-
upload_result = FileOperationResult(
411-
success=True,
412-
source_path="/local/file1.txt",
413-
destination_path="/remote/file1.txt",
414-
file_size=50,
415-
)
416-
download_result = FileOperationResult(
417-
success=True,
418-
source_path="/remote/file2.txt",
419-
destination_path="/local/file2.txt",
420-
file_size=75,
421-
)
343+
with patch.object(workspace, "_execute") as mock_execute:
344+
# Mock different results for different operations
345+
command_result = CommandResult(
346+
command="echo test",
347+
exit_code=0,
348+
stdout="test\n",
349+
stderr="",
350+
timeout_occurred=False,
351+
)
352+
upload_result = FileOperationResult(
353+
success=True,
354+
source_path="/local/file1.txt",
355+
destination_path="/remote/file1.txt",
356+
file_size=50,
357+
)
358+
download_result = FileOperationResult(
359+
success=True,
360+
source_path="/remote/file2.txt",
361+
destination_path="/local/file2.txt",
362+
file_size=75,
363+
)
422364

423-
with (
424-
patch.object(workspace, "_execute_command") as mock_execute_command,
425-
patch.object(workspace, "_execute") as mock_execute,
426-
):
427-
mock_execute_command.return_value = command_result
428-
mock_execute.side_effect = [upload_result, download_result]
365+
mock_execute.side_effect = [command_result, upload_result, download_result]
429366

430367
# Run operations concurrently
431368
tasks = [
@@ -439,5 +376,4 @@ async def test_async_concurrent_operations():
439376
assert results[0] == command_result
440377
assert results[1] == upload_result
441378
assert results[2] == download_result
442-
mock_execute_command.assert_called_once()
443-
assert mock_execute.call_count == 2
379+
assert mock_execute.call_count == 3

0 commit comments

Comments
 (0)