minimalcode 1.5.2__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.
Files changed (58) hide show
  1. minimalcode/__init__.py +3 -0
  2. minimalcode/__main__.py +3 -0
  3. minimalcode/agent.py +1084 -0
  4. minimalcode/cli.py +1520 -0
  5. minimalcode/clipboard.py +212 -0
  6. minimalcode/config.py +1065 -0
  7. minimalcode/defaults.json +491 -0
  8. minimalcode/errors.py +31 -0
  9. minimalcode/highlight/__init__.py +47 -0
  10. minimalcode/highlight/code.py +261 -0
  11. minimalcode/highlight/color.py +58 -0
  12. minimalcode/highlight/markdown.py +710 -0
  13. minimalcode/highlight/palette.py +73 -0
  14. minimalcode/highlight/sanitize.py +175 -0
  15. minimalcode/highlight/thinking.py +71 -0
  16. minimalcode/httpclient.py +278 -0
  17. minimalcode/importer.py +253 -0
  18. minimalcode/prompt.py +195 -0
  19. minimalcode/provider/__init__.py +28 -0
  20. minimalcode/provider/anthropic.py +805 -0
  21. minimalcode/provider/anthropic_env.py +161 -0
  22. minimalcode/provider/chat.py +394 -0
  23. minimalcode/provider/client.py +243 -0
  24. minimalcode/provider/common.py +176 -0
  25. minimalcode/provider/deepseek_web.py +1158 -0
  26. minimalcode/provider/factory.py +63 -0
  27. minimalcode/provider/responses.py +692 -0
  28. minimalcode/provider/sha3_wasm_bg.wasm +0 -0
  29. minimalcode/provider/transport.py +241 -0
  30. minimalcode/provider/types.py +54 -0
  31. minimalcode/sandbox/__init__.py +64 -0
  32. minimalcode/sandbox/audit.py +120 -0
  33. minimalcode/sandbox/backends.py +343 -0
  34. minimalcode/sandbox/execution.py +435 -0
  35. minimalcode/sandbox/network.py +793 -0
  36. minimalcode/sandbox/policy.py +253 -0
  37. minimalcode/sandbox/runner.py +347 -0
  38. minimalcode/sandbox/sensitive.py +286 -0
  39. minimalcode/session.py +465 -0
  40. minimalcode/shell.py +333 -0
  41. minimalcode/subagent.py +120 -0
  42. minimalcode/tools/__init__.py +46 -0
  43. minimalcode/tools/files.py +1139 -0
  44. minimalcode/tools/process.py +164 -0
  45. minimalcode/tools/remote.py +74 -0
  46. minimalcode/tools/runtime.py +219 -0
  47. minimalcode/tools/schemas.py +418 -0
  48. minimalcode/tools/search.py +521 -0
  49. minimalcode/tools/search_tools.py +583 -0
  50. minimalcode/tools/validation.py +99 -0
  51. minimalcode/vision.py +231 -0
  52. minimalcode/web.py +238 -0
  53. minimalcode-1.5.2.dist-info/METADATA +646 -0
  54. minimalcode-1.5.2.dist-info/RECORD +58 -0
  55. minimalcode-1.5.2.dist-info/WHEEL +5 -0
  56. minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
  57. minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
  58. minimalcode-1.5.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1139 @@
1
+ """Workspace-bound file operations: read, write, append, and structured edit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import importlib.util
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import threading
14
+ from collections import Counter
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from ..errors import ToolError
20
+ from .search import _MAX_READ_LINE_CHARS, drain_physical_line
21
+
22
+ # ``os.umask`` is process-global; serialize the read-and-restore window so a
23
+ # concurrently created directory cannot inherit a zero umask.
24
+ _UMASK_LOCK = threading.Lock()
25
+
26
+ # Failure feedback has to stay useful without turning a retry into another
27
+ # large prompt: a closest-match diff is capped by lines, per-line width, and
28
+ # total characters.
29
+ _MAX_DIAGNOSTIC_DIFF_LINES = 40
30
+ _MAX_DIAGNOSTIC_DIFF_CHARS = 4000
31
+ _MAX_DIAGNOSTIC_LINE_CHARS = 300
32
+ # Scoring every possible window is quadratic on a large file. Exact line hits
33
+ # are always scored; the remaining evenly spaced windows keep a typo-only match
34
+ # bounded on multi-megabyte inputs.
35
+ _MAX_SIMILARITY_WINDOWS = 4000
36
+
37
+ # Reported only when the requested range really is not valid UTF-8, so a plain
38
+ # read stays silent about encoding.
39
+ _NOT_UTF8_WARNING = (
40
+ "this range is not valid UTF-8; undecodable bytes were replaced with U+FFFD "
41
+ "and edit refuses this file"
42
+ )
43
+
44
+ # ``str.splitlines`` also breaks on VT, FF, NEL, and U+2028/U+2029, while
45
+ # ``read`` counts only CR/LF/CRLF. Line-mode edit must use the same definition
46
+ # or it replaces a different line than the caller read.
47
+ _PHYSICAL_LINE_RE = re.compile(r"[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$")
48
+
49
+
50
+ def _physical_lines(text: str) -> list[str]:
51
+ """Split ``text`` into CR/LF/CRLF lines, each keeping its terminator."""
52
+ return _PHYSICAL_LINE_RE.findall(text)
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class _TextScan:
57
+ """One pass over the requested line range of a text file."""
58
+
59
+ chunks: list[str]
60
+ start: int | None
61
+ end: int
62
+ last_line: int
63
+ truncated: bool
64
+ newline: str | None = None
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class _Diagnostic:
69
+ """One validation finding, keyed without its line so edits can move it."""
70
+
71
+ validator: str
72
+ line: int | None
73
+ code: str | None
74
+ message: str
75
+
76
+ @property
77
+ def key(self) -> tuple[str, str | None, str]:
78
+ return (self.validator, self.code, self.message)
79
+
80
+
81
+ _PYTHON_SUFFIXES = frozenset({".py", ".pyi"})
82
+ _MAX_REPORTED_DIAGNOSTICS = 8
83
+ _VALIDATION_TIMEOUT_SECONDS = 30
84
+ # Codes that mean "the validator itself did not run" (missing tool, broken
85
+ # config, timeout) rather than "the new content is wrong". Counting them as
86
+ # content diagnostics rolled a fresh write back and deleted the new file.
87
+ _VALIDATOR_FAILURES = frozenset({"exit", "unavailable", "timeout"})
88
+
89
+
90
+ def _source_preview(text: str, line: int | None) -> str:
91
+ if line is None:
92
+ return ""
93
+ # Same physical-line definition as `read`, so the previewed source is the
94
+ # line the validator actually reported.
95
+ lines = _physical_lines(text)
96
+ if line < 1 or line > len(lines):
97
+ return ""
98
+ source = lines[line - 1].rstrip()
99
+ if len(source) > 200:
100
+ source = source[:197] + "..."
101
+ return f"\n {line:>5} | {source}"
102
+
103
+
104
+ def _dominant_newline(counts: dict[str, int]) -> str | None:
105
+ """Return the most frequent line ending, or None when there is none."""
106
+ best = max(counts, key=counts.__getitem__)
107
+ return best if counts[best] else None
108
+
109
+
110
+ def _count_text_newlines(text: str) -> dict[str, int]:
111
+ crlf = text.count("\r\n")
112
+ return {
113
+ "\r\n": crlf,
114
+ "\n": text.count("\n") - crlf,
115
+ "\r": text.count("\r") - crlf,
116
+ }
117
+
118
+
119
+ def _detect_newline_optional(text: str) -> str | None:
120
+ return _dominant_newline(_count_text_newlines(text))
121
+
122
+
123
+ def _detect_newline(text: str) -> str:
124
+ """Return the file's dominant physical line ending."""
125
+ return _detect_newline_optional(text) or "\n"
126
+
127
+
128
+ def _line_ending_name(newline: str | None) -> str:
129
+ return {"\r\n": "CRLF", "\r": "CR", "\n": "LF"}.get(newline, "none")
130
+
131
+
132
+ def _detect_newline_bytes(data: bytes) -> str | None:
133
+ """Detect a physical line ending without decoding the whole file."""
134
+ crlf = data.count(b"\r\n")
135
+ return _dominant_newline(
136
+ {
137
+ "\r\n": crlf,
138
+ "\n": data.count(b"\n") - crlf,
139
+ "\r": data.count(b"\r") - crlf,
140
+ }
141
+ )
142
+
143
+
144
+ def _normalize_newlines(text: str, newline: str) -> str:
145
+ """Rewrite every physical line ending to ``newline``."""
146
+ normalized = text.replace("\r\n", "\n").replace("\r", "\n")
147
+ return normalized.replace("\n", newline)
148
+
149
+
150
+ def _line_number_at(text: str, offset: int) -> int:
151
+ return len(re.findall(r"\r\n|\r|\n", text[:offset])) + 1
152
+
153
+
154
+ def _clip_diagnostic(lines: list[str]) -> str:
155
+ """Join a diff without letting a wrong-file match flood the next request."""
156
+ clipped = [
157
+ line
158
+ if len(line) <= _MAX_DIAGNOSTIC_LINE_CHARS
159
+ else line[: _MAX_DIAGNOSTIC_LINE_CHARS - 3] + "..."
160
+ for line in lines
161
+ ]
162
+ if len(clipped) > _MAX_DIAGNOSTIC_DIFF_LINES:
163
+ head = _MAX_DIAGNOSTIC_DIFF_LINES - 4
164
+ tail = 3
165
+ omitted = len(clipped) - head - tail
166
+ clipped = [
167
+ *clipped[:head],
168
+ f"... ({omitted} diff lines omitted) ...",
169
+ *clipped[-tail:],
170
+ ]
171
+ text = "\n".join(clipped)
172
+ if len(text) > _MAX_DIAGNOSTIC_DIFF_CHARS:
173
+ text = text[: _MAX_DIAGNOSTIC_DIFF_CHARS - 20] + "\n... (diff truncated)"
174
+ return text
175
+
176
+
177
+ def _closest_fragment(original: str, needle: str) -> tuple[int, int, float, str] | None:
178
+ """Locate the region most similar to ``needle`` and render its diff.
179
+
180
+ Returns ``(start_line, end_line, similarity, diff)`` with 1-based inclusive
181
+ line numbers. ``difflib`` is only used after an exact match already failed,
182
+ and the candidate count is bounded so this stays a repair aid rather than a
183
+ second full-file scan.
184
+ """
185
+ # Physical lines, not `splitlines`: the latter also breaks on FF/VT/NEL and
186
+ # U+2028, which would report line numbers `read` never showed.
187
+ needle_lines = [
188
+ line.rstrip("\n") for line in _physical_lines(_normalize_newlines(needle, "\n"))
189
+ ]
190
+ if not needle_lines:
191
+ return None
192
+ file_lines = [
193
+ line.rstrip("\n")
194
+ for line in _physical_lines(_normalize_newlines(original, "\n"))
195
+ ]
196
+ if not file_lines:
197
+ return None
198
+ count = len(needle_lines)
199
+ last_start = max(0, len(file_lines) - count)
200
+ starts: set[int] = set()
201
+ positions: dict[str, list[int]] = {}
202
+ for index, line in enumerate(file_lines):
203
+ positions.setdefault(line, []).append(index)
204
+ for offset, line in enumerate(needle_lines):
205
+ for index in positions.get(line, ())[:50]:
206
+ starts.add(min(max(0, index - offset), last_start))
207
+ step = max(1, len(file_lines) // _MAX_SIMILARITY_WINDOWS)
208
+ for index in range(0, last_start + 1, step):
209
+ starts.add(index)
210
+ best_start = 0
211
+ best_ratio = -1.0
212
+ matcher = difflib.SequenceMatcher(autojunk=False)
213
+ for start in sorted(starts):
214
+ window = file_lines[start : start + count]
215
+ if not window:
216
+ continue
217
+ matcher.set_seqs(needle_lines, window)
218
+ ratio = matcher.ratio()
219
+ if ratio > best_ratio:
220
+ best_ratio = ratio
221
+ best_start = start
222
+ context_start = max(0, best_start - 1)
223
+ context_end = min(len(file_lines), best_start + count + 1)
224
+ actual = file_lines[context_start:context_end]
225
+ diff = list(
226
+ difflib.unified_diff(
227
+ needle_lines,
228
+ actual,
229
+ fromfile="old_text",
230
+ tofile=f"file lines {context_start + 1}-{context_end}",
231
+ n=0,
232
+ lineterm="",
233
+ )
234
+ )
235
+ if not diff:
236
+ # Identical line content can still reach here when the caller included
237
+ # a trailing empty line that splitlines() dropped. Show real context so
238
+ # the retry is not blind.
239
+ diff = [f"{context_start + 1}: {line}" for line in actual]
240
+ return (
241
+ best_start + 1,
242
+ min(len(file_lines), best_start + count),
243
+ max(0.0, best_ratio),
244
+ _clip_diagnostic(diff),
245
+ )
246
+
247
+
248
+ def _not_found_message(target: Path, original: str, old_text: str) -> str:
249
+ message = f"old_text was not found in {target}."
250
+ fragment = _closest_fragment(original, old_text)
251
+ if fragment is None:
252
+ return message + " The file has no text to compare against."
253
+ start, end, ratio, diff = fragment
254
+ return (
255
+ f"{message}\nMost similar region: lines {start}-{end} "
256
+ f"({ratio:.0%} similar):\n{diff}"
257
+ )
258
+
259
+
260
+ def _range_hint(lines: list[str], start: int, end: int) -> str:
261
+ """Describe the valid line range nearest to an out-of-bounds request."""
262
+ total = len(lines)
263
+ if total == 0:
264
+ return "the file is empty; write or append content instead"
265
+ nearest_start = min(max(1, start), total)
266
+ nearest_end = min(max(nearest_start, end), total)
267
+ # The preview is a hint, not a dump: `end_line=1000000` on a 200k-line file
268
+ # used to paste the whole file into the tool error and the next request.
269
+ shown_end = min(nearest_end, nearest_start + _MAX_DIAGNOSTIC_DIFF_LINES - 1)
270
+
271
+ def visible(line: str) -> str:
272
+ line = line.rstrip("\r\n")
273
+ if len(line) > _MAX_DIAGNOSTIC_LINE_CHARS:
274
+ return line[: _MAX_DIAGNOSTIC_LINE_CHARS - 3] + "..."
275
+ return line
276
+
277
+ preview = "".join(
278
+ f"{number:>6}\t{visible(lines[number - 1])}\n"
279
+ for number in range(nearest_start, shown_end + 1)
280
+ )
281
+ if preview and not preview.endswith(("\n", "\r")):
282
+ preview += "\n"
283
+ if shown_end < nearest_end:
284
+ preview += f"... (lines {shown_end + 1}-{nearest_end} omitted) ...\n"
285
+ return f"closest available range is lines {nearest_start}-{nearest_end}:\n{preview}"
286
+
287
+
288
+ class FileToolsMixin:
289
+ """File methods mixed into :class:`~minimalcode.tools.ToolRuntime`.
290
+
291
+ All paths are resolved by the runtime's sandbox-aware ``_path`` helper
292
+ before any read or write occurs.
293
+ """
294
+
295
+ def read(
296
+ self,
297
+ path: str,
298
+ start_line: int = 1,
299
+ end_line: int | None = None,
300
+ ) -> dict[str, Any]:
301
+ """Read a UTF-8 text file by 1-based line range with line numbers."""
302
+ target = self._path(path, operation="read")
303
+ if not target.is_file():
304
+ raise ToolError(f"file not found: {target}")
305
+ if isinstance(start_line, bool) or not isinstance(start_line, int):
306
+ raise ToolError("start_line must be an integer")
307
+ if end_line is not None and (
308
+ isinstance(end_line, bool) or not isinstance(end_line, int)
309
+ ):
310
+ raise ToolError("end_line must be an integer or null")
311
+ start = max(1, start_line)
312
+ stop = end_line
313
+ if stop is not None and stop < start:
314
+ raise ToolError("end_line must be greater than or equal to start_line")
315
+
316
+ # Reject binary input before decoding so a NUL-containing file is not
317
+ # rendered as a wall of replacement characters. The sample catches the
318
+ # common case early; the per-line check below catches a NUL that only
319
+ # appears after the sample.
320
+ with target.open("rb") as binary:
321
+ sample = binary.read(65536)
322
+ if b"\x00" in sample:
323
+ raise ToolError("binary files are not supported by read")
324
+
325
+ line_limit = min(self.max_file_chars + 1, _MAX_READ_LINE_CHARS)
326
+ warnings: list[str] = []
327
+ try:
328
+ scan = self._scan_lines(
329
+ target,
330
+ start=start,
331
+ stop=stop,
332
+ line_limit=line_limit,
333
+ errors="strict",
334
+ )
335
+ except UnicodeDecodeError:
336
+ # Decoding with ``replace`` used to hide this: the caller saw a
337
+ # clean read made of U+FFFD characters, and only ``edit`` later
338
+ # refused the same file. Read it again lossily so the content is
339
+ # still visible, and say that the bytes were not UTF-8 only when
340
+ # the returned range actually contains a replacement character.
341
+ scan = self._scan_lines(
342
+ target,
343
+ start=start,
344
+ stop=stop,
345
+ line_limit=line_limit,
346
+ errors="replace",
347
+ )
348
+ if "\ufffd" in "".join(scan.chunks):
349
+ warnings.append(_NOT_UTF8_WARNING)
350
+
351
+ line_ending = _detect_newline_bytes(sample) or scan.newline
352
+ if scan.start is None:
353
+ if start > max(1, scan.last_line):
354
+ raise ToolError(f"start_line {start} is beyond the end of the file")
355
+ result: dict[str, Any] = {
356
+ "status": "success",
357
+ "path": str(target),
358
+ "content": "",
359
+ "start_line": start,
360
+ "end_line": 0,
361
+ "line_ending": _line_ending_name(line_ending),
362
+ "truncated": scan.truncated,
363
+ "eof": True,
364
+ }
365
+ else:
366
+ result = {
367
+ "status": "success",
368
+ "path": str(target),
369
+ "start_line": scan.start,
370
+ "end_line": scan.end,
371
+ "content": "".join(scan.chunks),
372
+ "line_ending": _line_ending_name(line_ending),
373
+ "truncated": scan.truncated,
374
+ }
375
+ if warnings:
376
+ result["warnings"] = warnings
377
+ return result
378
+
379
+ def read_range(self, path: str, start_line: int, end_line: int) -> dict[str, Any]:
380
+ """Read an explicit range; both bounds are required by the schema."""
381
+ result = self.read(path, start_line, end_line)
382
+ result["line_count"] = max(0, result["end_line"] - result["start_line"] + 1)
383
+ return result
384
+
385
+ def _scan_lines(
386
+ self,
387
+ target: Path,
388
+ *,
389
+ start: int,
390
+ stop: int | None,
391
+ line_limit: int,
392
+ errors: str,
393
+ ) -> _TextScan:
394
+ """Collect one line range, decoding with the caller's error policy.
395
+
396
+ A strict pass is what makes a non-UTF-8 file visible: with
397
+ ``errors="replace"`` the decode never fails, so the caller could not
398
+ tell a clean file from one whose bytes were silently substituted.
399
+ ``utf-8-sig`` also drops a leading BOM instead of showing it as part of
400
+ line 1.
401
+ """
402
+ chunks: list[str] = []
403
+ used = 0
404
+ actual_start: int | None = None
405
+ actual_end = 0
406
+ truncated = False
407
+ last_line = 0
408
+ detected_newline: str | None = None
409
+ pending = ""
410
+ # Two extra characters make an exactly-at-the-limit physical line,
411
+ # including its terminator, readable as a complete line instead of
412
+ # being flagged as a cut and dropping every line after it.
413
+ read_limit = line_limit + 2
414
+ with target.open(
415
+ "r", encoding="utf-8-sig", errors=errors, newline=""
416
+ ) as handle:
417
+ number = 0
418
+ while True:
419
+ line = pending + handle.readline(read_limit)
420
+ pending = ""
421
+ if not line:
422
+ break
423
+ if line.endswith("\r") and len(line) >= read_limit:
424
+ # A limit that lands between CR and LF must not turn the
425
+ # LF into a separate empty line. Peek one character and
426
+ # keep it for the next iteration when it is real content.
427
+ following = handle.read(1)
428
+ if following == "\n":
429
+ line += following
430
+ else:
431
+ pending = following
432
+ number += 1
433
+ last_line = number
434
+ if detected_newline is None:
435
+ detected_newline = _detect_newline_optional(line)
436
+ if "\x00" in line:
437
+ raise ToolError("binary files are not supported by read")
438
+ line_cut = len(line) >= read_limit and not line.endswith(("\n", "\r"))
439
+ if line_cut:
440
+ # The limit may still be an exact fit for a complete line
441
+ # that had not delivered its terminator within the limit.
442
+ position = handle.tell()
443
+ following = handle.read(1)
444
+ if not following:
445
+ line_cut = False
446
+ elif following == "\n":
447
+ line += following
448
+ line_cut = False
449
+ elif following == "\r":
450
+ line += following
451
+ peek = handle.read(1)
452
+ if peek == "\n":
453
+ line += peek
454
+ elif peek:
455
+ handle.seek(position + 1)
456
+ line_cut = False
457
+ else:
458
+ handle.seek(position)
459
+ if number < start or (stop is not None and number > stop):
460
+ if line_cut:
461
+ # Skip the rest of an over-long physical line so the
462
+ # next readline starts a new line and the reported
463
+ # numbers keep matching the file.
464
+ drain_physical_line(handle, read_limit)
465
+ if stop is not None and number > stop:
466
+ break
467
+ continue
468
+ if line_cut:
469
+ truncated = True
470
+ rendered = f"{number:>6}\t{line.rstrip(chr(13) + chr(10))}\n"
471
+ remaining = self.max_file_chars - used
472
+ if len(rendered) > remaining:
473
+ truncated = True
474
+ if actual_start is None and remaining > 0:
475
+ # A first line larger than the budget must not look like
476
+ # an empty file; return its prefix so the caller sees
477
+ # content.
478
+ chunks.append(rendered[:remaining])
479
+ actual_start = number
480
+ actual_end = number
481
+ break
482
+ if actual_start is None:
483
+ actual_start = number
484
+ chunks.append(rendered)
485
+ used += len(rendered)
486
+ actual_end = number
487
+ if line_cut:
488
+ break
489
+ return _TextScan(
490
+ chunks=chunks,
491
+ start=actual_start,
492
+ end=actual_end,
493
+ last_line=last_line,
494
+ truncated=truncated,
495
+ newline=detected_newline,
496
+ )
497
+
498
+ @staticmethod
499
+ def _write_atomic(
500
+ target: Path, content: str | bytes, *, mode: int | None = None
501
+ ) -> None:
502
+ """Write a sibling temp file, fsync it, then replace the target.
503
+
504
+ The rename keeps readers from observing a half-written file, and the
505
+ original mode is restored for executable scripts. A new file uses the
506
+ process umask instead of mkstemp's 0600 so other users/groups can read
507
+ source files created by the agent.
508
+ """
509
+ fd: int
510
+ temp_name: str
511
+ try:
512
+ fd, temp_name = tempfile.mkstemp(
513
+ # A temp name longer than the filesystem's limit fails with an
514
+ # opaque EINVAL; the random suffix is what matters, not the stem.
515
+ prefix=f".{target.name[:40]}.",
516
+ dir=target.parent,
517
+ )
518
+ except OSError as exc:
519
+ raise ToolError(f"cannot write {target}: {exc}") from exc
520
+ temp_path = Path(temp_name)
521
+ try:
522
+ if isinstance(content, bytes):
523
+ with os.fdopen(fd, "wb") as handle:
524
+ handle.write(content)
525
+ handle.flush()
526
+ os.fsync(handle.fileno())
527
+ else:
528
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
529
+ handle.write(content)
530
+ handle.flush()
531
+ os.fsync(handle.fileno())
532
+ if mode is None:
533
+ with _UMASK_LOCK:
534
+ current_umask = os.umask(0)
535
+ os.umask(current_umask)
536
+ mode = 0o666 & ~current_umask
537
+ try:
538
+ os.chmod(temp_path, mode)
539
+ except OSError:
540
+ pass
541
+ try:
542
+ os.replace(temp_path, target)
543
+ except OSError as exc:
544
+ # A read-only target (or a read-only inherited mode on the
545
+ # temporary file) fails here; report a tool error instead of a
546
+ # raw PermissionError that the agent loop cannot explain.
547
+ raise ToolError(f"cannot write {target}: {exc}") from exc
548
+ try:
549
+ directory_fd = os.open(target.parent, os.O_RDONLY)
550
+ except OSError:
551
+ directory_fd = -1
552
+ if directory_fd >= 0:
553
+ try:
554
+ os.fsync(directory_fd)
555
+ except OSError:
556
+ pass
557
+ finally:
558
+ os.close(directory_fd)
559
+ except OSError as exc:
560
+ raise ToolError(f"cannot write {target}: {exc}") from exc
561
+ finally:
562
+ # A failed write or replace must not leave a hidden temp file. A
563
+ # Windows read-only attribute blocks unlink, so make it writable
564
+ # once and retry instead of leaking the file.
565
+ try:
566
+ temp_path.unlink(missing_ok=True)
567
+ except OSError:
568
+ try:
569
+ os.chmod(temp_path, 0o600)
570
+ temp_path.unlink(missing_ok=True)
571
+ except OSError:
572
+ pass
573
+
574
+ def _ruff_invocation(self) -> list[str] | None:
575
+ """Return a ruff command once, or None when ruff is unavailable."""
576
+ if hasattr(self, "_ruff_command_cache"):
577
+ return self._ruff_command_cache
578
+ executable = shutil.which("ruff")
579
+ if executable:
580
+ command = [executable]
581
+ elif importlib.util.find_spec("ruff") is not None:
582
+ command = [sys.executable, "-m", "ruff"]
583
+ else:
584
+ command = None
585
+ self._ruff_command_cache = command
586
+ return command
587
+
588
+ @staticmethod
589
+ def _run_validator(
590
+ argv: list[str], target: Path
591
+ ) -> subprocess.CompletedProcess[str] | _Diagnostic:
592
+ try:
593
+ return subprocess.run(
594
+ argv,
595
+ cwd=str(target.parent),
596
+ stdin=subprocess.DEVNULL,
597
+ capture_output=True,
598
+ text=True,
599
+ encoding="utf-8",
600
+ errors="replace",
601
+ timeout=_VALIDATION_TIMEOUT_SECONDS,
602
+ check=False,
603
+ )
604
+ except subprocess.TimeoutExpired:
605
+ return _Diagnostic(
606
+ "validation",
607
+ None,
608
+ "timeout",
609
+ f"did not finish within {_VALIDATION_TIMEOUT_SECONDS:g} seconds",
610
+ )
611
+ except (OSError, ValueError) as exc:
612
+ return _Diagnostic("validation", None, "unavailable", str(exc))
613
+
614
+ def _py_compile_diagnostics(self, target: Path) -> list[_Diagnostic]:
615
+ """Compile ``target`` without leaving a __pycache__ in the workspace."""
616
+ with tempfile.TemporaryDirectory(prefix="minimalcode-pycache-") as cache:
617
+ env = os.environ.copy()
618
+ env["PYTHONPYCACHEPREFIX"] = cache
619
+ try:
620
+ completed = subprocess.run(
621
+ [sys.executable, "-m", "py_compile", str(target)],
622
+ cwd=str(target.parent),
623
+ stdin=subprocess.DEVNULL,
624
+ capture_output=True,
625
+ text=True,
626
+ encoding="utf-8",
627
+ errors="replace",
628
+ timeout=_VALIDATION_TIMEOUT_SECONDS,
629
+ env=env,
630
+ check=False,
631
+ )
632
+ except subprocess.TimeoutExpired:
633
+ return [
634
+ _Diagnostic(
635
+ "py_compile",
636
+ None,
637
+ "timeout",
638
+ f"did not finish within {_VALIDATION_TIMEOUT_SECONDS:g}s",
639
+ )
640
+ ]
641
+ except (OSError, ValueError) as exc:
642
+ return [_Diagnostic("py_compile", None, "unavailable", str(exc))]
643
+ if completed.returncode == 0:
644
+ return []
645
+ detail = (completed.stderr or completed.stdout or "").strip()
646
+ line_match = re.search(r"\bline (\d+)\b", detail)
647
+ line = int(line_match.group(1)) if line_match else None
648
+ lines = [part.strip() for part in detail.splitlines() if part.strip()]
649
+ message = lines[-1] if lines else f"exit code {completed.returncode}"
650
+ code = message.split(":", 1)[0] if ":" in message else None
651
+ return [
652
+ _Diagnostic(
653
+ "py_compile",
654
+ line,
655
+ code,
656
+ message[:400],
657
+ )
658
+ ]
659
+
660
+ def _ruff_diagnostics(self, target: Path) -> tuple[list[_Diagnostic], list[str]]:
661
+ command = self._ruff_invocation()
662
+ if command is None:
663
+ return [], ["ruff is not installed; ruff check was skipped"]
664
+ completed = self._run_validator(
665
+ [*command, "check", "--output-format=concise", "--no-cache", str(target)],
666
+ target,
667
+ )
668
+ if isinstance(completed, _Diagnostic):
669
+ return [completed], []
670
+ if completed.returncode == 0:
671
+ return [], []
672
+ output = (completed.stdout or "") + (completed.stderr or "")
673
+ diagnostics: list[_Diagnostic] = []
674
+ pattern = re.compile(r"^(.*):(\d+):(\d+): ([A-Z]+[0-9]+) (.*)$")
675
+ for raw in output.splitlines():
676
+ match = pattern.match(raw.strip())
677
+ if match is None:
678
+ continue
679
+ diagnostics.append(
680
+ _Diagnostic(
681
+ "ruff",
682
+ int(match.group(2)),
683
+ match.group(4),
684
+ match.group(5)[:400],
685
+ )
686
+ )
687
+ if diagnostics:
688
+ return diagnostics, []
689
+ detail = " ".join(output.split())[:400]
690
+ return (
691
+ [
692
+ _Diagnostic(
693
+ "ruff",
694
+ None,
695
+ "exit",
696
+ detail or f"exit code {completed.returncode}",
697
+ )
698
+ ],
699
+ [],
700
+ )
701
+
702
+ def _python_diagnostics(self, target: Path) -> tuple[list[_Diagnostic], list[str]]:
703
+ if target.suffix.lower() not in _PYTHON_SUFFIXES:
704
+ return [], []
705
+ diagnostics = self._py_compile_diagnostics(target)
706
+ ruff, warnings = self._ruff_diagnostics(target)
707
+ return [*diagnostics, *ruff], warnings
708
+
709
+ def _validate_python_write(
710
+ self,
711
+ target: Path,
712
+ before: list[_Diagnostic],
713
+ attempted_text: str,
714
+ *,
715
+ original_bytes: bytes | None,
716
+ original_mode: int | None,
717
+ ) -> list[str]:
718
+ """Validate Python output, roll it back, and report new diagnostics."""
719
+ if target.suffix.lower() not in _PYTHON_SUFFIXES:
720
+ return []
721
+ after, warnings = self._python_diagnostics(target)
722
+ failures = [
723
+ diagnostic for diagnostic in after if diagnostic.code in _VALIDATOR_FAILURES
724
+ ]
725
+ if failures:
726
+ warnings = [
727
+ *warnings,
728
+ *(f"{item.validator}: {item.message}" for item in failures),
729
+ ]
730
+ checked = [
731
+ diagnostic
732
+ for diagnostic in after
733
+ if diagnostic.code not in _VALIDATOR_FAILURES
734
+ ]
735
+ remaining = Counter(diagnostic.key for diagnostic in checked)
736
+ remaining.subtract(Counter(diagnostic.key for diagnostic in before))
737
+ new_keys = {key for key, count in remaining.items() if count > 0}
738
+ if not new_keys:
739
+ return warnings
740
+ if original_bytes is None:
741
+ target.unlink(missing_ok=True)
742
+ else:
743
+ self._write_atomic(target, original_bytes, mode=original_mode)
744
+ reported = [diagnostic for diagnostic in after if diagnostic.key in new_keys]
745
+ lines = [f"validation failed for {target}; the change was rolled back."]
746
+ for diagnostic in reported[:_MAX_REPORTED_DIAGNOSTICS]:
747
+ location = (
748
+ f"{target}:{diagnostic.line}"
749
+ if diagnostic.line is not None
750
+ else str(target)
751
+ )
752
+ code = f" [{diagnostic.code}]" if diagnostic.code else ""
753
+ lines.append(
754
+ f"{location}: {diagnostic.validator}{code}: {diagnostic.message}"
755
+ + _source_preview(attempted_text, diagnostic.line)
756
+ )
757
+ omitted = len(reported) - _MAX_REPORTED_DIAGNOSTICS
758
+ if omitted > 0:
759
+ lines.append(f"... {omitted} more diagnostics omitted")
760
+ raise ToolError("\n".join(lines))
761
+
762
+ def write(self, path: str, content: str) -> dict[str, Any]:
763
+ """Create or fully replace a UTF-8 text file."""
764
+ if not isinstance(content, str):
765
+ raise ToolError("content must be a string")
766
+ if "\x00" in content:
767
+ # `read` rejects NUL bytes as binary, so accepting them here would
768
+ # create a file the agent can write but never read back.
769
+ raise ToolError("binary files are not supported by write")
770
+ target = self._path(path, operation="write")
771
+ if target.exists() and target.is_dir():
772
+ raise ToolError(f"path is a directory: {target}")
773
+ try:
774
+ target.parent.mkdir(parents=True, exist_ok=True)
775
+ except OSError as exc:
776
+ raise ToolError(f"cannot create parent directory: {exc}") from exc
777
+ existed = target.exists()
778
+ if existed and target.stat().st_size > self.max_file_bytes:
779
+ # `edit` and `append_file` refuse an oversized file; reading it to
780
+ # mirror the old bytes for validation rollback turned a legitimate
781
+ # truncate into a MemoryError that killed the process.
782
+ raise ToolError("file is too large for write")
783
+ original_bytes = target.read_bytes() if existed else None
784
+ newline = (
785
+ _detect_newline_bytes(original_bytes)
786
+ if original_bytes is not None
787
+ else _detect_newline_optional(content)
788
+ )
789
+ if newline is not None:
790
+ # Keep the file's physical style instead of letting a model's LF
791
+ # payload churn every line of a CRLF checkout.
792
+ content = _normalize_newlines(content, newline)
793
+ if original_bytes is not None and original_bytes.startswith(b"\xef\xbb\xbf"):
794
+ # `read` decodes with utf-8-sig, so a rewrite must put the BOM back:
795
+ # dropping it silently changed the encoding of the whole file.
796
+ content = "\ufeff" + content
797
+ try:
798
+ encoded = content.encode("utf-8")
799
+ except UnicodeEncodeError as exc:
800
+ raise ToolError(f"content is not valid UTF-8 text: {exc}") from exc
801
+ if len(encoded) > self.max_file_bytes:
802
+ raise ToolError(
803
+ f"content is too large for write: {len(encoded)} bytes exceeds "
804
+ f"tools.max_file_bytes ({self.max_file_bytes})"
805
+ )
806
+ # Never carry setuid/setgid bits onto rewritten content: the kernel
807
+ # clears them on a normal write, and restoring them would hand the new
808
+ # bytes the old privilege.
809
+ mode = target.stat().st_mode & 0o777 if existed else None
810
+ before: list[_Diagnostic] = []
811
+ if existed:
812
+ before, _ = self._python_diagnostics(target)
813
+ self._write_atomic(target, content, mode=mode)
814
+ warnings = self._validate_python_write(
815
+ target,
816
+ before,
817
+ content,
818
+ original_bytes=original_bytes,
819
+ original_mode=mode,
820
+ )
821
+ result: dict[str, Any] = {
822
+ "status": "success",
823
+ "path": str(target),
824
+ "bytes": len(encoded),
825
+ "created": not existed,
826
+ "line_ending": _line_ending_name(newline),
827
+ }
828
+ if warnings:
829
+ result["warnings"] = warnings
830
+ return result
831
+
832
+ def append_file(self, path: str, content: str) -> dict[str, Any]:
833
+ """Append UTF-8 text at EOF, preserving the existing line ending."""
834
+ if not isinstance(content, str):
835
+ raise ToolError("content must be a string")
836
+ try:
837
+ encoded = content.encode("utf-8")
838
+ except UnicodeEncodeError as exc:
839
+ raise ToolError(f"content is not valid UTF-8 text: {exc}") from exc
840
+ if b"\x00" in encoded:
841
+ raise ToolError("binary files are not supported by append_file")
842
+ target = self._path(path, operation="write")
843
+ if target.exists() and target.is_dir():
844
+ raise ToolError(f"path is a directory: {target}")
845
+ try:
846
+ target.parent.mkdir(parents=True, exist_ok=True)
847
+ except OSError as exc:
848
+ raise ToolError(f"cannot create parent directory: {exc}") from exc
849
+ existed = target.exists()
850
+ if existed and target.stat().st_size > self.max_file_bytes:
851
+ raise ToolError("file is too large for append_file")
852
+ original_bytes = target.read_bytes() if existed else None
853
+ if original_bytes is None:
854
+ original_text = ""
855
+ newline = _detect_newline(content)
856
+ else:
857
+ try:
858
+ original_text = original_bytes.decode("utf-8")
859
+ except UnicodeDecodeError as exc:
860
+ raise ToolError(
861
+ f"file is not valid UTF-8 text: {target}: {exc}"
862
+ ) from exc
863
+ if "\x00" in original_text:
864
+ raise ToolError("binary files are not supported by append_file")
865
+ newline = (
866
+ _detect_newline(original_text)
867
+ if original_text
868
+ else _detect_newline(content)
869
+ )
870
+ separator = ""
871
+ if (
872
+ original_text
873
+ and content
874
+ and not original_text.endswith(("\r", "\n"))
875
+ and not content.startswith(("\r", "\n"))
876
+ ):
877
+ # Appending to a file without a final terminator must not glue the
878
+ # first new line onto the old last line.
879
+ separator = newline
880
+ appended = separator + _normalize_newlines(content, newline)
881
+ updated = original_text + appended
882
+ try:
883
+ updated_bytes = updated.encode("utf-8")
884
+ except UnicodeEncodeError as exc:
885
+ raise ToolError(f"appended content is not valid UTF-8 text: {exc}") from exc
886
+ if len(updated_bytes) > self.max_file_bytes:
887
+ raise ToolError(
888
+ f"appended file is too large: {len(updated_bytes)} bytes exceeds "
889
+ f"tools.max_file_bytes ({self.max_file_bytes})"
890
+ )
891
+ mode = target.stat().st_mode & 0o777 if existed else None
892
+ before: list[_Diagnostic] = []
893
+ if existed:
894
+ before, _ = self._python_diagnostics(target)
895
+ self._write_atomic(target, updated, mode=mode)
896
+ warnings = self._validate_python_write(
897
+ target,
898
+ before,
899
+ updated,
900
+ original_bytes=original_bytes,
901
+ original_mode=mode,
902
+ )
903
+ result: dict[str, Any] = {
904
+ "status": "success",
905
+ "path": str(target),
906
+ "created": not existed,
907
+ "appended_bytes": len(appended.encode("utf-8")),
908
+ "bytes": len(updated_bytes),
909
+ "line_ending": _line_ending_name(newline),
910
+ }
911
+ if warnings:
912
+ result["warnings"] = warnings
913
+ return result
914
+
915
+ def _read_for_edit(self, target: Path) -> tuple[str, str]:
916
+ """Read an editable UTF-8 file and its dominant line ending."""
917
+ if not target.is_file():
918
+ raise ToolError(f"file not found: {target}")
919
+ if target.stat().st_size > self.max_file_bytes:
920
+ raise ToolError("file is too large for edit")
921
+ with target.open("r", encoding="utf-8", errors="strict", newline="") as handle:
922
+ try:
923
+ original = handle.read()
924
+ except UnicodeDecodeError as exc:
925
+ raise ToolError(
926
+ f"file is not valid UTF-8 text: {target}: {exc}"
927
+ ) from exc
928
+ if "\x00" in original:
929
+ raise ToolError("binary files are not supported by edit")
930
+ return original, _detect_newline(original)
931
+
932
+ def edit(
933
+ self,
934
+ path: str,
935
+ old_text: str | None = None,
936
+ new_text: str | None = None,
937
+ replace_all: bool = False,
938
+ start_line: int | None = None,
939
+ end_line: int | None = None,
940
+ ) -> dict[str, Any]:
941
+ """Replace a line range with a patch block, or fall back to exact text.
942
+
943
+ Line mode (``start_line``/``end_line`` plus ``new_text``) is the primary
944
+ interface: it cannot silently match a second occurrence elsewhere in
945
+ the file. Exact ``old_text`` remains available for files whose line
946
+ numbers are not meaningful, and its failure now carries the closest
947
+ region and a diff instead of a bare ``not found``.
948
+ """
949
+ if not isinstance(new_text, str):
950
+ raise ToolError("new_text must be a string")
951
+ if "\x00" in new_text:
952
+ # `read` and `append_file` reject NUL bytes as binary, so accepting
953
+ # one here would produce a file the agent can never read back.
954
+ raise ToolError("binary files are not supported by edit")
955
+ if not isinstance(replace_all, bool):
956
+ raise ToolError("replace_all must be a boolean")
957
+ for name, value in (("start_line", start_line), ("end_line", end_line)):
958
+ if value is not None and (
959
+ isinstance(value, bool) or not isinstance(value, int)
960
+ ):
961
+ raise ToolError(f"{name} must be an integer or null")
962
+ target = self._path(path, operation="write")
963
+ original, default_newline = self._read_for_edit(target)
964
+ bom = "\ufeff" if original.startswith("\ufeff") else ""
965
+ if bom:
966
+ # `read` strips the BOM, so line 1 must match what the model saw and
967
+ # the marker is written back verbatim instead of being dropped.
968
+ original = original[1:]
969
+ line_mode = start_line is not None or end_line is not None
970
+ if line_mode:
971
+ if old_text is not None:
972
+ raise ToolError(
973
+ "provide either start_line/end_line or old_text, not both"
974
+ )
975
+ if replace_all:
976
+ raise ToolError("replace_all only applies to exact-text edits")
977
+ if start_line is None:
978
+ raise ToolError("start_line is required when end_line is set")
979
+ if end_line is None:
980
+ raise ToolError("end_line is required when start_line is set")
981
+ lines = _physical_lines(original)
982
+ # ``end_line == start_line - 1`` is an empty range: insert before
983
+ # start_line. This gives the model a safe way to add a line without
984
+ # guessing adjacent text.
985
+ if end_line < start_line - 1:
986
+ raise ToolError(
987
+ f"end_line {end_line} must be at least start_line - 1 "
988
+ f"({start_line - 1})"
989
+ )
990
+ if start_line < 1:
991
+ raise ToolError("start_line must be at least 1")
992
+ if start_line > len(lines) + 1:
993
+ raise ToolError(
994
+ f"start_line {start_line} is beyond EOF: file has "
995
+ f"{len(lines)} lines; {_range_hint(lines, start_line, end_line)}"
996
+ )
997
+ if end_line > len(lines):
998
+ raise ToolError(
999
+ f"end_line {end_line} is beyond EOF: file has {len(lines)} "
1000
+ f"lines; {_range_hint(lines, start_line, end_line)}"
1001
+ )
1002
+ start_index = start_line - 1
1003
+ end_index = end_line
1004
+ prefix = "".join(lines[:start_index])
1005
+ span = "".join(lines[start_index:end_index])
1006
+ # An unterminated last line carries no ending of its own, so it
1007
+ # inherits the file's; `_detect_newline` would fall back to LF and
1008
+ # write LF lines into a CRLF file.
1009
+ span_newline = _detect_newline_optional(span) or default_newline
1010
+ replacement = _normalize_newlines(new_text, span_newline)
1011
+ if replacement:
1012
+ if (
1013
+ prefix
1014
+ and not prefix.endswith(("\r", "\n"))
1015
+ and not replacement.startswith(("\r", "\n"))
1016
+ ):
1017
+ # The insertion point follows a final line that has no
1018
+ # terminator; without one the new text is glued onto it.
1019
+ replacement = span_newline + replacement
1020
+ region_has_terminator = span.endswith(("\r", "\n"))
1021
+ following_exists = end_index < len(lines)
1022
+ if (
1023
+ region_has_terminator or following_exists
1024
+ ) and not replacement.endswith(("\r", "\n")):
1025
+ # A patch block is line-oriented. Without this, replacing
1026
+ # line 7 of a 20-line file would glue line 8 onto it.
1027
+ replacement += span_newline
1028
+ updated = prefix + replacement + "".join(lines[end_index:])
1029
+ lines_removed = end_index - start_index
1030
+ lines_added = len(new_text.splitlines())
1031
+ else:
1032
+ if not isinstance(old_text, str) or not old_text:
1033
+ raise ToolError(
1034
+ "old_text must be a non-empty string when start_line/end_line "
1035
+ "are omitted"
1036
+ )
1037
+ # Normalize the search text's newlines, then match any physical
1038
+ # ending in the file. This lets an LF snippet edit a CRLF file.
1039
+ needle = old_text.replace("\r\n", "\n").replace("\r", "\n")
1040
+ pattern = re.compile(
1041
+ r"(?:\r\n|\r|\n)".join(re.escape(part) for part in needle.split("\n"))
1042
+ )
1043
+ # Do not materialize every match: a one-character needle in a
1044
+ # multi-megabyte file would allocate millions of match objects even
1045
+ # when only the first occurrence is replaced.
1046
+ first = pattern.search(original)
1047
+ if first is None:
1048
+ raise ToolError(_not_found_message(target, original, old_text))
1049
+ second = pattern.search(original, first.end())
1050
+ if second is not None and not replace_all:
1051
+ locations = [
1052
+ _line_number_at(original, match.start())
1053
+ for match in (first, second, pattern.search(original, second.end()))
1054
+ if match is not None
1055
+ ]
1056
+ raise ToolError(
1057
+ f"old_text occurs more than once (first at lines "
1058
+ f"{', '.join(str(line) for line in locations)}); make it "
1059
+ "unique, use start_line/end_line, or set replace_all"
1060
+ )
1061
+
1062
+ # Replace only the matched spans so untouched lines keep their own
1063
+ # endings.
1064
+ normalized_new = _normalize_newlines(new_text, "\n")
1065
+
1066
+ def replacement_for(span: str) -> str:
1067
+ if "\r\n" in span:
1068
+ span_newline = "\r\n"
1069
+ elif "\r" in span:
1070
+ span_newline = "\r"
1071
+ elif "\n" in span:
1072
+ span_newline = "\n"
1073
+ else:
1074
+ span_newline = default_newline
1075
+ return normalized_new.replace("\n", span_newline)
1076
+
1077
+ if replace_all:
1078
+ pieces: list[str] = []
1079
+ last = 0
1080
+ count = 0
1081
+ lines_removed = 0
1082
+ lines_added = 0
1083
+ for count, match in enumerate(pattern.finditer(original), start=1):
1084
+ replacement = replacement_for(match.group(0))
1085
+ pieces.append(original[last : match.start()])
1086
+ pieces.append(replacement)
1087
+ lines_removed += len(match.group(0).splitlines())
1088
+ lines_added += len(replacement.splitlines())
1089
+ last = match.end()
1090
+ pieces.append(original[last:])
1091
+ updated = "".join(pieces)
1092
+ else:
1093
+ replacement = replacement_for(first.group(0))
1094
+ updated = (
1095
+ original[: first.start()] + replacement + original[first.end() :]
1096
+ )
1097
+ count = 1
1098
+ lines_removed = len(first.group(0).splitlines())
1099
+ lines_added = len(replacement.splitlines())
1100
+ if bom:
1101
+ updated = bom + updated
1102
+ try:
1103
+ encoded = updated.encode("utf-8")
1104
+ except UnicodeEncodeError as exc:
1105
+ raise ToolError(f"edited content is not valid UTF-8 text: {exc}") from exc
1106
+ if len(encoded) > self.max_file_bytes:
1107
+ raise ToolError(
1108
+ f"edited file is too large: {len(encoded)} bytes exceeds "
1109
+ f"tools.max_file_bytes ({self.max_file_bytes})"
1110
+ )
1111
+ mode = target.stat().st_mode & 0o777
1112
+ # The rollback image has to be the original bytes, BOM included.
1113
+ original_bytes = (bom + original).encode("utf-8")
1114
+ before, _ = self._python_diagnostics(target)
1115
+ self._write_atomic(target, updated, mode=mode)
1116
+ warnings = self._validate_python_write(
1117
+ target,
1118
+ before,
1119
+ updated,
1120
+ original_bytes=original_bytes,
1121
+ original_mode=mode,
1122
+ )
1123
+ result: dict[str, Any] = {
1124
+ "status": "success",
1125
+ "path": str(target),
1126
+ "mode": "lines" if line_mode else "exact",
1127
+ "lines_added": lines_added,
1128
+ "lines_removed": lines_removed,
1129
+ "bytes": len(encoded),
1130
+ "line_ending": _line_ending_name(default_newline),
1131
+ }
1132
+ if line_mode:
1133
+ result["start_line"] = start_line
1134
+ result["end_line"] = end_line
1135
+ else:
1136
+ result["replacements"] = count if replace_all else 1
1137
+ if warnings:
1138
+ result["warnings"] = warnings
1139
+ return result