File size: 9,305 Bytes
8edee29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
"""
executors/python_executor.py
-----------------------------
Python code executor for AutoDevAgent.

Runs generated Python code in a sandboxed subprocess with a strict
timeout. Captures stdout, stderr, and execution time. Returns a typed
ExecutionResult β€” never raises on code failures, only on executor
failures (e.g. subprocess could not be spawned).

Design decisions:
  - Uses subprocess.run() with timeout, not exec() or eval().
    This gives genuine process isolation β€” a crash in generated code
    cannot affect the parent process.
  - Code is written to a temp file rather than passed via -c flag.
    -c has shell escaping issues with multiline code and special chars.
  - stderr is always captured and cleaned before surfacing to the LLM.
    Raw tracebacks contain temp file paths that confuse the debug agent.
  - Timeout kills the process and returns ErrorType.TIMEOUT cleanly.

Known limitation:
  - subprocess isolation is not Docker-grade. A malicious script could
    access the filesystem or make network calls. Acceptable for a
    free-tier HuggingFace Spaces deployment; document in README.

Usage:
    from executors.python_executor import PythonExecutor
    from pipeline.state import PipelineState, Language

    executor = PythonExecutor()
    state = PipelineState(task="...", language=Language.PYTHON)
    state.generated_code = "print('hello')"
    result = executor.run(state)
    print(result["execution_result"].stdout)   # "hello"
    print(result["execution_result"].success)  # True
"""

import logging
import os
import subprocess
import sys
import tempfile
import time
from typing import Any

from config import settings
from pipeline.state import (
    ExecutionResult,
    PipelineState,
    PipelineStatus,
)

logger = logging.getLogger(__name__)


class PythonExecutor:
    """
    Executes Python code in a sandboxed subprocess.

    Writes generated code to a temporary file, runs it with the
    current Python interpreter under a configurable timeout, captures
    all output, and returns a typed ExecutionResult.

    Attributes:
        timeout: Maximum seconds allowed per execution run.
    """

    def __init__(self) -> None:
        """Initialise with timeout from central config."""
        self.timeout: int = settings.python_execution_timeout

    def run(self, state: PipelineState) -> dict[str, Any]:
        """
        Execute the generated code in state.generated_code.

        Writes code to a temp file, spawns a subprocess, enforces
        the timeout, captures stdout/stderr, and cleans error messages
        before returning.

        Args:
            state: Current PipelineState. Reads: generated_code.

        Returns:
            Partial state dict with keys:
              - "execution_result": ExecutionResult
              - "status": PipelineStatus.EXECUTING
        """
        code = state.generated_code

        if not code or not code.strip():
            logger.warning("PythonExecutor received empty code.")
            return {
                "execution_result": ExecutionResult(
                    success=False,
                    error_msg="No code was provided to execute.",
                ),
                "status": PipelineStatus.EXECUTING,
            }

        logger.info("PythonExecutor running code (%d lines)", len(code.splitlines()))

        # ── Dependency resolution ─────────────────────────────────── #
        # Detect imports and attempt to install missing packages before
        # execution so the code doesn't fail on ImportError needlessly.
        try:
            from utils.dependency_manager import DependencyManager
            dep_result = DependencyManager().resolve(code)
            if dep_result.failed:
                logger.warning(
                    "PythonExecutor: could not install: %s", dep_result.failed
                )
            elif dep_result.installed:
                logger.info(
                    "PythonExecutor: auto-installed: %s", dep_result.installed
                )
        except Exception as dep_err:
            # Dependency resolution failure is non-fatal β€” attempt execution anyway
            logger.warning("DependencyManager error (non-fatal): %s", dep_err)


        # Write code to a named temp file so subprocess can run it cleanly
        tmp_path = None
        try:
            tmp_file = tempfile.NamedTemporaryFile(
                mode="w",
                suffix=".py",
                delete=False,
                encoding="utf-8",
            )
            tmp_file.write(code)
            tmp_file.close()
            tmp_path = tmp_file.name

            result = self._execute(tmp_path)

        finally:
            # Always clean up the temp file
            if tmp_path and os.path.exists(tmp_path):
                os.unlink(tmp_path)

        return {
            "execution_result": result,
            "status": PipelineStatus.EXECUTING,
        }

    # ---------------------------------------------------------------- #
    #  Internal execution                                               #
    # ---------------------------------------------------------------- #

    def _execute(self, file_path: str) -> ExecutionResult:
        """
        Spawn a subprocess to run the code file and capture output.

        Args:
            file_path: Absolute path to the temporary Python file.

        Returns:
            ExecutionResult with success flag, stdout, stderr,
            cleaned error message, and execution time.
        """
        start = time.perf_counter()

        try:
            proc = subprocess.run(
                [sys.executable, file_path],
                capture_output=True,
                text=True,
                timeout=self.timeout,
            )
            elapsed = round(time.perf_counter() - start, 3)

            if proc.returncode == 0:
                logger.info("PythonExecutor success in %.3fs", elapsed)
                return ExecutionResult(
                    success=True,
                    stdout=proc.stdout.strip(),
                    stderr=proc.stderr.strip(),
                    exec_time=elapsed,
                )
            else:
                error_msg = _clean_error(proc.stderr, file_path)
                logger.info(
                    "PythonExecutor failed in %.3fs: %s",
                    elapsed,
                    error_msg[:120],
                )
                return ExecutionResult(
                    success=False,
                    stdout=proc.stdout.strip(),
                    stderr=proc.stderr.strip(),
                    error_msg=error_msg,
                    exec_time=elapsed,
                )

        except subprocess.TimeoutExpired:
            elapsed = round(time.perf_counter() - start, 3)
            msg = (
                f"Execution timed out after {self.timeout} seconds. "
                "The code may contain an infinite loop or expensive computation."
            )
            logger.warning("PythonExecutor timeout after %.3fs", elapsed)
            return ExecutionResult(
                success=False,
                error_msg=msg,
                exec_time=elapsed,
            )

        except Exception as e:
            # Executor itself failed β€” subprocess could not be spawned
            elapsed = round(time.perf_counter() - start, 3)
            msg = f"Executor internal error: {e}"
            logger.error(msg)
            return ExecutionResult(
                success=False,
                error_msg=msg,
                exec_time=elapsed,
            )


# ------------------------------------------------------------------ #
#  Helpers                                                             #
# ------------------------------------------------------------------ #

def _clean_error(stderr: str, file_path: str) -> str:
    """
    Clean a raw Python traceback for the debug agent.

    Raw tracebacks reference the temp file path (e.g.
    '/tmp/tmpXYZ123.py'), which is meaningless to the LLM. This
    function replaces those paths with 'generated_code.py' and
    strips redundant 'During handling...' noise.

    Also extracts just the final error line if the full traceback is
    very long, since the error type + message is what the debug agent
    needs most.

    Args:
        stderr:    Raw stderr string from the subprocess.
        file_path: Path to the temp file to scrub from the output.

    Returns:
        Cleaned error string suitable for the debug agent prompt.
    """
    if not stderr:
        return "Unknown error β€” no stderr output captured."

    # Replace temp file path with a clean placeholder
    cleaned = stderr.replace(file_path, "generated_code.py")

    # Remove 'During handling of the above exception...' noise
    noise_marker = "During handling of the above exception"
    if noise_marker in cleaned:
        cleaned = cleaned[: cleaned.index(noise_marker)].strip()

    # If the traceback is very long, keep the last 10 lines
    # (the traceback header + the final error line are most useful)
    lines = cleaned.strip().splitlines()
    if len(lines) > 10:
        cleaned = "\n".join(lines[-10:])

    return cleaned.strip()