axio-tools-docker 0.9.8__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.8 → axio_tools_docker-0.9.9}/PKG-INFO +1 -1
- {axio_tools_docker-0.9.8 → axio_tools_docker-0.9.9}/pyproject.toml +1 -1
- {axio_tools_docker-0.9.8 → axio_tools_docker-0.9.9}/src/axio_tools_docker/sandbox.py +25 -20
- {axio_tools_docker-0.9.8 → axio_tools_docker-0.9.9}/tests/test_integration.py +25 -18
- {axio_tools_docker-0.9.8 → axio_tools_docker-0.9.9}/.github/workflows/publish.yml +0 -0
- {axio_tools_docker-0.9.8 → axio_tools_docker-0.9.9}/.github/workflows/tests.yml +0 -0
- {axio_tools_docker-0.9.8 → axio_tools_docker-0.9.9}/.gitignore +0 -0
- {axio_tools_docker-0.9.8 → axio_tools_docker-0.9.9}/LICENSE +0 -0
- {axio_tools_docker-0.9.8 → axio_tools_docker-0.9.9}/Makefile +0 -0
- {axio_tools_docker-0.9.8 → axio_tools_docker-0.9.9}/README.md +0 -0
- {axio_tools_docker-0.9.8 → axio_tools_docker-0.9.9}/conftest.py +0 -0
- {axio_tools_docker-0.9.8 → axio_tools_docker-0.9.9}/src/axio_tools_docker/__init__.py +0 -0
- {axio_tools_docker-0.9.8 → axio_tools_docker-0.9.9}/src/axio_tools_docker/py.typed +0 -0
- {axio_tools_docker-0.9.8 → axio_tools_docker-0.9.9}/tests/__init__.py +0 -0
- {axio_tools_docker-0.9.8 → axio_tools_docker-0.9.9}/tests/test_sandbox.py +0 -0
- {axio_tools_docker-0.9.8 → 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,9 +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
|
-
sandbox._patched_files.discard(
|
|
101
|
-
raw = await sandbox.read_file_bytes(
|
|
99
|
+
resolved = _resolve_path(sandbox.workdir, path)
|
|
100
|
+
sandbox._patched_files.discard(resolved)
|
|
101
|
+
raw = await sandbox.read_file_bytes(resolved)
|
|
102
102
|
try:
|
|
103
103
|
text = raw.decode()
|
|
104
104
|
except UnicodeDecodeError:
|
|
@@ -118,14 +118,14 @@ async def read_file(
|
|
|
118
118
|
return result
|
|
119
119
|
|
|
120
120
|
|
|
121
|
-
async def list_files(
|
|
121
|
+
async def list_files(path: str = ".") -> str:
|
|
122
122
|
"""List files and directories. Shows permissions, size, modification time,
|
|
123
123
|
and name for each entry. Directories are listed first and marked with
|
|
124
124
|
a trailing slash. Use this to explore the project structure before
|
|
125
125
|
reading or editing files."""
|
|
126
126
|
sandbox: DockerSandbox = CONTEXT.get()
|
|
127
|
-
|
|
128
|
-
tar = await sandbox.get_archive(
|
|
127
|
+
resolved = _resolve_path(sandbox.workdir, path)
|
|
128
|
+
tar = await sandbox.get_archive(resolved)
|
|
129
129
|
|
|
130
130
|
members = tar.getmembers()
|
|
131
131
|
if not members:
|
|
@@ -175,7 +175,7 @@ async def run_python(code: str, cwd: str = ".", timeout: float = 5, stdin: str |
|
|
|
175
175
|
return await sandbox.exec(cmd, timeout=timeout, stdin=stdin)
|
|
176
176
|
|
|
177
177
|
|
|
178
|
-
async def patch_file(
|
|
178
|
+
async def patch_file(path: str, from_line: int, to_line: int, content: str, mode: int = 0o644) -> str:
|
|
179
179
|
"""Replace a range of lines in an existing file. Lines are 1-indexed:
|
|
180
180
|
from_line and to_line are both inclusive (from_line=2, to_line=4 replaces
|
|
181
181
|
lines 2, 3, 4). To insert without deleting, set to_line = from_line - 1.
|
|
@@ -185,13 +185,13 @@ async def patch_file(file_path: str, from_line: int, to_line: int, content: str,
|
|
|
185
185
|
Use this for surgical edits instead of rewriting the whole file with
|
|
186
186
|
write_file."""
|
|
187
187
|
sandbox: DockerSandbox = CONTEXT.get()
|
|
188
|
-
|
|
189
|
-
if
|
|
188
|
+
resolved = _resolve_path(sandbox.workdir, path)
|
|
189
|
+
if resolved in sandbox._patched_files:
|
|
190
190
|
raise RuntimeError(
|
|
191
|
-
f"{
|
|
191
|
+
f"{path!r} was already patched since the last read. "
|
|
192
192
|
"Re-read the file with line_numbers=True to get updated line numbers before patching again."
|
|
193
193
|
)
|
|
194
|
-
raw = await sandbox.read_file_bytes(
|
|
194
|
+
raw = await sandbox.read_file_bytes(resolved)
|
|
195
195
|
lines = raw.decode().splitlines(keepends=True)
|
|
196
196
|
n = len(lines)
|
|
197
197
|
if not (1 <= from_line <= n + 1):
|
|
@@ -203,9 +203,9 @@ async def patch_file(file_path: str, from_line: int, to_line: int, content: str,
|
|
|
203
203
|
content_lines[-1] += "\n"
|
|
204
204
|
new_lines = lines[: from_line - 1] + content_lines + lines[to_line:]
|
|
205
205
|
result = "".join(new_lines)
|
|
206
|
-
await sandbox.write_file(
|
|
207
|
-
sandbox._patched_files.add(
|
|
208
|
-
return f"{len(result)} bytes written to {
|
|
206
|
+
await sandbox.write_file(resolved, result, mode=mode)
|
|
207
|
+
sandbox._patched_files.add(resolved)
|
|
208
|
+
return f"{len(result)} bytes written to {resolved}"
|
|
209
209
|
|
|
210
210
|
|
|
211
211
|
# ---------------------------------------------------------------------------
|
|
@@ -542,7 +542,12 @@ class DockerSandbox:
|
|
|
542
542
|
async def get_archive(self, path: str) -> tarfile.TarFile:
|
|
543
543
|
"""Fetch a path from the container as a TarFile object."""
|
|
544
544
|
assert self._container is not None
|
|
545
|
-
|
|
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
|
|
546
551
|
|
|
547
552
|
async def read_file_bytes(self, path: str) -> bytes:
|
|
548
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,7 +147,7 @@ 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
|
|
|
@@ -154,7 +161,7 @@ async def test_patch_file_from_line_zero_rejected(sandbox: DockerSandbox) -> Non
|
|
|
154
161
|
await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
|
|
155
162
|
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
156
163
|
with pytest.raises(Exception, match="from_line=0"):
|
|
157
|
-
await tool(
|
|
164
|
+
await tool(path="/workspace/v.txt", from_line=0, to_line=1, content="x")
|
|
158
165
|
assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
|
|
159
166
|
|
|
160
167
|
|
|
@@ -162,7 +169,7 @@ async def test_patch_file_negative_from_line_rejected(sandbox: DockerSandbox) ->
|
|
|
162
169
|
await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
|
|
163
170
|
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
164
171
|
with pytest.raises(Exception, match="from_line=-1"):
|
|
165
|
-
await tool(
|
|
172
|
+
await tool(path="/workspace/v.txt", from_line=-1, to_line=1, content="x")
|
|
166
173
|
assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
|
|
167
174
|
|
|
168
175
|
|
|
@@ -170,7 +177,7 @@ async def test_patch_file_from_line_beyond_end_rejected(sandbox: DockerSandbox)
|
|
|
170
177
|
await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
|
|
171
178
|
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
172
179
|
with pytest.raises(Exception, match="from_line=5"):
|
|
173
|
-
await tool(
|
|
180
|
+
await tool(path="/workspace/v.txt", from_line=5, to_line=5, content="x")
|
|
174
181
|
assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
|
|
175
182
|
|
|
176
183
|
|
|
@@ -178,7 +185,7 @@ async def test_patch_file_to_line_beyond_end_rejected(sandbox: DockerSandbox) ->
|
|
|
178
185
|
await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
|
|
179
186
|
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
180
187
|
with pytest.raises(Exception, match="to_line=4"):
|
|
181
|
-
await tool(
|
|
188
|
+
await tool(path="/workspace/v.txt", from_line=2, to_line=4, content="x")
|
|
182
189
|
assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
|
|
183
190
|
|
|
184
191
|
|
|
@@ -187,7 +194,7 @@ async def test_patch_file_gap_range_rejected(sandbox: DockerSandbox) -> None:
|
|
|
187
194
|
await sandbox.write_file("/workspace/v.txt", "a\nb\nc\nd\ne\n")
|
|
188
195
|
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
189
196
|
with pytest.raises(Exception, match="to_line=2"):
|
|
190
|
-
await tool(
|
|
197
|
+
await tool(path="/workspace/v.txt", from_line=4, to_line=2, content="x")
|
|
191
198
|
assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\nd\ne\n"
|
|
192
199
|
|
|
193
200
|
|
|
@@ -200,9 +207,9 @@ async def test_patch_file_second_patch_rejected(sandbox: DockerSandbox) -> None:
|
|
|
200
207
|
"""Second patch to the same file without re-reading must raise RuntimeError."""
|
|
201
208
|
await sandbox.write_file("/workspace/dp.txt", "a\nb\nc\n")
|
|
202
209
|
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
203
|
-
await tool(
|
|
210
|
+
await tool(path="/workspace/dp.txt", from_line=1, to_line=1, content="A\n")
|
|
204
211
|
with pytest.raises(Exception, match="already patched"):
|
|
205
|
-
await tool(
|
|
212
|
+
await tool(path="/workspace/dp.txt", from_line=2, to_line=2, content="B\n")
|
|
206
213
|
raw = await sandbox.read_file_bytes("/workspace/dp.txt")
|
|
207
214
|
assert raw == b"A\nb\nc\n"
|
|
208
215
|
|
|
@@ -212,9 +219,9 @@ async def test_patch_file_read_clears_double_patch_guard(sandbox: DockerSandbox)
|
|
|
212
219
|
await sandbox.write_file("/workspace/rg.txt", "a\nb\nc\n")
|
|
213
220
|
patch_tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
214
221
|
read_tool = next(t for t in sandbox.tools if t.name == "read_file")
|
|
215
|
-
await patch_tool(
|
|
216
|
-
await read_tool(
|
|
217
|
-
await patch_tool(
|
|
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")
|
|
218
225
|
raw = await sandbox.read_file_bytes("/workspace/rg.txt")
|
|
219
226
|
assert raw == b"A\nB\nc\n"
|
|
220
227
|
|
|
@@ -228,7 +235,7 @@ async def test_patch_file_null_bytes_in_preserved_lines_survive(sandbox: DockerS
|
|
|
228
235
|
"""Null bytes in lines not being replaced must not be stripped."""
|
|
229
236
|
await sandbox.write_file("/workspace/null.txt", "line1\nline2\x00null\nline3\n")
|
|
230
237
|
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
231
|
-
await tool(
|
|
238
|
+
await tool(path="/workspace/null.txt", from_line=1, to_line=1, content="replaced\n")
|
|
232
239
|
raw = await sandbox.read_file_bytes("/workspace/null.txt")
|
|
233
240
|
assert b"line2\x00null" in raw, "null byte in preserved line was stripped"
|
|
234
241
|
assert raw.startswith(b"replaced\n")
|
|
@@ -240,7 +247,7 @@ async def test_patch_file_binary_file_raises_before_write(sandbox: DockerSandbox
|
|
|
240
247
|
tool = next(t for t in sandbox.tools if t.name == "patch_file")
|
|
241
248
|
original = await sandbox.read_file_bytes("/workspace/bin.bin")
|
|
242
249
|
with pytest.raises(Exception, match="codec can't decode"):
|
|
243
|
-
await tool(
|
|
250
|
+
await tool(path="/workspace/bin.bin", from_line=1, to_line=1, content="hello\n")
|
|
244
251
|
assert await sandbox.read_file_bytes("/workspace/bin.bin") == original
|
|
245
252
|
|
|
246
253
|
|
|
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
|