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.
- pi_durable/__init__.py +320 -0
- pi_durable/context.py +328 -0
- pi_durable/delta.py +269 -0
- pi_durable/documents.py +366 -0
- pi_durable/entries.py +66 -0
- pi_durable/env/__init__.py +408 -0
- pi_durable/env/decode.py +48 -0
- pi_durable/env/line_scan.py +190 -0
- pi_durable/env/posix.py +1547 -0
- pi_durable/errors.py +89 -0
- pi_durable/harness/__init__.py +1 -0
- pi_durable/harness/agent.py +344 -0
- pi_durable/harness/compaction.py +673 -0
- pi_durable/harness/context.py +515 -0
- pi_durable/harness/define.py +111 -0
- pi_durable/harness/events.py +766 -0
- pi_durable/harness/generation.py +1079 -0
- pi_durable/harness/harness.py +570 -0
- pi_durable/harness/inbox.py +255 -0
- pi_durable/harness/live.py +374 -0
- pi_durable/harness/output.py +467 -0
- pi_durable/harness/prompt.py +288 -0
- pi_durable/harness/provider.py +78 -0
- pi_durable/harness/registry.py +136 -0
- pi_durable/harness/scheduler.py +1977 -0
- pi_durable/harness/submissions.py +357 -0
- pi_durable/harness/task_graph.py +334 -0
- pi_durable/harness/tool.py +1365 -0
- pi_durable/harness/types.py +772 -0
- pi_durable/harness/usage.py +88 -0
- pi_durable/harness/util.py +204 -0
- pi_durable/harness/validation.py +460 -0
- pi_durable/harness/view.py +324 -0
- pi_durable/ids.py +63 -0
- pi_durable/json.py +186 -0
- pi_durable/providers/__init__.py +362 -0
- pi_durable/providers/anthropic.py +603 -0
- pi_durable/providers/base.py +97 -0
- pi_durable/providers/json_parse.py +117 -0
- pi_durable/providers/openai.py +574 -0
- pi_durable/providers/request.py +35 -0
- pi_durable/providers/retry.py +127 -0
- pi_durable/providers/stream.py +183 -0
- pi_durable/providers/transcript.py +224 -0
- pi_durable/providers/usage.py +58 -0
- pi_durable/py.typed +0 -0
- pi_durable/search.py +115 -0
- pi_durable/session/__init__.py +5 -0
- pi_durable/session/drafts.py +762 -0
- pi_durable/session/forks.py +109 -0
- pi_durable/session/observation.py +374 -0
- pi_durable/session/session.py +1193 -0
- pi_durable/session/transaction.py +1088 -0
- pi_durable/storage/__init__.py +5 -0
- pi_durable/storage/jsonl/__init__.py +15 -0
- pi_durable/storage/jsonl/storage.py +958 -0
- pi_durable/storage/memory.py +889 -0
- pi_durable/storage/scan.py +57 -0
- pi_durable/storage/sqlite/__init__.py +26 -0
- pi_durable/storage/sqlite/database.py +62 -0
- pi_durable/storage/sqlite/migrations.py +191 -0
- pi_durable/storage/sqlite/posix.py +314 -0
- pi_durable/storage/sqlite/schema.py +97 -0
- pi_durable/storage/sqlite/storage.py +1130 -0
- pi_durable/tasks.py +89 -0
- pi_durable/testing/__init__.py +41 -0
- pi_durable/testing/assertions.py +44 -0
- pi_durable/testing/env_conformance.py +828 -0
- pi_durable/testing/runner.py +64 -0
- pi_durable/testing/storage_conformance.py +2008 -0
- pi_durable/testing/types.py +99 -0
- pi_durable/tool_search/__init__.py +159 -0
- pi_durable/tools/__init__.py +81 -0
- pi_durable/tools/_values.py +43 -0
- pi_durable/tools/bash.py +250 -0
- pi_durable/tools/edit.py +214 -0
- pi_durable/tools/edit_diff.py +452 -0
- pi_durable/tools/env.py +19 -0
- pi_durable/tools/file_mutation_queue.py +68 -0
- pi_durable/tools/image.py +248 -0
- pi_durable/tools/image_processor.py +77 -0
- pi_durable/tools/path_utils.py +111 -0
- pi_durable/tools/read.py +506 -0
- pi_durable/tools/write.py +71 -0
- pi_durable/truncate.py +185 -0
- pi_durable/types.py +1297 -0
- pi_durable_python-1.3.0.dist-info/METADATA +276 -0
- pi_durable_python-1.3.0.dist-info/RECORD +90 -0
- pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
- pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
pi_durable/tools/edit.py
ADDED
|
@@ -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
|
+
]
|
pi_durable/tools/env.py
ADDED
|
@@ -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"]
|