From ad6de6bfd1c4d8252e2761065b473ecdeddc4a50 Mon Sep 17 00:00:00 2001 From: lxdlam Date: Fri, 21 Mar 2025 12:33:52 +0800 Subject: [PATCH 1/3] feat: refine PythonInterperter to proper take out the exception arguments --- dspy/primitives/python_interpreter.py | 6 ++- dspy/primitives/runner.js | 45 +++++++++++++++++---- tests/primitives/test_python_interpreter.py | 19 +++++++++ 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/dspy/primitives/python_interpreter.py b/dspy/primitives/python_interpreter.py index 9d420090ec..7aca51b074 100644 --- a/dspy/primitives/python_interpreter.py +++ b/dspy/primitives/python_interpreter.py @@ -5,7 +5,7 @@ import os -class InterpreterError(ValueError): +class InterpreterError(RuntimeError): pass @@ -119,8 +119,10 @@ def execute( error_type = result.get("errorType", "Sandbox Error") if error_type == "SyntaxError": raise SyntaxError(f"Invalid Python syntax. message: {error_msg}") + elif error_type == "FinalAnswer": + return result.get("errorArgs") else: - raise InterpreterError(f"{error_type}: {error_msg}") + raise InterpreterError(f"{error_type}: {result.get('errorArgs') or error_msg}") # If there's no error, return the "output" field return result.get("output", None) diff --git a/dspy/primitives/runner.js b/dspy/primitives/runner.js index 644c6820c0..b4d9d047dc 100644 --- a/dspy/primitives/runner.js +++ b/dspy/primitives/runner.js @@ -32,7 +32,7 @@ for await (const line of readLines(Deno.stdin)) { } const code = input.code || ""; - + // Wrap execution in a try/catch so we can handle syntax errors, etc. try { await pyodide.loadPackagesFromImports(code); @@ -40,6 +40,7 @@ for await (const line of readLines(Deno.stdin)) { pyodide.runPython(` import sys import io +import json # Keep references to the old stdout/stderr so we can restore them later old_stdout = sys.stdout @@ -53,20 +54,36 @@ sys.stdout = buf_stdout sys.stderr = buf_stderr `); - // 2. Run the user's code asynchronously + // 2. Setup proper exception arguments extractor and FinalAnswer bridge + // The idea is borrowed from `smolagents`, which uses the expcetion to simulate non-loca exit + // like some ugly style `call/cc`. + pyodide.runPython(` +import json + +def last_exception_args(): + return json.dumps(sys.last_exc.args) if sys.last_exc else None + +class FinalAnswer(Exception): + pass + +def final_answer(*args): + raise FinalAnswer(*args) + `); + + // 3. Run the user's code asynchronously const result = await pyodide.runPythonAsync(code); - // 3. Retrieve captured stdout/stderr + // 4. Retrieve captured stdout/stderr const capturedStdout = pyodide.runPython("buf_stdout.getvalue()"); const capturedStderr = pyodide.runPython("buf_stderr.getvalue()"); - // 4. Restore original stdout/stderr + // 5. Restore original stdout/stderr pyodide.runPython(` sys.stdout = old_stdout sys.stderr = old_stderr `); - // 5. Build our output object according to the rules: + // 6. Build our output object according to the rules: // - If result is None (or Python "None" => JS null), output all prints // - Else output the result only // Note: `None` in Python becomes `null` in JS. @@ -75,7 +92,7 @@ sys.stderr = old_stderr // The final statement was None or no return => deliver printed output // If you want to combine capturedStderr as well, you can append it // But here we'll just do stdout for clarity - output = capturedStdout; + output = capturedStdout; // If there's something in stderr, you might want to include that or log it // output += capturedStderr; } else { @@ -90,9 +107,23 @@ sys.stderr = old_stderr const errorType = error.type || "Error"; // error.message is mostly blank. const errorMessage = (error.message || "").trim(); + // The arguments of the exception are stored in sys.last_exc.args, + // which is always helpful but pyodide don't extract them for us. + // Use a bridge function to get them. + let errorArgs = []; + if (errorType !== "SyntaxError") { + // Only python exceptions have args. + const last_exception_args = pyodide.globals.get("last_exception_args"); + // Regarding https://pyodide.org/en/stable/usage/type-conversions.html#type-translations-errors, + // we do a addtional `json.dumps` and `JSON.parse` on the values, to avoid the possible memory leak. + errorArgs = JSON.parse(last_exception_args()) || []; + } + + console.log(JSON.stringify({ error: errorMessage, + errorArgs: errorArgs, errorType: errorType })); } -} +} \ No newline at end of file diff --git a/tests/primitives/test_python_interpreter.py b/tests/primitives/test_python_interpreter.py index ec20c48ea7..15740d3605 100644 --- a/tests/primitives/test_python_interpreter.py +++ b/tests/primitives/test_python_interpreter.py @@ -1,6 +1,8 @@ import shutil import pytest +import random + from dspy.primitives.python_interpreter import InterpreterError, PythonInterpreter @@ -42,3 +44,20 @@ def test_failure_zero_division(): code = "1+0/0" with pytest.raises(InterpreterError, match="ZeroDivisionError"): interpreter.execute(code) + + +def test_exception_args(): + with PythonInterpreter() as interpreter: + token = random.randint(1, 10**9) + code = f"raise ValueError({token})" + with pytest.raises(InterpreterError, match=rf"ValueError: \[{token}\]"): + interpreter.execute(code) + + +def test_final_answer_trick(): + with PythonInterpreter() as interpreter: + token = random.randint(1, 10**9) + code = f"final_answer('The result is', {token})" + result = interpreter(code) + + assert result == ["The result is", token], "The returned result is differ, `final_answer` trick don't work" From bf248c7c39a4ddc4b3027c54a7d13f5f632d2976 Mon Sep 17 00:00:00 2001 From: lxdlam Date: Fri, 21 Mar 2025 14:09:27 +0800 Subject: [PATCH 2/3] chore: typos --- dspy/primitives/runner.js | 3 +-- tests/primitives/test_python_interpreter.py | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dspy/primitives/runner.js b/dspy/primitives/runner.js index b4d9d047dc..5b2623181b 100644 --- a/dspy/primitives/runner.js +++ b/dspy/primitives/runner.js @@ -55,8 +55,7 @@ sys.stderr = buf_stderr `); // 2. Setup proper exception arguments extractor and FinalAnswer bridge - // The idea is borrowed from `smolagents`, which uses the expcetion to simulate non-loca exit - // like some ugly style `call/cc`. + // The idea is borrowed from `smolagents` that uses the exception to simulate non-local exit pyodide.runPython(` import json diff --git a/tests/primitives/test_python_interpreter.py b/tests/primitives/test_python_interpreter.py index 15740d3605..58b8ea8609 100644 --- a/tests/primitives/test_python_interpreter.py +++ b/tests/primitives/test_python_interpreter.py @@ -60,4 +60,5 @@ def test_final_answer_trick(): code = f"final_answer('The result is', {token})" result = interpreter(code) - assert result == ["The result is", token], "The returned result is differ, `final_answer` trick don't work" + # They should matain the same order + assert result == ["The result is", token], "The returned results are differ, `final_answer` trick doesn't work" From de056289868698bb8118094658abccdb92ac8209 Mon Sep 17 00:00:00 2001 From: lxdlam Date: Fri, 21 Mar 2025 14:20:58 +0800 Subject: [PATCH 3/3] chore: minor fixes --- dspy/primitives/runner.js | 1 - tests/primitives/test_python_interpreter.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/dspy/primitives/runner.js b/dspy/primitives/runner.js index 5b2623181b..18660efc0b 100644 --- a/dspy/primitives/runner.js +++ b/dspy/primitives/runner.js @@ -40,7 +40,6 @@ for await (const line of readLines(Deno.stdin)) { pyodide.runPython(` import sys import io -import json # Keep references to the old stdout/stderr so we can restore them later old_stdout = sys.stdout diff --git a/tests/primitives/test_python_interpreter.py b/tests/primitives/test_python_interpreter.py index 58b8ea8609..8ad606d25e 100644 --- a/tests/primitives/test_python_interpreter.py +++ b/tests/primitives/test_python_interpreter.py @@ -60,5 +60,5 @@ def test_final_answer_trick(): code = f"final_answer('The result is', {token})" result = interpreter(code) - # They should matain the same order - assert result == ["The result is", token], "The returned results are differ, `final_answer` trick doesn't work" + # They should matain the same order + assert result == ["The result is", token], "The returned results are differ, `final_answer` trick doesn't work"