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.

Files changed (56) hide show
  1. exponent/__init__.py +1 -0
  2. exponent/cli.py +112 -0
  3. exponent/commands/cloud_commands.py +85 -0
  4. exponent/commands/common.py +434 -0
  5. exponent/commands/config_commands.py +581 -0
  6. exponent/commands/github_app_commands.py +211 -0
  7. exponent/commands/listen_commands.py +96 -0
  8. exponent/commands/run_commands.py +208 -0
  9. exponent/commands/settings.py +56 -0
  10. exponent/commands/shell_commands.py +2840 -0
  11. exponent/commands/theme.py +246 -0
  12. exponent/commands/types.py +111 -0
  13. exponent/commands/upgrade.py +29 -0
  14. exponent/commands/utils.py +236 -0
  15. exponent/core/config.py +180 -0
  16. exponent/core/graphql/__init__.py +0 -0
  17. exponent/core/graphql/client.py +59 -0
  18. exponent/core/graphql/cloud_config_queries.py +77 -0
  19. exponent/core/graphql/get_chats_query.py +47 -0
  20. exponent/core/graphql/github_config_queries.py +56 -0
  21. exponent/core/graphql/mutations.py +75 -0
  22. exponent/core/graphql/queries.py +110 -0
  23. exponent/core/graphql/subscriptions.py +452 -0
  24. exponent/core/remote_execution/checkpoints.py +212 -0
  25. exponent/core/remote_execution/cli_rpc_types.py +214 -0
  26. exponent/core/remote_execution/client.py +545 -0
  27. exponent/core/remote_execution/code_execution.py +58 -0
  28. exponent/core/remote_execution/command_execution.py +105 -0
  29. exponent/core/remote_execution/error_info.py +45 -0
  30. exponent/core/remote_execution/exceptions.py +10 -0
  31. exponent/core/remote_execution/file_write.py +410 -0
  32. exponent/core/remote_execution/files.py +415 -0
  33. exponent/core/remote_execution/git.py +268 -0
  34. exponent/core/remote_execution/languages/python_execution.py +239 -0
  35. exponent/core/remote_execution/languages/shell_streaming.py +221 -0
  36. exponent/core/remote_execution/languages/types.py +20 -0
  37. exponent/core/remote_execution/session.py +128 -0
  38. exponent/core/remote_execution/system_context.py +54 -0
  39. exponent/core/remote_execution/tool_execution.py +289 -0
  40. exponent/core/remote_execution/truncation.py +284 -0
  41. exponent/core/remote_execution/types.py +670 -0
  42. exponent/core/remote_execution/utils.py +600 -0
  43. exponent/core/types/__init__.py +0 -0
  44. exponent/core/types/command_data.py +206 -0
  45. exponent/core/types/event_types.py +89 -0
  46. exponent/core/types/generated/__init__.py +0 -0
  47. exponent/core/types/generated/strategy_info.py +225 -0
  48. exponent/migration-docs/login.md +112 -0
  49. exponent/py.typed +4 -0
  50. exponent/utils/__init__.py +0 -0
  51. exponent/utils/colors.py +92 -0
  52. exponent/utils/version.py +289 -0
  53. indent-0.0.8.dist-info/METADATA +36 -0
  54. indent-0.0.8.dist-info/RECORD +56 -0
  55. indent-0.0.8.dist-info/WHEEL +4 -0
  56. 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")