axio-tools-docker 0.9.7__tar.gz → 0.9.8__tar.gz
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.
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/PKG-INFO +1 -1
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/pyproject.toml +1 -1
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/src/axio_tools_docker/sandbox.py +18 -2
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/tests/test_integration.py +99 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/.github/workflows/publish.yml +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/.github/workflows/tests.yml +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/.gitignore +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/LICENSE +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/Makefile +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/README.md +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/conftest.py +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/src/axio_tools_docker/__init__.py +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/src/axio_tools_docker/py.typed +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/tests/__init__.py +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/tests/test_sandbox.py +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.8}/uv.lock +0 -0
|
@@ -97,6 +97,7 @@ async def read_file(
|
|
|
97
97
|
write_file or patch_file."""
|
|
98
98
|
sandbox: DockerSandbox = CONTEXT.get()
|
|
99
99
|
path = _resolve_path(sandbox.workdir, filename)
|
|
100
|
+
sandbox._patched_files.discard(path)
|
|
100
101
|
raw = await sandbox.read_file_bytes(path)
|
|
101
102
|
try:
|
|
102
103
|
text = raw.decode()
|
|
@@ -179,18 +180,32 @@ async def patch_file(file_path: str, from_line: int, to_line: int, content: str,
|
|
|
179
180
|
from_line and to_line are both inclusive (from_line=2, to_line=4 replaces
|
|
180
181
|
lines 2, 3, 4). To insert without deleting, set to_line = from_line - 1.
|
|
181
182
|
Always read the file first with line_numbers=True to get correct line numbers.
|
|
183
|
+
Patch each file at most once per read — re-read with line_numbers=True after
|
|
184
|
+
patching before issuing another patch to the same file.
|
|
182
185
|
Use this for surgical edits instead of rewriting the whole file with
|
|
183
186
|
write_file."""
|
|
184
187
|
sandbox: DockerSandbox = CONTEXT.get()
|
|
185
188
|
path = _resolve_path(sandbox.workdir, file_path)
|
|
189
|
+
if path in sandbox._patched_files:
|
|
190
|
+
raise RuntimeError(
|
|
191
|
+
f"{file_path!r} was already patched since the last read. "
|
|
192
|
+
"Re-read the file with line_numbers=True to get updated line numbers before patching again."
|
|
193
|
+
)
|
|
186
194
|
raw = await sandbox.read_file_bytes(path)
|
|
187
195
|
lines = raw.decode().splitlines(keepends=True)
|
|
196
|
+
n = len(lines)
|
|
197
|
+
if not (1 <= from_line <= n + 1):
|
|
198
|
+
raise ValueError(f"from_line={from_line} out of range; file has {n} lines (valid: 1..{n + 1})")
|
|
199
|
+
if not (from_line - 1 <= to_line <= n):
|
|
200
|
+
raise ValueError(f"to_line={to_line} out of range (valid: {from_line - 1}..{n})")
|
|
188
201
|
content_lines = content.splitlines(keepends=True)
|
|
189
202
|
if content_lines and not content_lines[-1].endswith("\n"):
|
|
190
203
|
content_lines[-1] += "\n"
|
|
191
204
|
new_lines = lines[: from_line - 1] + content_lines + lines[to_line:]
|
|
192
|
-
|
|
193
|
-
|
|
205
|
+
result = "".join(new_lines)
|
|
206
|
+
await sandbox.write_file(path, result, mode=mode)
|
|
207
|
+
sandbox._patched_files.add(path)
|
|
208
|
+
return f"{len(result)} bytes written to {path}"
|
|
194
209
|
|
|
195
210
|
|
|
196
211
|
# ---------------------------------------------------------------------------
|
|
@@ -302,6 +317,7 @@ class DockerSandbox:
|
|
|
302
317
|
self.privileged = privileged
|
|
303
318
|
self.ulimits: dict[str, int | tuple[int, int]] = ulimits or {}
|
|
304
319
|
self.tmpfs: dict[str, str] = tmpfs or {}
|
|
320
|
+
self._patched_files: set[str] = set()
|
|
305
321
|
self.ports: dict[int, int] = ports or {}
|
|
306
322
|
self.platform = platform
|
|
307
323
|
self.extra_hosts: dict[str, str] = extra_hosts or {}
|
|
@@ -145,6 +145,105 @@ async def test_patch_file_insert(sandbox: DockerSandbox) -> None:
|
|
|
145
145
|
assert raw == b"line1\nline2\nline3\n"
|
|
146
146
|
|
|
147
147
|
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# patch_file validation
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def test_patch_file_from_line_zero_rejected(sandbox: DockerSandbox) -> None:
|
|
154
|
+
await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
|
|
155
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
156
|
+
with pytest.raises(Exception, match="from_line=0"):
|
|
157
|
+
await tool(file_path="/workspace/v.txt", from_line=0, to_line=1, content="x")
|
|
158
|
+
assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def test_patch_file_negative_from_line_rejected(sandbox: DockerSandbox) -> None:
|
|
162
|
+
await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
|
|
163
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
164
|
+
with pytest.raises(Exception, match="from_line=-1"):
|
|
165
|
+
await tool(file_path="/workspace/v.txt", from_line=-1, to_line=1, content="x")
|
|
166
|
+
assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def test_patch_file_from_line_beyond_end_rejected(sandbox: DockerSandbox) -> None:
|
|
170
|
+
await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
|
|
171
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
172
|
+
with pytest.raises(Exception, match="from_line=5"):
|
|
173
|
+
await tool(file_path="/workspace/v.txt", from_line=5, to_line=5, content="x")
|
|
174
|
+
assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def test_patch_file_to_line_beyond_end_rejected(sandbox: DockerSandbox) -> None:
|
|
178
|
+
await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
|
|
179
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
180
|
+
with pytest.raises(Exception, match="to_line=4"):
|
|
181
|
+
await tool(file_path="/workspace/v.txt", from_line=2, to_line=4, content="x")
|
|
182
|
+
assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def test_patch_file_gap_range_rejected(sandbox: DockerSandbox) -> None:
|
|
186
|
+
"""from_line > to_line + 1 must be rejected."""
|
|
187
|
+
await sandbox.write_file("/workspace/v.txt", "a\nb\nc\nd\ne\n")
|
|
188
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
189
|
+
with pytest.raises(Exception, match="to_line=2"):
|
|
190
|
+
await tool(file_path="/workspace/v.txt", from_line=4, to_line=2, content="x")
|
|
191
|
+
assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\nd\ne\n"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# patch_file double-patching guard
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def test_patch_file_second_patch_rejected(sandbox: DockerSandbox) -> None:
|
|
200
|
+
"""Second patch to the same file without re-reading must raise RuntimeError."""
|
|
201
|
+
await sandbox.write_file("/workspace/dp.txt", "a\nb\nc\n")
|
|
202
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
203
|
+
await tool(file_path="/workspace/dp.txt", from_line=1, to_line=1, content="A\n")
|
|
204
|
+
with pytest.raises(Exception, match="already patched"):
|
|
205
|
+
await tool(file_path="/workspace/dp.txt", from_line=2, to_line=2, content="B\n")
|
|
206
|
+
raw = await sandbox.read_file_bytes("/workspace/dp.txt")
|
|
207
|
+
assert raw == b"A\nb\nc\n"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def test_patch_file_read_clears_double_patch_guard(sandbox: DockerSandbox) -> None:
|
|
211
|
+
"""Re-reading the file via the read_file tool clears the patch guard."""
|
|
212
|
+
await sandbox.write_file("/workspace/rg.txt", "a\nb\nc\n")
|
|
213
|
+
patch_tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
214
|
+
read_tool = next(t for t in sandbox.tools if t.name == "read_file")
|
|
215
|
+
await patch_tool(file_path="/workspace/rg.txt", from_line=1, to_line=1, content="A\n")
|
|
216
|
+
await read_tool(filename="/workspace/rg.txt", line_numbers=True)
|
|
217
|
+
await patch_tool(file_path="/workspace/rg.txt", from_line=2, to_line=2, content="B\n")
|
|
218
|
+
raw = await sandbox.read_file_bytes("/workspace/rg.txt")
|
|
219
|
+
assert raw == b"A\nB\nc\n"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# patch_file special file content
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def test_patch_file_null_bytes_in_preserved_lines_survive(sandbox: DockerSandbox) -> None:
|
|
228
|
+
"""Null bytes in lines not being replaced must not be stripped."""
|
|
229
|
+
await sandbox.write_file("/workspace/null.txt", "line1\nline2\x00null\nline3\n")
|
|
230
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
231
|
+
await tool(file_path="/workspace/null.txt", from_line=1, to_line=1, content="replaced\n")
|
|
232
|
+
raw = await sandbox.read_file_bytes("/workspace/null.txt")
|
|
233
|
+
assert b"line2\x00null" in raw, "null byte in preserved line was stripped"
|
|
234
|
+
assert raw.startswith(b"replaced\n")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def test_patch_file_binary_file_raises_before_write(sandbox: DockerSandbox) -> None:
|
|
238
|
+
"""Patching a non-UTF-8 binary file must fail before any modification."""
|
|
239
|
+
await sandbox.exec("python3 -c \"open('/workspace/bin.bin','wb').write(bytes(range(128,200)))\"")
|
|
240
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
241
|
+
original = await sandbox.read_file_bytes("/workspace/bin.bin")
|
|
242
|
+
with pytest.raises(Exception, match="codec can't decode"):
|
|
243
|
+
await tool(file_path="/workspace/bin.bin", from_line=1, to_line=1, content="hello\n")
|
|
244
|
+
assert await sandbox.read_file_bytes("/workspace/bin.bin") == original
|
|
245
|
+
|
|
246
|
+
|
|
148
247
|
async def test_run_python(sandbox: DockerSandbox) -> None:
|
|
149
248
|
tool = next(t for t in sandbox.tools if t.name == "run_python")
|
|
150
249
|
result = await tool(code="print(2 + 2)")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|