indent 0.1.17__py3-none-any.whl → 0.1.18__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 CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.17'
32
- __version_tuple__ = version_tuple = (0, 1, 17)
31
+ __version__ = version = '0.1.18'
32
+ __version_tuple__ = version_tuple = (0, 1, 18)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -51,6 +51,7 @@ class ErrorToolResult(ToolResult, tag="error"):
51
51
 
52
52
 
53
53
  READ_TOOL_NAME = "read"
54
+ READ_TOOL_ARTIFACT_NAME = "read_tool_artifact"
54
55
 
55
56
 
56
57
  class ReadToolInput(ToolInput, tag=READ_TOOL_NAME):
@@ -80,6 +81,15 @@ class ReadToolResult(ToolResult, tag=READ_TOOL_NAME):
80
81
  return "\n".join(lines)
81
82
 
82
83
 
84
+ class ReadToolArtifactResult(ToolResult, tag=READ_TOOL_ARTIFACT_NAME):
85
+ s3_uri: str
86
+ file_path: str
87
+ media_type: str
88
+
89
+ def to_text(self) -> str:
90
+ return f"[Image artifact uploaded to {self.s3_uri}]"
91
+
92
+
83
93
  LIST_TOOL_NAME = "ls"
84
94
 
85
95
 
@@ -217,6 +227,7 @@ PartialToolResultType = PartialBashToolResult
217
227
 
218
228
  ToolResultType = (
219
229
  ReadToolResult
230
+ | ReadToolArtifactResult
220
231
  | WriteToolResult
221
232
  | ListToolResult
222
233
  | GlobToolResult
@@ -273,6 +284,16 @@ class KeepAliveCliChatResponse(msgspec.Struct, tag="keep_alive_cli_chat"):
273
284
  pass
274
285
 
275
286
 
287
+ class GenerateUploadUrlRequest(msgspec.Struct, tag="generate_upload_url"):
288
+ s3_key: str
289
+ content_type: str
290
+
291
+
292
+ class GenerateUploadUrlResponse(msgspec.Struct, tag="generate_upload_url"):
293
+ upload_url: str
294
+ s3_uri: str
295
+
296
+
276
297
  class CliRpcRequest(msgspec.Struct):
277
298
  request_id: str
278
299
  request: (
@@ -283,6 +304,7 @@ class CliRpcRequest(msgspec.Struct):
283
304
  | BatchToolExecutionRequest
284
305
  | SwitchCLIChatRequest
285
306
  | KeepAliveCliChatRequest
307
+ | GenerateUploadUrlRequest
286
308
  )
287
309
 
288
310
 
@@ -305,4 +327,5 @@ class CliRpcResponse(msgspec.Struct):
305
327
  | HttpResponse
306
328
  | SwitchCLIChatResponse
307
329
  | KeepAliveCliChatResponse
330
+ | GenerateUploadUrlResponse
308
331
  )
@@ -31,6 +31,8 @@ from exponent.core.remote_execution.cli_rpc_types import (
31
31
  CliRpcResponse,
32
32
  ErrorResponse,
33
33
  ErrorToolResult,
34
+ GenerateUploadUrlRequest,
35
+ GenerateUploadUrlResponse,
34
36
  GetAllFilesRequest,
35
37
  GetAllFilesResponse,
36
38
  HttpRequest,
@@ -115,6 +117,13 @@ class RemoteExecutionClient:
115
117
  # Track last request time for timeout functionality
116
118
  self._last_request_time: float | None = None
117
119
 
120
+ # Track pending upload URL requests
121
+ self._pending_upload_requests: dict[
122
+ str, asyncio.Future[GenerateUploadUrlResponse]
123
+ ] = {}
124
+ self._upload_request_lock = asyncio.Lock()
125
+ self._websocket: ClientConnection | None = None
126
+
118
127
  @property
119
128
  def working_directory(self) -> str:
120
129
  return self.current_session.working_directory
@@ -186,7 +195,21 @@ class RemoteExecutionClient:
186
195
  self._last_request_time = time.time()
187
196
 
188
197
  msg_data = json.loads(msg)
189
- if msg_data["type"] != "request":
198
+ if msg_data["type"] == "result":
199
+ data = json.dumps(msg_data["data"])
200
+ try:
201
+ response = msgspec.json.decode(data, type=CliRpcResponse)
202
+ if isinstance(response.response, GenerateUploadUrlResponse):
203
+ async with self._upload_request_lock:
204
+ if response.request_id in self._pending_upload_requests:
205
+ future = self._pending_upload_requests.pop(
206
+ response.request_id
207
+ )
208
+ future.set_result(response.response)
209
+ except Exception as e:
210
+ logger.error(f"Error handling upload URL response: {e}")
211
+ return None
212
+ elif msg_data["type"] != "request":
190
213
  return None
191
214
 
192
215
  data = json.dumps(msg_data["data"])
@@ -445,6 +468,8 @@ class RemoteExecutionClient:
445
468
  if connection_tracker is not None:
446
469
  await connection_tracker.set_connected(True)
447
470
 
471
+ self._websocket = websocket
472
+
448
473
  beats: asyncio.Queue[HeartbeatInfo] = asyncio.Queue()
449
474
  requests: asyncio.Queue[CliRpcRequest] = asyncio.Queue()
450
475
  results: asyncio.Queue[CliRpcResponse] = asyncio.Queue()
@@ -579,6 +604,38 @@ class RemoteExecutionClient:
579
604
  logger.info(f"Heartbeat response: {connected_state}")
580
605
  return connected_state
581
606
 
607
+ async def request_upload_url(
608
+ self, s3_key: str, content_type: str
609
+ ) -> GenerateUploadUrlResponse:
610
+ if self._websocket is None:
611
+ raise RuntimeError("No active websocket connection")
612
+
613
+ request_id = str(uuid.uuid4())
614
+ request = CliRpcRequest(
615
+ request_id=request_id,
616
+ request=GenerateUploadUrlRequest(s3_key=s3_key, content_type=content_type),
617
+ )
618
+
619
+ future: asyncio.Future[GenerateUploadUrlResponse] = asyncio.Future()
620
+ async with self._upload_request_lock:
621
+ self._pending_upload_requests[request_id] = future
622
+
623
+ try:
624
+ await self._websocket.send(
625
+ json.dumps({"type": "request", "data": msgspec.to_builtins(request)})
626
+ )
627
+
628
+ response = await asyncio.wait_for(future, timeout=30)
629
+ return response
630
+ except TimeoutError:
631
+ async with self._upload_request_lock:
632
+ self._pending_upload_requests.pop(request_id, None)
633
+ raise RuntimeError("Timeout waiting for upload URL response")
634
+ except Exception as e:
635
+ async with self._upload_request_lock:
636
+ self._pending_upload_requests.pop(request_id, None)
637
+ raise e
638
+
582
639
  async def handle_request(self, request: CliRpcRequest) -> CliRpcResponse:
583
640
  # Update last request time for timeout functionality
584
641
  self._last_request_time = time.time()
@@ -593,7 +650,7 @@ class RemoteExecutionClient:
593
650
  )
594
651
  else:
595
652
  raw_result = await execute_tool( # type: ignore[assignment]
596
- request.request.tool_input, self.working_directory
653
+ request.request.tool_input, self.working_directory, self
597
654
  )
598
655
  tool_result = truncate_result(raw_result)
599
656
  return CliRpcResponse(
@@ -620,7 +677,9 @@ class RemoteExecutionClient:
620
677
  )
621
678
  )
622
679
  else:
623
- coros.append(execute_tool(tool_input, self.working_directory))
680
+ coros.append(
681
+ execute_tool(tool_input, self.working_directory, self)
682
+ )
624
683
 
625
684
  results: list[ToolResultType | BaseException] = await asyncio.gather(
626
685
  *coros, return_exceptions=True
@@ -3,6 +3,7 @@ import uuid
3
3
  from collections.abc import Callable
4
4
  from pathlib import Path
5
5
  from time import time
6
+ from typing import TYPE_CHECKING
6
7
 
7
8
  from anyio import Path as AsyncPath
8
9
 
@@ -19,6 +20,7 @@ from exponent.core.remote_execution.cli_rpc_types import (
19
20
  GrepToolResult,
20
21
  ListToolInput,
21
22
  ListToolResult,
23
+ ReadToolArtifactResult,
22
24
  ReadToolInput,
23
25
  ReadToolResult,
24
26
  ToolInputType,
@@ -26,6 +28,9 @@ from exponent.core.remote_execution.cli_rpc_types import (
26
28
  WriteToolInput,
27
29
  WriteToolResult,
28
30
  )
31
+
32
+ if TYPE_CHECKING:
33
+ from exponent.core.remote_execution.client import RemoteExecutionClient
29
34
  from exponent.core.remote_execution.code_execution import (
30
35
  execute_code_streaming,
31
36
  )
@@ -45,10 +50,12 @@ logger = logging.getLogger(__name__)
45
50
 
46
51
 
47
52
  async def execute_tool(
48
- tool_input: ToolInputType, working_directory: str
53
+ tool_input: ToolInputType,
54
+ working_directory: str,
55
+ upload_client: "RemoteExecutionClient | None" = None,
49
56
  ) -> ToolResultType:
50
57
  if isinstance(tool_input, ReadToolInput):
51
- return await execute_read_file(tool_input, working_directory)
58
+ return await execute_read_file(tool_input, working_directory, upload_client)
52
59
  elif isinstance(tool_input, WriteToolInput):
53
60
  return await execute_write_file(tool_input, working_directory)
54
61
  elif isinstance(tool_input, ListToolInput):
@@ -69,9 +76,20 @@ def truncate_result[T: ToolResultType](tool_result: T) -> T:
69
76
  return truncate_tool_result(tool_result)
70
77
 
71
78
 
79
+ def is_image_file(file_path: str) -> tuple[bool, str | None]:
80
+ ext = Path(file_path).suffix.lower()
81
+ if ext == ".png":
82
+ return (True, "image/png")
83
+ elif ext in [".jpg", ".jpeg"]:
84
+ return (True, "image/jpeg")
85
+ return (False, None)
86
+
87
+
72
88
  async def execute_read_file( # noqa: PLR0911, PLR0915
73
- tool_input: ReadToolInput, working_directory: str
74
- ) -> ReadToolResult | ErrorToolResult:
89
+ tool_input: ReadToolInput,
90
+ working_directory: str,
91
+ upload_client: "RemoteExecutionClient | None" = None,
92
+ ) -> ReadToolResult | ReadToolArtifactResult | ErrorToolResult:
75
93
  # Validate absolute path requirement
76
94
  if not tool_input.file_path.startswith("/"):
77
95
  return ErrorToolResult(
@@ -87,6 +105,39 @@ async def execute_read_file( # noqa: PLR0911, PLR0915
87
105
 
88
106
  file = AsyncPath(working_directory, tool_input.file_path)
89
107
 
108
+ # Check if this is an image file and we have an upload client
109
+ is_image, media_type = is_image_file(tool_input.file_path)
110
+ if is_image and media_type and upload_client is not None:
111
+ try:
112
+ import aiohttp
113
+
114
+ file_name = Path(tool_input.file_path).name
115
+ s3_key = f"images/{uuid.uuid4()}/{file_name}"
116
+
117
+ upload_response = await upload_client.request_upload_url(s3_key, media_type)
118
+
119
+ async with aiohttp.ClientSession() as session:
120
+ f = await file.open("rb")
121
+ async with f:
122
+ file_data = await f.read()
123
+ async with session.put(
124
+ upload_response.upload_url,
125
+ data=file_data,
126
+ headers={"Content-Type": media_type},
127
+ ) as resp:
128
+ if resp.status != 200:
129
+ raise RuntimeError(
130
+ f"Upload failed with status {resp.status}"
131
+ )
132
+
133
+ return ReadToolArtifactResult(
134
+ s3_uri=upload_response.s3_uri,
135
+ file_path=tool_input.file_path,
136
+ media_type=media_type,
137
+ )
138
+ except Exception as e:
139
+ return ErrorToolResult(error_message=f"Failed to upload image to S3: {e!s}")
140
+
90
141
  try:
91
142
  exists = await file.exists()
92
143
  except (OSError, PermissionError) as e:
@@ -11,6 +11,7 @@ from exponent.core.remote_execution.cli_rpc_types import (
11
11
  GlobToolResult,
12
12
  GrepToolResult,
13
13
  ListToolResult,
14
+ ReadToolArtifactResult,
14
15
  ReadToolResult,
15
16
  ToolResult,
16
17
  WriteToolResult,
@@ -173,6 +174,14 @@ class TailTruncation(TruncationStrategy):
173
174
  return replace(result, **updates)
174
175
 
175
176
 
177
+ class NoOpTruncation(TruncationStrategy):
178
+ def should_truncate(self, result: ToolResult) -> bool:
179
+ return False
180
+
181
+ def truncate(self, result: ToolResult) -> ToolResult:
182
+ return result
183
+
184
+
176
185
  class StringListTruncation(TruncationStrategy):
177
186
  """Truncation for lists of strings that limits both number of items and individual string length."""
178
187
 
@@ -264,6 +273,7 @@ class StringListTruncation(TruncationStrategy):
264
273
 
265
274
  TRUNCATION_REGISTRY: dict[type[ToolResult], TruncationStrategy] = {
266
275
  ReadToolResult: StringFieldTruncation("content"),
276
+ ReadToolArtifactResult: NoOpTruncation(),
267
277
  WriteToolResult: StringFieldTruncation("message"),
268
278
  BashToolResult: TailTruncation("shell_output"),
269
279
  GrepToolResult: StringListTruncation("matches"),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: indent
3
- Version: 0.1.17
3
+ Version: 0.1.18
4
4
  Summary: Indent is an AI Pair Programmer
5
5
  Author-email: Sashank Thupukari <sashank@exponent.run>
6
6
  Requires-Python: <3.13,>=3.10
@@ -1,4 +1,4 @@
1
- exponent/__init__.py,sha256=TtVjjQ5FSnY_MX0ZAPLaNmAfTJWa0sEMBdMs65ngXMM,706
1
+ exponent/__init__.py,sha256=Nx3lULyklTDQB2p2ofjQ59zAxYunJHGjMIsvHePGZsI,706
2
2
  exponent/cli.py,sha256=QnIeDTgWaQJrRs5WESCkQpVEQiJiAO4qWgB0rYlkd78,3344
3
3
  exponent/py.typed,sha256=9XZl5avs8yHp89XP_1Fjtbeg_2rjYorCC9I0k_j-h2c,334
4
4
  exponent/commands/cloud_commands.py,sha256=TNSbKnc7VBo7VALj44CqV5tdCACJejEGmtYvc5wjza4,19080
@@ -18,8 +18,8 @@ exponent/core/graphql/mutations.py,sha256=cAAiSefyamBgy1zJEZ7LNk0rbjwawTPxMyj8eU
18
18
  exponent/core/graphql/queries.py,sha256=RYsk8bub0esspqgakilhzX07yJf2652Ey9tBZK1l_lY,3297
19
19
  exponent/core/graphql/subscriptions.py,sha256=SQngv_nYVNJjiZ_P2k0UcLIu1pzc4vi7q7lhH89NCZM,393
20
20
  exponent/core/remote_execution/checkpoints.py,sha256=3QGYMLa8vT7XmxMYTRcGrW8kNGHwRC0AkUfULribJWg,6354
21
- exponent/core/remote_execution/cli_rpc_types.py,sha256=8xCDy-3kA-PQew8l7CZtoCrCFHz9FsMWI8aXljh5wCg,7659
22
- exponent/core/remote_execution/client.py,sha256=l0x0wUlhsEBDAmC_llS4ihP0f-3gdrCvf4wwgDpWKNc,28916
21
+ exponent/core/remote_execution/cli_rpc_types.py,sha256=39MQRwi2WRBBOfIwUpbBDs_ffLgURTaT6kBby3TVTZQ,8255
22
+ exponent/core/remote_execution/client.py,sha256=9TlYzwh1lBiOeBlhGUhSo73KQGMh6nvbm1xdfxM48OY,31349
23
23
  exponent/core/remote_execution/code_execution.py,sha256=jYPB_7dJzS9BTPLX9fKQpsFPatwjbXuaFFSxT9tDTfI,2388
24
24
  exponent/core/remote_execution/error_info.py,sha256=Rd7OA3ps06qYejPVcOaMBB9AtftP3wqQoOfiILFASnc,1378
25
25
  exponent/core/remote_execution/exceptions.py,sha256=eT57lBnBhvh-KJ5lsKWcfgGA5-WisAxhjZx-Z6OupZY,135
@@ -29,9 +29,9 @@ exponent/core/remote_execution/git.py,sha256=dGjBpeoKJZsYgRwctSq29GmbsNIN9tbSA3V
29
29
  exponent/core/remote_execution/http_fetch.py,sha256=aFEyXd0S-MRfisSMuIFiEyc1AEAj9nUZ9Rj_P_YRows,2827
30
30
  exponent/core/remote_execution/session.py,sha256=jlQIdeUj0f7uOk3BgzlJtBJ_GyTIjCchBp5ApQuF2-I,3847
31
31
  exponent/core/remote_execution/system_context.py,sha256=QY1zY8_fWj3sh-fmLYtewvgxh7uTX4ITIJqlUTDkj6c,648
32
- exponent/core/remote_execution/tool_execution.py,sha256=g5C0CAlOJhxsNauVQVhXvYGFpA8rDnO05VsWO2ag05k,13654
32
+ exponent/core/remote_execution/tool_execution.py,sha256=jhsZCV_XDyXC9moN01xUTLvl-q84jMFtpauPqPGz2cQ,15591
33
33
  exponent/core/remote_execution/tool_type_utils.py,sha256=7qi6Qd8fvHts019ZSLPbtiy17BUqgqBg3P_gdfvFf7w,1301
34
- exponent/core/remote_execution/truncation.py,sha256=noB6c4eaebqq5ghTlYJkXbe2XY8Bz_GBeh9DazJUrrU,9644
34
+ exponent/core/remote_execution/truncation.py,sha256=1L0qwbWgQFRwJZeCXRVKkEhKmzoS1x2usDLPUIp-jlU,9923
35
35
  exponent/core/remote_execution/types.py,sha256=uo8h_ftcafmDp8YTxAI-H9qSaDygHYRFnnrmrBxukm4,14934
36
36
  exponent/core/remote_execution/utils.py,sha256=6PlBqYJ3OQwZ0dgXiIu3br04a-d-glDeDZpD0XGGPAE,14793
37
37
  exponent/core/remote_execution/languages/python_execution.py,sha256=nsX_LsXcUcHhiEHpSTjOTVNd7CxM146al0kw_iQX5OU,7724
@@ -46,7 +46,7 @@ exponent/migration-docs/login.md,sha256=KIeXy3m2nzSUgw-4PW1XzXfHael1D4Zu93CplLMb
46
46
  exponent/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
47
  exponent/utils/colors.py,sha256=HBkqe_ZmhJ9YiL2Fpulqek4KvLS5mwBTY4LQSM5N8SM,2762
48
48
  exponent/utils/version.py,sha256=GHZ9ET1kMyDubJZU3w2sah5Pw8XpiEakS5IOlt3wUnQ,8888
49
- indent-0.1.17.dist-info/METADATA,sha256=w5ahImXeo_nq_h3eZd92XsjsYzNmPU8nR-D9xSRIRRI,1308
50
- indent-0.1.17.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
51
- indent-0.1.17.dist-info/entry_points.txt,sha256=q8q1t1sbl4NULGOR0OV5RmSG4KEjkpEQRU_RUXEGzcs,44
52
- indent-0.1.17.dist-info/RECORD,,
49
+ indent-0.1.18.dist-info/METADATA,sha256=fhSeEdMOl6WPMNlWcAWROiZT13E1-b2DooMkOwemXm4,1308
50
+ indent-0.1.18.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
51
+ indent-0.1.18.dist-info/entry_points.txt,sha256=q8q1t1sbl4NULGOR0OV5RmSG4KEjkpEQRU_RUXEGzcs,44
52
+ indent-0.1.18.dist-info/RECORD,,