Skip to content

Commit d583c80

Browse files
jackgerritssonichi
andauthored
Update local cli executor to use same filename strategy as docker (#1981)
* consistent file saving across cli executors * test fixes * feedback * make path * formatting * run timeout test on windows --------- Co-authored-by: Chi Wang <[email protected]>
1 parent a814ba5 commit d583c80

File tree

5 files changed

+114
-87
lines changed

5 files changed

+114
-87
lines changed

autogen/coding/base.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Dict, List, Protocol, Union, runtime_checkable
1+
from typing import Any, Dict, List, Optional, Protocol, Union, runtime_checkable
22

33
from pydantic import BaseModel, Field
44

@@ -77,3 +77,12 @@ class IPythonCodeResult(CodeResult):
7777
default_factory=list,
7878
description="The list of files that the executed code blocks generated.",
7979
)
80+
81+
82+
class CommandLineCodeResult(CodeResult):
83+
"""(Experimental) A code result class for command line code executor."""
84+
85+
code_file: Optional[str] = Field(
86+
default=None,
87+
description="The file that the executed code block was saved to.",
88+
)

autogen/coding/docker_commandline_code_executor.py

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
from docker.models.containers import Container
1212
from docker.errors import ImageNotFound
1313

14-
from .local_commandline_code_executor import CommandLineCodeResult
14+
from .utils import _get_file_name_from_content
15+
from .base import CommandLineCodeResult
1516

1617
from ..code_utils import TIMEOUT_MSG, _cmd
1718
from .base import CodeBlock, CodeExecutor, CodeExtractor
@@ -168,25 +169,15 @@ def execute_code_blocks(self, code_blocks: List[CodeBlock]) -> CommandLineCodeRe
168169
lang = code_block.language
169170
code = code_block.code
170171

171-
code_hash = md5(code.encode()).hexdigest()
172-
173-
# Check if there is a filename comment
174-
# Get first line
175-
first_line = code.split("\n")[0]
176-
if first_line.startswith("# filename:"):
177-
filename = first_line.split(":")[1].strip()
178-
179-
# Handle relative paths in the filename
180-
path = Path(filename)
181-
if not path.is_absolute():
182-
path = Path("/workspace") / path
183-
path = path.resolve()
184-
try:
185-
path.relative_to(Path("/workspace"))
186-
except ValueError:
187-
return CommandLineCodeResult(exit_code=1, output="Filename is not in the workspace")
188-
else:
189-
# create a file with a automatically generated name
172+
try:
173+
# Check if there is a filename comment
174+
filename = _get_file_name_from_content(code, Path("/workspace"))
175+
except ValueError:
176+
return CommandLineCodeResult(exit_code=1, output="Filename is not in the workspace")
177+
178+
if filename is None:
179+
# create a file with an automatically generated name
180+
code_hash = md5(code.encode()).hexdigest()
190181
filename = f"tmp_code_{code_hash}.{'py' if lang.startswith('python') else lang}"
191182

192183
code_path = self._work_dir / filename

autogen/coding/local_commandline_code_executor.py

Lines changed: 55 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,27 @@
1+
from hashlib import md5
12
import os
23
from pathlib import Path
34
import re
5+
import sys
46
import uuid
57
import warnings
6-
from typing import ClassVar, List, Optional, Union
7-
from pydantic import Field
8+
from typing import ClassVar, List, Union
89

910
from ..agentchat.agent import LLMAgent
10-
from ..code_utils import execute_code
11-
from .base import CodeBlock, CodeExecutor, CodeExtractor, CodeResult
11+
from ..code_utils import TIMEOUT_MSG, WIN32, _cmd, execute_code
12+
from .base import CodeBlock, CodeExecutor, CodeExtractor, CommandLineCodeResult
1213
from .markdown_code_extractor import MarkdownCodeExtractor
1314

14-
__all__ = (
15-
"LocalCommandLineCodeExecutor",
16-
"CommandLineCodeResult",
17-
)
15+
from .utils import _get_file_name_from_content
1816

17+
import subprocess
1918

20-
class CommandLineCodeResult(CodeResult):
21-
"""(Experimental) A code result class for command line code executor."""
22-
23-
code_file: Optional[str] = Field(
24-
default=None,
25-
description="The file that the executed code block was saved to.",
26-
)
19+
__all__ = ("LocalCommandLineCodeExecutor",)
2720

2821

2922
class LocalCommandLineCodeExecutor(CodeExecutor):
23+
SUPPORTED_LANGUAGES: ClassVar[List[str]] = ["bash", "shell", "sh", "pwsh", "powershell", "ps1", "python"]
24+
3025
def __init__(
3126
self,
3227
timeout: int = 60,
@@ -113,41 +108,59 @@ def execute_code_blocks(self, code_blocks: List[CodeBlock]) -> CommandLineCodeRe
113108
Returns:
114109
CommandLineCodeResult: The result of the code execution."""
115110
logs_all = ""
111+
file_names = []
116112
for code_block in code_blocks:
117113
lang, code = code_block.language, code_block.code
114+
lang = lang.lower()
118115

119116
LocalCommandLineCodeExecutor.sanitize_command(lang, code)
120-
filename_uuid = uuid.uuid4().hex
121-
filename = None
122-
if lang in ["bash", "shell", "sh", "pwsh", "powershell", "ps1"]:
123-
filename = f"{filename_uuid}.{lang}"
124-
exitcode, logs, _ = execute_code(
125-
code=code,
126-
lang=lang,
127-
timeout=self._timeout,
128-
work_dir=str(self._work_dir),
129-
filename=filename,
130-
use_docker=False,
131-
)
132-
elif lang in ["python", "Python"]:
133-
filename = f"{filename_uuid}.py"
134-
exitcode, logs, _ = execute_code(
135-
code=code,
136-
lang="python",
137-
timeout=self._timeout,
138-
work_dir=str(self._work_dir),
139-
filename=filename,
140-
use_docker=False,
141-
)
142-
else:
117+
118+
if WIN32 and lang in ["sh", "shell"]:
119+
lang = "ps1"
120+
121+
if lang not in self.SUPPORTED_LANGUAGES:
143122
# In case the language is not supported, we return an error message.
144-
exitcode, logs, _ = (1, f"unknown language {lang}", None)
145-
logs_all += "\n" + logs
123+
exitcode = 1
124+
logs_all += "\n" + f"unknown language {lang}"
125+
break
126+
127+
try:
128+
# Check if there is a filename comment
129+
filename = _get_file_name_from_content(code, self._work_dir)
130+
except ValueError:
131+
return CommandLineCodeResult(exit_code=1, output="Filename is not in the workspace")
132+
133+
if filename is None:
134+
# create a file with an automatically generated name
135+
code_hash = md5(code.encode()).hexdigest()
136+
filename = f"tmp_code_{code_hash}.{'py' if lang.startswith('python') else lang}"
137+
138+
written_file = (self._work_dir / filename).resolve()
139+
written_file.open("w", encoding="utf-8").write(code)
140+
file_names.append(written_file)
141+
142+
program = sys.executable if lang.startswith("python") else _cmd(lang)
143+
cmd = [program, str(written_file.absolute())]
144+
145+
try:
146+
result = subprocess.run(
147+
cmd, cwd=self._work_dir, capture_output=True, text=True, timeout=float(self._timeout)
148+
)
149+
except subprocess.TimeoutExpired:
150+
logs_all += "\n" + TIMEOUT_MSG
151+
# Same exit code as the timeout command on linux.
152+
exitcode = 124
153+
break
154+
155+
logs_all += result.stderr
156+
logs_all += result.stdout
157+
exitcode = result.returncode
158+
146159
if exitcode != 0:
147160
break
148161

149-
code_filename = str(self._work_dir / filename) if filename is not None else None
150-
return CommandLineCodeResult(exit_code=exitcode, output=logs_all, code_file=code_filename)
162+
code_file = str(file_names[0]) if len(file_names) > 0 else None
163+
return CommandLineCodeResult(exit_code=exitcode, output=logs_all, code_file=code_file)
151164

152165
def restart(self) -> None:
153166
"""(Experimental) Restart the code executor."""

autogen/coding/utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Will return the filename relative to the workspace path
2+
from pathlib import Path
3+
from typing import Optional
4+
5+
6+
# Raises ValueError if the file is not in the workspace
7+
def _get_file_name_from_content(code: str, workspace_path: Path) -> Optional[str]:
8+
first_line = code.split("\n")[0]
9+
# TODO - support other languages
10+
if first_line.startswith("# filename:"):
11+
filename = first_line.split(":")[1].strip()
12+
13+
# Handle relative paths in the filename
14+
path = Path(filename)
15+
if not path.is_absolute():
16+
path = workspace_path / path
17+
path = path.resolve()
18+
# Throws an error if the file is not in the workspace
19+
relative = path.relative_to(workspace_path.resolve())
20+
return str(relative)
21+
22+
return None

test/coding/test_commandline_code_executor.py

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,6 @@ def _test_execute_code(executor: CodeExecutor) -> None:
103103
assert file_line.strip() == code_line.strip()
104104

105105

106-
@pytest.mark.skipif(sys.platform in ["win32"], reason="do not run on windows")
107106
@pytest.mark.parametrize("cls", classes_to_test)
108107
def test_commandline_code_executor_timeout(cls) -> None:
109108
with tempfile.TemporaryDirectory() as temp_dir:
@@ -193,36 +192,29 @@ def test_dangerous_commands(lang, code, expected_message):
193192
), f"Expected message '{expected_message}' not found in '{str(exc_info.value)}'"
194193

195194

196-
# This is kind of hard to test because each exec is a new env
197-
@pytest.mark.skipif(
198-
skip_docker or not is_docker_running(),
199-
reason="docker is not running or requested to skip docker tests",
200-
)
201-
def test_docker_invalid_relative_path() -> None:
202-
with DockerCommandLineCodeExecutor() as executor:
203-
code = """# filename: /tmp/test.py
195+
@pytest.mark.parametrize("cls", classes_to_test)
196+
def test_invalid_relative_path(cls) -> None:
197+
executor = cls()
198+
code = """# filename: /tmp/test.py
204199
205200
print("hello world")
206201
"""
207-
result = executor.execute_code_blocks([CodeBlock(code=code, language="python")])
208-
assert result.exit_code == 1 and "Filename is not in the workspace" in result.output
202+
result = executor.execute_code_blocks([CodeBlock(code=code, language="python")])
203+
assert result.exit_code == 1 and "Filename is not in the workspace" in result.output
209204

210205

211-
@pytest.mark.skipif(
212-
skip_docker or not is_docker_running(),
213-
reason="docker is not running or requested to skip docker tests",
214-
)
215-
def test_docker_valid_relative_path() -> None:
206+
@pytest.mark.parametrize("cls", classes_to_test)
207+
def test_valid_relative_path(cls) -> None:
216208
with tempfile.TemporaryDirectory() as temp_dir:
217209
temp_dir = Path(temp_dir)
218-
with DockerCommandLineCodeExecutor(work_dir=temp_dir) as executor:
219-
code = """# filename: test.py
210+
executor = cls(work_dir=temp_dir)
211+
code = """# filename: test.py
220212
221213
print("hello world")
222214
"""
223-
result = executor.execute_code_blocks([CodeBlock(code=code, language="python")])
224-
assert result.exit_code == 0
225-
assert "hello world" in result.output
226-
assert "test.py" in result.code_file
227-
assert (temp_dir / "test.py") == Path(result.code_file)
228-
assert (temp_dir / "test.py").exists()
215+
result = executor.execute_code_blocks([CodeBlock(code=code, language="python")])
216+
assert result.exit_code == 0
217+
assert "hello world" in result.output
218+
assert "test.py" in result.code_file
219+
assert (temp_dir / "test.py").resolve() == Path(result.code_file).resolve()
220+
assert (temp_dir / "test.py").exists()

0 commit comments

Comments
 (0)