aider/tests/basic/test_main.py

1556 lines
65 KiB
Python

import json
import os
import subprocess
import tempfile
from io import StringIO
from pathlib import Path
from unittest import TestCase
from unittest.mock import AsyncMock, MagicMock, patch
import git
from prompt_toolkit.input import DummyInput
from prompt_toolkit.output import DummyOutput
from aider.coders import Coder
from aider.dump import dump # noqa: F401
from aider.io import InputOutput
from aider.main import check_gitignore, load_dotenv_files, main, setup_git
from aider.utils import GitTemporaryDirectory, IgnorantTemporaryDirectory, make_repo
class TestMain(TestCase):
def setUp(self):
self.original_env = os.environ.copy()
os.environ["OPENAI_API_KEY"] = "deadbeef"
os.environ["AIDER_CHECK_UPDATE"] = "false"
os.environ["AIDER_ANALYTICS"] = "false"
self.original_cwd = os.getcwd()
self.tempdir_obj = IgnorantTemporaryDirectory()
self.tempdir = self.tempdir_obj.name
os.chdir(self.tempdir)
# Fake home directory prevents tests from using the real ~/.aider.conf.yml file:
self.homedir_obj = IgnorantTemporaryDirectory()
os.environ["HOME"] = self.homedir_obj.name
self.input_patcher = patch("builtins.input", return_value=None)
self.mock_input = self.input_patcher.start()
self.webbrowser_patcher = patch("aider.io.webbrowser.open")
self.mock_webbrowser = self.webbrowser_patcher.start()
def tearDown(self):
os.chdir(self.original_cwd)
self.tempdir_obj.cleanup()
self.homedir_obj.cleanup()
os.environ.clear()
os.environ.update(self.original_env)
self.input_patcher.stop()
self.webbrowser_patcher.stop()
async def test_main_with_empty_dir_no_files_on_command(self):
await main(["--no-git", "--exit", "--yes"], input=DummyInput(), output=DummyOutput())
async def test_main_with_emptqy_dir_new_file(self):
await main(
["foo.txt", "--yes", "--no-git", "--exit"], input=DummyInput(), output=DummyOutput()
)
self.assertTrue(os.path.exists("foo.txt"))
@patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message")
async def test_main_with_empty_git_dir_new_file(self, _):
make_repo()
await main(["--yes", "foo.txt", "--exit"], input=DummyInput(), output=DummyOutput())
self.assertTrue(os.path.exists("foo.txt"))
@patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message")
async def test_main_with_empty_git_dir_new_files(self, _):
make_repo()
await main(
["--yes", "foo.txt", "bar.txt", "--exit"], input=DummyInput(), output=DummyOutput()
)
self.assertTrue(os.path.exists("foo.txt"))
self.assertTrue(os.path.exists("bar.txt"))
async def test_main_with_dname_and_fname(self):
subdir = Path("subdir")
subdir.mkdir()
make_repo(str(subdir))
res = await main(["subdir", "foo.txt"], input=DummyInput(), output=DummyOutput())
self.assertNotEqual(res, None)
@patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message")
async def test_main_with_subdir_repo_fnames(self, _):
subdir = Path("subdir")
subdir.mkdir()
make_repo(str(subdir))
await main(
["--yes", str(subdir / "foo.txt"), str(subdir / "bar.txt"), "--exit"],
input=DummyInput(),
output=DummyOutput(),
)
self.assertTrue((subdir / "foo.txt").exists())
self.assertTrue((subdir / "bar.txt").exists())
async def test_main_with_git_config_yml(self):
make_repo()
Path(".aider.conf.yml").write_text("auto-commits: false\n")
with patch("aider.coders.Coder.create") as MockCoder:
await main(["--yes"], input=DummyInput(), output=DummyOutput())
_, kwargs = MockCoder.call_args
assert kwargs["auto_commits"] is False
Path(".aider.conf.yml").write_text("auto-commits: true\n")
with patch("aider.coders.Coder.create") as MockCoder:
await main([], input=DummyInput(), output=DummyOutput())
_, kwargs = MockCoder.call_args
assert kwargs["auto_commits"] is True
async def test_main_with_empty_git_dir_new_subdir_file(self):
make_repo()
subdir = Path("subdir")
subdir.mkdir()
fname = subdir / "foo.txt"
fname.touch()
subprocess.run(["git", "add", str(subdir)])
subprocess.run(["git", "commit", "-m", "added"])
# This will throw a git error on windows if get_tracked_files doesn't
# properly convert git/posix/paths to git\posix\paths.
# Because aider will try and `git add` a file that's already in the repo.
await main(["--yes", str(fname), "--exit"], input=DummyInput(), output=DummyOutput())
async def test_setup_git(self):
io = InputOutput(pretty=False, yes=True)
git_root = await setup_git(None, io)
git_root = Path(git_root).resolve()
self.assertEqual(git_root, Path(self.tempdir).resolve())
self.assertTrue(git.Repo(self.tempdir))
gitignore = Path.cwd() / ".gitignore"
self.assertTrue(gitignore.exists())
self.assertEqual(".aider*", gitignore.read_text().splitlines()[0])
async def test_check_gitignore(self):
with GitTemporaryDirectory():
os.environ["GIT_CONFIG_GLOBAL"] = "globalgitconfig"
io = InputOutput(pretty=False, yes=True)
cwd = Path.cwd()
gitignore = cwd / ".gitignore"
self.assertFalse(gitignore.exists())
await check_gitignore(cwd, io)
self.assertTrue(gitignore.exists())
self.assertEqual(".aider*", gitignore.read_text().splitlines()[0])
# Test without .env file present
gitignore.write_text("one\ntwo\n")
await check_gitignore(cwd, io)
self.assertEqual("one\ntwo\n.aider*\n", gitignore.read_text())
# Test with .env file present
env_file = cwd / ".env"
env_file.touch()
await check_gitignore(cwd, io)
self.assertEqual("one\ntwo\n.aider*\n.env\n", gitignore.read_text())
del os.environ["GIT_CONFIG_GLOBAL"]
async def test_command_line_gitignore_files_flag(self):
with GitTemporaryDirectory() as git_dir:
git_dir = Path(git_dir)
# Create a .gitignore file
gitignore_file = git_dir / ".gitignore"
gitignore_file.write_text("ignored.txt\n")
# Create an ignored file
ignored_file = git_dir / "ignored.txt"
ignored_file.write_text("This file should be ignored.")
# Get the absolute path to the ignored file
abs_ignored_file = str(ignored_file.resolve())
# Test without the --add-gitignore-files flag (default: False)
coder = await main(
["--exit", "--yes", abs_ignored_file],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
force_git_root=git_dir,
)
# Verify the ignored file is not in the chat
self.assertNotIn(abs_ignored_file, coder.abs_fnames)
# Test with --add-gitignore-files set to True
coder = await main(
["--add-gitignore-files", "--exit", "--yes", abs_ignored_file],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
force_git_root=git_dir,
)
# Verify the ignored file is in the chat
self.assertIn(abs_ignored_file, coder.abs_fnames)
# Test with --add-gitignore-files set to False
coder = await main(
["--no-add-gitignore-files", "--exit", "--yes", abs_ignored_file],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
force_git_root=git_dir,
)
# Verify the ignored file is not in the chat
self.assertNotIn(abs_ignored_file, coder.abs_fnames)
async def test_add_command_gitignore_files_flag(self):
with GitTemporaryDirectory() as git_dir:
git_dir = Path(git_dir)
# Create a .gitignore file
gitignore_file = git_dir / ".gitignore"
gitignore_file.write_text("ignored.txt\n")
# Create an ignored file
ignored_file = git_dir / "ignored.txt"
ignored_file.write_text("This file should be ignored.")
# Get the absolute path to the ignored file
abs_ignored_file = str(ignored_file.resolve())
rel_ignored_file = "ignored.txt"
# Test without the --add-gitignore-files flag (default: False)
coder = await main(
["--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
force_git_root=git_dir,
)
with patch.object(coder.io, "confirm_ask", return_value=True):
coder.commands.cmd_add(rel_ignored_file)
# Verify the ignored file is not in the chat
self.assertNotIn(abs_ignored_file, coder.abs_fnames)
# Test with --add-gitignore-files set to True
coder = await main(
["--add-gitignore-files", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
force_git_root=git_dir,
)
with patch.object(coder.io, "confirm_ask", return_value=True):
coder.commands.cmd_add(rel_ignored_file)
# Verify the ignored file is in the chat
self.assertIn(abs_ignored_file, coder.abs_fnames)
# Test with --add-gitignore-files set to False
coder = await main(
["--no-add-gitignore-files", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
force_git_root=git_dir,
)
with patch.object(coder.io, "confirm_ask", return_value=True):
coder.commands.cmd_add(rel_ignored_file)
# Verify the ignored file is not in the chat
self.assertNotIn(abs_ignored_file, coder.abs_fnames)
async def test_main_args(self):
with patch("aider.coders.Coder.create") as MockCoder:
# --yes will just ok the git repo without blocking on input
# following calls to main will see the new repo already
await main(["--no-auto-commits", "--yes"], input=DummyInput())
_, kwargs = MockCoder.call_args
assert kwargs["auto_commits"] is False
with patch("aider.coders.Coder.create") as MockCoder:
await main(["--auto-commits"], input=DummyInput())
_, kwargs = MockCoder.call_args
assert kwargs["auto_commits"] is True
with patch("aider.coders.Coder.create") as MockCoder:
await main([], input=DummyInput())
_, kwargs = MockCoder.call_args
assert kwargs["dirty_commits"] is True
assert kwargs["auto_commits"] is True
with patch("aider.coders.Coder.create") as MockCoder:
await main(["--no-dirty-commits"], input=DummyInput())
_, kwargs = MockCoder.call_args
assert kwargs["dirty_commits"] is False
with patch("aider.coders.Coder.create") as MockCoder:
await main(["--dirty-commits"], input=DummyInput())
_, kwargs = MockCoder.call_args
assert kwargs["dirty_commits"] is True
async def test_env_file_override(self):
with GitTemporaryDirectory() as git_dir:
git_dir = Path(git_dir)
git_env = git_dir / ".env"
fake_home = git_dir / "fake_home"
fake_home.mkdir()
os.environ["HOME"] = str(fake_home)
home_env = fake_home / ".env"
cwd = git_dir / "subdir"
cwd.mkdir()
os.chdir(cwd)
cwd_env = cwd / ".env"
named_env = git_dir / "named.env"
os.environ["E"] = "existing"
home_env.write_text("A=home\nB=home\nC=home\nD=home")
git_env.write_text("A=git\nB=git\nC=git")
cwd_env.write_text("A=cwd\nB=cwd")
named_env.write_text("A=named")
with patch("pathlib.Path.home", return_value=fake_home):
await main(["--yes", "--exit", "--env-file", str(named_env)])
self.assertEqual(os.environ["A"], "named")
self.assertEqual(os.environ["B"], "cwd")
self.assertEqual(os.environ["C"], "git")
self.assertEqual(os.environ["D"], "home")
self.assertEqual(os.environ["E"], "existing")
async def test_message_file_flag(self):
message_file_content = "This is a test message from a file."
message_file_path = tempfile.mktemp()
with open(message_file_path, "w", encoding="utf-8") as message_file:
message_file.write(message_file_content)
# Create a mock async function for the run method
async def mock_run(*args, **kwargs):
pass
with patch("aider.coders.Coder.create") as MockCoder:
# Create a mock coder instance with an async run method
mock_coder_instance = MagicMock()
mock_coder_instance.run = AsyncMock()
MockCoder.return_value = mock_coder_instance
await main(
["--yes", "--message-file", message_file_path],
input=DummyInput(),
output=DummyOutput(),
)
# Check that run was called with the correct message
mock_coder_instance.run.assert_called_once_with(with_message=message_file_content)
os.remove(message_file_path)
async def test_encodings_arg(self):
fname = "foo.py"
with GitTemporaryDirectory():
with patch("aider.coders.Coder.create") as MockCoder: # noqa: F841
with patch("aider.main.InputOutput") as MockSend:
def side_effect(*args, **kwargs):
self.assertEqual(kwargs["encoding"], "iso-8859-15")
return MagicMock()
MockSend.side_effect = side_effect
await main(["--yes", fname, "--encoding", "iso-8859-15"])
async def test_main_exit_calls_version_check(self):
with GitTemporaryDirectory():
with (
patch("aider.main.check_version") as mock_check_version,
patch("aider.main.InputOutput") as mock_input_output,
):
await main(["--exit", "--check-update"], input=DummyInput(), output=DummyOutput())
mock_check_version.assert_called_once()
mock_input_output.assert_called_once()
@patch("aider.main.InputOutput")
@patch("aider.coders.base_coder.Coder.run")
async def test_main_message_adds_to_input_history(self, mock_run, MockInputOutput):
test_message = "test message"
mock_io_instance = MockInputOutput.return_value
await main(["--message", test_message], input=DummyInput(), output=DummyOutput())
mock_io_instance.add_to_input_history.assert_called_once_with(test_message)
@patch("aider.main.InputOutput")
@patch("aider.coders.base_coder.Coder.run")
async def test_yes(self, mock_run, MockInputOutput):
test_message = "test message"
await main(["--yes", "--message", test_message])
args, kwargs = MockInputOutput.call_args
self.assertTrue(args[1])
@patch("aider.main.InputOutput")
@patch("aider.coders.base_coder.Coder.run")
async def test_default_yes(self, mock_run, MockInputOutput):
test_message = "test message"
await main(["--message", test_message])
args, kwargs = MockInputOutput.call_args
self.assertEqual(args[1], None)
async def test_dark_mode_sets_code_theme(self):
# Mock InputOutput to capture the configuration
with patch("aider.main.InputOutput") as MockInputOutput:
MockInputOutput.return_value.get_input.return_value = None
await main(
["--dark-mode", "--no-git", "--exit"], input=DummyInput(), output=DummyOutput()
)
# Ensure InputOutput was called
MockInputOutput.assert_called_once()
# Check if the code_theme setting is for dark mode
_, kwargs = MockInputOutput.call_args
self.assertEqual(kwargs["code_theme"], "monokai")
async def test_light_mode_sets_code_theme(self):
# Mock InputOutput to capture the configuration
with patch("aider.main.InputOutput") as MockInputOutput:
MockInputOutput.return_value.get_input.return_value = None
await main(
["--light-mode", "--no-git", "--exit"], input=DummyInput(), output=DummyOutput()
)
# Ensure InputOutput was called
MockInputOutput.assert_called_once()
# Check if the code_theme setting is for light mode
_, kwargs = MockInputOutput.call_args
self.assertEqual(kwargs["code_theme"], "default")
async def create_env_file(self, file_name, content):
env_file_path = Path(self.tempdir) / file_name
env_file_path.write_text(content)
return env_file_path
async def test_env_file_flag_sets_automatic_variable(self):
env_file_path = self.create_env_file(".env.test", "AIDER_DARK_MODE=True")
with patch("aider.main.InputOutput") as MockInputOutput:
MockInputOutput.return_value.get_input.return_value = None
MockInputOutput.return_value.get_input.confirm_ask = True
await main(
["--env-file", str(env_file_path), "--no-git", "--exit"],
input=DummyInput(),
output=DummyOutput(),
)
MockInputOutput.assert_called_once()
# Check if the color settings are for dark mode
_, kwargs = MockInputOutput.call_args
self.assertEqual(kwargs["code_theme"], "monokai")
async def test_default_env_file_sets_automatic_variable(self):
self.create_env_file(".env", "AIDER_DARK_MODE=True")
with patch("aider.main.InputOutput") as MockInputOutput:
MockInputOutput.return_value.get_input.return_value = None
MockInputOutput.return_value.get_input.confirm_ask = True
await main(["--no-git", "--exit"], input=DummyInput(), output=DummyOutput())
# Ensure InputOutput was called
MockInputOutput.assert_called_once()
# Check if the color settings are for dark mode
_, kwargs = MockInputOutput.call_args
self.assertEqual(kwargs["code_theme"], "monokai")
async def test_false_vals_in_env_file(self):
self.create_env_file(".env", "AIDER_SHOW_DIFFS=off")
with patch("aider.coders.Coder.create") as MockCoder:
await main(["--no-git", "--yes"], input=DummyInput(), output=DummyOutput())
MockCoder.assert_called_once()
_, kwargs = MockCoder.call_args
self.assertEqual(kwargs["show_diffs"], False)
async def test_true_vals_in_env_file(self):
self.create_env_file(".env", "AIDER_SHOW_DIFFS=on")
with patch("aider.coders.Coder.create") as MockCoder:
await main(["--no-git", "--yes"], input=DummyInput(), output=DummyOutput())
MockCoder.assert_called_once()
_, kwargs = MockCoder.call_args
self.assertEqual(kwargs["show_diffs"], True)
async def test_lint_option(self):
with GitTemporaryDirectory() as git_dir:
# Create a dirty file in the root
dirty_file = Path("dirty_file.py")
dirty_file.write_text("def foo():\n return 'bar'")
repo = git.Repo(".")
repo.git.add(str(dirty_file))
repo.git.commit("-m", "new")
dirty_file.write_text("def foo():\n return '!!!!!'")
# Create a subdirectory
subdir = Path(git_dir) / "subdir"
subdir.mkdir()
# Change to the subdirectory
os.chdir(subdir)
# Mock the Linter class
with patch("aider.linter.Linter.lint") as MockLinter:
MockLinter.return_value = ""
# Run main with --lint option
await main(["--lint", "--yes"])
# Check if the Linter was called with a filename ending in "dirty_file.py"
# but not ending in "subdir/dirty_file.py"
MockLinter.assert_called_once()
called_arg = MockLinter.call_args[0][0]
self.assertTrue(called_arg.endswith("dirty_file.py"))
self.assertFalse(called_arg.endswith(f"subdir{os.path.sep}dirty_file.py"))
async def test_verbose_mode_lists_env_vars(self):
self.create_env_file(".env", "AIDER_DARK_MODE=on")
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
await main(
["--no-git", "--verbose", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
)
output = mock_stdout.getvalue()
relevant_output = "\n".join(
line
for line in output.splitlines()
if "AIDER_DARK_MODE" in line or "dark_mode" in line
) # this bit just helps failing assertions to be easier to read
self.assertIn("AIDER_DARK_MODE", relevant_output)
self.assertIn("dark_mode", relevant_output)
self.assertRegex(relevant_output, r"AIDER_DARK_MODE:\s+on")
self.assertRegex(relevant_output, r"dark_mode:\s+True")
async def test_yaml_config_file_loading(self):
with GitTemporaryDirectory() as git_dir:
git_dir = Path(git_dir)
# Create fake home directory
fake_home = git_dir / "fake_home"
fake_home.mkdir()
os.environ["HOME"] = str(fake_home)
# Create subdirectory as current working directory
cwd = git_dir / "subdir"
cwd.mkdir()
os.chdir(cwd)
# Create .aider.conf.yml files in different locations
home_config = fake_home / ".aider.conf.yml"
git_config = git_dir / ".aider.conf.yml"
cwd_config = cwd / ".aider.conf.yml"
named_config = git_dir / "named.aider.conf.yml"
cwd_config.write_text("model: gpt-4-32k\nmap-tokens: 4096\n")
git_config.write_text("model: gpt-4\nmap-tokens: 2048\n")
home_config.write_text("model: gpt-3.5-turbo\nmap-tokens: 1024\n")
named_config.write_text("model: gpt-4-1106-preview\nmap-tokens: 8192\n")
with (
patch("pathlib.Path.home", return_value=fake_home),
patch("aider.coders.Coder.create") as MockCoder,
):
# Test loading from specified config file
await main(
["--yes", "--exit", "--config", str(named_config)],
input=DummyInput(),
output=DummyOutput(),
)
_, kwargs = MockCoder.call_args
self.assertEqual(kwargs["main_model"].name, "gpt-4-1106-preview")
self.assertEqual(kwargs["map_tokens"], 8192)
# Test loading from current working directory
await main(["--yes", "--exit"], input=DummyInput(), output=DummyOutput())
_, kwargs = MockCoder.call_args
print("kwargs:", kwargs) # Add this line for debugging
self.assertIn("main_model", kwargs, "main_model key not found in kwargs")
self.assertEqual(kwargs["main_model"].name, "gpt-4-32k")
self.assertEqual(kwargs["map_tokens"], 4096)
# Test loading from git root
cwd_config.unlink()
await main(["--yes", "--exit"], input=DummyInput(), output=DummyOutput())
_, kwargs = MockCoder.call_args
self.assertEqual(kwargs["main_model"].name, "gpt-4")
self.assertEqual(kwargs["map_tokens"], 2048)
# Test loading from home directory
git_config.unlink()
await main(["--yes", "--exit"], input=DummyInput(), output=DummyOutput())
_, kwargs = MockCoder.call_args
self.assertEqual(kwargs["main_model"].name, "gpt-3.5-turbo")
self.assertEqual(kwargs["map_tokens"], 1024)
async def test_map_tokens_option(self):
with GitTemporaryDirectory():
with patch("aider.coders.base_coder.RepoMap") as MockRepoMap:
MockRepoMap.return_value.max_map_tokens = 0
await main(
["--model", "gpt-4", "--map-tokens", "0", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
)
MockRepoMap.assert_not_called()
async def test_map_tokens_option_with_non_zero_value(self):
with GitTemporaryDirectory():
with patch("aider.coders.base_coder.RepoMap") as MockRepoMap:
MockRepoMap.return_value.max_map_tokens = 1000
await main(
["--model", "gpt-4", "--map-tokens", "1000", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
)
MockRepoMap.assert_called_once()
async def test_read_option(self):
with GitTemporaryDirectory():
test_file = "test_file.txt"
Path(test_file).touch()
coder = await main(
["--read", test_file, "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertIn(str(Path(test_file).resolve()), coder.abs_read_only_fnames)
async def test_read_option_with_external_file(self):
with tempfile.NamedTemporaryFile(mode="w", delete=False) as external_file:
external_file.write("External file content")
external_file_path = external_file.name
try:
with GitTemporaryDirectory():
coder = await main(
["--read", external_file_path, "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
real_external_file_path = os.path.realpath(external_file_path)
self.assertIn(real_external_file_path, coder.abs_read_only_fnames)
finally:
os.unlink(external_file_path)
async def test_model_metadata_file(self):
# Re-init so we don't have old data lying around from earlier test cases
from aider import models
models.model_info_manager = models.ModelInfoManager()
from aider.llm import litellm
litellm._lazy_module = None
with GitTemporaryDirectory():
metadata_file = Path(".aider.model.metadata.json")
# must be a fully qualified model name: provider/...
metadata_content = {"deepseek/deepseek-chat": {"max_input_tokens": 1234}}
metadata_file.write_text(json.dumps(metadata_content))
coder = await main(
[
"--model",
"deepseek/deepseek-chat",
"--model-metadata-file",
str(metadata_file),
"--exit",
"--yes",
],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertEqual(coder.main_model.info["max_input_tokens"], 1234)
async def test_sonnet_and_cache_options(self):
with GitTemporaryDirectory():
with patch("aider.coders.base_coder.RepoMap") as MockRepoMap:
mock_repo_map = MagicMock()
mock_repo_map.max_map_tokens = 1000 # Set a specific value
MockRepoMap.return_value = mock_repo_map
await main(
["--sonnet", "--cache-prompts", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
)
MockRepoMap.assert_called_once()
call_args, call_kwargs = MockRepoMap.call_args
self.assertEqual(
call_kwargs.get("refresh"), "files"
) # Check the 'refresh' keyword argument
async def test_sonnet_and_cache_prompts_options(self):
with GitTemporaryDirectory():
coder = await main(
["--sonnet", "--cache-prompts", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertTrue(coder.add_cache_headers)
async def test_4o_and_cache_options(self):
with GitTemporaryDirectory():
coder = await main(
["--4o", "--cache-prompts", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertFalse(coder.add_cache_headers)
async def test_return_coder(self):
with GitTemporaryDirectory():
result = await main(
["--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertIsInstance(result, Coder)
result = await main(
["--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=False,
)
self.assertIsNone(result)
async def test_map_mul_option(self):
with GitTemporaryDirectory():
coder = await main(
["--map-mul", "5", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertIsInstance(coder, Coder)
self.assertEqual(coder.repo_map.map_mul_no_files, 5)
async def test_suggest_shell_commands_default(self):
with GitTemporaryDirectory():
coder = await main(
["--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertTrue(coder.suggest_shell_commands)
async def test_suggest_shell_commands_disabled(self):
with GitTemporaryDirectory():
coder = await main(
["--no-suggest-shell-commands", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertFalse(coder.suggest_shell_commands)
async def test_suggest_shell_commands_enabled(self):
with GitTemporaryDirectory():
coder = await main(
["--suggest-shell-commands", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertTrue(coder.suggest_shell_commands)
async def test_detect_urls_default(self):
with GitTemporaryDirectory():
coder = await main(
["--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertTrue(coder.detect_urls)
async def test_detect_urls_disabled(self):
with GitTemporaryDirectory():
coder = await main(
["--no-detect-urls", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertFalse(coder.detect_urls)
async def test_detect_urls_enabled(self):
with GitTemporaryDirectory():
coder = await main(
["--detect-urls", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertTrue(coder.detect_urls)
async def test_accepts_settings_warnings(self):
# Test that appropriate warnings are shown based on accepts_settings configuration
with GitTemporaryDirectory():
# Test model that accepts the thinking_tokens setting
with (
patch("aider.io.InputOutput.tool_warning") as mock_warning,
patch("aider.models.Model.set_thinking_tokens") as mock_set_thinking,
):
await main(
[
"--model",
"anthropic/claude-3-7-sonnet-20250219",
"--thinking-tokens",
"1000",
"--yes",
"--exit",
],
input=DummyInput(),
output=DummyOutput(),
)
# No warning should be shown as this model accepts thinking_tokens
for call in mock_warning.call_args_list:
self.assertNotIn("thinking_tokens", call[0][0])
# Method should be called
mock_set_thinking.assert_called_once_with("1000")
# Test model that doesn't have accepts_settings for thinking_tokens
with (
patch("aider.io.InputOutput.tool_warning") as mock_warning,
patch("aider.models.Model.set_thinking_tokens") as mock_set_thinking,
):
await main(
[
"--model",
"gpt-4o",
"--thinking-tokens",
"1000",
"--check-model-accepts-settings",
"--yes",
"--exit",
],
input=DummyInput(),
output=DummyOutput(),
)
# Warning should be shown
warning_shown = False
for call in mock_warning.call_args_list:
if "thinking_tokens" in call[0][0]:
warning_shown = True
self.assertTrue(warning_shown)
# Method should NOT be called because model doesn't support it and check flag is on
mock_set_thinking.assert_not_called()
# Test model that accepts the reasoning_effort setting
with (
patch("aider.io.InputOutput.tool_warning") as mock_warning,
patch("aider.models.Model.set_reasoning_effort") as mock_set_reasoning,
):
await main(
["--model", "o1", "--reasoning-effort", "3", "--yes", "--exit"],
input=DummyInput(),
output=DummyOutput(),
)
# No warning should be shown as this model accepts reasoning_effort
for call in mock_warning.call_args_list:
self.assertNotIn("reasoning_effort", call[0][0])
# Method should be called
mock_set_reasoning.assert_called_once_with("3")
# Test model that doesn't have accepts_settings for reasoning_effort
with (
patch("aider.io.InputOutput.tool_warning") as mock_warning,
patch("aider.models.Model.set_reasoning_effort") as mock_set_reasoning,
):
await main(
["--model", "gpt-3.5-turbo", "--reasoning-effort", "3", "--yes", "--exit"],
input=DummyInput(),
output=DummyOutput(),
)
# Warning should be shown
warning_shown = False
for call in mock_warning.call_args_list:
if "reasoning_effort" in call[0][0]:
warning_shown = True
self.assertTrue(warning_shown)
# Method should still be called by default
mock_set_reasoning.assert_not_called()
@patch("aider.models.ModelInfoManager.set_verify_ssl")
async def test_no_verify_ssl_sets_model_info_manager(self, mock_set_verify_ssl):
with GitTemporaryDirectory():
# Mock Model class to avoid actual model initialization
with patch("aider.models.Model") as mock_model:
# Configure the mock to avoid the TypeError
mock_model.return_value.info = {}
mock_model.return_value.name = "gpt-4" # Add a string name
mock_model.return_value.validate_environment.return_value = {
"missing_keys": [],
"keys_in_environment": [],
}
# Mock fuzzy_match_models to avoid string operations on MagicMock
with patch("aider.models.fuzzy_match_models", return_value=[]):
await main(
["--no-verify-ssl", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
)
mock_set_verify_ssl.assert_called_once_with(False)
async def test_pytest_env_vars(self):
# Verify that environment variables from pytest.ini are properly set
self.assertEqual(os.environ.get("AIDER_ANALYTICS"), "false")
async def test_set_env_single(self):
# Test setting a single environment variable
with GitTemporaryDirectory():
await main(["--set-env", "TEST_VAR=test_value", "--exit", "--yes"])
self.assertEqual(os.environ.get("TEST_VAR"), "test_value")
async def test_set_env_multiple(self):
# Test setting multiple environment variables
with GitTemporaryDirectory():
await main(
[
"--set-env",
"TEST_VAR1=value1",
"--set-env",
"TEST_VAR2=value2",
"--exit",
"--yes",
]
)
self.assertEqual(os.environ.get("TEST_VAR1"), "value1")
self.assertEqual(os.environ.get("TEST_VAR2"), "value2")
async def test_set_env_with_spaces(self):
# Test setting env var with spaces in value
with GitTemporaryDirectory():
await main(["--set-env", "TEST_VAR=test value with spaces", "--exit", "--yes"])
self.assertEqual(os.environ.get("TEST_VAR"), "test value with spaces")
async def test_set_env_invalid_format(self):
# Test invalid format handling
with GitTemporaryDirectory():
result = await main(["--set-env", "INVALID_FORMAT", "--exit", "--yes"])
self.assertEqual(result, 1)
async def test_api_key_single(self):
# Test setting a single API key
with GitTemporaryDirectory():
await main(["--api-key", "anthropic=test-key", "--exit", "--yes"])
self.assertEqual(os.environ.get("ANTHROPIC_API_KEY"), "test-key")
async def test_api_key_multiple(self):
# Test setting multiple API keys
with GitTemporaryDirectory():
await main(
["--api-key", "anthropic=key1", "--api-key", "openai=key2", "--exit", "--yes"]
)
self.assertEqual(os.environ.get("ANTHROPIC_API_KEY"), "key1")
self.assertEqual(os.environ.get("OPENAI_API_KEY"), "key2")
async def test_api_key_invalid_format(self):
# Test invalid format handling
with GitTemporaryDirectory():
result = await main(["--api-key", "INVALID_FORMAT", "--exit", "--yes"])
self.assertEqual(result, 1)
async def test_git_config_include(self):
# Test that aider respects git config includes for user.name and user.email
with GitTemporaryDirectory() as git_dir:
git_dir = Path(git_dir)
# Create an includable config file with user settings
include_config = git_dir / "included.gitconfig"
include_config.write_text(
"[user]\n name = Included User\n email = included@example.com\n"
)
# Set up main git config to include the other file
repo = git.Repo(git_dir)
include_path = str(include_config).replace("\\", "/")
repo.git.config("--local", "include.path", str(include_path))
# Verify the config is set up correctly using git command
self.assertEqual(repo.git.config("user.name"), "Included User")
self.assertEqual(repo.git.config("user.email"), "included@example.com")
# Manually check the git config file to confirm include directive
git_config_path = git_dir / ".git" / "config"
git_config_content = git_config_path.read_text()
# Run aider and verify it doesn't change the git config
await main(["--yes", "--exit"], input=DummyInput(), output=DummyOutput())
# Check that the user settings are still the same using git command
repo = git.Repo(git_dir) # Re-open repo to ensure we get fresh config
self.assertEqual(repo.git.config("user.name"), "Included User")
self.assertEqual(repo.git.config("user.email"), "included@example.com")
# Manually check the git config file again to ensure it wasn't modified
git_config_content_after = git_config_path.read_text()
self.assertEqual(git_config_content, git_config_content_after)
async def test_git_config_include_directive(self):
# Test that aider respects the include directive in git config
with GitTemporaryDirectory() as git_dir:
git_dir = Path(git_dir)
# Create an includable config file with user settings
include_config = git_dir / "included.gitconfig"
include_config.write_text(
"[user]\n name = Directive User\n email = directive@example.com\n"
)
# Set up main git config with include directive
git_config = git_dir / ".git" / "config"
# Use normalized path with forward slashes for git config
include_path = str(include_config).replace("\\", "/")
with open(git_config, "a") as f:
f.write(f"\n[include]\n path = {include_path}\n")
# Read the modified config file
modified_config_content = git_config.read_text()
# Verify the include directive was added correctly
self.assertIn("[include]", modified_config_content)
# Verify the config is set up correctly using git command
repo = git.Repo(git_dir)
self.assertEqual(repo.git.config("user.name"), "Directive User")
self.assertEqual(repo.git.config("user.email"), "directive@example.com")
# Run aider and verify it doesn't change the git config
await main(["--yes", "--exit"], input=DummyInput(), output=DummyOutput())
# Check that the git config file wasn't modified
config_after_aider = git_config.read_text()
self.assertEqual(modified_config_content, config_after_aider)
# Check that the user settings are still the same using git command
repo = git.Repo(git_dir) # Re-open repo to ensure we get fresh config
self.assertEqual(repo.git.config("user.name"), "Directive User")
self.assertEqual(repo.git.config("user.email"), "directive@example.com")
async def test_resolve_aiderignore_path(self):
# Import the function directly to test it
from aider.args import resolve_aiderignore_path
# Test with absolute path
abs_path = os.path.abspath("/tmp/test/.aiderignore")
self.assertEqual(resolve_aiderignore_path(abs_path), abs_path)
# Test with relative path and git root
git_root = "/path/to/git/root"
rel_path = ".aiderignore"
self.assertEqual(
resolve_aiderignore_path(rel_path, git_root), str(Path(git_root) / rel_path)
)
# Test with relative path and no git root
rel_path = ".aiderignore"
self.assertEqual(resolve_aiderignore_path(rel_path), rel_path)
async def test_invalid_edit_format(self):
with GitTemporaryDirectory():
# Suppress stderr for this test as argparse prints an error message
with patch("sys.stderr", new_callable=StringIO) as mock_stderr:
with self.assertRaises(SystemExit) as cm:
_ = await main(
["--edit-format", "not-a-real-format", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
)
# argparse.ArgumentParser.exit() is called with status 2 for invalid choice
self.assertEqual(cm.exception.code, 2)
stderr_output = mock_stderr.getvalue()
self.assertIn("invalid choice", stderr_output)
self.assertIn("not-a-real-format", stderr_output)
async def test_default_model_selection(self):
with GitTemporaryDirectory():
# Test Anthropic API key
os.environ["ANTHROPIC_API_KEY"] = "test-key"
coder = await main(
["--exit", "--yes"], input=DummyInput(), output=DummyOutput(), return_coder=True
)
self.assertIn("sonnet", coder.main_model.name.lower())
del os.environ["ANTHROPIC_API_KEY"]
# Test DeepSeek API key
os.environ["DEEPSEEK_API_KEY"] = "test-key"
coder = await main(
["--exit", "--yes"], input=DummyInput(), output=DummyOutput(), return_coder=True
)
self.assertIn("deepseek", coder.main_model.name.lower())
del os.environ["DEEPSEEK_API_KEY"]
# Test OpenRouter API key
os.environ["OPENROUTER_API_KEY"] = "test-key"
coder = await main(
["--exit", "--yes"], input=DummyInput(), output=DummyOutput(), return_coder=True
)
self.assertIn("openrouter/", coder.main_model.name.lower())
del os.environ["OPENROUTER_API_KEY"]
# Test OpenAI API key
os.environ["OPENAI_API_KEY"] = "test-key"
coder = await main(
["--exit", "--yes"], input=DummyInput(), output=DummyOutput(), return_coder=True
)
self.assertIn("gpt-4", coder.main_model.name.lower())
del os.environ["OPENAI_API_KEY"]
# Test Gemini API key
os.environ["GEMINI_API_KEY"] = "test-key"
coder = await main(
["--exit", "--yes"], input=DummyInput(), output=DummyOutput(), return_coder=True
)
self.assertIn("gemini", coder.main_model.name.lower())
del os.environ["GEMINI_API_KEY"]
# Test no API keys - should offer OpenRouter OAuth
with patch("aider.onboarding.offer_openrouter_oauth") as mock_offer_oauth:
mock_offer_oauth.return_value = None # Simulate user declining or failure
result = await main(["--exit", "--yes"], input=DummyInput(), output=DummyOutput())
self.assertEqual(result, 1) # Expect failure since no model could be selected
mock_offer_oauth.assert_called_once()
async def test_model_precedence(self):
with GitTemporaryDirectory():
# Test that earlier API keys take precedence
os.environ["ANTHROPIC_API_KEY"] = "test-key"
os.environ["OPENAI_API_KEY"] = "test-key"
coder = await main(
["--exit", "--yes"], input=DummyInput(), output=DummyOutput(), return_coder=True
)
self.assertIn("sonnet", coder.main_model.name.lower())
del os.environ["ANTHROPIC_API_KEY"]
del os.environ["OPENAI_API_KEY"]
async def test_chat_language_spanish(self):
with GitTemporaryDirectory():
coder = await main(
["--chat-language", "Spanish", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
system_info = coder.get_platform_info()
self.assertIn("Spanish", system_info)
async def test_commit_language_japanese(self):
with GitTemporaryDirectory():
coder = await main(
["--commit-language", "japanese", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertIn("japanese", coder.commit_language)
@patch("git.Repo.init")
async def test_main_exit_with_git_command_not_found(self, mock_git_init):
mock_git_init.side_effect = git.exc.GitCommandNotFound("git", "Command 'git' not found")
try:
result = await main(["--exit", "--yes"], input=DummyInput(), output=DummyOutput())
except Exception as e:
self.fail(f"await main() raised an unexpected exception: {e}")
self.assertIsNone(result, "await main() should return None when called with --exit")
async def test_reasoning_effort_option(self):
coder = await main(
["--reasoning-effort", "3", "--no-check-model-accepts-settings", "--yes", "--exit"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertEqual(
coder.main_model.extra_params.get("extra_body", {}).get("reasoning_effort"), "3"
)
async def test_thinking_tokens_option(self):
coder = await main(
["--model", "sonnet", "--thinking-tokens", "1000", "--yes", "--exit"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertEqual(
coder.main_model.extra_params.get("thinking", {}).get("budget_tokens"), 1000
)
async def test_list_models_includes_metadata_models(self):
# Test that models from model-metadata.json appear in list-models output
with GitTemporaryDirectory():
# Create a temporary model-metadata.json with test models
metadata_file = Path(".aider.model.metadata.json")
test_models = {
"unique-model-name": {
"max_input_tokens": 8192,
"litellm_provider": "test-provider",
"mode": "chat", # Added mode attribute
},
"another-provider/another-unique-model": {
"max_input_tokens": 4096,
"litellm_provider": "another-provider",
"mode": "chat", # Added mode attribute
},
}
metadata_file.write_text(json.dumps(test_models))
# Capture stdout to check the output
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
await main(
[
"--list-models",
"unique-model",
"--model-metadata-file",
str(metadata_file),
"--yes",
"--no-gitignore",
],
input=DummyInput(),
output=DummyOutput(),
)
output = mock_stdout.getvalue()
# Check that the unique model name from our metadata file is listed
self.assertIn("test-provider/unique-model-name", output)
async def test_list_models_includes_all_model_sources(self):
# Test that models from both litellm.model_cost and model-metadata.json
# appear in list-models
with GitTemporaryDirectory():
# Create a temporary model-metadata.json with test models
metadata_file = Path(".aider.model.metadata.json")
test_models = {
"metadata-only-model": {
"max_input_tokens": 8192,
"litellm_provider": "test-provider",
"mode": "chat", # Added mode attribute
}
}
metadata_file.write_text(json.dumps(test_models))
# Capture stdout to check the output
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
await main(
[
"--list-models",
"metadata-only-model",
"--model-metadata-file",
str(metadata_file),
"--yes",
"--no-gitignore",
],
input=DummyInput(),
output=DummyOutput(),
)
output = mock_stdout.getvalue()
dump(output)
# Check that both models appear in the output
self.assertIn("test-provider/metadata-only-model", output)
async def test_check_model_accepts_settings_flag(self):
# Test that --check-model-accepts-settings affects whether settings are applied
with GitTemporaryDirectory():
# When flag is on, setting shouldn't be applied to non-supporting model
with patch("aider.models.Model.set_thinking_tokens") as mock_set_thinking:
await main(
[
"--model",
"gpt-4o",
"--thinking-tokens",
"1000",
"--check-model-accepts-settings",
"--yes",
"--exit",
],
input=DummyInput(),
output=DummyOutput(),
)
# Method should not be called because model doesn't support it and flag is on
mock_set_thinking.assert_not_called()
async def test_list_models_with_direct_resource_patch(self):
# Test that models from resources/model-metadata.json are included in list-models output
with GitTemporaryDirectory():
# Create a temporary file with test model metadata
test_file = Path(self.tempdir) / "test-model-metadata.json"
test_resource_models = {
"special-model": {
"max_input_tokens": 8192,
"litellm_provider": "resource-provider",
"mode": "chat",
}
}
test_file.write_text(json.dumps(test_resource_models))
# Create a mock for the resource file path
mock_resource_path = MagicMock()
mock_resource_path.__str__.return_value = str(test_file)
# Create a mock for the files function that returns an object with joinpath
mock_files = MagicMock()
mock_files.joinpath.return_value = mock_resource_path
with patch("aider.main.importlib_resources.files", return_value=mock_files):
# Capture stdout to check the output
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
await main(
["--list-models", "special", "--yes", "--no-gitignore"],
input=DummyInput(),
output=DummyOutput(),
)
output = mock_stdout.getvalue()
# Check that the resource model appears in the output
self.assertIn("resource-provider/special-model", output)
# When flag is off, setting should be applied regardless of support
with patch("aider.models.Model.set_reasoning_effort") as mock_set_reasoning:
await main(
[
"--model",
"gpt-3.5-turbo",
"--reasoning-effort",
"3",
"--no-check-model-accepts-settings",
"--yes",
"--exit",
],
input=DummyInput(),
output=DummyOutput(),
)
# Method should be called because flag is off
mock_set_reasoning.assert_called_once_with("3")
async def test_model_accepts_settings_attribute(self):
with GitTemporaryDirectory():
# Test with a model where we override the accepts_settings attribute
with patch("aider.models.Model") as MockModel:
# Setup mock model instance to simulate accepts_settings attribute
mock_instance = MockModel.return_value
mock_instance.name = "test-model"
mock_instance.accepts_settings = ["reasoning_effort"]
mock_instance.validate_environment.return_value = {
"missing_keys": [],
"keys_in_environment": [],
}
mock_instance.info = {}
mock_instance.weak_model_name = None
mock_instance.get_weak_model.return_value = None
# Run with both settings, but model only accepts reasoning_effort
await main(
[
"--model",
"test-model",
"--reasoning-effort",
"3",
"--thinking-tokens",
"1000",
"--check-model-accepts-settings",
"--yes",
"--exit",
],
input=DummyInput(),
output=DummyOutput(),
)
# Only set_reasoning_effort should be called, not set_thinking_tokens
mock_instance.set_reasoning_effort.assert_called_once_with("3")
mock_instance.set_thinking_tokens.assert_not_called()
@patch("aider.main.InputOutput")
async def test_stream_and_cache_warning(self, MockInputOutput):
mock_io_instance = MockInputOutput.return_value
with GitTemporaryDirectory():
await main(
["--stream", "--cache-prompts", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
)
mock_io_instance.tool_warning.assert_called_with(
"Cost estimates may be inaccurate when using streaming and caching."
)
@patch("aider.main.InputOutput")
async def test_stream_without_cache_no_warning(self, MockInputOutput):
mock_io_instance = MockInputOutput.return_value
with GitTemporaryDirectory():
await main(
["--stream", "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
)
for call in mock_io_instance.tool_warning.call_args_list:
self.assertNotIn("Cost estimates may be inaccurate", call[0][0])
async def test_argv_file_respects_git(self):
with GitTemporaryDirectory():
fname = Path("not_in_git.txt")
fname.touch()
with open(".gitignore", "w+") as f:
f.write("not_in_git.txt")
coder = await main(
argv=["--file", "not_in_git.txt"],
input=DummyInput(),
output=DummyOutput(),
return_coder=True,
)
self.assertNotIn("not_in_git.txt", str(coder.abs_fnames))
self.assertFalse(await coder.allowed_to_edit("not_in_git.txt"))
def test_load_dotenv_files_override(self):
with GitTemporaryDirectory() as git_dir:
git_dir = Path(git_dir)
# Create fake home and .aider directory
fake_home = git_dir / "fake_home"
fake_home.mkdir()
aider_dir = fake_home / ".aider"
aider_dir.mkdir()
# Create oauth keys file
oauth_keys_file = aider_dir / "oauth-keys.env"
oauth_keys_file.write_text("OAUTH_VAR=oauth_val\nSHARED_VAR=oauth_shared\n")
# Create git root .env file
git_root_env = git_dir / ".env"
git_root_env.write_text("GIT_VAR=git_val\nSHARED_VAR=git_shared\n")
# Create CWD .env file in a subdir
cwd_subdir = git_dir / "subdir"
cwd_subdir.mkdir()
cwd_env = cwd_subdir / ".env"
cwd_env.write_text("CWD_VAR=cwd_val\nSHARED_VAR=cwd_shared\n")
# Change to subdir
original_cwd = os.getcwd()
os.chdir(cwd_subdir)
# Clear relevant env vars before test
for var in ["OAUTH_VAR", "SHARED_VAR", "GIT_VAR", "CWD_VAR"]:
if var in os.environ:
del os.environ[var]
with patch("pathlib.Path.home", return_value=fake_home):
loaded_files = load_dotenv_files(str(git_dir), None)
# Assert files were loaded in expected order (oauth first)
self.assertIn(str(oauth_keys_file.resolve()), loaded_files)
self.assertIn(str(git_root_env.resolve()), loaded_files)
self.assertIn(str(cwd_env.resolve()), loaded_files)
self.assertLess(
loaded_files.index(str(oauth_keys_file.resolve())),
loaded_files.index(str(git_root_env.resolve())),
)
self.assertLess(
loaded_files.index(str(git_root_env.resolve())),
loaded_files.index(str(cwd_env.resolve())),
)
# Assert environment variables reflect the override order
self.assertEqual(os.environ.get("OAUTH_VAR"), "oauth_val")
self.assertEqual(os.environ.get("GIT_VAR"), "git_val")
self.assertEqual(os.environ.get("CWD_VAR"), "cwd_val")
# SHARED_VAR should be overridden by the last loaded file (cwd .env)
self.assertEqual(os.environ.get("SHARED_VAR"), "cwd_shared")
# Restore CWD
os.chdir(original_cwd)
@patch("aider.main.InputOutput")
async def test_cache_without_stream_no_warning(self, MockInputOutput):
mock_io_instance = MockInputOutput.return_value
with GitTemporaryDirectory():
await main(
["--cache-prompts", "--exit", "--yes", "--no-stream"],
input=DummyInput(),
output=DummyOutput(),
)
for call in mock_io_instance.tool_warning.call_args_list:
self.assertNotIn("Cost estimates may be inaccurate", call[0][0])
@patch("aider.coders.Coder.create")
async def test_mcp_servers_parsing(self, mock_coder_create):
# Setup mock coder
mock_coder_instance = MagicMock()
mock_coder_create.return_value = mock_coder_instance
# Test with --mcp-servers option
with GitTemporaryDirectory():
await main(
[
"--mcp-servers",
'{"mcpServers":{"git":{"command":"uvx","args":["mcp-server-git"]}}}',
"--exit",
"--yes",
],
input=DummyInput(),
output=DummyOutput(),
)
# Verify that Coder.create was called with mcp_servers parameter
mock_coder_create.assert_called_once()
_, kwargs = mock_coder_create.call_args
self.assertIn("mcp_servers", kwargs)
self.assertIsNotNone(kwargs["mcp_servers"])
# At least one server should be in the list
self.assertTrue(len(kwargs["mcp_servers"]) > 0)
# First server should have a name attribute
self.assertTrue(hasattr(kwargs["mcp_servers"][0], "name"))
# Test with --mcp-servers-file option
mock_coder_create.reset_mock()
with GitTemporaryDirectory():
# Create a temporary MCP servers file
mcp_file = Path("mcp_servers.json")
mcp_content = {"mcpServers": {"git": {"command": "uvx", "args": ["mcp-server-git"]}}}
mcp_file.write_text(json.dumps(mcp_content))
await main(
["--mcp-servers-file", str(mcp_file), "--exit", "--yes"],
input=DummyInput(),
output=DummyOutput(),
)
# Verify that Coder.create was called with mcp_servers parameter
mock_coder_create.assert_called_once()
_, kwargs = mock_coder_create.call_args
self.assertIn("mcp_servers", kwargs)
self.assertIsNotNone(kwargs["mcp_servers"])
# At least one server should be in the list
self.assertTrue(len(kwargs["mcp_servers"]) > 0)
# First server should have a name attribute
self.assertTrue(hasattr(kwargs["mcp_servers"][0], "name"))