axio-tools-local 0.2.2__tar.gz → 0.2.4__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.
Files changed (29) hide show
  1. {axio_tools_local-0.2.2 → axio_tools_local-0.2.4}/PKG-INFO +4 -1
  2. {axio_tools_local-0.2.2 → axio_tools_local-0.2.4}/pyproject.toml +6 -1
  3. {axio_tools_local-0.2.2 → axio_tools_local-0.2.4}/src/axio_tools_local/list_files.py +5 -1
  4. {axio_tools_local-0.2.2 → axio_tools_local-0.2.4}/src/axio_tools_local/patch_file.py +12 -9
  5. axio_tools_local-0.2.4/src/axio_tools_local/read_file.py +48 -0
  6. {axio_tools_local-0.2.2 → axio_tools_local-0.2.4}/src/axio_tools_local/run_python.py +10 -8
  7. {axio_tools_local-0.2.2 → axio_tools_local-0.2.4}/src/axio_tools_local/shell.py +13 -10
  8. axio_tools_local-0.2.4/tests/test_list_files.py +113 -0
  9. axio_tools_local-0.2.4/tests/test_patch_file.py +188 -0
  10. axio_tools_local-0.2.4/tests/test_read_file.py +170 -0
  11. axio_tools_local-0.2.4/tests/test_run_python.py +113 -0
  12. axio_tools_local-0.2.4/tests/test_shell.py +89 -0
  13. axio_tools_local-0.2.4/tests/test_write_file.py +90 -0
  14. {axio_tools_local-0.2.2 → axio_tools_local-0.2.4}/uv.lock +1 -1
  15. axio_tools_local-0.2.2/src/axio_tools_local/read_file.py +0 -41
  16. axio_tools_local-0.2.2/tests/test_list_files.py +0 -62
  17. axio_tools_local-0.2.2/tests/test_patch_file.py +0 -45
  18. axio_tools_local-0.2.2/tests/test_read_file.py +0 -49
  19. axio_tools_local-0.2.2/tests/test_run_python.py +0 -38
  20. axio_tools_local-0.2.2/tests/test_shell.py +0 -45
  21. axio_tools_local-0.2.2/tests/test_write_file.py +0 -37
  22. {axio_tools_local-0.2.2 → axio_tools_local-0.2.4}/.github/workflows/publish.yml +0 -0
  23. {axio_tools_local-0.2.2 → axio_tools_local-0.2.4}/.github/workflows/tests.yml +0 -0
  24. {axio_tools_local-0.2.2 → axio_tools_local-0.2.4}/LICENSE +0 -0
  25. {axio_tools_local-0.2.2 → axio_tools_local-0.2.4}/Makefile +0 -0
  26. {axio_tools_local-0.2.2 → axio_tools_local-0.2.4}/README.md +0 -0
  27. {axio_tools_local-0.2.2 → axio_tools_local-0.2.4}/src/axio_tools_local/__init__.py +0 -0
  28. {axio_tools_local-0.2.2 → axio_tools_local-0.2.4}/src/axio_tools_local/write_file.py +0 -0
  29. {axio_tools_local-0.2.2 → axio_tools_local-0.2.4}/tests/conftest.py +0 -0
@@ -1,9 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axio-tools-local
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Core filesystem and execution tools for Axio
5
+ Project-URL: Homepage, https://github.com/axio-agent/axio-tools-local
6
+ Project-URL: Repository, https://github.com/axio-agent/axio-tools-local
5
7
  License: MIT
6
8
  License-File: LICENSE
9
+ Keywords: agent,ai,filesystem,llm,shell,tools
7
10
  Requires-Python: >=3.12
8
11
  Requires-Dist: axio
9
12
  Description-Content-Type: text/markdown
@@ -1,10 +1,11 @@
1
1
  [project]
2
2
  name = "axio-tools-local"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "Core filesystem and execution tools for Axio"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
7
7
  license = {text = "MIT"}
8
+ keywords = ["llm", "agent", "ai", "tools", "filesystem", "shell"]
8
9
  dependencies = ["axio"]
9
10
 
10
11
  [project.entry-points."axio.tools"]
@@ -15,6 +16,10 @@ patch_file = "axio_tools_local.patch_file:PatchFile"
15
16
  read_file = "axio_tools_local.read_file:ReadFile"
16
17
  list_files = "axio_tools_local.list_files:ListFiles"
17
18
 
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/axio-agent/axio-tools-local"
22
+ Repository = "https://github.com/axio-agent/axio-tools-local"
18
23
  [build-system]
19
24
  requires = ["hatchling"]
20
25
  build-backend = "hatchling.build"
@@ -24,7 +24,11 @@ class ListFiles(ToolHandler):
24
24
  raise FileNotFoundError(f"{self.directory} is not a valid directory")
25
25
  lines: list[str] = []
26
26
  for fpath in sorted(path.glob("*"), key=lambda p: (not p.is_dir(), p.name)):
27
- st = fpath.stat()
27
+ try:
28
+ st = fpath.stat()
29
+ except OSError:
30
+ lines.append(f"{'?':10} {'?':>8} {'?':12} {fpath.name} [broken symlink]")
31
+ continue
28
32
  mode = stat_module.filemode(st.st_mode)
29
33
  size = st.st_size
30
34
  mtime = datetime.fromtimestamp(st.st_mtime).strftime("%b %d %H:%M")
@@ -6,11 +6,12 @@ from axio.tool import ToolHandler
6
6
 
7
7
 
8
8
  class PatchFile(ToolHandler):
9
- """Replace a range of lines in an existing file. Lines are 0-indexed:
10
- from_line is inclusive, to_line is exclusive. The content string replaces
11
- lines[from_line:to_line]. Always read the file first to get correct
12
- line numbers. Use this for surgical edits instead of rewriting the
13
- whole file with write_file."""
9
+ """Replace a range of lines in an existing file. Lines are 1-indexed:
10
+ from_line and to_line are both inclusive (from_line=2, to_line=4 replaces
11
+ lines 2, 3, 4). To insert without deleting, set to_line = from_line - 1.
12
+ Always read the file first with indexed=True to get correct line numbers.
13
+ Use this for surgical edits instead of rewriting the whole file with
14
+ write_file."""
14
15
 
15
16
  file_path: str
16
17
  mode: int = 0o644
@@ -33,11 +34,13 @@ class PatchFile(ToolHandler):
33
34
  with path.open("r") as f:
34
35
  lines = f.readlines()
35
36
 
36
- # content lines
37
- content = self.content.splitlines()
37
+ # content lines — preserve existing newlines
38
+ content_lines = self.content.splitlines(keepends=True)
39
+ if content_lines and not content_lines[-1].endswith("\n"):
40
+ content_lines[-1] += "\n"
38
41
 
39
- # patch lines
40
- new_lines = lines[: self.from_line] + content + lines[self.to_line :]
42
+ # patch lines (from_line/to_line are 1-indexed, both inclusive)
43
+ new_lines = lines[: self.from_line - 1] + content_lines + lines[self.to_line :]
41
44
  with path.open("w") as f:
42
45
  f.writelines(new_lines)
43
46
  return f"{f.tell()} bytes written to {self.file_path}"
@@ -0,0 +1,48 @@
1
+ import asyncio
2
+ import os
3
+
4
+ from axio.tool import ToolHandler
5
+
6
+
7
+ class ReadFile(ToolHandler):
8
+ """Read file contents. Returns text for text files, hex for binaries.
9
+ Lines are 1-indexed: start_line=1 is the first line, end_line=3 includes
10
+ line 3. Pass indexed=True to prefix each line with its 1-based line number
11
+ (tab-separated) — useful before calling patch_file. Large files are
12
+ truncated to max_chars. Always read the file before editing it with
13
+ write_file or patch_file."""
14
+
15
+ filename: str
16
+ max_chars: int = 32768
17
+ binary_as_hex: bool = True
18
+ start_line: int | None = None
19
+ end_line: int | None = None
20
+ indexed: bool = False
21
+
22
+ def __repr__(self) -> str:
23
+ return f"ReadFile(filename={self.filename!r})"
24
+
25
+ def _blocking(self) -> str:
26
+ path = os.path.join(os.getcwd(), self.filename)
27
+ with open(path, "rb") as f:
28
+ raw = f.read()
29
+ try:
30
+ text = raw.decode()
31
+ except UnicodeDecodeError:
32
+ if self.binary_as_hex:
33
+ return "Encoded binary data HEX: " + raw[: self.max_chars].hex()
34
+ raise
35
+ all_lines = text.splitlines(keepends=True)
36
+ start = 0 if self.start_line is None else self.start_line - 1
37
+ end = len(all_lines) if self.end_line is None else self.end_line
38
+ lines = all_lines[start:end]
39
+ if self.indexed:
40
+ result = "".join(f"{start + 1 + i}\t{line}" for i, line in enumerate(lines))
41
+ else:
42
+ result = "".join(lines)
43
+ if len(result) > self.max_chars:
44
+ return result[: self.max_chars] + "\n...[truncated]"
45
+ return result
46
+
47
+ async def __call__(self) -> str:
48
+ return await asyncio.to_thread(self._blocking)
@@ -41,16 +41,18 @@ class RunPython(ToolHandler):
41
41
  input=self.stdin if self.stdin is not None else None,
42
42
  stdin=subprocess.DEVNULL if self.stdin is None else None,
43
43
  )
44
- output = ""
45
- if result.stdout:
46
- output += result.stdout
47
- if result.stderr:
48
- output += f"\n[stderr]\n{result.stderr}"
49
- if result.returncode != 0:
50
- output += f"\n[exit code: {result.returncode}]"
51
- return output.strip() or "(no output)"
44
+ except subprocess.TimeoutExpired:
45
+ return f"[timeout: code exceeded {self.timeout}s]"
52
46
  finally:
53
47
  os.unlink(path)
48
+ output = ""
49
+ if result.stdout:
50
+ output += result.stdout
51
+ if result.stderr:
52
+ output += f"\n[stderr]\n{result.stderr}"
53
+ if result.returncode != 0:
54
+ output += f"\n[exit code: {result.returncode}]"
55
+ return output.strip() or "(no output)"
54
56
 
55
57
  async def __call__(self) -> str:
56
58
  return await asyncio.to_thread(self._blocking)
@@ -21,16 +21,19 @@ class Shell(ToolHandler):
21
21
  return f"Shell(command={_short(self.command)!r}, cwd={self.cwd!r})"
22
22
 
23
23
  def _blocking(self) -> str:
24
- result = subprocess.run(
25
- self.command,
26
- shell=True,
27
- capture_output=True,
28
- text=True,
29
- timeout=self.timeout,
30
- cwd=self.cwd,
31
- input=self.stdin if self.stdin is not None else None,
32
- stdin=subprocess.DEVNULL if self.stdin is None else None,
33
- )
24
+ try:
25
+ result = subprocess.run(
26
+ self.command,
27
+ shell=True,
28
+ capture_output=True,
29
+ text=True,
30
+ timeout=self.timeout,
31
+ cwd=self.cwd,
32
+ input=self.stdin if self.stdin is not None else None,
33
+ stdin=subprocess.DEVNULL if self.stdin is None else None,
34
+ )
35
+ except subprocess.TimeoutExpired:
36
+ return f"[timeout: command exceeded {self.timeout}s]"
34
37
  output = ""
35
38
  if result.stdout:
36
39
  output += result.stdout
@@ -0,0 +1,113 @@
1
+ """Tests for ListFiles tool handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from axio_tools_local.list_files import ListFiles
11
+
12
+
13
+ @pytest.fixture()
14
+ def tmp_cwd(tmp_path: Path) -> Path:
15
+ old = os.getcwd()
16
+ os.chdir(tmp_path)
17
+ yield tmp_path
18
+ os.chdir(old)
19
+
20
+
21
+ async def ls(directory: str = ".") -> str:
22
+ return await ListFiles(directory=directory)()
23
+
24
+
25
+ class TestListFilesBasic:
26
+ async def test_lists_files_and_dirs(self, tmp_cwd: Path) -> None:
27
+ (tmp_cwd / "a.txt").write_text("a")
28
+ (tmp_cwd / "b.txt").write_text("b")
29
+ (tmp_cwd / "subdir").mkdir()
30
+ result = await ls()
31
+ assert "a.txt" in result
32
+ assert "b.txt" in result
33
+ assert "subdir/" in result
34
+
35
+ async def test_dirs_listed_before_files(self, tmp_cwd: Path) -> None:
36
+ (tmp_cwd / "z_file.txt").write_text("z")
37
+ (tmp_cwd / "a_dir").mkdir()
38
+ result = await ls()
39
+ lines = result.splitlines()
40
+ dir_idx = next(i for i, l in enumerate(lines) if "a_dir/" in l)
41
+ file_idx = next(i for i, l in enumerate(lines) if "z_file.txt" in l)
42
+ assert dir_idx < file_idx
43
+
44
+ async def test_empty_directory(self, tmp_cwd: Path) -> None:
45
+ (tmp_cwd / "empty").mkdir()
46
+ result = await ls("empty")
47
+ assert result == "(empty directory)"
48
+
49
+ async def test_default_directory_is_dot(self, tmp_cwd: Path) -> None:
50
+ (tmp_cwd / "file.txt").write_text("x")
51
+ result = await ls()
52
+ assert "file.txt" in result
53
+
54
+ async def test_subdirectory_path(self, tmp_cwd: Path) -> None:
55
+ sub = tmp_cwd / "sub"
56
+ sub.mkdir()
57
+ (sub / "inner.txt").write_text("x")
58
+ result = await ls("sub")
59
+ assert "inner.txt" in result
60
+
61
+ async def test_not_a_directory_raises(self, tmp_cwd: Path) -> None:
62
+ with pytest.raises(FileNotFoundError):
63
+ await ls("nope")
64
+
65
+ async def test_shows_permissions(self, tmp_cwd: Path) -> None:
66
+ (tmp_cwd / "f.txt").write_text("x")
67
+ result = await ls()
68
+ # permissions line starts with '-' for files
69
+ assert any(line.startswith("-") for line in result.splitlines())
70
+
71
+ async def test_shows_size(self, tmp_cwd: Path) -> None:
72
+ (tmp_cwd / "f.txt").write_text("hello")
73
+ result = await ls()
74
+ assert "5" in result
75
+
76
+ async def test_trailing_slash_on_dirs_only(self, tmp_cwd: Path) -> None:
77
+ (tmp_cwd / "mydir").mkdir()
78
+ (tmp_cwd / "myfile.txt").write_text("x")
79
+ result = await ls()
80
+ assert "mydir/" in result
81
+ assert "myfile.txt/" not in result
82
+
83
+
84
+ class TestListFilesSymlinks:
85
+ async def test_broken_symlink_does_not_crash(self, tmp_cwd: Path) -> None:
86
+ """Broken symlink must produce a [broken symlink] entry, not an exception."""
87
+ link = tmp_cwd / "dead_link"
88
+ link.symlink_to(tmp_cwd / "nonexistent_target")
89
+ result = await ls()
90
+ assert "dead_link" in result
91
+ assert "[broken symlink]" in result
92
+
93
+ async def test_valid_symlink_to_file(self, tmp_cwd: Path) -> None:
94
+ target = tmp_cwd / "real.txt"
95
+ target.write_text("x")
96
+ (tmp_cwd / "link.txt").symlink_to(target)
97
+ result = await ls()
98
+ assert "link.txt" in result
99
+ assert "[broken symlink]" not in result
100
+
101
+ async def test_mixed_broken_and_valid(self, tmp_cwd: Path) -> None:
102
+ (tmp_cwd / "good.txt").write_text("x")
103
+ (tmp_cwd / "bad_link").symlink_to(tmp_cwd / "nowhere")
104
+ result = await ls()
105
+ assert "good.txt" in result
106
+ assert "bad_link" in result
107
+ assert "[broken symlink]" in result
108
+
109
+
110
+ class TestListFilesMisc:
111
+ async def test_repr(self) -> None:
112
+ assert "." in repr(ListFiles())
113
+ assert "src" in repr(ListFiles(directory="src"))
@@ -0,0 +1,188 @@
1
+ """Tests for PatchFile tool handler.
2
+
3
+ All line numbers are 1-indexed, both inclusive:
4
+ from_line=2, to_line=4 replaces lines 2, 3, 4.
5
+ Insert without deleting: to_line = from_line - 1.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from axio_tools_local.patch_file import PatchFile
16
+
17
+
18
+ @pytest.fixture()
19
+ def tmp_cwd(tmp_path: Path) -> Path:
20
+ old = os.getcwd()
21
+ os.chdir(tmp_path)
22
+ yield tmp_path
23
+ os.chdir(old)
24
+
25
+
26
+ async def patch(path: Path, from_line: int, to_line: int, content: str) -> str:
27
+ handler = PatchFile(file_path=path.name, from_line=from_line, to_line=to_line, content=content)
28
+ return await handler()
29
+
30
+
31
+ class TestPatchBasic:
32
+ async def test_replace_middle_lines(self, tmp_cwd: Path) -> None:
33
+ f = tmp_cwd / "f.txt"
34
+ f.write_text("line1\nline2\nline3\nline4\n")
35
+ await patch(f, 2, 3, "replaced\n")
36
+ assert f.read_text() == "line1\nreplaced\nline4\n"
37
+
38
+ async def test_replace_first_line(self, tmp_cwd: Path) -> None:
39
+ f = tmp_cwd / "f.txt"
40
+ f.write_text("line1\nline2\nline3\n")
41
+ await patch(f, 1, 1, "new1\n")
42
+ assert f.read_text() == "new1\nline2\nline3\n"
43
+
44
+ async def test_replace_last_line(self, tmp_cwd: Path) -> None:
45
+ f = tmp_cwd / "f.txt"
46
+ f.write_text("line1\nline2\nline3\n")
47
+ await patch(f, 3, 3, "new3\n")
48
+ assert f.read_text() == "line1\nline2\nnew3\n"
49
+
50
+ async def test_replace_all_lines(self, tmp_cwd: Path) -> None:
51
+ f = tmp_cwd / "f.txt"
52
+ f.write_text("a\nb\nc\n")
53
+ await patch(f, 1, 3, "x\ny\n")
54
+ assert f.read_text() == "x\ny\n"
55
+
56
+ async def test_replace_with_more_lines(self, tmp_cwd: Path) -> None:
57
+ """Replacing 1 line with 3 lines."""
58
+ f = tmp_cwd / "f.txt"
59
+ f.write_text("a\nb\nc\n")
60
+ await patch(f, 2, 2, "x\ny\nz\n")
61
+ assert f.read_text() == "a\nx\ny\nz\nc\n"
62
+
63
+ async def test_replace_with_fewer_lines(self, tmp_cwd: Path) -> None:
64
+ """Replacing 3 lines with 1 line."""
65
+ f = tmp_cwd / "f.txt"
66
+ f.write_text("a\nb\nc\nd\n")
67
+ await patch(f, 1, 3, "only\n")
68
+ assert f.read_text() == "only\nd\n"
69
+
70
+
71
+ class TestPatchInsertDelete:
72
+ async def test_insert_without_deleting(self, tmp_cwd: Path) -> None:
73
+ """to_line = from_line - 1 inserts before from_line without deleting."""
74
+ f = tmp_cwd / "f.txt"
75
+ f.write_text("a\nb\nc\n")
76
+ await patch(f, 2, 1, "inserted\n")
77
+ assert f.read_text() == "a\ninserted\nb\nc\n"
78
+
79
+ async def test_insert_at_start(self, tmp_cwd: Path) -> None:
80
+ f = tmp_cwd / "f.txt"
81
+ f.write_text("a\nb\n")
82
+ await patch(f, 1, 0, "before_a\n")
83
+ assert f.read_text() == "before_a\na\nb\n"
84
+
85
+ async def test_append_to_end(self, tmp_cwd: Path) -> None:
86
+ """Insert after last line: from_line = N+1, to_line = N."""
87
+ f = tmp_cwd / "f.txt"
88
+ f.write_text("a\nb\n")
89
+ await patch(f, 3, 2, "appended\n")
90
+ assert f.read_text() == "a\nb\nappended\n"
91
+
92
+ async def test_delete_lines_empty_content(self, tmp_cwd: Path) -> None:
93
+ """Empty content string deletes the specified lines."""
94
+ f = tmp_cwd / "f.txt"
95
+ f.write_text("a\nb\nc\n")
96
+ await patch(f, 2, 2, "")
97
+ assert f.read_text() == "a\nc\n"
98
+
99
+ async def test_delete_multiple_lines(self, tmp_cwd: Path) -> None:
100
+ f = tmp_cwd / "f.txt"
101
+ f.write_text("a\nb\nc\nd\n")
102
+ await patch(f, 2, 3, "")
103
+ assert f.read_text() == "a\nd\n"
104
+
105
+
106
+ class TestNewlineHandling:
107
+ async def test_content_without_trailing_newline(self, tmp_cwd: Path) -> None:
108
+ """Content without \\n must not corrupt next line."""
109
+ f = tmp_cwd / "f.txt"
110
+ f.write_text("a\nb\nc\n")
111
+ await patch(f, 2, 2, "replaced") # no trailing newline
112
+ assert f.read_text() == "a\nreplaced\nc\n"
113
+
114
+ async def test_multiline_content_no_trailing_newline(self, tmp_cwd: Path) -> None:
115
+ f = tmp_cwd / "f.txt"
116
+ f.write_text("a\nb\nc\n")
117
+ await patch(f, 2, 2, "x\ny") # no trailing newline on last line
118
+ assert f.read_text() == "a\nx\ny\nc\n"
119
+
120
+ async def test_adjacent_lines_not_corrupted(self, tmp_cwd: Path) -> None:
121
+ """Lines before and after patch range are exactly preserved."""
122
+ f = tmp_cwd / "f.txt"
123
+ f.write_text("first\nsecond\nthird\nfourth\nfifth\n")
124
+ await patch(f, 3, 3, "REPLACED\n")
125
+ lines = f.read_text().splitlines()
126
+ assert lines == ["first", "second", "REPLACED", "fourth", "fifth"]
127
+
128
+ async def test_content_extra_trailing_newlines(self, tmp_cwd: Path) -> None:
129
+ """Content with extra trailing newlines creates blank lines."""
130
+ f = tmp_cwd / "f.txt"
131
+ f.write_text("a\nb\nc\n")
132
+ await patch(f, 2, 2, "x\n\n")
133
+ assert f.read_text() == "a\nx\n\nc\n"
134
+
135
+ async def test_single_line_file_replace(self, tmp_cwd: Path) -> None:
136
+ f = tmp_cwd / "f.txt"
137
+ f.write_text("only line\n")
138
+ await patch(f, 1, 1, "new line\n")
139
+ assert f.read_text() == "new line\n"
140
+
141
+ async def test_single_line_file_no_trailing_newline(self, tmp_cwd: Path) -> None:
142
+ f = tmp_cwd / "f.txt"
143
+ f.write_text("only line")
144
+ await patch(f, 1, 1, "replaced\n")
145
+ assert f.read_text() == "replaced\n"
146
+
147
+ async def test_file_no_trailing_newline_patch_middle(self, tmp_cwd: Path) -> None:
148
+ f = tmp_cwd / "f.txt"
149
+ f.write_text("a\nb\nc") # c has no trailing newline
150
+ await patch(f, 2, 2, "B\n")
151
+ assert f.read_text() == "a\nB\nc"
152
+
153
+
154
+ class TestEdgeCases:
155
+ async def test_file_not_found(self, tmp_cwd: Path) -> None:
156
+ with pytest.raises(FileNotFoundError):
157
+ await patch(tmp_cwd / "missing.txt", 1, 1, "x")
158
+
159
+ async def test_returns_bytes_written_message(self, tmp_cwd: Path) -> None:
160
+ f = tmp_cwd / "f.txt"
161
+ f.write_text("a\nb\n")
162
+ result = await patch(f, 1, 1, "x\n")
163
+ assert "bytes written" in result
164
+ assert "f.txt" in result
165
+
166
+ async def test_empty_file_append(self, tmp_cwd: Path) -> None:
167
+ f = tmp_cwd / "f.txt"
168
+ f.write_text("")
169
+ await patch(f, 1, 0, "new\n")
170
+ assert f.read_text() == "new\n"
171
+
172
+ async def test_indented_code_preserved(self, tmp_cwd: Path) -> None:
173
+ f = tmp_cwd / "f.py"
174
+ f.write_text("def foo():\n return 1\n\ndef bar():\n return 2\n")
175
+ await patch(f, 2, 2, " return 42\n")
176
+ assert f.read_text() == "def foo():\n return 42\n\ndef bar():\n return 2\n"
177
+
178
+ async def test_unicode_content(self, tmp_cwd: Path) -> None:
179
+ f = tmp_cwd / "f.txt"
180
+ f.write_text("привет\nмир\n")
181
+ await patch(f, 1, 1, "hello\n")
182
+ assert f.read_text() == "hello\nмир\n"
183
+
184
+ async def test_repr(self) -> None:
185
+ h = PatchFile(file_path="f.py", from_line=5, to_line=10, content="code")
186
+ r = repr(h)
187
+ assert "f.py" in r
188
+ assert "5:10" in r
@@ -0,0 +1,170 @@
1
+ """Tests for ReadFile tool handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from axio_tools_local.read_file import ReadFile
11
+
12
+
13
+ @pytest.fixture()
14
+ def tmp_cwd(tmp_path: Path) -> Path:
15
+ old = os.getcwd()
16
+ os.chdir(tmp_path)
17
+ yield tmp_path
18
+ os.chdir(old)
19
+
20
+
21
+ async def read(tmp_cwd: Path, filename: str, **kwargs: object) -> str:
22
+ return await ReadFile(filename=filename, **kwargs)()
23
+
24
+
25
+ class TestReadFilePlain:
26
+ """indexed=False (default) — plain text, no line numbers."""
27
+
28
+ async def test_single_line(self, tmp_cwd: Path) -> None:
29
+ (tmp_cwd / "f.txt").write_text("content here")
30
+ assert await read(tmp_cwd, "f.txt") == "content here"
31
+
32
+ async def test_multiline(self, tmp_cwd: Path) -> None:
33
+ (tmp_cwd / "f.txt").write_text("a\nb\nc\n")
34
+ assert await read(tmp_cwd, "f.txt") == "a\nb\nc\n"
35
+
36
+ async def test_no_trailing_newline_preserved(self, tmp_cwd: Path) -> None:
37
+ (tmp_cwd / "f.txt").write_text("no newline")
38
+ assert await read(tmp_cwd, "f.txt") == "no newline"
39
+
40
+ async def test_empty_file(self, tmp_cwd: Path) -> None:
41
+ (tmp_cwd / "f.txt").write_text("")
42
+ assert await read(tmp_cwd, "f.txt") == ""
43
+
44
+ async def test_indentation_preserved(self, tmp_cwd: Path) -> None:
45
+ (tmp_cwd / "f.py").write_text("def f():\n pass\n")
46
+ result = await read(tmp_cwd, "f.py")
47
+ assert " pass" in result
48
+
49
+ async def test_unicode(self, tmp_cwd: Path) -> None:
50
+ (tmp_cwd / "f.txt").write_text("привет\nмир\n")
51
+ result = await read(tmp_cwd, "f.txt")
52
+ assert "привет" in result
53
+ assert "мир" in result
54
+
55
+
56
+ class TestReadFileIndexed:
57
+ """indexed=True — each line prefixed with 1-based number."""
58
+
59
+ async def test_first_line_is_1(self, tmp_cwd: Path) -> None:
60
+ (tmp_cwd / "f.txt").write_text("hello\n")
61
+ result = await read(tmp_cwd, "f.txt", indexed=True)
62
+ assert result.startswith("1\t")
63
+
64
+ async def test_numbers_are_one_indexed(self, tmp_cwd: Path) -> None:
65
+ (tmp_cwd / "f.txt").write_text("a\nb\nc\n")
66
+ result = await read(tmp_cwd, "f.txt", indexed=True)
67
+ lines = result.splitlines()
68
+ assert lines[0] == "1\ta"
69
+ assert lines[1] == "2\tb"
70
+ assert lines[2] == "3\tc"
71
+
72
+ async def test_no_line_numbers_without_indexed(self, tmp_cwd: Path) -> None:
73
+ (tmp_cwd / "f.txt").write_text("a\nb\n")
74
+ result = await read(tmp_cwd, "f.txt")
75
+ assert not any(line[0].isdigit() for line in result.splitlines())
76
+
77
+ async def test_single_line_no_trailing_newline(self, tmp_cwd: Path) -> None:
78
+ (tmp_cwd / "f.txt").write_text("only")
79
+ result = await read(tmp_cwd, "f.txt", indexed=True)
80
+ assert result == "1\tonly"
81
+
82
+ async def test_indentation_preserved_with_index(self, tmp_cwd: Path) -> None:
83
+ (tmp_cwd / "f.py").write_text("def f():\n pass\n")
84
+ result = await read(tmp_cwd, "f.py", indexed=True)
85
+ assert "2\t pass" in result
86
+
87
+
88
+ class TestReadFileRange:
89
+ async def test_start_line_1_is_first(self, tmp_cwd: Path) -> None:
90
+ (tmp_cwd / "f.txt").write_text("a\nb\nc\n")
91
+ result = await read(tmp_cwd, "f.txt", start_line=1)
92
+ assert "a" in result and "b" in result and "c" in result
93
+
94
+ async def test_start_line_skips_before(self, tmp_cwd: Path) -> None:
95
+ (tmp_cwd / "f.txt").write_text("a\nb\nc\nd\n")
96
+ result = await read(tmp_cwd, "f.txt", start_line=3)
97
+ assert "a" not in result
98
+ assert "b" not in result
99
+ assert "c" in result
100
+ assert "d" in result
101
+
102
+ async def test_end_line_is_inclusive(self, tmp_cwd: Path) -> None:
103
+ (tmp_cwd / "f.txt").write_text("a\nb\nc\nd\n")
104
+ result = await read(tmp_cwd, "f.txt", end_line=2)
105
+ assert "a" in result
106
+ assert "b" in result
107
+ assert "c" not in result
108
+ assert "d" not in result
109
+
110
+ async def test_start_and_end_line(self, tmp_cwd: Path) -> None:
111
+ (tmp_cwd / "f.txt").write_text("a\nb\nc\nd\ne\n")
112
+ result = await read(tmp_cwd, "f.txt", start_line=2, end_line=4)
113
+ assert "a" not in result
114
+ assert "b" in result
115
+ assert "c" in result
116
+ assert "d" in result
117
+ assert "e" not in result
118
+
119
+ async def test_indexed_numbers_reflect_file_position(self, tmp_cwd: Path) -> None:
120
+ """Line numbers must reflect position in the file, not position in the slice."""
121
+ lines = [f"line{i}\n" for i in range(1, 11)]
122
+ (tmp_cwd / "f.txt").write_text("".join(lines))
123
+ result = await read(tmp_cwd, "f.txt", start_line=5, end_line=7, indexed=True)
124
+ out = result.splitlines()
125
+ assert out[0] == "5\tline5"
126
+ assert out[1] == "6\tline6"
127
+ assert out[2] == "7\tline7"
128
+
129
+ async def test_single_line_range(self, tmp_cwd: Path) -> None:
130
+ (tmp_cwd / "f.txt").write_text("a\nb\nc\n")
131
+ result = await read(tmp_cwd, "f.txt", start_line=2, end_line=2)
132
+ assert result.strip() == "b"
133
+
134
+
135
+ class TestReadFileTruncation:
136
+ async def test_truncated_at_max_chars(self, tmp_cwd: Path) -> None:
137
+ (tmp_cwd / "f.txt").write_text("a" * 200)
138
+ result = await read(tmp_cwd, "f.txt", max_chars=10)
139
+ assert "[truncated]" in result
140
+
141
+ async def test_no_truncation_within_limit(self, tmp_cwd: Path) -> None:
142
+ (tmp_cwd / "f.txt").write_text("hello\n")
143
+ result = await read(tmp_cwd, "f.txt", max_chars=32768)
144
+ assert "[truncated]" not in result
145
+
146
+
147
+ class TestReadFileBinary:
148
+ async def test_binary_as_hex(self, tmp_cwd: Path) -> None:
149
+ (tmp_cwd / "b.dat").write_bytes(b"\x80\x81\xff")
150
+ result = await read(tmp_cwd, "b.dat", binary_as_hex=True)
151
+ assert "8081ff" in result
152
+
153
+ async def test_binary_hex_truncated_to_max_chars(self, tmp_cwd: Path) -> None:
154
+ (tmp_cwd / "b.dat").write_bytes(bytes(range(256)))
155
+ result = await read(tmp_cwd, "b.dat", binary_as_hex=True, max_chars=20)
156
+ assert len(result) < 600
157
+
158
+ async def test_binary_raises_without_hex(self, tmp_cwd: Path) -> None:
159
+ (tmp_cwd / "b.dat").write_bytes(b"\x80\x81\xff")
160
+ with pytest.raises(UnicodeDecodeError):
161
+ await ReadFile(filename="b.dat", binary_as_hex=False)()
162
+
163
+
164
+ class TestReadFileMisc:
165
+ async def test_file_not_found(self, tmp_cwd: Path) -> None:
166
+ with pytest.raises(FileNotFoundError):
167
+ await read(tmp_cwd, "nope.txt")
168
+
169
+ async def test_repr(self) -> None:
170
+ assert "test.py" in repr(ReadFile(filename="test.py"))