Skip to content

Commit 6a16716

Browse files
authored
Fix for sandboxed Python coder unit test (#466)
1 parent 39aa0cb commit 6a16716

File tree

2 files changed

+142
-82
lines changed

2 files changed

+142
-82
lines changed

src/forge/actors/coder.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
logger.setLevel(logging.DEBUG)
1919

2020

21-
class SandboxedPythonCoder(ForgeActor):
21+
class _SandboxedPythonCoder:
2222
"""A sandboxed code execution environment using enroot containers.
2323
2424
This is a proof of concept of using enroot to provided a sandboxed
@@ -57,13 +57,12 @@ def __init__(
5757
self.container_name = container_name
5858
self._initialized = False
5959

60-
@endpoint
6160
async def setup(self):
61+
"""Setup the sandboxed environment."""
6262
logging.debug("Setting up sandboxed actor")
6363
await self._maybe_create_image()
6464
self._recreate()
6565

66-
@endpoint
6766
async def recreate(self):
6867
"""Recreates the container instance from the base image."""
6968
self._recreate()
@@ -110,7 +109,6 @@ def _recreate(self):
110109
self._initialized = True
111110
logging.debug("Successfully initialized container")
112111

113-
@endpoint
114112
async def execute(self, code: str) -> tuple[str, str]:
115113
"""Executes Python code inside the container and returns the output.
116114
@@ -149,3 +147,51 @@ async def execute(self, code: str) -> tuple[str, str]:
149147
output = result.stdout
150148
error = result.stderr
151149
return output, error
150+
151+
152+
class SandboxedPythonCoder(ForgeActor):
153+
"""Monarch actor wrapper for _SandboxedPythonCoder.
154+
155+
This is a thin wrapper that makes the sandboxed Python coder available
156+
as a distributed Monarch actor. All business logic is in _SandboxedPythonCoder.
157+
158+
Args:
159+
docker_image: Docker image URL to import (e.g., "docker://python:3.10").
160+
sqsh_image_path: Local filesystem path where the enroot .sqsh image will be stored.
161+
container_name: Unique name for the enroot container instance.
162+
"""
163+
164+
def __init__(
165+
self,
166+
docker_image: str = "docker://python:3.10",
167+
sqsh_image_path: str = "python-image.sqsh",
168+
container_name: str = "sandbox",
169+
):
170+
self._coder = _SandboxedPythonCoder(
171+
docker_image=docker_image,
172+
sqsh_image_path=sqsh_image_path,
173+
container_name=container_name,
174+
)
175+
176+
@endpoint
177+
async def setup(self):
178+
"""Setup the sandboxed environment."""
179+
return await self._coder.setup()
180+
181+
@endpoint
182+
async def recreate(self):
183+
"""Recreate the container instance from the base image."""
184+
return await self._coder.recreate()
185+
186+
@endpoint
187+
async def execute(self, code: str) -> tuple[str, str]:
188+
"""Execute Python code inside the container.
189+
190+
Args:
191+
code: Python source code string to execute.
192+
193+
Returns:
194+
The captured stdout and stderr from the execution, as a
195+
(stdout, stderr) tuple of strings.
196+
"""
197+
return await self._coder.execute(code)

tests/unit_tests/test_coder.py

Lines changed: 92 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -10,104 +10,118 @@
1010
import os
1111
import tempfile
1212
import uuid
13-
from contextlib import asynccontextmanager
1413
from unittest.mock import Mock, patch
1514

1615
import pytest
17-
from forge.actors.coder import SandboxedPythonCoder
1816

19-
from monarch.actor import this_proc
17+
from forge.actors.coder import _SandboxedPythonCoder
2018

2119

22-
@asynccontextmanager
23-
async def create_mock_coder(
24-
execute_stdout="hello world\n",
25-
execute_returncode=0,
26-
execute_stderr="",
27-
import_fails=False,
28-
create_fails=False,
29-
):
30-
"""Context manager that creates a mocked SandboxedPythonCoder."""
20+
@pytest.mark.asyncio
21+
async def test_coder_success():
22+
"""Test successful execution."""
3123
unique_id = str(uuid.uuid4())[:8]
3224
container_name = f"test_sandbox_{unique_id}"
3325

3426
with tempfile.NamedTemporaryFile(suffix=".sqsh", delete=False) as temp_image:
3527
image_path = temp_image.name
3628

37-
coder = None
29+
def mock_subprocess_run(*args, **kwargs):
30+
"""Mock subprocess.run for testing."""
31+
cmd = args[0] if args else kwargs.get("args", [])
32+
cmd_str = " ".join(cmd) if isinstance(cmd, list) else str(cmd)
33+
34+
if "import" in cmd_str:
35+
result = Mock()
36+
result.returncode = 0
37+
result.stderr = ""
38+
return result
39+
elif "remove" in cmd_str:
40+
result = Mock()
41+
result.returncode = 0
42+
return result
43+
elif "create" in cmd_str:
44+
result = Mock()
45+
result.returncode = 0
46+
result.stderr = ""
47+
return result
48+
elif "start" in cmd_str:
49+
result = Mock()
50+
result.returncode = 0
51+
result.stdout = "Hello World\n"
52+
result.stderr = ""
53+
return result
54+
else:
55+
raise ValueError(f"Unexpected subprocess call: {cmd_str}")
56+
3857
try:
39-
with patch("subprocess.run") as mock_run:
40-
41-
def mock_subprocess_run(*args, **kwargs):
42-
cmd = args[0]
43-
if "import" in cmd:
44-
result = Mock()
45-
if import_fails:
46-
result.returncode = 1
47-
result.stderr = "Failed to import image: network error"
48-
else:
49-
result.returncode = 0
50-
result.stderr = ""
51-
return result
52-
elif "remove" in cmd:
53-
result = Mock()
54-
result.returncode = 0
55-
return result
56-
elif "create" in cmd:
57-
result = Mock()
58-
if create_fails:
59-
result.returncode = 1
60-
result.stderr = "Failed to create container: no space"
61-
else:
62-
result.returncode = 0
63-
result.stderr = ""
64-
return result
65-
elif "start" in cmd:
66-
result = Mock()
67-
result.returncode = execute_returncode
68-
result.stdout = execute_stdout
69-
result.stderr = execute_stderr
70-
return result
71-
else:
72-
raise ValueError(f"Unexpected subprocess call: {cmd}")
73-
74-
mock_run.side_effect = mock_subprocess_run
75-
76-
coder = this_proc().spawn(
77-
f"coder_{uuid.uuid1()}",
78-
SandboxedPythonCoder,
79-
"docker://python:3.10",
80-
image_path,
81-
container_name,
58+
with patch(
59+
"forge.actors.coder.subprocess.run", side_effect=mock_subprocess_run
60+
):
61+
coder = _SandboxedPythonCoder(
62+
docker_image="docker://python:3.10",
63+
sqsh_image_path=image_path,
64+
container_name=container_name,
8265
)
8366

84-
yield coder, mock_run
85-
67+
await coder.setup()
68+
result, _ = await coder.execute(code="print('Hello World')")
69+
assert result == "Hello World\n"
8670
finally:
87-
if coder:
88-
await SandboxedPythonCoder.shutdown(coder)
89-
9071
if os.path.exists(image_path):
9172
os.unlink(image_path)
9273

9374

94-
@pytest.mark.timeout(10)
95-
@pytest.mark.asyncio
96-
async def test_coder_success():
97-
"""Test successful execution."""
98-
async with create_mock_coder(execute_stdout="Hello World\n") as (coder, _):
99-
await coder.setup.call_one()
100-
result, _ = await coder.execute.call_one(code="print('Hello World')")
101-
assert result == "Hello World\n"
102-
103-
104-
@pytest.mark.timeout(10)
10575
@pytest.mark.asyncio
10676
async def test_coder_execution_failure():
10777
"""Test execution failure."""
108-
async with create_mock_coder(
109-
execute_returncode=1, execute_stderr="SyntaxError: invalid syntax"
110-
) as (coder, _):
111-
await coder.setup.call_one()
112-
output, err = await coder.execute.call_one(code="invalid syntax")
113-
assert "SyntaxError" in err
78+
unique_id = str(uuid.uuid4())[:8]
79+
container_name = f"test_sandbox_{unique_id}"
80+
81+
with tempfile.NamedTemporaryFile(suffix=".sqsh", delete=False) as temp_image:
82+
image_path = temp_image.name
83+
84+
def mock_subprocess_run(*args, **kwargs):
85+
"""Mock subprocess.run for testing."""
86+
cmd = args[0] if args else kwargs.get("args", [])
87+
cmd_str = " ".join(cmd) if isinstance(cmd, list) else str(cmd)
88+
89+
if "import" in cmd_str:
90+
result = Mock()
91+
result.returncode = 0
92+
result.stderr = ""
93+
return result
94+
elif "remove" in cmd_str:
95+
result = Mock()
96+
result.returncode = 0
97+
return result
98+
elif "create" in cmd_str:
99+
result = Mock()
100+
result.returncode = 0
101+
result.stderr = ""
102+
return result
103+
elif "start" in cmd_str:
104+
result = Mock()
105+
result.returncode = 1
106+
result.stdout = ""
107+
result.stderr = "SyntaxError: invalid syntax"
108+
return result
109+
else:
110+
raise ValueError(f"Unexpected subprocess call: {cmd_str}")
111+
112+
try:
113+
with patch(
114+
"forge.actors.coder.subprocess.run", side_effect=mock_subprocess_run
115+
):
116+
coder = _SandboxedPythonCoder(
117+
docker_image="docker://python:3.10",
118+
sqsh_image_path=image_path,
119+
container_name=container_name,
120+
)
121+
122+
await coder.setup()
123+
output, err = await coder.execute(code="invalid syntax")
124+
assert "SyntaxError" in err
125+
finally:
126+
if os.path.exists(image_path):
127+
os.unlink(image_path)

0 commit comments

Comments
 (0)