pi-durable-python 1.3.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.
Files changed (90) hide show
  1. pi_durable/__init__.py +320 -0
  2. pi_durable/context.py +328 -0
  3. pi_durable/delta.py +269 -0
  4. pi_durable/documents.py +366 -0
  5. pi_durable/entries.py +66 -0
  6. pi_durable/env/__init__.py +408 -0
  7. pi_durable/env/decode.py +48 -0
  8. pi_durable/env/line_scan.py +190 -0
  9. pi_durable/env/posix.py +1547 -0
  10. pi_durable/errors.py +89 -0
  11. pi_durable/harness/__init__.py +1 -0
  12. pi_durable/harness/agent.py +344 -0
  13. pi_durable/harness/compaction.py +673 -0
  14. pi_durable/harness/context.py +515 -0
  15. pi_durable/harness/define.py +111 -0
  16. pi_durable/harness/events.py +766 -0
  17. pi_durable/harness/generation.py +1079 -0
  18. pi_durable/harness/harness.py +570 -0
  19. pi_durable/harness/inbox.py +255 -0
  20. pi_durable/harness/live.py +374 -0
  21. pi_durable/harness/output.py +467 -0
  22. pi_durable/harness/prompt.py +288 -0
  23. pi_durable/harness/provider.py +78 -0
  24. pi_durable/harness/registry.py +136 -0
  25. pi_durable/harness/scheduler.py +1977 -0
  26. pi_durable/harness/submissions.py +357 -0
  27. pi_durable/harness/task_graph.py +334 -0
  28. pi_durable/harness/tool.py +1365 -0
  29. pi_durable/harness/types.py +772 -0
  30. pi_durable/harness/usage.py +88 -0
  31. pi_durable/harness/util.py +204 -0
  32. pi_durable/harness/validation.py +460 -0
  33. pi_durable/harness/view.py +324 -0
  34. pi_durable/ids.py +63 -0
  35. pi_durable/json.py +186 -0
  36. pi_durable/providers/__init__.py +362 -0
  37. pi_durable/providers/anthropic.py +603 -0
  38. pi_durable/providers/base.py +97 -0
  39. pi_durable/providers/json_parse.py +117 -0
  40. pi_durable/providers/openai.py +574 -0
  41. pi_durable/providers/request.py +35 -0
  42. pi_durable/providers/retry.py +127 -0
  43. pi_durable/providers/stream.py +183 -0
  44. pi_durable/providers/transcript.py +224 -0
  45. pi_durable/providers/usage.py +58 -0
  46. pi_durable/py.typed +0 -0
  47. pi_durable/search.py +115 -0
  48. pi_durable/session/__init__.py +5 -0
  49. pi_durable/session/drafts.py +762 -0
  50. pi_durable/session/forks.py +109 -0
  51. pi_durable/session/observation.py +374 -0
  52. pi_durable/session/session.py +1193 -0
  53. pi_durable/session/transaction.py +1088 -0
  54. pi_durable/storage/__init__.py +5 -0
  55. pi_durable/storage/jsonl/__init__.py +15 -0
  56. pi_durable/storage/jsonl/storage.py +958 -0
  57. pi_durable/storage/memory.py +889 -0
  58. pi_durable/storage/scan.py +57 -0
  59. pi_durable/storage/sqlite/__init__.py +26 -0
  60. pi_durable/storage/sqlite/database.py +62 -0
  61. pi_durable/storage/sqlite/migrations.py +191 -0
  62. pi_durable/storage/sqlite/posix.py +314 -0
  63. pi_durable/storage/sqlite/schema.py +97 -0
  64. pi_durable/storage/sqlite/storage.py +1130 -0
  65. pi_durable/tasks.py +89 -0
  66. pi_durable/testing/__init__.py +41 -0
  67. pi_durable/testing/assertions.py +44 -0
  68. pi_durable/testing/env_conformance.py +828 -0
  69. pi_durable/testing/runner.py +64 -0
  70. pi_durable/testing/storage_conformance.py +2008 -0
  71. pi_durable/testing/types.py +99 -0
  72. pi_durable/tool_search/__init__.py +159 -0
  73. pi_durable/tools/__init__.py +81 -0
  74. pi_durable/tools/_values.py +43 -0
  75. pi_durable/tools/bash.py +250 -0
  76. pi_durable/tools/edit.py +214 -0
  77. pi_durable/tools/edit_diff.py +452 -0
  78. pi_durable/tools/env.py +19 -0
  79. pi_durable/tools/file_mutation_queue.py +68 -0
  80. pi_durable/tools/image.py +248 -0
  81. pi_durable/tools/image_processor.py +77 -0
  82. pi_durable/tools/path_utils.py +111 -0
  83. pi_durable/tools/read.py +506 -0
  84. pi_durable/tools/write.py +71 -0
  85. pi_durable/truncate.py +185 -0
  86. pi_durable/types.py +1297 -0
  87. pi_durable_python-1.3.0.dist-info/METADATA +276 -0
  88. pi_durable_python-1.3.0.dist-info/RECORD +90 -0
  89. pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
  90. pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,214 @@
1
+ """The ``edit`` coding tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from typing import Any, TypedDict, cast
8
+
9
+ from ..context import Context
10
+ from ..env import FileError
11
+ from ..harness.define import define_tool
12
+ from ..harness.types import ToolExecutionApi, ToolExecutionResult, ToolRegistration
13
+ from ..json import JsonValue
14
+ from .edit_diff import (
15
+ apply_edits_to_normalized_content,
16
+ detect_line_ending,
17
+ generate_diff_string,
18
+ generate_unified_patch,
19
+ normalize_to_lf,
20
+ restore_line_endings,
21
+ strip_bom,
22
+ )
23
+ from .env import require_env
24
+ from .file_mutation_queue import with_file_mutation_queue
25
+ from .path_utils import resolve_tool_path
26
+
27
+
28
+ class ReplaceEdit(TypedDict):
29
+ oldText: str
30
+ newText: str
31
+
32
+
33
+ class EditToolInput(TypedDict):
34
+ """Arguments of one ``edit`` call."""
35
+
36
+ path: str
37
+ edits: list[ReplaceEdit]
38
+
39
+
40
+ class EditToolDetails(TypedDict, total=False):
41
+ """The two diff renderings, and where the first change lands in the new file."""
42
+
43
+ diff: str
44
+ patch: str
45
+ firstChangedLine: int
46
+
47
+
48
+ REPLACE_EDIT_SCHEMA: dict[str, Any] = {
49
+ "type": "object",
50
+ "properties": {
51
+ "oldText": {
52
+ "type": "string",
53
+ "description": (
54
+ "Exact text for one targeted replacement. It must be unique in the original file "
55
+ "and must not overlap with any other edits[].oldText in the same call."
56
+ ),
57
+ },
58
+ "newText": {"type": "string", "description": "Replacement text for this targeted edit."},
59
+ },
60
+ "required": ["oldText", "newText"],
61
+ }
62
+
63
+ EDIT_SCHEMA: dict[str, Any] = {
64
+ "type": "object",
65
+ "properties": {
66
+ "path": {
67
+ "type": "string",
68
+ "description": "Path to the file to edit (relative or absolute)",
69
+ },
70
+ "edits": {
71
+ "type": "array",
72
+ "items": REPLACE_EDIT_SCHEMA,
73
+ "description": (
74
+ "One or more targeted replacements. Each edit is matched against the original "
75
+ "file, not incrementally. Do not include overlapping or nested edits. If two "
76
+ "changes touch the same block or nearby lines, merge them into one edit instead."
77
+ ),
78
+ },
79
+ },
80
+ "required": ["path", "edits"],
81
+ }
82
+
83
+
84
+ def _is_single_edit(value: Any) -> bool:
85
+ if not isinstance(value, Mapping) or isinstance(value, (list, tuple)):
86
+ return False
87
+ return isinstance(value.get("oldText"), str) and isinstance(value.get("newText"), str)
88
+
89
+
90
+ def prepare_edit_arguments(input: Any) -> Any:
91
+ """Repair shapes models commonly send, without changing the call's arguments in place."""
92
+
93
+ if not isinstance(input, Mapping) or isinstance(input, (list, tuple)):
94
+ return input
95
+ args: dict[str, Any] = dict(input)
96
+ edits = args.get("edits")
97
+ if isinstance(edits, str):
98
+ try:
99
+ parsed = json.loads(edits)
100
+ except (ValueError, TypeError):
101
+ parsed = None
102
+ if isinstance(parsed, list):
103
+ args["edits"] = parsed
104
+ elif _is_single_edit(parsed):
105
+ args["edits"] = [parsed]
106
+ elif _is_single_edit(edits):
107
+ args["edits"] = [edits]
108
+
109
+ old_text = args.get("oldText")
110
+ new_text = args.get("newText")
111
+ if not isinstance(old_text, str) or not isinstance(new_text, str):
112
+ return args
113
+ merged = list(args["edits"]) if isinstance(args.get("edits"), list) else []
114
+ merged.append({"oldText": old_text, "newText": new_text})
115
+ rest = {key: value for key, value in args.items() if key not in ("oldText", "newText")}
116
+ rest["edits"] = merged
117
+ return rest
118
+
119
+
120
+ def _validate_edit_input(input: Mapping[str, Any]) -> tuple[str, list[dict[str, str]]]:
121
+ edits = input.get("edits")
122
+ if not isinstance(edits, list) or len(edits) == 0:
123
+ raise ValueError("Edit tool input is invalid. edits must contain at least one replacement.")
124
+ return str(input["path"]), [dict(edit) for edit in edits]
125
+
126
+
127
+ def _edit_access_error(path: str, error: FileError) -> RuntimeError:
128
+ message = f"Could not edit file: {path}. Error code: {error.code}."
129
+ raised = RuntimeError(message)
130
+ raised.__cause__ = error
131
+ return raised
132
+
133
+
134
+ def create_edit_tool() -> ToolRegistration:
135
+ """Edit a single file using exact text replacement."""
136
+
137
+ async def execute(
138
+ args: Mapping[str, Any], api: ToolExecutionApi, context: Context
139
+ ) -> ToolExecutionResult:
140
+ path, edits = _validate_edit_input(args)
141
+ env = require_env(api)
142
+ absolute_path = await resolve_tool_path(env, path, context)
143
+
144
+ async def mutate() -> ToolExecutionResult:
145
+ if context.abort_signal is not None and context.abort_signal.aborted:
146
+ raise RuntimeError("Operation aborted")
147
+ info = await env.file_info(absolute_path, context)
148
+ if not info.ok:
149
+ raise _edit_access_error(path, info.error)
150
+ if info.value.get("kind") not in ("file", "symlink"):
151
+ raise RuntimeError(f"Could not edit file: {path}. Path is not a file.")
152
+
153
+ read_result = await env.read_text_file(absolute_path, context)
154
+ if not read_result.ok:
155
+ raise _edit_access_error(path, read_result.error)
156
+ if context.abort_signal is not None and context.abort_signal.aborted:
157
+ raise RuntimeError("Operation aborted")
158
+
159
+ bom, content = strip_bom(read_result.value)
160
+ original_ending = detect_line_ending(content)
161
+ normalized_content = normalize_to_lf(content)
162
+ applied = apply_edits_to_normalized_content(normalized_content, edits, path)
163
+ if context.abort_signal is not None and context.abort_signal.aborted:
164
+ raise RuntimeError("Operation aborted")
165
+
166
+ final_content = bom + restore_line_endings(applied.new_content, original_ending)
167
+ write_result = await env.write_file(absolute_path, final_content, context)
168
+ if not write_result.ok:
169
+ raise _edit_access_error(path, write_result.error)
170
+ if context.abort_signal is not None and context.abort_signal.aborted:
171
+ raise RuntimeError("Operation aborted")
172
+
173
+ diff = generate_diff_string(applied.base_content, applied.new_content)
174
+ details: EditToolDetails = {
175
+ "diff": diff.diff,
176
+ "patch": generate_unified_patch(path, applied.base_content, applied.new_content),
177
+ }
178
+ if diff.first_changed_line is not None:
179
+ details["firstChangedLine"] = diff.first_changed_line
180
+ return {
181
+ "output": [
182
+ {"type": "text", "text": f"Successfully replaced {len(edits)} block(s) in {path}."}
183
+ ],
184
+ "details": cast(JsonValue, details),
185
+ }
186
+
187
+ return await with_file_mutation_queue(env, absolute_path, mutate, context)
188
+
189
+ return define_tool(
190
+ {
191
+ "name": "edit",
192
+ "description": (
193
+ "Edit a single file using exact text replacement. Every edits[].oldText must "
194
+ "match a unique, non-overlapping region of the original file. If two changes "
195
+ "affect the same block or nearby lines, merge them into one edit instead of "
196
+ "emitting overlapping edits. Do not include large unchanged regions just to "
197
+ "connect distant changes."
198
+ ),
199
+ "parameters": EDIT_SCHEMA,
200
+ "prepare_arguments": prepare_edit_arguments,
201
+ "execute": execute,
202
+ }
203
+ )
204
+
205
+
206
+ __all__ = [
207
+ "EDIT_SCHEMA",
208
+ "REPLACE_EDIT_SCHEMA",
209
+ "EditToolDetails",
210
+ "EditToolInput",
211
+ "ReplaceEdit",
212
+ "create_edit_tool",
213
+ "prepare_edit_arguments",
214
+ ]
@@ -0,0 +1,452 @@
1
+ """Exact-text edit matching, fuzzy normalization, and diff rendering.
2
+
3
+ Port of the v1.3.0 ``src/tools/edit-diff.ts``. The JavaScript implementation uses the ``diff``
4
+ package; the Python port uses :mod:`difflib` and renders the same patch and display formats.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import difflib
10
+ import re
11
+ import unicodedata
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+
16
+ def detect_line_ending(content: str) -> str:
17
+ """The file's line ending: ``\\r\\n`` when it comes first, otherwise ``\\n``."""
18
+
19
+ crlf_index = content.find("\r\n")
20
+ lf_index = content.find("\n")
21
+ if lf_index == -1 or crlf_index == -1:
22
+ return "\n"
23
+ return "\r\n" if crlf_index < lf_index else "\n"
24
+
25
+
26
+ def normalize_to_lf(text: str) -> str:
27
+ return text.replace("\r\n", "\n").replace("\r", "\n")
28
+
29
+
30
+ def restore_line_endings(text: str, ending: str) -> str:
31
+ return text.replace("\n", "\r\n") if ending == "\r\n" else text
32
+
33
+
34
+ def normalize_for_fuzzy_match(text: str) -> str:
35
+ """Strip trailing whitespace and normalize quotes, dashes, and special spaces."""
36
+
37
+ normalized = unicodedata.normalize("NFKC", text)
38
+ normalized = "\n".join(line.rstrip() for line in normalized.split("\n"))
39
+ normalized = re.sub("[\u2018\u2019\u201a\u201b]", "'", normalized)
40
+ normalized = re.sub("[\u201c\u201d\u201e\u201f]", '"', normalized)
41
+ normalized = re.sub("[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]", "-", normalized)
42
+ normalized = re.sub("[\u00a0\u2002-\u200a\u202f\u205f\u3000]", " ", normalized)
43
+ return normalized
44
+
45
+
46
+ def _split_lines_with_endings(content: str) -> list[str]:
47
+ return re.findall(r"[^\n]*\n|[^\n]+", content)
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class _LineSpan:
52
+ start: int
53
+ end: int
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class _Replacement:
58
+ match_index: int
59
+ match_length: int
60
+ new_text: str
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class MatchedEdit:
65
+ edit_index: int
66
+ match_index: int
67
+ match_length: int
68
+ new_text: str
69
+
70
+
71
+ def _line_spans(content: str) -> list[_LineSpan]:
72
+ spans: list[_LineSpan] = []
73
+ offset = 0
74
+ for line in _split_lines_with_endings(content):
75
+ spans.append(_LineSpan(offset, offset + len(line)))
76
+ offset += len(line)
77
+ return spans
78
+
79
+
80
+ def _replacement_line_range(lines: list[_LineSpan], replacement: _Replacement) -> tuple[int, int]:
81
+ replacement_start = replacement.match_index
82
+ replacement_end = replacement.match_index + replacement.match_length
83
+
84
+ start_line = -1
85
+ for index, line in enumerate(lines):
86
+ if line.start <= replacement_start < line.end:
87
+ start_line = index
88
+ break
89
+ if start_line == -1:
90
+ raise ValueError("Replacement range is outside the base content.")
91
+
92
+ end_line = start_line
93
+ while end_line < len(lines) and lines[end_line].end < replacement_end:
94
+ end_line += 1
95
+ if end_line >= len(lines):
96
+ raise ValueError("Replacement range is outside the base content.")
97
+ return start_line, end_line + 1
98
+
99
+
100
+ def _apply_replacements(content: str, replacements: list[_Replacement], offset: int = 0) -> str:
101
+ result = content
102
+ for replacement in reversed(replacements):
103
+ match_index = replacement.match_index - offset
104
+ result = (
105
+ result[:match_index] + replacement.new_text + result[match_index + replacement.match_length :]
106
+ )
107
+ return result
108
+
109
+
110
+ def apply_replacements_preserving_unchanged_lines(
111
+ original_content: str,
112
+ base_content: str,
113
+ replacements: list[_Replacement],
114
+ ) -> str:
115
+ """Apply normalized-view replacements while keeping untouched lines from the original."""
116
+
117
+ original_lines = _split_lines_with_endings(original_content)
118
+ base_lines = _line_spans(base_content)
119
+ if len(original_lines) != len(base_lines):
120
+ raise ValueError(
121
+ "Cannot preserve unchanged lines because the base content has a different line count."
122
+ )
123
+
124
+ groups: list[dict[str, Any]] = []
125
+ for replacement in sorted(replacements, key=lambda item: item.match_index):
126
+ start_line, end_line = _replacement_line_range(base_lines, replacement)
127
+ current = groups[-1] if groups else None
128
+ if current is not None and start_line < current["end_line"]:
129
+ current["end_line"] = max(current["end_line"], end_line)
130
+ current["replacements"].append(replacement)
131
+ continue
132
+ groups.append({"start_line": start_line, "end_line": end_line, "replacements": [replacement]})
133
+
134
+ original_line_index = 0
135
+ result = ""
136
+ for group in groups:
137
+ result += "".join(original_lines[original_line_index : group["start_line"]])
138
+ group_start = base_lines[group["start_line"]].start
139
+ group_end = base_lines[group["end_line"] - 1].end
140
+ result += _apply_replacements(base_content[group_start:group_end], group["replacements"], group_start)
141
+ original_line_index = group["end_line"]
142
+ result += "".join(original_lines[original_line_index:])
143
+ return result
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class FuzzyMatchResult:
148
+ found: bool
149
+ index: int
150
+ match_length: int
151
+ used_fuzzy_match: bool
152
+ content_for_replacement: str
153
+
154
+
155
+ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
156
+ """Find ``old_text`` exactly first, then in the fuzzy-normalized content."""
157
+
158
+ exact_index = content.find(old_text)
159
+ if exact_index != -1:
160
+ return FuzzyMatchResult(True, exact_index, len(old_text), False, content)
161
+
162
+ fuzzy_content = normalize_for_fuzzy_match(content)
163
+ fuzzy_old_text = normalize_for_fuzzy_match(old_text)
164
+ fuzzy_index = fuzzy_content.find(fuzzy_old_text)
165
+ if fuzzy_index == -1:
166
+ return FuzzyMatchResult(False, -1, 0, False, content)
167
+ return FuzzyMatchResult(True, fuzzy_index, len(fuzzy_old_text), True, fuzzy_content)
168
+
169
+
170
+ def strip_bom(content: str) -> tuple[str, str]:
171
+ """Split a leading byte-order mark from the text."""
172
+
173
+ if content.startswith("\ufeff"):
174
+ return "\ufeff", content[1:]
175
+ return "", content
176
+
177
+
178
+ def _count_occurrences(content: str, old_text: str) -> int:
179
+ return len(normalize_for_fuzzy_match(content).split(normalize_for_fuzzy_match(old_text))) - 1
180
+
181
+
182
+ def _not_found_error(path: str, edit_index: int, total_edits: int) -> ValueError:
183
+ if total_edits == 1:
184
+ return ValueError(
185
+ f"Could not find the exact text in {path}. The old text must match exactly including "
186
+ "all whitespace and newlines."
187
+ )
188
+ return ValueError(
189
+ f"Could not find edits[{edit_index}] in {path}. The oldText must match exactly including "
190
+ "all whitespace and newlines."
191
+ )
192
+
193
+
194
+ def _duplicate_error(path: str, edit_index: int, total_edits: int, occurrences: int) -> ValueError:
195
+ if total_edits == 1:
196
+ return ValueError(
197
+ f"Found {occurrences} occurrences of the text in {path}. The text must be unique. "
198
+ "Please provide more context to make it unique."
199
+ )
200
+ return ValueError(
201
+ f"Found {occurrences} occurrences of edits[{edit_index}] in {path}. Each oldText must be "
202
+ "unique. Please provide more context to make it unique."
203
+ )
204
+
205
+
206
+ def _empty_old_text_error(path: str, edit_index: int, total_edits: int) -> ValueError:
207
+ if total_edits == 1:
208
+ return ValueError(f"oldText must not be empty in {path}.")
209
+ return ValueError(f"edits[{edit_index}].oldText must not be empty in {path}.")
210
+
211
+
212
+ def _no_change_error(path: str, total_edits: int) -> ValueError:
213
+ if total_edits == 1:
214
+ return ValueError(
215
+ f"No changes made to {path}. The replacement produced identical content. This might "
216
+ "indicate an issue with special characters or the text not existing as expected."
217
+ )
218
+ return ValueError(f"No changes made to {path}. The replacements produced identical content.")
219
+
220
+
221
+ @dataclass(frozen=True, slots=True)
222
+ class AppliedEdits:
223
+ base_content: str
224
+ new_content: str
225
+
226
+
227
+ def apply_edits_to_normalized_content(
228
+ normalized_content: str,
229
+ edits: list[dict[str, str]],
230
+ path: str,
231
+ ) -> AppliedEdits:
232
+ """Apply all edits to LF-normalized content; every edit matches the original content."""
233
+
234
+ normalized_edits = [
235
+ {"old_text": normalize_to_lf(edit["oldText"]), "new_text": normalize_to_lf(edit["newText"])}
236
+ for edit in edits
237
+ ]
238
+ for index, edit in enumerate(normalized_edits):
239
+ if edit["old_text"] == "":
240
+ raise _empty_old_text_error(path, index, len(normalized_edits))
241
+
242
+ initial_matches = [fuzzy_find_text(normalized_content, edit["old_text"]) for edit in normalized_edits]
243
+ used_fuzzy_match = any(match.used_fuzzy_match for match in initial_matches)
244
+ replacement_base = (
245
+ normalize_for_fuzzy_match(normalized_content) if used_fuzzy_match else normalized_content
246
+ )
247
+
248
+ matched: list[MatchedEdit] = []
249
+ for index, edit in enumerate(normalized_edits):
250
+ match = fuzzy_find_text(replacement_base, edit["old_text"])
251
+ if not match.found:
252
+ raise _not_found_error(path, index, len(normalized_edits))
253
+ occurrences = _count_occurrences(replacement_base, edit["old_text"])
254
+ if occurrences > 1:
255
+ raise _duplicate_error(path, index, len(normalized_edits), occurrences)
256
+ matched.append(MatchedEdit(index, match.index, match.match_length, edit["new_text"]))
257
+
258
+ matched.sort(key=lambda item: item.match_index)
259
+ for previous, current in zip(matched, matched[1:], strict=False):
260
+ if previous.match_index + previous.match_length > current.match_index:
261
+ raise ValueError(
262
+ f"edits[{previous.edit_index}] and edits[{current.edit_index}] overlap in "
263
+ f"{path}. Merge them into one edit or target disjoint regions."
264
+ )
265
+
266
+ base_content = normalized_content
267
+ replacements = [_Replacement(item.match_index, item.match_length, item.new_text) for item in matched]
268
+ new_content = (
269
+ apply_replacements_preserving_unchanged_lines(normalized_content, replacement_base, replacements)
270
+ if used_fuzzy_match
271
+ else _apply_replacements(replacement_base, replacements)
272
+ )
273
+ if base_content == new_content:
274
+ raise _no_change_error(path, len(normalized_edits))
275
+ return AppliedEdits(base_content, new_content)
276
+
277
+
278
+ def generate_unified_patch(path: str, old_content: str, new_content: str, context_lines: int = 4) -> str:
279
+ """Generate a standard unified patch with the ``diff`` package's file-header-only format."""
280
+
281
+ old_lines = _split_lines_with_endings(old_content)
282
+ new_lines = _split_lines_with_endings(new_content)
283
+ matcher = difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False)
284
+ output = [f"--- {path}", f"+++ {path}"]
285
+
286
+ def emit(prefix: str, line: str) -> None:
287
+ if line.endswith("\n"):
288
+ output.append(prefix + line[:-1])
289
+ else:
290
+ output.append(prefix + line)
291
+ output.append("\")
292
+
293
+ for group in matcher.get_grouped_opcodes(context_lines):
294
+ old_start, old_end = group[0][1], group[-1][2]
295
+ new_start, new_end = group[0][3], group[-1][4]
296
+ old_count = old_end - old_start
297
+ new_count = new_end - new_start
298
+ old_first = old_start + 1
299
+ new_first = new_start + 1
300
+ if old_count == 0:
301
+ old_first -= 1
302
+ if new_count == 0:
303
+ new_first -= 1
304
+ output.append(f"@@ -{old_first},{old_count} +{new_first},{new_count} @@")
305
+ for tag, i1, i2, j1, j2 in group:
306
+ if tag == "equal":
307
+ for line in old_lines[i1:i2]:
308
+ emit(" ", line)
309
+ continue
310
+ for line in old_lines[i1:i2]:
311
+ emit("-", line)
312
+ for line in new_lines[j1:j2]:
313
+ emit("+", line)
314
+ return "\n".join(output) + "\n"
315
+
316
+
317
+ @dataclass(frozen=True, slots=True)
318
+ class DisplayDiff:
319
+ diff: str
320
+ first_changed_line: int | None
321
+
322
+
323
+ @dataclass(slots=True)
324
+ class _Part:
325
+ value: str
326
+ added: bool = False
327
+ removed: bool = False
328
+
329
+
330
+ def _parts(old_content: str, new_content: str) -> list[_Part]:
331
+ old_lines = old_content.splitlines(keepends=True)
332
+ new_lines = new_content.splitlines(keepends=True)
333
+ matcher = difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False)
334
+ parts: list[_Part] = []
335
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
336
+ if tag == "equal":
337
+ parts.append(_Part("".join(old_lines[i1:i2])))
338
+ elif tag == "delete":
339
+ parts.append(_Part("".join(old_lines[i1:i2]), removed=True))
340
+ elif tag == "insert":
341
+ parts.append(_Part("".join(new_lines[j1:j2]), added=True))
342
+ else:
343
+ parts.append(_Part("".join(old_lines[i1:i2]), removed=True))
344
+ parts.append(_Part("".join(new_lines[j1:j2]), added=True))
345
+ return parts
346
+
347
+
348
+ def generate_diff_string(old_content: str, new_content: str, context_lines: int = 4) -> DisplayDiff:
349
+ """Generate a display-oriented diff with line numbers and context."""
350
+
351
+ parts = _parts(old_content, new_content)
352
+ output: list[str] = []
353
+ old_lines = old_content.split("\n")
354
+ new_lines = new_content.split("\n")
355
+ line_number_width = len(str(max(len(old_lines), len(new_lines))))
356
+ old_line_number = 1
357
+ new_line_number = 1
358
+ last_was_change = False
359
+ first_changed_line: int | None = None
360
+
361
+ for index, part in enumerate(parts):
362
+ raw = part.value.split("\n")
363
+ if raw and raw[-1] == "":
364
+ raw.pop()
365
+
366
+ if part.added or part.removed:
367
+ if first_changed_line is None:
368
+ first_changed_line = new_line_number
369
+ for line in raw:
370
+ if part.added:
371
+ number = str(new_line_number).rjust(line_number_width)
372
+ output.append(f"+{number} {line}")
373
+ new_line_number += 1
374
+ else:
375
+ number = str(old_line_number).rjust(line_number_width)
376
+ output.append(f"-{number} {line}")
377
+ old_line_number += 1
378
+ last_was_change = True
379
+ continue
380
+
381
+ next_is_change = index < len(parts) - 1 and (parts[index + 1].added or parts[index + 1].removed)
382
+ if last_was_change and next_is_change:
383
+ if len(raw) <= context_lines * 2:
384
+ for line in raw:
385
+ number = str(old_line_number).rjust(line_number_width)
386
+ output.append(f" {number} {line}")
387
+ old_line_number += 1
388
+ new_line_number += 1
389
+ else:
390
+ leading = raw[:context_lines]
391
+ trailing = raw[-context_lines:]
392
+ skipped = len(raw) - len(leading) - len(trailing)
393
+ for line in leading:
394
+ number = str(old_line_number).rjust(line_number_width)
395
+ output.append(f" {number} {line}")
396
+ old_line_number += 1
397
+ new_line_number += 1
398
+ output.append(f" {' ' * line_number_width} ...")
399
+ old_line_number += skipped
400
+ new_line_number += skipped
401
+ for line in trailing:
402
+ number = str(old_line_number).rjust(line_number_width)
403
+ output.append(f" {number} {line}")
404
+ old_line_number += 1
405
+ new_line_number += 1
406
+ elif last_was_change:
407
+ shown = raw[:context_lines]
408
+ skipped = len(raw) - len(shown)
409
+ for line in shown:
410
+ number = str(old_line_number).rjust(line_number_width)
411
+ output.append(f" {number} {line}")
412
+ old_line_number += 1
413
+ new_line_number += 1
414
+ if skipped > 0:
415
+ output.append(f" {' ' * line_number_width} ...")
416
+ old_line_number += skipped
417
+ new_line_number += skipped
418
+ elif next_is_change:
419
+ skipped = max(0, len(raw) - context_lines)
420
+ if skipped > 0:
421
+ output.append(f" {' ' * line_number_width} ...")
422
+ old_line_number += skipped
423
+ new_line_number += skipped
424
+ for line in raw[skipped:]:
425
+ number = str(old_line_number).rjust(line_number_width)
426
+ output.append(f" {number} {line}")
427
+ old_line_number += 1
428
+ new_line_number += 1
429
+ else:
430
+ old_line_number += len(raw)
431
+ new_line_number += len(raw)
432
+ last_was_change = False
433
+
434
+ return DisplayDiff("\n".join(output), first_changed_line)
435
+
436
+
437
+ __all__ = [
438
+ "AppliedEdits",
439
+ "DisplayDiff",
440
+ "FuzzyMatchResult",
441
+ "MatchedEdit",
442
+ "apply_edits_to_normalized_content",
443
+ "apply_replacements_preserving_unchanged_lines",
444
+ "detect_line_ending",
445
+ "fuzzy_find_text",
446
+ "generate_diff_string",
447
+ "generate_unified_patch",
448
+ "normalize_for_fuzzy_match",
449
+ "normalize_to_lf",
450
+ "restore_line_endings",
451
+ "strip_bom",
452
+ ]
@@ -0,0 +1,19 @@
1
+ """Execution-environment access shared by the coding tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..env import ExecutionEnv
8
+
9
+
10
+ def require_env(api: Any) -> ExecutionEnv:
11
+ """The call's execution environment; a tool without one fails with an ordinary error."""
12
+
13
+ env = getattr(api, "env", None)
14
+ if env is None:
15
+ raise RuntimeError("No execution environment is configured")
16
+ return env
17
+
18
+
19
+ __all__ = ["require_env"]