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,401 @@
|
|
|
1
|
+
"""Synchronous filesystem operations in the Pi 0.79.6 compatibility pack."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .contracts import (
|
|
9
|
+
PiCompatErrorKind,
|
|
10
|
+
PiCompatOperationResult,
|
|
11
|
+
PiCompatSideEffectState,
|
|
12
|
+
)
|
|
13
|
+
from .mutations import file_mutation_lock
|
|
14
|
+
from .paths import (
|
|
15
|
+
_PathValidationError,
|
|
16
|
+
_is_absolute_cwd,
|
|
17
|
+
resolve_read_path,
|
|
18
|
+
resolve_to_cwd,
|
|
19
|
+
)
|
|
20
|
+
from .truncation import DEFAULT_MAX_BYTES, format_size, sanitize_text, truncate_head
|
|
21
|
+
|
|
22
|
+
__all__ = ["execute_ls", "execute_read", "execute_write"]
|
|
23
|
+
|
|
24
|
+
_DEFAULT_LS_LIMIT = 500
|
|
25
|
+
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _read_only_error(
|
|
29
|
+
message: str, error_kind: PiCompatErrorKind
|
|
30
|
+
) -> PiCompatOperationResult:
|
|
31
|
+
return PiCompatOperationResult(
|
|
32
|
+
model_text=sanitize_text(message),
|
|
33
|
+
truncated=False,
|
|
34
|
+
error_kind=error_kind,
|
|
35
|
+
exit_code=None,
|
|
36
|
+
changed_path=None,
|
|
37
|
+
side_effect_state=PiCompatSideEffectState.NOT_ATTEMPTED,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _invalid_arguments(message: str) -> PiCompatOperationResult:
|
|
42
|
+
return _read_only_error(message, PiCompatErrorKind.INVALID_ARGUMENTS)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _has_embedded_nul(value: str) -> bool:
|
|
46
|
+
return "\x00" in value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _cwd_error(cwd: object) -> str | None:
|
|
50
|
+
if not _is_absolute_cwd(cwd):
|
|
51
|
+
return "cwd must be an absolute path"
|
|
52
|
+
if "\x00" in str(cwd):
|
|
53
|
+
return "cwd must not contain NUL bytes"
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _filesystem_error_kind(error: OSError) -> PiCompatErrorKind:
|
|
58
|
+
if isinstance(error, FileNotFoundError):
|
|
59
|
+
return PiCompatErrorKind.NOT_FOUND
|
|
60
|
+
if isinstance(error, PermissionError):
|
|
61
|
+
return PiCompatErrorKind.PERMISSION_DENIED
|
|
62
|
+
return PiCompatErrorKind.IO_ERROR
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_uint32_be(data: bytes, offset: int) -> int:
|
|
66
|
+
if offset + 4 > len(data):
|
|
67
|
+
return 0
|
|
68
|
+
return int.from_bytes(data[offset : offset + 4], "big")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_animated_png(data: bytes) -> bool:
|
|
72
|
+
offset = len(_PNG_SIGNATURE)
|
|
73
|
+
while offset + 8 <= len(data):
|
|
74
|
+
chunk_length = _read_uint32_be(data, offset)
|
|
75
|
+
chunk_type_offset = offset + 4
|
|
76
|
+
if data[chunk_type_offset : chunk_type_offset + 4] == b"acTL":
|
|
77
|
+
return True
|
|
78
|
+
if data[chunk_type_offset : chunk_type_offset + 4] == b"IDAT":
|
|
79
|
+
return False
|
|
80
|
+
next_offset = offset + 8 + chunk_length + 4
|
|
81
|
+
if next_offset <= offset or next_offset > len(data):
|
|
82
|
+
return False
|
|
83
|
+
offset = next_offset
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _supported_image_mime_type(data: bytes) -> str | None:
|
|
88
|
+
if data.startswith(b"\xff\xd8\xff"):
|
|
89
|
+
return None if len(data) > 3 and data[3] == 0xF7 else "image/jpeg"
|
|
90
|
+
if data.startswith(_PNG_SIGNATURE):
|
|
91
|
+
is_png = (
|
|
92
|
+
len(data) >= 16
|
|
93
|
+
and _read_uint32_be(data, len(_PNG_SIGNATURE)) == 13
|
|
94
|
+
and data[12:16] == b"IHDR"
|
|
95
|
+
)
|
|
96
|
+
if is_png and not _is_animated_png(data):
|
|
97
|
+
return "image/png"
|
|
98
|
+
return None
|
|
99
|
+
if data.startswith(b"GIF"):
|
|
100
|
+
return "image/gif"
|
|
101
|
+
if data.startswith(b"RIFF") and data[8:12] == b"WEBP":
|
|
102
|
+
return "image/webp"
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_number(value: object) -> bool:
|
|
107
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _number_text(value: int | float) -> str:
|
|
111
|
+
if isinstance(value, float) and value.is_integer():
|
|
112
|
+
return str(int(value))
|
|
113
|
+
return str(value)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _js_truthy_number(value: int | float) -> bool:
|
|
117
|
+
return not (value == 0 or (isinstance(value, float) and math.isnan(value)))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _js_slice_index(value: int | float, length: int) -> int:
|
|
121
|
+
if isinstance(value, float):
|
|
122
|
+
if math.isnan(value):
|
|
123
|
+
return 0
|
|
124
|
+
if value == math.inf:
|
|
125
|
+
return length
|
|
126
|
+
if value == -math.inf:
|
|
127
|
+
return 0
|
|
128
|
+
integer = math.trunc(value)
|
|
129
|
+
if integer < 0:
|
|
130
|
+
return max(length + integer, 0)
|
|
131
|
+
return min(integer, length)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _read_start_line(
|
|
135
|
+
offset: int | float | None, line_count: int
|
|
136
|
+
) -> tuple[int | float, int]:
|
|
137
|
+
if offset is None or not _js_truthy_number(offset):
|
|
138
|
+
start_line: int | float = 0
|
|
139
|
+
else:
|
|
140
|
+
start_line = max(0, offset - 1)
|
|
141
|
+
return start_line, _js_slice_index(start_line, line_count)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def execute_read(
|
|
145
|
+
*,
|
|
146
|
+
cwd: Path,
|
|
147
|
+
path: str,
|
|
148
|
+
offset: int | float | None = None,
|
|
149
|
+
limit: int | float | None = None,
|
|
150
|
+
) -> PiCompatOperationResult:
|
|
151
|
+
"""Read text or a supported image using Pi's output wording and limits."""
|
|
152
|
+
|
|
153
|
+
cwd_error = _cwd_error(cwd)
|
|
154
|
+
if cwd_error is not None:
|
|
155
|
+
return _invalid_arguments(cwd_error)
|
|
156
|
+
if not isinstance(path, str):
|
|
157
|
+
return _invalid_arguments("path must be a string")
|
|
158
|
+
if _has_embedded_nul(path):
|
|
159
|
+
return _invalid_arguments("path must not contain NUL bytes")
|
|
160
|
+
if offset is not None and not _is_number(offset):
|
|
161
|
+
return _invalid_arguments("offset must be a number")
|
|
162
|
+
if limit is not None and not _is_number(limit):
|
|
163
|
+
return _invalid_arguments("limit must be a number")
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
absolute_path = resolve_read_path(path, cwd)
|
|
167
|
+
data = absolute_path.read_bytes()
|
|
168
|
+
except _PathValidationError as error:
|
|
169
|
+
return _invalid_arguments(str(error))
|
|
170
|
+
except OSError as error:
|
|
171
|
+
return _read_only_error(str(error), _filesystem_error_kind(error))
|
|
172
|
+
except UnicodeError:
|
|
173
|
+
return _invalid_arguments("path cannot be encoded by this filesystem")
|
|
174
|
+
|
|
175
|
+
mime_type = _supported_image_mime_type(data[:4100])
|
|
176
|
+
if mime_type is not None:
|
|
177
|
+
return PiCompatOperationResult(
|
|
178
|
+
model_text=(
|
|
179
|
+
f"Read image file [{mime_type}]\n"
|
|
180
|
+
"[Image attachment omitted: Millforge's tool-result contract is "
|
|
181
|
+
f"text-only. File size: {len(data)} bytes.]"
|
|
182
|
+
),
|
|
183
|
+
truncated=False,
|
|
184
|
+
error_kind=None,
|
|
185
|
+
exit_code=None,
|
|
186
|
+
changed_path=None,
|
|
187
|
+
side_effect_state=PiCompatSideEffectState.NOT_ATTEMPTED,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
text_content = data.decode("utf-8", errors="replace")
|
|
191
|
+
all_lines = text_content.split("\n")
|
|
192
|
+
start_line, start_index = _read_start_line(offset, len(all_lines))
|
|
193
|
+
if start_line >= len(all_lines):
|
|
194
|
+
return _invalid_arguments(
|
|
195
|
+
"Offset "
|
|
196
|
+
f"{_number_text(offset if offset is not None else 0)} is beyond end "
|
|
197
|
+
f"of file ({len(all_lines)} lines total)"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
user_limited_lines: int | float | None = None
|
|
201
|
+
if limit is None:
|
|
202
|
+
selected_content = "\n".join(all_lines[start_index:])
|
|
203
|
+
else:
|
|
204
|
+
end_line = min(start_line + limit, len(all_lines))
|
|
205
|
+
end_index = _js_slice_index(end_line, len(all_lines))
|
|
206
|
+
selected_content = "\n".join(all_lines[start_index:end_index])
|
|
207
|
+
user_limited_lines = end_line - start_line
|
|
208
|
+
|
|
209
|
+
truncation = truncate_head(selected_content)
|
|
210
|
+
start_line_display = start_line + 1
|
|
211
|
+
if truncation.first_line_exceeds_limit:
|
|
212
|
+
first_line = all_lines[start_index]
|
|
213
|
+
model_text = (
|
|
214
|
+
f"[Line {_number_text(start_line_display)} is "
|
|
215
|
+
f"{format_size(len(first_line.encode('utf-8')))}, exceeds "
|
|
216
|
+
f"{format_size(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n "
|
|
217
|
+
f"'{_number_text(start_line_display)}p' {path} | head -c "
|
|
218
|
+
f"{DEFAULT_MAX_BYTES}]"
|
|
219
|
+
)
|
|
220
|
+
elif truncation.truncated:
|
|
221
|
+
end_line_display = start_line_display + truncation.output_lines - 1
|
|
222
|
+
next_offset = end_line_display + 1
|
|
223
|
+
model_text = truncation.content
|
|
224
|
+
if truncation.truncated_by == "lines":
|
|
225
|
+
model_text += (
|
|
226
|
+
f"\n\n[Showing lines {_number_text(start_line_display)}-"
|
|
227
|
+
f"{_number_text(end_line_display)} of {len(all_lines)}. Use "
|
|
228
|
+
f"offset={_number_text(next_offset)} to continue.]"
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
model_text += (
|
|
232
|
+
f"\n\n[Showing lines {_number_text(start_line_display)}-"
|
|
233
|
+
f"{_number_text(end_line_display)} of {len(all_lines)} "
|
|
234
|
+
f"({format_size(DEFAULT_MAX_BYTES)} limit). Use "
|
|
235
|
+
f"offset={_number_text(next_offset)} to continue.]"
|
|
236
|
+
)
|
|
237
|
+
elif user_limited_lines is not None and start_line + user_limited_lines < len(
|
|
238
|
+
all_lines
|
|
239
|
+
):
|
|
240
|
+
remaining = len(all_lines) - (start_line + user_limited_lines)
|
|
241
|
+
next_offset = start_line + user_limited_lines + 1
|
|
242
|
+
model_text = (
|
|
243
|
+
f"{truncation.content}\n\n[{_number_text(remaining)} more lines in "
|
|
244
|
+
f"file. Use offset={_number_text(next_offset)} to continue.]"
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
model_text = truncation.content
|
|
248
|
+
|
|
249
|
+
return PiCompatOperationResult(
|
|
250
|
+
model_text=sanitize_text(model_text),
|
|
251
|
+
truncated=truncation.truncated,
|
|
252
|
+
error_kind=None,
|
|
253
|
+
exit_code=None,
|
|
254
|
+
changed_path=None,
|
|
255
|
+
side_effect_state=PiCompatSideEffectState.NOT_ATTEMPTED,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def execute_ls(
|
|
260
|
+
*,
|
|
261
|
+
cwd: Path,
|
|
262
|
+
path: str | None = None,
|
|
263
|
+
limit: int | float | None = None,
|
|
264
|
+
) -> PiCompatOperationResult:
|
|
265
|
+
"""List Pi-compatible directory entries, including dotfiles and directories."""
|
|
266
|
+
|
|
267
|
+
cwd_error = _cwd_error(cwd)
|
|
268
|
+
if cwd_error is not None:
|
|
269
|
+
return _invalid_arguments(cwd_error)
|
|
270
|
+
if path is not None and not isinstance(path, str):
|
|
271
|
+
return _invalid_arguments("path must be a string")
|
|
272
|
+
if path is not None and _has_embedded_nul(path):
|
|
273
|
+
return _invalid_arguments("path must not contain NUL bytes")
|
|
274
|
+
if limit is not None and not _is_number(limit):
|
|
275
|
+
return _invalid_arguments("limit must be a number")
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
dir_path = resolve_to_cwd(path or ".", cwd)
|
|
279
|
+
if not dir_path.exists():
|
|
280
|
+
return _read_only_error(
|
|
281
|
+
f"Path not found: {dir_path}", PiCompatErrorKind.NOT_FOUND
|
|
282
|
+
)
|
|
283
|
+
if not dir_path.is_dir():
|
|
284
|
+
return _invalid_arguments(f"Not a directory: {dir_path}")
|
|
285
|
+
entries = list(dir_path.iterdir())
|
|
286
|
+
except _PathValidationError as error:
|
|
287
|
+
return _invalid_arguments(str(error))
|
|
288
|
+
except OSError as error:
|
|
289
|
+
return _read_only_error(
|
|
290
|
+
f"Cannot read directory: {error}", _filesystem_error_kind(error)
|
|
291
|
+
)
|
|
292
|
+
except UnicodeError:
|
|
293
|
+
return _invalid_arguments("path cannot be encoded by this filesystem")
|
|
294
|
+
|
|
295
|
+
entries.sort(key=lambda entry: (entry.name.casefold(), entry.name))
|
|
296
|
+
effective_limit = _DEFAULT_LS_LIMIT if limit is None else limit
|
|
297
|
+
results: list[str] = []
|
|
298
|
+
entry_limit_reached = False
|
|
299
|
+
for entry in entries:
|
|
300
|
+
if len(results) >= effective_limit:
|
|
301
|
+
entry_limit_reached = True
|
|
302
|
+
break
|
|
303
|
+
try:
|
|
304
|
+
suffix = "/" if entry.is_dir() else ""
|
|
305
|
+
except OSError:
|
|
306
|
+
continue
|
|
307
|
+
results.append(f"{entry.name}{suffix}")
|
|
308
|
+
|
|
309
|
+
if not results:
|
|
310
|
+
return PiCompatOperationResult(
|
|
311
|
+
model_text="(empty directory)",
|
|
312
|
+
truncated=False,
|
|
313
|
+
error_kind=None,
|
|
314
|
+
exit_code=None,
|
|
315
|
+
changed_path=None,
|
|
316
|
+
side_effect_state=PiCompatSideEffectState.NOT_ATTEMPTED,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
truncation = truncate_head("\n".join(results), max_lines=2**53 - 1)
|
|
320
|
+
notices: list[str] = []
|
|
321
|
+
if entry_limit_reached:
|
|
322
|
+
notices.append(
|
|
323
|
+
f"{_number_text(effective_limit)} entries limit reached. Use "
|
|
324
|
+
f"limit={_number_text(effective_limit * 2)} for more"
|
|
325
|
+
)
|
|
326
|
+
if truncation.truncated:
|
|
327
|
+
notices.append(f"{format_size(DEFAULT_MAX_BYTES)} limit reached")
|
|
328
|
+
model_text = truncation.content
|
|
329
|
+
if notices:
|
|
330
|
+
model_text += f"\n\n[{'. '.join(notices)}]"
|
|
331
|
+
|
|
332
|
+
return PiCompatOperationResult(
|
|
333
|
+
model_text=sanitize_text(model_text),
|
|
334
|
+
truncated=entry_limit_reached or truncation.truncated,
|
|
335
|
+
error_kind=None,
|
|
336
|
+
exit_code=None,
|
|
337
|
+
changed_path=None,
|
|
338
|
+
side_effect_state=PiCompatSideEffectState.NOT_ATTEMPTED,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def execute_write(
|
|
343
|
+
*,
|
|
344
|
+
cwd: Path,
|
|
345
|
+
path: str,
|
|
346
|
+
content: str,
|
|
347
|
+
) -> PiCompatOperationResult:
|
|
348
|
+
"""Create parent directories and overwrite one file under the mutation lock."""
|
|
349
|
+
|
|
350
|
+
cwd_error = _cwd_error(cwd)
|
|
351
|
+
if cwd_error is not None:
|
|
352
|
+
return _invalid_arguments(cwd_error)
|
|
353
|
+
if not isinstance(path, str):
|
|
354
|
+
return _invalid_arguments("path must be a string")
|
|
355
|
+
if _has_embedded_nul(path):
|
|
356
|
+
return _invalid_arguments("path must not contain NUL bytes")
|
|
357
|
+
if not isinstance(content, str):
|
|
358
|
+
return _invalid_arguments("content must be a string")
|
|
359
|
+
|
|
360
|
+
mutation_started = False
|
|
361
|
+
try:
|
|
362
|
+
absolute_path = resolve_to_cwd(path, cwd)
|
|
363
|
+
with file_mutation_lock(absolute_path):
|
|
364
|
+
try:
|
|
365
|
+
mutation_started = True
|
|
366
|
+
absolute_path.parent.mkdir(parents=True, exist_ok=True)
|
|
367
|
+
with absolute_path.open("w", encoding="utf-8", newline="") as output:
|
|
368
|
+
output.write(content)
|
|
369
|
+
except (OSError, UnicodeError) as error:
|
|
370
|
+
return PiCompatOperationResult(
|
|
371
|
+
model_text=sanitize_text(str(error)),
|
|
372
|
+
truncated=False,
|
|
373
|
+
error_kind=(
|
|
374
|
+
_filesystem_error_kind(error)
|
|
375
|
+
if isinstance(error, OSError)
|
|
376
|
+
else PiCompatErrorKind.IO_ERROR
|
|
377
|
+
),
|
|
378
|
+
exit_code=None,
|
|
379
|
+
changed_path=None,
|
|
380
|
+
side_effect_state=(
|
|
381
|
+
PiCompatSideEffectState.COMPLETION_UNKNOWN
|
|
382
|
+
if mutation_started
|
|
383
|
+
else PiCompatSideEffectState.NOT_ATTEMPTED
|
|
384
|
+
),
|
|
385
|
+
)
|
|
386
|
+
except _PathValidationError as error:
|
|
387
|
+
return _invalid_arguments(str(error))
|
|
388
|
+
except UnicodeError:
|
|
389
|
+
return _invalid_arguments("path cannot be encoded by this filesystem")
|
|
390
|
+
|
|
391
|
+
javascript_string_length = len(content.encode("utf-16-le")) // 2
|
|
392
|
+
return PiCompatOperationResult(
|
|
393
|
+
model_text=sanitize_text(
|
|
394
|
+
f"Successfully wrote {javascript_string_length} bytes to {path}"
|
|
395
|
+
),
|
|
396
|
+
truncated=False,
|
|
397
|
+
error_kind=None,
|
|
398
|
+
exit_code=None,
|
|
399
|
+
changed_path=absolute_path,
|
|
400
|
+
side_effect_state=PiCompatSideEffectState.CONFIRMED_COMPLETE,
|
|
401
|
+
)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Pi-compatible path normalization and read-path recovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import unicodedata
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import unquote, urlparse
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"expand_path",
|
|
13
|
+
"normalize_path",
|
|
14
|
+
"resolve_read_path",
|
|
15
|
+
"resolve_to_cwd",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
_NARROW_NO_BREAK_SPACE = "\u202f"
|
|
19
|
+
_UNICODE_SPACES = re.compile(r"[\u00a0\u2000-\u200a\u202f\u205f\u3000]")
|
|
20
|
+
_MACOS_AM_PM = re.compile(r" (AM|PM)\.", re.IGNORECASE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _PathValidationError(ValueError):
|
|
24
|
+
"""Raised when a normalized path cannot be passed to this filesystem."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_absolute_cwd(cwd: object) -> bool:
|
|
28
|
+
return isinstance(cwd, Path) and cwd.is_absolute()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _file_url_path(value: str) -> str:
|
|
32
|
+
parsed = urlparse(value)
|
|
33
|
+
decoded_path = unquote(parsed.path)
|
|
34
|
+
if os.name == "nt" and re.match(r"^/[A-Za-z]:", decoded_path):
|
|
35
|
+
decoded_path = decoded_path[1:]
|
|
36
|
+
if parsed.netloc and parsed.netloc != "localhost":
|
|
37
|
+
return f"//{parsed.netloc}{decoded_path}"
|
|
38
|
+
return decoded_path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize_path(
|
|
42
|
+
input_path: str,
|
|
43
|
+
*,
|
|
44
|
+
trim: bool = False,
|
|
45
|
+
expand_tilde: bool = True,
|
|
46
|
+
home_directory: Path | None = None,
|
|
47
|
+
strip_at_prefix: bool = False,
|
|
48
|
+
normalize_unicode_spaces: bool = False,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Normalize a user path using the pinned Pi path utility's options."""
|
|
51
|
+
|
|
52
|
+
normalized = input_path.strip() if trim else input_path
|
|
53
|
+
if normalize_unicode_spaces:
|
|
54
|
+
normalized = _UNICODE_SPACES.sub(" ", normalized)
|
|
55
|
+
if strip_at_prefix and normalized.startswith("@"):
|
|
56
|
+
normalized = normalized[1:]
|
|
57
|
+
|
|
58
|
+
if expand_tilde:
|
|
59
|
+
home = str(home_directory if home_directory is not None else Path.home())
|
|
60
|
+
if normalized == "~":
|
|
61
|
+
return home
|
|
62
|
+
if normalized.startswith("~/") or (
|
|
63
|
+
os.name == "nt" and normalized.startswith("~\\")
|
|
64
|
+
):
|
|
65
|
+
return os.path.join(home, normalized[2:])
|
|
66
|
+
|
|
67
|
+
if normalized.startswith("file://"):
|
|
68
|
+
return _file_url_path(normalized)
|
|
69
|
+
return normalized
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def expand_path(input_path: str) -> str:
|
|
73
|
+
"""Expand a Pi tool path without resolving it against a run cwd."""
|
|
74
|
+
|
|
75
|
+
return normalize_path(
|
|
76
|
+
input_path,
|
|
77
|
+
normalize_unicode_spaces=True,
|
|
78
|
+
strip_at_prefix=True,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _validate_filesystem_path(value: str) -> None:
|
|
83
|
+
if "\x00" in value:
|
|
84
|
+
raise _PathValidationError("path must not contain NUL bytes")
|
|
85
|
+
try:
|
|
86
|
+
os.fsencode(value)
|
|
87
|
+
except UnicodeEncodeError as exc:
|
|
88
|
+
raise _PathValidationError("path cannot be encoded by this filesystem") from exc
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def resolve_to_cwd(file_path: str, cwd: Path) -> Path:
|
|
92
|
+
"""Resolve an absolute or run-relative Pi tool path without containment."""
|
|
93
|
+
|
|
94
|
+
_validate_filesystem_path(file_path)
|
|
95
|
+
try:
|
|
96
|
+
normalized = expand_path(file_path)
|
|
97
|
+
_validate_filesystem_path(normalized)
|
|
98
|
+
candidate = Path(normalized)
|
|
99
|
+
if not candidate.is_absolute():
|
|
100
|
+
candidate = cwd / candidate
|
|
101
|
+
resolved = Path(os.path.abspath(candidate))
|
|
102
|
+
_validate_filesystem_path(str(resolved))
|
|
103
|
+
except _PathValidationError:
|
|
104
|
+
raise
|
|
105
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
106
|
+
raise _PathValidationError("path is invalid") from exc
|
|
107
|
+
return resolved
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _exists(path: Path) -> bool:
|
|
111
|
+
try:
|
|
112
|
+
return path.exists()
|
|
113
|
+
except (OSError, UnicodeError, ValueError):
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _macos_screenshot_variant(path: Path) -> Path:
|
|
118
|
+
return Path(
|
|
119
|
+
_MACOS_AM_PM.sub(
|
|
120
|
+
lambda match: f"{_NARROW_NO_BREAK_SPACE}{match[1]}.", str(path)
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _nfd_variant(path: Path) -> Path:
|
|
126
|
+
return Path(unicodedata.normalize("NFD", str(path)))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _curly_quote_variant(path: Path) -> Path:
|
|
130
|
+
return Path(str(path).replace("'", "\u2019"))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resolve_read_path(file_path: str, cwd: Path) -> Path:
|
|
134
|
+
"""Resolve a read path and try Pi's macOS filename recovery variants."""
|
|
135
|
+
|
|
136
|
+
resolved = resolve_to_cwd(file_path, cwd)
|
|
137
|
+
if _exists(resolved):
|
|
138
|
+
return resolved
|
|
139
|
+
|
|
140
|
+
am_pm_variant = _macos_screenshot_variant(resolved)
|
|
141
|
+
if am_pm_variant != resolved and _exists(am_pm_variant):
|
|
142
|
+
return am_pm_variant
|
|
143
|
+
|
|
144
|
+
nfd_variant = _nfd_variant(resolved)
|
|
145
|
+
if nfd_variant != resolved and _exists(nfd_variant):
|
|
146
|
+
return nfd_variant
|
|
147
|
+
|
|
148
|
+
curly_variant = _curly_quote_variant(resolved)
|
|
149
|
+
if curly_variant != resolved and _exists(curly_variant):
|
|
150
|
+
return curly_variant
|
|
151
|
+
|
|
152
|
+
nfd_curly_variant = _curly_quote_variant(nfd_variant)
|
|
153
|
+
if nfd_curly_variant != resolved and _exists(nfd_curly_variant):
|
|
154
|
+
return nfd_curly_variant
|
|
155
|
+
return resolved
|