Skip to content

Commit

Permalink
Update PythonInterpreter: capture all stdout or last return expression
Browse files Browse the repository at this point in the history
  • Loading branch information
okhat committed Dec 26, 2024
1 parent efd6976 commit baed59f
Show file tree
Hide file tree
Showing 4 changed files with 119 additions and 37 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ dummy.csv
docs/docs/**/*.json*
*.index
*.pkl
*.tar.gz
2 changes: 1 addition & 1 deletion docs/docs/tutorials/multihop_search/index.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"source": [
"import dspy\n",
"\n",
"lm = dspy.LM('databricks/okhattab-llama-31-8b', max_tokens=3000)\n",
"lm = dspy.LM('<your_provider>/Llama-3.1-8B-Instruct', max_tokens=3000)\n",
"gpt4o = dspy.LM('openai/gpt-4o', max_tokens=3000)\n",
"\n",
"dspy.configure(lm=lm)"
Expand Down
52 changes: 36 additions & 16 deletions dspy/primitives/python_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,27 @@ class InterpreterError(ValueError):
class PythonInterpreter:
r"""
PythonInterpreter that runs code in a sandboxed environment using Deno and Pyodide.
Adapted from "Simon Willison’s TILs" (https://til.simonwillison.net/deno/pyodide-sandbox)
Prerequisites:
- Deno (https://docs.deno.com/runtime/getting_started/installation/).
- runner.js in the same directory as this file.
Example Usage:
Example Usage:
```python
code_string = "4 + 5"
output =PythonInterpreter()(code_string)
print(output)
code_string = "print('Hello'); 1 + 2"
interp = PythonInterpreter()
output = interp(code_string)
print(output) # If final statement is non-None, prints the numeric result, else prints captured output
interp.shutdown()
```
"""

def __init__(
self,
self,
deno_command: Optional[List[str]] = None
) -> None:
if isinstance(deno_command, dict):
deno_command = None #no-op
deno_command = None # no-op, just a guard in case someone passes a dict
self.deno_command = deno_command or [
"deno", "run", "--allow-read", self._get_runner_path()
]
Expand Down Expand Up @@ -57,6 +59,7 @@ def _ensure_deno_process(self) -> None:
raise InterpreterError(install_instructions) from e

def _inject_variables(self, code: str, variables: Dict[str, Any]) -> str:
# Insert Python assignments for each variable at the top of the code
injected_lines = []
for key, value in variables.items():
if not key.isidentifier():
Expand All @@ -67,6 +70,7 @@ def _inject_variables(self, code: str, variables: Dict[str, Any]) -> str:
return injected_code

def _serialize_value(self, value: Any) -> str:
# Basic safe serialization
if isinstance(value, str):
return repr(value)
elif isinstance(value, (int, float, bool)):
Expand All @@ -79,38 +83,54 @@ def _serialize_value(self, value: Any) -> str:
raise InterpreterError(f"Unsupported value type: {type(value).__name__}")

def execute(
self,
code: str,
self,
code: str,
variables: Optional[Dict[str, Any]] = None,
) -> Any:
variables = variables or {}
code = self._inject_variables(code, variables)
self._ensure_deno_process()

# Send the code as JSON
input_data = json.dumps({"code": code})
try:
self.deno_process.stdin.write(input_data + "\n")
self.deno_process.stdin.flush()
except BrokenPipeError:
# If the process died, restart and try again once
self._ensure_deno_process()
self.deno_process.stdin.write(input_data + "\n")
self.deno_process.stdin.flush()

# Read one JSON line from stdout
output_line = self.deno_process.stdout.readline().strip()
if not output_line:
# Possibly the subprocess died or gave no output
err_output = self.deno_process.stderr.read()
raise InterpreterError(f"No output from Deno subprocess. Stderr: {err_output}")

# Parse that line as JSON
try:
result = json.loads(output_line)
except json.JSONDecodeError:
# If not valid JSON, just return raw text
result = {"output": output_line}
if not isinstance(result, dict):
result = {"output": result}
if 'error' in result:
raise InterpreterError(f"Sandbox Error: {result['error']}")
return result.get('output', None)

# If we have an error, handle SyntaxError vs. other error
if "error" in result:
error_msg = result["error"]
error_type = result.get("errorType", "")
if error_type == "SyntaxError":
raise SyntaxError(error_msg)
else:
raise InterpreterError(f"Sandbox Error: {error_msg}")

# If there's no error, return the "output" field
return result.get("output", None)

def __call__(
self,
code: str,
self,
code: str,
variables: Optional[Dict[str, Any]] = None,
) -> Any:
return self.execute(code, variables)
Expand Down
101 changes: 81 additions & 20 deletions dspy/primitives/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,90 @@ import { readLines } from "https://deno.land/std@0.186.0/io/mod.ts";
const pyodide = await pyodideModule.loadPyodide();

for await (const line of readLines(Deno.stdin)) {
let input;
try {
input = JSON.parse(line);
} catch (error) {
console.log(JSON.stringify({ error: "Invalid JSON input: " + error.message }));
continue;
}
let input;
try {
input = JSON.parse(line);
} catch (error) {
console.log(JSON.stringify({
error: "Invalid JSON input: " + error.message,
errorType: "ValueError"
}));
continue;
}

if (typeof input !== 'object' || input === null) {
console.log(JSON.stringify({ error: "Input is not a JSON object" }));
continue;
}
// Expecting an object like { "code": "...", ... }
if (typeof input !== 'object' || input === null) {
console.log(JSON.stringify({
error: "Input is not a JSON object",
errorType: "ValueError"
}));
continue;
}

if (input.shutdown) {
break;
}
// Check for shutdown
if (input.shutdown) {
break;
}

const code = input.code || "";

// Wrap execution in a try/catch so we can handle syntax errors, etc.
try {
// 1. Temporarily override stdout/stderr so we can capture prints.
pyodide.runPython(`
import sys
import io
# Keep references to the old stdout/stderr so we can restore them later
old_stdout = sys.stdout
old_stderr = sys.stderr
# New "file-like" buffers
buf_stdout = io.StringIO()
buf_stderr = io.StringIO()
sys.stdout = buf_stdout
sys.stderr = buf_stderr
`);

// 2. Run the user's code asynchronously
const result = await pyodide.runPythonAsync(code);

// 3. Retrieve captured stdout/stderr
const capturedStdout = pyodide.runPython("buf_stdout.getvalue()");
const capturedStderr = pyodide.runPython("buf_stderr.getvalue()");

// 4. Restore original stdout/stderr
pyodide.runPython(`
sys.stdout = old_stdout
sys.stderr = old_stderr
`);

// 5. 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.
let output;
try {
const result = await pyodide.runPythonAsync(input.code || "");
output = JSON.stringify({ output: result });
} catch (error) {
output = JSON.stringify({ error: error.message.trim().split('\n').pop() || ''});
if (result === null || result === undefined) {
// 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;
// If there's something in stderr, you might want to include that or log it
// output += capturedStderr;
} else {
// If the code returned a real value, just return that
output = result;
}
console.log(output);

console.log(JSON.stringify({ output }));
} catch (error) {
// We have an error => check if it's a SyntaxError or something else
const errorType = error.name || "Error";
const errorMessage = (error.message || "").trim();
console.log(JSON.stringify({
error: errorMessage,
errorType: errorType
}));
}
}

0 comments on commit baed59f

Please sign in to comment.