diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 000000000..9b1c646e0 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,39 @@ +name: Python Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + + steps: + - name: Check out repository + uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install universal ctags + run: | + sudo apt-get update + sudo apt-get install -y universal-ctags + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + run: | + python -m unittest discover diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2a4b1815d..068be9acd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,3 +9,8 @@ repos: hooks: - id: flake8 args: ["--show-source"] + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + args: ["--profile", "black"] diff --git a/README.md b/README.md index 7d1efce01..35e430945 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,20 @@ `aider` is a command-line chat tool that allows you to write and edit code with GPT-4. You can ask GPT to help you start -a new project, or add features to your existing code. -`aider` is well suited for working with code in a git repo, -as it has features to easily -commit, diff and undo changes proposed by GPT. +a new project, or modify code in your existing git repo. +Aider has features to +[help GPT understand and modify larger codebases](https://aider.chat/docs/ctags.html) +and aider makes it easy to git commit, diff & undo changes proposed by GPT. -![aider screenshot](screenshot.gif) +![aider screenshot](assets/screenshot.gif) + +- [Example chat transcripts](#example-chat-transcripts) +- [Features](#features) +- [Installation](#installation) +- [Usage](#usage) +- [In chat commands](#in-chat-commands) +- [Tips](#tips) +- [Limitations](#limitations) ## Example chat transcripts @@ -22,13 +30,14 @@ Here are some example transcripts that show how you can chat with `aider` to wri * [**Create a Black Box Test Case**](https://aider.chat/examples/add-test.html): GPT creates a "black box" test case without access to the source of the method being tested, using only a [high level map of the repository based on ctags](https://aider.chat/docs/ctags.html). -* [**Download, analyze and plot US Census data**](https://aider.chat/examples/census.html): GPT-4 downloads census data, suggests some hypotheses to test, tests one and then summarizes and plots a graph of the results. +* [**Honor the NO_COLOR env var**](https://aider.chat/examples/no-color.html): The user pastes the NO_COLOR spec from no-color.org into the chat, and GPT-4 modifies the application to conform. You can find more chat transcripts on the [examples page](https://aider.chat/examples/). ## Features -* Chat with GPT-4 about your code by launching `aider` from the command line with set of source files to discuss and edit together. +* Chat with GPT-4 about your code by launching `aider` from the command line with set of source files to discuss and edit together. GPT can see and edit the content of those files. +* If you or GPT mention other filenames that are part of the git repo, `aider` will ask if you'd like to add them to the chat. See also the in-chat `/add` and `/drop` commands described below, which are other ways to manage which files are part of the chat context. * Request new features, changes, improvements, or bug fixes to your code. Ask for new test cases, updated documentation or code refactors. * `aider` will apply the edits suggested by GPT-4 directly to your source files. * `aider` will automatically commit each changeset to your local git repo with a descriptive commit message. These frequent, automatic commits provide a safety net. It's easy to undo `aider` changes or use standard git workflows to manage longer sequences of changes. @@ -50,7 +59,7 @@ which helps it understand and modify large codebases. * From GitHub: `pip install git+https://github.com/paul-gauthier/aider.git` * From your local copy of the repo in develop mode to pick up local edits immediately: `pip install -e .` -2. Set up your OpenAI API key as an environment variable `OPENAI_API_KEY` or by including it in a `.env` file. +2. Set up your OpenAI API key as an environment variable `OPENAI_API_KEY` or by including it in an `.aider.config.yml` file (see `aider --help`). 3. Optionally, install [universal ctags](https://github.com/universal-ctags/ctags). This is helpful if you plan to work with repositories with more than a handful of files. This allows `aider --ctags` to build a [map of your entire git repo](https://aider.chat/docs/ctags.html) and share it with GPT to help it better understand and modify large codebases. @@ -64,46 +73,42 @@ aider ... Replace ``, ``, etc., with the paths to the source code files you want to work on. These files will be added to the chat session. -You can also just launch `aider` anywhere in a git repo without naming files on the command line. -It will discover all the files in the repo. -You can then add and remove individual files in the chat session with the `/add` and `/drop` chat commands described below. +You can also just launch `aider` anywhere in a git repo without naming +files on the command line. It will discover all the files in the +repo. You can then add and remove individual files in the chat +session with the `/add` and `/drop` chat commands described below. +If you or GPT mention one of the repo's filenames in the conversation, +`aider` will ask if you'd like to add it to the chat. You can also use additional command-line options, environment variables or configuration file to set many options: ``` - -h, --help show this help message and exit -c CONFIG_FILE, --config CONFIG_FILE - Specify the config file (default: search for - .aider.conf.yml in git root or home directory) + Specify the config file (default: search for .aider.conf.yml in git root or home directory) --input-history-file INPUT_HISTORY_FILE - Specify the chat input history file (default: - .aider.input.history) [env var: AIDER_INPUT_HISTORY_FILE] + Specify the chat input history file (default: .aider.input.history) [env var: AIDER_INPUT_HISTORY_FILE] --chat-history-file CHAT_HISTORY_FILE - Specify the chat history file (default: - .aider.chat.history.md) [env var: AIDER_CHAT_HISTORY_FILE] - --model MODEL Specify the model to use for the main chat (default: gpt-4) - [env var: AIDER_MODEL] + Specify the chat history file (default: .aider.chat.history.md) [env var: AIDER_CHAT_HISTORY_FILE] + --model MODEL Specify the model to use for the main chat (default: gpt-4) [env var: AIDER_MODEL] -3 Use gpt-3.5-turbo model for the main chat (not advised) - --pretty Enable pretty, colorized output (default: True) [env var: - AIDER_PRETTY] - --no-pretty Disable pretty, colorized output - --apply FILE Apply the changes from the given file instead of running - the chat (debug) - --auto-commits Enable auto commit of changes (default: True) [env var: - AIDER_AUTO_COMMIT] - --no-auto-commits Disable auto commit of changes - --dry-run Perform a dry run without applying changes (default: False) - --show-diffs Show diffs when committing changes (default: False) [env - var: AIDER_SHOW_DIFFS] - --ctags [CTAGS] Add ctags to the chat to help GPT understand the codebase - (default: check for ctags executable) [env var: - AIDER_CTAGS] - --yes Always say yes to every confirmation - -v, --verbose Enable verbose output + --pretty Enable pretty, colorized output (default: True) [env var: AIDER_PRETTY] + --no-pretty Disable pretty, colorized output [env var: AIDER_NO_PRETTY] + --apply FILE Apply the changes from the given file instead of running the chat (debug) [env var: AIDER_APPLY] + --auto-commits Enable auto commit of changes (default: True) [env var: AIDER_AUTO_COMMITS] + --no-auto-commits Disable auto commit of changes [env var: AIDER_NO_AUTO_COMMITS] + --dirty-commits Enable dirty commit of changes [env var: AIDER_DIRTY_COMMITS] + --no-dirty-commits Disable dirty commit of changes [env var: AIDER_NO_DIRTY_COMMITS] + --openai-api-key OPENAI_API_KEY + Specify the OpenAI API key [env var: OPENAI_API_KEY] + --dry-run Perform a dry run without applying changes (default: False) [env var: AIDER_DRY_RUN] + --show-diffs Show diffs when committing changes (default: False) [env var: AIDER_SHOW_DIFFS] + --ctags [CTAGS] Add ctags to the chat to help GPT understand the codebase (default: check for ctags executable) [env var: AIDER_CTAGS] + --yes Always say yes to every confirmation [env var: AIDER_YES] + -v, --verbose Enable verbose output [env var: AIDER_VERBOSE] ``` -## Chat commands +## In chat commands `aider` supports the following commands from within the chat: @@ -120,7 +125,7 @@ To use a command, simply type it in the chat input followed by any required argu ## Tips -* Large changes are best performed as a sequence of bite sized steps. Same as if you were undertaking them by yourself. +* Large changes are best performed as a sequence of thoughtful bite sized steps, where you plan out the approach and overall design. Don't ask GPT to "build a house" all in one go. Instead, think about the architecture you want and then ask it to "build a foundation", "erect the walls", "run the wiring", etc. * Use Control-C to safely interrupt `aider` if it isn't providing a useful response. The partial response remains in the conversation, so you can refer to it when you reply with more information or direction. * Use the `/run` command to run tests, linters, etc and show the output to GPT so it can fix any issues. * Enter a multiline chat message by entering `{` alone on the first line. End the multiline message with `}` alone on the last line. diff --git a/_layouts/default.html b/_layouts/default.html index b10c23c22..36c6d5e7b 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -4,8 +4,8 @@ {% seo %} - - + + diff --git a/aider/coder.py b/aider/coder.py index 366cb1bc3..af761d62f 100755 --- a/aider/coder.py +++ b/aider/coder.py @@ -2,25 +2,25 @@ import os import sys -import traceback import time -from openai.error import RateLimitError - -from rich.console import Console -from rich.live import Live -from rich.markdown import Markdown +import traceback from pathlib import Path import git import openai +from openai.error import RateLimitError +from rich.console import Console +from rich.live import Live +from rich.markdown import Markdown # from aider.dump import dump -from aider import utils -from aider import prompts +from aider import prompts, utils from aider.commands import Commands from aider.repomap import RepoMap -openai.api_key = os.getenv("OPENAI_API_KEY") + +class MissingAPIKeyError(ValueError): + pass class Coder: @@ -37,10 +37,17 @@ class Coder: pretty=True, show_diffs=False, auto_commits=True, + dirty_commits=True, dry_run=False, use_ctags=False, verbose=False, + openai_api_key=None, ): + if openai_api_key: + openai.api_key = openai_api_key + else: + raise MissingAPIKeyError("No OpenAI API key provided.") + self.verbose = verbose self.abs_fnames = set() self.cur_messages = [] @@ -48,6 +55,7 @@ class Coder: self.io = io self.auto_commits = auto_commits + self.dirty_commits = dirty_commits self.dry_run = dry_run if pretty: @@ -66,7 +74,7 @@ class Coder: if self.repo: rel_repo_dir = os.path.relpath(self.repo.git_dir, os.getcwd()) - self.io.tool("Using git repo:", rel_repo_dir) + self.io.tool_output("Using git repo:", rel_repo_dir) else: self.io.tool_error("No suitable git repo, will not automatically commit edits.") self.find_common_root() @@ -83,7 +91,7 @@ class Coder: else: self.root = os.getcwd() - self.io.tool(f"Common root directory: {self.root}") + self.io.tool_output(f"Common root directory: {self.root}") def set_repo(self, cmd_line_fnames): if not cmd_line_fnames: @@ -93,7 +101,7 @@ class Coder: for fname in cmd_line_fnames: fname = Path(fname) if not fname.exists(): - self.io.tool(f"Creating empty file {fname}") + self.io.tool_output(f"Creating empty file {fname}") fname.parent.mkdir(parents=True, exist_ok=True) fname.touch() @@ -106,7 +114,7 @@ class Coder: if fname.is_dir(): continue - self.io.tool(f"Added {fname} to the chat") + self.io.tool_output(f"Added {fname} to the chat") fname = fname.resolve() self.abs_fnames.add(str(fname)) @@ -135,18 +143,18 @@ class Coder: if new_files: rel_repo_dir = os.path.relpath(repo.git_dir, os.getcwd()) - self.io.tool(f"Files not tracked in {rel_repo_dir}:") + self.io.tool_output(f"Files not tracked in {rel_repo_dir}:") for fn in new_files: - self.io.tool(f" - {fn}") + self.io.tool_output(f" - {fn}") if self.io.confirm_ask("Add them?"): for relative_fname in new_files: repo.git.add(relative_fname) - self.io.tool(f"Added {relative_fname} to the git repo") + self.io.tool_output(f"Added {relative_fname} to the git repo") show_files = ", ".join(new_files) commit_message = f"Added new files to the git repo: {show_files}" repo.git.commit("-m", commit_message, "--no-verify") commit_hash = repo.head.commit.hexsha[:7] - self.io.tool(f"Commit {commit_hash} {commit_message}") + self.io.tool_output(f"Commit {commit_hash} {commit_message}") else: self.io.tool_error("Skipped adding new files to the git repo.") return @@ -216,7 +224,7 @@ class Coder: if is_commit_command: return - if not self.auto_commits: + if not self.dirty_commits: return if not self.repo: return @@ -227,7 +235,12 @@ class Coder: return True def run_loop(self): - inp = self.io.get_input(self.abs_fnames, self.commands) + inp = self.io.get_input( + self.root, + self.get_inchat_relative_files(), + self.get_addable_relative_files(), + self.commands, + ) self.num_control_c = 0 @@ -243,7 +256,7 @@ class Coder: self.cur_messages = [] if inp.strip(): - self.io.tool("Use up-arrow to retry previous command:", inp) + self.io.tool_output("Use up-arrow to retry previous command:", inp) return if not inp: @@ -252,6 +265,8 @@ class Coder: if self.commands.is_command(inp): return self.commands.run(inp) + self.check_for_file_mentions(inp) + return self.send_new_user_message(inp) def send_new_user_message(self, inp): @@ -279,7 +294,7 @@ class Coder: dict(role="assistant", content=content), ] - self.io.tool() + self.io.tool_output() if interrupted: return @@ -327,20 +342,29 @@ class Coder: quotes = "".join(['"', "'", "`"]) words = set(word.strip(quotes) for word in words) - addable_rel_fnames = set(self.get_all_relative_files()) - set( - self.get_inchat_relative_files() - ) + addable_rel_fnames = self.get_addable_relative_files() mentioned_rel_fnames = set() - for word in words: - if word in addable_rel_fnames: - mentioned_rel_fnames.add(word) + fname_to_rel_fnames = {} + for rel_fname in addable_rel_fnames: + fname = os.path.basename(rel_fname) + if fname not in fname_to_rel_fnames: + fname_to_rel_fnames[fname] = [] + fname_to_rel_fnames[fname].append(rel_fname) + + for fname, rel_fnames in fname_to_rel_fnames.items(): + if len(rel_fnames) == 1 and fname in words: + mentioned_rel_fnames.add(rel_fnames[0]) + else: + for rel_fname in rel_fnames: + if rel_fname in words: + mentioned_rel_fnames.add(rel_fname) if not mentioned_rel_fnames: return for rel_fname in mentioned_rel_fnames: - self.io.tool(rel_fname) + self.io.tool_output(rel_fname) if not self.io.confirm_ask("Add these files to the chat?"): return @@ -450,9 +474,9 @@ class Coder: edited.add(path) if utils.do_replace(full_path, original, updated, self.dry_run): if self.dry_run: - self.io.tool(f"Dry run, did not apply edit to {path}") + self.io.tool_output(f"Dry run, did not apply edit to {path}") else: - self.io.tool(f"Applied edit to {path}") + self.io.tool_output(f"Applied edit to {path}") else: self.io.tool_error(f"Failed to apply edit to {path}") @@ -490,7 +514,9 @@ class Coder: ) return - commit_message = commit_message.strip().strip('"').strip() + commit_message = commit_message.strip() + if commit_message and commit_message[0] == '"' and commit_message[-1] == '"': + commit_message = commit_message[1:-1].strip() if interrupted: self.io.tool_error( @@ -548,7 +574,7 @@ class Coder: raise ValueError(f"Invalid value for 'which': {which}") if self.show_diffs or ask: - # don't use io.tool() because we don't want to log or further colorize + # don't use io.tool_output() because we don't want to log or further colorize print(diffs) context = self.get_context_from_history(history) @@ -562,9 +588,9 @@ class Coder: if ask: if which == "repo_files": - self.io.tool("Git repo has uncommitted changes.") + self.io.tool_output("Git repo has uncommitted changes.") else: - self.io.tool("Files have uncommitted changes.") + self.io.tool_output("Files have uncommitted changes.") res = self.io.prompt_ask( "Commit before the chat proceeds [y/n/commit message]?", @@ -572,7 +598,7 @@ class Coder: ).strip() self.last_asked_for_commit_time = self.get_last_modified() - self.io.tool() + self.io.tool_output() if res.lower() in ["n", "no"]: self.io.tool_error("Skipped commmit.") @@ -585,7 +611,7 @@ class Coder: full_commit_message = commit_message + "\n\n" + context repo.git.commit("-m", full_commit_message, "--no-verify") commit_hash = repo.head.commit.hexsha[:7] - self.io.tool(f"Commit {commit_hash} {commit_message}") + self.io.tool_output(f"Commit {commit_hash} {commit_message}") return commit_hash, commit_message @@ -615,6 +641,9 @@ class Coder: return 0 return max(Path(path).stat().st_mtime for path in files) + def get_addable_relative_files(self): + return set(self.get_all_relative_files()) - set(self.get_inchat_relative_files()) + def apply_updates(self, content, inp): try: edited = self.update_files(content, inp) diff --git a/aider/commands.py b/aider/commands.py index 00fd36182..56906e4e6 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -1,9 +1,11 @@ -import sys import os -import git -import subprocess import shlex +import subprocess +import sys + +import git from prompt_toolkit.completion import Completion + from aider import prompts @@ -37,7 +39,7 @@ class Commands: if cmd_method: return cmd_method(args) else: - self.io.tool(f"Error: Command {cmd_name} not found.") + self.io.tool_output(f"Error: Command {cmd_name} not found.") def run(self, inp): words = inp.strip().split() @@ -110,7 +112,7 @@ class Commands: self.io.tool_error("The last commit was not made by aider in this chat session.") return self.coder.repo.git.reset("--hard", "HEAD~1") - self.io.tool( + self.io.tool_output( f"{last_commit.message.strip()}\n" f"The above commit {self.coder.last_aider_commit_hash} " "was reset and removed from git.\n" @@ -131,7 +133,7 @@ class Commands: commits = f"{self.coder.last_aider_commit_hash}~1" diff = self.coder.get_diffs(commits, self.coder.last_aider_commit_hash) - # don't use io.tool() because we don't want to log or further colorize + # don't use io.tool_output() because we don't want to log or further colorize print(diff) def completions_add(self, partial): @@ -177,7 +179,7 @@ class Commands: abs_file_path = os.path.abspath(os.path.join(self.coder.root, matched_file)) if abs_file_path not in self.coder.abs_fnames: self.coder.abs_fnames.add(abs_file_path) - self.io.tool(f"Added {matched_file} to the chat") + self.io.tool_output(f"Added {matched_file} to the chat") added_fnames.append(matched_file) else: self.io.tool_error(f"{matched_file} is already in the chat") @@ -214,7 +216,7 @@ class Commands: for matched_file in matched_files: relative_fname = os.path.relpath(matched_file, self.coder.root) self.coder.abs_fnames.remove(matched_file) - self.io.tool(f"Removed {relative_fname} from the chat") + self.io.tool_output(f"Removed {relative_fname} from the chat") def cmd_run(self, args): "Run a shell command and optionally add the output to the chat" @@ -229,15 +231,15 @@ class Commands: print(combined_output) - ok = self.io.confirm_ask("Add the output to the chat?", default="y") - if not ok: - return + if self.io.confirm_ask("Add the output to the chat?", default="y"): + for line in combined_output.splitlines(): + self.io.tool_output(line, log_only=True) - msg = prompts.run_output.format( - command=args, - output=combined_output, - ) - return msg + msg = prompts.run_output.format( + command=args, + output=combined_output, + ) + return msg def cmd_exit(self, args): "Exit the application" @@ -258,14 +260,14 @@ class Commands: other_files.append(file) if chat_files: - self.io.tool("Files in chat:\n") + self.io.tool_output("Files in chat:\n") for file in chat_files: - self.io.tool(f" {file}") + self.io.tool_output(f" {file}") if other_files: - self.io.tool("\nRepo files not in the chat:\n") + self.io.tool_output("\nRepo files not in the chat:\n") for file in other_files: - self.io.tool(f" {file}") + self.io.tool_output(f" {file}") def cmd_help(self, args): "Show help about all commands" @@ -275,6 +277,6 @@ class Commands: cmd_method = getattr(self, cmd_method_name, None) if cmd_method: description = cmd_method.__doc__ - self.io.tool(f"{cmd} {description}") + self.io.tool_output(f"{cmd} {description}") else: - self.io.tool(f"{cmd} No description available.") + self.io.tool_output(f"{cmd} No description available.") diff --git a/aider/dump.py b/aider/dump.py index de6dcbb6d..2c8bf31c2 100644 --- a/aider/dump.py +++ b/aider/dump.py @@ -1,5 +1,5 @@ -import traceback import json +import traceback def cvt(s): diff --git a/aider/io.py b/aider/io.py index 1ca93a93d..6a2b6c8c7 100644 --- a/aider/io.py +++ b/aider/io.py @@ -1,24 +1,49 @@ import os -from prompt_toolkit.styles import Style -from prompt_toolkit.shortcuts import PromptSession, prompt +from collections import defaultdict +from datetime import datetime +from pathlib import Path + from prompt_toolkit.completion import Completer, Completion from prompt_toolkit.history import FileHistory -from prompt_toolkit.shortcuts import CompleteStyle +from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, prompt +from prompt_toolkit.styles import Style +from pygments.lexers import guess_lexer_for_filename +from pygments.token import Token +from pygments.util import ClassNotFound from rich.console import Console from rich.text import Text -from pathlib import Path -from datetime import datetime - -from aider import utils class FileContentCompleter(Completer): - def __init__(self, fnames, commands): + def __init__(self, root, rel_fnames, addable_rel_fnames, commands): self.commands = commands + self.addable_rel_fnames = addable_rel_fnames + self.rel_fnames = rel_fnames + + fname_to_rel_fnames = defaultdict(list) + for rel_fname in addable_rel_fnames: + fname = os.path.basename(rel_fname) + if fname != rel_fname: + fname_to_rel_fnames[fname].append(rel_fname) + self.fname_to_rel_fnames = fname_to_rel_fnames self.words = set() - for fname in fnames: - self.words.update(utils.get_name_identifiers(fname)) + + for rel_fname in addable_rel_fnames: + self.words.add(rel_fname) + + for rel_fname in rel_fnames: + self.words.add(rel_fname) + + fname = os.path.join(root, rel_fname) + with open(fname, "r") as f: + content = f.read() + try: + lexer = guess_lexer_for_filename(fname, content) + except ClassNotFound: + continue + tokens = list(lexer.get_tokens(content)) + self.words.update(token[1] for token in tokens if token[0] in Token.Name) def get_completions(self, document, complete_event): text = document.text_before_cursor @@ -35,11 +60,17 @@ class FileContentCompleter(Completer): return else: candidates = self.words + candidates.update(set(self.fname_to_rel_fnames)) last_word = words[-1] for word in candidates: if word.lower().startswith(last_word.lower()): - yield Completion(word, start_position=-len(last_word)) + rel_fnames = self.fname_to_rel_fnames.get(word, []) + if rel_fnames: + for rel_fname in rel_fnames: + yield Completion(rel_fname, start_position=-len(last_word)) + else: + yield Completion(word, start_position=-len(last_word)) class InputOutput: @@ -51,7 +82,18 @@ class InputOutput: chat_history_file=None, input=None, output=None, + user_input_color="blue", + tool_output_color=None, + tool_error_color="red", ): + no_color = os.environ.get("NO_COLOR") + if no_color is not None and no_color != "": + pretty = False + + self.user_input_color = user_input_color if pretty else None + self.tool_output_color = tool_output_color if pretty else None + self.tool_error_color = tool_error_color if pretty else None + self.input = input self.output = output self.pretty = pretty @@ -65,29 +107,20 @@ class InputOutput: if pretty: self.console = Console() else: - self.console = Console(force_terminal=True, no_color=True) + self.console = Console(no_color=True) current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") self.append_chat_history(f"\n# aider chat started at {current_time}\n\n") - def get_input(self, fnames, commands): + def get_input(self, root, rel_fnames, addable_rel_fnames, commands): if self.pretty: - self.console.rule() + style = dict(style=self.user_input_color) if self.user_input_color else dict() + self.console.rule(**style) else: print() - fnames = list(fnames) - if len(fnames) > 1: - common_prefix = os.path.commonpath(fnames) - if not common_prefix.endswith(os.path.sep): - common_prefix += os.path.sep - short_fnames = [fname.replace(common_prefix, "", 1) for fname in fnames] - elif len(fnames): - short_fnames = [os.path.basename(fnames[0])] - else: - short_fnames = [] - - show = " ".join(short_fnames) + rel_fnames = list(rel_fnames) + show = " ".join(rel_fnames) if len(show) > 10: show += "\n" show += "> " @@ -95,22 +128,29 @@ class InputOutput: inp = "" multiline_input = False - style = Style.from_dict({"": "green"}) + if self.user_input_color: + style = Style.from_dict({"": self.user_input_color}) + else: + style = None while True: - completer_instance = FileContentCompleter(fnames, commands) + completer_instance = FileContentCompleter( + root, rel_fnames, addable_rel_fnames, commands + ) if multiline_input: show = ". " session_kwargs = { "message": show, "completer": completer_instance, - "style": style, "reserve_space_for_menu": 4, "complete_style": CompleteStyle.MULTI_COLUMN, "input": self.input, "output": self.output, } + if style: + session_kwargs["style"] = style + if self.input_history_file is not None: session_kwargs["history"] = FileHistory(self.input_history_file) @@ -180,9 +220,10 @@ class InputOutput: self.append_chat_history(hist, linebreak=True, blockquote=True) message = Text(message) - self.console.print(message, style="red") + style = dict(style=self.tool_error_color) if self.tool_error_color else dict() + self.console.print(message, **style) - def tool(self, *messages, log_only=False): + def tool_output(self, *messages, log_only=False): if messages: hist = " ".join(messages) hist = f"{hist.strip()}" @@ -190,7 +231,8 @@ class InputOutput: if not log_only: messages = list(map(Text, messages)) - self.console.print(*messages) + style = dict(style=self.tool_output_color) if self.tool_output_color else dict() + self.console.print(*messages, **style) def append_chat_history(self, text, linebreak=False, blockquote=False): if blockquote: diff --git a/aider/main.py b/aider/main.py index 22b6ef11a..9913c171f 100644 --- a/aider/main.py +++ b/aider/main.py @@ -1,8 +1,9 @@ import os import sys -import git + import configargparse -from dotenv import load_dotenv +import git + from aider.coder import Coder from aider.io import InputOutput @@ -19,13 +20,11 @@ def main(args=None, input=None, output=None): if args is None: args = sys.argv[1:] - load_dotenv() - env_prefix = "AIDER_" + git_root = get_git_root() default_config_files = [ os.path.expanduser("~/.aider.conf.yml"), ] - git_root = get_git_root() if git_root: default_config_files.insert(0, os.path.join(git_root, ".aider.conf.yml")) @@ -34,6 +33,7 @@ def main(args=None, input=None, output=None): add_config_file_help=True, default_config_files=default_config_files, config_file_parser_class=configargparse.YAMLConfigFileParser, + auto_env_var_prefix="AIDER_", ) parser.add_argument( @@ -63,21 +63,18 @@ def main(args=None, input=None, output=None): parser.add_argument( "--input-history-file", metavar="INPUT_HISTORY_FILE", - env_var=f"{env_prefix}INPUT_HISTORY_FILE", default=default_input_history_file, help=f"Specify the chat input history file (default: {default_input_history_file})", ) parser.add_argument( "--chat-history-file", metavar="CHAT_HISTORY_FILE", - env_var=f"{env_prefix}CHAT_HISTORY_FILE", default=default_chat_history_file, help=f"Specify the chat history file (default: {default_chat_history_file})", ) parser.add_argument( "--model", metavar="MODEL", - env_var=f"{env_prefix}MODEL", default="gpt-4", help="Specify the model to use for the main chat (default: gpt-4)", ) @@ -91,17 +88,30 @@ def main(args=None, input=None, output=None): parser.add_argument( "--pretty", action="store_true", - env_var=f"{env_prefix}PRETTY", default=True, help="Enable pretty, colorized output (default: True)", ) - parser.add_argument( "--no-pretty", action="store_false", dest="pretty", help="Disable pretty, colorized output", ) + parser.add_argument( + "--user-input-color", + default="green", + help="Set the color for user input (default: green)", + ) + parser.add_argument( + "--tool-output-color", + default=None, + help="Set the color for tool output (default: None)", + ) + parser.add_argument( + "--tool-error-color", + default="red", + help="Set the color for tool error messages (default: red)", + ) parser.add_argument( "--apply", metavar="FILE", @@ -110,7 +120,7 @@ def main(args=None, input=None, output=None): parser.add_argument( "--auto-commits", action="store_true", - env_var=f"{env_prefix}AUTO_COMMIT", + dest="auto_commits", default=True, help="Enable auto commit of changes (default: True)", ) @@ -118,9 +128,28 @@ def main(args=None, input=None, output=None): parser.add_argument( "--no-auto-commits", action="store_false", - dest="auto_commit", + dest="auto_commits", help="Disable auto commit of changes", ) + parser.add_argument( + "--dirty-commits", + action="store_true", + dest="dirty_commits", + help="Enable dirty commit of changes", + default=True, + ) + parser.add_argument( + "--no-dirty-commits", + action="store_false", + dest="dirty_commits", + help="Disable dirty commit of changes", + ) + parser.add_argument( + "--openai-api-key", + metavar="OPENAI_API_KEY", + help="Specify the OpenAI API key", + env_var="OPENAI_API_KEY", + ) parser.add_argument( "--dry-run", action="store_true", @@ -130,7 +159,6 @@ def main(args=None, input=None, output=None): parser.add_argument( "--show-diffs", action="store_true", - env_var=f"{env_prefix}SHOW_DIFFS", help="Show diffs when committing changes (default: False)", default=False, ) @@ -140,7 +168,6 @@ def main(args=None, input=None, output=None): nargs="?", const=True, default=None, - env_var=f"{env_prefix}CTAGS", help=( "Add ctags to the chat to help GPT understand the codebase (default: check for ctags" " executable)" @@ -168,9 +195,23 @@ def main(args=None, input=None, output=None): args.chat_history_file, input=input, output=output, + user_input_color=args.user_input_color, + tool_output_color=args.tool_output_color, + tool_error_color=args.tool_error_color, ) - io.tool(*sys.argv, log_only=True) + if args.verbose: + show = parser.format_values() + io.tool_output(show) + io.tool_output("Option settings:") + for arg, val in sorted(vars(args).items()): + io.tool_output(f" - {arg}: {val}") + + io.tool_output(*sys.argv, log_only=True) + + if not args.openai_api_key: + io.tool_error("No OpenAI API key provided. Use --openai-api-key or env OPENAI_API_KEY.") + return 1 coder = Coder( io, @@ -179,12 +220,15 @@ def main(args=None, input=None, output=None): pretty=args.pretty, show_diffs=args.show_diffs, auto_commits=args.auto_commits, + dirty_commits=args.dirty_commits, dry_run=args.dry_run, use_ctags=args.ctags, verbose=args.verbose, + openai_api_key=args.openai_api_key, ) - if args.auto_commits: - coder.commit(ask=True, prefix="wip: ", which="repo_files") + + if args.dirty_commits: + coder.commit(ask=True, which="repo_files") if args.apply: with open(args.apply, "r") as f: @@ -192,6 +236,7 @@ def main(args=None, input=None, output=None): coder.update_files(content, inp="") return + io.tool_output("Use /help to see in-chat commands.") coder.run() diff --git a/aider/prompts.py b/aider/prompts.py index 0a53e5188..bfcca2f26 100644 --- a/aider/prompts.py +++ b/aider/prompts.py @@ -8,7 +8,7 @@ Take requests for changes to the supplied code. If the request is ambiguous, ask questions. Once you understand the request you MUST: -1. List the files you need to modify. If they are *read-only* ask the user to make them *read-write* using the file's full path name. +1. List the files you need to modify. Do not suggest changes to *read-only* files. You *MUST* ask the user to make them *read-write* using the file's full path name. End your reply and wait for their approval. 2. Think step-by-step and explain the needed changes. 3. Describe each change with an *edit block* per the example below. """ @@ -74,7 +74,7 @@ Reply with JUST the commit message, without quotes, comments, questions, etc! # COMMANDS undo_command_reply = "I did `git reset --hard HEAD~1` to discard the last edits." -added_files = "I added the content of these additional files: {fnames}" +added_files = "I added these *read-write* files: {fnames}" run_output = """I ran this command: diff --git a/aider/repomap.py b/aider/repomap.py index 1bbfa13e3..35ba01f58 100644 --- a/aider/repomap.py +++ b/aider/repomap.py @@ -1,15 +1,14 @@ -import os import json -import sys +import os import subprocess -import tiktoken +import sys import tempfile -from collections import defaultdict, Counter +from collections import Counter, defaultdict + import networkx as nx +import tiktoken from aider import prompts, utils -from aider.dump import dump - # Global cache for tags TAGS_CACHE = {} @@ -203,6 +202,7 @@ def find_py_files(directory): def call_map(): import random + import graphviz fnames = sys.argv[1:] @@ -322,3 +322,4 @@ def call_map(): if __name__ == "__main__": call_map() + # print(rm.get_tags_map(sys.argv[1:])) diff --git a/aider/utils.py b/aider/utils.py index aee42a36c..7bc2c703f 100644 --- a/aider/utils.py +++ b/aider/utils.py @@ -1,11 +1,11 @@ -import re import math - +import re from difflib import SequenceMatcher from pathlib import Path -from pygments.util import ClassNotFound + from pygments.lexers import guess_lexer_for_filename from pygments.token import Token +from pygments.util import ClassNotFound # from aider.dump import dump @@ -234,10 +234,15 @@ def find_original_update_blocks(content): processed.append(cur) # original_marker filename = processed[-2].splitlines()[-1].strip() - if not len(filename) or "`" in filename: - filename = processed[-2].splitlines()[-2].strip() + try: if not len(filename) or "`" in filename: - raise ValueError(f"Bad/missing filename. It should go right above {ORIGINAL}") + filename = processed[-2].splitlines()[-2].strip() + if not len(filename) or "`" in filename: + raise ValueError( + f"Bad/missing filename. It should go right above {ORIGINAL}" + ) + except IndexError: + raise ValueError(f"Bad/missing filename. It should go right above {ORIGINAL}") original_text = pieces.pop() processed.append(original_text) @@ -248,8 +253,10 @@ def find_original_update_blocks(content): raise ValueError(f"Expected {DIVIDER}") updated_text = pieces.pop() + processed.append(updated_text) updated_marker = pieces.pop() + processed.append(updated_marker) if updated_marker.strip() != UPDATED: raise ValueError(f"Expected {UPDATED}") diff --git a/assets/screenshot.gif b/assets/screenshot.gif new file mode 100644 index 000000000..9baf4f218 Binary files /dev/null and b/assets/screenshot.gif differ diff --git a/screenshot.png b/assets/screenshot.png similarity index 100% rename from screenshot.png rename to assets/screenshot.png diff --git a/docs/ctags.md b/docs/ctags.md index 5dcf50401..c8b40a28b 100644 --- a/docs/ctags.md +++ b/docs/ctags.md @@ -1,5 +1,5 @@ -# Improving GPT-4's codebase understanding with a map +# Improving GPT-4's codebase understanding with ctags ![robot flowchat](../assets/robot-flowchart.png) diff --git a/examples/README.md b/examples/README.md index c8e4f42a8..2575dbe4c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,6 +11,8 @@ In the chats, you'll see a varity of coding tasks like generating new code, edit * [**Create a Black Box Test Case**](https://aider.chat/examples/add-test.html): GPT creates a "black box" test case without access to the source of the method being tested, using only a [high level map of the repository based on ctags](https://aider.chat/docs/ctags.html). +* [**Honor the NO_COLOR env var**](https://aider.chat/examples/no-color.html): The user pastes the NO_COLOR spec from no-color.org into the chat, and GPT-4 modifies the application to conform. + * [**Download, analyze and plot US Census data**](https://aider.chat/examples/census.html): GPT-4 downloads census data, suggests some hypotheses to test, tests one and then summarizes and plots a graph of the results. * [**Semantic Search & Replace**](semantic-search-replace.md): Updating a collection of function calls, which requires dealing with various formatting and semantic differences in the various function call sites. diff --git a/examples/no-color.md b/examples/no-color.md new file mode 100644 index 000000000..b51ac6b98 --- /dev/null +++ b/examples/no-color.md @@ -0,0 +1,156 @@ + +# Honor the NO_COLOR environment variable + +The user pastes the specification of the NO_COLOR environment variable protocol +from [no-color.org](https://no-color.org) into the chat with GPT-4. + +GPT figures out which files need to be edited, and asks permission to make changes. +It then disables all the color and pretty-printing functions if NO_COLOR is +set, so the tool conforms to the specification. + +The user then asks for a test case, which GPT adds as well. + +## Chat transcript +> $ aider +> Using git repo: .git +> Use /help to see in-chat commands. + +#### Command-line software which adds ANSI color to its output by default should check for a NO_COLOR environment variable that, when present and not an empty string (regardless of its value), prevents the addition of ANSI color. + +To implement this feature, we need to modify the `aider/io.py` file. Please make it *read-write* so I can provide the necessary changes. + +> aider/io.py +> Add these files to the chat? y + +Great! Now, I will make the necessary changes to check for the `NO_COLOR` environment variable and disable ANSI color when it is present and not an empty string. + +```python +aider/io.py +<<<<<<< ORIGINAL +import os +======= +import os +import os.path +>>>>>>> UPDATED +``` + +```python +aider/io.py +<<<<<<< ORIGINAL + def __init__( + self, + pretty=True, + yes=False, + input_history_file=None, + chat_history_file=None, + input=None, + output=None, + user_input_color="blue", + tool_output_color=None, + tool_error_color="red", + ): + self.user_input_color = user_input_color + self.tool_output_color = tool_output_color + self.tool_error_color = tool_error_color + self.input = input + self.output = output + self.pretty = pretty + self.yes = yes + self.input_history_file = input_history_file + if chat_history_file is not None: + self.chat_history_file = Path(chat_history_file) + else: + self.chat_history_file = None + + if pretty: + self.console = Console() + else: + self.console = Console(force_terminal=True, no_color=True) +======= + def __init__( + self, + pretty=True, + yes=False, + input_history_file=None, + chat_history_file=None, + input=None, + output=None, + user_input_color="blue", + tool_output_color=None, + tool_error_color="red", + ): + no_color = os.environ.get("NO_COLOR") + if no_color is not None and no_color != "": + pretty = False + + self.user_input_color = user_input_color if pretty else None + self.tool_output_color = tool_output_color if pretty else None + self.tool_error_color = tool_error_color if pretty else None + self.input = input + self.output = output + self.pretty = pretty + self.yes = yes + self.input_history_file = input_history_file + if chat_history_file is not None: + self.chat_history_file = Path(chat_history_file) + else: + self.chat_history_file = None + + self.console = Console(force_terminal=not pretty, no_color=not pretty) +>>>>>>> UPDATED +``` + +These changes will make the software check for the `NO_COLOR` environment variable and disable ANSI color when it is present and not an empty string. + +> Applied edit to aider/io.py +> Applied edit to aider/io.py +> Commit 3218242 aider: Added check for NO_COLOR environment variable to disable ANSI color. + +#### add a test + +To add a test for the `NO_COLOR` environment variable, we will create a new test function in the `tests/test_io.py` file. Since this file does not exist, we will create it first. + +```python +tests/test_io.py +<<<<<<< ORIGINAL +======= +import os +import unittest +from unittest.mock import patch +from aider.io import InputOutput + +class TestInputOutput(unittest.TestCase): + def test_no_color_environment_variable(self): + with patch.dict(os.environ, {"NO_COLOR": "1"}): + io = InputOutput() + self.assertFalse(io.pretty) + +if __name__ == "__main__": + unittest.main() +>>>>>>> UPDATED +``` + +This test function checks if the `pretty` attribute of the `InputOutput` class is set to `False` when the `NO_COLOR` environment variable is set. + +> Allow creation of new file tests/test_io.py? y +> Add tests/test_io.py to git? y +> Applied edit to tests/test_io.py +> Commit 52a9df5 aider: Added a test function to check if the `pretty` attribute of the `InputOutput` class is set to `False` when the `NO_COLOR` environment variable is set. + +#### /run pytest + +> ============================= test session starts ============================== +> platform darwin -- Python 3.11.3, pytest-7.3.1, pluggy-1.0.0 +> rootdir: /Users/gauthier/Projects/aider +> collected 25 items +> +> tests/test_coder.py ...... [ 24%] +> tests/test_commands.py . [ 28%] +> tests/test_io.py . [ 32%] +> tests/test_main.py .... [ 48%] +> tests/test_repomap.py ... [ 60%] +> tests/test_utils.py .......... [100%] +> +> ============================== 25 passed in 1.41s ============================== +> +> Add the output to the chat? y diff --git a/requirements.txt b/requirements.txt index 9ad889621..ef00f47e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,6 @@ multidict==6.0.4 openai==0.27.6 prompt-toolkit==3.0.38 Pygments==2.15.1 -python-dotenv==1.0.0 requests==2.30.0 rich==13.3.5 smmap==5.0.0 diff --git a/screenshot.gif b/screenshot.gif deleted file mode 100644 index ced2de497..000000000 Binary files a/screenshot.gif and /dev/null differ diff --git a/setup.py b/setup.py index 6b922cfd1..20e70ba88 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -from setuptools import setup, find_packages +from setuptools import find_packages, setup with open("requirements.txt") as f: requirements = f.read().splitlines() diff --git a/tests/test_coder.py b/tests/test_coder.py new file mode 100644 index 000000000..52ffabdb9 --- /dev/null +++ b/tests/test_coder.py @@ -0,0 +1,114 @@ +import os +import unittest +from unittest.mock import MagicMock + +from aider.coder import Coder + + +class TestCoder(unittest.TestCase): + def test_check_for_file_mentions(self): + # Mock the IO object + mock_io = MagicMock() + + # Initialize the Coder object with the mocked IO and mocked repo + coder = Coder(io=mock_io, openai_api_key="fake_key") + + # Mock the git repo + mock_repo = MagicMock() + mock_repo.git.ls_files.return_value = "file1.txt\nfile2.py" + coder.repo = mock_repo + + # Call the check_for_file_mentions method + coder.check_for_file_mentions("Please check file1.txt and file2.py") + + # Check if coder.abs_fnames contains both files + expected_files = {os.path.abspath("file1.txt"), os.path.abspath("file2.py")} + self.assertEqual(coder.abs_fnames, expected_files) + + def test_check_for_filename_mentions_of_longer_paths(self): + # Mock the IO object + mock_io = MagicMock() + + # Initialize the Coder object with the mocked IO and mocked repo + coder = Coder(io=mock_io, openai_api_key="fake_key") + + # Mock the git repo + mock_repo = MagicMock() + mock_repo.git.ls_files.return_value = "./file1.txt\n./file2.py" + coder.repo = mock_repo + + # Call the check_for_file_mentions method + coder.check_for_file_mentions("Please check file1.txt and file2.py") + + # Check if coder.abs_fnames contains both files + expected_files = {os.path.abspath("file1.txt"), os.path.abspath("file2.py")} + self.assertEqual(coder.abs_fnames, expected_files) + + def test_check_for_ambiguous_filename_mentions_of_longer_paths(self): + # Mock the IO object + mock_io = MagicMock() + + # Initialize the Coder object with the mocked IO and mocked repo + coder = Coder(io=mock_io, openai_api_key="fake_key") + + # Mock the git repo + mock_repo = MagicMock() + mock_repo.git.ls_files.return_value = "./file1.txt\n./other/file1.txt" + coder.repo = mock_repo + + # Call the check_for_file_mentions method + coder.check_for_file_mentions("Please check file1.txt!") + + self.assertEqual(coder.abs_fnames, set()) + + def test_get_commit_message(self): + # Mock the IO object + mock_io = MagicMock() + + # Initialize the Coder object with the mocked IO and mocked repo + coder = Coder(io=mock_io, openai_api_key="fake_key") + + # Mock the send method to return a tuple with a message and False + coder.send = MagicMock(return_value=("a good commit message", False)) + + # Call the get_commit_message method with dummy diff and context + result = coder.get_commit_message("dummy diff", "dummy context") + + # Assert that the returned message is the expected one + self.assertEqual(result, "a good commit message") + + def test_get_commit_message_strip_quotes(self): + # Mock the IO object + mock_io = MagicMock() + + # Initialize the Coder object with the mocked IO and mocked repo + coder = Coder(io=mock_io, openai_api_key="fake_key") + + # Mock the send method to return a tuple with a message and False + coder.send = MagicMock(return_value=('"a good commit message"', False)) + + # Call the get_commit_message method with dummy diff and context + result = coder.get_commit_message("dummy diff", "dummy context") + + # Assert that the returned message is the expected one + self.assertEqual(result, "a good commit message") + + def test_get_commit_message_no_strip_unmatched_quotes(self): + # Mock the IO object + mock_io = MagicMock() + + # Initialize the Coder object with the mocked IO and mocked repo + coder = Coder(io=mock_io, openai_api_key="fake_key") + + # Mock the send method to return a tuple with a message and False + coder.send = MagicMock(return_value=('a good "commit message"', False)) + + # Call the get_commit_message method with dummy diff and context + result = coder.get_commit_message("dummy diff", "dummy context") + + # Assert that the returned message is the expected one + self.assertEqual(result, 'a good "commit message"') + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_commands.py b/tests/test_commands.py index 7dbb62f2d..5f3d596d1 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,27 +1,33 @@ import os +import shutil import tempfile from unittest import TestCase -from unittest.mock import MagicMock + from aider.commands import Commands from aider.io import InputOutput class TestCommands(TestCase): + def setUp(self): + self.original_cwd = os.getcwd() + self.tempdir = tempfile.mkdtemp() + os.chdir(self.tempdir) + + def tearDown(self): + os.chdir(self.original_cwd) + shutil.rmtree(self.tempdir) + def test_cmd_add(self): - # Create a temporary directory and change the current working directory - with tempfile.TemporaryDirectory() as tmpdir: - os.chdir(tmpdir) + # Initialize the Commands and InputOutput objects + io = InputOutput(pretty=False, yes=True) + from aider.coder import Coder - # Initialize the Commands and InputOutput objects - io = InputOutput(pretty=False, yes=True) - from aider.coder import Coder + coder = Coder(io, openai_api_key="deadbeef") + commands = Commands(io, coder) - coder = Coder(io) - commands = Commands(io, coder) + # Call the cmd_add method with 'foo.txt' and 'bar.txt' as a single string + commands.cmd_add("foo.txt bar.txt") - # Call the cmd_add method with 'foo.txt' and 'bar.txt' as a single string - commands.cmd_add("foo.txt bar.txt") - - # Check if both files have been created in the temporary directory - self.assertTrue(os.path.exists("foo.txt")) - self.assertTrue(os.path.exists("bar.txt")) + # Check if both files have been created in the temporary directory + self.assertTrue(os.path.exists("foo.txt")) + self.assertTrue(os.path.exists("bar.txt")) diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 000000000..dfc3806ff --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,16 @@ +import os +import unittest +from unittest.mock import patch + +from aider.io import InputOutput + + +class TestInputOutput(unittest.TestCase): + def test_no_color_environment_variable(self): + with patch.dict(os.environ, {"NO_COLOR": "1"}): + io = InputOutput() + self.assertFalse(io.pretty) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_main.py b/tests/test_main.py index a497c1d74..eb35435dc 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,34 +1,82 @@ import os -import tempfile -from unittest import TestCase -from aider.main import main +import shutil import subprocess -from prompt_toolkit.input import create_input +import tempfile from io import StringIO +from unittest import TestCase +from unittest.mock import patch + +from prompt_toolkit.input import create_input from prompt_toolkit.output import DummyOutput +from aider.main import main + class TestMain(TestCase): + def setUp(self): + os.environ["OPENAI_API_KEY"] = "deadbeef" + self.original_cwd = os.getcwd() + self.tempdir = tempfile.mkdtemp() + os.chdir(self.tempdir) + + def tearDown(self): + os.chdir(self.original_cwd) + shutil.rmtree(self.tempdir) + def test_main_with_empty_dir_no_files_on_command(self): - with tempfile.TemporaryDirectory() as temp_dir: - os.chdir(temp_dir) - pipe_input = create_input(StringIO("")) - main([], input=pipe_input, output=DummyOutput()) - pipe_input.close() + pipe_input = create_input(StringIO("")) + main([], input=pipe_input, output=DummyOutput()) + pipe_input.close() def test_main_with_empty_dir_new_file(self): - with tempfile.TemporaryDirectory() as temp_dir: - os.chdir(temp_dir) - pipe_input = create_input(StringIO("")) - main(["foo.txt"], input=pipe_input, output=DummyOutput()) - pipe_input.close() - self.assertTrue(os.path.exists("foo.txt")) + pipe_input = create_input(StringIO("")) + main(["foo.txt"], input=pipe_input, output=DummyOutput()) + pipe_input.close() + self.assertTrue(os.path.exists("foo.txt")) def test_main_with_empty_git_dir_new_file(self): - with tempfile.TemporaryDirectory() as temp_dir: - os.chdir(temp_dir) - subprocess.run(["git", "init"], cwd=temp_dir) - pipe_input = create_input(StringIO("")) - main(["--yes", "foo.txt"], input=pipe_input, output=DummyOutput()) - pipe_input.close() - self.assertTrue(os.path.exists("foo.txt")) + subprocess.run(["git", "init"]) + subprocess.run(["git", "config", "user.email", "dummy@example.com"]) + subprocess.run(["git", "config", "user.name", "Dummy User"]) + pipe_input = create_input(StringIO("")) + main(["--yes", "foo.txt"], input=pipe_input, output=DummyOutput()) + pipe_input.close() + self.assertTrue(os.path.exists("foo.txt")) + + def test_main_args(self): + with patch("aider.main.Coder") as MockCoder: + main(["--no-auto-commits"]) + _, kwargs = MockCoder.call_args + assert kwargs["auto_commits"] is False + + with patch("aider.main.Coder") as MockCoder: + main(["--auto-commits"]) + _, kwargs = MockCoder.call_args + assert kwargs["auto_commits"] is True + + with patch("aider.main.Coder") as MockCoder: + main([]) + _, kwargs = MockCoder.call_args + assert kwargs["dirty_commits"] is True + assert kwargs["auto_commits"] is True + assert kwargs["pretty"] is True + + with patch("aider.main.Coder") as MockCoder: + main(["--no-pretty"]) + _, kwargs = MockCoder.call_args + assert kwargs["pretty"] is False + + with patch("aider.main.Coder") as MockCoder: + main(["--pretty"]) + _, kwargs = MockCoder.call_args + assert kwargs["pretty"] is True + + with patch("aider.main.Coder") as MockCoder: + main(["--no-dirty-commits"]) + _, kwargs = MockCoder.call_args + assert kwargs["dirty_commits"] is False + + with patch("aider.main.Coder") as MockCoder: + main(["--dirty-commits"]) + _, kwargs = MockCoder.call_args + assert kwargs["dirty_commits"] is True diff --git a/tests/test_repomap.py b/tests/test_repomap.py index aa7e10b36..b43fc7779 100644 --- a/tests/test_repomap.py +++ b/tests/test_repomap.py @@ -1,6 +1,9 @@ import os import tempfile import unittest +from subprocess import CompletedProcess +from unittest.mock import patch + from aider.repomap import RepoMap @@ -55,6 +58,27 @@ def my_function(arg1, arg2): self.assertIn("my_method", result) self.assertIn("my_function", result) + def test_check_for_ctags_failure(self): + with patch("subprocess.run") as mock_run: + mock_run.side_effect = Exception("ctags not found") + repo_map = RepoMap(use_ctags=True) + result = repo_map.check_for_ctags() + self.assertFalse(result) + + def test_check_for_ctags_success(self): + with patch("subprocess.run") as mock_run: + mock_run.return_value = CompletedProcess( + args=["ctags", "--version"], + returncode=0, + stdout=( + b'{"_type": "tag", "name": "status", "path": "aider/main.py", "pattern": "/^ ' + b' status = main()$/", "kind": "variable"}' + ), + ) + repo_map = RepoMap(use_ctags=True) + result = repo_map.check_for_ctags() + self.assertTrue(result) + def test_get_tags_map_without_ctags(self): # Create a temporary directory with a sample Python file containing identifiers test_files = [ diff --git a/tests/test_utils.py b/tests/test_utils.py index bddeb67dc..30c0dcaae 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,7 @@ # flake8: noqa: E501 import unittest + from aider import utils @@ -153,6 +154,51 @@ aider/coder.py # Should not raise a ValueError list(utils.find_original_update_blocks(edit)) + def test_incomplete_edit_block_missing_filename(self): + edit = """ +No problem! Here are the changes to patch `subprocess.check_output` instead of `subprocess.run` in both tests: + +```python +tests/test_repomap.py +<<<<<<< ORIGINAL + def test_check_for_ctags_failure(self): + with patch("subprocess.run") as mock_run: + mock_run.side_effect = Exception("ctags not found") +======= + def test_check_for_ctags_failure(self): + with patch("subprocess.check_output") as mock_check_output: + mock_check_output.side_effect = Exception("ctags not found") +>>>>>>> UPDATED + +<<<<<<< ORIGINAL + def test_check_for_ctags_success(self): + with patch("subprocess.run") as mock_run: + mock_run.return_value = CompletedProcess(args=["ctags", "--version"], returncode=0, stdout='''{ + "_type": "tag", + "name": "status", + "path": "aider/main.py", + "pattern": "/^ status = main()$/", + "kind": "variable" +}''') +======= + def test_check_for_ctags_success(self): + with patch("subprocess.check_output") as mock_check_output: + mock_check_output.return_value = '''{ + "_type": "tag", + "name": "status", + "path": "aider/main.py", + "pattern": "/^ status = main()$/", + "kind": "variable" +}''' +>>>>>>> UPDATED +``` + +These changes replace the `subprocess.run` patches with `subprocess.check_output` patches in both `test_check_for_ctags_failure` and `test_check_for_ctags_success` tests. +""" + with self.assertRaises(ValueError) as cm: + list(utils.find_original_update_blocks(edit)) + self.assertIn("missing filename", str(cm.exception)) + if __name__ == "__main__": unittest.main()