indent 0.1.9__py3-none-any.whl → 0.1.11__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.

@@ -1,6 +1,12 @@
1
+ from typing import TYPE_CHECKING, Any
2
+
1
3
  import msgspec
2
4
  import yaml
3
- from typing import Any
5
+
6
+ if TYPE_CHECKING:
7
+ from exponent_server.core.tools.edit_tool import (
8
+ EditToolInput as ServerSideEditToolInput,
9
+ )
4
10
 
5
11
 
6
12
  class PartialToolResult(msgspec.Struct, tag_field="tool_name", omit_defaults=True):
@@ -46,11 +52,17 @@ class ReadToolInput(ToolInput, tag=READ_TOOL_NAME):
46
52
  limit: int | None = None
47
53
 
48
54
 
55
+ class FileMetadata(msgspec.Struct):
56
+ modified_timestamp: float
57
+ file_mode: str
58
+
59
+
49
60
  class ReadToolResult(ToolResult, tag=READ_TOOL_NAME):
50
61
  content: str
51
62
  num_lines: int
52
63
  start_line: int
53
64
  total_lines: int
65
+ metadata: FileMetadata | None = None
54
66
 
55
67
  def to_text(self) -> str:
56
68
  lines = self.content.splitlines()
@@ -99,6 +111,7 @@ class WriteToolInput(ToolInput, tag=WRITE_TOOL_NAME):
99
111
 
100
112
  class WriteToolResult(ToolResult, tag=WRITE_TOOL_NAME):
101
113
  message: str
114
+ metadata: FileMetadata | None = None
102
115
 
103
116
 
104
117
  GREP_TOOL_NAME = "grep"
@@ -125,10 +138,27 @@ class EditToolInput(ToolInput, tag=EDIT_TOOL_NAME):
125
138
  old_string: str
126
139
  new_string: str
127
140
  replace_all: bool = False
141
+ # The last retrieved modified timestamp. This is used to check if the file has been modified since the last read/write that we are aware of (in which case we should re-read the file before editing).
142
+ last_known_modified_timestamp: float | None = None
143
+
144
+ @classmethod
145
+ def from_server_side_input(
146
+ cls,
147
+ server_side_input: "ServerSideEditToolInput",
148
+ last_known_modified_timestamp: float | None,
149
+ ) -> "EditToolInput":
150
+ return cls(
151
+ file_path=server_side_input.file_path,
152
+ old_string=server_side_input.old_string,
153
+ new_string=server_side_input.new_string,
154
+ replace_all=server_side_input.replace_all,
155
+ last_known_modified_timestamp=last_known_modified_timestamp,
156
+ )
128
157
 
129
158
 
130
159
  class EditToolResult(ToolResult, tag=EDIT_TOOL_NAME):
131
160
  message: str
161
+ metadata: FileMetadata | None = None
132
162
 
133
163
 
134
164
  BASH_TOOL_NAME = "bash"
@@ -6,7 +6,7 @@ import logging
6
6
  from collections.abc import AsyncGenerator, Callable
7
7
  from contextlib import asynccontextmanager
8
8
  from dataclasses import dataclass
9
- from typing import Any, TypeVar, Union, cast
9
+ from typing import Any, TypeVar, cast
10
10
 
11
11
  import msgspec
12
12
  import websockets.client
@@ -29,15 +29,14 @@ from exponent.core.remote_execution.cli_rpc_types import (
29
29
  ErrorResponse,
30
30
  GetAllFilesRequest,
31
31
  GetAllFilesResponse,
32
- HttpResponse,
33
32
  HttpRequest,
33
+ SwitchCLIChatRequest,
34
+ SwitchCLIChatResponse,
34
35
  TerminateRequest,
35
36
  TerminateResponse,
36
37
  ToolExecutionRequest,
37
38
  ToolExecutionResponse,
38
39
  ToolResultType,
39
- SwitchCLIChatRequest,
40
- SwitchCLIChatResponse,
41
40
  )
42
41
  from exponent.core.remote_execution.code_execution import (
43
42
  execute_code_streaming,
@@ -87,7 +86,7 @@ class SwitchCLIChat:
87
86
  new_chat_uuid: str
88
87
 
89
88
 
90
- REMOTE_EXECUTION_CLIENT_EXIT_INFO = Union[WSDisconnected, SwitchCLIChat]
89
+ REMOTE_EXECUTION_CLIENT_EXIT_INFO = WSDisconnected | SwitchCLIChat
91
90
 
92
91
 
93
92
  class RemoteExecutionClient:
@@ -302,7 +301,7 @@ class RemoteExecutionClient:
302
301
  async with results_lock:
303
302
  logger.info(f"Putting response {response}")
304
303
  await results.put(response)
305
- except Exception as e: # noqa: BLE001
304
+ except Exception as e:
306
305
  logger.info(f"Error handling request {request}:\n\n{e}")
307
306
  async with results_lock:
308
307
  await results.put(
@@ -550,7 +549,7 @@ class RemoteExecutionClient:
550
549
  )
551
550
  tool_result = truncate_result(raw_result)
552
551
  results.append(tool_result)
553
- except Exception as e: # noqa: BLE001
552
+ except Exception as e:
554
553
  logger.error(f"Error executing tool {tool_input}: {e}")
555
554
  from exponent.core.remote_execution.cli_rpc_types import (
556
555
  ErrorToolResult,
@@ -77,7 +77,7 @@ def lint_file(file_path: str, working_directory: str) -> str:
77
77
 
78
78
  # If the subprocess ran successfully, return a success message
79
79
  return f"Lint results:\n\n{result.stdout}\n\n{result.stderr}"
80
- except Exception as e: # noqa: BLE001
80
+ except Exception as e:
81
81
  # For any other errors, return a generic error message
82
82
  return f"An error occurred while linting: {e!s}"
83
83
 
@@ -103,7 +103,7 @@ async def execute_full_file_rewrite(
103
103
 
104
104
  return result
105
105
 
106
- except Exception as e: # noqa: BLE001
106
+ except Exception as e:
107
107
  return f"An error occurred: {e!s}"
108
108
 
109
109
 
@@ -1,21 +1,13 @@
1
1
  import os
2
- from asyncio import gather, to_thread
2
+ from asyncio import to_thread
3
3
  from typing import Final, cast
4
4
 
5
5
  from anyio import Path as AsyncPath
6
6
  from python_ripgrep import PySortMode, PySortModeKind, files, search
7
- from rapidfuzz import process
8
7
 
9
8
  from exponent.core.remote_execution.cli_rpc_types import ErrorToolResult, GrepToolResult
10
9
  from exponent.core.remote_execution.types import (
11
- FileAttachment,
12
10
  FilePath,
13
- GetFileAttachmentRequest,
14
- GetFileAttachmentResponse,
15
- GetFileAttachmentsRequest,
16
- GetFileAttachmentsResponse,
17
- GetMatchingFilesRequest,
18
- GetMatchingFilesResponse,
19
11
  ListFilesRequest,
20
12
  ListFilesResponse,
21
13
  RemoteFile,
@@ -114,99 +106,6 @@ async def get_file_content(
114
106
  return content, exists
115
107
 
116
108
 
117
- async def get_file_attachments(
118
- get_file_attachments_request: GetFileAttachmentsRequest,
119
- client_working_directory: str,
120
- ) -> GetFileAttachmentsResponse:
121
- """Get the content of the files at the specified paths.
122
-
123
- Args:
124
- get_file_attachments_request: An object containing the file paths.
125
- client_working_directory: The working directory of the client.
126
-
127
- Returns:
128
- A list of FileAttachment objects containing the content of the files.
129
- """
130
- remote_files = get_file_attachments_request.files
131
- attachments = await gather(
132
- *[
133
- get_file_content(
134
- AsyncPath(client_working_directory) / remote_file.file_path
135
- )
136
- for remote_file in remote_files
137
- ]
138
- )
139
-
140
- files = [
141
- FileAttachment(attachment_type="file", file=remote_file, content=content)
142
- for remote_file, (content, _) in zip(remote_files, attachments)
143
- ]
144
-
145
- return GetFileAttachmentsResponse(
146
- correlation_id=get_file_attachments_request.correlation_id,
147
- file_attachments=files,
148
- )
149
-
150
-
151
- async def get_file_attachment(
152
- get_file_attachment_request: GetFileAttachmentRequest, client_working_directory: str
153
- ) -> GetFileAttachmentResponse:
154
- """Get the content of the file at the specified path.
155
-
156
- Args:
157
- get_file_attachment_request: An object containing the file path.
158
- client_working_directory: The working directory of the client.
159
-
160
- Returns:
161
- A FileAttachment object containing the content of the file.
162
- """
163
- file = get_file_attachment_request.file
164
- absolute_path = await file.resolve(client_working_directory)
165
-
166
- content, exists = await get_file_content(absolute_path)
167
-
168
- return GetFileAttachmentResponse(
169
- content=content,
170
- exists=exists,
171
- file=file,
172
- correlation_id=get_file_attachment_request.correlation_id,
173
- )
174
-
175
-
176
- async def get_matching_files(
177
- search_term: GetMatchingFilesRequest,
178
- file_cache: FileCache,
179
- ) -> GetMatchingFilesResponse:
180
- """Get the files that match the search term.
181
-
182
- Args:
183
- search_term: The search term to match against the files.
184
- file_cache: A cache of the files in the working directory.
185
-
186
- Returns:
187
- A list of RemoteFile objects that match the search term.
188
- """
189
- # Use rapidfuzz to find the best matching files
190
- matching_files = await to_thread(
191
- process.extract,
192
- search_term.search_term,
193
- await file_cache.get_files(),
194
- limit=MAX_MATCHING_FILES,
195
- score_cutoff=0,
196
- )
197
-
198
- directory = file_cache.working_directory
199
- files: list[RemoteFile] = [
200
- RemoteFile(file_path=file, working_directory=directory)
201
- for file, _, _ in matching_files
202
- ]
203
-
204
- return GetMatchingFilesResponse(
205
- files=files,
206
- correlation_id=search_term.correlation_id,
207
- )
208
-
209
-
210
109
  async def search_files(
211
110
  path_str: str,
212
111
  file_pattern: str | None,
@@ -167,7 +167,7 @@ async def _get_git_branch(repo: Repository) -> str | None:
167
167
  else:
168
168
  return None
169
169
 
170
- except Exception: # noqa: BLE001
170
+ except Exception:
171
171
  return None
172
172
 
173
173
 
@@ -1,13 +1,12 @@
1
1
  """HTTP fetch implementation for remote execution client."""
2
2
 
3
3
  import logging
4
- from typing import Any
5
4
 
6
5
  import httpx
7
6
 
8
7
  from exponent.core.remote_execution.cli_rpc_types import (
9
- HttpResponse,
10
8
  HttpRequest,
9
+ HttpResponse,
11
10
  )
12
11
 
13
12
  logger = logging.getLogger(__name__)
@@ -79,7 +78,7 @@ async def fetch_http_content(http_request: HttpRequest) -> HttpResponse:
79
78
  )
80
79
 
81
80
  except httpx.RequestError as e:
82
- error_msg = f"Request error for {http_request.url}: {str(e)}"
81
+ error_msg = f"Request error for {http_request.url}: {e!s}"
83
82
  return HttpResponse(
84
83
  status_code=None,
85
84
  content="",
@@ -87,7 +86,7 @@ async def fetch_http_content(http_request: HttpRequest) -> HttpResponse:
87
86
  )
88
87
 
89
88
  except Exception as e:
90
- error_msg = f"Unexpected error fetching {http_request.url}: {str(e)}"
89
+ error_msg = f"Unexpected error fetching {http_request.url}: {e!s}"
91
90
  return HttpResponse(
92
91
  status_code=None,
93
92
  content="",
@@ -140,7 +140,7 @@ class Kernel:
140
140
  except queue.Empty:
141
141
  continue
142
142
 
143
- except Exception as e: # noqa: BLE001 - TODO: Deep audit potential exceptions
143
+ except Exception as e:
144
144
  logger.info(f"Error getting message from kernel: {e}")
145
145
  break
146
146
 
@@ -75,7 +75,7 @@ async def read_stream(
75
75
  break
76
76
 
77
77
 
78
- async def execute_shell_streaming(
78
+ async def execute_shell_streaming( # noqa: PLR0915
79
79
  code: str,
80
80
  working_directory: str,
81
81
  timeout: int,
@@ -120,7 +120,7 @@ async def get_session(
120
120
  )
121
121
  try:
122
122
  yield session
123
- except Exception as exc: # noqa: BLE001
123
+ except Exception as exc:
124
124
  await send_exception_log(exc, session=session, settings=None)
125
125
  raise ExponentError(str(exc))
126
126
  finally:
@@ -2,14 +2,11 @@ import getpass
2
2
  import os
3
3
  import platform
4
4
 
5
- from anyio import Path as AsyncPath
6
-
7
5
  from exponent.core.remote_execution.git import get_git_info
8
6
  from exponent.core.remote_execution.languages import python_execution
9
7
  from exponent.core.remote_execution.types import (
10
8
  SystemInfo,
11
9
  )
12
- from exponent.core.remote_execution.utils import safe_read_file
13
10
 
14
11
 
15
12
  async def get_system_info(working_directory: str) -> SystemInfo:
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import uuid
2
3
  from collections.abc import Callable
3
4
  from pathlib import Path
@@ -36,9 +37,12 @@ from exponent.core.remote_execution.types import (
36
37
  )
37
38
  from exponent.core.remote_execution.utils import (
38
39
  assert_unreachable,
40
+ safe_get_file_metadata,
39
41
  safe_read_file,
40
42
  )
41
43
 
44
+ logger = logging.getLogger(__name__)
45
+
42
46
 
43
47
  async def execute_tool(
44
48
  tool_input: ToolInputType, working_directory: str
@@ -116,9 +120,11 @@ async def execute_read_file( # noqa: PLR0911
116
120
  return ErrorToolResult(
117
121
  error_message="File appears to be binary or has invalid text encoding"
118
122
  )
119
- except Exception as e: # noqa: BLE001
123
+ except Exception as e:
120
124
  return ErrorToolResult(error_message=f"Error reading file: {e!s}")
121
125
 
126
+ metadata = await safe_get_file_metadata(file)
127
+
122
128
  # Handle empty files
123
129
  if not content:
124
130
  return ReadToolResult(
@@ -126,6 +132,7 @@ async def execute_read_file( # noqa: PLR0911
126
132
  num_lines=0,
127
133
  start_line=0,
128
134
  total_lines=0,
135
+ metadata=metadata,
129
136
  )
130
137
 
131
138
  content_lines = content.splitlines(keepends=True)
@@ -138,6 +145,7 @@ async def execute_read_file( # noqa: PLR0911
138
145
  num_lines=0,
139
146
  start_line=offset,
140
147
  total_lines=total_lines,
148
+ metadata=metadata,
141
149
  )
142
150
 
143
151
  # Apply offset and limit
@@ -180,6 +188,7 @@ async def execute_read_file( # noqa: PLR0911
180
188
  num_lines=num_lines,
181
189
  start_line=offset,
182
190
  total_lines=total_lines,
191
+ metadata=metadata,
183
192
  )
184
193
 
185
194
 
@@ -213,6 +222,16 @@ async def execute_edit_file( # noqa: PLR0911
213
222
  if not exists:
214
223
  return ErrorToolResult(error_message="File not found")
215
224
 
225
+ if tool_input.last_known_modified_timestamp is not None:
226
+ metadata = await safe_get_file_metadata(file)
227
+ if (
228
+ metadata is not None
229
+ and metadata.modified_timestamp > tool_input.last_known_modified_timestamp
230
+ ):
231
+ return ErrorToolResult(
232
+ error_message="File has been modified since last read/write"
233
+ )
234
+
216
235
  try:
217
236
  if await file.is_dir():
218
237
  return ErrorToolResult(
@@ -232,7 +251,7 @@ async def execute_edit_file( # noqa: PLR0911
232
251
  return ErrorToolResult(
233
252
  error_message="File appears to be binary or has invalid text encoding"
234
253
  )
235
- except Exception as e: # noqa: BLE001
254
+ except Exception as e:
236
255
  return ErrorToolResult(error_message=f"Error reading file: {e!s}")
237
256
 
238
257
  # Check if search text exists
@@ -265,9 +284,10 @@ async def execute_edit_file( # noqa: PLR0911
265
284
  path = Path(working_directory, tool_input.file_path)
266
285
  await execute_full_file_rewrite(path, new_content, working_directory)
267
286
  return EditToolResult(
268
- message=f"Successfully replaced text in {tool_input.file_path}"
287
+ message=f"Successfully replaced text in {tool_input.file_path}",
288
+ metadata=await safe_get_file_metadata(path),
269
289
  )
270
- except Exception as e: # noqa: BLE001
290
+ except Exception as e:
271
291
  return ErrorToolResult(error_message=f"Error writing file: {e!s}")
272
292
 
273
293
 
@@ -123,6 +123,56 @@ class CompositeTruncation(TruncationStrategy):
123
123
  return result
124
124
 
125
125
 
126
+ class TailTruncation(TruncationStrategy):
127
+ """Truncation strategy that keeps the end of the output (tail) instead of the beginning."""
128
+
129
+ def __init__(
130
+ self,
131
+ field_name: str,
132
+ character_limit: int = DEFAULT_CHARACTER_LIMIT,
133
+ ):
134
+ self.field_name = field_name
135
+ self.character_limit = character_limit
136
+
137
+ def should_truncate(self, result: ToolResult) -> bool:
138
+ if hasattr(result, self.field_name):
139
+ value = getattr(result, self.field_name)
140
+ if isinstance(value, str):
141
+ return len(value) > self.character_limit
142
+ return False
143
+
144
+ def truncate(self, result: ToolResult) -> ToolResult:
145
+ if not hasattr(result, self.field_name):
146
+ return result
147
+
148
+ value = getattr(result, self.field_name)
149
+ if not isinstance(value, str):
150
+ return result
151
+
152
+ if len(value) <= self.character_limit:
153
+ return result
154
+
155
+ # Keep the last character_limit characters
156
+ truncated_value = value[-self.character_limit :]
157
+
158
+ # Try to start at a newline if possible for cleaner output
159
+ newline_pos = truncated_value.find("\n")
160
+ if (
161
+ newline_pos != -1 and newline_pos < 1000
162
+ ): # Only adjust if newline is reasonably close to start
163
+ truncated_value = truncated_value[newline_pos + 1 :]
164
+
165
+ # Add truncation indicator at the beginning
166
+ truncation_msg = f"... (output truncated, showing last {len(truncated_value)} characters) ...\n"
167
+ truncated_value = truncation_msg + truncated_value
168
+
169
+ updates: dict[str, Any] = {self.field_name: truncated_value}
170
+ if hasattr(result, "truncated"):
171
+ updates["truncated"] = True
172
+
173
+ return replace(result, **updates)
174
+
175
+
126
176
  class StringListTruncation(TruncationStrategy):
127
177
  """Truncation for lists of strings that limits both number of items and individual string length."""
128
178
 
@@ -215,7 +265,7 @@ class StringListTruncation(TruncationStrategy):
215
265
  TRUNCATION_REGISTRY: dict[type[ToolResult], TruncationStrategy] = {
216
266
  ReadToolResult: StringFieldTruncation("content"),
217
267
  WriteToolResult: StringFieldTruncation("message"),
218
- BashToolResult: StringFieldTruncation("shell_output"),
268
+ BashToolResult: TailTruncation("shell_output"),
219
269
  GrepToolResult: StringListTruncation("matches"),
220
270
  GlobToolResult: StringListTruncation("filenames", max_item_length=4096),
221
271
  ListToolResult: StringListTruncation("files", max_item_length=4096),
@@ -236,49 +286,3 @@ def truncate_tool_result(result: T) -> T:
236
286
  return cast(T, strategy.truncate(result))
237
287
 
238
288
  return result
239
-
240
-
241
- def register_truncation_strategy(
242
- result_type: type[ToolResult],
243
- strategy: TruncationStrategy,
244
- ) -> None:
245
- TRUNCATION_REGISTRY[result_type] = strategy
246
-
247
-
248
- def configure_truncation_limits(
249
- character_limit: int | None = None,
250
- list_item_limit: int | None = None,
251
- list_preview_items: int | None = None,
252
- string_item_length: int | None = None,
253
- ) -> None:
254
- for strategy in TRUNCATION_REGISTRY.values():
255
- if isinstance(strategy, StringFieldTruncation) and character_limit is not None:
256
- strategy.character_limit = character_limit
257
- elif isinstance(strategy, ListFieldTruncation):
258
- if list_item_limit is not None:
259
- strategy.item_limit = list_item_limit
260
- if list_preview_items is not None:
261
- strategy.preview_items = list_preview_items
262
- elif isinstance(strategy, StringListTruncation):
263
- if list_item_limit is not None:
264
- strategy.max_items = list_item_limit
265
- if list_preview_items is not None:
266
- strategy.preview_items = list_preview_items
267
- if string_item_length is not None:
268
- strategy.max_item_length = string_item_length
269
- elif isinstance(strategy, CompositeTruncation):
270
- for sub_strategy in strategy.strategies:
271
- if isinstance(sub_strategy, StringFieldTruncation) and character_limit:
272
- sub_strategy.character_limit = character_limit
273
- elif isinstance(sub_strategy, ListFieldTruncation):
274
- if list_item_limit is not None:
275
- sub_strategy.item_limit = list_item_limit
276
- if list_preview_items is not None:
277
- sub_strategy.preview_items = list_preview_items
278
- elif isinstance(sub_strategy, StringListTruncation):
279
- if list_item_limit is not None:
280
- sub_strategy.max_items = list_item_limit
281
- if list_preview_items is not None:
282
- sub_strategy.preview_items = list_preview_items
283
- if string_item_length is not None:
284
- sub_strategy.max_item_length = string_item_length