|
"""file_ops.py
|
|
|
|
This module provides various file manipulation skills for the OpenHands agent.
|
|
|
|
Functions:
|
|
- open_file(path: str, line_number: int | None = 1, context_lines: int = 100): Opens a file and optionally moves to a specific line.
|
|
- goto_line(line_number: int): Moves the window to show the specified line number.
|
|
- scroll_down(): Moves the window down by the number of lines specified in WINDOW.
|
|
- scroll_up(): Moves the window up by the number of lines specified in WINDOW.
|
|
- search_dir(search_term: str, dir_path: str = './'): Searches for a term in all files in the specified directory.
|
|
- search_file(search_term: str, file_path: str | None = None): Searches for a term in the specified file or the currently open file.
|
|
- find_file(file_name: str, dir_path: str = './'): Finds all files with the given name in the specified directory.
|
|
"""
|
|
|
|
import os
|
|
|
|
from openhands.linter import DefaultLinter, LintResult
|
|
|
|
CURRENT_FILE: str | None = None
|
|
CURRENT_LINE = 1
|
|
WINDOW = 100
|
|
|
|
|
|
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.]'
|
|
LINTER_ERROR_MSG = '[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n'
|
|
|
|
|
|
|
|
|
|
|
|
def _output_error(error_msg: str) -> bool:
|
|
print(f'ERROR: {error_msg}')
|
|
return False
|
|
|
|
|
|
def _is_valid_filename(file_name) -> bool:
|
|
if not file_name or not isinstance(file_name, str) or not file_name.strip():
|
|
return False
|
|
invalid_chars = '<>:"/\\|?*'
|
|
if os.name == 'nt':
|
|
invalid_chars = '<>:"/\\|?*'
|
|
elif os.name == 'posix':
|
|
invalid_chars = '\0'
|
|
|
|
for char in invalid_chars:
|
|
if char in file_name:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _is_valid_path(path) -> bool:
|
|
if not path or not isinstance(path, str):
|
|
return False
|
|
try:
|
|
return os.path.exists(os.path.normpath(path))
|
|
except PermissionError:
|
|
return False
|
|
|
|
|
|
def _create_paths(file_name) -> bool:
|
|
try:
|
|
dirname = os.path.dirname(file_name)
|
|
if dirname:
|
|
os.makedirs(dirname, exist_ok=True)
|
|
return True
|
|
except PermissionError:
|
|
return False
|
|
|
|
|
|
def _check_current_file(file_path: str | None = None) -> bool:
|
|
global CURRENT_FILE
|
|
if not file_path:
|
|
file_path = CURRENT_FILE
|
|
if not file_path or not os.path.isfile(file_path):
|
|
return _output_error('No file open. Use the open_file function first.')
|
|
return True
|
|
|
|
|
|
def _clamp(value, min_value, max_value):
|
|
return max(min_value, min(value, max_value))
|
|
|
|
|
|
def _lint_file(file_path: str) -> tuple[str | None, int | None]:
|
|
"""Lint the file at the given path and return a tuple with a boolean indicating if there are errors,
|
|
and the line number of the first error, if any.
|
|
|
|
Returns:
|
|
tuple[str | None, int | None]: (lint_error, first_error_line_number)
|
|
"""
|
|
linter = DefaultLinter()
|
|
lint_error: list[LintResult] = linter.lint(file_path)
|
|
if not lint_error:
|
|
|
|
return None, None
|
|
first_error_line = lint_error[0].line if len(lint_error) > 0 else None
|
|
error_text = 'ERRORS:\n' + '\n'.join(
|
|
[f'{file_path}:{err.line}:{err.column}: {err.message}' for err in lint_error]
|
|
)
|
|
return error_text, first_error_line
|
|
|
|
|
|
def _print_window(
|
|
file_path, targeted_line, window, return_str=False, ignore_window=False
|
|
):
|
|
global CURRENT_LINE
|
|
_check_current_file(file_path)
|
|
with open(file_path) as file:
|
|
content = file.read()
|
|
|
|
|
|
if not content.endswith('\n'):
|
|
content += '\n'
|
|
|
|
lines = content.splitlines(True)
|
|
total_lines = len(lines)
|
|
|
|
|
|
CURRENT_LINE = _clamp(targeted_line, 1, total_lines)
|
|
half_window = max(1, window // 2)
|
|
if ignore_window:
|
|
|
|
start = max(1, CURRENT_LINE)
|
|
end = min(total_lines, CURRENT_LINE + window)
|
|
else:
|
|
|
|
start = max(1, CURRENT_LINE - half_window)
|
|
end = min(total_lines, CURRENT_LINE + half_window)
|
|
|
|
|
|
if start == 1:
|
|
end = min(total_lines, start + window - 1)
|
|
if end == total_lines:
|
|
start = max(1, end - window + 1)
|
|
|
|
output = ''
|
|
|
|
|
|
if start > 1:
|
|
output += f'({start - 1} more lines above)\n'
|
|
else:
|
|
output += '(this is the beginning of the file)\n'
|
|
for i in range(start, end + 1):
|
|
_new_line = f'{i}|{lines[i-1]}'
|
|
if not _new_line.endswith('\n'):
|
|
_new_line += '\n'
|
|
output += _new_line
|
|
if end < total_lines:
|
|
output += f'({total_lines - end} more lines below)\n'
|
|
else:
|
|
output += '(this is the end of the file)\n'
|
|
output = output.rstrip()
|
|
|
|
if return_str:
|
|
return output
|
|
else:
|
|
print(output)
|
|
|
|
|
|
def _cur_file_header(current_file, total_lines) -> str:
|
|
if not current_file:
|
|
return ''
|
|
return f'[File: {os.path.abspath(current_file)} ({total_lines} lines total)]\n'
|
|
|
|
|
|
def open_file(
|
|
path: str, line_number: int | None = 1, context_lines: int | None = WINDOW
|
|
) -> None:
|
|
"""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!
|
|
If line_number is provided, the window will be moved to include that line.
|
|
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.
|
|
|
|
Args:
|
|
path: str: The path to the file to open, preferred absolute path.
|
|
line_number: int | None = 1: The line number to move to. Defaults to 1.
|
|
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.
|
|
"""
|
|
global CURRENT_FILE, CURRENT_LINE, WINDOW
|
|
|
|
if not os.path.isfile(path):
|
|
_output_error(f'File {path} not found.')
|
|
return
|
|
|
|
CURRENT_FILE = os.path.abspath(path)
|
|
with open(CURRENT_FILE) as file:
|
|
total_lines = max(1, sum(1 for _ in file))
|
|
|
|
if not isinstance(line_number, int) or line_number < 1 or line_number > total_lines:
|
|
_output_error(f'Line number must be between 1 and {total_lines}')
|
|
return
|
|
CURRENT_LINE = line_number
|
|
|
|
|
|
if context_lines is None or context_lines < 1:
|
|
context_lines = WINDOW
|
|
|
|
output = _cur_file_header(CURRENT_FILE, total_lines)
|
|
output += _print_window(
|
|
CURRENT_FILE,
|
|
CURRENT_LINE,
|
|
_clamp(context_lines, 1, 100),
|
|
return_str=True,
|
|
ignore_window=False,
|
|
)
|
|
if output.strip().endswith('more lines below)'):
|
|
output += '\n[Use `scroll_down` to view the next 100 lines of the file!]'
|
|
print(output)
|
|
|
|
|
|
def goto_line(line_number: int) -> None:
|
|
"""Moves the window to show the specified line number.
|
|
|
|
Args:
|
|
line_number: int: The line number to move to.
|
|
"""
|
|
global CURRENT_FILE, CURRENT_LINE, WINDOW
|
|
_check_current_file()
|
|
|
|
with open(str(CURRENT_FILE)) as file:
|
|
total_lines = max(1, sum(1 for _ in file))
|
|
if not isinstance(line_number, int) or line_number < 1 or line_number > total_lines:
|
|
_output_error(f'Line number must be between 1 and {total_lines}.')
|
|
return
|
|
|
|
CURRENT_LINE = _clamp(line_number, 1, total_lines)
|
|
|
|
output = _cur_file_header(CURRENT_FILE, total_lines)
|
|
output += _print_window(
|
|
CURRENT_FILE, CURRENT_LINE, WINDOW, return_str=True, ignore_window=False
|
|
)
|
|
print(output)
|
|
|
|
|
|
def scroll_down() -> None:
|
|
"""Moves the window down by 100 lines.
|
|
|
|
Args:
|
|
None
|
|
"""
|
|
global CURRENT_FILE, CURRENT_LINE, WINDOW
|
|
_check_current_file()
|
|
|
|
with open(str(CURRENT_FILE)) as file:
|
|
total_lines = max(1, sum(1 for _ in file))
|
|
CURRENT_LINE = _clamp(CURRENT_LINE + WINDOW, 1, total_lines)
|
|
output = _cur_file_header(CURRENT_FILE, total_lines)
|
|
output += _print_window(
|
|
CURRENT_FILE, CURRENT_LINE, WINDOW, return_str=True, ignore_window=True
|
|
)
|
|
print(output)
|
|
|
|
|
|
def scroll_up() -> None:
|
|
"""Moves the window up by 100 lines.
|
|
|
|
Args:
|
|
None
|
|
"""
|
|
global CURRENT_FILE, CURRENT_LINE, WINDOW
|
|
_check_current_file()
|
|
|
|
with open(str(CURRENT_FILE)) as file:
|
|
total_lines = max(1, sum(1 for _ in file))
|
|
CURRENT_LINE = _clamp(CURRENT_LINE - WINDOW, 1, total_lines)
|
|
output = _cur_file_header(CURRENT_FILE, total_lines)
|
|
output += _print_window(
|
|
CURRENT_FILE, CURRENT_LINE, WINDOW, return_str=True, ignore_window=True
|
|
)
|
|
print(output)
|
|
|
|
|
|
class LineNumberError(Exception):
|
|
pass
|
|
|
|
|
|
def search_dir(search_term: str, dir_path: str = './') -> None:
|
|
"""Searches for search_term in all files in dir. If dir is not provided, searches in the current directory.
|
|
|
|
Args:
|
|
search_term: str: The term to search for.
|
|
dir_path: str: The path to the directory to search.
|
|
"""
|
|
if not os.path.isdir(dir_path):
|
|
_output_error(f'Directory {dir_path} not found')
|
|
return
|
|
matches = []
|
|
for root, _, files in os.walk(dir_path):
|
|
for file in files:
|
|
if file.startswith('.'):
|
|
continue
|
|
file_path = os.path.join(root, file)
|
|
with open(file_path, 'r', errors='ignore') as f:
|
|
for line_num, line in enumerate(f, 1):
|
|
if search_term in line:
|
|
matches.append((file_path, line_num, line.strip()))
|
|
|
|
if not matches:
|
|
print(f'No matches found for "{search_term}" in {dir_path}')
|
|
return
|
|
|
|
num_matches = len(matches)
|
|
num_files = len(set(match[0] for match in matches))
|
|
|
|
if num_files > 100:
|
|
print(
|
|
f'More than {num_files} files matched for "{search_term}" in {dir_path}. Please narrow your search.'
|
|
)
|
|
return
|
|
|
|
print(f'[Found {num_matches} matches for "{search_term}" in {dir_path}]')
|
|
for file_path, line_num, line in matches:
|
|
print(f'{file_path} (Line {line_num}): {line}')
|
|
print(f'[End of matches for "{search_term}" in {dir_path}]')
|
|
|
|
|
|
def search_file(search_term: str, file_path: str | None = None) -> None:
|
|
"""Searches for search_term in file. If file is not provided, searches in the current open file.
|
|
|
|
Args:
|
|
search_term: str: The term to search for.
|
|
file_path: str | None: The path to the file to search.
|
|
"""
|
|
global CURRENT_FILE
|
|
if file_path is None:
|
|
file_path = CURRENT_FILE
|
|
if file_path is None:
|
|
_output_error('No file specified or open. Use the open_file function first.')
|
|
return
|
|
if not os.path.isfile(file_path):
|
|
_output_error(f'File {file_path} not found.')
|
|
return
|
|
|
|
matches = []
|
|
with open(file_path) as file:
|
|
for i, line in enumerate(file, 1):
|
|
if search_term in line:
|
|
matches.append((i, line.strip()))
|
|
|
|
if matches:
|
|
print(f'[Found {len(matches)} matches for "{search_term}" in {file_path}]')
|
|
for match in matches:
|
|
print(f'Line {match[0]}: {match[1]}')
|
|
print(f'[End of matches for "{search_term}" in {file_path}]')
|
|
else:
|
|
print(f'[No matches found for "{search_term}" in {file_path}]')
|
|
|
|
|
|
def find_file(file_name: str, dir_path: str = './') -> None:
|
|
"""Finds all files with the given name in the specified directory.
|
|
|
|
Args:
|
|
file_name: str: The name of the file to find.
|
|
dir_path: str: The path to the directory to search.
|
|
"""
|
|
if not os.path.isdir(dir_path):
|
|
_output_error(f'Directory {dir_path} not found')
|
|
return
|
|
|
|
matches = []
|
|
for root, _, files in os.walk(dir_path):
|
|
for file in files:
|
|
if file_name in file:
|
|
matches.append(os.path.join(root, file))
|
|
|
|
if matches:
|
|
print(f'[Found {len(matches)} matches for "{file_name}" in {dir_path}]')
|
|
for match in matches:
|
|
print(f'{match}')
|
|
print(f'[End of matches for "{file_name}" in {dir_path}]')
|
|
else:
|
|
print(f'[No matches found for "{file_name}" in {dir_path}]')
|
|
|
|
|
|
__all__ = [
|
|
'open_file',
|
|
'goto_line',
|
|
'scroll_down',
|
|
'scroll_up',
|
|
'search_dir',
|
|
'search_file',
|
|
'find_file',
|
|
]
|
|
|