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

1""" 

2Read File Tool 

3 

4Context Engineering: 

5- Just-in-time file reading 

6- Supports partial file reading (line ranges) 

7- Token-efficient output 

8- Binary file detection 

9 

10Simple, focused tool for reading files on-demand. 

11""" 

12 

13from pathlib import Path 

14from typing import Optional 

15from pydantic import BaseModel, Field 

16from loguru import logger 

17 

18from alprina_cli.tools.base import AlprinaToolBase, ToolOk, ToolError 

19 

20 

21MAX_LINES = 1000 # Context-efficient limit 

22 

23 

24class ReadFileParams(BaseModel): 

25 """ 

26 Parameters for file reading. 

27 

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 ) 

45 

46 

47class ReadFileTool(AlprinaToolBase[ReadFileParams]): 

48 """ 

49 Read file contents. 

50 

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 

56 

57 Usage: 

58 ```python 

59 tool = ReadFileTool() 

60 

61 # Read entire file 

62 result = await tool.execute(ReadFileParams( 

63 file_path="./src/main.py" 

64 )) 

65 

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 """ 

74 

75 name: str = "ReadFile" 

76 description: str = """Read file contents. 

77 

78Capabilities: 

79- Read entire files or line ranges 

80- Binary file detection 

81- Context-efficient (max line limits) 

82- Line numbering 

83 

84Returns: File contents with line numbers""" 

85 params: type[ReadFileParams] = ReadFileParams 

86 

87 async def execute(self, params: ReadFileParams) -> ToolOk | ToolError: 

88 """ 

89 Execute file read. 

90 

91 Context: Returns limited, line-numbered content. 

92 """ 

93 logger.debug(f"ReadFile: {params.file_path}") 

94 

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 

100 

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 ) 

107 

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 ) 

113 

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 ) 

120 

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 ) 

129 

130 lines = content.splitlines() 

131 total_lines = len(lines) 

132 

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 

136 

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 ) 

147 

148 # Extract lines 

149 selected_lines = lines[start:end] 

150 

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 

157 

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}") 

162 

163 output = "\n".join(numbered_lines) 

164 

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" 

175 

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 ) 

188 

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 ) 

195 

196 def _is_binary(self, file_path: Path) -> bool: 

197 """ 

198 Check if file is binary. 

199 

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) 

206 

207 # Check for null bytes (common in binary files) 

208 if b'\x00' in chunk: 

209 return True 

210 

211 # Try to decode as text 

212 try: 

213 chunk.decode('utf-8') 

214 return False 

215 except UnicodeDecodeError: 

216 return True 

217 

218 except Exception: 

219 return False