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,375 @@
1
+ import asyncio
2
+ import fcntl
3
+ import logging
4
+ import os
5
+ import pty
6
+ import signal
7
+ import struct
8
+ import sys
9
+ import termios
10
+ import time
11
+ import traceback
12
+ from collections.abc import Callable
13
+
14
+ from exponent.core.remote_execution.terminal_types import (
15
+ TerminalMessage,
16
+ TerminalOutput,
17
+ TerminalResetSessions,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class TerminalSession:
24
+ """
25
+ Manages a PTY session for terminal emulation.
26
+ Runs on the CLI machine and streams output back to server.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ session_id: str,
32
+ output_callback: Callable[[str], None],
33
+ cols: int = 80,
34
+ rows: int = 24,
35
+ ):
36
+ self.session_id = session_id
37
+ self.output_callback = output_callback # Called with terminal output
38
+ self.cols = cols
39
+ self.rows = rows
40
+ self.master_fd: int | None = None
41
+ self.pid: int | None = None
42
+ self._running = False
43
+ self._read_task: asyncio.Task[None] | None = None
44
+
45
+ async def start(
46
+ self,
47
+ command: list[str] | None = None,
48
+ env: dict[str, str] | None = None,
49
+ ) -> None:
50
+ """Start the terminal session with PTY"""
51
+ if self._running:
52
+ raise RuntimeError(f"Terminal session {self.session_id} already running")
53
+
54
+ # Default to bash if no command specified
55
+ if command is None:
56
+ command = ["/bin/bash"]
57
+
58
+ # Spawn process with PTY
59
+ try:
60
+ self.pid, self.master_fd = pty.fork()
61
+ except OSError as e:
62
+ logger.error(
63
+ "Failed to fork PTY",
64
+ )
65
+ raise RuntimeError(f"Failed to fork PTY: {e}") from e
66
+
67
+ if self.pid == 0:
68
+ # Child process - execute command
69
+ try:
70
+ # Set up environment
71
+ if env:
72
+ for key, value in env.items():
73
+ os.environ[key] = value
74
+
75
+ # Set terminal environment
76
+ os.environ["TERM"] = "xterm-256color"
77
+ os.environ["COLORTERM"] = "truecolor"
78
+
79
+ # Execute command
80
+ os.execvp(command[0], command)
81
+ except Exception as e:
82
+ # If exec fails, log and exit child process
83
+ traceback.print_exc()
84
+ sys.stderr.write(f"Failed to execute command {command}: {e}\n")
85
+ sys.stderr.flush()
86
+ os._exit(1)
87
+ else:
88
+ # Parent process - set up non-blocking I/O
89
+ flags = fcntl.fcntl(self.master_fd, fcntl.F_GETFL)
90
+ fcntl.fcntl(self.master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
91
+
92
+ # Set initial size
93
+ self.resize(self.cols, self.rows)
94
+
95
+ # Start reading from PTY
96
+ self._running = True
97
+ self._read_task = asyncio.create_task(self._read_from_pty())
98
+
99
+ async def _read_from_pty(self) -> None:
100
+ """Continuously read from PTY using event loop's add_reader (non-blocking)"""
101
+ if self.master_fd is None:
102
+ return
103
+
104
+ loop = asyncio.get_event_loop()
105
+ read_queue: asyncio.Queue[bytes | None] = asyncio.Queue()
106
+
107
+ def read_callback() -> None:
108
+ """Called by event loop when data is available on the FD"""
109
+ if self.master_fd is None:
110
+ return
111
+ try:
112
+ data = os.read(self.master_fd, 4096)
113
+ if data:
114
+ # Put data in queue to be processed by async task
115
+ read_queue.put_nowait(data)
116
+ else:
117
+ # EOF - PTY closed
118
+ read_queue.put_nowait(None)
119
+ except OSError as e:
120
+ if e.errno == 11: # EAGAIN - shouldn't happen with add_reader
121
+ pass
122
+ else:
123
+ read_queue.put_nowait(None)
124
+ except Exception:
125
+ logger.error(
126
+ "Unexpected error in PTY read callback",
127
+ )
128
+ read_queue.put_nowait(None)
129
+
130
+ # Register the FD with the event loop
131
+ loop.add_reader(self.master_fd, read_callback)
132
+
133
+ try:
134
+ while self._running:
135
+ # Wait for data from the queue (non-blocking for event loop)
136
+ data = await read_queue.get()
137
+
138
+ if data is None:
139
+ # EOF or error
140
+ break
141
+
142
+ # Process the data
143
+ decoded = data.decode("utf-8", errors="replace")
144
+ self.output_callback(decoded)
145
+ finally:
146
+ # Unregister the FD from the event loop
147
+ loop.remove_reader(self.master_fd)
148
+
149
+ async def write_input(self, data: str) -> None:
150
+ """Write user input to PTY"""
151
+ if not self._running or self.master_fd is None:
152
+ raise RuntimeError(f"Terminal session {self.session_id} not running")
153
+
154
+ try:
155
+ os.write(self.master_fd, data.encode("utf-8"))
156
+ except OSError:
157
+ logger.error(
158
+ "Error writing to PTY",
159
+ )
160
+ raise
161
+
162
+ def resize(self, cols: int, rows: int) -> None:
163
+ """Resize the PTY to match terminal dimensions"""
164
+ if self.master_fd is None:
165
+ return
166
+
167
+ self.cols = cols
168
+ self.rows = rows
169
+
170
+ try:
171
+ size = struct.pack("HHHH", rows, cols, 0, 0)
172
+ fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, size)
173
+ except Exception:
174
+ logger.error(
175
+ "Error resizing PTY",
176
+ )
177
+
178
+ async def stop(self) -> tuple[bool, int | None]:
179
+ """
180
+ Stop the terminal session and clean up resources.
181
+ Returns (success, exit_code)
182
+ """
183
+ if not self._running:
184
+ return True, None
185
+
186
+ self._running = False
187
+
188
+ # Cancel read task
189
+ if self._read_task and not self._read_task.done():
190
+ self._read_task.cancel()
191
+ try:
192
+ await self._read_task
193
+ except asyncio.CancelledError:
194
+ pass
195
+
196
+ exit_code = None
197
+
198
+ # Close file descriptor
199
+ if self.master_fd is not None:
200
+ try:
201
+ os.close(self.master_fd)
202
+ except Exception:
203
+ logger.error(
204
+ "Error closing PTY fd",
205
+ )
206
+ self.master_fd = None
207
+
208
+ # Kill child process
209
+ if self.pid is not None:
210
+ try:
211
+ os.kill(self.pid, signal.SIGTERM)
212
+ # Wait for process to terminate (with timeout)
213
+ for _ in range(10): # Wait up to 1 second
214
+ try:
215
+ pid, status = os.waitpid(self.pid, os.WNOHANG)
216
+ if pid != 0:
217
+ exit_code = os.WEXITSTATUS(status)
218
+ break
219
+ except ChildProcessError:
220
+ break
221
+ await asyncio.sleep(0.1)
222
+ else:
223
+ # Force kill if still running
224
+ try:
225
+ os.kill(self.pid, signal.SIGKILL)
226
+ os.waitpid(self.pid, 0)
227
+ except Exception:
228
+ pass
229
+ except Exception:
230
+ logger.error(
231
+ "Error killing PTY process",
232
+ )
233
+ self.pid = None
234
+
235
+ logger.info(
236
+ "Terminal session stopped",
237
+ )
238
+
239
+ return True, exit_code
240
+
241
+ @property
242
+ def is_running(self) -> bool:
243
+ """Check if terminal session is running"""
244
+ return self._running and self.master_fd is not None
245
+
246
+
247
+ class TerminalSessionManager:
248
+ """Manages multiple terminal sessions"""
249
+
250
+ def __init__(self, output_queue: asyncio.Queue[TerminalMessage]) -> None:
251
+ self._sessions: dict[str, TerminalSession] = {}
252
+ self._lock = asyncio.Lock()
253
+ self._websocket: object | None = None
254
+ self._output_queue = output_queue
255
+
256
+ # Send reset message immediately to clear stale sessions
257
+ try:
258
+ reset_message = TerminalResetSessions()
259
+ self._output_queue.put_nowait(reset_message)
260
+ logger.info("Sent TerminalResetSessions message")
261
+ except asyncio.QueueFull:
262
+ logger.error("Failed to queue terminal reset message - queue full")
263
+
264
+ def set_websocket(self, websocket: object) -> None:
265
+ """Set the websocket for sending output"""
266
+ self._websocket = websocket
267
+
268
+ async def start_session(
269
+ self,
270
+ websocket: object,
271
+ session_id: str,
272
+ command: list[str] | None = None,
273
+ cols: int = 80,
274
+ rows: int = 24,
275
+ env: dict[str, str] | None = None,
276
+ ) -> str:
277
+ """Start a new terminal session"""
278
+ async with self._lock:
279
+ if session_id in self._sessions:
280
+ raise RuntimeError(f"Terminal session {session_id} already exists")
281
+
282
+ # Store websocket reference
283
+ self._websocket = websocket
284
+
285
+ # Create output callback that queues data to be sent
286
+ def output_callback(data: str) -> None:
287
+ # Queue the output to be sent asynchronously
288
+ try:
289
+ terminal_output = TerminalOutput(
290
+ session_id=session_id,
291
+ data=data,
292
+ timestamp=time.time(),
293
+ )
294
+ self._output_queue.put_nowait(terminal_output)
295
+ except asyncio.QueueFull:
296
+ logger.error(
297
+ "Terminal output queue full",
298
+ )
299
+
300
+ session = TerminalSession(
301
+ session_id=session_id,
302
+ output_callback=output_callback,
303
+ cols=cols,
304
+ rows=rows,
305
+ )
306
+
307
+ await session.start(command=command, env=env)
308
+ self._sessions[session_id] = session
309
+
310
+ return session_id
311
+
312
+ async def send_input(self, session_id: str, data: str) -> bool:
313
+ """Send input to a terminal session"""
314
+ async with self._lock:
315
+ session = self._sessions.get(session_id)
316
+ if session is None:
317
+ return False
318
+
319
+ try:
320
+ await session.write_input(data)
321
+ return True
322
+ except Exception:
323
+ logger.error(
324
+ "Failed to send input to terminal",
325
+ )
326
+ return False
327
+
328
+ async def resize_terminal(self, session_id: str, rows: int, cols: int) -> bool:
329
+ """Resize a terminal session"""
330
+ async with self._lock:
331
+ session = self._sessions.get(session_id)
332
+ if session is None:
333
+ return False
334
+
335
+ try:
336
+ session.resize(cols, rows)
337
+ return True
338
+ except Exception:
339
+ logger.error(
340
+ "Failed to resize terminal",
341
+ )
342
+ return False
343
+
344
+ async def stop_session(self, session_id: str) -> bool:
345
+ """Stop a terminal session"""
346
+ async with self._lock:
347
+ session = self._sessions.pop(session_id, None)
348
+ if session is None:
349
+ return True # Already stopped
350
+
351
+ try:
352
+ await session.stop()
353
+ return True
354
+ except Exception:
355
+ logger.error(
356
+ "Failed to stop terminal",
357
+ )
358
+ return False
359
+
360
+ async def stop_all_sessions(self) -> None:
361
+ """Stop all terminal sessions (cleanup on disconnect)"""
362
+ async with self._lock:
363
+ session_ids = list(self._sessions.keys())
364
+ for session_id in session_ids:
365
+ session = self._sessions.pop(session_id, None)
366
+ if session:
367
+ try:
368
+ await session.stop()
369
+ logger.info(
370
+ "Stopped terminal session on cleanup",
371
+ )
372
+ except Exception:
373
+ logger.error(
374
+ "Error stopping terminal session on cleanup",
375
+ )
@@ -0,0 +1,29 @@
1
+ """Type definitions for terminal output streaming."""
2
+
3
+ import msgspec
4
+
5
+
6
+ class TerminalOutput(msgspec.Struct, tag="terminal_output"):
7
+ """Terminal output data from CLI to web client."""
8
+
9
+ session_id: str
10
+ data: str
11
+ timestamp: float
12
+
13
+
14
+ class TerminalStatus(msgspec.Struct, tag="terminal_status"):
15
+ """Terminal status update from CLI to web client."""
16
+
17
+ session_id: str
18
+ status: str
19
+ message: str
20
+ exit_code: int | None = None
21
+
22
+
23
+ class TerminalResetSessions(msgspec.Struct, tag="terminal_reset_sessions"):
24
+ """Sent from CLI when terminal session manager starts to clear stale sessions."""
25
+
26
+ # No fields needed - just a signal
27
+
28
+
29
+ TerminalMessage = TerminalOutput | TerminalStatus | TerminalResetSessions