prompture 0.0.50.dev1__py3-none-any.whl → 0.0.51.dev1__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.
- prompture/__init__.py +62 -0
- prompture/_version.py +2 -2
- prompture/agent.py +61 -2
- prompture/agent_types.py +98 -0
- prompture/analysis/__init__.py +19 -0
- prompture/analysis/analyzer.py +142 -0
- prompture/analysis/ast_visitors.py +302 -0
- prompture/analysis/risk_scoring.py +219 -0
- prompture/history.py +299 -0
- prompture/sandbox/__init__.py +31 -0
- prompture/sandbox/exceptions.py +54 -0
- prompture/sandbox/resource_limits.py +128 -0
- prompture/sandbox/restrictions.py +292 -0
- prompture/sandbox/sandbox.py +406 -0
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/METADATA +1 -1
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/RECORD +20 -10
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/WHEEL +0 -0
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/entry_points.txt +0 -0
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/licenses/LICENSE +0 -0
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/top_level.txt +0 -0
prompture/history.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""History export and filtering utilities for Agent results.
|
|
2
|
+
|
|
3
|
+
Provides functions to filter, search, and analyze agent execution history.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .agent_types import AgentResult, AgentStep, StepType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def filter_steps(
|
|
16
|
+
steps: list[AgentStep],
|
|
17
|
+
*,
|
|
18
|
+
step_type: StepType | list[StepType] | None = None,
|
|
19
|
+
tool_name: str | list[str] | None = None,
|
|
20
|
+
after_timestamp: float | None = None,
|
|
21
|
+
before_timestamp: float | None = None,
|
|
22
|
+
) -> list[AgentStep]:
|
|
23
|
+
"""Filter steps by type, tool name, or timestamp range.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
steps: List of AgentStep objects to filter.
|
|
27
|
+
step_type: Filter by step type (single or list).
|
|
28
|
+
tool_name: Filter by tool name (single or list). Only applies
|
|
29
|
+
to tool_call and tool_result step types.
|
|
30
|
+
after_timestamp: Only include steps after this Unix timestamp.
|
|
31
|
+
before_timestamp: Only include steps before this Unix timestamp.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Filtered list of AgentStep objects.
|
|
35
|
+
|
|
36
|
+
Example::
|
|
37
|
+
|
|
38
|
+
from prompture.history import filter_steps
|
|
39
|
+
from prompture import StepType
|
|
40
|
+
|
|
41
|
+
# Get only tool call steps
|
|
42
|
+
tool_calls = filter_steps(result.steps, step_type=StepType.tool_call)
|
|
43
|
+
|
|
44
|
+
# Get steps for a specific tool
|
|
45
|
+
search_steps = filter_steps(result.steps, tool_name="search")
|
|
46
|
+
|
|
47
|
+
# Get steps in a time range
|
|
48
|
+
recent = filter_steps(result.steps, after_timestamp=start_time)
|
|
49
|
+
"""
|
|
50
|
+
result: list[AgentStep] = []
|
|
51
|
+
|
|
52
|
+
# Normalize step_type to a set
|
|
53
|
+
type_filter: set[StepType] | None = None
|
|
54
|
+
if step_type is not None:
|
|
55
|
+
if isinstance(step_type, list):
|
|
56
|
+
type_filter = set(step_type)
|
|
57
|
+
else:
|
|
58
|
+
type_filter = {step_type}
|
|
59
|
+
|
|
60
|
+
# Normalize tool_name to a set
|
|
61
|
+
tool_filter: set[str] | None = None
|
|
62
|
+
if tool_name is not None:
|
|
63
|
+
if isinstance(tool_name, list):
|
|
64
|
+
tool_filter = set(tool_name)
|
|
65
|
+
else:
|
|
66
|
+
tool_filter = {tool_name}
|
|
67
|
+
|
|
68
|
+
for step in steps:
|
|
69
|
+
# Filter by type
|
|
70
|
+
if type_filter is not None and step.step_type not in type_filter:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
# Filter by tool name
|
|
74
|
+
if tool_filter is not None and (step.tool_name is None or step.tool_name not in tool_filter):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
# Filter by timestamp range
|
|
78
|
+
if after_timestamp is not None and step.timestamp <= after_timestamp:
|
|
79
|
+
continue
|
|
80
|
+
if before_timestamp is not None and step.timestamp >= before_timestamp:
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
result.append(step)
|
|
84
|
+
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def search_messages(
|
|
89
|
+
messages: list[dict[str, Any]],
|
|
90
|
+
*,
|
|
91
|
+
role: str | list[str] | None = None,
|
|
92
|
+
content_contains: str | None = None,
|
|
93
|
+
has_tool_calls: bool | None = None,
|
|
94
|
+
) -> list[dict[str, Any]]:
|
|
95
|
+
"""Search messages by role or content.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
messages: List of message dictionaries from AgentResult.messages.
|
|
99
|
+
role: Filter by role (single or list, e.g., "user", "assistant", "tool").
|
|
100
|
+
content_contains: Filter to messages whose content contains this substring
|
|
101
|
+
(case-insensitive).
|
|
102
|
+
has_tool_calls: If True, only return messages with tool_calls.
|
|
103
|
+
If False, only return messages without tool_calls.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Filtered list of message dictionaries.
|
|
107
|
+
|
|
108
|
+
Example::
|
|
109
|
+
|
|
110
|
+
from prompture.history import search_messages
|
|
111
|
+
|
|
112
|
+
# Get all assistant messages
|
|
113
|
+
assistant_msgs = search_messages(result.messages, role="assistant")
|
|
114
|
+
|
|
115
|
+
# Search for messages mentioning "error"
|
|
116
|
+
error_msgs = search_messages(result.messages, content_contains="error")
|
|
117
|
+
|
|
118
|
+
# Get messages with tool calls
|
|
119
|
+
tool_msgs = search_messages(result.messages, has_tool_calls=True)
|
|
120
|
+
"""
|
|
121
|
+
result: list[dict[str, Any]] = []
|
|
122
|
+
|
|
123
|
+
# Normalize role to a set
|
|
124
|
+
role_filter: set[str] | None = None
|
|
125
|
+
if role is not None:
|
|
126
|
+
if isinstance(role, list):
|
|
127
|
+
role_filter = set(role)
|
|
128
|
+
else:
|
|
129
|
+
role_filter = {role}
|
|
130
|
+
|
|
131
|
+
for msg in messages:
|
|
132
|
+
# Filter by role
|
|
133
|
+
if role_filter is not None:
|
|
134
|
+
msg_role = msg.get("role", "")
|
|
135
|
+
if msg_role not in role_filter:
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
# Filter by content
|
|
139
|
+
if content_contains is not None:
|
|
140
|
+
content = msg.get("content", "")
|
|
141
|
+
if content is None:
|
|
142
|
+
content = ""
|
|
143
|
+
if content_contains.lower() not in content.lower():
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
# Filter by tool calls
|
|
147
|
+
if has_tool_calls is not None:
|
|
148
|
+
msg_has_tools = bool(msg.get("tool_calls"))
|
|
149
|
+
if msg_has_tools != has_tool_calls:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
result.append(msg)
|
|
153
|
+
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_tool_call_summary(result: AgentResult) -> list[dict[str, Any]]:
|
|
158
|
+
"""Get a summary of all tool calls from an AgentResult.
|
|
159
|
+
|
|
160
|
+
Returns a list of dictionaries, each containing:
|
|
161
|
+
- name: The tool name
|
|
162
|
+
- arguments: The arguments passed to the tool
|
|
163
|
+
- result: The tool's return value (if available)
|
|
164
|
+
- timestamp: When the call was made
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
result: AgentResult from an agent run.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
List of tool call summary dictionaries.
|
|
171
|
+
|
|
172
|
+
Example::
|
|
173
|
+
|
|
174
|
+
from prompture.history import get_tool_call_summary
|
|
175
|
+
|
|
176
|
+
summary = get_tool_call_summary(result)
|
|
177
|
+
for call in summary:
|
|
178
|
+
print(f"{call['name']}: {call['arguments']} -> {call.get('result', 'N/A')}")
|
|
179
|
+
"""
|
|
180
|
+
summaries: list[dict[str, Any]] = []
|
|
181
|
+
|
|
182
|
+
# Pair up tool_call steps with their corresponding tool_result steps
|
|
183
|
+
tool_calls = [s for s in result.steps if s.step_type == StepType.tool_call]
|
|
184
|
+
tool_results = [s for s in result.steps if s.step_type == StepType.tool_result]
|
|
185
|
+
|
|
186
|
+
for i, call_step in enumerate(tool_calls):
|
|
187
|
+
summary: dict[str, Any] = {
|
|
188
|
+
"name": call_step.tool_name,
|
|
189
|
+
"arguments": call_step.tool_args or {},
|
|
190
|
+
"timestamp": call_step.timestamp,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# Try to find the corresponding result
|
|
194
|
+
if i < len(tool_results):
|
|
195
|
+
result_step = tool_results[i]
|
|
196
|
+
summary["result"] = result_step.content
|
|
197
|
+
|
|
198
|
+
summaries.append(summary)
|
|
199
|
+
|
|
200
|
+
return summaries
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def calculate_cost_breakdown(run_usage: dict[str, Any]) -> dict[str, Any]:
|
|
204
|
+
"""Calculate a detailed cost breakdown from run_usage.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
run_usage: The run_usage dictionary from AgentResult.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Dictionary with cost breakdown:
|
|
211
|
+
- prompt_tokens: Total prompt tokens used
|
|
212
|
+
- completion_tokens: Total completion tokens used
|
|
213
|
+
- total_tokens: Total tokens used
|
|
214
|
+
- prompt_cost: Cost for prompt tokens
|
|
215
|
+
- completion_cost: Cost for completion tokens
|
|
216
|
+
- total_cost: Total cost
|
|
217
|
+
- call_count: Number of API calls
|
|
218
|
+
- error_count: Number of errors
|
|
219
|
+
|
|
220
|
+
Example::
|
|
221
|
+
|
|
222
|
+
from prompture.history import calculate_cost_breakdown
|
|
223
|
+
|
|
224
|
+
breakdown = calculate_cost_breakdown(result.run_usage)
|
|
225
|
+
print(f"Total cost: ${breakdown['total_cost']:.4f}")
|
|
226
|
+
print(f"Calls: {breakdown['call_count']}")
|
|
227
|
+
"""
|
|
228
|
+
return {
|
|
229
|
+
"prompt_tokens": run_usage.get("prompt_tokens", 0),
|
|
230
|
+
"completion_tokens": run_usage.get("completion_tokens", 0),
|
|
231
|
+
"total_tokens": run_usage.get("total_tokens", 0),
|
|
232
|
+
"prompt_cost": run_usage.get("prompt_cost", 0.0),
|
|
233
|
+
"completion_cost": run_usage.get("completion_cost", 0.0),
|
|
234
|
+
"total_cost": run_usage.get("total_cost", 0.0),
|
|
235
|
+
"call_count": run_usage.get("call_count", 0),
|
|
236
|
+
"error_count": run_usage.get("error_count", 0),
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def export_result_json(result: AgentResult, include_messages: bool = True) -> str:
|
|
241
|
+
"""Export an AgentResult to a JSON string.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
result: AgentResult to export.
|
|
245
|
+
include_messages: Whether to include the full message history.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
JSON string representation of the result.
|
|
249
|
+
|
|
250
|
+
Example::
|
|
251
|
+
|
|
252
|
+
from prompture.history import export_result_json
|
|
253
|
+
|
|
254
|
+
json_str = export_result_json(result)
|
|
255
|
+
with open("agent_history.json", "w") as f:
|
|
256
|
+
f.write(json_str)
|
|
257
|
+
"""
|
|
258
|
+
data = result_to_dict(result, include_messages=include_messages)
|
|
259
|
+
return json.dumps(data, indent=2, default=str)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def result_to_dict(result: AgentResult, include_messages: bool = True) -> dict[str, Any]:
|
|
263
|
+
"""Convert an AgentResult to a dictionary.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
result: AgentResult to convert.
|
|
267
|
+
include_messages: Whether to include the full message history.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Dictionary representation of the result.
|
|
271
|
+
"""
|
|
272
|
+
data: dict[str, Any] = {
|
|
273
|
+
"output": str(result.output) if result.output is not None else None,
|
|
274
|
+
"output_text": result.output_text,
|
|
275
|
+
"state": result.state.value if hasattr(result.state, "value") else str(result.state),
|
|
276
|
+
"usage": result.usage,
|
|
277
|
+
"run_usage": result.run_usage,
|
|
278
|
+
"steps": [_step_to_dict(s) for s in result.steps],
|
|
279
|
+
"all_tool_calls": result.all_tool_calls,
|
|
280
|
+
"exported_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if include_messages:
|
|
284
|
+
data["messages"] = result.messages
|
|
285
|
+
|
|
286
|
+
return data
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _step_to_dict(step: AgentStep) -> dict[str, Any]:
|
|
290
|
+
"""Convert an AgentStep to a dictionary."""
|
|
291
|
+
return {
|
|
292
|
+
"step_type": step.step_type.value if hasattr(step.step_type, "value") else str(step.step_type),
|
|
293
|
+
"timestamp": step.timestamp,
|
|
294
|
+
"content": step.content,
|
|
295
|
+
"tool_name": step.tool_name,
|
|
296
|
+
"tool_args": step.tool_args,
|
|
297
|
+
"tool_result": step.tool_result,
|
|
298
|
+
"duration_ms": step.duration_ms,
|
|
299
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Python sandbox module for safe code execution.
|
|
2
|
+
|
|
3
|
+
Provides a restricted Python execution environment with configurable
|
|
4
|
+
import restrictions, filesystem path restrictions, and resource limits.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
ImportViolationError,
|
|
9
|
+
PathViolationError,
|
|
10
|
+
ResourceLimitError,
|
|
11
|
+
SandboxError,
|
|
12
|
+
SandboxTimeoutError,
|
|
13
|
+
)
|
|
14
|
+
from .resource_limits import ResourceContext, ResourceLimits
|
|
15
|
+
from .restrictions import ALWAYS_BLOCKED_IMPORTS, ImportRestrictions, PathRestrictions
|
|
16
|
+
from .sandbox import PythonSandbox, SandboxResult
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ALWAYS_BLOCKED_IMPORTS",
|
|
20
|
+
"ImportRestrictions",
|
|
21
|
+
"ImportViolationError",
|
|
22
|
+
"PathRestrictions",
|
|
23
|
+
"PathViolationError",
|
|
24
|
+
"PythonSandbox",
|
|
25
|
+
"ResourceContext",
|
|
26
|
+
"ResourceLimitError",
|
|
27
|
+
"ResourceLimits",
|
|
28
|
+
"SandboxError",
|
|
29
|
+
"SandboxResult",
|
|
30
|
+
"SandboxTimeoutError",
|
|
31
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Sandbox exceptions.
|
|
2
|
+
|
|
3
|
+
Custom exceptions for sandbox violations and errors.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SandboxError(Exception):
|
|
10
|
+
"""Base exception for all sandbox-related errors."""
|
|
11
|
+
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ImportViolationError(SandboxError):
|
|
16
|
+
"""Raised when code attempts to import a blocked module."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, module_name: str, reason: str | None = None) -> None:
|
|
19
|
+
self.module_name = module_name
|
|
20
|
+
self.reason = reason
|
|
21
|
+
message = f"Import of '{module_name}' is not allowed"
|
|
22
|
+
if reason:
|
|
23
|
+
message += f": {reason}"
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PathViolationError(SandboxError):
|
|
28
|
+
"""Raised when code attempts to access a restricted path."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, path: str, operation: str = "access") -> None:
|
|
31
|
+
self.path = path
|
|
32
|
+
self.operation = operation
|
|
33
|
+
super().__init__(f"Cannot {operation} path '{path}': outside allowed directories")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SandboxTimeoutError(SandboxError):
|
|
37
|
+
"""Raised when code execution exceeds the timeout limit."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, timeout_seconds: float) -> None:
|
|
40
|
+
self.timeout_seconds = timeout_seconds
|
|
41
|
+
super().__init__(f"Execution exceeded timeout of {timeout_seconds} seconds")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ResourceLimitError(SandboxError):
|
|
45
|
+
"""Raised when code exceeds resource limits (memory, etc.)."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, resource: str, limit: int | float, used: int | float | None = None) -> None:
|
|
48
|
+
self.resource = resource
|
|
49
|
+
self.limit = limit
|
|
50
|
+
self.used = used
|
|
51
|
+
message = f"Resource limit exceeded for {resource}: limit={limit}"
|
|
52
|
+
if used is not None:
|
|
53
|
+
message += f", used={used}"
|
|
54
|
+
super().__init__(message)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Resource limits for sandbox execution.
|
|
2
|
+
|
|
3
|
+
Provides timeout and memory limit handling.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import platform
|
|
9
|
+
import signal
|
|
10
|
+
from collections.abc import Generator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .exceptions import SandboxTimeoutError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ResourceLimits:
|
|
20
|
+
"""Configuration for resource limits in the sandbox.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
timeout_seconds: Maximum execution time in seconds.
|
|
24
|
+
max_memory_bytes: Maximum memory usage in bytes (Unix only via resource module).
|
|
25
|
+
Note: Memory limits are only enforced on Unix systems.
|
|
26
|
+
max_output_bytes: Maximum output size in bytes.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
timeout_seconds: float = 30.0
|
|
30
|
+
max_memory_bytes: int | None = None
|
|
31
|
+
max_output_bytes: int = 1024 * 1024 # 1 MB default
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TimeoutHandler:
|
|
35
|
+
"""Context manager for handling execution timeouts.
|
|
36
|
+
|
|
37
|
+
Uses signal.SIGALRM on Unix systems, threading-based approach on Windows.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, seconds: float) -> None:
|
|
41
|
+
self.seconds = seconds
|
|
42
|
+
self._old_handler: Any = None
|
|
43
|
+
self._is_unix = platform.system() != "Windows"
|
|
44
|
+
|
|
45
|
+
def _timeout_handler(self, signum: int, frame: Any) -> None:
|
|
46
|
+
"""Signal handler for timeout."""
|
|
47
|
+
raise SandboxTimeoutError(self.seconds)
|
|
48
|
+
|
|
49
|
+
def __enter__(self) -> TimeoutHandler:
|
|
50
|
+
if self._is_unix and self.seconds > 0:
|
|
51
|
+
self._old_handler = signal.signal(signal.SIGALRM, self._timeout_handler)
|
|
52
|
+
signal.setitimer(signal.ITIMER_REAL, self.seconds)
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
56
|
+
if self._is_unix and self.seconds > 0:
|
|
57
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
58
|
+
if self._old_handler is not None:
|
|
59
|
+
signal.signal(signal.SIGALRM, self._old_handler)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ResourceContext:
|
|
63
|
+
"""Context manager for resource limits.
|
|
64
|
+
|
|
65
|
+
Applies timeout and optionally memory limits during code execution.
|
|
66
|
+
|
|
67
|
+
Note:
|
|
68
|
+
Memory limits via the resource module are only available on Unix.
|
|
69
|
+
On Windows, only timeout limits are enforced (and with limitations).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, limits: ResourceLimits) -> None:
|
|
73
|
+
self.limits = limits
|
|
74
|
+
self._timeout_handler: TimeoutHandler | None = None
|
|
75
|
+
self._old_memory_limit: tuple[int, int] | None = None
|
|
76
|
+
self._is_unix = platform.system() != "Windows"
|
|
77
|
+
|
|
78
|
+
def __enter__(self) -> ResourceContext:
|
|
79
|
+
# Set up timeout
|
|
80
|
+
if self.limits.timeout_seconds > 0:
|
|
81
|
+
self._timeout_handler = TimeoutHandler(self.limits.timeout_seconds)
|
|
82
|
+
self._timeout_handler.__enter__()
|
|
83
|
+
|
|
84
|
+
# Set up memory limit (Unix only)
|
|
85
|
+
if self._is_unix and self.limits.max_memory_bytes is not None:
|
|
86
|
+
try:
|
|
87
|
+
import resource as res
|
|
88
|
+
|
|
89
|
+
self._old_memory_limit = res.getrlimit(res.RLIMIT_AS)
|
|
90
|
+
res.setrlimit(res.RLIMIT_AS, (self.limits.max_memory_bytes, self.limits.max_memory_bytes))
|
|
91
|
+
except (ImportError, ValueError, OSError):
|
|
92
|
+
# resource module not available or limit can't be set
|
|
93
|
+
self._old_memory_limit = None
|
|
94
|
+
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
98
|
+
# Clean up timeout
|
|
99
|
+
if self._timeout_handler is not None:
|
|
100
|
+
self._timeout_handler.__exit__(exc_type, exc_val, exc_tb)
|
|
101
|
+
|
|
102
|
+
# Clean up memory limit
|
|
103
|
+
if self._is_unix and self._old_memory_limit is not None:
|
|
104
|
+
try:
|
|
105
|
+
import resource as res
|
|
106
|
+
|
|
107
|
+
res.setrlimit(res.RLIMIT_AS, self._old_memory_limit)
|
|
108
|
+
except (ImportError, ValueError, OSError):
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@contextmanager
|
|
113
|
+
def enforce_limits(limits: ResourceLimits) -> Generator[None, None, None]:
|
|
114
|
+
"""Context manager to enforce resource limits.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
limits: ResourceLimits configuration.
|
|
118
|
+
|
|
119
|
+
Yields:
|
|
120
|
+
None - use this as a context manager around code execution.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
SandboxTimeoutError: If execution exceeds timeout.
|
|
124
|
+
ResourceLimitError: If memory limit is exceeded (Unix only).
|
|
125
|
+
"""
|
|
126
|
+
ctx = ResourceContext(limits)
|
|
127
|
+
with ctx:
|
|
128
|
+
yield
|