codeshield-runtime 0.1.0__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.
- codeshield/__init__.py +17 -0
- codeshield/__main__.py +10 -0
- codeshield/analyzer.py +151 -0
- codeshield/classifier.py +163 -0
- codeshield/cli.py +101 -0
- codeshield/environment.py +256 -0
- codeshield/loop.py +420 -0
- codeshield/runner.py +221 -0
- codeshield/schemas.py +120 -0
- codeshield/tools.py +76 -0
- codeshield_runtime-0.1.0.dist-info/METADATA +277 -0
- codeshield_runtime-0.1.0.dist-info/RECORD +14 -0
- codeshield_runtime-0.1.0.dist-info/WHEEL +4 -0
- codeshield_runtime-0.1.0.dist-info/licenses/LICENSE +21 -0
codeshield/loop.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Self-healing execution loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from codeshield.analyzer import validate_syntax_and_safety
|
|
12
|
+
from codeshield.classifier import TracebackClassifier
|
|
13
|
+
from codeshield.environment import SandboxManager
|
|
14
|
+
from codeshield.runner import SubprocessRunner
|
|
15
|
+
from codeshield.schemas import (
|
|
16
|
+
CodeExecutionRequest,
|
|
17
|
+
ErrorDiagnosis,
|
|
18
|
+
ExecutionResult,
|
|
19
|
+
PatchProposal,
|
|
20
|
+
ValidationReport,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_MAX_ITERATIONS = 3
|
|
27
|
+
DEFAULT_MAX_LLM_RETRIES = 3
|
|
28
|
+
DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SelfHealingError(RuntimeError):
|
|
32
|
+
"""Raised when the self-healing loop cannot complete execution safely."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LLMPatchError(RuntimeError):
|
|
36
|
+
"""Raised when the LLM patch generator cannot produce a safe correction."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GeminiPatchGenerator:
|
|
40
|
+
"""Generate patches using the Google Gemini API with AST validation."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
api_key: str | None = None,
|
|
45
|
+
model: str | None = None,
|
|
46
|
+
max_llm_retries: int = DEFAULT_MAX_LLM_RETRIES,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Initialize the Gemini patch generator.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
api_key: Gemini API key. If ``None``, ``GEMINI_API_KEY`` env var is used.
|
|
52
|
+
model: Gemini model name. Defaults to ``GEMINI_MODEL`` env var or
|
|
53
|
+
``gemini-2.5-flash``.
|
|
54
|
+
max_llm_retries: Maximum attempts to ask Gemini for a valid patch.
|
|
55
|
+
"""
|
|
56
|
+
self._api_key = api_key or os.environ.get("GEMINI_API_KEY")
|
|
57
|
+
self._model = model or os.environ.get("GEMINI_MODEL") or DEFAULT_GEMINI_MODEL
|
|
58
|
+
self._max_llm_retries = max(1, max_llm_retries)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def is_configured(self) -> bool:
|
|
62
|
+
"""Return ``True`` when an API key is available."""
|
|
63
|
+
return bool(self._api_key)
|
|
64
|
+
|
|
65
|
+
def __call__(self, code: str, diagnosis: ErrorDiagnosis) -> str | None:
|
|
66
|
+
"""Generate a patched version of ``code`` using Gemini, or ``None``."""
|
|
67
|
+
if not self.is_configured:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
for attempt in range(1, self._max_llm_retries + 1):
|
|
71
|
+
logger.info(
|
|
72
|
+
"Requesting patch from Gemini (attempt %d/%d)",
|
|
73
|
+
attempt,
|
|
74
|
+
self._max_llm_retries,
|
|
75
|
+
)
|
|
76
|
+
try:
|
|
77
|
+
raw_response = self._call_gemini(code, diagnosis)
|
|
78
|
+
except Exception as exc: # noqa: BLE001
|
|
79
|
+
logger.warning("Gemini API call failed: %s", exc)
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if not raw_response:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
patched_code = self._extract_code(raw_response)
|
|
86
|
+
if patched_code == code or not patched_code.strip():
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
report = validate_syntax_and_safety(patched_code)
|
|
90
|
+
if report.is_valid:
|
|
91
|
+
logger.info("Gemini patch passed AST validation")
|
|
92
|
+
return patched_code
|
|
93
|
+
|
|
94
|
+
logger.warning(
|
|
95
|
+
"Gemini patch failed AST validation: %s",
|
|
96
|
+
report.violations,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
logger.warning("Gemini could not produce a valid patch after all retries")
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def _call_gemini(self, code: str, diagnosis: ErrorDiagnosis) -> str:
|
|
103
|
+
"""Call the Gemini API and return the raw text response."""
|
|
104
|
+
try:
|
|
105
|
+
from google import genai
|
|
106
|
+
from google.genai import types
|
|
107
|
+
except ImportError as exc:
|
|
108
|
+
raise LLMPatchError(
|
|
109
|
+
"google-genai is not installed. "
|
|
110
|
+
"Install it with: pip install 'autonomous-code-execution-engine[llm]'"
|
|
111
|
+
) from exc
|
|
112
|
+
|
|
113
|
+
prompt = self._build_prompt(code, diagnosis)
|
|
114
|
+
client = genai.Client(api_key=self._api_key)
|
|
115
|
+
response = client.models.generate_content(
|
|
116
|
+
model=self._model,
|
|
117
|
+
contents=[
|
|
118
|
+
types.Content(
|
|
119
|
+
role="user",
|
|
120
|
+
parts=[types.Part.from_text(text=prompt)],
|
|
121
|
+
),
|
|
122
|
+
],
|
|
123
|
+
config=types.GenerateContentConfig(
|
|
124
|
+
temperature=0.2,
|
|
125
|
+
max_output_tokens=2048,
|
|
126
|
+
automatic_function_calling=types.AutomaticFunctionCallingConfig(
|
|
127
|
+
disable=True
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if not response or not response.text:
|
|
133
|
+
return ""
|
|
134
|
+
return response.text
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _build_prompt(code: str, diagnosis: ErrorDiagnosis) -> str:
|
|
138
|
+
"""Build a deterministic prompt for Gemini."""
|
|
139
|
+
context = "\n".join(diagnosis.context) if diagnosis.context else "N/A"
|
|
140
|
+
return (
|
|
141
|
+
"You are an expert Python debugger. "
|
|
142
|
+
"Given the code, the runtime error and the surrounding context, "
|
|
143
|
+
"return ONLY the corrected Python code. "
|
|
144
|
+
"Do not include explanations, comments or markdown formatting.\n\n"
|
|
145
|
+
f"Error type: {diagnosis.error_type}\n"
|
|
146
|
+
f"Error message: {diagnosis.message}\n"
|
|
147
|
+
f"Failing line: {diagnosis.root_cause_line}\n"
|
|
148
|
+
f"Context:\n{context}\n\n"
|
|
149
|
+
f"Code:\n{code}\n\n"
|
|
150
|
+
"Corrected code:"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _extract_code(raw: str) -> str:
|
|
155
|
+
"""Extract a Python code block from a markdown-wrapped response."""
|
|
156
|
+
fenced = re.search(r"```python\n(.*?)\n```", raw, re.DOTALL | re.IGNORECASE)
|
|
157
|
+
if fenced:
|
|
158
|
+
return fenced.group(1).strip()
|
|
159
|
+
|
|
160
|
+
plain = re.search(r"```\n(.*?)\n```", raw, re.DOTALL)
|
|
161
|
+
if plain:
|
|
162
|
+
return plain.group(1).strip()
|
|
163
|
+
|
|
164
|
+
return raw.strip()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class SelfHealingEngine:
|
|
168
|
+
"""Orchestrate AST validation, sandboxed execution and self-healing retries."""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
|
173
|
+
sandbox: SandboxManager | None = None,
|
|
174
|
+
runner: SubprocessRunner | None = None,
|
|
175
|
+
classifier: TracebackClassifier | None = None,
|
|
176
|
+
patch_generator: Callable[[str, ErrorDiagnosis], str | None] | None = None,
|
|
177
|
+
gemini_api_key: str | None = None,
|
|
178
|
+
gemini_model: str | None = None,
|
|
179
|
+
use_llm: bool = True,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Initialize the self-healing engine.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
max_iterations: Maximum number of validation/execution iterations.
|
|
185
|
+
sandbox: Optional ``SandboxManager``; a temporary one is created when
|
|
186
|
+
``None``.
|
|
187
|
+
runner: Optional ``SubprocessRunner``; built from ``sandbox`` when ``None``.
|
|
188
|
+
classifier: Optional ``TracebackClassifier``.
|
|
189
|
+
patch_generator: Optional callable ``(code, diagnosis) -> patched_code``
|
|
190
|
+
used to produce corrections. Overrides the Gemini/local default.
|
|
191
|
+
gemini_api_key: Optional Gemini API key. Falls back to ``GEMINI_API_KEY``
|
|
192
|
+
environment variable.
|
|
193
|
+
gemini_model: Optional Gemini model name. Falls back to ``GEMINI_MODEL``
|
|
194
|
+
environment variable or ``gemini-2.5-flash``.
|
|
195
|
+
use_llm: If ``True`` and a Gemini API key is available, use Gemini for
|
|
196
|
+
patch generation; otherwise fall back to the local deterministic
|
|
197
|
+
generator.
|
|
198
|
+
"""
|
|
199
|
+
if max_iterations < 1:
|
|
200
|
+
raise SelfHealingError("max_iterations must be at least 1")
|
|
201
|
+
|
|
202
|
+
self._max_iterations = max_iterations
|
|
203
|
+
self._sandbox = sandbox or SandboxManager()
|
|
204
|
+
self._runner = runner or SubprocessRunner(self._sandbox)
|
|
205
|
+
self._classifier = classifier or TracebackClassifier()
|
|
206
|
+
|
|
207
|
+
if patch_generator is not None:
|
|
208
|
+
self._patch_generator = patch_generator
|
|
209
|
+
elif use_llm:
|
|
210
|
+
gemini = GeminiPatchGenerator(
|
|
211
|
+
api_key=gemini_api_key,
|
|
212
|
+
model=gemini_model,
|
|
213
|
+
)
|
|
214
|
+
self._patch_generator = (
|
|
215
|
+
gemini if gemini.is_configured else self._default_patch_generator
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
self._patch_generator = self._default_patch_generator
|
|
219
|
+
|
|
220
|
+
def run(
|
|
221
|
+
self,
|
|
222
|
+
request: CodeExecutionRequest | str,
|
|
223
|
+
timeout: float | None = None,
|
|
224
|
+
) -> tuple[ExecutionResult, ErrorDiagnosis | None]:
|
|
225
|
+
"""Run code through the AST -> sandbox -> heal loop.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
request: Either a ``CodeExecutionRequest`` or a raw source string.
|
|
229
|
+
timeout: Optional execution timeout override.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
The final ``ExecutionResult`` and an optional ``ErrorDiagnosis``.
|
|
233
|
+
|
|
234
|
+
Raises:
|
|
235
|
+
SelfHealingError: when the loop exhausts all iterations without a
|
|
236
|
+
clean result.
|
|
237
|
+
"""
|
|
238
|
+
if isinstance(request, str):
|
|
239
|
+
request = CodeExecutionRequest(code=request)
|
|
240
|
+
|
|
241
|
+
diagnosis: ErrorDiagnosis | None = None
|
|
242
|
+
for attempt in range(1, self._max_iterations + 1):
|
|
243
|
+
logger.info("Self-healing iteration %d/%d", attempt, self._max_iterations)
|
|
244
|
+
|
|
245
|
+
report = validate_syntax_and_safety(request.code)
|
|
246
|
+
if not report.is_valid:
|
|
247
|
+
if report.exception is not None:
|
|
248
|
+
return self._syntax_failure_result(request, report), None
|
|
249
|
+
|
|
250
|
+
diagnosis = ErrorDiagnosis(
|
|
251
|
+
error_type="StaticSafetyViolation",
|
|
252
|
+
root_cause_line=None,
|
|
253
|
+
message="; ".join(report.violations),
|
|
254
|
+
context=[],
|
|
255
|
+
)
|
|
256
|
+
patched = self._generate_and_validate_patch(request.code, diagnosis)
|
|
257
|
+
if patched is None:
|
|
258
|
+
raise SelfHealingError(
|
|
259
|
+
f"Static safety violations cannot be auto-patched: {report.violations}"
|
|
260
|
+
)
|
|
261
|
+
request = self._apply_patch(request, patched, attempt)
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
result = self._runner.run(request, timeout=timeout)
|
|
265
|
+
|
|
266
|
+
if self._is_success(result):
|
|
267
|
+
return result, None
|
|
268
|
+
|
|
269
|
+
diagnosis = self._classifier.classify_from_result(request.code, result.stderr)
|
|
270
|
+
patched = self._generate_and_validate_patch(request.code, diagnosis)
|
|
271
|
+
|
|
272
|
+
if patched is None:
|
|
273
|
+
logger.warning(
|
|
274
|
+
"No deterministic patch available for %s at line %s",
|
|
275
|
+
diagnosis.error_type,
|
|
276
|
+
diagnosis.root_cause_line,
|
|
277
|
+
)
|
|
278
|
+
return result, diagnosis
|
|
279
|
+
|
|
280
|
+
request = self._apply_patch(request, patched, attempt)
|
|
281
|
+
|
|
282
|
+
raise SelfHealingError(
|
|
283
|
+
f"Self-healing loop exhausted after {self._max_iterations} attempts. "
|
|
284
|
+
f"Last diagnosis: {diagnosis.error_type}"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def _is_success(self, result: ExecutionResult) -> bool:
|
|
288
|
+
"""Return ``True`` when the result represents a clean execution."""
|
|
289
|
+
return (
|
|
290
|
+
result.exit_code == 0
|
|
291
|
+
and not result.silent_failure_detected
|
|
292
|
+
and not result.timed_out
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def _generate_and_validate_patch(
|
|
296
|
+
self,
|
|
297
|
+
code: str,
|
|
298
|
+
diagnosis: ErrorDiagnosis,
|
|
299
|
+
) -> PatchProposal | None:
|
|
300
|
+
"""Produce a patch, validate it with the AST and return the proposal."""
|
|
301
|
+
patched_code = self._patch_generator(code, diagnosis)
|
|
302
|
+
if patched_code is None or patched_code == code:
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
report = validate_syntax_and_safety(patched_code)
|
|
306
|
+
return PatchProposal(
|
|
307
|
+
file_path=Path("<dynamic>"),
|
|
308
|
+
patched_code=patched_code,
|
|
309
|
+
is_syntax_valid=report.is_valid,
|
|
310
|
+
diagnosis=diagnosis,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def _apply_patch(
|
|
314
|
+
self,
|
|
315
|
+
request: CodeExecutionRequest,
|
|
316
|
+
proposal: PatchProposal,
|
|
317
|
+
attempt: int,
|
|
318
|
+
) -> CodeExecutionRequest:
|
|
319
|
+
"""Return a new request with the patched code and an updated file name."""
|
|
320
|
+
if not proposal.is_syntax_valid:
|
|
321
|
+
raise SelfHealingError(
|
|
322
|
+
f"Proposed patch failed AST validation: {proposal.patched_code[:200]}"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
file_name = f"script_attempt_{attempt}.py"
|
|
326
|
+
return CodeExecutionRequest(
|
|
327
|
+
code=proposal.patched_code,
|
|
328
|
+
timeout_seconds=request.timeout_seconds,
|
|
329
|
+
requirements=list(request.requirements),
|
|
330
|
+
file_name=file_name,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def _syntax_failure_result(
|
|
334
|
+
self,
|
|
335
|
+
request: CodeExecutionRequest,
|
|
336
|
+
report: ValidationReport,
|
|
337
|
+
) -> ExecutionResult:
|
|
338
|
+
"""Build a synthetic ``ExecutionResult`` for a syntax validation failure."""
|
|
339
|
+
stderr = report.violations[0] if report.violations else "Syntax validation failed"
|
|
340
|
+
return ExecutionResult(
|
|
341
|
+
stdout="",
|
|
342
|
+
stderr=stderr,
|
|
343
|
+
exit_code=1,
|
|
344
|
+
duration_seconds=0.0,
|
|
345
|
+
silent_failure_detected=False,
|
|
346
|
+
timed_out=False,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
def _default_patch_generator(
|
|
350
|
+
self,
|
|
351
|
+
code: str,
|
|
352
|
+
diagnosis: ErrorDiagnosis,
|
|
353
|
+
) -> str | None:
|
|
354
|
+
"""Generate a deterministic patch based on the diagnosis.
|
|
355
|
+
|
|
356
|
+
The default generator only handles a small, safe subset of runtime errors:
|
|
357
|
+
- ``NameError``: add an ``import`` or placeholder definition.
|
|
358
|
+
- ``ImportError`` / ``ModuleNotFoundError``: try a well-known alias.
|
|
359
|
+
- ``SyntaxError``: not auto-patched (returns ``None``).
|
|
360
|
+
|
|
361
|
+
More complex errors require a model-based patch generator.
|
|
362
|
+
"""
|
|
363
|
+
if diagnosis.error_type == "NameError":
|
|
364
|
+
missing = self._classifier.extract_missing_name(diagnosis)
|
|
365
|
+
if missing is None:
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
if missing in {
|
|
369
|
+
"math",
|
|
370
|
+
"json",
|
|
371
|
+
"os",
|
|
372
|
+
"sys",
|
|
373
|
+
"re",
|
|
374
|
+
"time",
|
|
375
|
+
"datetime",
|
|
376
|
+
"collections",
|
|
377
|
+
"itertools",
|
|
378
|
+
"pathlib",
|
|
379
|
+
"typing",
|
|
380
|
+
}:
|
|
381
|
+
return f"import {missing}\n{code}"
|
|
382
|
+
return f"{missing} = None\n{code}"
|
|
383
|
+
|
|
384
|
+
if diagnosis.error_type in {"ImportError", "ModuleNotFoundError"}:
|
|
385
|
+
missing = self._classifier.extract_missing_module(diagnosis)
|
|
386
|
+
if missing is None:
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
# Simple alias fallback for common data-science package names.
|
|
390
|
+
aliases: dict[str, str] = {
|
|
391
|
+
"sklearn": "import sklearn",
|
|
392
|
+
"pandas": "import pandas as pd",
|
|
393
|
+
"numpy": "import numpy as np",
|
|
394
|
+
"matplotlib": "import matplotlib",
|
|
395
|
+
}
|
|
396
|
+
if missing in aliases:
|
|
397
|
+
return f"{aliases[missing]}\n{code}"
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
if diagnosis.error_type in {
|
|
401
|
+
"IndexError",
|
|
402
|
+
"TypeError",
|
|
403
|
+
"AttributeError",
|
|
404
|
+
"ZeroDivisionError",
|
|
405
|
+
"ValueError",
|
|
406
|
+
"KeyError",
|
|
407
|
+
}:
|
|
408
|
+
# These categories require domain knowledge to patch safely.
|
|
409
|
+
# Gemini handles them when the LLM path is configured.
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
def __enter__(self) -> SelfHealingEngine:
|
|
415
|
+
"""Ensure the sandbox exists when used as a context manager."""
|
|
416
|
+
self._sandbox.create()
|
|
417
|
+
return self
|
|
418
|
+
|
|
419
|
+
def __exit__(self, *exc: object) -> None:
|
|
420
|
+
self._sandbox.cleanup()
|
codeshield/runner.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Subprocess runner that executes Python code inside a ``uv`` sandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
|
|
14
|
+
from codeshield.environment import SandboxError, SandboxManager
|
|
15
|
+
from codeshield.schemas import CodeExecutionRequest, ExecutionResult
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
DEFAULT_TIMEOUT_SECONDS: float = 60.0
|
|
21
|
+
DEFAULT_SILENT_PATTERNS: tuple[str, ...] = (
|
|
22
|
+
r"empty\s+[Dd]ata[Ff]rame",
|
|
23
|
+
r"all\s+[Nn]a[Nn]",
|
|
24
|
+
r"Traceback",
|
|
25
|
+
r"Pipeline\s+failed",
|
|
26
|
+
r"[Ff]atal\s+[Ee]rror",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SubprocessRunnerError(RuntimeError):
|
|
31
|
+
"""Raised when the runner cannot prepare or launch a code execution."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SubprocessRunner:
|
|
35
|
+
"""Execute Python source code in an isolated subprocess with streaming I/O."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
sandbox: SandboxManager,
|
|
40
|
+
default_timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
41
|
+
silent_failure_patterns: Iterable[str] | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Initialize the runner.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
sandbox: ``SandboxManager`` that provides the isolated interpreter.
|
|
47
|
+
default_timeout: Default execution timeout in seconds.
|
|
48
|
+
silent_failure_patterns: Optional regex patterns used to detect silent
|
|
49
|
+
failures inside process output.
|
|
50
|
+
"""
|
|
51
|
+
if default_timeout <= 0:
|
|
52
|
+
raise SubprocessRunnerError("default_timeout must be greater than 0")
|
|
53
|
+
|
|
54
|
+
self._sandbox = sandbox
|
|
55
|
+
self._default_timeout = default_timeout
|
|
56
|
+
self._silent_patterns = [
|
|
57
|
+
re.compile(pattern, re.IGNORECASE)
|
|
58
|
+
for pattern in (silent_failure_patterns or DEFAULT_SILENT_PATTERNS)
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
def run(
|
|
62
|
+
self,
|
|
63
|
+
request: CodeExecutionRequest | str,
|
|
64
|
+
timeout: float | None = None,
|
|
65
|
+
) -> ExecutionResult:
|
|
66
|
+
"""Run ``request.code`` inside the sandbox and return the result.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
request: Either a ``CodeExecutionRequest`` or a raw source string.
|
|
70
|
+
timeout: Optional override for the execution timeout. Defaults to
|
|
71
|
+
``default_timeout`` or ``request.timeout_seconds`` when a request
|
|
72
|
+
object is supplied.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
An ``ExecutionResult`` with captured output, timing and flags.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
SubprocessRunnerError: when the sandbox interpreter is missing.
|
|
79
|
+
"""
|
|
80
|
+
if isinstance(request, str):
|
|
81
|
+
request = CodeExecutionRequest(code=request)
|
|
82
|
+
|
|
83
|
+
effective_timeout = timeout if timeout is not None else request.timeout_seconds
|
|
84
|
+
file_name = request.file_name or "script.py"
|
|
85
|
+
script_path = self._sandbox.workspace / file_name
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
script_path.write_text(request.code, encoding="utf-8")
|
|
89
|
+
except OSError as exc:
|
|
90
|
+
raise SubprocessRunnerError(f"Failed to write script to {script_path}: {exc}") from exc
|
|
91
|
+
|
|
92
|
+
if not self._sandbox.python_executable.exists():
|
|
93
|
+
raise SubprocessRunnerError(
|
|
94
|
+
f"Sandbox interpreter not found at {self._sandbox.python_executable}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
self._install_requirements_if_needed(request.requirements)
|
|
98
|
+
|
|
99
|
+
cmd = [str(self._sandbox.python_executable), str(script_path)]
|
|
100
|
+
env = self._build_environment()
|
|
101
|
+
|
|
102
|
+
logger.info("Executing %s with timeout %.1fs", script_path, effective_timeout)
|
|
103
|
+
return self._run_subprocess(cmd, env, effective_timeout)
|
|
104
|
+
|
|
105
|
+
def _install_requirements_if_needed(self, packages: list[str]) -> None:
|
|
106
|
+
"""Install requested packages in the sandbox before execution."""
|
|
107
|
+
if not packages:
|
|
108
|
+
return
|
|
109
|
+
try:
|
|
110
|
+
self._sandbox.install_requirements(packages)
|
|
111
|
+
except SandboxError as exc:
|
|
112
|
+
raise SubprocessRunnerError(f"Failed to install requirements: {exc}") from exc
|
|
113
|
+
|
|
114
|
+
def _build_environment(self) -> dict[str, str]:
|
|
115
|
+
"""Return the environment for the subprocess.
|
|
116
|
+
|
|
117
|
+
Inherit ``PATH`` from the parent so that ``uv``-managed binaries work,
|
|
118
|
+
but remove Python-specific variables that could leak the parent venv.
|
|
119
|
+
"""
|
|
120
|
+
env = os.environ.copy()
|
|
121
|
+
for key in ("VIRTUAL_ENV", "PYTHONHOME", "PYTHONPATH"):
|
|
122
|
+
env.pop(key, None)
|
|
123
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
124
|
+
return env
|
|
125
|
+
|
|
126
|
+
def _run_subprocess(
|
|
127
|
+
self,
|
|
128
|
+
cmd: list[str],
|
|
129
|
+
env: dict[str, str],
|
|
130
|
+
timeout: float,
|
|
131
|
+
) -> ExecutionResult:
|
|
132
|
+
"""Launch the process, stream I/O, enforce timeout and return result."""
|
|
133
|
+
start = time.monotonic()
|
|
134
|
+
try:
|
|
135
|
+
process = subprocess.Popen(
|
|
136
|
+
cmd,
|
|
137
|
+
stdout=subprocess.PIPE,
|
|
138
|
+
stderr=subprocess.PIPE,
|
|
139
|
+
text=True,
|
|
140
|
+
env=env,
|
|
141
|
+
bufsize=1,
|
|
142
|
+
)
|
|
143
|
+
except OSError as exc:
|
|
144
|
+
raise SubprocessRunnerError(f"Failed to start subprocess: {exc}") from exc
|
|
145
|
+
|
|
146
|
+
stdout_lines: list[str] = []
|
|
147
|
+
stderr_lines: list[str] = []
|
|
148
|
+
|
|
149
|
+
def reader(pipe, sink: list[str]) -> None:
|
|
150
|
+
"""Read lines from a pipe without blocking the main thread."""
|
|
151
|
+
try:
|
|
152
|
+
for line in pipe:
|
|
153
|
+
sink.append(line)
|
|
154
|
+
except Exception as exc: # noqa: BLE001
|
|
155
|
+
logger.debug("Stream reader terminated: %s", exc)
|
|
156
|
+
finally:
|
|
157
|
+
pipe.close()
|
|
158
|
+
|
|
159
|
+
stdout_thread = threading.Thread(
|
|
160
|
+
target=reader,
|
|
161
|
+
args=(process.stdout, stdout_lines),
|
|
162
|
+
daemon=True,
|
|
163
|
+
)
|
|
164
|
+
stderr_thread = threading.Thread(
|
|
165
|
+
target=reader,
|
|
166
|
+
args=(process.stderr, stderr_lines),
|
|
167
|
+
daemon=True,
|
|
168
|
+
)
|
|
169
|
+
stdout_thread.start()
|
|
170
|
+
stderr_thread.start()
|
|
171
|
+
|
|
172
|
+
timed_out = False
|
|
173
|
+
try:
|
|
174
|
+
process.wait(timeout=timeout)
|
|
175
|
+
except subprocess.TimeoutExpired:
|
|
176
|
+
timed_out = True
|
|
177
|
+
logger.warning("Process exceeded timeout %.1fs; terminating", timeout)
|
|
178
|
+
self._terminate_process(process)
|
|
179
|
+
|
|
180
|
+
# Give the reader threads a short grace period to flush remaining bytes.
|
|
181
|
+
stdout_thread.join(timeout=1.0)
|
|
182
|
+
stderr_thread.join(timeout=1.0)
|
|
183
|
+
|
|
184
|
+
duration = time.monotonic() - start
|
|
185
|
+
stdout = "".join(stdout_lines)
|
|
186
|
+
stderr = "".join(stderr_lines)
|
|
187
|
+
|
|
188
|
+
exit_code = process.returncode
|
|
189
|
+
if exit_code is None:
|
|
190
|
+
exit_code = -1
|
|
191
|
+
|
|
192
|
+
silent_failure = self._detect_silent_failure(exit_code, stdout, stderr)
|
|
193
|
+
|
|
194
|
+
return ExecutionResult(
|
|
195
|
+
stdout=stdout,
|
|
196
|
+
stderr=stderr,
|
|
197
|
+
exit_code=exit_code,
|
|
198
|
+
duration_seconds=round(duration, 6),
|
|
199
|
+
silent_failure_detected=silent_failure,
|
|
200
|
+
timed_out=timed_out,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _terminate_process(self, process: subprocess.Popen) -> None:
|
|
204
|
+
"""Gracefully terminate and, if necessary, kill the process."""
|
|
205
|
+
with contextlib.suppress(OSError):
|
|
206
|
+
process.terminate()
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
process.wait(timeout=2.0)
|
|
210
|
+
except subprocess.TimeoutExpired:
|
|
211
|
+
with contextlib.suppress(OSError):
|
|
212
|
+
process.kill()
|
|
213
|
+
process.wait(timeout=2.0)
|
|
214
|
+
|
|
215
|
+
def _detect_silent_failure(self, exit_code: int, stdout: str, stderr: str) -> bool:
|
|
216
|
+
"""Return ``True`` when a zero exit code hides suspicious output patterns."""
|
|
217
|
+
if exit_code != 0:
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
combined = f"{stdout}\n{stderr}"
|
|
221
|
+
return any(pattern.search(combined) for pattern in self._silent_patterns)
|