toolplane-python-client 0.1.0__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.
- toolplane/__init__.py +106 -0
- toolplane/common/__init__.py +93 -0
- toolplane/common/base_config.py +129 -0
- toolplane/common/base_connection_manager.py +171 -0
- toolplane/common/base_session_manager.py +321 -0
- toolplane/common/base_tool_manager.py +347 -0
- toolplane/common/constants.py +47 -0
- toolplane/common/utils.py +310 -0
- toolplane/core/__init__.py +67 -0
- toolplane/core/config.py +107 -0
- toolplane/core/connection.py +285 -0
- toolplane/core/errors.py +298 -0
- toolplane/core/machine.py +480 -0
- toolplane/core/request.py +775 -0
- toolplane/core/session.py +332 -0
- toolplane/core/session_context.py +514 -0
- toolplane/core/task.py +130 -0
- toolplane/core/tool.py +329 -0
- toolplane/http_core/__init__.py +37 -0
- toolplane/http_core/http_config.py +97 -0
- toolplane/http_core/http_connection.py +409 -0
- toolplane/http_core/http_machine.py +298 -0
- toolplane/http_core/http_request.py +748 -0
- toolplane/http_core/http_session.py +348 -0
- toolplane/http_core/http_session_context.py +491 -0
- toolplane/http_core/http_task.py +101 -0
- toolplane/http_core/http_tool.py +400 -0
- toolplane/interfaces/__init__.py +27 -0
- toolplane/interfaces/client_interface.py +122 -0
- toolplane/interfaces/connection_interface.py +193 -0
- toolplane/interfaces/event_interface.py +290 -0
- toolplane/interfaces/request_interface.py +439 -0
- toolplane/interfaces/session_interface.py +288 -0
- toolplane/interfaces/tool_interface.py +441 -0
- toolplane/proto/__init__.py +0 -0
- toolplane/proto/service_pb2.py +315 -0
- toolplane/proto/service_pb2_grpc.py +2240 -0
- toolplane/provider_cli.py +268 -0
- toolplane/provider_registry.py +77 -0
- toolplane/provider_runtime.py +302 -0
- toolplane/toolkits/__init__.py +0 -0
- toolplane/toolkits/standalone_tools/__init__.py +0 -0
- toolplane/toolkits/standalone_tools/create_directory.py +94 -0
- toolplane/toolkits/standalone_tools/create_file.py +124 -0
- toolplane/toolkits/standalone_tools/file_search.py +229 -0
- toolplane/toolkits/standalone_tools/grep_search.py +372 -0
- toolplane/toolkits/standalone_tools/launcher.py +146 -0
- toolplane/toolkits/standalone_tools/list_dir.py +395 -0
- toolplane/toolkits/standalone_tools/read_file.py +346 -0
- toolplane/toolkits/standalone_tools/replace_string_in_file.py +407 -0
- toolplane/toolkits/standalone_tools/run_tests.py +66 -0
- toolplane/toolkits/standalone_tools/semantic_search.py +485 -0
- toolplane/toolkits/standalone_tools/standalone_toolkit.py +979 -0
- toolplane/toolkits/standalone_tools/test_failure_analysis.py +618 -0
- toolplane/toolkits/standalone_tools/test_standalone_toolkit.py +517 -0
- toolplane/toolkits/swe/__init__.py +35 -0
- toolplane/toolkits/swe/create_directory.py +15 -0
- toolplane/toolkits/swe/create_file.py +15 -0
- toolplane/toolkits/swe/descriptions.py +273 -0
- toolplane/toolkits/swe/execute_bash.py +93 -0
- toolplane/toolkits/swe/file_editor.py +775 -0
- toolplane/toolkits/swe/file_search.py +16 -0
- toolplane/toolkits/swe/finish.py +50 -0
- toolplane/toolkits/swe/grep_search.py +19 -0
- toolplane/toolkits/swe/list_dir.py +407 -0
- toolplane/toolkits/swe/read_file.py +18 -0
- toolplane/toolkits/swe/replace_string_in_file.py +17 -0
- toolplane/toolkits/swe/search.py +260 -0
- toolplane/toolkits/swe/semantic_search.py +20 -0
- toolplane/toolkits/swe/str_replace_editor.py +647 -0
- toolplane/toolkits/swe/submit.py +29 -0
- toolplane/toolkits/swe/swe_toolkit.py +1296 -0
- toolplane/toolplane_client.py +686 -0
- toolplane/toolplane_http_client.py +681 -0
- toolplane/utils/__init__.py +3 -0
- toolplane/utils/schema.py +146 -0
- toolplane_python_client-0.1.0.dist-info/METADATA +543 -0
- toolplane_python_client-0.1.0.dist-info/RECORD +81 -0
- toolplane_python_client-0.1.0.dist-info/WHEEL +5 -0
- toolplane_python_client-0.1.0.dist-info/entry_points.txt +2 -0
- toolplane_python_client-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Description: Custom editing tool for viewing, creating and editing files
|
|
5
|
+
* State is persistent across command calls and discussions with the user
|
|
6
|
+
* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep
|
|
7
|
+
* The `create` command cannot be used if the specified `path` already exists as a file
|
|
8
|
+
* If a `command` generates a long output, it will be truncated and marked with `<response clipped>`
|
|
9
|
+
* The `undo_edit` command will revert the last edit made to the file at `path`
|
|
10
|
+
|
|
11
|
+
Notes for using the `str_replace` command:
|
|
12
|
+
* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!
|
|
13
|
+
* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique
|
|
14
|
+
* The `new_str` parameter should contain the edited lines that should replace the `old_str`
|
|
15
|
+
|
|
16
|
+
Parameters:
|
|
17
|
+
(1) command (string, required): The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
|
|
18
|
+
Allowed values: [`view`, `create`, `str_replace`, `insert`, `undo_edit`]
|
|
19
|
+
(2) path (string, required): Absolute path to file or directory, e.g. `/testbed/file.py` or `/testbed`.
|
|
20
|
+
(3) file_text (string, optional): Required parameter of `create` command, with the content of the file to be created.
|
|
21
|
+
(4) old_str (string, optional): Required parameter of `str_replace` command containing the string in `path` to replace.
|
|
22
|
+
(5) new_str (string, optional): Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.
|
|
23
|
+
(6) insert_line (integer, optional): Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.
|
|
24
|
+
(7) view_range (array, optional): Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.
|
|
25
|
+
(8) enable_linting (boolean, optional): Optional parameter to enable Python linting checks before saving changes. Default is `false`.
|
|
26
|
+
(9) concise (boolean, optional): Optional parameter to enable a condensed view for Python files. Default is `false`. Super useful for understanding the structure of a Python file without getting bogged down in the details.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import tempfile
|
|
35
|
+
import warnings
|
|
36
|
+
from collections import defaultdict
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Dict, List, Optional, Tuple
|
|
39
|
+
|
|
40
|
+
import chardet
|
|
41
|
+
|
|
42
|
+
# sys.stdout.reconfigure(encoding='utf-8')
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_temp_dir():
|
|
46
|
+
"""Get platform-appropriate temp directory"""
|
|
47
|
+
return Path(tempfile.gettempdir())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_state_file_path(filename="editor_state.json"):
|
|
51
|
+
"""Get the editor state file path.
|
|
52
|
+
|
|
53
|
+
The default is a per-user cache directory, NOT the shared world-writable
|
|
54
|
+
temp dir: the state holds full pre-edit copies of every edited file, so a
|
|
55
|
+
predictable shared path both leaks file contents to other local users and
|
|
56
|
+
is poisonable (undo_edit writes history bytes back to disk). Override via
|
|
57
|
+
TOOLPLANE_EDITOR_STATE_FILE (or the legacy EDITOR_STATE_FILE).
|
|
58
|
+
"""
|
|
59
|
+
override = os.environ.get("TOOLPLANE_EDITOR_STATE_FILE") or os.environ.get(
|
|
60
|
+
"EDITOR_STATE_FILE"
|
|
61
|
+
)
|
|
62
|
+
if override:
|
|
63
|
+
return Path(override)
|
|
64
|
+
return Path.home() / ".cache" / "toolplane" / filename
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
STATE_FILE = str(get_state_file_path())
|
|
68
|
+
SNIPPET_LINES = 4
|
|
69
|
+
|
|
70
|
+
# We ignore certain warnings from tree_sitter (optional).
|
|
71
|
+
warnings.simplefilter("ignore", category=FutureWarning)
|
|
72
|
+
|
|
73
|
+
_LINT_ERROR_TEMPLATE = """Your proposed edit has introduced new syntax error(s).
|
|
74
|
+
Please read this error message carefully and then retry editing the file.
|
|
75
|
+
ERRORS:
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
TRUNCATED_MESSAGE = (
|
|
79
|
+
"<response clipped><NOTE>To save on context only part of this file has been "
|
|
80
|
+
"shown to you. You should retry this tool after you have searched inside the file "
|
|
81
|
+
"with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>"
|
|
82
|
+
)
|
|
83
|
+
MAX_RESPONSE_LEN = 32000 # 4000 #12000 # 16000
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
import io
|
|
87
|
+
import sys
|
|
88
|
+
|
|
89
|
+
# NOTE: this module deliberately does NOT replace sys.stdout at import time.
|
|
90
|
+
# A module-level stdout swap wraps (and on garbage collection, closes) the
|
|
91
|
+
# caller's capture buffer — pytest capture, piped output, and the provider's
|
|
92
|
+
# own result capture all break. Printing stays UTF-8-safe via safe_print.
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def safe_print(x):
|
|
96
|
+
try:
|
|
97
|
+
print(x)
|
|
98
|
+
except UnicodeEncodeError:
|
|
99
|
+
print(x.encode("utf-8", errors="replace").decode("utf-8", errors="replace"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def maybe_truncate(content: str, truncate_after: Optional[int] = MAX_RESPONSE_LEN):
|
|
103
|
+
if not truncate_after or len(content) <= truncate_after:
|
|
104
|
+
return content
|
|
105
|
+
return content[:truncate_after] + TRUNCATED_MESSAGE
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class EditorError(Exception):
|
|
109
|
+
"""Raised for usage or file system errors within the editor tool."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class EditorResult:
|
|
113
|
+
"""
|
|
114
|
+
Simple container for output and optional error messages.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, output: str, error: str = ""):
|
|
118
|
+
self.output = output
|
|
119
|
+
self.error = error
|
|
120
|
+
|
|
121
|
+
def __str__(self):
|
|
122
|
+
if self.error:
|
|
123
|
+
return f"ERROR: {self.error}\n\n{self.output}"
|
|
124
|
+
return self.output
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_history() -> Dict[str, List[str]]:
|
|
128
|
+
"""
|
|
129
|
+
Load the file edit history from STATE_FILE if it exists.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
|
133
|
+
data = json.load(f)
|
|
134
|
+
return {k: v for k, v in data.items()}
|
|
135
|
+
except FileNotFoundError:
|
|
136
|
+
return {}
|
|
137
|
+
except Exception as e:
|
|
138
|
+
safe_print(f"Warning: Could not load editor history from {STATE_FILE}: {e}")
|
|
139
|
+
return {}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def save_history(history: Dict[str, List[str]]):
|
|
143
|
+
"""
|
|
144
|
+
Save the file edit history to STATE_FILE as JSON.
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
Path(STATE_FILE).parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
|
149
|
+
json.dump(history, f)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
safe_print(f"Warning: Could not write editor history to {STATE_FILE}: {e}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class StrReplaceEditor:
|
|
155
|
+
"""
|
|
156
|
+
A file editor that supports the following commands:
|
|
157
|
+
- view
|
|
158
|
+
- create
|
|
159
|
+
- str_replace
|
|
160
|
+
- insert
|
|
161
|
+
- undo_edit
|
|
162
|
+
|
|
163
|
+
The edit history is kept in memory (self.file_history) and also persisted to disk.
|
|
164
|
+
|
|
165
|
+
Additionally, a `--concise` option for `view` on Python files:
|
|
166
|
+
- Uses tree-sitter to skip large function bodies.
|
|
167
|
+
- Preserves original line numbering, printing placeholders for elided lines.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self, file_history: Dict[str, List[str]], enable_linting: bool = False
|
|
172
|
+
):
|
|
173
|
+
self.file_history = defaultdict(list, file_history)
|
|
174
|
+
self.enable_linting = enable_linting
|
|
175
|
+
|
|
176
|
+
def run(
|
|
177
|
+
self,
|
|
178
|
+
command: str,
|
|
179
|
+
path_str: str,
|
|
180
|
+
file_text: str = None,
|
|
181
|
+
view_range: List[int] = None,
|
|
182
|
+
old_str: str = None,
|
|
183
|
+
new_str: str = None,
|
|
184
|
+
insert_line: int = None,
|
|
185
|
+
concise: bool = False,
|
|
186
|
+
python_only: bool = True,
|
|
187
|
+
) -> EditorResult:
|
|
188
|
+
path = Path(path_str)
|
|
189
|
+
self.validate_path(command, path)
|
|
190
|
+
|
|
191
|
+
if command == "view":
|
|
192
|
+
return self.view(path, view_range, concise=concise, python_only=python_only)
|
|
193
|
+
elif command == "create":
|
|
194
|
+
return self.create(path, file_text)
|
|
195
|
+
elif command == "str_replace":
|
|
196
|
+
return self.str_replace(path, old_str, new_str)
|
|
197
|
+
elif command == "insert":
|
|
198
|
+
return self.insert(path, insert_line, new_str)
|
|
199
|
+
elif command == "undo_edit":
|
|
200
|
+
return self.undo_edit(path)
|
|
201
|
+
else:
|
|
202
|
+
raise EditorError(
|
|
203
|
+
f"Unrecognized command '{command}'. "
|
|
204
|
+
"Allowed commands: view, create, str_replace, insert, undo_edit."
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def validate_path(self, command: str, path: Path):
|
|
208
|
+
# Optional workspace jail: when TOOLPLANE_WORKSPACE_ROOT is set, every
|
|
209
|
+
# editor operation must resolve inside it (symlinks resolved first).
|
|
210
|
+
root = os.environ.get("TOOLPLANE_WORKSPACE_ROOT", "").strip()
|
|
211
|
+
if root:
|
|
212
|
+
try:
|
|
213
|
+
Path(path).resolve().relative_to(Path(root).resolve())
|
|
214
|
+
except ValueError:
|
|
215
|
+
raise EditorError(
|
|
216
|
+
f"Path '{path}' is outside TOOLPLANE_WORKSPACE_ROOT ({root})."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if command == "create":
|
|
220
|
+
if path.exists():
|
|
221
|
+
raise EditorError(
|
|
222
|
+
f"File already exists at: {path}. Cannot overwrite with 'create'."
|
|
223
|
+
)
|
|
224
|
+
else:
|
|
225
|
+
if not path.exists():
|
|
226
|
+
raise EditorError(f"The path '{path}' does not exist.")
|
|
227
|
+
|
|
228
|
+
if path.is_dir() and command != "view":
|
|
229
|
+
raise EditorError(
|
|
230
|
+
f"The path '{path}' is a directory. Only 'view' can be used on directories."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def read_path(path: Path) -> str:
|
|
235
|
+
encoding = chardet.detect(path.read_bytes())["encoding"]
|
|
236
|
+
if encoding is None:
|
|
237
|
+
encoding = "utf-8"
|
|
238
|
+
return path.read_text(encoding=encoding)
|
|
239
|
+
|
|
240
|
+
def view(
|
|
241
|
+
self,
|
|
242
|
+
path: Path,
|
|
243
|
+
view_range: Optional[List[int]] = None,
|
|
244
|
+
concise: bool = False,
|
|
245
|
+
python_only: bool = True,
|
|
246
|
+
) -> EditorResult:
|
|
247
|
+
"""
|
|
248
|
+
If path is a directory, list contents (2 levels deep, excluding hidden).
|
|
249
|
+
If path is a file, optionally use the 'concise' approach for Python.
|
|
250
|
+
Then apply [start_line, end_line] slicing if provided.
|
|
251
|
+
"""
|
|
252
|
+
if path.is_dir():
|
|
253
|
+
if not python_only:
|
|
254
|
+
cmd = ["find", str(path), "-maxdepth", "2", "-not", "-path", "*/.*"]
|
|
255
|
+
else:
|
|
256
|
+
# Use `-type d -o -name '*.py'` to only list directories or *.py files
|
|
257
|
+
cmd = [
|
|
258
|
+
"find",
|
|
259
|
+
str(path),
|
|
260
|
+
"-maxdepth",
|
|
261
|
+
"2",
|
|
262
|
+
"-not",
|
|
263
|
+
"-path",
|
|
264
|
+
"*/.*",
|
|
265
|
+
"(",
|
|
266
|
+
"-type",
|
|
267
|
+
"d",
|
|
268
|
+
"-o",
|
|
269
|
+
"-name",
|
|
270
|
+
"*.py",
|
|
271
|
+
")",
|
|
272
|
+
]
|
|
273
|
+
try:
|
|
274
|
+
# Try using the newer parameters first (Python 3.7+)
|
|
275
|
+
try:
|
|
276
|
+
proc = subprocess.run(
|
|
277
|
+
cmd, capture_output=True, text=True, check=False
|
|
278
|
+
)
|
|
279
|
+
except TypeError:
|
|
280
|
+
# Fallback for Python 3.5 and 3.6 where capture_output and text are not supported
|
|
281
|
+
proc = subprocess.run(
|
|
282
|
+
cmd,
|
|
283
|
+
stdout=subprocess.PIPE,
|
|
284
|
+
stderr=subprocess.PIPE,
|
|
285
|
+
universal_newlines=True,
|
|
286
|
+
check=False,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
stderr = proc.stderr.strip()
|
|
290
|
+
stdout = proc.stdout
|
|
291
|
+
if stderr:
|
|
292
|
+
return EditorResult(output="", error=stderr)
|
|
293
|
+
|
|
294
|
+
msg = (
|
|
295
|
+
f"Here's the files and directories up to 2 levels deep in {path}, "
|
|
296
|
+
"excluding hidden:\n" + stdout
|
|
297
|
+
)
|
|
298
|
+
msg = maybe_truncate(msg)
|
|
299
|
+
return EditorResult(output=msg)
|
|
300
|
+
except Exception as e:
|
|
301
|
+
return EditorResult(
|
|
302
|
+
output="",
|
|
303
|
+
error=f"Ran into {e} while trying to list directory contents of {path}.",
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# ====================
|
|
307
|
+
# NEW RESTRICTION: only .py files are allowed for viewing
|
|
308
|
+
if path.suffix != ".py" and python_only:
|
|
309
|
+
error_msg = (
|
|
310
|
+
f"ERROR: Viewing non-Python files is disallowed for saving context. "
|
|
311
|
+
f"File '{path.name}' is not a .py file."
|
|
312
|
+
)
|
|
313
|
+
return EditorResult(output="", error=error_msg)
|
|
314
|
+
# ====================
|
|
315
|
+
|
|
316
|
+
# -----------------------------------------------
|
|
317
|
+
# NEW LOGIC for deciding whether to use 'concise' mode
|
|
318
|
+
# -----------------------------------------------
|
|
319
|
+
# If no view_range is given, user did NOT explicitly request 'concise',
|
|
320
|
+
# and we have a Python file with more than 50 lines => default to concise
|
|
321
|
+
if path.suffix == ".py" and not view_range and not concise:
|
|
322
|
+
file_text_tmp = self.read_path(path)
|
|
323
|
+
if len(file_text_tmp.splitlines()) > 110:
|
|
324
|
+
concise = True
|
|
325
|
+
|
|
326
|
+
# For a file
|
|
327
|
+
if path.suffix == ".py" and concise:
|
|
328
|
+
# Use the tree_sitter approach
|
|
329
|
+
lines_with_original_numbers = self._get_elided_lines(path)
|
|
330
|
+
else:
|
|
331
|
+
# Normal reading
|
|
332
|
+
file_text = self.read_path(path)
|
|
333
|
+
lines_with_original_numbers = [
|
|
334
|
+
(i, line) for i, line in enumerate(file_text.splitlines())
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
# Optionally slice by [start_line, end_line]
|
|
338
|
+
total_lines = len(lines_with_original_numbers)
|
|
339
|
+
if view_range and len(view_range) == 2:
|
|
340
|
+
start, end = view_range
|
|
341
|
+
if not (1 <= start <= total_lines):
|
|
342
|
+
return EditorResult(
|
|
343
|
+
output="",
|
|
344
|
+
error=(
|
|
345
|
+
f"Invalid view_range {view_range}: start line must be in [1, {total_lines}]"
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
if end != -1 and (end < start or end > total_lines):
|
|
349
|
+
return EditorResult(
|
|
350
|
+
output="",
|
|
351
|
+
error=(
|
|
352
|
+
f"Invalid view_range {view_range}: end must be >= start "
|
|
353
|
+
f"and <= {total_lines}, or -1 to view until end."
|
|
354
|
+
),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Filter lines by 1-based index
|
|
358
|
+
sliced_lines = []
|
|
359
|
+
for i, text in lines_with_original_numbers:
|
|
360
|
+
one_based = i + 1
|
|
361
|
+
if one_based < start:
|
|
362
|
+
continue
|
|
363
|
+
if end != -1 and one_based > end:
|
|
364
|
+
continue
|
|
365
|
+
sliced_lines.append((i, text))
|
|
366
|
+
else:
|
|
367
|
+
# No slicing
|
|
368
|
+
sliced_lines = lines_with_original_numbers
|
|
369
|
+
|
|
370
|
+
# Now produce a cat-like output (line numbering = i+1)
|
|
371
|
+
if concise:
|
|
372
|
+
final_output = f"Here is a condensed view for file: {path}; [Note: Useful for understanding file structure in a concise manner. Please use specific view_range without concise cmd if you want to explore further into the relevant parts.]\n"
|
|
373
|
+
else:
|
|
374
|
+
final_output = (
|
|
375
|
+
f"Here's the result of running `cat -n` on the file: {path}:\n"
|
|
376
|
+
)
|
|
377
|
+
# Then maybe truncate
|
|
378
|
+
output_str_list = []
|
|
379
|
+
for i, text in sliced_lines:
|
|
380
|
+
# i is 0-based
|
|
381
|
+
output_str_list.append(f"{i+1:6d} {text}")
|
|
382
|
+
|
|
383
|
+
final_output += "\n".join(output_str_list)
|
|
384
|
+
final_output = maybe_truncate(final_output)
|
|
385
|
+
return EditorResult(output=final_output)
|
|
386
|
+
|
|
387
|
+
def _get_elided_lines(self, path: Path) -> List[Tuple[int, str]]:
|
|
388
|
+
"""
|
|
389
|
+
Parse the Python file with the built-in 'ast' module to skip
|
|
390
|
+
large function bodies (≥ 5 lines).
|
|
391
|
+
Return a list of (zero_based_line_idx, text).
|
|
392
|
+
"""
|
|
393
|
+
import ast
|
|
394
|
+
|
|
395
|
+
file_text = self.read_path(path)
|
|
396
|
+
try:
|
|
397
|
+
tree = ast.parse(file_text, filename=str(path))
|
|
398
|
+
except SyntaxError as e:
|
|
399
|
+
# Raise EditorError to make sure we handle it gracefully upstream
|
|
400
|
+
raise EditorError(f"Syntax error for file {path}: {e}")
|
|
401
|
+
|
|
402
|
+
def max_lineno_in_subtree(n: ast.AST) -> int:
|
|
403
|
+
m = getattr(n, "lineno", 0)
|
|
404
|
+
for child in ast.iter_child_nodes(n):
|
|
405
|
+
m = max(m, max_lineno_in_subtree(child))
|
|
406
|
+
return m
|
|
407
|
+
|
|
408
|
+
# We'll gather the line ranges for all large function bodies
|
|
409
|
+
elide_line_ranges = []
|
|
410
|
+
for node in ast.walk(tree):
|
|
411
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.body:
|
|
412
|
+
# Attempt to use end_lineno (Python 3.8+), else fallback
|
|
413
|
+
last_stmt = node.body[-1]
|
|
414
|
+
if hasattr(last_stmt, "end_lineno") and last_stmt.end_lineno:
|
|
415
|
+
# 0-based start of the body (first statement in the function)
|
|
416
|
+
body_start = node.body[0].lineno - 1
|
|
417
|
+
|
|
418
|
+
body_end = last_stmt.end_lineno - 1
|
|
419
|
+
else:
|
|
420
|
+
body_start = node.body[0].lineno - 1
|
|
421
|
+
|
|
422
|
+
docstring = ast.get_docstring(node)
|
|
423
|
+
|
|
424
|
+
if docstring:
|
|
425
|
+
num_docstring_lines = docstring.count("\n") + 1
|
|
426
|
+
body_start -= num_docstring_lines
|
|
427
|
+
|
|
428
|
+
# Fallback: traverse the subtree to find max lineno
|
|
429
|
+
body_end = max_lineno_in_subtree(last_stmt) - 1
|
|
430
|
+
|
|
431
|
+
# Skip the body if spans ≥ 5 lines
|
|
432
|
+
if (body_end - body_start) >= 3:
|
|
433
|
+
elide_line_ranges.append((body_start, body_end))
|
|
434
|
+
|
|
435
|
+
if isinstance(node, ast.ClassDef) and node.body:
|
|
436
|
+
# elide large docstrings for classes
|
|
437
|
+
has_docstring1 = (
|
|
438
|
+
isinstance(node.body[0], ast.Expr)
|
|
439
|
+
and isinstance(node.body[0].value, ast.Constant)
|
|
440
|
+
and isinstance(node.body[0].value.value, str)
|
|
441
|
+
)
|
|
442
|
+
has_docstring2 = isinstance(node.body[0], ast.Expr) and isinstance(
|
|
443
|
+
node.body[0].value, ast.Str
|
|
444
|
+
)
|
|
445
|
+
has_docstring = has_docstring1 or has_docstring2
|
|
446
|
+
if has_docstring:
|
|
447
|
+
if hasattr(node.body[0], "end_lineno") and node.body[0].end_lineno:
|
|
448
|
+
docstring_start = node.body[0].lineno - 1
|
|
449
|
+
docstring_end = node.body[0].end_lineno - 1
|
|
450
|
+
else:
|
|
451
|
+
docstring_start = node.lineno
|
|
452
|
+
docstring_end = max_lineno_in_subtree(node.body[0]) - 1
|
|
453
|
+
|
|
454
|
+
if (docstring_end - docstring_start) >= 4:
|
|
455
|
+
elide_line_ranges.append(
|
|
456
|
+
(docstring_start + 1, docstring_end - 1)
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
# Build a set of lines to skip
|
|
460
|
+
elide_lines = {
|
|
461
|
+
line for (start, end) in elide_line_ranges for line in range(start, end + 1)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
# Add an "elision notice" at the beginning of each range
|
|
465
|
+
elide_messages = [
|
|
466
|
+
(start, f"... eliding lines {start+1}-{end+1} ...")
|
|
467
|
+
for (start, end) in elide_line_ranges
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
# Lines we do keep
|
|
471
|
+
all_lines = file_text.splitlines()
|
|
472
|
+
keep_lines = [
|
|
473
|
+
(i, line) for i, line in enumerate(all_lines) if i not in elide_lines
|
|
474
|
+
]
|
|
475
|
+
|
|
476
|
+
# Combine and sort by line index
|
|
477
|
+
combined = elide_messages + keep_lines
|
|
478
|
+
combined.sort(key=lambda x: x[0])
|
|
479
|
+
|
|
480
|
+
return combined
|
|
481
|
+
|
|
482
|
+
def create(self, path: Path, file_text: str) -> EditorResult:
|
|
483
|
+
if file_text is None:
|
|
484
|
+
raise EditorError("Cannot create file without 'file_text' parameter.")
|
|
485
|
+
|
|
486
|
+
if self.enable_linting and path.suffix == ".py":
|
|
487
|
+
lint_error = self._lint_check(file_text, str(path))
|
|
488
|
+
if lint_error:
|
|
489
|
+
return EditorResult(output="", error=_LINT_ERROR_TEMPLATE + lint_error)
|
|
490
|
+
|
|
491
|
+
try:
|
|
492
|
+
path.write_text(file_text, encoding="utf-8")
|
|
493
|
+
self.file_history[str(path)].append("")
|
|
494
|
+
except Exception as e:
|
|
495
|
+
raise EditorError(f"Error creating file at {path}: {e}")
|
|
496
|
+
|
|
497
|
+
success_msg = f"File created at {path}. "
|
|
498
|
+
success_msg += self._make_output(file_text, str(path))
|
|
499
|
+
success_msg += "Review the file and make sure that it is as expected. Edit the file if necessary."
|
|
500
|
+
|
|
501
|
+
return EditorResult(output=f"{success_msg}")
|
|
502
|
+
|
|
503
|
+
def str_replace(self, path: Path, old_str: str, new_str: str) -> EditorResult:
|
|
504
|
+
if old_str is None:
|
|
505
|
+
raise EditorError("Missing required parameter 'old_str' for 'str_replace'.")
|
|
506
|
+
|
|
507
|
+
file_content = self.read_file(path).expandtabs()
|
|
508
|
+
old_str = old_str.expandtabs()
|
|
509
|
+
new_str = new_str.expandtabs() if new_str else ""
|
|
510
|
+
occurrences = file_content.count(old_str)
|
|
511
|
+
if occurrences == 0:
|
|
512
|
+
raise EditorError(
|
|
513
|
+
f"No occurrences of '{old_str}' found in {path} for replacement."
|
|
514
|
+
)
|
|
515
|
+
if occurrences > 1:
|
|
516
|
+
raise EditorError(
|
|
517
|
+
f"Multiple occurrences of '{old_str}' found in {path}. "
|
|
518
|
+
"Please ensure it is unique before using str_replace."
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
old_text = file_content
|
|
522
|
+
updated_text = file_content.replace(old_str, new_str if new_str else "")
|
|
523
|
+
|
|
524
|
+
if self.enable_linting and path.suffix == ".py":
|
|
525
|
+
lint_error = self._lint_check(updated_text, str(path))
|
|
526
|
+
if lint_error:
|
|
527
|
+
return EditorResult(output="", error=_LINT_ERROR_TEMPLATE + lint_error)
|
|
528
|
+
|
|
529
|
+
self.file_history[str(path)].append(old_text)
|
|
530
|
+
self.write_file(path, updated_text)
|
|
531
|
+
|
|
532
|
+
# Original snippet logic
|
|
533
|
+
replacement_line = file_content.split(old_str)[0].count("\n")
|
|
534
|
+
start_line = max(0, replacement_line - SNIPPET_LINES)
|
|
535
|
+
end_line = replacement_line + SNIPPET_LINES + (new_str or "").count("\n")
|
|
536
|
+
snippet = "\n".join(updated_text.split("\n")[start_line : end_line + 1])
|
|
537
|
+
|
|
538
|
+
success_msg = f"The file {path} has been edited. "
|
|
539
|
+
success_msg += self._make_output(
|
|
540
|
+
snippet, f"a snippet of {path}", start_line + 1
|
|
541
|
+
)
|
|
542
|
+
success_msg += "Review the changes and make sure they are as expected. Edit the file again if necessary."
|
|
543
|
+
|
|
544
|
+
return EditorResult(output=success_msg)
|
|
545
|
+
|
|
546
|
+
def insert(self, path: Path, insert_line: int, new_str: str) -> EditorResult:
|
|
547
|
+
if new_str is None:
|
|
548
|
+
raise EditorError("Missing required parameter 'new_str' for 'insert'.")
|
|
549
|
+
|
|
550
|
+
old_text = self.read_file(path).expandtabs()
|
|
551
|
+
new_str = new_str.expandtabs()
|
|
552
|
+
file_text_lines = old_text.split("\n")
|
|
553
|
+
|
|
554
|
+
if insert_line < 0 or insert_line > len(file_text_lines):
|
|
555
|
+
raise EditorError(
|
|
556
|
+
f"Invalid insert_line {insert_line}. Must be in [0, {len(file_text_lines)}]."
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
new_str_lines = new_str.split("\n")
|
|
560
|
+
new_file_text_lines = (
|
|
561
|
+
file_text_lines[:insert_line]
|
|
562
|
+
+ new_str_lines
|
|
563
|
+
+ file_text_lines[insert_line:]
|
|
564
|
+
)
|
|
565
|
+
updated_text = "\n".join(new_file_text_lines)
|
|
566
|
+
|
|
567
|
+
if self.enable_linting and path.suffix == ".py":
|
|
568
|
+
lint_error = self._lint_check(updated_text, str(path))
|
|
569
|
+
if lint_error:
|
|
570
|
+
return EditorResult(output="", error=_LINT_ERROR_TEMPLATE + lint_error)
|
|
571
|
+
|
|
572
|
+
self.file_history[str(path)].append(old_text)
|
|
573
|
+
self.write_file(path, updated_text)
|
|
574
|
+
|
|
575
|
+
# Original snippet logic
|
|
576
|
+
snippet_lines = (
|
|
577
|
+
file_text_lines[max(0, insert_line - SNIPPET_LINES) : insert_line]
|
|
578
|
+
+ new_str_lines
|
|
579
|
+
+ file_text_lines[insert_line : insert_line + SNIPPET_LINES]
|
|
580
|
+
)
|
|
581
|
+
snippet = "\n".join(snippet_lines)
|
|
582
|
+
|
|
583
|
+
success_msg = f"The file {path} has been edited. "
|
|
584
|
+
success_msg += self._make_output(
|
|
585
|
+
snippet,
|
|
586
|
+
"a snippet of the edited file",
|
|
587
|
+
max(1, insert_line - SNIPPET_LINES + 1),
|
|
588
|
+
)
|
|
589
|
+
success_msg += (
|
|
590
|
+
"Review the changes and make sure they are as expected "
|
|
591
|
+
"(correct indentation, no duplicate lines, etc). Edit the file again if necessary."
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
return EditorResult(output=success_msg)
|
|
595
|
+
|
|
596
|
+
def undo_edit(self, path: Path) -> EditorResult:
|
|
597
|
+
path_str = str(path)
|
|
598
|
+
if not self.file_history[path_str]:
|
|
599
|
+
raise EditorError(f"No previous edits found for {path} to undo.")
|
|
600
|
+
|
|
601
|
+
old_text = self.file_history[path_str].pop()
|
|
602
|
+
self.write_file(path, old_text)
|
|
603
|
+
|
|
604
|
+
return EditorResult(
|
|
605
|
+
output=(
|
|
606
|
+
f"Last edit to {path} undone successfully. "
|
|
607
|
+
+ self._make_output(old_text, str(path))
|
|
608
|
+
)
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
def read_file(self, path: Path) -> str:
|
|
612
|
+
try:
|
|
613
|
+
return self.read_path(path)
|
|
614
|
+
except Exception as e:
|
|
615
|
+
raise EditorError(f"Failed to read file {path}: {e}")
|
|
616
|
+
|
|
617
|
+
def write_file(self, path: Path, content: str):
|
|
618
|
+
try:
|
|
619
|
+
path.write_text(content, encoding="utf-8")
|
|
620
|
+
except Exception as e:
|
|
621
|
+
raise EditorError(f"Failed to write file {path}: {e}")
|
|
622
|
+
|
|
623
|
+
def _make_output(
|
|
624
|
+
self,
|
|
625
|
+
file_content: str,
|
|
626
|
+
file_descriptor: str,
|
|
627
|
+
init_line: int = 1,
|
|
628
|
+
expand_tabs: bool = True,
|
|
629
|
+
) -> str:
|
|
630
|
+
"""
|
|
631
|
+
Mimics cat -n style numbering, plus maybe_truncate to avoid huge outputs.
|
|
632
|
+
"""
|
|
633
|
+
file_content = maybe_truncate(file_content)
|
|
634
|
+
if expand_tabs:
|
|
635
|
+
file_content = file_content.expandtabs()
|
|
636
|
+
|
|
637
|
+
lines = file_content.split("\n")
|
|
638
|
+
numbered = "\n".join(
|
|
639
|
+
f"{i + init_line:6}\t{line}" for i, line in enumerate(lines)
|
|
640
|
+
)
|
|
641
|
+
return (
|
|
642
|
+
f"Here's the result of running `cat -n` on {file_descriptor}:\n"
|
|
643
|
+
+ numbered
|
|
644
|
+
+ "\n"
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
def _lint_check(self, new_content: str, file_path: str) -> str:
|
|
648
|
+
import ast
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
ast.parse(new_content, filename=file_path)
|
|
652
|
+
return ""
|
|
653
|
+
except SyntaxError as e:
|
|
654
|
+
return str(e)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def main():
|
|
658
|
+
def parse_view_range(range_str: str):
|
|
659
|
+
# Remove surrounding brackets if present
|
|
660
|
+
range_str = range_str.strip().strip("[]()")
|
|
661
|
+
|
|
662
|
+
# Split on commas or whitespace
|
|
663
|
+
parts = range_str.replace(",", " ").split()
|
|
664
|
+
|
|
665
|
+
if len(parts) != 2:
|
|
666
|
+
raise argparse.ArgumentTypeError(f"Expected two numbers, got: {range_str}")
|
|
667
|
+
try:
|
|
668
|
+
start_line = int(parts[0])
|
|
669
|
+
end_line = int(parts[1])
|
|
670
|
+
except ValueError:
|
|
671
|
+
raise argparse.ArgumentTypeError(f"Could not convert {parts} to integers.")
|
|
672
|
+
return [start_line, end_line]
|
|
673
|
+
|
|
674
|
+
parser = argparse.ArgumentParser(
|
|
675
|
+
description=(
|
|
676
|
+
"A disk-backed file editing tool (view, create, str_replace, insert, undo_edit) "
|
|
677
|
+
"with optional linting. Also supports a 'concise' view for Python files."
|
|
678
|
+
)
|
|
679
|
+
)
|
|
680
|
+
parser.add_argument(
|
|
681
|
+
"command",
|
|
682
|
+
type=str,
|
|
683
|
+
help="One of: view, create, str_replace, insert, undo_edit",
|
|
684
|
+
)
|
|
685
|
+
parser.add_argument(
|
|
686
|
+
"--path",
|
|
687
|
+
type=str,
|
|
688
|
+
help="Path to the target file or directory (absolute path recommended)",
|
|
689
|
+
)
|
|
690
|
+
parser.add_argument(
|
|
691
|
+
"--file_text",
|
|
692
|
+
type=str,
|
|
693
|
+
default=None,
|
|
694
|
+
help="File content (for 'create')",
|
|
695
|
+
)
|
|
696
|
+
parser.add_argument(
|
|
697
|
+
"--view_range",
|
|
698
|
+
type=parse_view_range,
|
|
699
|
+
default=None,
|
|
700
|
+
help="Line range to view [start_line, end_line], use -1 for end.",
|
|
701
|
+
)
|
|
702
|
+
parser.add_argument(
|
|
703
|
+
"--old_str",
|
|
704
|
+
type=str,
|
|
705
|
+
default=None,
|
|
706
|
+
help="Old string (for 'str_replace')",
|
|
707
|
+
)
|
|
708
|
+
parser.add_argument(
|
|
709
|
+
"--new_str",
|
|
710
|
+
type=str,
|
|
711
|
+
default=None,
|
|
712
|
+
help="New string (for 'str_replace' or 'insert')",
|
|
713
|
+
)
|
|
714
|
+
parser.add_argument(
|
|
715
|
+
"--insert_line",
|
|
716
|
+
type=int,
|
|
717
|
+
default=None,
|
|
718
|
+
help="Line number to insert text at (for 'insert')",
|
|
719
|
+
)
|
|
720
|
+
parser.add_argument(
|
|
721
|
+
"--enable_linting",
|
|
722
|
+
type=bool,
|
|
723
|
+
default=False,
|
|
724
|
+
help="Enable linting checks for Python files before saving changes.",
|
|
725
|
+
)
|
|
726
|
+
parser.add_argument(
|
|
727
|
+
"--concise",
|
|
728
|
+
type=str,
|
|
729
|
+
default="False",
|
|
730
|
+
help="If True, attempts to produce a condensed view for Python files, eliding large function bodies.",
|
|
731
|
+
)
|
|
732
|
+
parser.add_argument(
|
|
733
|
+
"--python_only",
|
|
734
|
+
type=bool,
|
|
735
|
+
default=True,
|
|
736
|
+
help="If True, attempts to limit view (for both dir and file level) to Python files only.",
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
args = parser.parse_args()
|
|
740
|
+
|
|
741
|
+
file_history = load_history()
|
|
742
|
+
editor = StrReplaceEditor(file_history, enable_linting=args.enable_linting)
|
|
743
|
+
if args.concise.lower() == "true":
|
|
744
|
+
args.concise = True
|
|
745
|
+
else:
|
|
746
|
+
args.concise = False
|
|
747
|
+
|
|
748
|
+
try:
|
|
749
|
+
result = editor.run(
|
|
750
|
+
command=args.command,
|
|
751
|
+
path_str=args.path,
|
|
752
|
+
file_text=args.file_text,
|
|
753
|
+
view_range=args.view_range,
|
|
754
|
+
old_str=args.old_str,
|
|
755
|
+
new_str=args.new_str,
|
|
756
|
+
insert_line=args.insert_line,
|
|
757
|
+
concise=args.concise,
|
|
758
|
+
)
|
|
759
|
+
safe_print(result.output)
|
|
760
|
+
if result.error:
|
|
761
|
+
safe_print(f"ERROR: {result.error}")
|
|
762
|
+
|
|
763
|
+
except EditorError as e:
|
|
764
|
+
safe_print(f"ERROR: {e}")
|
|
765
|
+
except Exception as e:
|
|
766
|
+
safe_print(f"ERROR: Unhandled exception: {e}")
|
|
767
|
+
import traceback
|
|
768
|
+
|
|
769
|
+
traceback.print_exc()
|
|
770
|
+
|
|
771
|
+
save_history(dict(editor.file_history))
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
if __name__ == "__main__":
|
|
775
|
+
main()
|