axio-tools-docker 0.9.7__tar.gz → 0.9.9__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.9}/PKG-INFO +1 -1
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.9}/pyproject.toml +1 -1
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.9}/src/axio_tools_docker/sandbox.py +37 -16
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.9}/tests/test_integration.py +112 -6
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.9}/.github/workflows/publish.yml +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.9}/.github/workflows/tests.yml +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.9}/.gitignore +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.9}/LICENSE +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.9}/Makefile +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.9}/README.md +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.9}/conftest.py +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.9}/src/axio_tools_docker/__init__.py +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.9}/src/axio_tools_docker/py.typed +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.9}/tests/__init__.py +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.9}/tests/test_sandbox.py +0 -0
- {axio_tools_docker-0.9.7 → axio_tools_docker-0.9.9}/uv.lock +0 -0
|
@@ -72,18 +72,18 @@ async def shell(command: str, timeout: float = 5, cwd: str = ".", stdin: str | N
|
|
|
72
72
|
return await sandbox.exec(cmd, timeout=timeout, stdin=stdin)
|
|
73
73
|
|
|
74
74
|
|
|
75
|
-
async def write_file(
|
|
75
|
+
async def write_file(path: str, content: str, mode: int = 0o644) -> str:
|
|
76
76
|
"""Create or overwrite a file with the given content. Parent directories
|
|
77
77
|
are created automatically. Use this for new files or full rewrites.
|
|
78
78
|
For partial edits prefer patch_file instead."""
|
|
79
79
|
sandbox: DockerSandbox = CONTEXT.get()
|
|
80
|
-
|
|
81
|
-
return await sandbox.write_file(
|
|
80
|
+
resolved = _resolve_path(sandbox.workdir, path)
|
|
81
|
+
return await sandbox.write_file(resolved, content, mode=mode)
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
async def read_file(
|
|
85
|
-
|
|
86
|
-
max_chars: int =
|
|
85
|
+
path: str,
|
|
86
|
+
max_chars: int = 524288, # ~128k tokens at ~4 chars/token
|
|
87
87
|
binary_as_hex: bool = True,
|
|
88
88
|
start_line: int | None = None,
|
|
89
89
|
end_line: int | None = None,
|
|
@@ -96,8 +96,9 @@ async def read_file(
|
|
|
96
96
|
are truncated to max_chars. Always read the file before editing it with
|
|
97
97
|
write_file or patch_file."""
|
|
98
98
|
sandbox: DockerSandbox = CONTEXT.get()
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
resolved = _resolve_path(sandbox.workdir, path)
|
|
100
|
+
sandbox._patched_files.discard(resolved)
|
|
101
|
+
raw = await sandbox.read_file_bytes(resolved)
|
|
101
102
|
try:
|
|
102
103
|
text = raw.decode()
|
|
103
104
|
except UnicodeDecodeError:
|
|
@@ -117,14 +118,14 @@ async def read_file(
|
|
|
117
118
|
return result
|
|
118
119
|
|
|
119
120
|
|
|
120
|
-
async def list_files(
|
|
121
|
+
async def list_files(path: str = ".") -> str:
|
|
121
122
|
"""List files and directories. Shows permissions, size, modification time,
|
|
122
123
|
and name for each entry. Directories are listed first and marked with
|
|
123
124
|
a trailing slash. Use this to explore the project structure before
|
|
124
125
|
reading or editing files."""
|
|
125
126
|
sandbox: DockerSandbox = CONTEXT.get()
|
|
126
|
-
|
|
127
|
-
tar = await sandbox.get_archive(
|
|
127
|
+
resolved = _resolve_path(sandbox.workdir, path)
|
|
128
|
+
tar = await sandbox.get_archive(resolved)
|
|
128
129
|
|
|
129
130
|
members = tar.getmembers()
|
|
130
131
|
if not members:
|
|
@@ -174,23 +175,37 @@ async def run_python(code: str, cwd: str = ".", timeout: float = 5, stdin: str |
|
|
|
174
175
|
return await sandbox.exec(cmd, timeout=timeout, stdin=stdin)
|
|
175
176
|
|
|
176
177
|
|
|
177
|
-
async def patch_file(
|
|
178
|
+
async def patch_file(path: str, from_line: int, to_line: int, content: str, mode: int = 0o644) -> str:
|
|
178
179
|
"""Replace a range of lines in an existing file. Lines are 1-indexed:
|
|
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
|
-
|
|
186
|
-
|
|
188
|
+
resolved = _resolve_path(sandbox.workdir, path)
|
|
189
|
+
if resolved in sandbox._patched_files:
|
|
190
|
+
raise RuntimeError(
|
|
191
|
+
f"{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
|
+
)
|
|
194
|
+
raw = await sandbox.read_file_bytes(resolved)
|
|
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(resolved, result, mode=mode)
|
|
207
|
+
sandbox._patched_files.add(resolved)
|
|
208
|
+
return f"{len(result)} bytes written to {resolved}"
|
|
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 {}
|
|
@@ -526,7 +542,12 @@ class DockerSandbox:
|
|
|
526
542
|
async def get_archive(self, path: str) -> tarfile.TarFile:
|
|
527
543
|
"""Fetch a path from the container as a TarFile object."""
|
|
528
544
|
assert self._container is not None
|
|
529
|
-
|
|
545
|
+
try:
|
|
546
|
+
return cast(tarfile.TarFile, await self._container.get_archive(path=path))
|
|
547
|
+
except aiodocker.exceptions.DockerError as exc:
|
|
548
|
+
if exc.status == 404:
|
|
549
|
+
raise FileNotFoundError(f"No such file or directory: {path!r}") from exc
|
|
550
|
+
raise
|
|
530
551
|
|
|
531
552
|
async def read_file_bytes(self, path: str) -> bytes:
|
|
532
553
|
"""Read a file from inside the container and return raw bytes."""
|
|
@@ -94,14 +94,14 @@ async def test_write_file_mode(sandbox: DockerSandbox) -> None:
|
|
|
94
94
|
async def test_read_file_line_range(sandbox: DockerSandbox) -> None:
|
|
95
95
|
await sandbox.write_file("/workspace/lines.txt", "a\nb\nc\nd\ne\n")
|
|
96
96
|
tool = next(t for t in sandbox.tools if t.name == "read_file")
|
|
97
|
-
result = await tool(
|
|
97
|
+
result = await tool(path="lines.txt", start_line=2, end_line=4)
|
|
98
98
|
assert result.strip() == "b\nc\nd"
|
|
99
99
|
|
|
100
100
|
|
|
101
101
|
async def test_read_file_line_numbers(sandbox: DockerSandbox) -> None:
|
|
102
102
|
await sandbox.write_file("/workspace/numbered.txt", "foo\nbar\n")
|
|
103
103
|
tool = next(t for t in sandbox.tools if t.name == "read_file")
|
|
104
|
-
result = await tool(
|
|
104
|
+
result = await tool(path="numbered.txt", line_numbers=True)
|
|
105
105
|
assert "1\tfoo" in result
|
|
106
106
|
assert "2\tbar" in result
|
|
107
107
|
|
|
@@ -109,10 +109,17 @@ async def test_read_file_line_numbers(sandbox: DockerSandbox) -> None:
|
|
|
109
109
|
async def test_read_file_truncation(sandbox: DockerSandbox) -> None:
|
|
110
110
|
await sandbox.write_file("/workspace/big.txt", "x" * 100)
|
|
111
111
|
tool = next(t for t in sandbox.tools if t.name == "read_file")
|
|
112
|
-
result = await tool(
|
|
112
|
+
result = await tool(path="big.txt", max_chars=10)
|
|
113
113
|
assert "truncated" in result
|
|
114
114
|
|
|
115
115
|
|
|
116
|
+
async def test_read_file_missing_raises_file_not_found(sandbox: DockerSandbox) -> None:
|
|
117
|
+
# A 404 from the container archive endpoint must surface as a clear
|
|
118
|
+
# FileNotFoundError, not a raw Docker 404.
|
|
119
|
+
with pytest.raises(FileNotFoundError, match="No such file or directory"):
|
|
120
|
+
await sandbox.read_file_bytes("/workspace/nope.txt")
|
|
121
|
+
|
|
122
|
+
|
|
116
123
|
async def test_list_files(sandbox: DockerSandbox) -> None:
|
|
117
124
|
await sandbox.exec("mkdir -p /workspace/listing")
|
|
118
125
|
await sandbox.write_file("/workspace/listing/a.py", "x")
|
|
@@ -120,7 +127,7 @@ async def test_list_files(sandbox: DockerSandbox) -> None:
|
|
|
120
127
|
await sandbox.exec("mkdir -p /workspace/listing/subdir")
|
|
121
128
|
|
|
122
129
|
tool = next(t for t in sandbox.tools if t.name == "list_files")
|
|
123
|
-
result = await tool(
|
|
130
|
+
result = await tool(path="/workspace/listing")
|
|
124
131
|
|
|
125
132
|
assert "a.py" in result
|
|
126
133
|
assert "b.txt" in result
|
|
@@ -131,7 +138,7 @@ async def test_list_files(sandbox: DockerSandbox) -> None:
|
|
|
131
138
|
async def test_patch_file(sandbox: DockerSandbox) -> None:
|
|
132
139
|
await sandbox.write_file("/workspace/patch_me.txt", "line1\nline2\nline3\n")
|
|
133
140
|
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
134
|
-
await tool(
|
|
141
|
+
await tool(path="/workspace/patch_me.txt", from_line=2, to_line=2, content="REPLACED")
|
|
135
142
|
raw = await sandbox.read_file_bytes("/workspace/patch_me.txt")
|
|
136
143
|
assert raw == b"line1\nREPLACED\nline3\n"
|
|
137
144
|
|
|
@@ -140,11 +147,110 @@ async def test_patch_file_insert(sandbox: DockerSandbox) -> None:
|
|
|
140
147
|
"""to_line = from_line - 1 inserts without deleting."""
|
|
141
148
|
await sandbox.write_file("/workspace/insert_me.txt", "line1\nline3\n")
|
|
142
149
|
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
143
|
-
await tool(
|
|
150
|
+
await tool(path="/workspace/insert_me.txt", from_line=2, to_line=1, content="line2")
|
|
144
151
|
raw = await sandbox.read_file_bytes("/workspace/insert_me.txt")
|
|
145
152
|
assert raw == b"line1\nline2\nline3\n"
|
|
146
153
|
|
|
147
154
|
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# patch_file validation
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def test_patch_file_from_line_zero_rejected(sandbox: DockerSandbox) -> None:
|
|
161
|
+
await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
|
|
162
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
163
|
+
with pytest.raises(Exception, match="from_line=0"):
|
|
164
|
+
await tool(path="/workspace/v.txt", from_line=0, to_line=1, content="x")
|
|
165
|
+
assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def test_patch_file_negative_from_line_rejected(sandbox: DockerSandbox) -> None:
|
|
169
|
+
await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
|
|
170
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
171
|
+
with pytest.raises(Exception, match="from_line=-1"):
|
|
172
|
+
await tool(path="/workspace/v.txt", from_line=-1, to_line=1, content="x")
|
|
173
|
+
assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def test_patch_file_from_line_beyond_end_rejected(sandbox: DockerSandbox) -> None:
|
|
177
|
+
await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
|
|
178
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
179
|
+
with pytest.raises(Exception, match="from_line=5"):
|
|
180
|
+
await tool(path="/workspace/v.txt", from_line=5, to_line=5, content="x")
|
|
181
|
+
assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def test_patch_file_to_line_beyond_end_rejected(sandbox: DockerSandbox) -> None:
|
|
185
|
+
await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
|
|
186
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
187
|
+
with pytest.raises(Exception, match="to_line=4"):
|
|
188
|
+
await tool(path="/workspace/v.txt", from_line=2, to_line=4, content="x")
|
|
189
|
+
assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def test_patch_file_gap_range_rejected(sandbox: DockerSandbox) -> None:
|
|
193
|
+
"""from_line > to_line + 1 must be rejected."""
|
|
194
|
+
await sandbox.write_file("/workspace/v.txt", "a\nb\nc\nd\ne\n")
|
|
195
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
196
|
+
with pytest.raises(Exception, match="to_line=2"):
|
|
197
|
+
await tool(path="/workspace/v.txt", from_line=4, to_line=2, content="x")
|
|
198
|
+
assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\nd\ne\n"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# patch_file double-patching guard
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def test_patch_file_second_patch_rejected(sandbox: DockerSandbox) -> None:
|
|
207
|
+
"""Second patch to the same file without re-reading must raise RuntimeError."""
|
|
208
|
+
await sandbox.write_file("/workspace/dp.txt", "a\nb\nc\n")
|
|
209
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
210
|
+
await tool(path="/workspace/dp.txt", from_line=1, to_line=1, content="A\n")
|
|
211
|
+
with pytest.raises(Exception, match="already patched"):
|
|
212
|
+
await tool(path="/workspace/dp.txt", from_line=2, to_line=2, content="B\n")
|
|
213
|
+
raw = await sandbox.read_file_bytes("/workspace/dp.txt")
|
|
214
|
+
assert raw == b"A\nb\nc\n"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def test_patch_file_read_clears_double_patch_guard(sandbox: DockerSandbox) -> None:
|
|
218
|
+
"""Re-reading the file via the read_file tool clears the patch guard."""
|
|
219
|
+
await sandbox.write_file("/workspace/rg.txt", "a\nb\nc\n")
|
|
220
|
+
patch_tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
221
|
+
read_tool = next(t for t in sandbox.tools if t.name == "read_file")
|
|
222
|
+
await patch_tool(path="/workspace/rg.txt", from_line=1, to_line=1, content="A\n")
|
|
223
|
+
await read_tool(path="/workspace/rg.txt", line_numbers=True)
|
|
224
|
+
await patch_tool(path="/workspace/rg.txt", from_line=2, to_line=2, content="B\n")
|
|
225
|
+
raw = await sandbox.read_file_bytes("/workspace/rg.txt")
|
|
226
|
+
assert raw == b"A\nB\nc\n"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# patch_file special file content
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def test_patch_file_null_bytes_in_preserved_lines_survive(sandbox: DockerSandbox) -> None:
|
|
235
|
+
"""Null bytes in lines not being replaced must not be stripped."""
|
|
236
|
+
await sandbox.write_file("/workspace/null.txt", "line1\nline2\x00null\nline3\n")
|
|
237
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
238
|
+
await tool(path="/workspace/null.txt", from_line=1, to_line=1, content="replaced\n")
|
|
239
|
+
raw = await sandbox.read_file_bytes("/workspace/null.txt")
|
|
240
|
+
assert b"line2\x00null" in raw, "null byte in preserved line was stripped"
|
|
241
|
+
assert raw.startswith(b"replaced\n")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def test_patch_file_binary_file_raises_before_write(sandbox: DockerSandbox) -> None:
|
|
245
|
+
"""Patching a non-UTF-8 binary file must fail before any modification."""
|
|
246
|
+
await sandbox.exec("python3 -c \"open('/workspace/bin.bin','wb').write(bytes(range(128,200)))\"")
|
|
247
|
+
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
248
|
+
original = await sandbox.read_file_bytes("/workspace/bin.bin")
|
|
249
|
+
with pytest.raises(Exception, match="codec can't decode"):
|
|
250
|
+
await tool(path="/workspace/bin.bin", from_line=1, to_line=1, content="hello\n")
|
|
251
|
+
assert await sandbox.read_file_bytes("/workspace/bin.bin") == original
|
|
252
|
+
|
|
253
|
+
|
|
148
254
|
async def test_run_python(sandbox: DockerSandbox) -> None:
|
|
149
255
|
tool = next(t for t in sandbox.tools if t.name == "run_python")
|
|
150
256
|
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
|