indent 0.0.8__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.
Potentially problematic release.
This version of indent might be problematic. Click here for more details.
- exponent/__init__.py +1 -0
- exponent/cli.py +112 -0
- exponent/commands/cloud_commands.py +85 -0
- exponent/commands/common.py +434 -0
- exponent/commands/config_commands.py +581 -0
- exponent/commands/github_app_commands.py +211 -0
- exponent/commands/listen_commands.py +96 -0
- exponent/commands/run_commands.py +208 -0
- exponent/commands/settings.py +56 -0
- exponent/commands/shell_commands.py +2840 -0
- exponent/commands/theme.py +246 -0
- exponent/commands/types.py +111 -0
- exponent/commands/upgrade.py +29 -0
- exponent/commands/utils.py +236 -0
- exponent/core/config.py +180 -0
- exponent/core/graphql/__init__.py +0 -0
- exponent/core/graphql/client.py +59 -0
- exponent/core/graphql/cloud_config_queries.py +77 -0
- exponent/core/graphql/get_chats_query.py +47 -0
- exponent/core/graphql/github_config_queries.py +56 -0
- exponent/core/graphql/mutations.py +75 -0
- exponent/core/graphql/queries.py +110 -0
- exponent/core/graphql/subscriptions.py +452 -0
- exponent/core/remote_execution/checkpoints.py +212 -0
- exponent/core/remote_execution/cli_rpc_types.py +214 -0
- exponent/core/remote_execution/client.py +545 -0
- exponent/core/remote_execution/code_execution.py +58 -0
- exponent/core/remote_execution/command_execution.py +105 -0
- exponent/core/remote_execution/error_info.py +45 -0
- exponent/core/remote_execution/exceptions.py +10 -0
- exponent/core/remote_execution/file_write.py +410 -0
- exponent/core/remote_execution/files.py +415 -0
- exponent/core/remote_execution/git.py +268 -0
- exponent/core/remote_execution/languages/python_execution.py +239 -0
- exponent/core/remote_execution/languages/shell_streaming.py +221 -0
- exponent/core/remote_execution/languages/types.py +20 -0
- exponent/core/remote_execution/session.py +128 -0
- exponent/core/remote_execution/system_context.py +54 -0
- exponent/core/remote_execution/tool_execution.py +289 -0
- exponent/core/remote_execution/truncation.py +284 -0
- exponent/core/remote_execution/types.py +670 -0
- exponent/core/remote_execution/utils.py +600 -0
- exponent/core/types/__init__.py +0 -0
- exponent/core/types/command_data.py +206 -0
- exponent/core/types/event_types.py +89 -0
- exponent/core/types/generated/__init__.py +0 -0
- exponent/core/types/generated/strategy_info.py +225 -0
- exponent/migration-docs/login.md +112 -0
- exponent/py.typed +4 -0
- exponent/utils/__init__.py +0 -0
- exponent/utils/colors.py +92 -0
- exponent/utils/version.py +289 -0
- indent-0.0.8.dist-info/METADATA +36 -0
- indent-0.0.8.dist-info/RECORD +56 -0
- indent-0.0.8.dist-info/WHEEL +4 -0
- indent-0.0.8.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import queue
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import AsyncGenerator, Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from jupyter_client.client import KernelClient
|
|
12
|
+
from jupyter_client.manager import KernelManager
|
|
13
|
+
|
|
14
|
+
from exponent.core.remote_execution.languages.types import (
|
|
15
|
+
PythonExecutionResult,
|
|
16
|
+
StreamedOutputPiece,
|
|
17
|
+
)
|
|
18
|
+
from exponent.core.remote_execution.types import PythonEnvInfo
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class IOChannelHandler:
|
|
24
|
+
ESCAPE_SEQUENCE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
|
|
25
|
+
|
|
26
|
+
def __init__(self, user_interrupted: Callable[[], bool] | None = None) -> None:
|
|
27
|
+
self.output_buffer: queue.Queue[str] = queue.Queue()
|
|
28
|
+
self.user_interrupted = user_interrupted
|
|
29
|
+
|
|
30
|
+
def add_message(self, message: dict[str, Any]) -> None:
|
|
31
|
+
logger.debug(f"Jupyter kernel message received: {message}")
|
|
32
|
+
output = None
|
|
33
|
+
if message["msg_type"] == "stream":
|
|
34
|
+
output = message["content"]["text"]
|
|
35
|
+
elif message["msg_type"] == "error":
|
|
36
|
+
raw_content = "\n".join(message["content"]["traceback"])
|
|
37
|
+
content = self.ESCAPE_SEQUENCE.sub("", raw_content)
|
|
38
|
+
output = content
|
|
39
|
+
if output:
|
|
40
|
+
self.output_buffer.put(output)
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def is_idle(message: dict[str, Any]) -> bool:
|
|
44
|
+
return bool(
|
|
45
|
+
message["header"]["msg_type"] == "status"
|
|
46
|
+
and message["content"]["execution_state"] == "idle"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Kernel:
|
|
51
|
+
def __init__(self, working_directory: str) -> None:
|
|
52
|
+
self._manager: KernelManager | None = None
|
|
53
|
+
self._client: KernelClient | None = None
|
|
54
|
+
self.io_handler: IOChannelHandler = IOChannelHandler()
|
|
55
|
+
self.working_directory = working_directory
|
|
56
|
+
self.interrupted_by_user: bool = False
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def manager(self) -> KernelManager:
|
|
60
|
+
if not self._manager:
|
|
61
|
+
self._manager = KernelManager(kernel_name="python3")
|
|
62
|
+
self._manager.start_kernel(cwd=self.working_directory)
|
|
63
|
+
return self._manager
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def client(self) -> KernelClient:
|
|
67
|
+
if not self._client:
|
|
68
|
+
self._client = self.manager.client()
|
|
69
|
+
|
|
70
|
+
while not self._client.is_alive():
|
|
71
|
+
time.sleep(0.1)
|
|
72
|
+
|
|
73
|
+
self._client.start_channels()
|
|
74
|
+
return self._client
|
|
75
|
+
|
|
76
|
+
async def wait_for_ready(self, timeout: int = 5) -> None:
|
|
77
|
+
manager = self.manager
|
|
78
|
+
start_time = time.time()
|
|
79
|
+
while not manager.is_alive():
|
|
80
|
+
if time.time() - start_time > timeout:
|
|
81
|
+
raise Exception("Kernel took too long to start")
|
|
82
|
+
await asyncio.sleep(0.05)
|
|
83
|
+
await asyncio.sleep(0.5)
|
|
84
|
+
|
|
85
|
+
def _clear_channels(self) -> None:
|
|
86
|
+
"""Clear all pending messages from kernel channels."""
|
|
87
|
+
# First clear shell and control channels
|
|
88
|
+
channels = [
|
|
89
|
+
self.client.shell_channel,
|
|
90
|
+
self.client.control_channel,
|
|
91
|
+
]
|
|
92
|
+
for channel in channels:
|
|
93
|
+
try:
|
|
94
|
+
while True:
|
|
95
|
+
channel.get_msg(timeout=0.1)
|
|
96
|
+
except queue.Empty:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
# Then process iopub channel until we get an idle state
|
|
100
|
+
iterations = 0
|
|
101
|
+
while True:
|
|
102
|
+
try:
|
|
103
|
+
msg = self.client.iopub_channel.get_msg(timeout=0.1)
|
|
104
|
+
self.io_handler.add_message(msg)
|
|
105
|
+
if self.io_handler.is_idle(msg):
|
|
106
|
+
break
|
|
107
|
+
except queue.Empty:
|
|
108
|
+
iterations += 1
|
|
109
|
+
if iterations > 10:
|
|
110
|
+
logger.info("Kernel took too long to become idle")
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
def iopub_listener(self, client: KernelClient) -> None:
|
|
114
|
+
while True:
|
|
115
|
+
try:
|
|
116
|
+
if (
|
|
117
|
+
self.io_handler.user_interrupted
|
|
118
|
+
and self.io_handler.user_interrupted()
|
|
119
|
+
):
|
|
120
|
+
logger.info("External halt signal received")
|
|
121
|
+
self.manager.interrupt_kernel()
|
|
122
|
+
self.interrupted_by_user = True
|
|
123
|
+
|
|
124
|
+
# Wait for kernel to push any final output
|
|
125
|
+
time.sleep(0.5)
|
|
126
|
+
|
|
127
|
+
# Clear all channels to reset kernel state
|
|
128
|
+
self._clear_channels()
|
|
129
|
+
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
msg = client.iopub_channel.get_msg(timeout=1)
|
|
134
|
+
logger.debug(f"Received message from kernel: {msg}")
|
|
135
|
+
self.io_handler.add_message(msg)
|
|
136
|
+
|
|
137
|
+
if self.io_handler.is_idle(msg):
|
|
138
|
+
logger.debug("Kernel is idle.")
|
|
139
|
+
break
|
|
140
|
+
except queue.Empty:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
except Exception as e: # noqa: BLE001 - TODO: Deep audit potential exceptions
|
|
144
|
+
logger.info(f"Error getting message from kernel: {e}")
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
# Deprecated, use execute_code_streaming
|
|
148
|
+
async def execute_code(self, code: str) -> str:
|
|
149
|
+
async for result in self.execute_code_streaming(code):
|
|
150
|
+
if isinstance(result, PythonExecutionResult):
|
|
151
|
+
return result.output
|
|
152
|
+
# should be unreachable
|
|
153
|
+
raise Exception("No result from kernel")
|
|
154
|
+
|
|
155
|
+
async def execute_code_streaming(
|
|
156
|
+
self, code: str, user_interrupted: Callable[[], bool] | None = None
|
|
157
|
+
) -> AsyncGenerator[StreamedOutputPiece | PythonExecutionResult, None]:
|
|
158
|
+
await self.wait_for_ready()
|
|
159
|
+
self.interrupted_by_user = False
|
|
160
|
+
|
|
161
|
+
self.io_handler = IOChannelHandler(user_interrupted=user_interrupted)
|
|
162
|
+
|
|
163
|
+
client = self.client
|
|
164
|
+
client.connect_iopub()
|
|
165
|
+
iopub_thread = threading.Thread(target=self.iopub_listener, args=(client,))
|
|
166
|
+
logger.info("Starting IO listener thread.")
|
|
167
|
+
iopub_thread.start()
|
|
168
|
+
|
|
169
|
+
logger.info("Executing code in kernel.")
|
|
170
|
+
client.execute(code)
|
|
171
|
+
|
|
172
|
+
stopping = False
|
|
173
|
+
|
|
174
|
+
results = []
|
|
175
|
+
while True:
|
|
176
|
+
stopping = not iopub_thread.is_alive()
|
|
177
|
+
|
|
178
|
+
while not self.io_handler.output_buffer.empty():
|
|
179
|
+
output = self.io_handler.output_buffer.get()
|
|
180
|
+
logger.info("Execution output: %s", output)
|
|
181
|
+
yield StreamedOutputPiece(content=output)
|
|
182
|
+
results.append(output)
|
|
183
|
+
|
|
184
|
+
if stopping:
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
await asyncio.sleep(0.05)
|
|
188
|
+
|
|
189
|
+
# Wait for thread to fully exit
|
|
190
|
+
iopub_thread.join(timeout=1.0)
|
|
191
|
+
yield PythonExecutionResult(
|
|
192
|
+
output="".join(results), halted=self.interrupted_by_user
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def close(self) -> None:
|
|
196
|
+
if self._client:
|
|
197
|
+
self._client.stop_channels()
|
|
198
|
+
self._client = None
|
|
199
|
+
if self._manager:
|
|
200
|
+
self._manager.shutdown_kernel()
|
|
201
|
+
self._manager = None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def execute_python(code: str, kernel: Kernel) -> str:
|
|
205
|
+
return await kernel.execute_code(code)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def execute_python_streaming(
|
|
209
|
+
code: str, kernel: Kernel, user_interrupted: Callable[[], bool] | None = None
|
|
210
|
+
) -> AsyncGenerator[StreamedOutputPiece | PythonExecutionResult, None]:
|
|
211
|
+
async for result in kernel.execute_code_streaming(
|
|
212
|
+
code, user_interrupted=user_interrupted
|
|
213
|
+
):
|
|
214
|
+
yield result
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
### Environment Helpers
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def get_python_env_info() -> PythonEnvInfo:
|
|
221
|
+
return PythonEnvInfo(
|
|
222
|
+
interpreter_path=get_active_python_interpreter_path(),
|
|
223
|
+
interpreter_version=get_active_python_interpreter_version(),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def get_active_python_interpreter_path() -> str | None:
|
|
228
|
+
return sys.executable
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def get_active_python_interpreter_version() -> str | None:
|
|
232
|
+
version = sys.version
|
|
233
|
+
|
|
234
|
+
match = re.search(r"(\d+\.\d+\.\d+).*", version)
|
|
235
|
+
|
|
236
|
+
if match:
|
|
237
|
+
return match.group(1)
|
|
238
|
+
|
|
239
|
+
return None
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import shutil
|
|
5
|
+
import signal
|
|
6
|
+
from collections.abc import AsyncGenerator, Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from exponent.core.remote_execution.languages.types import (
|
|
10
|
+
ShellExecutionResult,
|
|
11
|
+
StreamedOutputPiece,
|
|
12
|
+
)
|
|
13
|
+
from exponent.core.remote_execution.utils import smart_decode
|
|
14
|
+
|
|
15
|
+
STDOUT_FD = 1
|
|
16
|
+
STDERR_FD = 2
|
|
17
|
+
MAX_TIMEOUT = 300
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_rc_file_source_command(shell_path: str) -> str:
|
|
21
|
+
"""
|
|
22
|
+
Returns a command to source the user's shell rc file
|
|
23
|
+
Login profiles are already sourced via the -l flag
|
|
24
|
+
"""
|
|
25
|
+
# On Windows, shell behavior is different
|
|
26
|
+
if platform.system() == "Windows":
|
|
27
|
+
return "" # Windows shells don't typically use rc files in the same way
|
|
28
|
+
|
|
29
|
+
shell_name = os.path.basename(shell_path)
|
|
30
|
+
home_dir = os.path.expanduser("~")
|
|
31
|
+
|
|
32
|
+
if shell_name == "zsh":
|
|
33
|
+
zshrc = os.path.join(home_dir, ".zshrc")
|
|
34
|
+
if os.path.exists(zshrc):
|
|
35
|
+
return f"source {zshrc} 2>/dev/null || true; "
|
|
36
|
+
elif shell_name == "bash":
|
|
37
|
+
bashrc = os.path.join(home_dir, ".bashrc")
|
|
38
|
+
if os.path.exists(bashrc):
|
|
39
|
+
return f"source {bashrc} 2>/dev/null || true; "
|
|
40
|
+
|
|
41
|
+
return "" # No rc file found or unsupported shell
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def read_stream(
|
|
45
|
+
stream: asyncio.StreamReader, fd: int, output: list[tuple[int, str]]
|
|
46
|
+
) -> AsyncGenerator[StreamedOutputPiece, None]:
|
|
47
|
+
data: bytes = b""
|
|
48
|
+
encoding = "utf-8"
|
|
49
|
+
|
|
50
|
+
while True:
|
|
51
|
+
try:
|
|
52
|
+
data = await stream.read(4096)
|
|
53
|
+
if not data:
|
|
54
|
+
break
|
|
55
|
+
chunk = data.decode(encoding=encoding)
|
|
56
|
+
output.append((fd, chunk))
|
|
57
|
+
yield StreamedOutputPiece(content=chunk)
|
|
58
|
+
except UnicodeDecodeError:
|
|
59
|
+
decode_result = smart_decode(data)
|
|
60
|
+
|
|
61
|
+
if decode_result is None:
|
|
62
|
+
break
|
|
63
|
+
|
|
64
|
+
# Store the detected encoding as a hint for how
|
|
65
|
+
# to decode the remaining chunks of data
|
|
66
|
+
chunk, encoding = decode_result
|
|
67
|
+
|
|
68
|
+
output.append((fd, chunk))
|
|
69
|
+
yield StreamedOutputPiece(content=chunk)
|
|
70
|
+
|
|
71
|
+
continue
|
|
72
|
+
except asyncio.CancelledError:
|
|
73
|
+
raise
|
|
74
|
+
except Exception:
|
|
75
|
+
break
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def execute_shell_streaming(
|
|
79
|
+
code: str,
|
|
80
|
+
working_directory: str,
|
|
81
|
+
timeout: int,
|
|
82
|
+
should_halt: Callable[[], bool] | None = None,
|
|
83
|
+
) -> AsyncGenerator[StreamedOutputPiece | ShellExecutionResult, None]:
|
|
84
|
+
timeout_seconds = min(timeout, MAX_TIMEOUT)
|
|
85
|
+
|
|
86
|
+
shell_path = os.environ.get("SHELL") or shutil.which("bash") or shutil.which("sh")
|
|
87
|
+
|
|
88
|
+
if not shell_path:
|
|
89
|
+
process = await asyncio.create_subprocess_shell(
|
|
90
|
+
code,
|
|
91
|
+
stdout=asyncio.subprocess.PIPE,
|
|
92
|
+
stderr=asyncio.subprocess.PIPE,
|
|
93
|
+
cwd=working_directory,
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
# Add rc file sourcing to the command
|
|
97
|
+
rc_source_cmd = get_rc_file_source_command(shell_path)
|
|
98
|
+
full_command = f"{rc_source_cmd}{code}"
|
|
99
|
+
|
|
100
|
+
process = await asyncio.create_subprocess_exec(
|
|
101
|
+
shell_path,
|
|
102
|
+
"-l",
|
|
103
|
+
"-c",
|
|
104
|
+
full_command,
|
|
105
|
+
stdout=asyncio.subprocess.PIPE,
|
|
106
|
+
stderr=asyncio.subprocess.PIPE,
|
|
107
|
+
cwd=working_directory,
|
|
108
|
+
start_new_session=True if platform.system() != "Windows" else False,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
exit_code = None
|
|
112
|
+
output: list[tuple[int, str]] = []
|
|
113
|
+
halted = False
|
|
114
|
+
timed_out = False
|
|
115
|
+
assert process.stdout
|
|
116
|
+
assert process.stderr
|
|
117
|
+
|
|
118
|
+
async def monitor_halt() -> None:
|
|
119
|
+
nonlocal halted
|
|
120
|
+
|
|
121
|
+
while True:
|
|
122
|
+
if should_halt and should_halt():
|
|
123
|
+
# Send signal to process group for proper interrupt propagation
|
|
124
|
+
try:
|
|
125
|
+
if platform.system() != "Windows":
|
|
126
|
+
# Send SIGTERM to the process group
|
|
127
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
128
|
+
try:
|
|
129
|
+
await asyncio.wait_for(process.wait(), timeout=2.0)
|
|
130
|
+
except TimeoutError:
|
|
131
|
+
# Fall back to SIGKILL to process group
|
|
132
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
133
|
+
else:
|
|
134
|
+
# Windows fallback
|
|
135
|
+
process.terminate()
|
|
136
|
+
try:
|
|
137
|
+
await asyncio.wait_for(process.wait(), timeout=2.0)
|
|
138
|
+
except TimeoutError:
|
|
139
|
+
process.kill()
|
|
140
|
+
except (ProcessLookupError, OSError):
|
|
141
|
+
# Process or process group already terminated
|
|
142
|
+
pass
|
|
143
|
+
halted = True
|
|
144
|
+
break
|
|
145
|
+
if process.returncode is not None:
|
|
146
|
+
break
|
|
147
|
+
await asyncio.sleep(0.1)
|
|
148
|
+
|
|
149
|
+
def on_timeout() -> None:
|
|
150
|
+
nonlocal timed_out
|
|
151
|
+
timed_out = True
|
|
152
|
+
process.kill()
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
halt_task = asyncio.create_task(monitor_halt()) if should_halt else None
|
|
156
|
+
timeout_handle = asyncio.get_running_loop().call_later(
|
|
157
|
+
timeout_seconds, on_timeout
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Stream stdout and stderr concurrently using wait
|
|
161
|
+
stdout_gen = read_stream(process.stdout, STDOUT_FD, output)
|
|
162
|
+
stderr_gen = read_stream(process.stderr, STDERR_FD, output)
|
|
163
|
+
|
|
164
|
+
stdout_task = asyncio.create_task(stdout_gen.__anext__())
|
|
165
|
+
stderr_task = asyncio.create_task(stderr_gen.__anext__())
|
|
166
|
+
pending = {stdout_task, stderr_task}
|
|
167
|
+
|
|
168
|
+
while pending:
|
|
169
|
+
done, pending = await asyncio.wait(
|
|
170
|
+
pending, return_when=asyncio.FIRST_COMPLETED
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
for task in done:
|
|
174
|
+
try:
|
|
175
|
+
piece = await task
|
|
176
|
+
yield piece
|
|
177
|
+
|
|
178
|
+
if process.returncode is not None:
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
# Schedule next read from the same stream
|
|
182
|
+
if task is stdout_task and not process.stdout.at_eof():
|
|
183
|
+
stdout_task = asyncio.create_task(stdout_gen.__anext__())
|
|
184
|
+
pending.add(stdout_task)
|
|
185
|
+
elif task is stderr_task and not process.stderr.at_eof():
|
|
186
|
+
stderr_task = asyncio.create_task(stderr_gen.__anext__())
|
|
187
|
+
pending.add(stderr_task)
|
|
188
|
+
except StopAsyncIteration:
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
exit_code = await process.wait()
|
|
192
|
+
timeout_handle.cancel()
|
|
193
|
+
|
|
194
|
+
except asyncio.CancelledError:
|
|
195
|
+
process.kill()
|
|
196
|
+
raise
|
|
197
|
+
finally:
|
|
198
|
+
# Explicitly kill the process if it's still running
|
|
199
|
+
if process and process.returncode is None:
|
|
200
|
+
try:
|
|
201
|
+
if platform.system() != "Windows":
|
|
202
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
203
|
+
else:
|
|
204
|
+
process.terminate()
|
|
205
|
+
except (ProcessLookupError, OSError):
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
tasks_to_cancel: list[asyncio.Task[Any]] = [stdout_task, stderr_task]
|
|
209
|
+
if halt_task:
|
|
210
|
+
tasks_to_cancel.append(halt_task)
|
|
211
|
+
|
|
212
|
+
await asyncio.gather(*tasks_to_cancel, return_exceptions=True)
|
|
213
|
+
|
|
214
|
+
formatted_output = "".join([chunk for (_, chunk) in output]).strip() + "\n\n"
|
|
215
|
+
|
|
216
|
+
yield ShellExecutionResult(
|
|
217
|
+
output=formatted_output,
|
|
218
|
+
cancelled_for_timeout=timed_out,
|
|
219
|
+
exit_code=None if timed_out else exit_code,
|
|
220
|
+
halted=halted,
|
|
221
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class StreamedOutputPiece:
|
|
6
|
+
content: str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ShellExecutionResult:
|
|
11
|
+
output: str
|
|
12
|
+
cancelled_for_timeout: bool
|
|
13
|
+
exit_code: int | None
|
|
14
|
+
halted: bool = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class PythonExecutionResult:
|
|
19
|
+
output: str
|
|
20
|
+
halted: bool = False
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import AsyncGenerator
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import TYPE_CHECKING, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from httpx import AsyncClient, Request, Response
|
|
8
|
+
|
|
9
|
+
from exponent.core.remote_execution.exceptions import ExponentError
|
|
10
|
+
from exponent.core.remote_execution.languages.python_execution import Kernel
|
|
11
|
+
from exponent.core.remote_execution.utils import format_error_log
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from exponent.core.config import Settings
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SessionLog:
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self.log_buffer: list[str] = []
|
|
22
|
+
self.max_size = 5
|
|
23
|
+
|
|
24
|
+
def append_log(self, log: str) -> None:
|
|
25
|
+
self.log_buffer.append(log)
|
|
26
|
+
self.log_buffer = self.log_buffer[-self.max_size :]
|
|
27
|
+
|
|
28
|
+
def get_logs(self) -> list[str]:
|
|
29
|
+
return self.log_buffer
|
|
30
|
+
|
|
31
|
+
async def log_request(self, request: Request) -> None:
|
|
32
|
+
self.append_log(f"Request: {request.method} {request.url}")
|
|
33
|
+
|
|
34
|
+
async def log_response(self, response: Response) -> None:
|
|
35
|
+
request = response.request
|
|
36
|
+
await response.aread()
|
|
37
|
+
self.append_log(
|
|
38
|
+
f"Response for request: {request.method} {request.url}\n"
|
|
39
|
+
f"Response: {response.status_code}, {response.text}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RemoteExecutionClientSession:
|
|
44
|
+
def __init__(
|
|
45
|
+
self, working_directory: str, base_url: str, base_ws_url: str, api_key: str
|
|
46
|
+
):
|
|
47
|
+
self.chat_uuid: str | None = None
|
|
48
|
+
|
|
49
|
+
self.working_directory = working_directory
|
|
50
|
+
self.kernel = Kernel(working_directory=working_directory)
|
|
51
|
+
self.api_log = SessionLog()
|
|
52
|
+
|
|
53
|
+
self.api_client = AsyncClient(
|
|
54
|
+
base_url=base_url,
|
|
55
|
+
headers={"API-KEY": api_key},
|
|
56
|
+
event_hooks={
|
|
57
|
+
"request": [self.api_log.log_request],
|
|
58
|
+
"response": [self.api_log.log_response],
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
self.ws_client = AsyncClient(
|
|
63
|
+
base_url=base_ws_url,
|
|
64
|
+
headers={"API-KEY": api_key},
|
|
65
|
+
event_hooks={
|
|
66
|
+
"request": [self.api_log.log_request],
|
|
67
|
+
"response": [self.api_log.log_response],
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def set_chat_uuid(self, chat_uuid: str) -> None:
|
|
72
|
+
self.chat_uuid = chat_uuid
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def send_exception_log(
|
|
76
|
+
exc: Exception,
|
|
77
|
+
session: RemoteExecutionClientSession | None = None,
|
|
78
|
+
settings: Optional["Settings"] = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
error_log = format_error_log(
|
|
81
|
+
exc=exc,
|
|
82
|
+
chat_uuid=session.chat_uuid if session else None,
|
|
83
|
+
attachment_lines=session.api_log.get_logs() if session else None,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if session:
|
|
87
|
+
api_client = session.api_client
|
|
88
|
+
elif settings:
|
|
89
|
+
api_key = settings.api_key
|
|
90
|
+
if not api_key:
|
|
91
|
+
raise ValueError("No API key provided")
|
|
92
|
+
api_client = AsyncClient(
|
|
93
|
+
base_url=settings.get_base_api_url(),
|
|
94
|
+
headers={"API-KEY": api_key},
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
raise ValueError("No session or settings provided")
|
|
98
|
+
|
|
99
|
+
if not error_log:
|
|
100
|
+
return
|
|
101
|
+
try:
|
|
102
|
+
await api_client.post(
|
|
103
|
+
"/api/remote_execution/log_error",
|
|
104
|
+
content=error_log.model_dump_json(),
|
|
105
|
+
timeout=60,
|
|
106
|
+
)
|
|
107
|
+
except httpx.ConnectError:
|
|
108
|
+
logger.info("Failed to send error log")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@asynccontextmanager
|
|
112
|
+
async def get_session(
|
|
113
|
+
working_directory: str,
|
|
114
|
+
base_url: str,
|
|
115
|
+
base_ws_url: str,
|
|
116
|
+
api_key: str,
|
|
117
|
+
) -> AsyncGenerator[RemoteExecutionClientSession, None]:
|
|
118
|
+
session = RemoteExecutionClientSession(
|
|
119
|
+
working_directory, base_url, base_ws_url, api_key
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
yield session
|
|
123
|
+
except Exception as exc: # noqa: BLE001
|
|
124
|
+
await send_exception_log(exc, session=session, settings=None)
|
|
125
|
+
raise ExponentError(str(exc))
|
|
126
|
+
finally:
|
|
127
|
+
session.kernel.close()
|
|
128
|
+
await session.api_client.aclose()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
|
|
5
|
+
from anyio import Path as AsyncPath
|
|
6
|
+
|
|
7
|
+
from exponent.core.remote_execution.git import get_git_info
|
|
8
|
+
from exponent.core.remote_execution.languages import python_execution
|
|
9
|
+
from exponent.core.remote_execution.types import (
|
|
10
|
+
SystemContextRequest,
|
|
11
|
+
SystemContextResponse,
|
|
12
|
+
SystemInfo,
|
|
13
|
+
)
|
|
14
|
+
from exponent.core.remote_execution.utils import safe_read_file
|
|
15
|
+
|
|
16
|
+
EXPONENT_TXT_FILENAMES = [
|
|
17
|
+
"exponent.txt",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def get_system_context(
|
|
22
|
+
request: SystemContextRequest, working_directory: str
|
|
23
|
+
) -> SystemContextResponse:
|
|
24
|
+
return SystemContextResponse(
|
|
25
|
+
correlation_id=request.correlation_id,
|
|
26
|
+
exponent_txt=await _read_exponent_txt(working_directory),
|
|
27
|
+
system_info=await get_system_info(working_directory),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def get_system_info(working_directory: str) -> SystemInfo:
|
|
32
|
+
return SystemInfo(
|
|
33
|
+
name=getpass.getuser(),
|
|
34
|
+
cwd=working_directory,
|
|
35
|
+
os=platform.system(),
|
|
36
|
+
shell=_get_user_shell(),
|
|
37
|
+
git=await get_git_info(working_directory),
|
|
38
|
+
python_env=python_execution.get_python_env_info(),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def _read_exponent_txt(working_directory: str) -> str | None:
|
|
43
|
+
for filename in EXPONENT_TXT_FILENAMES:
|
|
44
|
+
file_path = AsyncPath(os.path.join(working_directory, filename.lower()))
|
|
45
|
+
exists = await file_path.exists()
|
|
46
|
+
|
|
47
|
+
if exists:
|
|
48
|
+
return await safe_read_file(file_path)
|
|
49
|
+
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_user_shell() -> str:
|
|
54
|
+
return os.environ.get("SHELL", "bash")
|