millforge 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- millforge/__init__.py +1174 -0
- millforge/_forge/LICENSE +21 -0
- millforge/_forge/PROVENANCE.json +295 -0
- millforge/_forge/UPDATE_POLICY.md +24 -0
- millforge/_forge/__init__.py +14 -0
- millforge/_forge/adapter.py +2232 -0
- millforge/_forge/base_runner.py +121 -0
- millforge/_forge/clients/__init__.py +10 -0
- millforge/_forge/clients/base.py +200 -0
- millforge/_forge/context/__init__.py +23 -0
- millforge/_forge/context/manager.py +178 -0
- millforge/_forge/context/strategies.py +335 -0
- millforge/_forge/core/__init__.py +16 -0
- millforge/_forge/core/inference.py +433 -0
- millforge/_forge/core/messages.py +119 -0
- millforge/_forge/core/runner.py +479 -0
- millforge/_forge/core/steps.py +108 -0
- millforge/_forge/core/workflow.py +400 -0
- millforge/_forge/errors.py +222 -0
- millforge/_forge/guardrails/__init__.py +21 -0
- millforge/_forge/guardrails/error_tracker.py +71 -0
- millforge/_forge/guardrails/guardrails.py +194 -0
- millforge/_forge/guardrails/nudge.py +47 -0
- millforge/_forge/guardrails/response_validator.py +119 -0
- millforge/_forge/guardrails/step_enforcer.py +183 -0
- millforge/_forge/prompts/__init__.py +16 -0
- millforge/_forge/prompts/nudges.py +95 -0
- millforge/_forge/prompts/templates.py +285 -0
- millforge/_version.py +3 -0
- millforge/artifacts.py +570 -0
- millforge/base/__init__.py +97 -0
- millforge/base/composition.py +402 -0
- millforge/base/context.py +285 -0
- millforge/base/harness.py +138 -0
- millforge/base/identity.py +465 -0
- millforge/base/options.py +34 -0
- millforge/base/platform.py +17 -0
- millforge/base/prompt.py +317 -0
- millforge/base/runner.py +546 -0
- millforge/compiled_plan.py +970 -0
- millforge/compiler/__init__.py +231 -0
- millforge/compiler/artifact_validation.py +257 -0
- millforge/compiler/canonicalization.py +169 -0
- millforge/compiler/capabilities.py +66 -0
- millforge/compiler/catalogs.py +500 -0
- millforge/compiler/diagnostics.py +491 -0
- millforge/compiler/graph.py +678 -0
- millforge/compiler/lowering.py +198 -0
- millforge/compiler/output.py +692 -0
- millforge/compiler/parsing.py +1424 -0
- millforge/compiler/requests.py +1180 -0
- millforge/compiler/schema_validation.py +272 -0
- millforge/compiler/semantic.py +490 -0
- millforge/compiler/service.py +448 -0
- millforge/compiler/source.py +375 -0
- millforge/compiler/validators.py +184 -0
- millforge/connectors/__init__.py +95 -0
- millforge/connectors/admission.py +801 -0
- millforge/connectors/broker.py +202 -0
- millforge/connectors/contracts.py +1159 -0
- millforge/connectors/diagnostics.py +189 -0
- millforge/connectors/fake.py +66 -0
- millforge/connectors/runtime.py +236 -0
- millforge/contracts.py +2860 -0
- millforge/custom_tools/__init__.py +67 -0
- millforge/custom_tools/compiler.py +724 -0
- millforge/custom_tools/contracts.py +1093 -0
- millforge/custom_tools/diagnostics.py +205 -0
- millforge/eval_artifacts.py +952 -0
- millforge/eval_boundary.py +2435 -0
- millforge/eval_fixtures/__init__.py +1 -0
- millforge/eval_fixtures/default_pack/__init__.py +1 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.bug_diagnosis.traceback.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.direct_edit.import_sort.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.evidence_discipline.no_source_change.v1.json +51 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.false_closure.visible_green.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.multi_file.api_contract.v1.json +54 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.recovery.malformed_artifact.v1.json +54 -0
- millforge/eval_fixtures/default_pack/manifest.json +12 -0
- millforge/eval_modes.py +1282 -0
- millforge/eval_presets.py +1398 -0
- millforge/eval_reports.py +2517 -0
- millforge/eval_suite.py +2429 -0
- millforge/eval_trials.py +2632 -0
- millforge/eval_workflow.py +794 -0
- millforge/exceptions.py +122 -0
- millforge/model_backend.py +2098 -0
- millforge/protocols.py +340 -0
- millforge/py.typed +0 -0
- millforge/runtime.py +1791 -0
- millforge/testing/__init__.py +1089 -0
- millforge/tools/__init__.py +83 -0
- millforge/tools/builtin_runtime.py +1339 -0
- millforge/tools/builtins.py +773 -0
- millforge/tools/execution.py +1545 -0
- millforge/tools/path_policy.py +155 -0
- millforge/tools/pi_compat/PI_LICENSE +21 -0
- millforge/tools/pi_compat/PROVENANCE.json +55 -0
- millforge/tools/pi_compat/UPDATE_POLICY.md +36 -0
- millforge/tools/pi_compat/__init__.py +34 -0
- millforge/tools/pi_compat/contracts.py +49 -0
- millforge/tools/pi_compat/editing.py +390 -0
- millforge/tools/pi_compat/mutations.py +57 -0
- millforge/tools/pi_compat/operations.py +401 -0
- millforge/tools/pi_compat/paths.py +155 -0
- millforge/tools/pi_compat/process.py +1375 -0
- millforge/tools/pi_compat/search.py +738 -0
- millforge/tools/pi_compat/truncation.py +267 -0
- millforge/tools/pi_compat_catalog.py +396 -0
- millforge/tools/pi_compat_runtime.py +460 -0
- millforge/tools/registry.py +553 -0
- millforge/tools/results.py +533 -0
- millforge-0.1.0.dist-info/METADATA +844 -0
- millforge-0.1.0.dist-info/RECORD +116 -0
- millforge-0.1.0.dist-info/WHEEL +4 -0
- millforge-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Pi-derived exact and fuzzy multi-edit behavior."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import unicodedata
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
from .contracts import (
|
|
13
|
+
PiCompatErrorKind,
|
|
14
|
+
PiCompatOperationResult,
|
|
15
|
+
PiCompatSideEffectState,
|
|
16
|
+
)
|
|
17
|
+
from .mutations import file_mutation_lock
|
|
18
|
+
from .paths import _PathValidationError, _is_absolute_cwd, resolve_to_cwd
|
|
19
|
+
from .truncation import sanitize_text
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Edit",
|
|
23
|
+
"PiCompatEditError",
|
|
24
|
+
"apply_edits_to_normalized_content",
|
|
25
|
+
"detect_line_ending",
|
|
26
|
+
"execute_edit",
|
|
27
|
+
"normalize_for_fuzzy_match",
|
|
28
|
+
"normalize_to_lf",
|
|
29
|
+
"restore_line_endings",
|
|
30
|
+
"strip_bom",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Edit:
|
|
36
|
+
"""One Pi edit replacement."""
|
|
37
|
+
|
|
38
|
+
old_text: str
|
|
39
|
+
new_text: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class _MatchedEdit:
|
|
44
|
+
edit_index: int
|
|
45
|
+
match_index: int
|
|
46
|
+
match_length: int
|
|
47
|
+
new_text: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class _Match:
|
|
52
|
+
found: bool
|
|
53
|
+
index: int
|
|
54
|
+
length: int
|
|
55
|
+
used_fuzzy_match: bool
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PiCompatEditError(ValueError):
|
|
59
|
+
"""A Pi-compatible edit validation conflict."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_FUZZY_TRANSLATION_TABLE = str.maketrans(
|
|
63
|
+
cast(
|
|
64
|
+
dict[str, str | int | None],
|
|
65
|
+
{
|
|
66
|
+
"\u2010": "-",
|
|
67
|
+
"\u2011": "-",
|
|
68
|
+
"\u2012": "-",
|
|
69
|
+
"\u2013": "-",
|
|
70
|
+
"\u2014": "-",
|
|
71
|
+
"\u2015": "-",
|
|
72
|
+
"\u2212": "-",
|
|
73
|
+
"\u00a0": " ",
|
|
74
|
+
"\u2002": " ",
|
|
75
|
+
"\u2003": " ",
|
|
76
|
+
"\u2004": " ",
|
|
77
|
+
"\u2005": " ",
|
|
78
|
+
"\u2006": " ",
|
|
79
|
+
"\u2007": " ",
|
|
80
|
+
"\u2008": " ",
|
|
81
|
+
"\u2009": " ",
|
|
82
|
+
"\u200a": " ",
|
|
83
|
+
"\u202f": " ",
|
|
84
|
+
"\u205f": " ",
|
|
85
|
+
"\u3000": " ",
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def detect_line_ending(content: str) -> str:
|
|
92
|
+
"""Return Pi's detected output line ending."""
|
|
93
|
+
|
|
94
|
+
crlf_index = content.find("\r\n")
|
|
95
|
+
lf_index = content.find("\n")
|
|
96
|
+
if lf_index == -1 or crlf_index == -1:
|
|
97
|
+
return "\n"
|
|
98
|
+
return "\r\n" if crlf_index < lf_index else "\n"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def normalize_to_lf(text: str) -> str:
|
|
102
|
+
"""Normalize all supported newline spellings to LF."""
|
|
103
|
+
|
|
104
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def restore_line_endings(text: str, ending: str) -> str:
|
|
108
|
+
"""Restore Pi's detected CRLF convention after applying edits."""
|
|
109
|
+
|
|
110
|
+
return text.replace("\n", "\r\n") if ending == "\r\n" else text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def strip_bom(content: str) -> tuple[str, str]:
|
|
114
|
+
"""Split a UTF-8 BOM from the text that the model matches."""
|
|
115
|
+
|
|
116
|
+
if content.startswith("\ufeff"):
|
|
117
|
+
return "\ufeff", content[1:]
|
|
118
|
+
return "", content
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def normalize_for_fuzzy_match(text: str) -> str:
|
|
122
|
+
"""Apply Pi's progressive Unicode and trailing-whitespace normalization."""
|
|
123
|
+
|
|
124
|
+
normalized = unicodedata.normalize("NFKC", text)
|
|
125
|
+
normalized = "\n".join(line.rstrip() for line in normalized.split("\n"))
|
|
126
|
+
return (
|
|
127
|
+
normalized.replace("\u2018", "'")
|
|
128
|
+
.replace("\u2019", "'")
|
|
129
|
+
.replace("\u201a", "'")
|
|
130
|
+
.replace("\u201b", "'")
|
|
131
|
+
.replace("\u201c", '"')
|
|
132
|
+
.replace("\u201d", '"')
|
|
133
|
+
.replace("\u201e", '"')
|
|
134
|
+
.replace("\u201f", '"')
|
|
135
|
+
.translate(_FUZZY_TRANSLATION_TABLE)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _fuzzy_find_text(content: str, old_text: str) -> _Match:
|
|
140
|
+
exact_index = content.find(old_text)
|
|
141
|
+
if exact_index != -1:
|
|
142
|
+
return _Match(True, exact_index, len(old_text), False)
|
|
143
|
+
|
|
144
|
+
fuzzy_content = normalize_for_fuzzy_match(content)
|
|
145
|
+
fuzzy_old_text = normalize_for_fuzzy_match(old_text)
|
|
146
|
+
fuzzy_index = fuzzy_content.find(fuzzy_old_text)
|
|
147
|
+
if fuzzy_index == -1:
|
|
148
|
+
return _Match(False, -1, 0, False)
|
|
149
|
+
return _Match(True, fuzzy_index, len(fuzzy_old_text), True)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _count_occurrences(content: str, old_text: str) -> int:
|
|
153
|
+
return normalize_for_fuzzy_match(content).count(normalize_for_fuzzy_match(old_text))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _not_found_error(path: str, edit_index: int, total_edits: int) -> PiCompatEditError:
|
|
157
|
+
if total_edits == 1:
|
|
158
|
+
return PiCompatEditError(
|
|
159
|
+
f"Could not find the exact text in {path}. The old text must match "
|
|
160
|
+
"exactly including all whitespace and newlines."
|
|
161
|
+
)
|
|
162
|
+
return PiCompatEditError(
|
|
163
|
+
f"Could not find edits[{edit_index}] in {path}. The oldText must match "
|
|
164
|
+
"exactly including all whitespace and newlines."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _duplicate_error(
|
|
169
|
+
path: str, edit_index: int, total_edits: int, occurrences: int
|
|
170
|
+
) -> PiCompatEditError:
|
|
171
|
+
if total_edits == 1:
|
|
172
|
+
return PiCompatEditError(
|
|
173
|
+
f"Found {occurrences} occurrences of the text in {path}. The text "
|
|
174
|
+
"must be unique. Please provide more context to make it unique."
|
|
175
|
+
)
|
|
176
|
+
return PiCompatEditError(
|
|
177
|
+
f"Found {occurrences} occurrences of edits[{edit_index}] in {path}. "
|
|
178
|
+
"Each oldText must be unique. Please provide more context to make it unique."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _empty_old_text_error(
|
|
183
|
+
path: str, edit_index: int, total_edits: int
|
|
184
|
+
) -> PiCompatEditError:
|
|
185
|
+
if total_edits == 1:
|
|
186
|
+
return PiCompatEditError(f"oldText must not be empty in {path}.")
|
|
187
|
+
return PiCompatEditError(
|
|
188
|
+
f"edits[{edit_index}].oldText must not be empty in {path}."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _no_change_error(path: str, total_edits: int) -> PiCompatEditError:
|
|
193
|
+
if total_edits == 1:
|
|
194
|
+
return PiCompatEditError(
|
|
195
|
+
f"No changes made to {path}. The replacement produced identical content. "
|
|
196
|
+
"This might indicate an issue with special characters or the text not "
|
|
197
|
+
"existing as expected."
|
|
198
|
+
)
|
|
199
|
+
return PiCompatEditError(
|
|
200
|
+
f"No changes made to {path}. The replacements produced identical content."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def apply_edits_to_normalized_content(
|
|
205
|
+
normalized_content: str, edits: Sequence[Edit], path: str
|
|
206
|
+
) -> tuple[str, str]:
|
|
207
|
+
"""Match all Pi edits against one original LF-normalized content snapshot."""
|
|
208
|
+
|
|
209
|
+
normalized_edits = [
|
|
210
|
+
Edit(normalize_to_lf(edit.old_text), normalize_to_lf(edit.new_text))
|
|
211
|
+
for edit in edits
|
|
212
|
+
]
|
|
213
|
+
for index, edit in enumerate(normalized_edits):
|
|
214
|
+
if not edit.old_text:
|
|
215
|
+
raise _empty_old_text_error(path, index, len(normalized_edits))
|
|
216
|
+
|
|
217
|
+
initial_matches = [
|
|
218
|
+
_fuzzy_find_text(normalized_content, edit.old_text) for edit in normalized_edits
|
|
219
|
+
]
|
|
220
|
+
base_content = (
|
|
221
|
+
normalize_for_fuzzy_match(normalized_content)
|
|
222
|
+
if any(match.used_fuzzy_match for match in initial_matches)
|
|
223
|
+
else normalized_content
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
matched_edits: list[_MatchedEdit] = []
|
|
227
|
+
for index, edit in enumerate(normalized_edits):
|
|
228
|
+
match = _fuzzy_find_text(base_content, edit.old_text)
|
|
229
|
+
if not match.found:
|
|
230
|
+
raise _not_found_error(path, index, len(normalized_edits))
|
|
231
|
+
occurrences = _count_occurrences(base_content, edit.old_text)
|
|
232
|
+
if occurrences > 1:
|
|
233
|
+
raise _duplicate_error(path, index, len(normalized_edits), occurrences)
|
|
234
|
+
matched_edits.append(
|
|
235
|
+
_MatchedEdit(index, match.index, match.length, edit.new_text)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
matched_edits.sort(key=lambda edit: edit.match_index)
|
|
239
|
+
for previous, current in zip(matched_edits, matched_edits[1:], strict=False):
|
|
240
|
+
if previous.match_index + previous.match_length > current.match_index:
|
|
241
|
+
raise PiCompatEditError(
|
|
242
|
+
f"edits[{previous.edit_index}] and edits[{current.edit_index}] "
|
|
243
|
+
f"overlap in {path}. Merge them into one edit or target disjoint regions."
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
new_content = base_content
|
|
247
|
+
for matched_edit in reversed(matched_edits):
|
|
248
|
+
new_content = (
|
|
249
|
+
new_content[: matched_edit.match_index]
|
|
250
|
+
+ matched_edit.new_text
|
|
251
|
+
+ new_content[matched_edit.match_index + matched_edit.match_length :]
|
|
252
|
+
)
|
|
253
|
+
if base_content == new_content:
|
|
254
|
+
raise _no_change_error(path, len(normalized_edits))
|
|
255
|
+
return base_content, new_content
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _invalid_result(message: str) -> PiCompatOperationResult:
|
|
259
|
+
return PiCompatOperationResult(
|
|
260
|
+
model_text=sanitize_text(message),
|
|
261
|
+
truncated=False,
|
|
262
|
+
error_kind=PiCompatErrorKind.INVALID_ARGUMENTS,
|
|
263
|
+
exit_code=None,
|
|
264
|
+
changed_path=None,
|
|
265
|
+
side_effect_state=PiCompatSideEffectState.NOT_ATTEMPTED,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _error_code(error: OSError | UnicodeError) -> str:
|
|
270
|
+
if isinstance(error, OSError) and error.errno == errno.ENOENT:
|
|
271
|
+
return "ENOENT"
|
|
272
|
+
if isinstance(error, OSError) and error.errno in {errno.EACCES, errno.EPERM}:
|
|
273
|
+
return "EACCES"
|
|
274
|
+
return type(error).__name__
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _filesystem_error_result(
|
|
278
|
+
error: OSError | UnicodeError,
|
|
279
|
+
*,
|
|
280
|
+
model_path: str,
|
|
281
|
+
write_started: bool,
|
|
282
|
+
) -> PiCompatOperationResult:
|
|
283
|
+
if isinstance(error, FileNotFoundError):
|
|
284
|
+
kind = PiCompatErrorKind.NOT_FOUND
|
|
285
|
+
elif isinstance(error, PermissionError):
|
|
286
|
+
kind = PiCompatErrorKind.PERMISSION_DENIED
|
|
287
|
+
else:
|
|
288
|
+
kind = PiCompatErrorKind.IO_ERROR
|
|
289
|
+
return PiCompatOperationResult(
|
|
290
|
+
model_text=sanitize_text(
|
|
291
|
+
f"Could not edit file: {model_path}. Error code: {_error_code(error)}."
|
|
292
|
+
),
|
|
293
|
+
truncated=False,
|
|
294
|
+
error_kind=kind,
|
|
295
|
+
exit_code=None,
|
|
296
|
+
changed_path=None,
|
|
297
|
+
side_effect_state=(
|
|
298
|
+
PiCompatSideEffectState.COMPLETION_UNKNOWN
|
|
299
|
+
if write_started
|
|
300
|
+
else PiCompatSideEffectState.NOT_ATTEMPTED
|
|
301
|
+
),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _coerce_edits(edits: Sequence[Mapping[str, object]]) -> list[Edit] | None:
|
|
306
|
+
coerced: list[Edit] = []
|
|
307
|
+
for edit in edits:
|
|
308
|
+
if not isinstance(edit, Mapping):
|
|
309
|
+
return None
|
|
310
|
+
old_text = edit.get("oldText")
|
|
311
|
+
new_text = edit.get("newText")
|
|
312
|
+
if not isinstance(old_text, str) or not isinstance(new_text, str):
|
|
313
|
+
return None
|
|
314
|
+
coerced.append(Edit(old_text, new_text))
|
|
315
|
+
return coerced
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def execute_edit(
|
|
319
|
+
*,
|
|
320
|
+
cwd: Path,
|
|
321
|
+
path: str,
|
|
322
|
+
edits: Sequence[Mapping[str, object]],
|
|
323
|
+
) -> PiCompatOperationResult:
|
|
324
|
+
"""Apply all Pi edits atomically after validating them against one snapshot."""
|
|
325
|
+
|
|
326
|
+
if not _is_absolute_cwd(cwd):
|
|
327
|
+
return _invalid_result("cwd must be an absolute path")
|
|
328
|
+
if "\x00" in str(cwd):
|
|
329
|
+
return _invalid_result("cwd must not contain NUL bytes")
|
|
330
|
+
if not isinstance(path, str):
|
|
331
|
+
return _invalid_result("path must be a string")
|
|
332
|
+
if "\x00" in path:
|
|
333
|
+
return _invalid_result("path must not contain NUL bytes")
|
|
334
|
+
if isinstance(edits, (str, bytes)) or not isinstance(edits, Sequence):
|
|
335
|
+
return _invalid_result("edits must be an array")
|
|
336
|
+
typed_edits = _coerce_edits(edits)
|
|
337
|
+
if typed_edits is None:
|
|
338
|
+
return _invalid_result("each edit must contain string oldText and newText")
|
|
339
|
+
if not typed_edits:
|
|
340
|
+
return _invalid_result(
|
|
341
|
+
"Edit tool input is invalid. edits must contain at least one replacement."
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
write_started = False
|
|
345
|
+
try:
|
|
346
|
+
absolute_path = resolve_to_cwd(path, cwd)
|
|
347
|
+
with file_mutation_lock(absolute_path):
|
|
348
|
+
try:
|
|
349
|
+
raw_content = absolute_path.read_bytes().decode(
|
|
350
|
+
"utf-8", errors="replace"
|
|
351
|
+
)
|
|
352
|
+
bom, content = strip_bom(raw_content)
|
|
353
|
+
original_ending = detect_line_ending(content)
|
|
354
|
+
_, new_content = apply_edits_to_normalized_content(
|
|
355
|
+
normalize_to_lf(content), typed_edits, path
|
|
356
|
+
)
|
|
357
|
+
final_content = bom + restore_line_endings(new_content, original_ending)
|
|
358
|
+
write_started = True
|
|
359
|
+
with absolute_path.open("w", encoding="utf-8", newline="") as output:
|
|
360
|
+
output.write(final_content)
|
|
361
|
+
except PiCompatEditError as error:
|
|
362
|
+
return PiCompatOperationResult(
|
|
363
|
+
model_text=sanitize_text(str(error)),
|
|
364
|
+
truncated=False,
|
|
365
|
+
error_kind=PiCompatErrorKind.CONFLICT,
|
|
366
|
+
exit_code=None,
|
|
367
|
+
changed_path=None,
|
|
368
|
+
side_effect_state=PiCompatSideEffectState.NOT_ATTEMPTED,
|
|
369
|
+
)
|
|
370
|
+
except (OSError, UnicodeError) as error:
|
|
371
|
+
return _filesystem_error_result(
|
|
372
|
+
error,
|
|
373
|
+
model_path=path,
|
|
374
|
+
write_started=write_started,
|
|
375
|
+
)
|
|
376
|
+
except _PathValidationError as error:
|
|
377
|
+
return _invalid_result(str(error))
|
|
378
|
+
except UnicodeError:
|
|
379
|
+
return _invalid_result("path cannot be encoded by this filesystem")
|
|
380
|
+
|
|
381
|
+
return PiCompatOperationResult(
|
|
382
|
+
model_text=sanitize_text(
|
|
383
|
+
f"Successfully replaced {len(typed_edits)} block(s) in {path}."
|
|
384
|
+
),
|
|
385
|
+
truncated=False,
|
|
386
|
+
error_kind=None,
|
|
387
|
+
exit_code=None,
|
|
388
|
+
changed_path=absolute_path,
|
|
389
|
+
side_effect_state=PiCompatSideEffectState.CONFIRMED_COMPLETE,
|
|
390
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""In-process serialization for writes and edits targeting one filesystem path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterator
|
|
11
|
+
|
|
12
|
+
__all__ = ["file_mutation_lock"]
|
|
13
|
+
|
|
14
|
+
_registration_lock = threading.Lock()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class _PathLockEntry:
|
|
19
|
+
lock: threading.RLock
|
|
20
|
+
users: int = 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_path_locks: dict[str, _PathLockEntry] = {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _mutation_key(path: Path) -> str:
|
|
27
|
+
absolute = Path(os.path.abspath(path))
|
|
28
|
+
try:
|
|
29
|
+
return str(absolute.resolve(strict=True))
|
|
30
|
+
except (OSError, RuntimeError):
|
|
31
|
+
return os.path.normcase(os.path.normpath(str(absolute)))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@contextmanager
|
|
35
|
+
def file_mutation_lock(path: Path) -> Iterator[None]:
|
|
36
|
+
"""Serialize mutations to the same existing or lexical target path.
|
|
37
|
+
|
|
38
|
+
``users`` includes both lock holders and queued callers, so the entry can
|
|
39
|
+
disappear only after the last caller has released it.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
key = _mutation_key(path)
|
|
43
|
+
with _registration_lock:
|
|
44
|
+
entry = _path_locks.get(key)
|
|
45
|
+
if entry is None:
|
|
46
|
+
entry = _PathLockEntry(lock=threading.RLock())
|
|
47
|
+
_path_locks[key] = entry
|
|
48
|
+
entry.users += 1
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
with entry.lock:
|
|
52
|
+
yield
|
|
53
|
+
finally:
|
|
54
|
+
with _registration_lock:
|
|
55
|
+
entry.users -= 1
|
|
56
|
+
if entry.users == 0 and _path_locks.get(key) is entry:
|
|
57
|
+
del _path_locks[key]
|