diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 53d0e40514c..e48626eea77 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -13,9 +13,11 @@ import threading import time import traceback +import zipfile from collections import defaultdict from datetime import datetime from json.decoder import JSONDecodeError +from os.path import expanduser from pathlib import Path from typing import List @@ -362,14 +364,14 @@ def __init__( self.io.tool_warning(f"Skipping {fname} that matches aiderignore spec.") continue - if not fname.exists(): + if not self.io.exists(fname): if utils.touch_file(fname): self.io.tool_output(f"Creating empty file {fname}") else: self.io.tool_warning(f"Can not create {fname}, skipping.") continue - if not fname.is_file(): + if not self.io.is_file(fname): self.io.tool_warning(f"Skipping {fname} that is not a normal file.") continue @@ -384,11 +386,33 @@ def __init__( if read_only_fnames: self.abs_read_only_fnames = set() for fname in read_only_fnames: - abs_fname = self.abs_root_path(fname) - if os.path.exists(abs_fname): - self.abs_read_only_fnames.add(abs_fname) + if '!' in fname: + # Handle JAR paths + jar_path, internal_path = fname.split('!', 1) + if internal_path.startswith('/'): + internal_path = internal_path[1:] + jar_path = expanduser(jar_path) + if os.path.isfile(jar_path): + try: + with zipfile.ZipFile(jar_path, 'r') as jar: + try: + jar.getinfo(internal_path) # Check if file exists in JAR + self.abs_read_only_fnames.add(fname) + except KeyError: + self.io.tool_warning(f"Error: File {internal_path} not found in JAR {jar_path}. Skipping.") + except zipfile.BadZipFile: + self.io.tool_warning(f"Error: {jar_path} is not a valid JAR/ZIP file. Skipping.") + except OSError as err: + self.io.tool_warning(f"Error: Unable to read JAR {jar_path}: {err}. Skipping.") + else: + self.io.tool_warning(f"Error: JAR file {jar_path} does not exist. Skipping.") else: - self.io.tool_warning(f"Error: Read-only file {fname} does not exist. Skipping.") + # Handle regular files + abs_fname = self.abs_root_path(fname) + if os.path.exists(abs_fname): + self.abs_read_only_fnames.add(abs_fname) + else: + self.io.tool_warning(f"Error: Read-only file {fname} does not exist. Skipping.") if map_tokens is None: use_repo_map = main_model.use_repo_map diff --git a/aider/commands.py b/aider/commands.py index 5d938098df8..e3710b1e309 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -4,6 +4,7 @@ import subprocess import sys import tempfile +import zipfile from collections import OrderedDict from os.path import expanduser from pathlib import Path @@ -212,7 +213,7 @@ def do_run(self, cmd_name, args): try: return cmd_method(args) except ANY_GIT_ERROR as err: - self.io.tool_error(f"Unable to complete {cmd_name}: {err}") + self.io.tool_error(f"Unable to complete1 {cmd_name}: {err}") def matching_commands(self, inp): words = inp.strip().split() @@ -704,8 +705,8 @@ def cmd_add(self, args): self.io.tool_warning(f"Skipping {fname} due to aiderignore or --subtree-only.") continue - if fname.exists(): - if fname.is_file(): + if os.path.exists(fname): + if os.path.isfile(fname): all_matched_files.add(str(fname)) continue # an existing dir, escape any special chars so they won't be globs @@ -798,14 +799,6 @@ def cmd_drop(self, args=""): # Expand tilde in the path expanded_word = os.path.expanduser(word) - # Handle read-only files separately, without glob_filtered_to_repo - read_only_matched = [f for f in self.coder.abs_read_only_fnames if expanded_word in f] - - if read_only_matched: - for matched_file in read_only_matched: - self.coder.abs_read_only_fnames.remove(matched_file) - self.io.tool_output(f"Removed read-only file {matched_file} from the chat") - matched_files = self.glob_filtered_to_repo(expanded_word) if not matched_files: @@ -816,6 +809,12 @@ def cmd_drop(self, args=""): if abs_fname in self.coder.abs_fnames: self.coder.abs_fnames.remove(abs_fname) self.io.tool_output(f"Removed {matched_file} from the chat") + else: + # input path to abs path, because read-only are stored as absolute paths + abs_fname = os.path.normpath(os.path.join(self.coder.root, matched_file)) if not os.path.isabs(matched_file) else matched_file + if abs_fname in self.coder.abs_read_only_fnames: + self.coder.abs_read_only_fnames.remove(abs_fname) + self.io.tool_output(f"Removed read-only file {matched_file} from the chat") def cmd_git(self, args): "Run a git command (output excluded from chat)" @@ -1185,12 +1184,20 @@ def cmd_read_only(self, args): # First collect all expanded paths for pattern in filenames: expanded_pattern = expanduser(pattern) + + # Handle regular files and directories if os.path.isabs(expanded_pattern): - # For absolute paths, glob it - matches = list(glob.glob(expanded_pattern)) + if self.io.exists(expanded_pattern): + matches = [expanded_pattern] + else: + # For absolute paths, glob it + matches = list(glob.glob(expanded_pattern)) else: + if self.io.exists(expanded_pattern): + matches = [self.coder.abs_root_path(expanded_pattern)] + else: # For relative paths and globs, use glob from the root directory - matches = list(Path(self.coder.root).glob(expanded_pattern)) + matches = list(Path(self.coder.root).glob(expanded_pattern)) if not matches: self.io.tool_error(f"No matches found for: {pattern}") @@ -1199,13 +1206,18 @@ def cmd_read_only(self, args): # Then process them in sorted order for path in sorted(all_paths): - abs_path = self.coder.abs_root_path(path) - if os.path.isfile(abs_path): + if isinstance(path, str) and '!' in path and self.io.exists(path): + # This is a JAR path + abs_path = path # Keep the full JAR!internal_path self._add_read_only_file(abs_path, path) - elif os.path.isdir(abs_path): - self._add_read_only_directory(abs_path, path) else: - self.io.tool_error(f"Not a file or directory: {abs_path}") + abs_path = self.coder.abs_root_path(path) + if os.path.isfile(abs_path): + self._add_read_only_file(abs_path, path) + elif os.path.isdir(abs_path): + self._add_read_only_directory(abs_path, path) + else: + self.io.tool_error(f"Not a file or directory: {abs_path}") def _add_read_only_file(self, abs_path, original_name): if is_image_file(original_name) and not self.coder.main_model.info.get("supports_vision"): diff --git a/aider/io.py b/aider/io.py index 193b4459925..7e4a757a648 100644 --- a/aider/io.py +++ b/aider/io.py @@ -1,6 +1,7 @@ import base64 import os import webbrowser +import zipfile from collections import defaultdict from dataclasses import dataclass from datetime import datetime @@ -312,25 +313,140 @@ def read_image(self, filename): self.tool_error(f"{filename}: {e}") return + def _parse_jar_path(self, path): + """ + Parse a JAR/ZIP file path with an internal file path. + + Args: + path (str): Path to the file, can include JAR!internal_path + + Returns: + tuple: (jar_path, internal_path) or (None, None) if invalid + """ + if type(path) is not str or '!' not in path: + return None, None + + jar_path, internal_path = path.split('!', 1) + if internal_path.startswith('/'): + internal_path = internal_path[1:] # Remove leading slash + + return jar_path, internal_path + + def _validate_jar_file(self, jar_path): + """ + Validate a JAR/ZIP file. + + Args: + jar_path (str): Path to the JAR/ZIP file + + Returns: + bool: True if the file is a valid JAR/ZIP, False otherwise + """ + if not os.path.isfile(jar_path): + return False + + try: + with zipfile.ZipFile(jar_path, 'r'): + return True + except (zipfile.BadZipFile, OSError): + return False + + def exists(self, path): + """ + Check if a file exists, including files inside JAR/ZIP archives. + + Args: + path (str): Path to the file, can include JAR!internal_path + + Returns: + bool: True if the file exists, False otherwise + """ + jar_path, internal_path = self._parse_jar_path(path) + if jar_path is None: + return os.path.exists(path) + + if not self._validate_jar_file(jar_path): + return False + + try: + with zipfile.ZipFile(jar_path, 'r') as jar: + try: + jar.getinfo(internal_path) + return True + except KeyError: + return False + except (zipfile.BadZipFile, OSError): + return False + + + def is_file(self, path): + """ + Check if a path is a file, including files inside JAR/ZIP archives. + + Args: + path (str): Path to the file, can include JAR!internal_path + + Returns: + bool: True if the path is a file, False otherwise + """ + jar_path, internal_path = self._parse_jar_path(path) + if jar_path is None: + return os.path.isfile(path) + + if not self._validate_jar_file(jar_path): + return False + + try: + with zipfile.ZipFile(jar_path, 'r') as jar: + try: + info = jar.getinfo(internal_path) + return not info.is_dir() + except KeyError: + return False + except (zipfile.BadZipFile, OSError): + return False + def read_text(self, filename): if is_image_file(filename): return self.read_image(filename) + jar_path, internal_path = self._parse_jar_path(filename) + if jar_path is None: + try: + with open(str(filename), "r", encoding=self.encoding) as f: + return f.read() + except OSError as err: + self.tool_error(f"{filename}: unable to read: {err}") + return + except FileNotFoundError: + self.tool_error(f"{filename}: file not found error") + return + except IsADirectoryError: + self.tool_error(f"{filename}: is a directory") + return + except UnicodeError as e: + self.tool_error(f"{filename}: {e}") + self.tool_error("Use --encoding to set the unicode encoding.") + return + + if not self._validate_jar_file(jar_path): + self.tool_error(f"{jar_path}: not a valid JAR/ZIP file") + return + try: - with open(str(filename), "r", encoding=self.encoding) as f: - return f.read() + with zipfile.ZipFile(jar_path, 'r') as jar: + try: + with jar.open(internal_path) as f: + return f.read().decode(self.encoding) + except KeyError: + self.tool_error(f"{internal_path}: not found in JAR {jar_path}") + return + except UnicodeError as e: + self.tool_error(f"{filename}: {e}") + self.tool_error("Use --encoding to set the unicode encoding.") + return except OSError as err: - self.tool_error(f"{filename}: unable to read: {err}") - return - except FileNotFoundError: - self.tool_error(f"{filename}: file not found error") - return - except IsADirectoryError: - self.tool_error(f"{filename}: is a directory") - return - except UnicodeError as e: - self.tool_error(f"{filename}: {e}") - self.tool_error("Use --encoding to set the unicode encoding.") + self.tool_error(f"{jar_path}: unable to read: {err}") return def write_text(self, filename, content): diff --git a/tests/basic/test_commands.py b/tests/basic/test_commands.py index be92049dbdd..81f9d195786 100644 --- a/tests/basic/test_commands.py +++ b/tests/basic/test_commands.py @@ -4,6 +4,7 @@ import shutil import sys import tempfile +import zipfile from io import StringIO from pathlib import Path from unittest import TestCase, mock @@ -903,6 +904,33 @@ def test_cmd_read_only_with_image_file(self): ) ) + def test_cmd_read_only_with_jar_file(self): + with GitTemporaryDirectory() as repo_dir: + io = InputOutput(pretty=False, fancy_input=False, yes=False) + coder = Coder.create(self.GPT35, None, io) + commands = Commands(io, coder) + + # Create a JAR file with a Markdown file inside + jar_path = Path(repo_dir) / "test_docs.jar" + with zipfile.ZipFile(jar_path, 'w') as jar: + jar.writestr('README.md', '# Test Documentation\n\nThis is a test markdown file.') + + # Add the JAR file to read-only files + commands.cmd_read_only(str(jar_path) + '!README.md') + + # Check if the internal file was added to read-only files + self.assertEqual(len(coder.abs_read_only_fnames), 1) + self.assertTrue( + any( + str(jar_path) + '!README.md' in fname + for fname in coder.abs_read_only_fnames + ) + ) + + # Verify the content can be read + content = io.read_text(str(jar_path) + '!README.md') + self.assertEqual(content, '# Test Documentation\n\nThis is a test markdown file.') + def test_cmd_read_only_with_glob_pattern(self): with GitTemporaryDirectory() as repo_dir: io = InputOutput(pretty=False, fancy_input=False, yes=False)