Coverage for src/alprina_cli/tools/file/read.py: 24%
72 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
1"""
2Read File Tool
4Context Engineering:
5- Just-in-time file reading
6- Supports partial file reading (line ranges)
7- Token-efficient output
8- Binary file detection
10Simple, focused tool for reading files on-demand.
11"""
13from pathlib import Path
14from typing import Optional
15from pydantic import BaseModel, Field
16from loguru import logger
18from alprina_cli.tools.base import AlprinaToolBase, ToolOk, ToolError
21MAX_LINES = 1000 # Context-efficient limit
24class ReadFileParams(BaseModel):
25 """
26 Parameters for file reading.
28 Context: Simple schema for reading files.
29 """
30 file_path: str = Field(
31 description="Path to file to read"
32 )
33 start_line: Optional[int] = Field(
34 default=None,
35 description="Start reading from this line (1-indexed, inclusive)"
36 )
37 end_line: Optional[int] = Field(
38 default=None,
39 description="Stop reading at this line (1-indexed, inclusive)"
40 )
41 max_lines: int = Field(
42 default=MAX_LINES,
43 description=f"Maximum lines to read (default: {MAX_LINES})"
44 )
47class ReadFileTool(AlprinaToolBase[ReadFileParams]):
48 """
49 Read file contents.
51 Context Engineering Benefits:
52 - Just-in-time file reading (not pre-loading)
53 - Partial file reading (line ranges)
54 - Max lines limit for context control
55 - Binary file detection
57 Usage:
58 ```python
59 tool = ReadFileTool()
61 # Read entire file
62 result = await tool.execute(ReadFileParams(
63 file_path="./src/main.py"
64 ))
66 # Read specific lines
67 result = await tool.execute(ReadFileParams(
68 file_path="./src/main.py",
69 start_line=10,
70 end_line=50
71 ))
72 ```
73 """
75 name: str = "ReadFile"
76 description: str = """Read file contents.
78Capabilities:
79- Read entire files or line ranges
80- Binary file detection
81- Context-efficient (max line limits)
82- Line numbering
84Returns: File contents with line numbers"""
85 params: type[ReadFileParams] = ReadFileParams
87 async def execute(self, params: ReadFileParams) -> ToolOk | ToolError:
88 """
89 Execute file read.
91 Context: Returns limited, line-numbered content.
92 """
93 logger.debug(f"ReadFile: {params.file_path}")
95 try:
96 # Resolve file path
97 file_path = Path(params.file_path).expanduser()
98 if not file_path.is_absolute():
99 file_path = Path.cwd() / file_path
101 # Check file exists
102 if not file_path.exists():
103 return ToolError(
104 message=f"File not found: {params.file_path}",
105 brief="File not found"
106 )
108 if not file_path.is_file():
109 return ToolError(
110 message=f"Not a file: {params.file_path}",
111 brief="Not a file"
112 )
114 # Check if binary file
115 if self._is_binary(file_path):
116 return ToolError(
117 message=f"File appears to be binary: {params.file_path}",
118 brief="Binary file"
119 )
121 # Read file
122 try:
123 content = file_path.read_text(errors="ignore")
124 except Exception as e:
125 return ToolError(
126 message=f"Could not read file: {str(e)}",
127 brief="Read failed"
128 )
130 lines = content.splitlines()
131 total_lines = len(lines)
133 # Determine line range
134 start = (params.start_line or 1) - 1 # Convert to 0-indexed
135 end = (params.end_line or total_lines) if params.end_line else total_lines
137 # Validate range
138 if start < 0:
139 start = 0
140 if end > total_lines:
141 end = total_lines
142 if start >= end:
143 return ToolError(
144 message=f"Invalid line range: {start+1}-{end}",
145 brief="Invalid range"
146 )
148 # Extract lines
149 selected_lines = lines[start:end]
151 # Apply max_lines limit
152 if len(selected_lines) > params.max_lines:
153 selected_lines = selected_lines[:params.max_lines]
154 truncated = True
155 else:
156 truncated = False
158 # Format with line numbers
159 numbered_lines = []
160 for i, line in enumerate(selected_lines, start=start+1):
161 numbered_lines.append(f"{i:4d} | {line}")
163 output = "\n".join(numbered_lines)
165 # Build message
166 if truncated:
167 message = (
168 f"Read lines {start+1}-{start+params.max_lines} of {total_lines} "
169 f"(truncated to {params.max_lines} lines)"
170 )
171 elif params.start_line or params.end_line:
172 message = f"Read lines {start+1}-{end} of {total_lines}"
173 else:
174 message = f"Read {len(selected_lines)} lines"
176 return ToolOk(
177 content={
178 "file_path": str(file_path),
179 "total_lines": total_lines,
180 "start_line": start + 1,
181 "end_line": start + len(selected_lines),
182 "lines_returned": len(selected_lines),
183 "truncated": truncated
184 },
185 output=output,
186 metadata={"message": message}
187 )
189 except Exception as e:
190 logger.error(f"Read file failed: {e}")
191 return ToolError(
192 message=f"Read file failed: {str(e)}",
193 brief="Read failed"
194 )
196 def _is_binary(self, file_path: Path) -> bool:
197 """
198 Check if file is binary.
200 Context: Quick heuristic to avoid reading binary files.
201 """
202 try:
203 # Read first 8192 bytes
204 with open(file_path, 'rb') as f:
205 chunk = f.read(8192)
207 # Check for null bytes (common in binary files)
208 if b'\x00' in chunk:
209 return True
211 # Try to decode as text
212 try:
213 chunk.decode('utf-8')
214 return False
215 except UnicodeDecodeError:
216 return True
218 except Exception:
219 return False