diff --git a/.dockerignore b/.dockerignore index ce03212b5..830cf8498 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,4 @@ OLD* .env .venv .aider.* +build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a4455f2c..3810ab8d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,7 @@ name: Release on: + workflow_dispatch: push: tags: - 'v*' @@ -29,3 +30,19 @@ jobs: run: | python setup.py sdist bdist_wheel twine upload dist/* + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./docker/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/aider:${{ github.ref_name }} + ${{ secrets.DOCKERHUB_USERNAME }}/aider:latest diff --git a/HISTORY.md b/HISTORY.md index d182a2226..f6b750ec3 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,33 @@ # Release history +### Next release + +- Added support for `.aiderignore` file, which instructs aider to ignore parts of the git repo. +- `/run` and `/git` now accept full shell commands, like: `/run (cd subdir; ls)` +- New `--commit` cmd line arg, which just commits all pending changes with a sensible commit message geneated by gpt-3.5. +- Added universal ctags to the [aider docker image](https://aider.chat/docs/docker.html) + +### v0.14.2 + +- Easily [run aider from a docker image](https://aider.chat/docs/docker.html) +- Fixed bug with chat history summarization. +- Fixed bug if `soundfile` package not available. + +### v0.14.1 + +- /add and /drop handle absolute filenames and quoted filenames +- /add checks to be sure files are within the git repo (or root) +- If needed, warn users that in-chat file paths are all relative to the git repo +- Fixed /add bug in when aider launched in repo subdir +- Show models supported by api/key if requested model isn't available + +### v0.14.0 + +- [Support for Claude2 and other LLMs via OpenRouter](https://aider.chat/docs/faq.html#accessing-other-llms-with-openrouter) by @joshuavial +- Documentation for [running the aider benchmarking suite](https://github.com/paul-gauthier/aider/tree/main/benchmark) +- Aider now requires Python >= 3.9 + + ### v0.13.0 - [Only git commit dirty files that GPT tries to edit](https://aider.chat/docs/faq.html#how-did-v0130-change-git-usage) diff --git a/README.md b/README.md index 6dd7b5f53..accaa31b0 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,6 @@ Aider has some ability to help GPT figure out which files to edit all by itself, * Use Meta-ENTER (Esc+ENTER in some environments) to enter multiline chat messages. Or enter `{` alone on the first line to start a multiline message and `}` alone on the last line to end it. * If your code is throwing an error, share the error output with GPT using `/run` or by pasting it into the chat. Let GPT figure out and fix the bug. * GPT knows about a lot of standard tools and libraries, but may get some of the fine details wrong about APIs and function arguments. You can paste doc snippets into the chat to resolve these issues. -* [Aider will notice if you launch it on a git repo with uncommitted changes and offer to commit them before proceeding](https://aider.chat/docs/faq.html#how-does-aider-use-git). * GPT can only see the content of the files you specifically "add to the chat". Aider also sends GPT-4 a [map of your entire git repo](https://aider.chat/docs/ctags.html). So GPT may ask to see additional files if it feels that's needed for your requests. * I also shared some general [GPT coding tips on Hacker News](https://news.ycombinator.com/item?id=36211879). @@ -170,4 +169,4 @@ For more information, see the [FAQ](https://aider.chat/docs/faq.html). * *This project is stellar.* -- [funkytaco](https://github.com/paul-gauthier/aider/issues/112#issuecomment-1637429008) * *Amazing project, definitely the best AI coding assistant I've used.* -- [joshuavial](https://github.com/paul-gauthier/aider/issues/84) * *I am an aider addict. I'm getting so much more work done, but in less time.* -- [dandandan](https://discord.com/channels/1131200896827654144/1131200896827654149/1135913253483069470) -* *Best agent for actual dev work in existing codebases.* -- [Nick Dobos](https://twitter.com/NickADobos/status/1690408967963652097?s=20) \ No newline at end of file +* *Best agent for actual dev work in existing codebases.* -- [Nick Dobos](https://twitter.com/NickADobos/status/1690408967963652097?s=20) diff --git a/aider/__init__.py b/aider/__init__.py index bb8bac653..a6e4787d8 100644 --- a/aider/__init__.py +++ b/aider/__init__.py @@ -1 +1 @@ -__version__ = "0.13.1-dev" +__version__ = "0.14.3-dev" diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index c6710ab49..6a1478a93 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -55,6 +55,7 @@ class Coder: main_model, edit_format, io, + skip_model_availabily_check=False, **kwargs, ): from . import EditBlockCoder, WholeFileCoder @@ -62,8 +63,8 @@ class Coder: if not main_model: main_model = models.GPT35_16k - if not main_model.always_available: - if not check_model_availability(main_model): + if not skip_model_availabily_check and not main_model.always_available: + if not check_model_availability(io, main_model): if main_model != models.GPT4: io.tool_error( f"API key does not support {main_model.name}, falling back to" @@ -99,6 +100,7 @@ class Coder: stream=True, use_git=True, voice_language=None, + aider_ignore_file=None, ): if not fnames: fnames = [] @@ -153,7 +155,7 @@ class Coder: if use_git: try: - self.repo = GitRepo(self.io, fnames, git_dname) + self.repo = GitRepo(self.io, fnames, git_dname, aider_ignore_file) self.root = self.repo.root except FileNotFoundError: self.repo = None @@ -185,7 +187,7 @@ class Coder: self.summarizer = ChatSummary(models.Model.weak_model()) self.summarizer_thread = None - self.summarized_done_messages = None + self.summarized_done_messages = [] # validate the functions jsonschema if self.functions: @@ -352,7 +354,11 @@ class Coder: self.summarizer_thread.start() def summarize_worker(self): - self.summarized_done_messages = self.summarizer.summarize(self.done_messages) + try: + self.summarized_done_messages = self.summarizer.summarize(self.done_messages) + except ValueError as err: + self.io.tool_error(err.args[0]) + if self.verbose: self.io.tool_output("Finished summarizing chat history.") @@ -364,7 +370,7 @@ class Coder: self.summarizer_thread = None self.done_messages = self.summarized_done_messages - self.summarized_done_messages = None + self.summarized_done_messages = [] def move_back_cur_messages(self, message): self.done_messages += self.cur_messages @@ -595,15 +601,17 @@ class Coder: self.io.tool_error(show_content_err) raise Exception("No data found in openai response!") - prompt_tokens = completion.usage.prompt_tokens - completion_tokens = completion.usage.completion_tokens + tokens = None + if hasattr(completion, "usage"): + prompt_tokens = completion.usage.prompt_tokens + completion_tokens = completion.usage.completion_tokens - tokens = f"{prompt_tokens} prompt tokens, {completion_tokens} completion tokens" - if self.main_model.prompt_price: - cost = prompt_tokens * self.main_model.prompt_price / 1000 - cost += completion_tokens * self.main_model.completion_price / 1000 - tokens += f", ${cost:.6f} cost" - self.total_cost += cost + tokens = f"{prompt_tokens} prompt tokens, {completion_tokens} completion tokens" + if self.main_model.prompt_price: + cost = prompt_tokens * self.main_model.prompt_price / 1000 + cost += completion_tokens * self.main_model.completion_price / 1000 + tokens += f", ${cost:.6f} cost" + self.total_cost += cost show_resp = self.render_incremental_response(True) if self.pretty: @@ -614,7 +622,9 @@ class Coder: show_resp = Text(show_resp or "") self.io.console.print(show_resp) - self.io.tool_output(tokens) + + if tokens is not None: + self.io.tool_output(tokens) def show_send_output_stream(self, completion): live = None @@ -895,7 +905,12 @@ class Coder: return True -def check_model_availability(main_model): +def check_model_availability(io, main_model): available_models = openai.Model.list() model_ids = [model.id for model in available_models["data"]] - return main_model.name in model_ids + if main_model.name in model_ids: + return True + + available_models = ", ".join(model_ids) + io.tool_error(f"API key supports: {available_models}") + return False diff --git a/aider/commands.py b/aider/commands.py index a4caf7142..63291a72d 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -1,5 +1,5 @@ import json -import shlex +import re import subprocess import sys from pathlib import Path @@ -209,6 +209,10 @@ class Commands: or last_commit.hexsha[:7] != self.coder.last_aider_commit_hash ): self.io.tool_error("The last commit was not made by aider in this chat session.") + self.io.tool_error( + "You could try `/git reset --hard HEAD^` but be aware that this is a destructive" + " command!" + ) return self.coder.repo.repo.git.reset("--hard", "HEAD~1") self.io.tool_output( @@ -228,6 +232,7 @@ class Commands: if not self.coder.last_aider_commit_hash: self.io.tool_error("No previous aider commit found.") + self.io.tool_error("You could try `/git diff` or `/git diff HEAD^`.") return commits = f"{self.coder.last_aider_commit_hash}~1" @@ -272,29 +277,37 @@ class Commands: git_files = self.coder.repo.get_tracked_files() if self.coder.repo else [] all_matched_files = set() - for word in args.split(): + + filenames = parse_quoted_filenames(args) + for word in filenames: + if Path(word).is_absolute(): + fname = Path(word) + else: + fname = Path(self.coder.root) / word + + if fname.exists() and fname.is_file(): + all_matched_files.add(str(fname)) + continue + # an existing dir will fall through and get recursed by glob + matched_files = self.glob_filtered_to_repo(word) + if matched_files: + all_matched_files.update(matched_files) + continue - if not matched_files: - if any(char in word for char in "*?[]"): - self.io.tool_error(f"No files to add matching pattern: {word}") - else: - if Path(word).exists(): - if Path(word).is_file(): - matched_files = [word] - else: - self.io.tool_error(f"Unable to add: {word}") - elif self.io.confirm_ask( - f"No files matched '{word}'. Do you want to create the file?" - ): - (Path(self.coder.root) / word).touch() - matched_files = [word] - - all_matched_files.update(matched_files) + if self.io.confirm_ask(f"No files matched '{word}'. Do you want to create {fname}?"): + fname.touch() + all_matched_files.add(str(fname)) for matched_file in all_matched_files: abs_file_path = self.coder.abs_root_path(matched_file) + if not abs_file_path.startswith(self.coder.root): + self.io.tool_error( + f"Can not add {abs_file_path}, which is not within {self.coder.root}" + ) + continue + if self.coder.repo and matched_file not in git_files: self.coder.repo.repo.git.add(abs_file_path) git_added.append(matched_file) @@ -339,7 +352,8 @@ class Commands: self.io.tool_output("Dropping all files from the chat session.") self.coder.abs_fnames = set() - for word in args.split(): + filenames = parse_quoted_filenames(args) + for word in filenames: matched_files = self.glob_filtered_to_repo(word) if not matched_files: @@ -355,10 +369,15 @@ class Commands: "Run a git command" combined_output = None try: - parsed_args = shlex.split("git " + args) + args = "git " + args env = dict(GIT_EDITOR="true", **subprocess.os.environ) result = subprocess.run( - parsed_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env + args, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + shell=True, ) combined_output = result.stdout except Exception as e: @@ -373,9 +392,8 @@ class Commands: "Run a shell command and optionally add the output to the chat" combined_output = None try: - parsed_args = shlex.split(args) result = subprocess.run( - parsed_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, shell=True ) combined_output = result.stdout except Exception as e: @@ -447,7 +465,9 @@ class Commands: try: self.voice = voice.Voice() except voice.SoundDeviceError: - self.io.tool_error("Unable to import `sounddevice`, is portaudio installed?") + self.io.tool_error( + "Unable to import `sounddevice` and/or `soundfile`, is portaudio installed?" + ) return history_iter = self.io.get_input_history() @@ -487,3 +507,9 @@ def expand_subdir(file_path): for file in file_path.rglob("*"): if file.is_file(): yield str(file) + + +def parse_quoted_filenames(args): + filenames = re.findall(r"\"(.+?)\"|(\S+)", args) + filenames = [name for sublist in filenames for name in sublist if name] + return filenames diff --git a/aider/history.py b/aider/history.py index 2aeda9483..6cf8c5a31 100644 --- a/aider/history.py +++ b/aider/history.py @@ -85,6 +85,8 @@ class ChatSummary: ] summary = simple_send_with_retries(self.model.name, messages) + if summary is None: + raise ValueError(f"summarizer unexpectedly failed for {self.model.name}") summary = prompts.summary_prefix + summary return [dict(role="user", content=summary)] diff --git a/aider/main.py b/aider/main.py index 755e118bb..9b3b2597b 100644 --- a/aider/main.py +++ b/aider/main.py @@ -60,10 +60,10 @@ def setup_git(git_root, io): with repo.config_writer() as git_config: if not global_git_config.has_option("user", "name"): git_config.set_value("user", "name", "Your Name") - io.tool_error('Update git name with: git config --global user.name "Your Name"') + io.tool_error('Update git name with: git config user.name "Your Name"') if not global_git_config.has_option("user", "email"): git_config.set_value("user", "email", "you@example.com") - io.tool_error('Update git email with: git config --global user.email "you@example.com"') + io.tool_error('Update git email with: git config user.email "you@example.com"') io.tool_output("Git repository created in the current working directory.") @@ -74,6 +74,13 @@ def check_gitignore(git_root, io, ask=True): if not git_root: return + try: + repo = git.Repo(git_root) + if repo.ignored(".aider"): + return + except git.exc.InvalidGitRepositoryError: + pass + pat = ".aider*" gitignore_file = Path(git_root) / ".gitignore" @@ -142,6 +149,12 @@ def main(argv=None, input=None, output=None, force_git_root=None): default=models.GPT4.name, help=f"Specify the model to use for the main chat (default: {models.GPT4.name})", ) + core_group.add_argument( + "--skip-model-availability-check", + metavar="SKIP_MODEL_AVAILABILITY_CHECK", + default=False, + help="Override to skip model availability check (default: False)", + ) core_group.add_argument( "-3", action="store_const", @@ -286,6 +299,21 @@ def main(argv=None, input=None, output=None, force_git_root=None): default=True, help="Enable/disable looking for a git repo (default: True)", ) + git_group.add_argument( + "--gitignore", + action=argparse.BooleanOptionalAction, + default=True, + help="Enable/disable adding .aider* to .gitignore (default: True)", + ) + default_aiderignore_file = ( + os.path.join(git_root, ".aiderignore") if git_root else ".aiderignore" + ) + git_group.add_argument( + "--aiderignore", + metavar="AIDERIGNORE", + default=default_aiderignore_file, + help="Specify the aider ignore file (default: .aiderignore in git root)", + ) git_group.add_argument( "--auto-commits", action=argparse.BooleanOptionalAction, @@ -302,7 +330,13 @@ def main(argv=None, input=None, output=None, force_git_root=None): "--dry-run", action=argparse.BooleanOptionalAction, default=False, - help="Enable/disable performing a dry run without modifying files (default: False)", + help="Perform a dry run without modifying files (default: False)", + ) + git_group.add_argument( + "--commit", + action="store_true", + help="Commit all pending changes with a suitable commit message, then exit", + default=False, ) ########## @@ -423,7 +457,8 @@ def main(argv=None, input=None, output=None, force_git_root=None): if args.git: git_root = setup_git(git_root, io) - check_gitignore(git_root, io) + if args.gitignore: + check_gitignore(git_root, io) def scrub_sensitive_info(text): # Replace sensitive information with placeholder @@ -465,6 +500,7 @@ def main(argv=None, input=None, output=None, force_git_root=None): main_model, args.edit_format, io, + args.skip_model_availability_check, ## fnames=fnames, git_dname=git_dname, @@ -480,11 +516,16 @@ def main(argv=None, input=None, output=None, force_git_root=None): stream=args.stream, use_git=args.git, voice_language=args.voice_language, + aider_ignore_file=args.aiderignore, ) except ValueError as err: io.tool_error(str(err)) return 1 + if args.commit: + coder.commands.cmd_commit("") + return + if args.show_repo_map: repo_map = coder.get_repo_map() if repo_map: @@ -501,6 +542,15 @@ def main(argv=None, input=None, output=None, force_git_root=None): io.tool_output("Use /help to see in-chat commands, run with --help to see cmd line args") + if git_root and Path.cwd().resolve() != Path(git_root).resolve(): + io.tool_error( + "Note: in-chat filenames are always relative to the git working dir, not the current" + " working dir." + ) + + io.tool_error(f"Cur working dir: {Path.cwd()}") + io.tool_error(f"Git working dir: {git_root}") + if args.message: io.tool_output() coder.run(with_message=args.message) diff --git a/aider/models/__init__.py b/aider/models/__init__.py index f299009c6..fa8aa3673 100644 --- a/aider/models/__init__.py +++ b/aider/models/__init__.py @@ -1,5 +1,15 @@ from .model import Model +from .openai import OpenAIModel +from .openrouter import OpenRouterModel GPT4 = Model.create("gpt-4") GPT35 = Model.create("gpt-3.5-turbo") GPT35_16k = Model.create("gpt-3.5-turbo-16k") + +__all__ = [ + OpenAIModel, + OpenRouterModel, + GPT4, + GPT35, + GPT35_16k, +] diff --git a/aider/repo.py b/aider/repo.py index a44f235b0..866928235 100644 --- a/aider/repo.py +++ b/aider/repo.py @@ -2,6 +2,7 @@ import os from pathlib import Path, PurePosixPath import git +import pathspec from aider import models, prompts, utils from aider.sendchat import simple_send_with_retries @@ -11,8 +12,11 @@ from .dump import dump # noqa: F401 class GitRepo: repo = None + aider_ignore_file = None + aider_ignore_spec = None + aider_ignore_ts = 0 - def __init__(self, io, fnames, git_dname): + def __init__(self, io, fnames, git_dname, aider_ignore_file=None): self.io = io if git_dname: @@ -49,14 +53,20 @@ class GitRepo: self.repo = git.Repo(repo_paths.pop(), odbt=git.GitDB) self.root = utils.safe_abs_path(self.repo.working_tree_dir) + if aider_ignore_file: + self.aider_ignore_file = Path(aider_ignore_file) + def commit(self, fnames=None, context=None, prefix=None, message=None): if not fnames and not self.repo.is_dirty(): return + diffs = self.get_diffs(fnames) + if not diffs: + return + if message: commit_message = message else: - diffs = self.get_diffs(fnames) commit_message = self.get_commit_message(diffs, context) if not commit_message: @@ -190,7 +200,22 @@ class GitRepo: for path in files ) - return res + return self.filter_ignored_files(res) + + def filter_ignored_files(self, fnames): + if not self.aider_ignore_file or not self.aider_ignore_file.is_file(): + return fnames + + mtime = self.aider_ignore_file.stat().st_mtime + if mtime != self.aider_ignore_ts: + self.aider_ignore_ts = mtime + lines = self.aider_ignore_file.read_text().splitlines() + self.aider_ignore_spec = pathspec.PathSpec.from_lines( + pathspec.patterns.GitWildMatchPattern, + lines, + ) + + return [fname for fname in fnames if not self.aider_ignore_spec.match_file(fname)] def path_in_repo(self, path): if not self.repo: diff --git a/aider/repomap.py b/aider/repomap.py index aba7104b8..c210aab8a 100644 --- a/aider/repomap.py +++ b/aider/repomap.py @@ -83,6 +83,8 @@ class RepoMap: cache_missing = False + warned_files = set() + def __init__( self, map_tokens=1024, @@ -235,7 +237,10 @@ class RepoMap: for fname in fnames: if not Path(fname).is_file(): - self.io.tool_error(f"Repo-map can't include {fname}") + if fname not in self.warned_files: + self.io.tool_error(f"Repo-map can't include {fname}") + + self.warned_files.add(fname) continue # dump(fname) diff --git a/aider/voice.py b/aider/voice.py index 78f94f4ae..3ee9651ad 100644 --- a/aider/voice.py +++ b/aider/voice.py @@ -5,7 +5,12 @@ import time import numpy as np import openai -import soundfile as sf + +try: + import soundfile as sf +except (OSError, ModuleNotFoundError): + sf = None + from prompt_toolkit.shortcuts import prompt from .dump import dump # noqa: F401 @@ -23,12 +28,14 @@ class Voice: threshold = 0.15 def __init__(self): + if sf is None: + raise SoundDeviceError try: print("Initializing sound device...") import sounddevice as sd self.sd = sd - except OSError: + except (OSError, ModuleNotFoundError): raise SoundDeviceError def callback(self, indata, frames, time, status): diff --git a/benchmark/Dockerfile b/benchmark/Dockerfile index ad79affa6..ac552e877 100644 --- a/benchmark/Dockerfile +++ b/benchmark/Dockerfile @@ -1,7 +1,8 @@ -FROM python:3.8-slim +FROM python:3.10-slim RUN apt-get update && apt-get install -y less git COPY requirements.txt /aider/requirements.txt RUN pip install lox typer pandas matplotlib imgcat aider-chat RUN pip install --upgrade pip && pip install -r /aider/requirements.txt +RUN git config --global --add safe.directory /aider WORKDIR /aider diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 000000000..d87a4190b --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,107 @@ + +# Aider code editing benchmark harness + +Aider uses a "code editing" benchmark to quantitatively measure how well it works +with the GPT-3.5 and GPT-4 models. +This directory holds the harness and tools needed to run the benchmarking suite. + +## Background + +The benchmark is based on the [Exercism +python](https://github.com/exercism/python) coding exercises. +This +benchmark evaluates how effectively aider and GPT can translate a +natural language coding request into executable code saved into +files that pass unit tests. +It provides an end-to-end evaluation of not just +GPT's coding ability, but also its capacity to *edit existing code* +and *format those code edits* so that aider can save the +edits to the local source files. + +See [this writeup for a longer discussion about the benchmark and how to interpret the results](https://aider.chat/docs/benchmarks.html). + +The benchmark is intended to be run *inside a docker container*. +This is because the benchmarking harness will be +taking code written by an LLM +and executing it without any human review or supervision! +The LLM could generate dangerous python that harms your system, like this: `import os; os.system("sudo rm -rf /")`. +Running inside a docker container helps limit the damage that could be done. + +## Usage + +There are 3 main tasks involved in benchmarking aider: + +1. Install and setup for benchmarking. + +2. Run the benchmark to measure performance across the 133 exercises. + +3. Generate a summary report of how many of the exercises succeeded or failed. + +### Setup for benchmarking + +First, prepare all the groundwork for running the benchmarks. +These steps only need to be done once. + +``` +# Clone the aider repo +git clone git@github.com:paul-gauthier/aider.git + +# Create the scratch dir to hold benchmarking results inside the main aider dir: +cd aider +mkdir tmp.benchmarks + +# Clone the exercism repo +git clone git@github.com:exercism/python.git + +# Copy the practice exercises into the benchmark scratch dir +cp -rp python/exercises/practice tmp.benchmarks/practice + +# Build the docker container +./benchmark/docker_build.sh +``` + +### Running the benchmark + +Launch the docker container and run the benchmark inside it: + +``` +# Launch the docker container +./benchmark/docker.sh + +# Inside the container, install aider as a development build. +# This way you're running the code that you cloned above, including any local changes. +pip install -e . + +# Run the benchmark: +./benchmark/benchmark.py a-helpful-name-for-this-run --model gpt-3.5-turbo --edit-format whole --threads 10 +``` + +The above will create a folder `tmp.benchmarks/YYYY-MM-DD-HH-MM-SS--a-helpful-name-for-this-run` with benchmarking results. +Run like this, the script will run all 133 exercises in a random order. + +You can run `./benchmark/benchmark.py --help` for a list of all the arguments, but here are the most useful to keep in mind: + +- `--model` is the name of the model, same as you would pass directly to `aider`. +- `--edit-format` is the name of the edit format, same as you would pass directly to `aider`. When working with an experimental LLM, I recommend starting with `whole` +- `--threads` specifies how many exercises to benchmark in parallel. Start with a single thread if you are working out the kinks on your benchmarking setup or working with a new model, etc. Once you are getting reliable results, you can speed up the process by running with more threads. 10 works well against the OpenAI APIs. +- `--num-tests` specifies how many of the 133 tests to run before stopping. This is another way to start gently as you debug your benchmarking setup. +- `--keywords` filters the tests to run to only the ones whose name match the supplied argument (similar to `pytest -k xxxx`). + +### Generating a benchmark report + +You can generate stats about any benchmark, including ones which are still running. +You don't need to run this inside the docker container, as it is just +collecting stats not executing unsafe python. + +``` +# Generate stats for a specific benchmarking directory +./benchmark/benchmark.py --stats tmp.benchmarks/YYYY-MM-DD-HH-MM-SS--a-helpful-name-for-this-run +``` + +## Limitations, notes + +- If you're experimenting with non-OpenAI models, the benchmarking harness may not provide enough switches/control to specify the integration to such models. You probably need to edit `benchmark.py` to instantiate `Coder()` appropriately. You can just hack this in or add new switches/config. +- Benchmarking all 133 exercises against GPT-4 will cost about $10-20. +- Benchmarking aider is intended for folks who are actively developing aider or doing experimental work adapting it for use with [new LLM models](https://github.com/paul-gauthier/aider/issues/172). +- These scripts are not intended for use by typical aider users. +- Some of the tools are written as `bash` scripts, so it will be hard to use them on Windows. diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 2d16653c8..72f007c49 100755 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -547,7 +547,7 @@ def run_test( chat_history_file=history_fname, ) - main_model = models.Model(model_name) + main_model = models.Model.create(model_name) edit_format = edit_format or main_model.edit_format dump(main_model) diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 000000000..6ecbb889a --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.10-slim +COPY . /aider +RUN apt-get update && \ + apt-get install --no-install-recommends -y git universal-ctags libportaudio2 && \ + rm -rf /var/lib/apt/lists/* && \ + pip install --no-cache-dir /aider && \ + rm -rf /aider +WORKDIR /app +ENTRYPOINT ["aider"] diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 000000000..a06f1b773 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,32 @@ + +# Run aider with docker (experimental) + +You can run aider via docker without doing any local installation, like this: + +``` +docker pull paulgauthier/aider +docker run -it --volume $(pwd):/app paulgauthier/aider --openai-api-key $OPENAI_API_KEY [...other aider args...] +``` + +You should run the above commands from the root of your git repo, +since the `--volume` arg maps your current directory into the +docker container. +Given that, you need to be in the root of your git repo for aider to be able to +see the repo and all its files. + +You should be sure your that +git repo config contains your user name and email, since the +docker container won't have your global git config. +Run these commands while in your git repo, before +you do the `docker run` command: + +``` +git config user.email "you@example.com" +git config user.name "Your Name" +``` + + +## Limitations + +- When you use the in-chat `/run` command, it will be running shell commands *inside the docker container*. So those commands won't be running in your local environment, which may make it tricky to `/run` tests, etc for your project. +- The `/voice` command won't work unless you can figure out how to give the docker container access to your host audio device. The container has libportaudio2 installed, so it should work if you can do that. diff --git a/docs/faq.md b/docs/faq.md index bd71fe4bf..aae406fc2 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -8,6 +8,7 @@ - [Can I use aider with other LLMs, local LLMs, etc?](#can-i-use-aider-with-other-llms-local-llms-etc) - [Can I change the system prompts that aider uses?](#can-i-change-the-system-prompts-that-aider-uses) - [Can I run aider in Google Colab?](#can-i-run-aider-in-google-colab) +- [How can I run aider locally from source code?](#how-can-i-run-aider-locally-from-source-code) ## How does aider use git? @@ -224,3 +225,21 @@ all the raw information being sent to/from GPT in the conversation. User [imabutahersiddik](https://github.com/imabutahersiddik) has provided this [Colab notebook](https://colab.research.google.com/drive/1J9XynhrCqekPL5PR6olHP6eE--rnnjS9?usp=sharing). + +## How can I run aider locally from source code? + +To run the project locally, follow these steps: + +``` +# Clone the repository: +git clone git@github.com:paul-gauthier/aider.git + +# Navigate to the project directory: +cd aider + +# Install the dependencies listed in the `requirements.txt` file: +pip install -r requirements.txt + +# Run the local version of Aider: +python -m aider.main +``` \ No newline at end of file diff --git a/docs/voice.md b/docs/voice.md index 0b93bf336..2c994c727 100644 --- a/docs/voice.md +++ b/docs/voice.md @@ -3,7 +3,9 @@ Speak with GPT about your code! Request new features, test cases or bug fixes using your voice and let GPT do the work of editing the files in your local git repo. As with all of aider's capabilities, you can use voice-to-code with an existing repo or to start a new project. -Voice support fits quite naturally into aider's AI pair programming chat interface. Now you can fluidly switch between voice and text chat where you ask GPT to edit the code and your own editor when it makes more sense for you to drive. +Voice support fits quite naturally into aider's AI pair programming +chat interface. Now you can fluidly switch between voice and text chat +when you ask GPT to edit your code. ## How to use voice-to-code diff --git a/hello.py b/hello.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/requirements.txt b/requirements.txt index 0eedb5cf0..d29bd2f45 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,3 +33,4 @@ scipy==1.10.1 jsonschema==4.17.3 sounddevice==0.4.6 soundfile==0.12.1 +pathspec==0.11.2 diff --git a/scripts/versionbump.py b/scripts/versionbump.py old mode 100644 new mode 100755 index 2704cb6cb..df8e00028 --- a/scripts/versionbump.py +++ b/scripts/versionbump.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + import argparse import re import subprocess diff --git a/tests/test_commands.py b/tests/test_commands.py index 70362651d..89524e73b 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -14,7 +14,7 @@ from aider.coders import Coder from aider.commands import Commands from aider.dump import dump # noqa: F401 from aider.io import InputOutput -from tests.utils import GitTemporaryDirectory +from tests.utils import ChdirTemporaryDirectory, GitTemporaryDirectory, make_repo class TestCommands(TestCase): @@ -69,8 +69,8 @@ class TestCommands(TestCase): self.assertNotIn(str(Path("test.txt").resolve()), coder.abs_fnames) def test_cmd_add_no_match(self): - # Initialize the Commands and InputOutput objects - io = InputOutput(pretty=False, yes=True) + # yes=False means we will *not* create the file when it is not found + io = InputOutput(pretty=False, yes=False) from aider.coders import Coder coder = Coder.create(models.GPT35, None, io) @@ -82,6 +82,23 @@ class TestCommands(TestCase): # Check if no files have been added to the chat session self.assertEqual(len(coder.abs_fnames), 0) + def test_cmd_add_no_match_but_make_it(self): + # yes=True means we *will* create the file when it is not found + io = InputOutput(pretty=False, yes=True) + from aider.coders import Coder + + coder = Coder.create(models.GPT35, None, io) + commands = Commands(io, coder) + + fname = Path("[abc].nonexistent") + + # Call the cmd_add method with a non-existent file pattern + commands.cmd_add(str(fname)) + + # Check if no files have been added to the chat session + self.assertEqual(len(coder.abs_fnames), 1) + self.assertTrue(fname.exists()) + def test_cmd_add_drop_directory(self): # Initialize the Commands and InputOutput objects io = InputOutput(pretty=False, yes=False) @@ -255,6 +272,25 @@ class TestCommands(TestCase): self.assertNotIn(filenames[1], coder.abs_fnames) self.assertIn(filenames[2], coder.abs_fnames) + def test_cmd_add_from_subdir_again(self): + with GitTemporaryDirectory(): + io = InputOutput(pretty=False, yes=False) + from aider.coders import Coder + + coder = Coder.create(models.GPT35, None, io) + commands = Commands(io, coder) + + Path("side_dir").mkdir() + os.chdir("side_dir") + + # add a file that is in the side_dir + with open("temp.txt", "w"): + pass + + # this was blowing up with GitCommandError, per: + # https://github.com/paul-gauthier/aider/issues/201 + commands.cmd_add("temp.txt") + def test_cmd_commit(self): with GitTemporaryDirectory(): fname = "test.txt" @@ -276,3 +312,93 @@ class TestCommands(TestCase): commit_message = "Test commit message" commands.cmd_commit(commit_message) self.assertFalse(repo.is_dirty()) + + def test_cmd_add_from_outside_root(self): + with ChdirTemporaryDirectory() as tmp_dname: + root = Path("root") + root.mkdir() + os.chdir(str(root)) + + io = InputOutput(pretty=False, yes=False) + from aider.coders import Coder + + coder = Coder.create(models.GPT35, None, io) + commands = Commands(io, coder) + + outside_file = Path(tmp_dname) / "outside.txt" + outside_file.touch() + + # This should not be allowed! + # https://github.com/paul-gauthier/aider/issues/178 + commands.cmd_add("../outside.txt") + + self.assertEqual(len(coder.abs_fnames), 0) + + def test_cmd_add_from_outside_git(self): + with ChdirTemporaryDirectory() as tmp_dname: + root = Path("root") + root.mkdir() + os.chdir(str(root)) + + make_repo() + + io = InputOutput(pretty=False, yes=False) + from aider.coders import Coder + + coder = Coder.create(models.GPT35, None, io) + commands = Commands(io, coder) + + outside_file = Path(tmp_dname) / "outside.txt" + outside_file.touch() + + # This should not be allowed! + # It was blowing up with GitCommandError, per: + # https://github.com/paul-gauthier/aider/issues/178 + commands.cmd_add("../outside.txt") + + self.assertEqual(len(coder.abs_fnames), 0) + + def test_cmd_add_filename_with_special_chars(self): + with ChdirTemporaryDirectory(): + io = InputOutput(pretty=False, yes=False) + from aider.coders import Coder + + coder = Coder.create(models.GPT35, None, io) + commands = Commands(io, coder) + + fname = Path("with[brackets].txt") + fname.touch() + + commands.cmd_add(str(fname)) + + self.assertIn(str(fname.resolve()), coder.abs_fnames) + + def test_cmd_add_abs_filename(self): + with ChdirTemporaryDirectory(): + io = InputOutput(pretty=False, yes=False) + from aider.coders import Coder + + coder = Coder.create(models.GPT35, None, io) + commands = Commands(io, coder) + + fname = Path("file.txt") + fname.touch() + + commands.cmd_add(str(fname.resolve())) + + self.assertIn(str(fname.resolve()), coder.abs_fnames) + + def test_cmd_add_quoted_filename(self): + with ChdirTemporaryDirectory(): + io = InputOutput(pretty=False, yes=False) + from aider.coders import Coder + + coder = Coder.create(models.GPT35, None, io) + commands = Commands(io, coder) + + fname = Path("file with spaces.txt") + fname.touch() + + commands.cmd_add(f'"{fname}"') + + self.assertIn(str(fname.resolve()), coder.abs_fnames) diff --git a/tests/test_repo.py b/tests/test_repo.py index 77092c2da..3e5b42f55 100644 --- a/tests/test_repo.py +++ b/tests/test_repo.py @@ -68,7 +68,6 @@ class TestRepo(unittest.TestCase): git_repo = GitRepo(InputOutput(), None, ".") diffs = git_repo.diff_commits(False, "HEAD~1", "HEAD") - dump(diffs) self.assertIn("two", diffs) @patch("aider.repo.simple_send_with_retries") @@ -171,6 +170,55 @@ class TestRepo(unittest.TestCase): self.assertIn(str(fname), fnames) self.assertIn(str(fname2), fnames) + def test_get_tracked_files_with_aiderignore(self): + with GitTemporaryDirectory(): + # new repo + raw_repo = git.Repo() + + # add it, but no commits at all in the raw_repo yet + fname = Path("new.txt") + fname.touch() + raw_repo.git.add(str(fname)) + + aiderignore = Path(".aiderignore") + git_repo = GitRepo(InputOutput(), None, None, str(aiderignore)) + + # better be there + fnames = git_repo.get_tracked_files() + self.assertIn(str(fname), fnames) + + # commit it, better still be there + raw_repo.git.commit("-m", "new") + fnames = git_repo.get_tracked_files() + self.assertIn(str(fname), fnames) + + # new file, added but not committed + fname2 = Path("new2.txt") + fname2.touch() + raw_repo.git.add(str(fname2)) + + # both should be there + fnames = git_repo.get_tracked_files() + self.assertIn(str(fname), fnames) + self.assertIn(str(fname2), fnames) + + aiderignore.write_text("new.txt\n") + + # new.txt should be gone! + fnames = git_repo.get_tracked_files() + self.assertNotIn(str(fname), fnames) + self.assertIn(str(fname2), fnames) + + # This does not work in github actions?! + # The mtime doesn't change, even if I time.sleep(1) + # Before doing this write_text()!? + # + # aiderignore.write_text("new2.txt\n") + # new2.txt should be gone! + # fnames = git_repo.get_tracked_files() + # self.assertIn(str(fname), fnames) + # self.assertNotIn(str(fname2), fnames) + def test_get_tracked_files_from_subdir(self): with GitTemporaryDirectory(): # new repo @@ -194,3 +242,21 @@ class TestRepo(unittest.TestCase): raw_repo.git.commit("-m", "new") fnames = git_repo.get_tracked_files() self.assertIn(str(fname), fnames) + + @patch("aider.repo.simple_send_with_retries") + def test_noop_commit(self, mock_send): + mock_send.return_value = '"a good commit message"' + + with GitTemporaryDirectory(): + # new repo + raw_repo = git.Repo() + + # add it, but no commits at all in the raw_repo yet + fname = Path("file.txt") + fname.touch() + raw_repo.git.add(str(fname)) + raw_repo.git.commit("-m", "new") + + git_repo = GitRepo(InputOutput(), None, None) + + git_repo.commit(fnames=[str(fname)])