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/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""CodeShield.
|
|
2
|
+
|
|
3
|
+
A secure, isolated, and self-healing Python code execution engine.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
from codeshield.loop import SelfHealingEngine
|
|
9
|
+
from codeshield.schemas import CodeExecutionRequest, ExecutionResult
|
|
10
|
+
from codeshield.tools import create_code_execution_tool
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"SelfHealingEngine",
|
|
14
|
+
"CodeExecutionRequest",
|
|
15
|
+
"ExecutionResult",
|
|
16
|
+
"create_code_execution_tool",
|
|
17
|
+
]
|
codeshield/__main__.py
ADDED
codeshield/analyzer.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Static syntax and safety analysis using only the standard ``ast`` module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from codeshield.schemas import ValidationReport
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Parametrizable functions considered dangerous when invoked directly.
|
|
15
|
+
_DANGEROUS_NAMES: frozenset[str] = frozenset({
|
|
16
|
+
"eval",
|
|
17
|
+
"exec",
|
|
18
|
+
"compile",
|
|
19
|
+
"os.system",
|
|
20
|
+
"subprocess.call",
|
|
21
|
+
"subprocess.run",
|
|
22
|
+
"subprocess.Popen",
|
|
23
|
+
})
|
|
24
|
+
_GENERIC_EXCEPTIONS: frozenset[str] = frozenset({"Exception", "BaseException"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class _SafetyContext:
|
|
29
|
+
"""Mutable collector used by the AST visitor."""
|
|
30
|
+
|
|
31
|
+
violations: list[str] = field(default_factory=list)
|
|
32
|
+
line_numbers: dict[int, str] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
def add_violation(self, message: str, *, line: int | None = None) -> None:
|
|
35
|
+
"""Append a violation with an optional source line hint."""
|
|
36
|
+
if line is not None:
|
|
37
|
+
message = f"Line {line}: {message}"
|
|
38
|
+
self.violations.append(message)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _SafetyVisitor(ast.NodeVisitor):
|
|
42
|
+
"""AST visitor that flags generic exception handlers and dangerous calls."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, context: _SafetyContext) -> None:
|
|
45
|
+
self._context = context
|
|
46
|
+
|
|
47
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None: # noqa: N802
|
|
48
|
+
"""Detect overly broad exception handlers."""
|
|
49
|
+
self._check_generic_except(node)
|
|
50
|
+
self.generic_visit(node)
|
|
51
|
+
|
|
52
|
+
def visit_Call(self, node: ast.Call) -> None: # noqa: N802
|
|
53
|
+
"""Detect dangerous parametrizable function calls."""
|
|
54
|
+
self._check_dangerous_call(node)
|
|
55
|
+
self.generic_visit(node)
|
|
56
|
+
|
|
57
|
+
def _check_generic_except(self, node: ast.ExceptHandler) -> None:
|
|
58
|
+
"""Flag ``except:``, ``except Exception:`` and ``except BaseException:``."""
|
|
59
|
+
if node.type is None:
|
|
60
|
+
self._context.add_violation(
|
|
61
|
+
"Bare ``except:`` clause is not allowed because it catches all exceptions",
|
|
62
|
+
line=node.lineno,
|
|
63
|
+
)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
name = self._name_from_node(node.type)
|
|
67
|
+
if name in _GENERIC_EXCEPTIONS:
|
|
68
|
+
self._context.add_violation(
|
|
69
|
+
f"Overly generic exception handler ``except {name}:`` is not allowed",
|
|
70
|
+
line=node.lineno,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def _check_dangerous_call(self, node: ast.Call) -> None:
|
|
74
|
+
"""Flag direct calls to eval/exec/compile."""
|
|
75
|
+
name = self._name_from_node(node.func)
|
|
76
|
+
if name in _DANGEROUS_NAMES:
|
|
77
|
+
self._context.add_violation(
|
|
78
|
+
f"Dangerous parametrizable function call ``{name}()`` is not allowed",
|
|
79
|
+
line=node.lineno,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _name_from_node(node: ast.AST | None) -> str:
|
|
84
|
+
"""Return a best-effort dotted name for an AST node."""
|
|
85
|
+
if node is None:
|
|
86
|
+
return ""
|
|
87
|
+
if isinstance(node, ast.Name):
|
|
88
|
+
return node.id
|
|
89
|
+
if isinstance(node, ast.Attribute):
|
|
90
|
+
return f"{_SafetyVisitor._name_from_node(node.value)}.{node.attr}"
|
|
91
|
+
if isinstance(node, ast.Subscript):
|
|
92
|
+
return _SafetyVisitor._name_from_node(node.value)
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _lines_from_source(code: str) -> dict[int, str]:
|
|
97
|
+
"""Map 1-indexed line numbers to stripped source lines."""
|
|
98
|
+
return {idx + 1: line for idx, line in enumerate(code.splitlines())}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _split_first_line(code: str) -> tuple[str, ...]:
|
|
102
|
+
"""Return source lines as a tuple to keep them hashable when needed."""
|
|
103
|
+
return tuple(code.splitlines())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def validate_syntax_and_safety(code: str) -> ValidationReport:
|
|
107
|
+
"""Validate ``code`` using the standard ``ast`` module.
|
|
108
|
+
|
|
109
|
+
The validation detects:
|
|
110
|
+
- Syntax errors raised by ``ast.parse``.
|
|
111
|
+
- Bare ``except:`` clauses and handlers using ``Exception``/``BaseException``.
|
|
112
|
+
- Direct calls to dangerous parametrizable functions: ``eval``, ``exec`` and ``compile``.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
code: Python source code to validate.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
A ``ValidationReport`` containing the result and any violations.
|
|
119
|
+
"""
|
|
120
|
+
lines = _lines_from_source(code)
|
|
121
|
+
try:
|
|
122
|
+
tree = ast.parse(code, filename="<dynamic>", mode="exec")
|
|
123
|
+
except SyntaxError as exc:
|
|
124
|
+
location = f"line {exc.lineno}" if exc.lineno else "unknown location"
|
|
125
|
+
text = lines.get(exc.lineno, "") if exc.lineno else ""
|
|
126
|
+
pointer = f"\n{text}\n{' ' * ((exc.offset or 1) - 1)}^" if exc.offset and text else ""
|
|
127
|
+
message = f"SyntaxError at {location}: {exc.msg}{pointer}"
|
|
128
|
+
logger.debug("Syntax validation failed: %s", message)
|
|
129
|
+
return ValidationReport(
|
|
130
|
+
is_valid=False,
|
|
131
|
+
violations=[message],
|
|
132
|
+
exception=exc,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
context = _SafetyContext(line_numbers=lines)
|
|
136
|
+
visitor = _SafetyVisitor(context)
|
|
137
|
+
visitor.visit(tree)
|
|
138
|
+
|
|
139
|
+
is_valid = not context.violations
|
|
140
|
+
logger.debug(
|
|
141
|
+
"Safety validation completed: %d violation(s) found.",
|
|
142
|
+
len(context.violations),
|
|
143
|
+
)
|
|
144
|
+
return ValidationReport(
|
|
145
|
+
is_valid=is_valid,
|
|
146
|
+
violations=context.violations,
|
|
147
|
+
exception=None,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__: list[str] = ["validate_syntax_and_safety"]
|
codeshield/classifier.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Traceback parsing and error classification utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from codeshield.schemas import ErrorDiagnosis
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TracebackClassifier:
|
|
14
|
+
"""Extract structured diagnostics from Python tracebacks."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, context_radius: int = 2) -> None:
|
|
17
|
+
"""Initialize the classifier.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
context_radius: Number of source lines to include before and after
|
|
21
|
+
the failing line.
|
|
22
|
+
"""
|
|
23
|
+
self._context_radius = max(0, context_radius)
|
|
24
|
+
|
|
25
|
+
def classify(self, code: str, stderr: str) -> ErrorDiagnosis | None:
|
|
26
|
+
"""Extract an ``ErrorDiagnosis`` from ``stderr``.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
code: Original source code used to provide context.
|
|
30
|
+
stderr: Standard error produced by the executed process.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
An ``ErrorDiagnosis`` if a traceback could be parsed, otherwise ``None``.
|
|
34
|
+
"""
|
|
35
|
+
if not stderr.strip():
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
if "Traceback" not in stderr:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
exception_match = self._extract_exception_line(stderr)
|
|
42
|
+
if not exception_match:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
exc_type, exc_message = exception_match
|
|
46
|
+
line_no = self._extract_failing_line(stderr, code)
|
|
47
|
+
context = self._extract_context(code, line_no)
|
|
48
|
+
|
|
49
|
+
logger.debug(
|
|
50
|
+
"Classified %s at line %s: %s",
|
|
51
|
+
exc_type,
|
|
52
|
+
line_no,
|
|
53
|
+
exc_message,
|
|
54
|
+
)
|
|
55
|
+
return ErrorDiagnosis(
|
|
56
|
+
error_type=exc_type,
|
|
57
|
+
root_cause_line=line_no,
|
|
58
|
+
message=exc_message,
|
|
59
|
+
context=context,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def classify_from_result(self, code: str, stderr: str) -> ErrorDiagnosis:
|
|
63
|
+
"""Return a diagnosis, falling back to a generic one when parsing fails."""
|
|
64
|
+
diagnosis = self.classify(code, stderr)
|
|
65
|
+
if diagnosis is not None:
|
|
66
|
+
return diagnosis
|
|
67
|
+
|
|
68
|
+
return ErrorDiagnosis(
|
|
69
|
+
error_type="UnknownError",
|
|
70
|
+
root_cause_line=None,
|
|
71
|
+
message=stderr.strip() or "An unknown error occurred during execution.",
|
|
72
|
+
context=[],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _extract_exception_line(self, stderr: str) -> tuple[str, str] | None:
|
|
76
|
+
"""Parse the final traceback line ``ExceptionName: message``.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
A tuple ``(exception_name, message)`` or ``None``.
|
|
80
|
+
"""
|
|
81
|
+
# Find the last line starting after the traceback header that matches
|
|
82
|
+
# an exception declaration. We scan all lines and keep the last valid one.
|
|
83
|
+
in_traceback = False
|
|
84
|
+
last_match: tuple[str, str] | None = None
|
|
85
|
+
|
|
86
|
+
for raw_line in stderr.splitlines():
|
|
87
|
+
line = raw_line.rstrip()
|
|
88
|
+
if in_traceback:
|
|
89
|
+
pattern = r"^([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*):\s*(.*)$"
|
|
90
|
+
match = re.match(pattern, line)
|
|
91
|
+
if match:
|
|
92
|
+
exception_name = match.group(1)
|
|
93
|
+
message = match.group(2).strip()
|
|
94
|
+
# Ignore built-in notes that follow the exception line.
|
|
95
|
+
last_match = (exception_name, message)
|
|
96
|
+
if line.startswith("Traceback"):
|
|
97
|
+
in_traceback = True
|
|
98
|
+
|
|
99
|
+
return last_match
|
|
100
|
+
|
|
101
|
+
def _extract_failing_line(self, stderr: str, code: str) -> int | None:
|
|
102
|
+
"""Determine the most relevant failing source line from the traceback.
|
|
103
|
+
|
|
104
|
+
The method looks for the deepest file in the executed code, and if the
|
|
105
|
+
script is not external, returns its line number. If only external frames
|
|
106
|
+
are available, the last line number is returned as a fallback.
|
|
107
|
+
"""
|
|
108
|
+
# Pattern: File "...", line X, in ...
|
|
109
|
+
pattern = re.compile(r'File "([^"]+)", line (\d+), in (.+)')
|
|
110
|
+
lines = code.splitlines()
|
|
111
|
+
|
|
112
|
+
last_line: int | None = None
|
|
113
|
+
for raw in reversed(stderr.splitlines()):
|
|
114
|
+
match = pattern.search(raw)
|
|
115
|
+
if not match:
|
|
116
|
+
continue
|
|
117
|
+
file_path, line_no_str, _ = match.groups()
|
|
118
|
+
line_no = int(line_no_str)
|
|
119
|
+
last_line = line_no
|
|
120
|
+
|
|
121
|
+
# If the frame references the inline script, use it directly.
|
|
122
|
+
if "<string>" in file_path or file_path.endswith("script.py"):
|
|
123
|
+
return line_no
|
|
124
|
+
|
|
125
|
+
# Heuristic: line number is inside the provided source range.
|
|
126
|
+
if 1 <= line_no <= len(lines):
|
|
127
|
+
return line_no
|
|
128
|
+
|
|
129
|
+
return last_line
|
|
130
|
+
|
|
131
|
+
def _extract_context(self, code: str, line_no: int | None) -> list[str]:
|
|
132
|
+
"""Return surrounding source lines for ``line_no``."""
|
|
133
|
+
if line_no is None:
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
lines = code.splitlines()
|
|
137
|
+
if not lines:
|
|
138
|
+
return []
|
|
139
|
+
|
|
140
|
+
start = max(1, line_no - self._context_radius)
|
|
141
|
+
end = min(len(lines), line_no + self._context_radius)
|
|
142
|
+
return [f"{idx}: {lines[idx - 1]}" for idx in range(start, end + 1)]
|
|
143
|
+
|
|
144
|
+
def extract_missing_name(self, diagnosis: ErrorDiagnosis) -> str | None:
|
|
145
|
+
"""For ``NameError``, try to extract the missing identifier."""
|
|
146
|
+
if diagnosis.error_type != "NameError":
|
|
147
|
+
return None
|
|
148
|
+
match = re.search(r"name '([^']+)' is not defined", diagnosis.message)
|
|
149
|
+
if match:
|
|
150
|
+
return match.group(1)
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
def extract_missing_module(self, diagnosis: ErrorDiagnosis) -> str | None:
|
|
154
|
+
"""For ``ImportError``/``ModuleNotFoundError``, try to extract the module name."""
|
|
155
|
+
if diagnosis.error_type not in {"ImportError", "ModuleNotFoundError"}:
|
|
156
|
+
return None
|
|
157
|
+
match = re.search(r"No module named '([^']+)'", diagnosis.message)
|
|
158
|
+
if match:
|
|
159
|
+
return match.group(1)
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
__all__: list[str] = ["TracebackClassifier"]
|
codeshield/cli.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Command-line interface for the execution engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from codeshield.loop import SelfHealingEngine
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
"""Return the argument parser for the CLI."""
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="codeshield",
|
|
19
|
+
description="Deterministic, isolated, and self-healing Python code execution.",
|
|
20
|
+
)
|
|
21
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
22
|
+
|
|
23
|
+
run_parser = subparsers.add_parser(
|
|
24
|
+
"run",
|
|
25
|
+
help="Execute a Python source file inside a sandbox.",
|
|
26
|
+
)
|
|
27
|
+
run_parser.add_argument(
|
|
28
|
+
"file",
|
|
29
|
+
type=Path,
|
|
30
|
+
help="Path to the Python file to execute.",
|
|
31
|
+
)
|
|
32
|
+
run_parser.add_argument(
|
|
33
|
+
"--timeout",
|
|
34
|
+
type=float,
|
|
35
|
+
default=60.0,
|
|
36
|
+
help="Execution timeout in seconds (default: 60).",
|
|
37
|
+
)
|
|
38
|
+
run_parser.add_argument(
|
|
39
|
+
"--llm",
|
|
40
|
+
action="store_true",
|
|
41
|
+
default=True,
|
|
42
|
+
help="Enable LLM-guided self-healing when GEMINI_API_KEY is set (default: on).",
|
|
43
|
+
)
|
|
44
|
+
run_parser.add_argument(
|
|
45
|
+
"--no-llm",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="Disable LLM-guided self-healing and use the local fallback.",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return parser
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read_file(file_path: Path) -> str:
|
|
54
|
+
"""Read and return the contents of ``file_path``."""
|
|
55
|
+
try:
|
|
56
|
+
return file_path.read_text(encoding="utf-8")
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
logger.error("Could not read %s: %s", file_path, exc)
|
|
59
|
+
raise SystemExit(1) from exc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv: list[str] | None = None) -> int:
|
|
63
|
+
"""Entry point for the CLI."""
|
|
64
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
65
|
+
|
|
66
|
+
parser = _build_parser()
|
|
67
|
+
args = parser.parse_args(argv)
|
|
68
|
+
|
|
69
|
+
if args.command != "run":
|
|
70
|
+
parser.print_help()
|
|
71
|
+
return 2
|
|
72
|
+
|
|
73
|
+
if not args.file.exists():
|
|
74
|
+
logger.error("File not found: %s", args.file)
|
|
75
|
+
return 1
|
|
76
|
+
|
|
77
|
+
code = _read_file(args.file)
|
|
78
|
+
use_llm = not args.no_llm if args.no_llm else args.llm
|
|
79
|
+
|
|
80
|
+
engine = SelfHealingEngine(use_llm=use_llm)
|
|
81
|
+
with engine:
|
|
82
|
+
result, diagnosis = engine.run(code, timeout=args.timeout)
|
|
83
|
+
|
|
84
|
+
print("--- STDOUT ---")
|
|
85
|
+
print(result.stdout)
|
|
86
|
+
if result.stderr:
|
|
87
|
+
print("--- STDERR ---")
|
|
88
|
+
print(result.stderr)
|
|
89
|
+
|
|
90
|
+
if result.timed_out:
|
|
91
|
+
print("Execution timed out")
|
|
92
|
+
if result.silent_failure_detected:
|
|
93
|
+
print("Silent failure detected")
|
|
94
|
+
if diagnosis:
|
|
95
|
+
print(f"Diagnosis: {diagnosis.error_type} - {diagnosis.message}")
|
|
96
|
+
|
|
97
|
+
return 0 if result.exit_code == 0 and not result.silent_failure_detected else 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
sys.exit(main())
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Management of isolated ``uv`` virtual environments used as sandboxes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SandboxError(RuntimeError):
|
|
17
|
+
"""Raised when a sandbox cannot be created, destroyed or updated."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SandboxManager:
|
|
21
|
+
"""Create, populate and clean up an isolated virtual environment.
|
|
22
|
+
|
|
23
|
+
The manager prefers the ``uv`` toolchain when available, and transparently
|
|
24
|
+
falls back to the standard ``venv``/``pip`` tooling otherwise. This makes
|
|
25
|
+
the package usable on systems where ``uv`` has not yet been installed while
|
|
26
|
+
still taking advantage of ``uv`` when it is present.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
workspace: Path | str | None = None,
|
|
32
|
+
venv_name: str = ".venv",
|
|
33
|
+
python: str | None = None,
|
|
34
|
+
keep: bool = False,
|
|
35
|
+
backend: str | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Initialize a sandbox manager.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
workspace: Directory that will host the virtual environment. If ``None``,
|
|
41
|
+
a temporary directory is created.
|
|
42
|
+
venv_name: Name of the virtual environment directory inside ``workspace``.
|
|
43
|
+
python: Python version or interpreter requested for the backend.
|
|
44
|
+
keep: If ``False`` (default), the workspace is removed when the manager
|
|
45
|
+
is garbage collected or ``cleanup`` is called.
|
|
46
|
+
backend: Environment backend to use. ``"uv"`` or ``"venv"``. When ``None``,
|
|
47
|
+
``uv`` is auto-detected and preferred.
|
|
48
|
+
"""
|
|
49
|
+
self._workspace = (
|
|
50
|
+
Path(workspace)
|
|
51
|
+
if workspace
|
|
52
|
+
else Path(tempfile.mkdtemp(prefix="codeshield_"))
|
|
53
|
+
)
|
|
54
|
+
self._venv_name = venv_name
|
|
55
|
+
self._python = python or sys.executable
|
|
56
|
+
self._keep = keep
|
|
57
|
+
self._backend = self._resolve_backend(backend)
|
|
58
|
+
self._uv_path: Path | None = None
|
|
59
|
+
self._venv_path = self._workspace / self._venv_name
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def workspace(self) -> Path:
|
|
63
|
+
"""Return the root sandbox workspace directory."""
|
|
64
|
+
return self._workspace
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def venv_path(self) -> Path:
|
|
68
|
+
"""Return the virtual environment directory."""
|
|
69
|
+
return self._venv_path
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def python_executable(self) -> Path:
|
|
73
|
+
"""Return the interpreter path inside the virtual environment."""
|
|
74
|
+
if self._is_windows():
|
|
75
|
+
return self._venv_path / "Scripts" / "python.exe"
|
|
76
|
+
return self._venv_path / "bin" / "python"
|
|
77
|
+
|
|
78
|
+
def uv_executable(self) -> Path:
|
|
79
|
+
"""Resolve and cache the ``uv`` executable path.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
SandboxError: when ``uv`` is not available on ``PATH``.
|
|
83
|
+
"""
|
|
84
|
+
if self._uv_path is None:
|
|
85
|
+
uv = shutil.which("uv")
|
|
86
|
+
if uv is None:
|
|
87
|
+
raise SandboxError(
|
|
88
|
+
"The `uv` executable was not found on PATH. "
|
|
89
|
+
"Install uv from https://github.com/astral-sh/uv"
|
|
90
|
+
)
|
|
91
|
+
self._uv_path = Path(uv)
|
|
92
|
+
return self._uv_path
|
|
93
|
+
|
|
94
|
+
def create(self) -> Path:
|
|
95
|
+
"""Create the virtual environment.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The path to the virtual environment.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
SandboxError: if the backend fails to create the environment.
|
|
102
|
+
"""
|
|
103
|
+
self._workspace.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
|
|
105
|
+
if self._backend == "uv":
|
|
106
|
+
self._create_with_uv()
|
|
107
|
+
else:
|
|
108
|
+
self._create_with_venv()
|
|
109
|
+
|
|
110
|
+
if not self.python_executable.exists():
|
|
111
|
+
raise SandboxError(
|
|
112
|
+
f"Virtual environment was created but interpreter not found at "
|
|
113
|
+
f"{self.python_executable}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
logger.info("Virtual environment ready at %s", self._venv_path)
|
|
117
|
+
return self._venv_path
|
|
118
|
+
|
|
119
|
+
def _create_with_uv(self) -> None:
|
|
120
|
+
"""Create a virtual environment using ``uv venv``."""
|
|
121
|
+
cmd: list[str] = [str(self.uv_executable()), "venv"]
|
|
122
|
+
if self._python:
|
|
123
|
+
cmd.extend(["--python", self._python])
|
|
124
|
+
cmd.append(str(self._venv_name))
|
|
125
|
+
|
|
126
|
+
logger.info("Creating uv venv at %s", self._venv_path)
|
|
127
|
+
result = self._run_command(cmd, cwd=self._workspace)
|
|
128
|
+
if result.returncode != 0:
|
|
129
|
+
raise SandboxError(
|
|
130
|
+
f"`uv venv` failed (exit {result.returncode}): "
|
|
131
|
+
f"{result.stderr or result.stdout}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def _create_with_venv(self) -> None:
|
|
135
|
+
"""Create a virtual environment using the standard ``venv`` module."""
|
|
136
|
+
cmd = [self._python, "-m", "venv", str(self._venv_name)]
|
|
137
|
+
logger.info("Creating venv with %s at %s", self._python, self._venv_path)
|
|
138
|
+
result = self._run_command(cmd, cwd=self._workspace)
|
|
139
|
+
if result.returncode != 0:
|
|
140
|
+
raise SandboxError(
|
|
141
|
+
f"`python -m venv` failed (exit {result.returncode}): "
|
|
142
|
+
f"{result.stderr or result.stdout}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def install_requirements(self, requirements: Sequence[str] | Path | str) -> None:
|
|
146
|
+
"""Install packages from a list or a ``requirements.txt`` path.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
requirements: Either a sequence of package specifiers or a path
|
|
150
|
+
to a requirements file.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
SandboxError: if the installation fails.
|
|
154
|
+
"""
|
|
155
|
+
if isinstance(requirements, (str, Path)):
|
|
156
|
+
requirements_path = Path(requirements)
|
|
157
|
+
if not requirements_path.exists():
|
|
158
|
+
raise SandboxError(f"Requirements file not found: {requirements_path}")
|
|
159
|
+
|
|
160
|
+
if self._backend == "uv":
|
|
161
|
+
cmd = [
|
|
162
|
+
str(self.uv_executable()),
|
|
163
|
+
"pip",
|
|
164
|
+
"install",
|
|
165
|
+
"-r",
|
|
166
|
+
str(requirements_path),
|
|
167
|
+
]
|
|
168
|
+
else:
|
|
169
|
+
cmd = [
|
|
170
|
+
str(self.python_executable),
|
|
171
|
+
"-m",
|
|
172
|
+
"pip",
|
|
173
|
+
"install",
|
|
174
|
+
"-r",
|
|
175
|
+
str(requirements_path),
|
|
176
|
+
]
|
|
177
|
+
else:
|
|
178
|
+
if not requirements:
|
|
179
|
+
return
|
|
180
|
+
if self._backend == "uv":
|
|
181
|
+
cmd = [str(self.uv_executable()), "pip", "install", *requirements]
|
|
182
|
+
else:
|
|
183
|
+
cmd = [str(self.python_executable), "-m", "pip", "install", *requirements]
|
|
184
|
+
|
|
185
|
+
logger.info("Installing requirements in sandbox: %s", cmd)
|
|
186
|
+
result = self._run_command(cmd, cwd=self._workspace)
|
|
187
|
+
if result.returncode != 0:
|
|
188
|
+
raise SandboxError(
|
|
189
|
+
f"Requirement installation failed (exit {result.returncode}): "
|
|
190
|
+
f"{result.stderr or result.stdout}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def write_requirements_file(
|
|
194
|
+
self, packages: Sequence[str], file_name: str = "requirements.txt"
|
|
195
|
+
) -> Path:
|
|
196
|
+
"""Write package specifiers to ``workspace/requirements.txt``.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Path of the written file.
|
|
200
|
+
"""
|
|
201
|
+
path = self._workspace / file_name
|
|
202
|
+
path.write_text("\n".join(packages) + "\n", encoding="utf-8")
|
|
203
|
+
return path
|
|
204
|
+
|
|
205
|
+
def cleanup(self) -> None:
|
|
206
|
+
"""Remove the workspace unless ``keep`` was set to ``True``."""
|
|
207
|
+
if self._keep or not self._workspace.exists():
|
|
208
|
+
return
|
|
209
|
+
logger.info("Cleaning up sandbox workspace: %s", self._workspace)
|
|
210
|
+
try:
|
|
211
|
+
shutil.rmtree(self._workspace, ignore_errors=True)
|
|
212
|
+
except OSError as exc:
|
|
213
|
+
logger.warning("Could not remove workspace %s: %s", self._workspace, exc)
|
|
214
|
+
|
|
215
|
+
def __enter__(self) -> SandboxManager:
|
|
216
|
+
self.create()
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
def __exit__(self, *exc: object) -> None:
|
|
220
|
+
self.cleanup()
|
|
221
|
+
|
|
222
|
+
def _resolve_backend(self, backend: str | None) -> str:
|
|
223
|
+
"""Resolve the environment backend, preferring ``uv`` when available."""
|
|
224
|
+
if backend in {"uv", "venv"}:
|
|
225
|
+
return backend
|
|
226
|
+
if backend is not None:
|
|
227
|
+
raise SandboxError(f"Unknown backend '{backend}'; choose 'uv' or 'venv'")
|
|
228
|
+
if shutil.which("uv") is not None:
|
|
229
|
+
return "uv"
|
|
230
|
+
logger.warning(
|
|
231
|
+
"uv was not found on PATH; falling back to the standard venv/pip backend. "
|
|
232
|
+
"Install uv from https://github.com/astral-sh/uv for faster sandboxes."
|
|
233
|
+
)
|
|
234
|
+
return "venv"
|
|
235
|
+
|
|
236
|
+
def _run_command(
|
|
237
|
+
self,
|
|
238
|
+
cmd: list[str],
|
|
239
|
+
cwd: Path,
|
|
240
|
+
) -> subprocess.CompletedProcess[str]:
|
|
241
|
+
"""Run a command and return a ``CompletedProcess`` with text output."""
|
|
242
|
+
try:
|
|
243
|
+
return subprocess.run(
|
|
244
|
+
cmd,
|
|
245
|
+
cwd=cwd,
|
|
246
|
+
capture_output=True,
|
|
247
|
+
text=True,
|
|
248
|
+
check=False,
|
|
249
|
+
)
|
|
250
|
+
except OSError as exc:
|
|
251
|
+
raise SandboxError(f"Failed to spawn command {cmd[0]!r}: {exc}") from exc
|
|
252
|
+
|
|
253
|
+
@staticmethod
|
|
254
|
+
def _is_windows() -> bool:
|
|
255
|
+
"""Return ``True`` when running on Windows."""
|
|
256
|
+
return sys.platform.startswith("win")
|