Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

this enabled /read-only /path/to/sources.jar!/org/subdir/Component.java to be used. #2379

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions aider/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
50 changes: 31 additions & 19 deletions aider/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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)"
Expand Down Expand Up @@ -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}")
Expand All @@ -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"):
Expand Down
142 changes: 129 additions & 13 deletions aider/io.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import base64
import os
import webbrowser
import zipfile
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
Expand Down Expand Up @@ -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):
Expand Down
28 changes: 28 additions & 0 deletions tests/basic/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down