asta-code-execution 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- asta_code_execution-0.1.2.dist-info/METADATA +9 -0
- asta_code_execution-0.1.2.dist-info/RECORD +8 -0
- asta_code_execution-0.1.2.dist-info/WHEEL +4 -0
- code_execution/__init__.py +14 -0
- code_execution/executor.py +105 -0
- code_execution/ipython_session.py +266 -0
- code_execution/process_backend.py +272 -0
- code_execution/py.typed +0 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
code_execution/__init__.py,sha256=IeWaNDWSA0QNQDqsek2xXowglHp7NTVRoM4qzJXCCHk,371
|
|
2
|
+
code_execution/executor.py,sha256=xKf2fp6RJx-gMrc0UIZKgLxywCdiaXwrMOlZ_n007b0,3394
|
|
3
|
+
code_execution/ipython_session.py,sha256=Yd-KYheOzJ0BNErEPqJHmFf-C0iplmZ6gHnoXdeH65c,8453
|
|
4
|
+
code_execution/process_backend.py,sha256=xFtiQMIXwJN4Iqas6gCza1UlxNqMzSyCWs0gtz67nEE,9270
|
|
5
|
+
code_execution/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
asta_code_execution-0.1.2.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
7
|
+
asta_code_execution-0.1.2.dist-info/METADATA,sha256=3weUIeEeNGsJhQXiyvEJUJ_vnHsS_0ieN1xyx2Nbxlo,245
|
|
8
|
+
asta_code_execution-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Code Execution."""
|
|
2
|
+
|
|
3
|
+
from .executor import IPythonBackend, IPythonExecutor, LocalIPythonBackend
|
|
4
|
+
from .ipython_session import ExecutionConfig, IPythonSession
|
|
5
|
+
from .process_backend import ProcessIPythonBackend
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ExecutionConfig",
|
|
9
|
+
"IPythonBackend",
|
|
10
|
+
"IPythonExecutor",
|
|
11
|
+
"IPythonSession",
|
|
12
|
+
"LocalIPythonBackend",
|
|
13
|
+
"ProcessIPythonBackend",
|
|
14
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Execution backends for running IPython cells."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
|
|
10
|
+
from .ipython_session import ExecutionConfig, IPythonSession
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IPythonBackend(Protocol):
|
|
14
|
+
"""Protocol for backends that execute IPython cells."""
|
|
15
|
+
|
|
16
|
+
def run_cell(
|
|
17
|
+
self,
|
|
18
|
+
code_str: str,
|
|
19
|
+
*,
|
|
20
|
+
use_subprocess: bool = False,
|
|
21
|
+
timeout_s: float | None = None,
|
|
22
|
+
allow_mime: Iterable[str] | None = None,
|
|
23
|
+
matplotlib_backend: str | None = ExecutionConfig.matplotlib_backend,
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
"""Execute a code cell and return normalized outputs."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@contextmanager
|
|
30
|
+
def _execution_context(cwd: str | None, env: dict[str, str] | None) -> Iterable[None]:
|
|
31
|
+
# Temporarily apply environment and working directory changes for a run.
|
|
32
|
+
previous_cwd = os.getcwd()
|
|
33
|
+
previous_env: dict[str, str | None] = {}
|
|
34
|
+
try:
|
|
35
|
+
if env:
|
|
36
|
+
for key, value in env.items():
|
|
37
|
+
previous_env[key] = os.environ.get(key)
|
|
38
|
+
os.environ[key] = value
|
|
39
|
+
if cwd:
|
|
40
|
+
os.chdir(cwd)
|
|
41
|
+
yield
|
|
42
|
+
finally:
|
|
43
|
+
if cwd:
|
|
44
|
+
os.chdir(previous_cwd)
|
|
45
|
+
if env:
|
|
46
|
+
for key in env:
|
|
47
|
+
previous_value = previous_env.get(key)
|
|
48
|
+
if previous_value is None:
|
|
49
|
+
os.environ.pop(key, None)
|
|
50
|
+
else:
|
|
51
|
+
os.environ[key] = previous_value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class LocalIPythonBackend:
|
|
55
|
+
"""Local backend that executes code via IPythonSession."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, *, cwd: str | None = None, env: dict[str, str] | None = None) -> None:
|
|
58
|
+
"""Initialize the backend with optional working directory and env vars."""
|
|
59
|
+
self._cwd = cwd
|
|
60
|
+
self._env = env
|
|
61
|
+
|
|
62
|
+
def run_cell(
|
|
63
|
+
self,
|
|
64
|
+
code_str: str,
|
|
65
|
+
*,
|
|
66
|
+
use_subprocess: bool = False,
|
|
67
|
+
timeout_s: float | None = None,
|
|
68
|
+
allow_mime: Iterable[str] | None = None,
|
|
69
|
+
matplotlib_backend: str | None = ExecutionConfig.matplotlib_backend,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
"""Execute a code cell in a local IPython session."""
|
|
72
|
+
with _execution_context(self._cwd, self._env):
|
|
73
|
+
session = IPythonSession(
|
|
74
|
+
use_subprocess=use_subprocess,
|
|
75
|
+
timeout_s=timeout_s,
|
|
76
|
+
allow_mime=allow_mime,
|
|
77
|
+
matplotlib_backend=matplotlib_backend,
|
|
78
|
+
)
|
|
79
|
+
return session.run_cell(code_str)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class IPythonExecutor:
|
|
83
|
+
"""Facade that executes IPython cells via a configurable backend."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, backend: IPythonBackend) -> None:
|
|
86
|
+
"""Initialize the executor with the provided backend."""
|
|
87
|
+
self._backend = backend
|
|
88
|
+
|
|
89
|
+
def run_cell(
|
|
90
|
+
self,
|
|
91
|
+
code_str: str,
|
|
92
|
+
*,
|
|
93
|
+
use_subprocess: bool = False,
|
|
94
|
+
timeout_s: float | None = None,
|
|
95
|
+
allow_mime: Iterable[str] | None = None,
|
|
96
|
+
matplotlib_backend: str | None = ExecutionConfig.matplotlib_backend,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
"""Execute a code cell using the configured backend."""
|
|
99
|
+
return self._backend.run_cell(
|
|
100
|
+
code_str,
|
|
101
|
+
use_subprocess=use_subprocess,
|
|
102
|
+
timeout_s=timeout_s,
|
|
103
|
+
allow_mime=allow_mime,
|
|
104
|
+
matplotlib_backend=matplotlib_backend,
|
|
105
|
+
)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""IPython-backed execution helpers with optional subprocess isolation."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import traceback
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from multiprocessing import get_context
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from IPython.core.formatters import DisplayFormatter
|
|
11
|
+
from IPython.core.interactiveshell import InteractiveShell
|
|
12
|
+
from IPython.utils.capture import capture_output
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ExecutionConfig:
|
|
17
|
+
"""Configuration for controlling how an IPython session executes cells."""
|
|
18
|
+
|
|
19
|
+
use_subprocess: bool = False
|
|
20
|
+
timeout_s: float | None = None
|
|
21
|
+
allow_mime: frozenset[str] = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"text/plain",
|
|
24
|
+
"text/html",
|
|
25
|
+
"text/markdown",
|
|
26
|
+
"text/latex",
|
|
27
|
+
"image/png",
|
|
28
|
+
"image/svg+xml",
|
|
29
|
+
"image/jpeg",
|
|
30
|
+
"application/json",
|
|
31
|
+
"application/javascript",
|
|
32
|
+
"application/pdf",
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
matplotlib_backend: str | None = "module://matplotlib_inline.backend_inline"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _normalize_value(value: Any) -> Any:
|
|
39
|
+
# Ensure outputs are JSON-safe for downstream serialization.
|
|
40
|
+
if isinstance(value, bytes):
|
|
41
|
+
return base64.b64encode(value).decode("ascii")
|
|
42
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
43
|
+
return value
|
|
44
|
+
if isinstance(value, (list, tuple)):
|
|
45
|
+
return [_normalize_value(item) for item in value]
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
return {str(key): _normalize_value(item) for key, item in value.items()}
|
|
48
|
+
return repr(value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _normalize_mime_bundle(data: dict[str, Any], allow_mime: frozenset[str]) -> dict[str, Any]:
|
|
52
|
+
# Filter to a predictable MIME allowlist to avoid leaking unexpected payload types.
|
|
53
|
+
normalized: dict[str, Any] = {}
|
|
54
|
+
for mime_type, payload in data.items():
|
|
55
|
+
if mime_type not in allow_mime:
|
|
56
|
+
continue
|
|
57
|
+
normalized[mime_type] = _normalize_value(payload)
|
|
58
|
+
return normalized
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _format_error(exc: BaseException | None) -> dict[str, str] | None:
|
|
62
|
+
# Preserve traceback details so callers can render useful diagnostics.
|
|
63
|
+
if exc is None:
|
|
64
|
+
return None
|
|
65
|
+
return {
|
|
66
|
+
"type": type(exc).__name__,
|
|
67
|
+
"message": str(exc),
|
|
68
|
+
"traceback": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _run_cell_with_shell(
|
|
73
|
+
shell: InteractiveShell,
|
|
74
|
+
code_str: str,
|
|
75
|
+
allow_mime: frozenset[str],
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
# Execute in-process to preserve state between calls when isolation isn't needed.
|
|
78
|
+
with capture_output() as captured:
|
|
79
|
+
result = shell.run_cell(code_str)
|
|
80
|
+
|
|
81
|
+
error = result.error_before_exec or result.error_in_exec
|
|
82
|
+
outputs = {
|
|
83
|
+
"stdout": captured.stdout,
|
|
84
|
+
"stderr": captured.stderr,
|
|
85
|
+
"rich_outputs": [],
|
|
86
|
+
"success": result.success,
|
|
87
|
+
"error": _format_error(error),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for display_obj in captured.outputs:
|
|
91
|
+
outputs["rich_outputs"].append(_normalize_mime_bundle(display_obj.data, allow_mime))
|
|
92
|
+
|
|
93
|
+
return outputs
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _configure_display_formatters(
|
|
97
|
+
shell: InteractiveShell,
|
|
98
|
+
allow_mime: frozenset[str],
|
|
99
|
+
) -> None:
|
|
100
|
+
formatter = shell.display_formatter
|
|
101
|
+
if formatter is None:
|
|
102
|
+
return
|
|
103
|
+
display_formatter = cast(DisplayFormatter, formatter)
|
|
104
|
+
available = [mime for mime in allow_mime if mime in display_formatter.formatters]
|
|
105
|
+
if not available:
|
|
106
|
+
return
|
|
107
|
+
for mime in available:
|
|
108
|
+
display_formatter.formatters[mime].enabled = True
|
|
109
|
+
display_formatter.active_types = available
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _configure_matplotlib_backend(
|
|
113
|
+
backend: str | None,
|
|
114
|
+
allow_mime: frozenset[str],
|
|
115
|
+
) -> None:
|
|
116
|
+
if not backend:
|
|
117
|
+
return
|
|
118
|
+
try:
|
|
119
|
+
import matplotlib
|
|
120
|
+
import matplotlib_inline.backend_inline as backend_inline
|
|
121
|
+
except Exception:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
current = matplotlib.get_backend()
|
|
126
|
+
except Exception:
|
|
127
|
+
current = None
|
|
128
|
+
|
|
129
|
+
if current == backend:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
# Ensure rich display payloads are emitted in non-interactive contexts.
|
|
134
|
+
matplotlib.use(backend, force=True)
|
|
135
|
+
if "matplotlib_inline.backend_inline" in backend:
|
|
136
|
+
formats: list[str] = []
|
|
137
|
+
if "image/png" in allow_mime:
|
|
138
|
+
formats.append("png")
|
|
139
|
+
if "image/svg+xml" in allow_mime:
|
|
140
|
+
formats.append("svg")
|
|
141
|
+
if "image/jpeg" in allow_mime:
|
|
142
|
+
formats.append("jpeg")
|
|
143
|
+
if formats:
|
|
144
|
+
backend_inline.set_matplotlib_formats(*formats)
|
|
145
|
+
except Exception:
|
|
146
|
+
# If pyplot or another backend is already loaded, keep running.
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _configure_shell(
|
|
151
|
+
shell: InteractiveShell,
|
|
152
|
+
allow_mime: frozenset[str],
|
|
153
|
+
matplotlib_backend: str | None,
|
|
154
|
+
) -> None:
|
|
155
|
+
_configure_display_formatters(shell, allow_mime)
|
|
156
|
+
_configure_matplotlib_backend(matplotlib_backend, allow_mime)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _ensure_pip_available() -> None:
|
|
160
|
+
# Bootstrap pip so %pip magic can install packages on demand.
|
|
161
|
+
try:
|
|
162
|
+
import pip # noqa: F401
|
|
163
|
+
|
|
164
|
+
return
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
import ensurepip
|
|
170
|
+
except Exception:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
ensurepip.bootstrap()
|
|
175
|
+
except Exception:
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _run_cell_in_subprocess(
|
|
180
|
+
code_str: str,
|
|
181
|
+
allow_mime: frozenset[str],
|
|
182
|
+
matplotlib_backend: str | None,
|
|
183
|
+
connection,
|
|
184
|
+
) -> None:
|
|
185
|
+
# Run in a child process so a hard timeout can be enforced without blocking.
|
|
186
|
+
shell = InteractiveShell.instance()
|
|
187
|
+
_configure_shell(shell, allow_mime, matplotlib_backend)
|
|
188
|
+
outputs = _run_cell_with_shell(shell, code_str, allow_mime)
|
|
189
|
+
connection.send(outputs)
|
|
190
|
+
connection.close()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class IPythonSession:
|
|
194
|
+
"""Run code in an IPython shell with optional subprocess isolation."""
|
|
195
|
+
|
|
196
|
+
def __init__(
|
|
197
|
+
self,
|
|
198
|
+
*,
|
|
199
|
+
use_subprocess: bool = False,
|
|
200
|
+
timeout_s: float | None = None,
|
|
201
|
+
allow_mime: Iterable[str] | None = None,
|
|
202
|
+
matplotlib_backend: str | None = ExecutionConfig.matplotlib_backend,
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Initialize an IPython execution session with optional isolation settings."""
|
|
205
|
+
_ensure_pip_available()
|
|
206
|
+
self._config = ExecutionConfig(
|
|
207
|
+
use_subprocess=use_subprocess,
|
|
208
|
+
timeout_s=timeout_s,
|
|
209
|
+
allow_mime=frozenset(allow_mime or ExecutionConfig.allow_mime),
|
|
210
|
+
matplotlib_backend=matplotlib_backend,
|
|
211
|
+
)
|
|
212
|
+
# Create a shell instance that persists variables between calls.
|
|
213
|
+
self.shell = InteractiveShell.instance()
|
|
214
|
+
_configure_shell(self.shell, self._config.allow_mime, self._config.matplotlib_backend)
|
|
215
|
+
|
|
216
|
+
def run_cell(self, code_str: str) -> dict[str, Any]:
|
|
217
|
+
"""Run a code cell and return captured outputs, errors, and success state."""
|
|
218
|
+
if self._config.timeout_s is not None and not self._config.use_subprocess:
|
|
219
|
+
raise ValueError("timeout_s requires use_subprocess=True to enforce a hard timeout")
|
|
220
|
+
if not self._config.use_subprocess:
|
|
221
|
+
return _run_cell_with_shell(self.shell, code_str, self._config.allow_mime)
|
|
222
|
+
|
|
223
|
+
ctx = get_context("spawn")
|
|
224
|
+
parent_conn, child_conn = ctx.Pipe(duplex=False)
|
|
225
|
+
process = ctx.Process(
|
|
226
|
+
target=_run_cell_in_subprocess,
|
|
227
|
+
args=(code_str, self._config.allow_mime, self._config.matplotlib_backend, child_conn),
|
|
228
|
+
)
|
|
229
|
+
process.start()
|
|
230
|
+
child_conn.close()
|
|
231
|
+
process.join(timeout=self._config.timeout_s)
|
|
232
|
+
|
|
233
|
+
if process.is_alive():
|
|
234
|
+
process.terminate()
|
|
235
|
+
process.join()
|
|
236
|
+
parent_conn.close()
|
|
237
|
+
return {
|
|
238
|
+
"stdout": "",
|
|
239
|
+
"stderr": "",
|
|
240
|
+
"rich_outputs": [],
|
|
241
|
+
"success": False,
|
|
242
|
+
"error": {
|
|
243
|
+
"type": "TimeoutError",
|
|
244
|
+
"message": f"Execution exceeded {self._config.timeout_s} seconds",
|
|
245
|
+
"traceback": "",
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if parent_conn.poll():
|
|
250
|
+
result = parent_conn.recv()
|
|
251
|
+
parent_conn.close()
|
|
252
|
+
return result
|
|
253
|
+
|
|
254
|
+
parent_conn.close()
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
"stdout": "",
|
|
258
|
+
"stderr": "",
|
|
259
|
+
"rich_outputs": [],
|
|
260
|
+
"success": False,
|
|
261
|
+
"error": {
|
|
262
|
+
"type": "RuntimeError",
|
|
263
|
+
"message": "Subprocess exited without returning a result",
|
|
264
|
+
"traceback": "",
|
|
265
|
+
},
|
|
266
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Subprocess-based IPython execution backend for local isolated runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .ipython_session import ExecutionConfig
|
|
16
|
+
|
|
17
|
+
_DEFAULT_SANDBOX_PACKAGES = [
|
|
18
|
+
"ipython",
|
|
19
|
+
"numpy",
|
|
20
|
+
"pandas",
|
|
21
|
+
"matplotlib",
|
|
22
|
+
"matplotlib-inline",
|
|
23
|
+
"seaborn",
|
|
24
|
+
"scikit-learn",
|
|
25
|
+
"scipy",
|
|
26
|
+
"statsmodels",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
_SANDBOX_RUNNER = """\
|
|
30
|
+
import json
|
|
31
|
+
import sys
|
|
32
|
+
import traceback
|
|
33
|
+
|
|
34
|
+
from code_execution.ipython_session import ExecutionConfig, IPythonSession
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _format_error(exc: BaseException) -> dict[str, str]:
|
|
38
|
+
return {
|
|
39
|
+
"type": type(exc).__name__,
|
|
40
|
+
"message": str(exc),
|
|
41
|
+
"traceback": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main() -> None:
|
|
46
|
+
try:
|
|
47
|
+
payload = json.load(sys.stdin)
|
|
48
|
+
allow_mime = payload.get("allow_mime")
|
|
49
|
+
session = IPythonSession(
|
|
50
|
+
use_subprocess=payload.get("use_subprocess", False),
|
|
51
|
+
timeout_s=payload.get("timeout_s"),
|
|
52
|
+
allow_mime=allow_mime,
|
|
53
|
+
matplotlib_backend=payload.get(
|
|
54
|
+
"matplotlib_backend",
|
|
55
|
+
ExecutionConfig.matplotlib_backend,
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
result = session.run_cell(payload["code_str"])
|
|
59
|
+
except BaseException as exc:
|
|
60
|
+
result = {
|
|
61
|
+
"stdout": "",
|
|
62
|
+
"stderr": "",
|
|
63
|
+
"rich_outputs": [],
|
|
64
|
+
"success": False,
|
|
65
|
+
"error": _format_error(exc),
|
|
66
|
+
}
|
|
67
|
+
json.dump(result, sys.stdout)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _find_uv() -> str:
|
|
76
|
+
"""Return path to the ``uv`` binary, or raise if not found."""
|
|
77
|
+
uv = shutil.which("uv")
|
|
78
|
+
if uv is None:
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
"uv is required for ProcessIPythonBackend but was not found on PATH"
|
|
81
|
+
)
|
|
82
|
+
return uv
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# TODO: Transplant this implementation onto asta_sandbox.SandboxBase (making run_cell
|
|
86
|
+
# async via asyncio.to_thread) and move it into the asta-sandbox library as a first-class
|
|
87
|
+
# local process backend alongside InProcessExecutor and ModalEphemeralExecutor. Once done,
|
|
88
|
+
# _ProcessBackendAdapter in agents.py can be removed and ProcessIPythonBackend can be used
|
|
89
|
+
# directly wherever a SandboxBase is expected.
|
|
90
|
+
class ProcessIPythonBackend:
|
|
91
|
+
"""Backend that executes IPython cells in isolated subprocesses.
|
|
92
|
+
|
|
93
|
+
Each backend instance lazily creates a *base sandbox venv* with a curated
|
|
94
|
+
set of packages (mirroring the Modal sandbox image). Individual
|
|
95
|
+
``run_cell`` invocations get a per-process temporary directory so that any
|
|
96
|
+
``uv pip install`` executed by experiment code does **not** mutate the
|
|
97
|
+
base venv.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
cwd: str | None = None,
|
|
104
|
+
env: dict[str, str] | None = None,
|
|
105
|
+
packages: list[str] | None = None,
|
|
106
|
+
sandbox_venv_path: str | None = None,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Initialize the backend with optional working directory and env vars."""
|
|
109
|
+
self._cwd = cwd
|
|
110
|
+
self._env = env
|
|
111
|
+
self._packages = packages if packages is not None else list(_DEFAULT_SANDBOX_PACKAGES)
|
|
112
|
+
self._sandbox_venv_path: Path | None = (
|
|
113
|
+
Path(sandbox_venv_path) if sandbox_venv_path else None
|
|
114
|
+
)
|
|
115
|
+
self._sandbox_ready = False
|
|
116
|
+
self._owned_venv_dir: tempfile.TemporaryDirectory[str] | None = None
|
|
117
|
+
|
|
118
|
+
# -- lazy venv setup -----------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def _ensure_sandbox(self) -> Path:
|
|
121
|
+
"""Create the base sandbox venv if it doesn't already exist."""
|
|
122
|
+
if self._sandbox_ready and self._sandbox_venv_path is not None:
|
|
123
|
+
return self._sandbox_venv_path
|
|
124
|
+
|
|
125
|
+
uv = _find_uv()
|
|
126
|
+
|
|
127
|
+
if self._sandbox_venv_path is None:
|
|
128
|
+
self._owned_venv_dir = tempfile.TemporaryDirectory(prefix="process_sandbox_")
|
|
129
|
+
self._sandbox_venv_path = Path(self._owned_venv_dir.name)
|
|
130
|
+
|
|
131
|
+
venv_path = self._sandbox_venv_path
|
|
132
|
+
python_bin = venv_path / "bin" / "python"
|
|
133
|
+
|
|
134
|
+
if not python_bin.exists():
|
|
135
|
+
subprocess.run(
|
|
136
|
+
[uv, "venv", str(venv_path), "--seed", "--python", sys.executable],
|
|
137
|
+
check=True,
|
|
138
|
+
capture_output=True,
|
|
139
|
+
text=True,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Install curated packages
|
|
143
|
+
if self._packages:
|
|
144
|
+
subprocess.run(
|
|
145
|
+
[uv, "pip", "install", "--python", str(python_bin), *self._packages],
|
|
146
|
+
check=True,
|
|
147
|
+
capture_output=True,
|
|
148
|
+
text=True,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Install code_execution package (editable) so the sandbox runner works
|
|
152
|
+
code_exec_pkg = Path(__file__).resolve().parent.parent.parent # packages/code_execution
|
|
153
|
+
subprocess.run(
|
|
154
|
+
[uv, "pip", "install", "--python", str(python_bin), "-e", str(code_exec_pkg)],
|
|
155
|
+
check=True,
|
|
156
|
+
capture_output=True,
|
|
157
|
+
text=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
self._sandbox_ready = True
|
|
161
|
+
return venv_path
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def sandbox_python(self) -> str:
|
|
165
|
+
"""Return the path to the sandbox venv's Python interpreter."""
|
|
166
|
+
venv = self._ensure_sandbox()
|
|
167
|
+
return str(venv / "bin" / "python")
|
|
168
|
+
|
|
169
|
+
# -- cell execution ------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def run_cell(
|
|
172
|
+
self,
|
|
173
|
+
code_str: str,
|
|
174
|
+
*,
|
|
175
|
+
use_subprocess: bool = False,
|
|
176
|
+
timeout_s: float | None = None,
|
|
177
|
+
allow_mime: Iterable[str] | None = None,
|
|
178
|
+
matplotlib_backend: str | None = ExecutionConfig.matplotlib_backend,
|
|
179
|
+
) -> dict[str, Any]:
|
|
180
|
+
"""Execute a code cell in an isolated subprocess and return normalized outputs."""
|
|
181
|
+
venv_path = self._ensure_sandbox()
|
|
182
|
+
python_bin = str(venv_path / "bin" / "python")
|
|
183
|
+
|
|
184
|
+
payload = {
|
|
185
|
+
"code_str": code_str,
|
|
186
|
+
"use_subprocess": use_subprocess,
|
|
187
|
+
"timeout_s": None,
|
|
188
|
+
"allow_mime": list(allow_mime) if allow_mime else None,
|
|
189
|
+
"matplotlib_backend": matplotlib_backend,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
child_env = os.environ.copy()
|
|
193
|
+
if self._env:
|
|
194
|
+
child_env.update(self._env)
|
|
195
|
+
|
|
196
|
+
# Per-cell temp directory for any packages installed by experiment code.
|
|
197
|
+
# An `install-package` script on PATH installs into this directory
|
|
198
|
+
# via `uv pip install --target`, keeping the base sandbox venv clean.
|
|
199
|
+
cell_tmp = tempfile.mkdtemp(prefix="cell_pkgs_")
|
|
200
|
+
try:
|
|
201
|
+
cell_install_dir = os.path.join(cell_tmp, "lib")
|
|
202
|
+
os.makedirs(cell_install_dir, exist_ok=True)
|
|
203
|
+
|
|
204
|
+
existing_pp = child_env.get("PYTHONPATH", "")
|
|
205
|
+
child_env["PYTHONPATH"] = (
|
|
206
|
+
f"{cell_install_dir}:{existing_pp}" if existing_pp else cell_install_dir
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Create an install-package script that experiment code calls
|
|
210
|
+
# to install packages into the per-process temp directory.
|
|
211
|
+
script_dir = os.path.join(cell_tmp, "bin")
|
|
212
|
+
os.makedirs(script_dir, exist_ok=True)
|
|
213
|
+
real_uv = _find_uv()
|
|
214
|
+
script_path = os.path.join(script_dir, "install-package")
|
|
215
|
+
with open(script_path, "w") as f:
|
|
216
|
+
f.write(f"""#!/bin/sh
|
|
217
|
+
# Install a Python package into the per-process temp directory.
|
|
218
|
+
exec "{real_uv}" pip install --target "{cell_install_dir}" --quiet "$@"
|
|
219
|
+
""")
|
|
220
|
+
os.chmod(script_path, 0o755)
|
|
221
|
+
|
|
222
|
+
venv_bin = str(venv_path / "bin")
|
|
223
|
+
child_env["PATH"] = f"{script_dir}:{venv_bin}:{child_env.get('PATH', '')}"
|
|
224
|
+
child_env["VIRTUAL_ENV"] = str(venv_path)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
proc = subprocess.run(
|
|
228
|
+
[python_bin, "-c", _SANDBOX_RUNNER],
|
|
229
|
+
input=json.dumps(payload),
|
|
230
|
+
capture_output=True,
|
|
231
|
+
text=True,
|
|
232
|
+
timeout=timeout_s,
|
|
233
|
+
cwd=self._cwd,
|
|
234
|
+
env=child_env,
|
|
235
|
+
)
|
|
236
|
+
except subprocess.TimeoutExpired:
|
|
237
|
+
return {
|
|
238
|
+
"stdout": "",
|
|
239
|
+
"stderr": "",
|
|
240
|
+
"rich_outputs": [],
|
|
241
|
+
"success": False,
|
|
242
|
+
"error": {
|
|
243
|
+
"type": "TimeoutError",
|
|
244
|
+
"message": f"Process execution timed out after {timeout_s}s",
|
|
245
|
+
"traceback": "",
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
finally:
|
|
249
|
+
shutil.rmtree(cell_tmp, ignore_errors=True)
|
|
250
|
+
|
|
251
|
+
stdout = proc.stdout or ""
|
|
252
|
+
stderr = proc.stderr or ""
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
result = json.loads(stdout) if stdout.strip() else {}
|
|
256
|
+
except json.JSONDecodeError:
|
|
257
|
+
return {
|
|
258
|
+
"stdout": "",
|
|
259
|
+
"stderr": stderr,
|
|
260
|
+
"rich_outputs": [],
|
|
261
|
+
"success": False,
|
|
262
|
+
"error": {
|
|
263
|
+
"type": "RuntimeError",
|
|
264
|
+
"message": "Subprocess output was not valid JSON.",
|
|
265
|
+
"traceback": stdout,
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if stderr:
|
|
270
|
+
result.setdefault("stderr", "")
|
|
271
|
+
result["stderr"] = f"{result['stderr']}{stderr}"
|
|
272
|
+
return result
|
code_execution/py.typed
ADDED
|
File without changes
|