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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axio-tools-docker
3
- Version: 0.9.8
3
+ Version: 0.9.9
4
4
  Summary: Docker sandbox tools for Axio
5
5
  Project-URL: Documentation, https://docs.axio-agent.com
6
6
  Project-URL: Homepage, https://github.com/mosquito/axio-agent
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "axio-tools-docker"
3
- version = "0.9.8"
3
+ version = "0.9.9"
4
4
  description = "Docker sandbox tools for Axio"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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(file_path: str, content: str, mode: int = 0o644) -> str:
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
- path = _resolve_path(sandbox.workdir, file_path)
81
- return await sandbox.write_file(path, content, mode=mode)
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
- filename: str,
86
- max_chars: int = 32768,
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
- path = _resolve_path(sandbox.workdir, filename)
100
- sandbox._patched_files.discard(path)
101
- raw = await sandbox.read_file_bytes(path)
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(directory: str = ".") -> str:
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
- path = _resolve_path(sandbox.workdir, directory)
128
- tar = await sandbox.get_archive(path)
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(file_path: str, from_line: int, to_line: int, content: str, mode: int = 0o644) -> str:
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
- path = _resolve_path(sandbox.workdir, file_path)
189
- if path in sandbox._patched_files:
188
+ resolved = _resolve_path(sandbox.workdir, path)
189
+ if resolved in sandbox._patched_files:
190
190
  raise RuntimeError(
191
- f"{file_path!r} was already patched since the last read. "
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(path)
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(path, result, mode=mode)
207
- sandbox._patched_files.add(path)
208
- return f"{len(result)} bytes written to {path}"
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
- return cast(tarfile.TarFile, await self._container.get_archive(path=path))
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(filename="lines.txt", start_line=2, end_line=4)
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(filename="numbered.txt", line_numbers=True)
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(filename="big.txt", max_chars=10)
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(directory="/workspace/listing")
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(file_path="/workspace/patch_me.txt", from_line=2, to_line=2, content="REPLACED")
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(file_path="/workspace/insert_me.txt", from_line=2, to_line=1, content="line2")
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(file_path="/workspace/v.txt", from_line=0, to_line=1, content="x")
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(file_path="/workspace/v.txt", from_line=-1, to_line=1, content="x")
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(file_path="/workspace/v.txt", from_line=5, to_line=5, content="x")
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(file_path="/workspace/v.txt", from_line=2, to_line=4, content="x")
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(file_path="/workspace/v.txt", from_line=4, to_line=2, content="x")
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(file_path="/workspace/dp.txt", from_line=1, to_line=1, content="A\n")
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(file_path="/workspace/dp.txt", from_line=2, to_line=2, content="B\n")
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(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")
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(file_path="/workspace/null.txt", from_line=1, to_line=1, content="replaced\n")
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(file_path="/workspace/bin.bin", from_line=1, to_line=1, content="hello\n")
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