indent 0.1.26__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.
Files changed (55) hide show
  1. exponent/__init__.py +34 -0
  2. exponent/cli.py +110 -0
  3. exponent/commands/cloud_commands.py +585 -0
  4. exponent/commands/common.py +411 -0
  5. exponent/commands/config_commands.py +334 -0
  6. exponent/commands/run_commands.py +222 -0
  7. exponent/commands/settings.py +56 -0
  8. exponent/commands/types.py +111 -0
  9. exponent/commands/upgrade.py +29 -0
  10. exponent/commands/utils.py +146 -0
  11. exponent/core/config.py +180 -0
  12. exponent/core/graphql/__init__.py +0 -0
  13. exponent/core/graphql/client.py +61 -0
  14. exponent/core/graphql/get_chats_query.py +47 -0
  15. exponent/core/graphql/mutations.py +160 -0
  16. exponent/core/graphql/queries.py +146 -0
  17. exponent/core/graphql/subscriptions.py +16 -0
  18. exponent/core/remote_execution/checkpoints.py +212 -0
  19. exponent/core/remote_execution/cli_rpc_types.py +499 -0
  20. exponent/core/remote_execution/client.py +999 -0
  21. exponent/core/remote_execution/code_execution.py +77 -0
  22. exponent/core/remote_execution/default_env.py +31 -0
  23. exponent/core/remote_execution/error_info.py +45 -0
  24. exponent/core/remote_execution/exceptions.py +10 -0
  25. exponent/core/remote_execution/file_write.py +35 -0
  26. exponent/core/remote_execution/files.py +330 -0
  27. exponent/core/remote_execution/git.py +268 -0
  28. exponent/core/remote_execution/http_fetch.py +94 -0
  29. exponent/core/remote_execution/languages/python_execution.py +239 -0
  30. exponent/core/remote_execution/languages/shell_streaming.py +226 -0
  31. exponent/core/remote_execution/languages/types.py +20 -0
  32. exponent/core/remote_execution/port_utils.py +73 -0
  33. exponent/core/remote_execution/session.py +128 -0
  34. exponent/core/remote_execution/system_context.py +26 -0
  35. exponent/core/remote_execution/terminal_session.py +375 -0
  36. exponent/core/remote_execution/terminal_types.py +29 -0
  37. exponent/core/remote_execution/tool_execution.py +595 -0
  38. exponent/core/remote_execution/tool_type_utils.py +39 -0
  39. exponent/core/remote_execution/truncation.py +296 -0
  40. exponent/core/remote_execution/types.py +635 -0
  41. exponent/core/remote_execution/utils.py +477 -0
  42. exponent/core/types/__init__.py +0 -0
  43. exponent/core/types/command_data.py +206 -0
  44. exponent/core/types/event_types.py +89 -0
  45. exponent/core/types/generated/__init__.py +0 -0
  46. exponent/core/types/generated/strategy_info.py +213 -0
  47. exponent/migration-docs/login.md +112 -0
  48. exponent/py.typed +4 -0
  49. exponent/utils/__init__.py +0 -0
  50. exponent/utils/colors.py +92 -0
  51. exponent/utils/version.py +289 -0
  52. indent-0.1.26.dist-info/METADATA +38 -0
  53. indent-0.1.26.dist-info/RECORD +55 -0
  54. indent-0.1.26.dist-info/WHEEL +4 -0
  55. indent-0.1.26.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,226 @@
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.default_env import get_process_env
10
+ from exponent.core.remote_execution.languages.types import (
11
+ ShellExecutionResult,
12
+ StreamedOutputPiece,
13
+ )
14
+ from exponent.core.remote_execution.utils import smart_decode
15
+
16
+ STDOUT_FD = 1
17
+ STDERR_FD = 2
18
+ MAX_TIMEOUT = 300
19
+
20
+
21
+ def get_rc_file_source_command(shell_path: str) -> str:
22
+ """
23
+ Returns a command to source the user's shell rc file
24
+ Login profiles are already sourced via the -l flag
25
+ """
26
+ # On Windows, shell behavior is different
27
+ if platform.system() == "Windows":
28
+ return "" # Windows shells don't typically use rc files in the same way
29
+
30
+ shell_name = os.path.basename(shell_path)
31
+ home_dir = os.path.expanduser("~")
32
+
33
+ if shell_name == "zsh":
34
+ zshrc = os.path.join(home_dir, ".zshrc")
35
+ if os.path.exists(zshrc):
36
+ return f"source {zshrc} 2>/dev/null || true; "
37
+ elif shell_name == "bash":
38
+ bashrc = os.path.join(home_dir, ".bashrc")
39
+ if os.path.exists(bashrc):
40
+ return f"source {bashrc} 2>/dev/null || true; "
41
+
42
+ return "" # No rc file found or unsupported shell
43
+
44
+
45
+ async def read_stream(
46
+ stream: asyncio.StreamReader, fd: int, output: list[tuple[int, str]]
47
+ ) -> AsyncGenerator[StreamedOutputPiece, None]:
48
+ data: bytes = b""
49
+ encoding = "utf-8"
50
+
51
+ while True:
52
+ try:
53
+ data = await stream.read(50_000)
54
+ if not data:
55
+ break
56
+ chunk = data.decode(encoding=encoding, errors="replace")
57
+ output.append((fd, chunk))
58
+ yield StreamedOutputPiece(content=chunk)
59
+ except UnicodeDecodeError:
60
+ decode_result = smart_decode(data)
61
+
62
+ if decode_result is None:
63
+ break
64
+
65
+ # Store the detected encoding as a hint for how
66
+ # to decode the remaining chunks of data
67
+ chunk, encoding = decode_result
68
+
69
+ output.append((fd, chunk))
70
+ yield StreamedOutputPiece(content=chunk)
71
+
72
+ continue
73
+ except asyncio.CancelledError:
74
+ raise
75
+ except Exception:
76
+ break
77
+
78
+
79
+ async def execute_shell_streaming( # noqa: PLR0915
80
+ code: str,
81
+ working_directory: str,
82
+ timeout: int,
83
+ should_halt: Callable[[], bool] | None = None,
84
+ env: dict[str, str] | None = None,
85
+ ) -> AsyncGenerator[StreamedOutputPiece | ShellExecutionResult, None]:
86
+ timeout_seconds = min(timeout, MAX_TIMEOUT)
87
+
88
+ shell_path = os.environ.get("SHELL") or shutil.which("bash") or shutil.which("sh")
89
+
90
+ if not shell_path:
91
+ process = await asyncio.create_subprocess_shell(
92
+ code,
93
+ stdout=asyncio.subprocess.PIPE,
94
+ stderr=asyncio.subprocess.PIPE,
95
+ cwd=working_directory,
96
+ env=get_process_env(env),
97
+ )
98
+ else:
99
+ # Add rc file sourcing to the command
100
+ rc_source_cmd = get_rc_file_source_command(shell_path)
101
+ full_command = f"{rc_source_cmd}{code}"
102
+
103
+ process = await asyncio.create_subprocess_exec(
104
+ shell_path,
105
+ "-l",
106
+ "-c",
107
+ full_command,
108
+ stdout=asyncio.subprocess.PIPE,
109
+ stderr=asyncio.subprocess.PIPE,
110
+ cwd=working_directory,
111
+ env=get_process_env(env),
112
+ start_new_session=True if platform.system() != "Windows" else False,
113
+ )
114
+
115
+ exit_code = None
116
+ output: list[tuple[int, str]] = []
117
+ halted = False
118
+ timed_out = False
119
+ assert process.stdout
120
+ assert process.stderr
121
+
122
+ async def monitor_halt() -> None:
123
+ nonlocal halted
124
+
125
+ while True:
126
+ if should_halt and should_halt():
127
+ # Send signal to process group for proper interrupt propagation
128
+ try:
129
+ if platform.system() != "Windows":
130
+ # Send SIGTERM to the process group
131
+ os.killpg(process.pid, signal.SIGTERM)
132
+ try:
133
+ await asyncio.wait_for(process.wait(), timeout=2.0)
134
+ except TimeoutError:
135
+ # Fall back to SIGKILL to process group
136
+ os.killpg(process.pid, signal.SIGKILL)
137
+ else:
138
+ # Windows fallback
139
+ process.terminate()
140
+ try:
141
+ await asyncio.wait_for(process.wait(), timeout=2.0)
142
+ except TimeoutError:
143
+ process.kill()
144
+ except (ProcessLookupError, OSError):
145
+ # Process or process group already terminated
146
+ pass
147
+ halted = True
148
+ break
149
+ if process.returncode is not None:
150
+ break
151
+ await asyncio.sleep(0.1)
152
+
153
+ def on_timeout() -> None:
154
+ nonlocal timed_out
155
+ timed_out = True
156
+ try:
157
+ process.kill()
158
+ except ProcessLookupError:
159
+ pass
160
+
161
+ try:
162
+ halt_task = asyncio.create_task(monitor_halt()) if should_halt else None
163
+ timeout_handle = asyncio.get_running_loop().call_later(
164
+ timeout_seconds, on_timeout
165
+ )
166
+
167
+ # Stream stdout and stderr concurrently using wait
168
+ stdout_gen = read_stream(process.stdout, STDOUT_FD, output)
169
+ stderr_gen = read_stream(process.stderr, STDERR_FD, output)
170
+
171
+ stdout_task = asyncio.create_task(stdout_gen.__anext__())
172
+ stderr_task = asyncio.create_task(stderr_gen.__anext__())
173
+ pending = {stdout_task, stderr_task}
174
+
175
+ while pending:
176
+ done, pending = await asyncio.wait(
177
+ pending, return_when=asyncio.FIRST_COMPLETED
178
+ )
179
+
180
+ for task in done:
181
+ try:
182
+ piece = await task
183
+ yield piece
184
+
185
+ # Schedule next read from the same stream
186
+ # Don't check process.returncode here - we need to drain all buffered output
187
+ if task is stdout_task and not process.stdout.at_eof():
188
+ stdout_task = asyncio.create_task(stdout_gen.__anext__())
189
+ pending.add(stdout_task)
190
+ elif task is stderr_task and not process.stderr.at_eof():
191
+ stderr_task = asyncio.create_task(stderr_gen.__anext__())
192
+ pending.add(stderr_task)
193
+ except StopAsyncIteration:
194
+ continue
195
+
196
+ exit_code = await process.wait()
197
+ timeout_handle.cancel()
198
+
199
+ except asyncio.CancelledError:
200
+ process.kill()
201
+ raise
202
+ finally:
203
+ # Explicitly kill the process if it's still running
204
+ if process and process.returncode is None:
205
+ try:
206
+ if platform.system() != "Windows":
207
+ os.killpg(process.pid, signal.SIGTERM)
208
+ else:
209
+ process.terminate()
210
+ except (ProcessLookupError, OSError):
211
+ pass
212
+
213
+ tasks_to_cancel: list[asyncio.Task[Any]] = [stdout_task, stderr_task]
214
+ if halt_task:
215
+ tasks_to_cancel.append(halt_task)
216
+
217
+ await asyncio.gather(*tasks_to_cancel, return_exceptions=True)
218
+
219
+ formatted_output = "".join([chunk for (_, chunk) in output]).strip() + "\n\n"
220
+
221
+ yield ShellExecutionResult(
222
+ output=formatted_output,
223
+ cancelled_for_timeout=timed_out,
224
+ exit_code=None if timed_out else exit_code,
225
+ halted=halted,
226
+ )
@@ -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,73 @@
1
+ import time
2
+
3
+ import psutil
4
+
5
+ from exponent.core.remote_execution.types import PortInfo
6
+
7
+
8
+ def get_port_usage() -> list[PortInfo] | None:
9
+ """
10
+ Get information about all listening ports on the system.
11
+
12
+ Returns:
13
+ List of PortInfo objects containing process name, port, protocol, pid, and uptime.
14
+ Returns None if there's a permission error.
15
+ Returns empty list if no listening ports are found.
16
+ """
17
+ try:
18
+ connections = psutil.net_connections(kind="tcp")
19
+ except (psutil.AccessDenied, PermissionError):
20
+ # If we don't have permission to see connections, return None
21
+ return None
22
+ except Exception:
23
+ # For any other unexpected errors, return None
24
+ return None
25
+
26
+ port_info_list: list[PortInfo] = []
27
+ current_time = time.time()
28
+
29
+ for conn in connections:
30
+ # Only include TCP ports in LISTEN state
31
+ if conn.status != "LISTEN":
32
+ continue
33
+
34
+ # Skip if no local address (shouldn't happen for LISTEN, but be safe)
35
+ if not conn.laddr:
36
+ continue
37
+
38
+ port = conn.laddr.port
39
+ pid = conn.pid
40
+
41
+ # Try to get process information
42
+ process_name = "unknown"
43
+ uptime_seconds = None
44
+
45
+ if pid:
46
+ try:
47
+ process = psutil.Process(pid)
48
+ process_name = process.name()
49
+
50
+ # Calculate uptime
51
+ create_time = process.create_time()
52
+ uptime_seconds = current_time - create_time
53
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
54
+ # Process disappeared or we don't have permission
55
+ pass
56
+ except Exception:
57
+ # Any other unexpected error, just skip process info
58
+ pass
59
+
60
+ port_info = PortInfo(
61
+ process_name=process_name,
62
+ port=port,
63
+ protocol="TCP",
64
+ pid=pid,
65
+ uptime_seconds=uptime_seconds,
66
+ )
67
+ port_info_list.append(port_info)
68
+
69
+ # Limit to 50 ports to avoid bloating the heartbeat payload
70
+ if len(port_info_list) >= 50:
71
+ break
72
+
73
+ return port_info_list
@@ -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:
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,26 @@
1
+ import getpass
2
+ import os
3
+ import platform
4
+
5
+ from exponent.core.remote_execution.git import get_git_info
6
+ from exponent.core.remote_execution.languages import python_execution
7
+ from exponent.core.remote_execution.port_utils import get_port_usage
8
+ from exponent.core.remote_execution.types import (
9
+ SystemInfo,
10
+ )
11
+
12
+
13
+ async def get_system_info(working_directory: str) -> SystemInfo:
14
+ return SystemInfo(
15
+ name=getpass.getuser(),
16
+ cwd=working_directory,
17
+ os=platform.system(),
18
+ shell=_get_user_shell(),
19
+ git=await get_git_info(working_directory),
20
+ python_env=python_execution.get_python_env_info(),
21
+ port_usage=get_port_usage(),
22
+ )
23
+
24
+
25
+ def _get_user_shell() -> str:
26
+ return os.environ.get("SHELL", "bash")