multi-replace-file-content-tool 0.1.0__tar.gz → 0.1.1__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: multi_replace_file_content_tool
3
+ Version: 0.1.1
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
7
+ Requires-Dist: aiofiles>=25.1.0
8
+ Requires-Dist: openchadpy>=0.1.23
9
+ Requires-Dist: types-aiofiles>=25.1.0.20260518
@@ -0,0 +1,304 @@
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 asyncio
23
+ import ctypes
24
+ import logging
25
+ import os
26
+ import sys
27
+ import tempfile
28
+ import threading
29
+ from typing import Any, Dict, List
30
+ import aiofiles
31
+ from openchadpy.tool_base import ToolBase
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Atomic replace helper
37
+ # ---------------------------------------------------------------------------
38
+ if sys.platform == "win32":
39
+ _kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
40
+ _MOVEFILE_REPLACE_EXISTING: int = 0x00000001
41
+ _MOVEFILE_WRITE_THROUGH: int = 0x00000008
42
+ _REPLACEFILE_WRITE_THROUGH: int = 0x00000001
43
+ _REPLACEFILE_IGNORE_MERGE_ERRORS: int = 0x00000002
44
+
45
+ def _atomic_replace(src: str, dst: str) -> None:
46
+ """Atomically replace *dst* with *src* using Win32 APIs.
47
+
48
+ * Existing destination -> ReplaceFileW (preserves metadata, most atomic)
49
+ * New destination -> MoveFileExW (single kernel rename)
50
+ Both paths include WRITE_THROUGH to guarantee data hits disk before return.
51
+ """
52
+ if os.path.exists(dst):
53
+ ok = _kernel32.ReplaceFileW(
54
+ dst, # lpReplacedFileName
55
+ src, # lpReplacementFileName
56
+ None, # lpBackupFileName (no backup)
57
+ _REPLACEFILE_WRITE_THROUGH | _REPLACEFILE_IGNORE_MERGE_ERRORS,
58
+ None,
59
+ None,
60
+ )
61
+ if not ok:
62
+ raise OSError(
63
+ f"ReplaceFileW failed (error {ctypes.GetLastError()})"
64
+ )
65
+ else:
66
+ ok = _kernel32.MoveFileExW(
67
+ src,
68
+ dst,
69
+ _MOVEFILE_REPLACE_EXISTING | _MOVEFILE_WRITE_THROUGH,
70
+ )
71
+ if not ok:
72
+ raise OSError(
73
+ f"MoveFileExW failed (error {ctypes.GetLastError()})"
74
+ )
75
+ else:
76
+ def _atomic_replace(src: str, dst: str) -> None: # type: ignore[misc]
77
+ """Atomically replace *dst* with *src* (POSIX rename)."""
78
+ os.replace(src, dst)
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Per-file lock registry
83
+ # ---------------------------------------------------------------------------
84
+ _file_locks: Dict[str, asyncio.Lock] = {}
85
+ _registry_lock = threading.Lock()
86
+
87
+
88
+ def _get_file_lock(path: str) -> asyncio.Lock:
89
+ """Return (creating if needed) the asyncio.Lock for *path*.
90
+
91
+ The canonical path (resolved symlinks + normalised case) is used as the
92
+ key so different string representations of the same file share one lock.
93
+ No deadlock is possible: each operation acquires at most one file lock.
94
+ """
95
+ canonical = os.path.normcase(os.path.realpath(path))
96
+ with _registry_lock:
97
+ if canonical not in _file_locks:
98
+ _file_locks[canonical] = asyncio.Lock()
99
+ return _file_locks[canonical]
100
+
101
+
102
+ class Tool(ToolBase):
103
+ name = "multi_replace_file_content"
104
+ description = (
105
+ "Edit multiple non-adjacent blocks in a single file with one call. "
106
+ "Each chunk describes a search range and the exact text to replace. "
107
+ "Chunks are applied in reverse line order to preserve correct offsets. "
108
+ "Use replace_file_content for a single contiguous edit."
109
+ )
110
+ input_schema = {
111
+ "type": "object",
112
+ "properties": {
113
+ "path": {
114
+ "type": "string",
115
+ "description": "Absolute path to the file to edit.",
116
+ },
117
+ "chunks": {
118
+ "type": "array",
119
+ "description": "Ordered list of replacement chunks to apply.",
120
+ "minItems": 1,
121
+ "items": {
122
+ "type": "object",
123
+ "properties": {
124
+ "start_line": {
125
+ "type": "integer",
126
+ "description": "Start of search range, 1-indexed inclusive.",
127
+ },
128
+ "end_line": {
129
+ "type": "integer",
130
+ "description": "End of search range, 1-indexed inclusive.",
131
+ },
132
+ "target_content": {
133
+ "type": "string",
134
+ "description": (
135
+ "Exact text to find within the range. "
136
+ "Must match character-for-character including whitespace."
137
+ ),
138
+ },
139
+ "replacement_content": {
140
+ "type": "string",
141
+ "description": "New text to substitute in place of target_content.",
142
+ },
143
+ "allow_multiple": {
144
+ "type": "boolean",
145
+ "description": (
146
+ "When true, all occurrences within the range are replaced. "
147
+ "When false (default), errors if multiple matches are found."
148
+ ),
149
+ },
150
+ },
151
+ "required": [
152
+ "start_line",
153
+ "end_line",
154
+ "target_content",
155
+ "replacement_content",
156
+ ],
157
+ },
158
+ },
159
+ },
160
+ "required": ["path", "chunks"],
161
+ }
162
+ allowed_callers = ["direct", "code_execution"]
163
+
164
+ async def execute(self, **kwargs) -> Dict[str, Any]: # noqa: C901
165
+ path: str = kwargs.get("path", "").strip()
166
+ chunks: List[Dict[str, Any]] = kwargs.get("chunks", [])
167
+
168
+ if not path:
169
+ return {"error": "path is required and must not be empty."}
170
+ if not chunks:
171
+ return {"error": "chunks must be a non-empty list."}
172
+
173
+ # --- Validate each chunk before touching the file ---
174
+ errors: List[str] = []
175
+ for i, chunk in enumerate(chunks):
176
+ sl = chunk.get("start_line")
177
+ el = chunk.get("end_line")
178
+ tc = chunk.get("target_content")
179
+ if sl is None or el is None:
180
+ errors.append(f"Chunk {i}: start_line and end_line are required.")
181
+ elif int(sl) < 1 or int(el) < int(sl):
182
+ errors.append(
183
+ f"Chunk {i}: invalid range start_line={sl}, end_line={el}."
184
+ )
185
+ if not tc:
186
+ errors.append(f"Chunk {i}: target_content must not be empty.")
187
+ if "replacement_content" not in chunk:
188
+ errors.append(f"Chunk {i}: replacement_content is required.")
189
+ if errors:
190
+ return {"error": "Validation errors in chunks:\n" + "\n".join(errors)}
191
+
192
+ lock = _get_file_lock(path)
193
+ await lock.acquire()
194
+ try:
195
+ if not os.path.isfile(path):
196
+ return {"error": f"File not found: {path!r}"}
197
+
198
+ # --- Read file ---
199
+ try:
200
+ async with aiofiles.open(path, "r", encoding="utf-8", errors="replace") as f:
201
+ content = await f.read()
202
+ all_lines: List[str] = content.splitlines(keepends=True)
203
+ except Exception as e:
204
+ return {"error": f"Failed to read file: {e}"}
205
+
206
+ total_lines = len(all_lines)
207
+
208
+ # --- Apply chunks in reverse start_line order to preserve offsets ---
209
+ sorted_chunks = sorted(chunks, key=lambda c: int(c["start_line"]), reverse=True)
210
+
211
+ results: List[Dict[str, Any]] = []
212
+ for i, chunk in enumerate(sorted_chunks):
213
+ sl = int(chunk["start_line"])
214
+ el = min(int(chunk["end_line"]), total_lines)
215
+ target = chunk["target_content"]
216
+ replacement = chunk["replacement_content"]
217
+ allow_multiple = bool(chunk.get("allow_multiple", False))
218
+
219
+ slice_text = "".join(all_lines[sl - 1 : el])
220
+ count = slice_text.count(target)
221
+
222
+ if count == 0:
223
+ return {
224
+ "error": (
225
+ f"Chunk {i} (lines {sl}\u2013{el}): target_content not found."
226
+ ),
227
+ "partial_results": results,
228
+ }
229
+ if count > 1 and not allow_multiple:
230
+ return {
231
+ "error": (
232
+ f"Chunk {i} (lines {sl}\u2013{el}): found {count} occurrences "
233
+ "but allow_multiple is false."
234
+ ),
235
+ "partial_results": results,
236
+ }
237
+
238
+ if allow_multiple:
239
+ new_slice = slice_text.replace(target, replacement)
240
+ else:
241
+ new_slice = slice_text.replace(target, replacement, 1)
242
+
243
+ # Replace the lines in the buffer
244
+ new_slice_lines = _splitlines_keep(new_slice)
245
+ all_lines = all_lines[: sl - 1] + new_slice_lines + all_lines[el:]
246
+ total_lines = len(all_lines)
247
+
248
+ results.append(
249
+ {
250
+ "chunk_index": i,
251
+ "range": {"start_line": sl, "end_line": el},
252
+ "replacements_made": count if allow_multiple else 1,
253
+ }
254
+ )
255
+
256
+ # --- Write back (atomic: temp file + os.replace) ---
257
+ tmp_path = ""
258
+ try:
259
+ parent_dir = os.path.dirname(path) or "."
260
+ tmp_fd, tmp_path = tempfile.mkstemp(dir=parent_dir, suffix=".tmp")
261
+ os.close(tmp_fd)
262
+
263
+ async with aiofiles.open(tmp_path, "w", encoding="utf-8") as f:
264
+ await f.write("".join(all_lines))
265
+
266
+ # Flush data to disk before the rename so a crash cannot leave
267
+ # the destination referencing unwritten sectors.
268
+ with open(tmp_path, "rb") as sync_f:
269
+ os.fsync(sync_f.fileno())
270
+
271
+ _atomic_replace(tmp_path, path)
272
+ tmp_path = ""
273
+ except Exception as e:
274
+ if tmp_path:
275
+ try:
276
+ os.unlink(tmp_path)
277
+ except OSError:
278
+ pass
279
+ return {"error": f"Failed to write file: {e}"}
280
+
281
+ logger.info(
282
+ f"[multi_replace_file_content] applied {len(results)} chunk(s) to {path!r}"
283
+ )
284
+ return {
285
+ "path": path,
286
+ "chunks_applied": len(results),
287
+ "results": results,
288
+ }
289
+ finally:
290
+ lock.release()
291
+
292
+
293
+
294
+ def _splitlines_keep(text: str) -> List[str]:
295
+ """Split text into lines while preserving line endings (like readlines())."""
296
+ lines: List[str] = []
297
+ start = 0
298
+ for i, ch in enumerate(text):
299
+ if ch == "\n":
300
+ lines.append(text[start : i + 1])
301
+ start = i + 1
302
+ if start < len(text):
303
+ lines.append(text[start:])
304
+ return lines
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: multi_replace_file_content_tool
3
+ Version: 0.1.1
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
7
+ Requires-Dist: aiofiles>=25.1.0
8
+ Requires-Dist: openchadpy>=0.1.23
9
+ Requires-Dist: types-aiofiles>=25.1.0.20260518
@@ -5,4 +5,5 @@ multi_replace_file_content_tool/main.py
5
5
  multi_replace_file_content_tool.egg-info/PKG-INFO
6
6
  multi_replace_file_content_tool.egg-info/SOURCES.txt
7
7
  multi_replace_file_content_tool.egg-info/dependency_links.txt
8
+ multi_replace_file_content_tool.egg-info/requires.txt
8
9
  multi_replace_file_content_tool.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ aiofiles>=25.1.0
2
+ openchadpy>=0.1.23
3
+ types-aiofiles>=25.1.0.20260518
@@ -0,0 +1,11 @@
1
+ [project]
2
+ name = "multi_replace_file_content_tool"
3
+ version = "0.1.1"
4
+ description = "Edit multiple non-adjacent blocks in a single file with one call."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "aiofiles>=25.1.0",
9
+ "openchadpy>=0.1.23",
10
+ "types-aiofiles>=25.1.0.20260518",
11
+ ]
@@ -1,6 +0,0 @@
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
@@ -1,208 +0,0 @@
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
@@ -1,6 +0,0 @@
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
@@ -1,7 +0,0 @@
1
- [project]
2
- name = "multi-replace-file-content-tool"
3
- version = "0.1.0"
4
- description = "Edit multiple non-adjacent blocks in a single file with one call."
5
- readme = "README.md"
6
- requires-python = ">=3.13"
7
- dependencies = []