-
Notifications
You must be signed in to change notification settings - Fork 7.4k
Jupyter Code Executor in v0.4 (alternative implementation) #4885
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
Merged
ekzhu
merged 21 commits into
microsoft:main
from
Leon0402:feauture/juputerCodeExecutor2
Jan 18, 2025
+457
−0
Merged
Changes from 7 commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
e014cb5
Implement local jupyter notebook execution support
Leon0402 c6acfa6
Add tests for images and html
Leon0402 a1281ac
Add missing ipython kernel dep
Leon0402 c0a2b76
Make Error Handling consistent with jupyter notebook.
Leon0402 eae31ff
Use more sensible default for websocket connection
Leon0402 4513422
Implementation based on nbclient
Leon0402 980bd88
Fix stuff broken through rebase
Leon0402 0a12657
Update dependencies
Leon0402 99ff064
Implementation without changes to nbclient
Leon0402 362ef05
Stop on first failure
Leon0402 9787183
Merge remote-tracking branch 'upstream/main' into feauture/juputerCod…
Leon0402 05d69cb
Implement suggestions
Leon0402 8397d22
Add API docs
ekzhu 0ba8f97
Merge branch 'main' into feauture/juputerCodeExecutor2
ekzhu c178e20
Add example for CodeExecutorAgent
Leon0402 60202f7
Merge remote-tracking branch 'upstream/main' into feauture/juputerCod…
Leon0402 7eec6a6
Merge branch 'main' into feauture/juputerCodeExecutor2
ekzhu 905d583
Fix poe checks stuff
Leon0402 dfd62c5
Merge branch 'feauture/juputerCodeExecutor2' of github.com:Leon0402/a…
Leon0402 1ab4a4d
Merge branch 'main' into feauture/juputerCodeExecutor2
ekzhu 1a1bb9b
fix doc
ekzhu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
6 changes: 6 additions & 0 deletions
6
python/packages/autogen-ext/src/autogen_ext/code_executors/jupyter/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
from ._jupyter_code_executor import JupyterCodeExecutor, JupyterCodeResult | ||
|
||
__all__ = [ | ||
"JupyterCodeExecutor", | ||
"JupyterCodeResult", | ||
] |
160 changes: 160 additions & 0 deletions
160
python/packages/autogen-ext/src/autogen_ext/code_executors/jupyter/_jupyter_code_executor.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,160 @@ | ||
import asyncio | ||
import base64 | ||
import json | ||
import re | ||
import sys | ||
import uuid | ||
from dataclasses import dataclass | ||
from pathlib import Path | ||
from types import TracebackType | ||
|
||
if sys.version_info >= (3, 11): | ||
from typing import Self | ||
else: | ||
from typing_extensions import Self | ||
|
||
from autogen_core import CancellationToken | ||
from autogen_core.code_executor import CodeBlock, CodeExecutor, CodeResult | ||
from nbclient import NotebookClient | ||
from nbformat import v4 as nbformat | ||
|
||
from .._common import silence_pip | ||
|
||
|
||
@dataclass | ||
class JupyterCodeResult(CodeResult): | ||
"""A code result class for Jupyter code executor.""" | ||
|
||
output_files: list[Path] | ||
|
||
|
||
class JupyterCodeExecutor(CodeExecutor): | ||
def __init__( | ||
self, | ||
kernel_name: str = "python3", | ||
timeout: int = 60, | ||
output_dir: Path = Path("."), | ||
): | ||
"""A code executor class that executes code statefully using nbclient. | ||
ekzhu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
Args: | ||
ekzhu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
kernel_name (str): The kernel name to use. By default, "python3". | ||
timeout (int): The timeout for code execution, by default 60. | ||
output_dir (Path): The directory to save output files, by default ".". | ||
""" | ||
if timeout < 1: | ||
raise ValueError("Timeout must be greater than or equal to 1.") | ||
|
||
self._kernel_name = kernel_name | ||
self._timeout = timeout | ||
self._output_dir = output_dir | ||
self._start() | ||
|
||
async def execute_code_blocks( | ||
self, code_blocks: list[CodeBlock], cancellation_token: CancellationToken | ||
) -> JupyterCodeResult: | ||
"""Execute code blocks and return the result. | ||
|
||
Args: | ||
code_blocks (list[CodeBlock]): The code blocks to execute. | ||
|
||
Returns: | ||
JupyterCodeResult: The result of the code execution. | ||
""" | ||
outputs: list[str] = [] | ||
output_files: list[Path] = [] | ||
exit_code = 0 | ||
|
||
for code_block in code_blocks: | ||
result = await self._execute_code_block(code_block, cancellation_token) | ||
exit_code = result.exit_code | ||
outputs.append(result.output) | ||
output_files.extend(result.output_files) | ||
|
||
return JupyterCodeResult(exit_code=exit_code, output="\n".join(outputs), output_files=output_files) | ||
|
||
async def _execute_code_block( | ||
self, code_block: CodeBlock, cancellation_token: CancellationToken | ||
) -> JupyterCodeResult: | ||
"""Execute single code block and return the result. | ||
|
||
Args: | ||
code_block (CodeBlock): The code block to execute. | ||
|
||
Returns: | ||
JupyterCodeResult: The result of the code execution. | ||
""" | ||
execute_task = asyncio.create_task( | ||
Leon0402 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
self._client.async_execute_cell_standalone( | ||
nbformat.new_code_cell(silence_pip(code_block.code, code_block.language)), cleanup_kc=False | ||
) | ||
) | ||
cancellation_token.link_future(execute_task) | ||
output_cell = await asyncio.wait_for(asyncio.shield(execute_task), timeout=self._timeout) | ||
|
||
outputs: list[str] = [] | ||
output_files: list[Path] = [] | ||
exit_code = 0 | ||
|
||
for output in output_cell.get("outputs", []): | ||
match output.get("output_type"): | ||
case "stream": | ||
outputs.append(output.get("text", "")) | ||
case "error": | ||
traceback = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", "\n".join(output["traceback"])) | ||
outputs.append(traceback) | ||
exit_code = 1 | ||
case "execute_result" | "display_data": | ||
data = output.get("data", {}) | ||
for mime, content in data.items(): | ||
match mime: | ||
case "text/plain": | ||
outputs.append(content) | ||
case "image/png": | ||
path = self._save_image(content) | ||
output_files.append(path) | ||
case "image/jpeg": | ||
# TODO: Should this also be encoded? Images are encoded as both png and jpg | ||
pass | ||
case "text/html": | ||
path = self._save_html(content) | ||
output_files.append(path) | ||
case _: | ||
outputs.append(json.dumps(content)) | ||
|
||
return JupyterCodeResult(exit_code=exit_code, output="\n".join(outputs), output_files=output_files) | ||
ekzhu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
def _save_image(self, image_data_base64: str) -> Path: | ||
"""Save image data to a file.""" | ||
image_data = base64.b64decode(image_data_base64) | ||
path = self._output_dir / f"{uuid.uuid4().hex}.png" | ||
path.write_bytes(image_data) | ||
return path.absolute() | ||
|
||
def _save_html(self, html_data: str) -> Path: | ||
"""Save HTML data to a file.""" | ||
path = self._output_dir / f"{uuid.uuid4().hex}.html" | ||
path.write_text(html_data) | ||
return path.absolute() | ||
|
||
async def restart(self) -> None: | ||
"""Restart the code executor.""" | ||
self._start() | ||
|
||
def _start(self) -> None: | ||
self._client = NotebookClient( | ||
nb=nbformat.new_notebook(), kernel_name=self._kernel_name, timeout=self._timeout, allow_errors=True | ||
) | ||
|
||
async def stop(self) -> None: | ||
"""Stop the kernel.""" | ||
if self._client.km is not None: | ||
await self._client._async_cleanup_kernel() # type: ignore | ||
|
||
async def __aenter__(self) -> Self: | ||
return self | ||
|
||
async def __aexit__( | ||
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None | ||
) -> None: | ||
await self.stop() |
169 changes: 169 additions & 0 deletions
169
python/packages/autogen-ext/tests/code_executors/test_jupyter_code_executor.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,169 @@ | ||
import asyncio | ||
import inspect | ||
from pathlib import Path | ||
|
||
import pytest | ||
from autogen_core import CancellationToken | ||
from autogen_core.code_executor import CodeBlock | ||
from autogen_ext.code_executors.jupyter import JupyterCodeExecutor, JupyterCodeResult | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_execute_code(tmp_path: Path) -> None: | ||
async with JupyterCodeExecutor(output_dir=tmp_path) as executor: | ||
code_blocks = [CodeBlock(code="import sys; print('hello world!')", language="python")] | ||
code_result = await executor.execute_code_blocks(code_blocks, CancellationToken()) | ||
assert code_result == JupyterCodeResult(exit_code=0, output="hello world!\n", output_files=[]) | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_execute_code_error(tmp_path: Path) -> None: | ||
async with JupyterCodeExecutor(output_dir=tmp_path) as executor: | ||
code_blocks = [CodeBlock(code="print(undefined_variable)", language="python")] | ||
code_result = await executor.execute_code_blocks(code_blocks, CancellationToken()) | ||
assert code_result == JupyterCodeResult( | ||
exit_code=1, | ||
output=inspect.cleandoc(""" | ||
--------------------------------------------------------------------------- | ||
NameError Traceback (most recent call last) | ||
Cell In[1], line 1 | ||
----> 1 print(undefined_variable) | ||
|
||
NameError: name 'undefined_variable' is not defined | ||
"""), | ||
output_files=[], | ||
) | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_execute_multiple_code_blocks(tmp_path: Path) -> None: | ||
async with JupyterCodeExecutor(output_dir=tmp_path) as executor: | ||
code_blocks = [ | ||
CodeBlock(code="import sys; print('hello world!')", language="python"), | ||
CodeBlock(code="a = 100 + 100; print(a)", language="python"), | ||
] | ||
code_result = await executor.execute_code_blocks(code_blocks, CancellationToken()) | ||
assert code_result == JupyterCodeResult(exit_code=0, output="hello world!\n\n200\n", output_files=[]) | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_depedent_executions(tmp_path: Path) -> None: | ||
async with JupyterCodeExecutor(output_dir=tmp_path) as executor: | ||
code_blocks_1 = [CodeBlock(code="a = 'hello world!'", language="python")] | ||
code_blocks_2 = [ | ||
CodeBlock(code="print(a)", language="python"), | ||
] | ||
await executor.execute_code_blocks(code_blocks_1, CancellationToken()) | ||
code_result = await executor.execute_code_blocks(code_blocks_2, CancellationToken()) | ||
assert code_result == JupyterCodeResult(exit_code=0, output="hello world!\n", output_files=[]) | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_execute_multiple_code_blocks_error(tmp_path: Path) -> None: | ||
async with JupyterCodeExecutor(output_dir=tmp_path) as executor: | ||
code_blocks = [ | ||
CodeBlock(code="import sys; print('hello world!')", language="python"), | ||
CodeBlock(code="a = 100 + 100; print(a); print(undefined_variable)", language="python"), | ||
] | ||
code_result = await executor.execute_code_blocks(code_blocks, CancellationToken()) | ||
assert code_result == JupyterCodeResult( | ||
exit_code=1, | ||
output=inspect.cleandoc(""" | ||
hello world! | ||
|
||
200 | ||
|
||
--------------------------------------------------------------------------- | ||
NameError Traceback (most recent call last) | ||
Cell In[2], line 1 | ||
----> 1 a = 100 + 100; print(a); print(undefined_variable) | ||
|
||
NameError: name 'undefined_variable' is not defined | ||
"""), | ||
output_files=[], | ||
) | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_execute_code_after_restart(tmp_path: Path) -> None: | ||
async with JupyterCodeExecutor(output_dir=tmp_path) as executor: | ||
await executor.restart() | ||
|
||
code_blocks = [CodeBlock(code="import sys; print('hello world!')", language="python")] | ||
code_result = await executor.execute_code_blocks(code_blocks, CancellationToken()) | ||
assert code_result == JupyterCodeResult(exit_code=0, output="hello world!\n", output_files=[]) | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_commandline_code_executor_timeout(tmp_path: Path) -> None: | ||
async with JupyterCodeExecutor(output_dir=tmp_path, timeout=2) as executor: | ||
code_blocks = [CodeBlock(code="import time; time.sleep(10); print('hello world!')", language="python")] | ||
|
||
with pytest.raises(asyncio.TimeoutError): | ||
await executor.execute_code_blocks(code_blocks, CancellationToken()) | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_commandline_code_executor_cancellation(tmp_path: Path) -> None: | ||
async with JupyterCodeExecutor(output_dir=tmp_path) as executor: | ||
code_blocks = [CodeBlock(code="import time; time.sleep(10); print('hello world!')", language="python")] | ||
|
||
cancellation_token = CancellationToken() | ||
code_result_coroutine = executor.execute_code_blocks(code_blocks, cancellation_token) | ||
|
||
await asyncio.sleep(1) | ||
cancellation_token.cancel() | ||
|
||
with pytest.raises(asyncio.CancelledError): | ||
await code_result_coroutine | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_execute_code_with_image_output(tmp_path: Path) -> None: | ||
async with JupyterCodeExecutor(output_dir=tmp_path) as executor: | ||
code_blocks = [ | ||
CodeBlock( | ||
code=inspect.cleandoc(""" | ||
from PIL import Image, ImageDraw | ||
img = Image.new("RGB", (100, 100), color="white") | ||
draw = ImageDraw.Draw(img) | ||
draw.rectangle((10, 10, 90, 90), outline="black", fill="blue") | ||
display(img) | ||
"""), | ||
language="python", | ||
) | ||
] | ||
|
||
code_result = await executor.execute_code_blocks(code_blocks, CancellationToken()) | ||
|
||
assert len(code_result.output_files) == 1 | ||
assert code_result == JupyterCodeResult( | ||
exit_code=0, | ||
output="<PIL.Image.Image image mode=RGB size=100x100>", | ||
output_files=code_result.output_files, | ||
) | ||
assert code_result.output_files[0].parent == tmp_path | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_execute_code_with_html_output(tmp_path: Path) -> None: | ||
async with JupyterCodeExecutor(output_dir=tmp_path) as executor: | ||
code_blocks = [ | ||
CodeBlock( | ||
code=inspect.cleandoc(""" | ||
from IPython.core.display import HTML | ||
HTML("<div style='color:blue'>Hello, HTML world!</div>") | ||
"""), | ||
language="python", | ||
) | ||
] | ||
|
||
code_result = await executor.execute_code_blocks(code_blocks, CancellationToken()) | ||
|
||
assert len(code_result.output_files) == 1 | ||
assert code_result == JupyterCodeResult( | ||
exit_code=0, | ||
output="<IPython.core.display.HTML object>", | ||
output_files=code_result.output_files, | ||
) | ||
assert code_result.output_files[0].parent == tmp_path |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.