file_ops.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. """file_ops.py
  2. This module provides various file manipulation skills for the OpenHands agent.
  3. Functions:
  4. - open_file(path: str, line_number: int | None = 1, context_lines: int = 100): Opens a file and optionally moves to a specific line.
  5. - goto_line(line_number: int): Moves the window to show the specified line number.
  6. - scroll_down(): Moves the window down by the number of lines specified in WINDOW.
  7. - scroll_up(): Moves the window up by the number of lines specified in WINDOW.
  8. - search_dir(search_term: str, dir_path: str = './'): Searches for a term in all files in the specified directory.
  9. - search_file(search_term: str, file_path: str | None = None): Searches for a term in the specified file or the currently open file.
  10. - find_file(file_name: str, dir_path: str = './'): Finds all files with the given name in the specified directory.
  11. """
  12. import os
  13. from openhands.linter import DefaultLinter, LintResult
  14. CURRENT_FILE: str | None = None
  15. CURRENT_LINE = 1
  16. WINDOW = 100
  17. # This is also used in unit tests!
  18. MSG_FILE_UPDATED = '[File updated (edited at line {line_number}). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]'
  19. LINTER_ERROR_MSG = '[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n'
  20. # ==================================================================================================
  21. def _output_error(error_msg: str) -> bool:
  22. print(f'ERROR: {error_msg}')
  23. return False
  24. def _is_valid_filename(file_name) -> bool:
  25. if not file_name or not isinstance(file_name, str) or not file_name.strip():
  26. return False
  27. invalid_chars = '<>:"/\\|?*'
  28. if os.name == 'nt': # Windows
  29. invalid_chars = '<>:"/\\|?*'
  30. elif os.name == 'posix': # Unix-like systems
  31. invalid_chars = '\0'
  32. for char in invalid_chars:
  33. if char in file_name:
  34. return False
  35. return True
  36. def _is_valid_path(path) -> bool:
  37. if not path or not isinstance(path, str):
  38. return False
  39. try:
  40. return os.path.exists(os.path.normpath(path))
  41. except PermissionError:
  42. return False
  43. def _create_paths(file_name) -> bool:
  44. try:
  45. dirname = os.path.dirname(file_name)
  46. if dirname:
  47. os.makedirs(dirname, exist_ok=True)
  48. return True
  49. except PermissionError:
  50. return False
  51. def _check_current_file(file_path: str | None = None) -> bool:
  52. global CURRENT_FILE
  53. if not file_path:
  54. file_path = CURRENT_FILE
  55. if not file_path or not os.path.isfile(file_path):
  56. return _output_error('No file open. Use the open_file function first.')
  57. return True
  58. def _clamp(value, min_value, max_value):
  59. return max(min_value, min(value, max_value))
  60. def _lint_file(file_path: str) -> tuple[str | None, int | None]:
  61. """Lint the file at the given path and return a tuple with a boolean indicating if there are errors,
  62. and the line number of the first error, if any.
  63. Returns:
  64. tuple[str | None, int | None]: (lint_error, first_error_line_number)
  65. """
  66. linter = DefaultLinter()
  67. lint_error: list[LintResult] = linter.lint(file_path)
  68. if not lint_error:
  69. # Linting successful. No issues found.
  70. return None, None
  71. first_error_line = lint_error[0].line if len(lint_error) > 0 else None
  72. error_text = 'ERRORS:\n' + '\n'.join(
  73. [f'{file_path}:{err.line}:{err.column}: {err.message}' for err in lint_error]
  74. )
  75. return error_text, first_error_line
  76. def _print_window(
  77. file_path, targeted_line, window, return_str=False, ignore_window=False
  78. ):
  79. global CURRENT_LINE
  80. _check_current_file(file_path)
  81. with open(file_path) as file:
  82. content = file.read()
  83. # Ensure the content ends with a newline character
  84. if not content.endswith('\n'):
  85. content += '\n'
  86. lines = content.splitlines(True) # Keep all line ending characters
  87. total_lines = len(lines)
  88. # cover edge cases
  89. CURRENT_LINE = _clamp(targeted_line, 1, total_lines)
  90. half_window = max(1, window // 2)
  91. if ignore_window:
  92. # Use CURRENT_LINE as starting line (for e.g. scroll_down)
  93. start = max(1, CURRENT_LINE)
  94. end = min(total_lines, CURRENT_LINE + window)
  95. else:
  96. # Ensure at least one line above and below the targeted line
  97. start = max(1, CURRENT_LINE - half_window)
  98. end = min(total_lines, CURRENT_LINE + half_window)
  99. # Adjust start and end to ensure at least one line above and below
  100. if start == 1:
  101. end = min(total_lines, start + window - 1)
  102. if end == total_lines:
  103. start = max(1, end - window + 1)
  104. output = ''
  105. # only display this when there's at least one line above
  106. if start > 1:
  107. output += f'({start - 1} more lines above)\n'
  108. else:
  109. output += '(this is the beginning of the file)\n'
  110. for i in range(start, end + 1):
  111. _new_line = f'{i}|{lines[i-1]}'
  112. if not _new_line.endswith('\n'):
  113. _new_line += '\n'
  114. output += _new_line
  115. if end < total_lines:
  116. output += f'({total_lines - end} more lines below)\n'
  117. else:
  118. output += '(this is the end of the file)\n'
  119. output = output.rstrip()
  120. if return_str:
  121. return output
  122. else:
  123. print(output)
  124. def _cur_file_header(current_file, total_lines) -> str:
  125. if not current_file:
  126. return ''
  127. return f'[File: {os.path.abspath(current_file)} ({total_lines} lines total)]\n'
  128. def open_file(
  129. path: str, line_number: int | None = 1, context_lines: int | None = WINDOW
  130. ) -> None:
  131. """Opens the file at the given path in the editor. IF the file is to be edited, first use `scroll_down` repeatedly to read the full file!
  132. If line_number is provided, the window will be moved to include that line.
  133. It only shows the first 100 lines by default! `context_lines` is the max number of lines to be displayed, up to 100. Use `scroll_up` and `scroll_down` to view more content up or down.
  134. Args:
  135. path: str: The path to the file to open, preferred absolute path.
  136. line_number: int | None = 1: The line number to move to. Defaults to 1.
  137. context_lines: int | None = 100: Only shows this number of lines in the context window (usually from line 1), with line_number as the center (if possible). Defaults to 100.
  138. """
  139. global CURRENT_FILE, CURRENT_LINE, WINDOW
  140. if not os.path.isfile(path):
  141. _output_error(f'File {path} not found.')
  142. return
  143. CURRENT_FILE = os.path.abspath(path)
  144. with open(CURRENT_FILE) as file:
  145. total_lines = max(1, sum(1 for _ in file))
  146. if not isinstance(line_number, int) or line_number < 1 or line_number > total_lines:
  147. _output_error(f'Line number must be between 1 and {total_lines}')
  148. return
  149. CURRENT_LINE = line_number
  150. # Override WINDOW with context_lines
  151. if context_lines is None or context_lines < 1:
  152. context_lines = WINDOW
  153. output = _cur_file_header(CURRENT_FILE, total_lines)
  154. output += _print_window(
  155. CURRENT_FILE,
  156. CURRENT_LINE,
  157. _clamp(context_lines, 1, 100),
  158. return_str=True,
  159. ignore_window=False,
  160. )
  161. if output.strip().endswith('more lines below)'):
  162. output += '\n[Use `scroll_down` to view the next 100 lines of the file!]'
  163. print(output)
  164. def goto_line(line_number: int) -> None:
  165. """Moves the window to show the specified line number.
  166. Args:
  167. line_number: int: The line number to move to.
  168. """
  169. global CURRENT_FILE, CURRENT_LINE, WINDOW
  170. _check_current_file()
  171. with open(str(CURRENT_FILE)) as file:
  172. total_lines = max(1, sum(1 for _ in file))
  173. if not isinstance(line_number, int) or line_number < 1 or line_number > total_lines:
  174. _output_error(f'Line number must be between 1 and {total_lines}.')
  175. return
  176. CURRENT_LINE = _clamp(line_number, 1, total_lines)
  177. output = _cur_file_header(CURRENT_FILE, total_lines)
  178. output += _print_window(
  179. CURRENT_FILE, CURRENT_LINE, WINDOW, return_str=True, ignore_window=False
  180. )
  181. print(output)
  182. def scroll_down() -> None:
  183. """Moves the window down by 100 lines.
  184. Args:
  185. None
  186. """
  187. global CURRENT_FILE, CURRENT_LINE, WINDOW
  188. _check_current_file()
  189. with open(str(CURRENT_FILE)) as file:
  190. total_lines = max(1, sum(1 for _ in file))
  191. CURRENT_LINE = _clamp(CURRENT_LINE + WINDOW, 1, total_lines)
  192. output = _cur_file_header(CURRENT_FILE, total_lines)
  193. output += _print_window(
  194. CURRENT_FILE, CURRENT_LINE, WINDOW, return_str=True, ignore_window=True
  195. )
  196. print(output)
  197. def scroll_up() -> None:
  198. """Moves the window up by 100 lines.
  199. Args:
  200. None
  201. """
  202. global CURRENT_FILE, CURRENT_LINE, WINDOW
  203. _check_current_file()
  204. with open(str(CURRENT_FILE)) as file:
  205. total_lines = max(1, sum(1 for _ in file))
  206. CURRENT_LINE = _clamp(CURRENT_LINE - WINDOW, 1, total_lines)
  207. output = _cur_file_header(CURRENT_FILE, total_lines)
  208. output += _print_window(
  209. CURRENT_FILE, CURRENT_LINE, WINDOW, return_str=True, ignore_window=True
  210. )
  211. print(output)
  212. class LineNumberError(Exception):
  213. pass
  214. def search_dir(search_term: str, dir_path: str = './') -> None:
  215. """Searches for search_term in all files in dir. If dir is not provided, searches in the current directory.
  216. Args:
  217. search_term: str: The term to search for.
  218. dir_path: str: The path to the directory to search.
  219. """
  220. if not os.path.isdir(dir_path):
  221. _output_error(f'Directory {dir_path} not found')
  222. return
  223. matches = []
  224. for root, _, files in os.walk(dir_path):
  225. for file in files:
  226. if file.startswith('.'):
  227. continue
  228. file_path = os.path.join(root, file)
  229. with open(file_path, 'r', errors='ignore') as f:
  230. for line_num, line in enumerate(f, 1):
  231. if search_term in line:
  232. matches.append((file_path, line_num, line.strip()))
  233. if not matches:
  234. print(f'No matches found for "{search_term}" in {dir_path}')
  235. return
  236. num_matches = len(matches)
  237. num_files = len(set(match[0] for match in matches))
  238. if num_files > 100:
  239. print(
  240. f'More than {num_files} files matched for "{search_term}" in {dir_path}. Please narrow your search.'
  241. )
  242. return
  243. print(f'[Found {num_matches} matches for "{search_term}" in {dir_path}]')
  244. for file_path, line_num, line in matches:
  245. print(f'{file_path} (Line {line_num}): {line}')
  246. print(f'[End of matches for "{search_term}" in {dir_path}]')
  247. def search_file(search_term: str, file_path: str | None = None) -> None:
  248. """Searches for search_term in file. If file is not provided, searches in the current open file.
  249. Args:
  250. search_term: str: The term to search for.
  251. file_path: str | None: The path to the file to search.
  252. """
  253. global CURRENT_FILE
  254. if file_path is None:
  255. file_path = CURRENT_FILE
  256. if file_path is None:
  257. _output_error('No file specified or open. Use the open_file function first.')
  258. return
  259. if not os.path.isfile(file_path):
  260. _output_error(f'File {file_path} not found.')
  261. return
  262. matches = []
  263. with open(file_path) as file:
  264. for i, line in enumerate(file, 1):
  265. if search_term in line:
  266. matches.append((i, line.strip()))
  267. if matches:
  268. print(f'[Found {len(matches)} matches for "{search_term}" in {file_path}]')
  269. for match in matches:
  270. print(f'Line {match[0]}: {match[1]}')
  271. print(f'[End of matches for "{search_term}" in {file_path}]')
  272. else:
  273. print(f'[No matches found for "{search_term}" in {file_path}]')
  274. def find_file(file_name: str, dir_path: str = './') -> None:
  275. """Finds all files with the given name in the specified directory.
  276. Args:
  277. file_name: str: The name of the file to find.
  278. dir_path: str: The path to the directory to search.
  279. """
  280. if not os.path.isdir(dir_path):
  281. _output_error(f'Directory {dir_path} not found')
  282. return
  283. matches = []
  284. for root, _, files in os.walk(dir_path):
  285. for file in files:
  286. if file_name in file:
  287. matches.append(os.path.join(root, file))
  288. if matches:
  289. print(f'[Found {len(matches)} matches for "{file_name}" in {dir_path}]')
  290. for match in matches:
  291. print(f'{match}')
  292. print(f'[End of matches for "{file_name}" in {dir_path}]')
  293. else:
  294. print(f'[No matches found for "{file_name}" in {dir_path}]')
  295. __all__ = [
  296. 'open_file',
  297. 'goto_line',
  298. 'scroll_down',
  299. 'scroll_up',
  300. 'search_dir',
  301. 'search_file',
  302. 'find_file',
  303. ]