multi-replace-file-content-tool 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.
File without changes
@@ -0,0 +1,208 @@
1
+ """
2
+ multi_replace_file_content Tool
3
+ ================================
4
+ Edit multiple non-adjacent blocks in a single file in one atomic call.
5
+
6
+ Each chunk specifies its own search range and target text to replace.
7
+ Chunks are applied in reverse line-order so earlier line numbers remain
8
+ valid after each substitution.
9
+
10
+ Parameters:
11
+ path - Absolute path to the file to edit (required).
12
+ chunks - List of replacement chunk objects (required, min 1).
13
+
14
+ Each chunk object has:
15
+ start_line - Start of search range, 1-indexed (required).
16
+ end_line - End of search range, 1-indexed inclusive (required).
17
+ target_content - Exact text to find within the range (required).
18
+ replacement_content - New text to substitute in (required).
19
+ allow_multiple - Replace all occurrences in range (default false).
20
+ """
21
+
22
+ import logging
23
+ import os
24
+ from typing import Any, Dict, List
25
+ import aiofiles
26
+ from openchadpy.tool_base import ToolBase
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class Tool(ToolBase):
32
+ name = "multi_replace_file_content"
33
+ description = (
34
+ "Edit multiple non-adjacent blocks in a single file with one call. "
35
+ "Each chunk describes a search range and the exact text to replace. "
36
+ "Chunks are applied in reverse line order to preserve correct offsets. "
37
+ "Use replace_file_content for a single contiguous edit."
38
+ )
39
+ input_schema = {
40
+ "type": "object",
41
+ "properties": {
42
+ "path": {
43
+ "type": "string",
44
+ "description": "Absolute path to the file to edit.",
45
+ },
46
+ "chunks": {
47
+ "type": "array",
48
+ "description": "Ordered list of replacement chunks to apply.",
49
+ "minItems": 1,
50
+ "items": {
51
+ "type": "object",
52
+ "properties": {
53
+ "start_line": {
54
+ "type": "integer",
55
+ "description": "Start of search range, 1-indexed inclusive.",
56
+ },
57
+ "end_line": {
58
+ "type": "integer",
59
+ "description": "End of search range, 1-indexed inclusive.",
60
+ },
61
+ "target_content": {
62
+ "type": "string",
63
+ "description": (
64
+ "Exact text to find within the range. "
65
+ "Must match character-for-character including whitespace."
66
+ ),
67
+ },
68
+ "replacement_content": {
69
+ "type": "string",
70
+ "description": "New text to substitute in place of target_content.",
71
+ },
72
+ "allow_multiple": {
73
+ "type": "boolean",
74
+ "description": (
75
+ "When true, all occurrences within the range are replaced. "
76
+ "When false (default), errors if multiple matches are found."
77
+ ),
78
+ },
79
+ },
80
+ "required": [
81
+ "start_line",
82
+ "end_line",
83
+ "target_content",
84
+ "replacement_content",
85
+ ],
86
+ },
87
+ },
88
+ },
89
+ "required": ["path", "chunks"],
90
+ }
91
+ allowed_callers = ["direct", "code_execution"]
92
+
93
+ async def execute(self, **kwargs) -> Dict[str, Any]: # noqa: C901
94
+ path: str = kwargs.get("path", "").strip()
95
+ chunks: List[Dict[str, Any]] = kwargs.get("chunks", [])
96
+
97
+ if not path:
98
+ return {"error": "path is required and must not be empty."}
99
+ if not chunks:
100
+ return {"error": "chunks must be a non-empty list."}
101
+ if not os.path.isfile(path):
102
+ return {"error": f"File not found: {path!r}"}
103
+
104
+ # --- Validate each chunk before touching the file ---
105
+ errors: List[str] = []
106
+ for i, chunk in enumerate(chunks):
107
+ sl = chunk.get("start_line")
108
+ el = chunk.get("end_line")
109
+ tc = chunk.get("target_content")
110
+ if sl is None or el is None:
111
+ errors.append(f"Chunk {i}: start_line and end_line are required.")
112
+ elif int(sl) < 1 or int(el) < int(sl):
113
+ errors.append(
114
+ f"Chunk {i}: invalid range start_line={sl}, end_line={el}."
115
+ )
116
+ if not tc:
117
+ errors.append(f"Chunk {i}: target_content must not be empty.")
118
+ if "replacement_content" not in chunk:
119
+ errors.append(f"Chunk {i}: replacement_content is required.")
120
+ if errors:
121
+ return {"error": "Validation errors in chunks:\n" + "\n".join(errors)}
122
+
123
+ # --- Read file ---
124
+ try:
125
+ async with aiofiles.open(path, "r", encoding="utf-8", errors="replace") as f:
126
+ content = await f.read()
127
+ all_lines: List[str] = content.splitlines(keepends=True)
128
+ except Exception as e:
129
+ return {"error": f"Failed to read file: {e}"}
130
+
131
+ total_lines = len(all_lines)
132
+
133
+ # --- Apply chunks in reverse start_line order to preserve offsets ---
134
+ sorted_chunks = sorted(chunks, key=lambda c: int(c["start_line"]), reverse=True)
135
+
136
+ results: List[Dict[str, Any]] = []
137
+ for i, chunk in enumerate(sorted_chunks):
138
+ sl = int(chunk["start_line"])
139
+ el = min(int(chunk["end_line"]), total_lines)
140
+ target = chunk["target_content"]
141
+ replacement = chunk["replacement_content"]
142
+ allow_multiple = bool(chunk.get("allow_multiple", False))
143
+
144
+ slice_text = "".join(all_lines[sl - 1 : el])
145
+ count = slice_text.count(target)
146
+
147
+ if count == 0:
148
+ return {
149
+ "error": (
150
+ f"Chunk {i} (lines {sl}–{el}): target_content not found."
151
+ ),
152
+ "partial_results": results,
153
+ }
154
+ if count > 1 and not allow_multiple:
155
+ return {
156
+ "error": (
157
+ f"Chunk {i} (lines {sl}–{el}): found {count} occurrences "
158
+ "but allow_multiple is false."
159
+ ),
160
+ "partial_results": results,
161
+ }
162
+
163
+ if allow_multiple:
164
+ new_slice = slice_text.replace(target, replacement)
165
+ else:
166
+ new_slice = slice_text.replace(target, replacement, 1)
167
+
168
+ # Replace the lines in the buffer
169
+ new_slice_lines = _splitlines_keep(new_slice)
170
+ all_lines = all_lines[: sl - 1] + new_slice_lines + all_lines[el:]
171
+ total_lines = len(all_lines)
172
+
173
+ results.append(
174
+ {
175
+ "chunk_index": i,
176
+ "range": {"start_line": sl, "end_line": el},
177
+ "replacements_made": count if allow_multiple else 1,
178
+ }
179
+ )
180
+
181
+ # --- Write back ---
182
+ try:
183
+ async with aiofiles.open(path, "w", encoding="utf-8") as f:
184
+ await f.write("".join(all_lines))
185
+ except Exception as e:
186
+ return {"error": f"Failed to write file: {e}"}
187
+
188
+ logger.info(
189
+ f"[multi_replace_file_content] applied {len(results)} chunk(s) to {path!r}"
190
+ )
191
+ return {
192
+ "path": path,
193
+ "chunks_applied": len(results),
194
+ "results": results,
195
+ }
196
+
197
+
198
+ def _splitlines_keep(text: str) -> List[str]:
199
+ """Split text into lines while preserving line endings (like readlines())."""
200
+ lines: List[str] = []
201
+ start = 0
202
+ for i, ch in enumerate(text):
203
+ if ch == "\n":
204
+ lines.append(text[start : i + 1])
205
+ start = i + 1
206
+ if start < len(text):
207
+ lines.append(text[start:])
208
+ return lines
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: multi-replace-file-content-tool
3
+ Version: 0.1.0
4
+ Summary: Edit multiple non-adjacent blocks in a single file with one call.
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
@@ -0,0 +1,6 @@
1
+ multi_replace_file_content_tool/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ multi_replace_file_content_tool/main.py,sha256=VAeCLIYJNDyut9_8zEQT-yOG41uFP1RGjS3-cGQrjaU,8112
3
+ multi_replace_file_content_tool-0.1.0.dist-info/METADATA,sha256=S86B_HegNm-ATCePAI7TfgVKG3s0jmFQonI_swigXto,214
4
+ multi_replace_file_content_tool-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
5
+ multi_replace_file_content_tool-0.1.0.dist-info/top_level.txt,sha256=S88tbq0-Luz_xfBK3cZlfoP9jcYNshAo2LlFha9Vaug,32
6
+ multi_replace_file_content_tool-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ multi_replace_file_content_tool