"""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 # This is also used in unit tests! 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': # Windows invalid_chars = '<>:"/\\|?*' elif os.name == 'posix': # Unix-like systems 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: # Linting successful. No issues found. 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() # Ensure the content ends with a newline character if not content.endswith('\n'): content += '\n' lines = content.splitlines(True) # Keep all line ending characters total_lines = len(lines) # cover edge cases CURRENT_LINE = _clamp(targeted_line, 1, total_lines) half_window = max(1, window // 2) if ignore_window: # Use CURRENT_LINE as starting line (for e.g. scroll_down) start = max(1, CURRENT_LINE) end = min(total_lines, CURRENT_LINE + window) else: # Ensure at least one line above and below the targeted line start = max(1, CURRENT_LINE - half_window) end = min(total_lines, CURRENT_LINE + half_window) # Adjust start and end to ensure at least one line above and below if start == 1: end = min(total_lines, start + window - 1) if end == total_lines: start = max(1, end - window + 1) output = '' # only display this when there's at least one line above 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 # Override WINDOW with context_lines 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', ]