From f95aa29034eb09bd167ecebd3e48d4c852188b0e Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Mon, 29 Dec 2025 22:30:11 +0100 Subject: [PATCH 01/50] refactor: convert setUp/tearDown to pytest autouse fixture Phase 1 of pytest migration: Replace unittest setUp/tearDown with function-scoped autouse fixture. Changes: - Add pytest import - Create test_env autouse fixture that runs for each test - Fixture provides same environment as setUp/tearDown via request.instance - Remove setUp method from TestMain - Remove tearDown method from TestMain - Keep TestCase inheritance (will remove in Phase 2.2) - Keep all test methods unchanged Verification: - All 83 tests pass (83/83) - Tests still use self.tempdir, self.create_env_file, etc. - Fixture handles setup/teardown automatically Why function-scoped not module-level: - Tests create files, repos, modify environment - Need fresh isolation per test - Module-level would share state and break tests Next: Phase 2.1 - Convert assertions to plain assert (keep TestCase) --- tests/basic/test_main.py | 71 ++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 25 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index bc082a40590..e229b9917ea 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import git +import pytest from prompt_toolkit.input import DummyInput from prompt_toolkit.output import DummyOutput @@ -29,32 +30,52 @@ def mock_autosave_future(): return AsyncMock()() +@pytest.fixture(autouse=True) +def test_env(request): + """Autouse fixture providing test environment (replaces setUp/tearDown).""" + # Setup (formerly setUp) + original_env = os.environ.copy() + os.environ["OPENAI_API_KEY"] = "deadbeef" + os.environ["AIDER_CHECK_UPDATE"] = "false" + os.environ["AIDER_ANALYTICS"] = "false" + original_cwd = os.getcwd() + tempdir_obj = IgnorantTemporaryDirectory() + tempdir = tempdir_obj.name + os.chdir(tempdir) + # Fake home directory prevents tests from using the real ~/.aider.conf.yml file: + homedir_obj = IgnorantTemporaryDirectory() + os.environ["HOME"] = homedir_obj.name + + input_patcher = patch("builtins.input", return_value=None) + mock_input = input_patcher.start() + webbrowser_patcher = patch("aider.io.webbrowser.open") + mock_webbrowser = webbrowser_patcher.start() + + # Make values available to tests via request.instance + if request.instance: + request.instance.tempdir = tempdir + request.instance.tempdir_obj = tempdir_obj + request.instance.homedir_obj = homedir_obj + request.instance.original_env = original_env + request.instance.original_cwd = original_cwd + request.instance.mock_input = mock_input + request.instance.mock_webbrowser = mock_webbrowser + request.instance.input_patcher = input_patcher + request.instance.webbrowser_patcher = webbrowser_patcher + + yield + + # Teardown (formerly tearDown) + os.chdir(original_cwd) + tempdir_obj.cleanup() + homedir_obj.cleanup() + os.environ.clear() + os.environ.update(original_env) + input_patcher.stop() + webbrowser_patcher.stop() + + 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() def test_main_with_empty_dir_no_files_on_command(self): main(["--no-git", "--exit", "--yes-always"], input=DummyInput(), output=DummyOutput()) From f627b3dd9362eeeeab00e05180eccfd013b8d486 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Mon, 29 Dec 2025 22:35:37 +0100 Subject: [PATCH 02/50] refactor: convert assertions batch 1 (11 basic main() tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2.1 Batch 1: Convert unittest assertions to plain assert for basic main() functionality tests while keeping TestCase base class. Tests converted: - test_main_with_empty_dir_no_files_on_command (no assertions) - test_main_with_emptqy_dir_new_file - test_main_with_empty_git_dir_new_file - test_main_with_empty_git_dir_new_files - test_main_with_dname_and_fname - test_main_with_subdir_repo_fnames - test_main_with_empty_git_dir_new_subdir_file (no assertions) - test_setup_git - test_check_gitignore - test_return_coder - test_main_with_git_config_yml (already had plain assert) Assertions converted: - self.assertTrue(x) → assert x - self.assertFalse(x) → assert not x - self.assertEqual(a, b) → assert a == b - self.assertNotEqual(a, b) → assert a != b (or assert a is not None) - self.assertIsInstance(x, T) → assert isinstance(x, T) Verification: - All 11 tests pass (11/11) - TestCase base class still in place - pytest assertion rewriting tested and working Next: Batch 2 - Environment & configuration tests (20 tests) --- tests/basic/test_main.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index e229b9917ea..97c7c5d04ad 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -86,13 +86,13 @@ def test_main_with_emptqy_dir_new_file(self): input=DummyInput(), output=DummyOutput(), ) - self.assertTrue(os.path.exists("foo.txt")) + assert os.path.exists("foo.txt") @patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") def test_main_with_empty_git_dir_new_file(self, _): make_repo() main(["--yes-always", "foo.txt", "--exit"], input=DummyInput(), output=DummyOutput()) - self.assertTrue(os.path.exists("foo.txt")) + assert os.path.exists("foo.txt") @patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") def test_main_with_empty_git_dir_new_files(self, _): @@ -102,15 +102,15 @@ def test_main_with_empty_git_dir_new_files(self, _): input=DummyInput(), output=DummyOutput(), ) - self.assertTrue(os.path.exists("foo.txt")) - self.assertTrue(os.path.exists("bar.txt")) + assert os.path.exists("foo.txt") + assert os.path.exists("bar.txt") def test_main_with_dname_and_fname(self): subdir = Path("subdir") subdir.mkdir() make_repo(str(subdir)) res = main(["subdir", "foo.txt"], input=DummyInput(), output=DummyOutput()) - self.assertNotEqual(res, None) + assert res is not None @patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") def test_main_with_subdir_repo_fnames(self, _): @@ -122,8 +122,8 @@ def test_main_with_subdir_repo_fnames(self, _): input=DummyInput(), output=DummyOutput(), ) - self.assertTrue((subdir / "foo.txt").exists()) - self.assertTrue((subdir / "bar.txt").exists()) + assert (subdir / "foo.txt").exists() + assert (subdir / "bar.txt").exists() def test_main_copy_paste_model_overrides(self): overrides = json.dumps({"gpt-4o": {"fast": {"temperature": 0.42}}}) @@ -201,13 +201,13 @@ def test_setup_git(self): io = InputOutput(pretty=False, yes=True) git_root = asyncio.run(setup_git(None, io)) git_root = Path(git_root).resolve() - self.assertEqual(git_root, Path(self.tempdir).resolve()) + assert git_root == Path(self.tempdir).resolve() - self.assertTrue(git.Repo(self.tempdir)) + assert git.Repo(self.tempdir) gitignore = Path.cwd() / ".gitignore" - self.assertTrue(gitignore.exists()) - self.assertEqual(".aider*", gitignore.read_text().splitlines()[0]) + assert gitignore.exists() + assert ".aider*" == gitignore.read_text().splitlines()[0] def test_check_gitignore(self): with GitTemporaryDirectory(): @@ -217,22 +217,22 @@ def test_check_gitignore(self): cwd = Path.cwd() gitignore = cwd / ".gitignore" - self.assertFalse(gitignore.exists()) + assert not gitignore.exists() asyncio.run(check_gitignore(cwd, io)) - self.assertTrue(gitignore.exists()) + assert gitignore.exists() - self.assertEqual(".aider*", gitignore.read_text().splitlines()[0]) + assert ".aider*" == gitignore.read_text().splitlines()[0] # Test without .env file present gitignore.write_text("one\ntwo\n") asyncio.run(check_gitignore(cwd, io)) - self.assertEqual("one\ntwo\n.aider*\n", gitignore.read_text()) + assert "one\ntwo\n.aider*\n" == gitignore.read_text() # Test with .env file present env_file = cwd / ".env" env_file.touch() asyncio.run(check_gitignore(cwd, io)) - self.assertEqual("one\ntwo\n.aider*\n.env\n", gitignore.read_text()) + assert "one\ntwo\n.aider*\n.env\n" == gitignore.read_text() del os.environ["GIT_CONFIG_GLOBAL"] def test_command_line_gitignore_files_flag(self): @@ -895,7 +895,7 @@ def test_return_coder(self): output=DummyOutput(), return_coder=True, ) - self.assertIsInstance(result, Coder) + assert isinstance(result, Coder) result = main( ["--exit", "--yes-always"], @@ -903,7 +903,7 @@ def test_return_coder(self): output=DummyOutput(), return_coder=False, ) - self.assertEqual(result, 0) + assert result == 0 def test_map_mul_option(self): with GitTemporaryDirectory(): From daddb123e201c8e900607fa2b0232192aaae09b3 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Mon, 29 Dec 2025 22:40:21 +0100 Subject: [PATCH 03/50] refactor: convert assertions batch 2 (18 env & config tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2.1 Batch 2: Convert unittest assertions to plain assert for environment and configuration tests while keeping TestCase base class. Tests converted: - test_env_file_override - test_env_file_flag_sets_automatic_variable - test_default_env_file_sets_automatic_variable - test_false_vals_in_env_file - test_true_vals_in_env_file - test_verbose_mode_lists_env_vars - test_yaml_config_file_loading - test_pytest_env_vars - test_set_env_single - test_set_env_multiple - test_set_env_with_spaces - test_set_env_invalid_format - test_api_key_single - test_api_key_multiple - test_api_key_invalid_format - test_git_config_include - test_git_config_include_directive - test_load_dotenv_files_override Assertions converted: - self.assertEqual(a, b) → assert a == b - self.assertIn(x, y) → assert x in y - self.assertRegex(s, r) → assert re.search(r, s) - self.assertLess(a, b) → assert a < b Verification: - All 18 tests pass (18/18) - TestCase base class still in place Progress: 29/83 tests converted (35%) Next: Batch 3 - Model configuration tests --- tests/basic/test_main.py | 113 ++++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 56 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 97c7c5d04ad..55336dfe1c5 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -414,11 +414,11 @@ def test_env_file_override(self): with patch("pathlib.Path.home", return_value=fake_home): main(["--yes-always", "--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") + assert os.environ["A"] == "named" + assert os.environ["B"] == "cwd" + assert os.environ["C"] == "git" + assert os.environ["D"] == "home" + assert os.environ["E"] == "existing" def test_message_file_flag(self): message_file_content = "This is a test message from a file." @@ -548,7 +548,7 @@ def test_env_file_flag_sets_automatic_variable(self): MockInputOutput.assert_called_once() # Check if the color settings are for dark mode _, kwargs = MockInputOutput.call_args - self.assertEqual(kwargs["code_theme"], "monokai") + assert kwargs["code_theme"] == "monokai" def test_default_env_file_sets_automatic_variable(self): self.create_env_file(".env", "AIDER_DARK_MODE=True") @@ -560,7 +560,7 @@ def test_default_env_file_sets_automatic_variable(self): MockInputOutput.assert_called_once() # Check if the color settings are for dark mode _, kwargs = MockInputOutput.call_args - self.assertEqual(kwargs["code_theme"], "monokai") + assert kwargs["code_theme"] == "monokai" def test_false_vals_in_env_file(self): self.create_env_file(".env", "AIDER_SHOW_DIFFS=off") @@ -570,7 +570,7 @@ def test_false_vals_in_env_file(self): main(["--no-git", "--yes-always"], input=DummyInput(), output=DummyOutput()) MockCoder.assert_called_once() _, kwargs = MockCoder.call_args - self.assertEqual(kwargs["show_diffs"], False) + assert kwargs["show_diffs"] is False def test_true_vals_in_env_file(self): self.create_env_file(".env", "AIDER_SHOW_DIFFS=on") @@ -580,7 +580,7 @@ def test_true_vals_in_env_file(self): main(["--no-git", "--yes-always"], input=DummyInput(), output=DummyOutput()) MockCoder.assert_called_once() _, kwargs = MockCoder.call_args - self.assertEqual(kwargs["show_diffs"], True) + assert kwargs["show_diffs"] is True def test_lint_option(self): with GitTemporaryDirectory() as git_dir: @@ -687,10 +687,11 @@ def test_verbose_mode_lists_env_vars(self): 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") + assert "AIDER_DARK_MODE" in relevant_output + assert "dark_mode" in relevant_output + import re + assert re.search(r"AIDER_DARK_MODE:\s+on", relevant_output) + assert re.search(r"dark_mode:\s+True", relevant_output) def test_yaml_config_file_loading(self): with GitTemporaryDirectory() as git_dir: @@ -730,33 +731,33 @@ def test_yaml_config_file_loading(self): output=DummyOutput(), ) _, kwargs = MockCoder.call_args - self.assertEqual(kwargs["main_model"].name, "gpt-4-1106-preview") - self.assertEqual(kwargs["map_tokens"], 8192) + assert kwargs["main_model"].name == "gpt-4-1106-preview" + assert kwargs["map_tokens"] == 8192 # Test loading from current working directory mock_coder_instance._autosave_future = mock_autosave_future() main(["--yes-always", "--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) + assert "main_model" in kwargs, "main_model key not found in kwargs" + assert kwargs["main_model"].name == "gpt-4-32k" + assert kwargs["map_tokens"] == 4096 # Test loading from git root cwd_config.unlink() mock_coder_instance._autosave_future = mock_autosave_future() main(["--yes-always", "--exit"], input=DummyInput(), output=DummyOutput()) _, kwargs = MockCoder.call_args - self.assertEqual(kwargs["main_model"].name, "gpt-4") - self.assertEqual(kwargs["map_tokens"], 2048) + assert kwargs["main_model"].name == "gpt-4" + assert kwargs["map_tokens"] == 2048 # Test loading from home directory git_config.unlink() mock_coder_instance._autosave_future = mock_autosave_future() main(["--yes-always", "--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) + assert kwargs["main_model"].name == "gpt-3.5-turbo" + assert kwargs["map_tokens"] == 1024 def test_map_tokens_option(self): with GitTemporaryDirectory(): @@ -1095,13 +1096,13 @@ def test_no_verify_ssl_sets_model_info_manager(self, mock_set_verify_ssl): def test_pytest_env_vars(self): # Verify that environment variables from pytest.ini are properly set - self.assertEqual(os.environ.get("AIDER_ANALYTICS"), "false") + assert os.environ.get("AIDER_ANALYTICS") == "false" def test_set_env_single(self): # Test setting a single environment variable with GitTemporaryDirectory(): main(["--set-env", "TEST_VAR=test_value", "--exit", "--yes-always"]) - self.assertEqual(os.environ.get("TEST_VAR"), "test_value") + assert os.environ.get("TEST_VAR") == "test_value" def test_set_env_multiple(self): # Test setting multiple environment variables @@ -1116,26 +1117,26 @@ def test_set_env_multiple(self): "--yes-always", ] ) - self.assertEqual(os.environ.get("TEST_VAR1"), "value1") - self.assertEqual(os.environ.get("TEST_VAR2"), "value2") + assert os.environ.get("TEST_VAR1") == "value1" + assert os.environ.get("TEST_VAR2") == "value2" def test_set_env_with_spaces(self): # Test setting env var with spaces in value with GitTemporaryDirectory(): main(["--set-env", "TEST_VAR=test value with spaces", "--exit", "--yes-always"]) - self.assertEqual(os.environ.get("TEST_VAR"), "test value with spaces") + assert os.environ.get("TEST_VAR") == "test value with spaces" def test_set_env_invalid_format(self): # Test invalid format handling with GitTemporaryDirectory(): result = main(["--set-env", "INVALID_FORMAT", "--exit", "--yes-always"]) - self.assertEqual(result, 1) + assert result == 1 def test_api_key_single(self): # Test setting a single API key with GitTemporaryDirectory(): main(["--api-key", "anthropic=test-key", "--exit", "--yes-always"]) - self.assertEqual(os.environ.get("ANTHROPIC_API_KEY"), "test-key") + assert os.environ.get("ANTHROPIC_API_KEY") == "test-key" def test_api_key_multiple(self): # Test setting multiple API keys @@ -1150,14 +1151,14 @@ def test_api_key_multiple(self): "--yes-always", ] ) - self.assertEqual(os.environ.get("ANTHROPIC_API_KEY"), "key1") - self.assertEqual(os.environ.get("OPENAI_API_KEY"), "key2") + assert os.environ.get("ANTHROPIC_API_KEY") == "key1" + assert os.environ.get("OPENAI_API_KEY") == "key2" def test_api_key_invalid_format(self): # Test invalid format handling with GitTemporaryDirectory(): result = main(["--api-key", "INVALID_FORMAT", "--exit", "--yes-always"]) - self.assertEqual(result, 1) + assert result == 1 def test_git_config_include(self): # Test that aider respects git config includes for user.name and user.email @@ -1176,8 +1177,8 @@ def test_git_config_include(self): 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") + assert repo.git.config("user.name") == "Included User" + assert 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" @@ -1188,12 +1189,12 @@ def test_git_config_include(self): # 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") + assert repo.git.config("user.name") == "Included User" + assert 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) + assert git_config_content == git_config_content_after def test_git_config_include_directive(self): # Test that aider respects the include directive in git config @@ -1217,24 +1218,24 @@ def test_git_config_include_directive(self): modified_config_content = git_config.read_text() # Verify the include directive was added correctly - self.assertIn("[include]", modified_config_content) + assert "[include]" in 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") + assert repo.git.config("user.name") == "Directive User" + assert repo.git.config("user.email") == "directive@example.com" # Run aider and verify it doesn't change the git config main(["--yes-always", "--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) + assert 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") + assert repo.git.config("user.name") == "Directive User" + assert repo.git.config("user.email") == "directive@example.com" def test_resolve_aiderignore_path(self): # Import the function directly to test it @@ -1773,24 +1774,24 @@ def test_load_dotenv_files_override(self): 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())), + assert str(oauth_keys_file.resolve()) in loaded_files + assert str(git_root_env.resolve()) in loaded_files + assert str(cwd_env.resolve()) in loaded_files + assert ( + 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 ( + 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") + assert os.environ.get("OAUTH_VAR") == "oauth_val" + assert os.environ.get("GIT_VAR") == "git_val" + assert 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") + assert os.environ.get("SHARED_VAR") == "cwd_shared" # Restore CWD os.chdir(original_cwd) From 5614fa6711164e5da0d6e72eacd81fd51265de97 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Mon, 29 Dec 2025 22:50:13 +0100 Subject: [PATCH 04/50] refactor: convert assertions batch 3 (model config tests) - test_resolve_aiderignore_path - test_invalid_edit_format - test_default_model_selection - test_model_precedence - test_model_overrides_suffix_applied - test_model_overrides_no_match_preserves_model_name - test_chat_language_spanish - test_commit_language_japanese - test_main_exit_with_git_command_not_found - test_reasoning_effort_option - test_thinking_tokens_option - test_list_models_includes_metadata_models - test_list_models_includes_all_model_sources - test_list_models_with_direct_resource_patch - test_stream_without_cache_no_warning - test_cache_without_stream_no_warning Converted unittest assertions to plain assert statements while keeping TestCase base class. All 83 tests still passing. --- tests/basic/test_main.py | 110 ++++++++++++++++----------------------- 1 file changed, 46 insertions(+), 64 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 55336dfe1c5..09151fdfe9e 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -142,10 +142,10 @@ def test_main_copy_paste_model_overrides(self): return_coder=True, ) - self.assertIsInstance(coder, CopyPasteCoder) - self.assertTrue(coder.main_model.copy_paste_mode) - self.assertEqual(coder.main_model.copy_paste_transport, "clipboard") - self.assertEqual(coder.main_model.override_kwargs, {"temperature": 0.42}) + assert isinstance(coder, CopyPasteCoder) + assert coder.main_model.copy_paste_mode + assert coder.main_model.copy_paste_transport == "clipboard" + assert coder.main_model.override_kwargs == {"temperature": 0.42} @patch("aider.main.ClipboardWatcher") def test_main_copy_paste_flag_sets_mode(self, mock_watcher): @@ -158,11 +158,11 @@ def test_main_copy_paste_flag_sets_mode(self, mock_watcher): return_coder=True, ) - self.assertNotIsInstance(coder, CopyPasteCoder) - self.assertTrue(coder.main_model.copy_paste_mode) - self.assertEqual(coder.main_model.copy_paste_transport, "api") - self.assertTrue(coder.copy_paste_mode) - self.assertFalse(coder.manual_copy_paste) + assert not isinstance(coder, CopyPasteCoder) + assert coder.main_model.copy_paste_mode + assert coder.main_model.copy_paste_transport == "api" + assert coder.copy_paste_mode + assert not coder.manual_copy_paste def test_main_with_git_config_yml(self): make_repo() @@ -793,7 +793,7 @@ def test_read_option(self): return_coder=True, ) - self.assertIn(str(Path(test_file).resolve()), coder.abs_read_only_fnames) + assert str(Path(test_file).resolve()) in coder.abs_read_only_fnames def test_read_option_with_external_file(self): with tempfile.NamedTemporaryFile(mode="w", delete=False) as external_file: @@ -810,7 +810,7 @@ def test_read_option_with_external_file(self): ) real_external_file_path = os.path.realpath(external_file_path) - self.assertIn(real_external_file_path, coder.abs_read_only_fnames) + assert real_external_file_path in coder.abs_read_only_fnames finally: os.unlink(external_file_path) @@ -845,7 +845,7 @@ def test_model_metadata_file(self): return_coder=True, ) - self.assertEqual(coder.main_model.info["max_input_tokens"], 1234) + assert coder.main_model.info["max_input_tokens"] == 1234 def test_sonnet_and_cache_options(self): with GitTemporaryDirectory(): @@ -862,9 +862,7 @@ def test_sonnet_and_cache_options(self): MockRepoMap.assert_called_once() call_args, call_kwargs = MockRepoMap.call_args - self.assertEqual( - call_kwargs.get("refresh"), "files" - ) # Check the 'refresh' keyword argument + assert call_kwargs.get("refresh") == "files" # Check the 'refresh' keyword argument def test_sonnet_and_cache_prompts_options(self): with GitTemporaryDirectory(): @@ -875,7 +873,7 @@ def test_sonnet_and_cache_prompts_options(self): return_coder=True, ) - self.assertTrue(coder.add_cache_headers) + assert coder.add_cache_headers def test_4o_and_cache_options(self): with GitTemporaryDirectory(): @@ -886,7 +884,7 @@ def test_4o_and_cache_options(self): return_coder=True, ) - self.assertFalse(coder.add_cache_headers) + assert not coder.add_cache_headers def test_return_coder(self): with GitTemporaryDirectory(): @@ -1243,34 +1241,32 @@ def test_resolve_aiderignore_path(self): # Test with absolute path abs_path = os.path.abspath("/tmp/test/.aiderignore") - self.assertEqual(resolve_aiderignore_path(abs_path), abs_path) + assert 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) - ) + assert 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) + assert resolve_aiderignore_path(rel_path) == rel_path 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: + with pytest.raises(SystemExit) as cm: _ = main( ["--edit-format", "not-a-real-format", "--exit", "--yes-always"], input=DummyInput(), output=DummyOutput(), ) # argparse.ArgumentParser.exit() is called with status 2 for invalid choice - self.assertEqual(cm.exception.code, 2) + assert cm.value.code == 2 stderr_output = mock_stderr.getvalue() - self.assertIn("invalid choice", stderr_output) - self.assertIn("not-a-real-format", stderr_output) + assert "invalid choice" in stderr_output + assert "not-a-real-format" in stderr_output def test_default_model_selection(self): with GitTemporaryDirectory(): @@ -1282,7 +1278,7 @@ def test_default_model_selection(self): output=DummyOutput(), return_coder=True, ) - self.assertIn("sonnet", coder.main_model.name.lower()) + assert "sonnet" in coder.main_model.name.lower() del os.environ["ANTHROPIC_API_KEY"] # Test DeepSeek API key @@ -1293,7 +1289,7 @@ def test_default_model_selection(self): output=DummyOutput(), return_coder=True, ) - self.assertIn("deepseek", coder.main_model.name.lower()) + assert "deepseek" in coder.main_model.name.lower() del os.environ["DEEPSEEK_API_KEY"] # Test OpenRouter API key @@ -1304,7 +1300,7 @@ def test_default_model_selection(self): output=DummyOutput(), return_coder=True, ) - self.assertIn("openrouter/", coder.main_model.name.lower()) + assert "openrouter/" in coder.main_model.name.lower() del os.environ["OPENROUTER_API_KEY"] # Test OpenAI API key @@ -1315,7 +1311,7 @@ def test_default_model_selection(self): output=DummyOutput(), return_coder=True, ) - self.assertIn("gpt-4", coder.main_model.name.lower()) + assert "gpt-4" in coder.main_model.name.lower() del os.environ["OPENAI_API_KEY"] # Test Gemini API key @@ -1326,14 +1322,14 @@ def test_default_model_selection(self): output=DummyOutput(), return_coder=True, ) - self.assertIn("gemini", coder.main_model.name.lower()) + assert "gemini" in 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 = main(["--exit", "--yes-always"], input=DummyInput(), output=DummyOutput()) - self.assertEqual(result, 1) # Expect failure since no model could be selected + assert result == 1 # Expect failure since no model could be selected mock_offer_oauth.assert_called_once() def test_model_precedence(self): @@ -1347,7 +1343,7 @@ def test_model_precedence(self): output=DummyOutput(), return_coder=True, ) - self.assertIn("sonnet", coder.main_model.name.lower()) + assert "sonnet" in coder.main_model.name.lower() del os.environ["ANTHROPIC_API_KEY"] del os.environ["OPENAI_API_KEY"] @@ -1395,12 +1391,9 @@ def test_model_overrides_suffix_applied(self): matched_call_found = True break - self.assertTrue( - matched_call_found, - ( - "Expected a Model call with base name 'gpt-4o' and override_kwargs" - " {'temperature': 0.1}" - ), + assert matched_call_found, ( + "Expected a Model call with base name 'gpt-4o' and override_kwargs" + " {'temperature': 0.1}" ) def test_model_overrides_no_match_preserves_model_name(self): @@ -1442,12 +1435,9 @@ def test_model_overrides_no_match_preserves_model_name(self): matched_call_found = True break - self.assertTrue( - matched_call_found, - ( - "Expected a Model call with the full model name preserved and empty" - " override_kwargs" - ), + assert matched_call_found, ( + "Expected a Model call with the full model name preserved and empty" + " override_kwargs" ) def test_chat_language_spanish(self): @@ -1459,7 +1449,7 @@ def test_chat_language_spanish(self): return_coder=True, ) system_info = coder.get_platform_info() - self.assertIn("Spanish", system_info) + assert "Spanish" in system_info def test_commit_language_japanese(self): with GitTemporaryDirectory(): @@ -1469,18 +1459,14 @@ def test_commit_language_japanese(self): output=DummyOutput(), return_coder=True, ) - self.assertIn("japanese", coder.commit_language) + assert "japanese" in coder.commit_language @patch("git.Repo.init") 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 = main(["--exit", "--yes-always"], input=DummyInput(), output=DummyOutput()) - except Exception as e: - self.fail(f"main() raised an unexpected exception: {e}") - - self.assertEqual(result, 0, "main() should return 0 (success) when called with --exit") + result = main(["--exit", "--yes-always"], input=DummyInput(), output=DummyOutput()) + assert result == 0, "main() should return 0 (success) when called with --exit" def test_reasoning_effort_option(self): coder = main( @@ -1495,9 +1481,7 @@ def test_reasoning_effort_option(self): output=DummyOutput(), return_coder=True, ) - self.assertEqual( - coder.main_model.extra_params.get("extra_body", {}).get("reasoning_effort"), "3" - ) + assert coder.main_model.extra_params.get("extra_body", {}).get("reasoning_effort") == "3" def test_thinking_tokens_option(self): coder = main( @@ -1506,9 +1490,7 @@ def test_thinking_tokens_option(self): output=DummyOutput(), return_coder=True, ) - self.assertEqual( - coder.main_model.extra_params.get("thinking", {}).get("budget_tokens"), 1000 - ) + assert coder.main_model.extra_params.get("thinking", {}).get("budget_tokens") == 1000 def test_list_models_includes_metadata_models(self): # Test that models from model-metadata.json appear in list-models output @@ -1546,7 +1528,7 @@ def test_list_models_includes_metadata_models(self): output = mock_stdout.getvalue() # Check that the unique model name from our metadata file is listed - self.assertIn("test-provider/unique-model-name", output) + assert "test-provider/unique-model-name" in output def test_list_models_includes_all_model_sources(self): # Test that models from both litellm.model_cost and model-metadata.json @@ -1582,7 +1564,7 @@ def test_list_models_includes_all_model_sources(self): dump(output) # Check that both models appear in the output - self.assertIn("test-provider/metadata-only-model", output) + assert "test-provider/metadata-only-model" in output def test_check_model_accepts_settings_flag(self): # Test that --check-model-accepts-settings affects whether settings are applied @@ -1638,7 +1620,7 @@ def test_list_models_with_direct_resource_patch(self): output = mock_stdout.getvalue() # Check that the resource model appears in the output - self.assertIn("resource-provider/special-model", output) + assert "resource-provider/special-model" in output # When flag is off, setting should be applied regardless of support with patch("aider.models.Model.set_reasoning_effort") as mock_set_reasoning: @@ -1720,7 +1702,7 @@ def test_stream_without_cache_no_warning(self, MockInputOutput): output=DummyOutput(), ) for call in mock_io_instance.tool_warning.call_args_list: - self.assertNotIn("Cost estimates may be inaccurate", call[0][0]) + assert "Cost estimates may be inaccurate" not in call[0][0] def test_argv_file_respects_git(self): with GitTemporaryDirectory(): @@ -1807,7 +1789,7 @@ def test_cache_without_stream_no_warning(self, MockInputOutput): output=DummyOutput(), ) for call in mock_io_instance.tool_warning.call_args_list: - self.assertNotIn("Cost estimates may be inaccurate", call[0][0]) + assert "Cost estimates may be inaccurate" not in call[0][0] @patch("aider.coders.Coder.create") def test_mcp_servers_parsing(self, mock_coder_create): From 0873857748742970df93a4409a805bfdac4e08cf Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Mon, 29 Dec 2025 22:53:29 +0100 Subject: [PATCH 05/50] refactor: convert assertions batch 4 (remaining tests) - test_command_line_gitignore_files_flag - test_add_command_gitignore_files_flag - test_encodings_arg - test_yes - test_default_yes - test_dark_mode_sets_code_theme - test_light_mode_sets_code_theme - test_lint_option - test_lint_option_with_explicit_files - test_lint_option_with_glob_pattern - test_map_mul_option - test_suggest_shell_commands_default/disabled/enabled - test_detect_urls_default/disabled/enabled - test_accepts_settings_warnings - test_argv_file_respects_git - test_mcp_servers_parsing Converted all remaining unittest assertions to plain assert statements. All 83 tests still passing. Ready to remove TestCase inheritance. --- tests/basic/test_main.py | 84 ++++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 09151fdfe9e..f7f712c20fb 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -259,7 +259,7 @@ def test_command_line_gitignore_files_flag(self): force_git_root=git_dir, ) # Verify the ignored file is not in the chat - self.assertNotIn(abs_ignored_file, coder.abs_fnames) + assert abs_ignored_file not in coder.abs_fnames # Test with --add-gitignore-files set to True coder = main( @@ -270,7 +270,7 @@ def test_command_line_gitignore_files_flag(self): force_git_root=git_dir, ) # Verify the ignored file is in the chat - self.assertIn(abs_ignored_file, coder.abs_fnames) + assert abs_ignored_file in coder.abs_fnames # Test with --add-gitignore-files set to False coder = main( @@ -281,7 +281,7 @@ def test_command_line_gitignore_files_flag(self): force_git_root=git_dir, ) # Verify the ignored file is not in the chat - self.assertNotIn(abs_ignored_file, coder.abs_fnames) + assert abs_ignored_file not in coder.abs_fnames def test_add_command_gitignore_files_flag(self): with GitTemporaryDirectory() as git_dir: @@ -314,7 +314,7 @@ def test_add_command_gitignore_files_flag(self): pass # Verify the ignored file is not in the chat - self.assertNotIn(abs_ignored_file, coder.abs_fnames) + assert abs_ignored_file not in coder.abs_fnames # Test with --add-gitignore-files set to True coder = main( @@ -330,7 +330,7 @@ def test_add_command_gitignore_files_flag(self): pass # Verify the ignored file is in the chat - self.assertIn(abs_ignored_file, coder.abs_fnames) + assert abs_ignored_file in coder.abs_fnames # Test with --add-gitignore-files set to False coder = main( @@ -347,7 +347,7 @@ def test_add_command_gitignore_files_flag(self): pass # Verify the ignored file is not in the chat - self.assertNotIn(abs_ignored_file, coder.abs_fnames) + assert abs_ignored_file not in coder.abs_fnames def test_main_args(self): with patch("aider.coders.Coder.create") as MockCoder: @@ -457,7 +457,7 @@ def test_encodings_arg(self): with patch("aider.main.InputOutput") as MockSend: def side_effect(*args, **kwargs): - self.assertEqual(kwargs["encoding"], "iso-8859-15") + assert kwargs["encoding"] == "iso-8859-15" mock_io = MagicMock() mock_io.confirm_ask = AsyncMock(return_value=True) return mock_io @@ -496,7 +496,7 @@ def test_yes(self, mock_run, MockInputOutput): main(["--yes-always", "--message", test_message]) args, kwargs = MockInputOutput.call_args - self.assertTrue(args[1]) + assert args[1] @patch("aider.main.InputOutput", autospec=True) @patch("aider.coders.base_coder.Coder.run") @@ -506,7 +506,7 @@ def test_default_yes(self, mock_run, MockInputOutput): main(["--message", test_message]) args, kwargs = MockInputOutput.call_args - self.assertEqual(args[1], None) + assert args[1] is None def test_dark_mode_sets_code_theme(self): # Mock InputOutput to capture the configuration @@ -517,7 +517,7 @@ def test_dark_mode_sets_code_theme(self): MockInputOutput.assert_called_once() # Check if the code_theme setting is for dark mode _, kwargs = MockInputOutput.call_args - self.assertEqual(kwargs["code_theme"], "monokai") + assert kwargs["code_theme"] == "monokai" def test_light_mode_sets_code_theme(self): # Mock InputOutput to capture the configuration @@ -528,7 +528,7 @@ def test_light_mode_sets_code_theme(self): MockInputOutput.assert_called_once() # Check if the code_theme setting is for light mode _, kwargs = MockInputOutput.call_args - self.assertEqual(kwargs["code_theme"], "default") + assert kwargs["code_theme"] == "default" def create_env_file(self, file_name, content): env_file_path = Path(self.tempdir) / file_name @@ -612,8 +612,8 @@ def test_lint_option(self): # 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")) + assert called_arg.endswith("dirty_file.py") + assert not called_arg.endswith(f"subdir{os.path.sep}dirty_file.py") def test_lint_option_with_explicit_files(self): with GitTemporaryDirectory(): @@ -635,12 +635,12 @@ def test_lint_option_with_explicit_files(self): ) # Check if the Linter was called twice (once for each file) - self.assertEqual(MockLinter.call_count, 2) + assert MockLinter.call_count == 2 # Check that both files were linted called_files = [call[0][0] for call in MockLinter.call_args_list] - self.assertTrue(any(f.endswith("file1.py") for f in called_files)) - self.assertTrue(any(f.endswith("file2.py") for f in called_files)) + assert any(f.endswith("file1.py") for f in called_files) + assert any(f.endswith("file2.py") for f in called_files) def test_lint_option_with_glob_pattern(self): with GitTemporaryDirectory(): @@ -664,14 +664,14 @@ def test_lint_option_with_glob_pattern(self): ) # Check if the Linter was called for Python files matching the glob - self.assertGreaterEqual(MockLinter.call_count, 2) + assert MockLinter.call_count >= 2 # Check that Python files were linted called_files = [call[0][0] for call in MockLinter.call_args_list] - self.assertTrue(any(f.endswith("test1.py") for f in called_files)) - self.assertTrue(any(f.endswith("test2.py") for f in called_files)) + assert any(f.endswith("test1.py") for f in called_files) + assert any(f.endswith("test2.py") for f in called_files) # Check that non-Python file was not linted - self.assertFalse(any(f.endswith("readme.txt") for f in called_files)) + assert not any(f.endswith("readme.txt") for f in called_files) def test_verbose_mode_lists_env_vars(self): self.create_env_file(".env", "AIDER_DARK_MODE=on") @@ -912,8 +912,8 @@ def test_map_mul_option(self): output=DummyOutput(), return_coder=True, ) - self.assertIsInstance(coder, Coder) - self.assertEqual(coder.repo_map.map_mul_no_files, 5) + assert isinstance(coder, Coder) + assert coder.repo_map.map_mul_no_files == 5 def test_suggest_shell_commands_default(self): with GitTemporaryDirectory(): @@ -923,7 +923,7 @@ def test_suggest_shell_commands_default(self): output=DummyOutput(), return_coder=True, ) - self.assertTrue(coder.suggest_shell_commands) + assert coder.suggest_shell_commands def test_suggest_shell_commands_disabled(self): with GitTemporaryDirectory(): @@ -933,7 +933,7 @@ def test_suggest_shell_commands_disabled(self): output=DummyOutput(), return_coder=True, ) - self.assertFalse(coder.suggest_shell_commands) + assert not coder.suggest_shell_commands def test_suggest_shell_commands_enabled(self): with GitTemporaryDirectory(): @@ -943,7 +943,7 @@ def test_suggest_shell_commands_enabled(self): output=DummyOutput(), return_coder=True, ) - self.assertTrue(coder.suggest_shell_commands) + assert coder.suggest_shell_commands def test_detect_urls_default(self): with GitTemporaryDirectory(): @@ -953,7 +953,7 @@ def test_detect_urls_default(self): output=DummyOutput(), return_coder=True, ) - self.assertTrue(coder.detect_urls) + assert coder.detect_urls def test_detect_urls_disabled(self): with GitTemporaryDirectory(): @@ -963,7 +963,7 @@ def test_detect_urls_disabled(self): output=DummyOutput(), return_coder=True, ) - self.assertFalse(coder.detect_urls) + assert not coder.detect_urls def test_detect_urls_enabled(self): with GitTemporaryDirectory(): @@ -973,7 +973,7 @@ def test_detect_urls_enabled(self): output=DummyOutput(), return_coder=True, ) - self.assertTrue(coder.detect_urls) + assert coder.detect_urls def test_accepts_settings_warnings(self): # Test that appropriate warnings are shown based on accepts_settings configuration @@ -997,7 +997,7 @@ def test_accepts_settings_warnings(self): ) # 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]) + assert "thinking_tokens" not in call[0][0] # Method should be called mock_set_thinking.assert_called_once_with("1000") @@ -1024,7 +1024,7 @@ def test_accepts_settings_warnings(self): for call in mock_warning.call_args_list: if "thinking_tokens" in call[0][0]: warning_shown = True - self.assertTrue(warning_shown) + assert warning_shown # Method should NOT be called because model doesn't support it and check flag is on mock_set_thinking.assert_not_called() @@ -1040,7 +1040,7 @@ def test_accepts_settings_warnings(self): ) # 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]) + assert "reasoning_effort" not in call[0][0] # Method should be called mock_set_reasoning.assert_called_once_with("3") @@ -1066,7 +1066,7 @@ def test_accepts_settings_warnings(self): for call in mock_warning.call_args_list: if "reasoning_effort" in call[0][0]: warning_shown = True - self.assertTrue(warning_shown) + assert warning_shown # Method should still be called by default mock_set_reasoning.assert_not_called() @@ -1716,8 +1716,8 @@ def test_argv_file_respects_git(self): output=DummyOutput(), return_coder=True, ) - self.assertNotIn("not_in_git.txt", str(coder.abs_fnames)) - self.assertFalse(asyncio.run(coder.allowed_to_edit("not_in_git.txt"))) + assert "not_in_git.txt" not in str(coder.abs_fnames) + assert not asyncio.run(coder.allowed_to_edit("not_in_git.txt")) def test_load_dotenv_files_override(self): with GitTemporaryDirectory() as git_dir: @@ -1814,12 +1814,12 @@ def test_mcp_servers_parsing(self, mock_coder_create): # 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"]) + assert "mcp_servers" in kwargs + assert kwargs["mcp_servers"] is not None # At least one server should be in the list - self.assertTrue(len(kwargs["mcp_servers"]) > 0) + assert len(kwargs["mcp_servers"]) > 0 # First server should have a name attribute - self.assertTrue(hasattr(kwargs["mcp_servers"][0], "name")) + assert hasattr(kwargs["mcp_servers"][0], "name") # Test with --mcp-servers-file option mock_coder_create.reset_mock() @@ -1840,9 +1840,9 @@ def test_mcp_servers_parsing(self, mock_coder_create): # 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"]) + assert "mcp_servers" in kwargs + assert kwargs["mcp_servers"] is not None # At least one server should be in the list - self.assertTrue(len(kwargs["mcp_servers"]) > 0) + assert len(kwargs["mcp_servers"]) > 0 # First server should have a name attribute - self.assertTrue(hasattr(kwargs["mcp_servers"][0], "name")) + assert hasattr(kwargs["mcp_servers"][0], "name") From 08105e713871030e8bc4317ba7d6a209369db6a3 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Mon, 29 Dec 2025 22:54:47 +0100 Subject: [PATCH 06/50] refactor: remove unittest.TestCase inheritance Removed TestCase base class and unittest import. All tests now use pure pytest patterns with plain assert statements and pytest fixtures. All 83 tests passing. Phase 2 (assertion conversion) complete! --- tests/basic/test_main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index f7f712c20fb..a3060dec980 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -5,7 +5,6 @@ import tempfile from io import StringIO from pathlib import Path -from unittest import TestCase from unittest.mock import AsyncMock, MagicMock, patch import git @@ -75,7 +74,7 @@ def test_env(request): webbrowser_patcher.stop() -class TestMain(TestCase): +class TestMain: def test_main_with_empty_dir_no_files_on_command(self): main(["--no-git", "--exit", "--yes-always"], input=DummyInput(), output=DummyOutput()) From 47371bf03ed760dab6b7bee2e665b9c7beba7745 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 00:13:15 +0100 Subject: [PATCH 07/50] style: Apply code formatting --- tests/basic/test_main.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index a3060dec980..41be9af9274 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -75,7 +75,6 @@ def test_env(request): class TestMain: - def test_main_with_empty_dir_no_files_on_command(self): main(["--no-git", "--exit", "--yes-always"], input=DummyInput(), output=DummyOutput()) @@ -689,6 +688,7 @@ def test_verbose_mode_lists_env_vars(self): assert "AIDER_DARK_MODE" in relevant_output assert "dark_mode" in relevant_output import re + assert re.search(r"AIDER_DARK_MODE:\s+on", relevant_output) assert re.search(r"dark_mode:\s+True", relevant_output) @@ -1758,13 +1758,11 @@ def test_load_dotenv_files_override(self): assert str(oauth_keys_file.resolve()) in loaded_files assert str(git_root_env.resolve()) in loaded_files assert str(cwd_env.resolve()) in loaded_files - assert ( - loaded_files.index(str(oauth_keys_file.resolve())) - < loaded_files.index(str(git_root_env.resolve())) + assert loaded_files.index(str(oauth_keys_file.resolve())) < loaded_files.index( + str(git_root_env.resolve()) ) - assert ( - loaded_files.index(str(git_root_env.resolve())) - < loaded_files.index(str(cwd_env.resolve())) + assert loaded_files.index(str(git_root_env.resolve())) < loaded_files.index( + str(cwd_env.resolve()) ) # Assert environment variables reflect the override order From 92e11d846728d6fc1e071f087c358486671aea82 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 00:46:40 +0100 Subject: [PATCH 08/50] refactor: parametrize boolean flag tests (Phase 3A.1) Replace 6 separate boolean flag tests with a single parametrized test: - test_suggest_shell_commands_* (3 tests) - test_detect_urls_* (3 tests) Reduces duplication while maintaining all test cases. All 83 tests pass. --- tests/basic/test_main.py | 83 ++++++++++++---------------------------- 1 file changed, 25 insertions(+), 58 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 41be9af9274..09a12f53da1 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -914,65 +914,32 @@ def test_map_mul_option(self): assert isinstance(coder, Coder) assert coder.repo_map.map_mul_no_files == 5 - def test_suggest_shell_commands_default(self): + @pytest.mark.parametrize( + "flag_arg,attr_name,expected", + [ + (None, "suggest_shell_commands", True), + ("--no-suggest-shell-commands", "suggest_shell_commands", False), + ("--suggest-shell-commands", "suggest_shell_commands", True), + (None, "detect_urls", True), + ("--no-detect-urls", "detect_urls", False), + ("--detect-urls", "detect_urls", True), + ], + ids=[ + "suggest_default", + "suggest_disabled", + "suggest_enabled", + "urls_default", + "urls_disabled", + "urls_enabled", + ], + ) + def test_boolean_flags(self, flag_arg, attr_name, expected): with GitTemporaryDirectory(): - coder = main( - ["--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), - return_coder=True, - ) - assert coder.suggest_shell_commands - - def test_suggest_shell_commands_disabled(self): - with GitTemporaryDirectory(): - coder = main( - ["--no-suggest-shell-commands", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), - return_coder=True, - ) - assert not coder.suggest_shell_commands - - def test_suggest_shell_commands_enabled(self): - with GitTemporaryDirectory(): - coder = main( - ["--suggest-shell-commands", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), - return_coder=True, - ) - assert coder.suggest_shell_commands - - def test_detect_urls_default(self): - with GitTemporaryDirectory(): - coder = main( - ["--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), - return_coder=True, - ) - assert coder.detect_urls - - def test_detect_urls_disabled(self): - with GitTemporaryDirectory(): - coder = main( - ["--no-detect-urls", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), - return_coder=True, - ) - assert not coder.detect_urls - - def test_detect_urls_enabled(self): - with GitTemporaryDirectory(): - coder = main( - ["--detect-urls", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), - return_coder=True, - ) - assert coder.detect_urls + args = ["--exit", "--yes-always"] + if flag_arg: + args.insert(0, flag_arg) + coder = main(args, input=DummyInput(), output=DummyOutput(), return_coder=True) + assert getattr(coder, attr_name) == expected def test_accepts_settings_warnings(self): # Test that appropriate warnings are shown based on accepts_settings configuration From 8d070c5e22f35817572371f5a923155e597668f4 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 00:48:32 +0100 Subject: [PATCH 09/50] refactor: parametrize API key tests (Phase 3A.2) Replace 3 separate API key tests with a single parametrized test: - test_api_key_single - test_api_key_multiple - test_api_key_invalid_format All 83 tests pass. --- tests/basic/test_main.py | 54 +++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 09a12f53da1..e012ccd5fe3 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -1096,33 +1096,35 @@ def test_set_env_invalid_format(self): result = main(["--set-env", "INVALID_FORMAT", "--exit", "--yes-always"]) assert result == 1 - def test_api_key_single(self): - # Test setting a single API key - with GitTemporaryDirectory(): - main(["--api-key", "anthropic=test-key", "--exit", "--yes-always"]) - assert os.environ.get("ANTHROPIC_API_KEY") == "test-key" - - def test_api_key_multiple(self): - # Test setting multiple API keys - with GitTemporaryDirectory(): - main( - [ - "--api-key", - "anthropic=key1", - "--api-key", - "openai=key2", - "--exit", - "--yes-always", - ] - ) - assert os.environ.get("ANTHROPIC_API_KEY") == "key1" - assert os.environ.get("OPENAI_API_KEY") == "key2" - - def test_api_key_invalid_format(self): - # Test invalid format handling + @pytest.mark.parametrize( + "api_key_args,expected_env,expected_result", + [ + ( + ["--api-key", "anthropic=test-key"], + {"ANTHROPIC_API_KEY": "test-key"}, + None, + ), + ( + ["--api-key", "anthropic=key1", "--api-key", "openai=key2"], + {"ANTHROPIC_API_KEY": "key1", "OPENAI_API_KEY": "key2"}, + None, + ), + ( + ["--api-key", "INVALID_FORMAT"], + {}, + 1, + ), + ], + ids=["single", "multiple", "invalid_format"], + ) + def test_api_key(self, api_key_args, expected_env, expected_result): with GitTemporaryDirectory(): - result = main(["--api-key", "INVALID_FORMAT", "--exit", "--yes-always"]) - assert result == 1 + args = api_key_args + ["--exit", "--yes-always"] + result = main(args) + if expected_result is not None: + assert result == expected_result + for env_var, expected_value in expected_env.items(): + assert os.environ.get(env_var) == expected_value def test_git_config_include(self): # Test that aider respects git config includes for user.name and user.email From 4c5a459c998c21682ab96acc3bda5587edbf2176 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 00:49:43 +0100 Subject: [PATCH 10/50] refactor: parametrize --set-env tests (Phase 3A.3) Replace 4 separate --set-env tests with a single parametrized test: - test_set_env_single - test_set_env_multiple - test_set_env_with_spaces - test_set_env_invalid_format All 83 tests pass. --- tests/basic/test_main.py | 65 ++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index e012ccd5fe3..b9b7fd8afe4 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -1062,39 +1062,40 @@ def test_pytest_env_vars(self): # Verify that environment variables from pytest.ini are properly set assert os.environ.get("AIDER_ANALYTICS") == "false" - def test_set_env_single(self): - # Test setting a single environment variable - with GitTemporaryDirectory(): - main(["--set-env", "TEST_VAR=test_value", "--exit", "--yes-always"]) - assert os.environ.get("TEST_VAR") == "test_value" - - def test_set_env_multiple(self): - # Test setting multiple environment variables - with GitTemporaryDirectory(): - main( - [ - "--set-env", - "TEST_VAR1=value1", - "--set-env", - "TEST_VAR2=value2", - "--exit", - "--yes-always", - ] - ) - assert os.environ.get("TEST_VAR1") == "value1" - assert os.environ.get("TEST_VAR2") == "value2" - - def test_set_env_with_spaces(self): - # Test setting env var with spaces in value - with GitTemporaryDirectory(): - main(["--set-env", "TEST_VAR=test value with spaces", "--exit", "--yes-always"]) - assert os.environ.get("TEST_VAR") == "test value with spaces" - - def test_set_env_invalid_format(self): - # Test invalid format handling + @pytest.mark.parametrize( + "set_env_args,expected_env,expected_result", + [ + ( + ["--set-env", "TEST_VAR=test_value"], + {"TEST_VAR": "test_value"}, + None, + ), + ( + ["--set-env", "TEST_VAR1=value1", "--set-env", "TEST_VAR2=value2"], + {"TEST_VAR1": "value1", "TEST_VAR2": "value2"}, + None, + ), + ( + ["--set-env", "TEST_VAR=test value with spaces"], + {"TEST_VAR": "test value with spaces"}, + None, + ), + ( + ["--set-env", "INVALID_FORMAT"], + {}, + 1, + ), + ], + ids=["single", "multiple", "with_spaces", "invalid_format"], + ) + def test_set_env(self, set_env_args, expected_env, expected_result): with GitTemporaryDirectory(): - result = main(["--set-env", "INVALID_FORMAT", "--exit", "--yes-always"]) - assert result == 1 + args = set_env_args + ["--exit", "--yes-always"] + result = main(args) + if expected_result is not None: + assert result == expected_result + for env_var, expected_value in expected_env.items(): + assert os.environ.get(env_var) == expected_value @pytest.mark.parametrize( "api_key_args,expected_env,expected_result", From 973e22c86050d064ac26f230097ae147b12845cb Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 00:51:07 +0100 Subject: [PATCH 11/50] refactor: parametrize mode tests (Phase 3A.4) Replace 2 separate mode tests with a single parametrized test: - test_dark_mode_sets_code_theme - test_light_mode_sets_code_theme All 83 tests pass. --- tests/basic/test_main.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index b9b7fd8afe4..0c2ff36d27c 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -506,27 +506,24 @@ def test_default_yes(self, mock_run, MockInputOutput): args, kwargs = MockInputOutput.call_args assert args[1] is None - 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 - 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 - assert kwargs["code_theme"] == "monokai" - - def test_light_mode_sets_code_theme(self): + @pytest.mark.parametrize( + "mode_flag,expected_theme", + [ + ("--dark-mode", "monokai"), + ("--light-mode", "default"), + ], + ids=["dark_mode", "light_mode"], + ) + def test_mode_sets_code_theme(self, mode_flag, expected_theme): # Mock InputOutput to capture the configuration with patch("aider.main.InputOutput") as MockInputOutput: MockInputOutput.return_value.get_input.return_value = None - main(["--light-mode", "--no-git", "--exit"], input=DummyInput(), output=DummyOutput()) + main([mode_flag, "--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 + # Check if the code_theme setting matches expected _, kwargs = MockInputOutput.call_args - assert kwargs["code_theme"] == "default" + assert kwargs["code_theme"] == expected_theme def create_env_file(self, file_name, content): env_file_path = Path(self.tempdir) / file_name From e897414bce325609116eab114618642f4c1ee6ff Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 00:52:51 +0100 Subject: [PATCH 12/50] refactor: split default model selection test (Phase 3A.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split large test_default_model_selection into: - Parametrized test for 5 API key scenarios (anthropic, deepseek, openrouter, openai, gemini) - Separate test for OAuth fallback when no API keys present All 88 tests pass (83 → 88 due to test expansion). --- tests/basic/test_main.py | 127 +++++++++++++++++++++------------------ 1 file changed, 67 insertions(+), 60 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 0c2ff36d27c..4f029da9d9f 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -1234,69 +1234,76 @@ def test_invalid_edit_format(self): assert "invalid choice" in stderr_output assert "not-a-real-format" in stderr_output - def test_default_model_selection(self): + @pytest.mark.parametrize( + "api_key_env,expected_model_substr", + [ + ("ANTHROPIC_API_KEY", "sonnet"), + ("DEEPSEEK_API_KEY", "deepseek"), + ("OPENROUTER_API_KEY", "openrouter/"), + ("OPENAI_API_KEY", "gpt-4"), + ("GEMINI_API_KEY", "gemini"), + ], + ids=["anthropic", "deepseek", "openrouter", "openai", "gemini"], + ) + def test_default_model_selection(self, api_key_env, expected_model_substr): with GitTemporaryDirectory(): - # Test Anthropic API key - os.environ["ANTHROPIC_API_KEY"] = "test-key" - coder = main( - ["--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), - return_coder=True, - ) - assert "sonnet" in coder.main_model.name.lower() - del os.environ["ANTHROPIC_API_KEY"] + # Save and clear all API keys to test each one in isolation + saved_keys = {} + api_keys = [ + "ANTHROPIC_API_KEY", + "DEEPSEEK_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + ] + for key in api_keys: + if key in os.environ: + saved_keys[key] = os.environ[key] + del os.environ[key] - # Test DeepSeek API key - os.environ["DEEPSEEK_API_KEY"] = "test-key" - coder = main( - ["--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), - return_coder=True, - ) - assert "deepseek" in coder.main_model.name.lower() - del os.environ["DEEPSEEK_API_KEY"] - - # Test OpenRouter API key - os.environ["OPENROUTER_API_KEY"] = "test-key" - coder = main( - ["--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), - return_coder=True, - ) - assert "openrouter/" in coder.main_model.name.lower() - del os.environ["OPENROUTER_API_KEY"] - - # Test OpenAI API key - os.environ["OPENAI_API_KEY"] = "test-key" - coder = main( - ["--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), - return_coder=True, - ) - assert "gpt-4" in coder.main_model.name.lower() - del os.environ["OPENAI_API_KEY"] + try: + os.environ[api_key_env] = "test-key" + coder = main( + ["--exit", "--yes-always"], + input=DummyInput(), + output=DummyOutput(), + return_coder=True, + ) + assert expected_model_substr in coder.main_model.name.lower() + finally: + # Restore saved API keys + if api_key_env in os.environ: + del os.environ[api_key_env] + for key, value in saved_keys.items(): + os.environ[key] = value + + def test_default_model_selection_oauth_fallback(self): + # Test no API keys - should offer OpenRouter OAuth + with GitTemporaryDirectory(): + # Clear all API keys to simulate no configured keys + saved_keys = {} + api_keys = [ + "ANTHROPIC_API_KEY", + "DEEPSEEK_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + ] + for key in api_keys: + if key in os.environ: + saved_keys[key] = os.environ[key] + del os.environ[key] - # Test Gemini API key - os.environ["GEMINI_API_KEY"] = "test-key" - coder = main( - ["--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), - return_coder=True, - ) - assert "gemini" in 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 = main(["--exit", "--yes-always"], input=DummyInput(), output=DummyOutput()) - assert result == 1 # Expect failure since no model could be selected - mock_offer_oauth.assert_called_once() + try: + with patch("aider.onboarding.offer_openrouter_oauth") as mock_offer_oauth: + mock_offer_oauth.return_value = None # Simulate user declining or failure + result = main(["--exit", "--yes-always"], input=DummyInput(), output=DummyOutput()) + assert result == 1 # Expect failure since no model could be selected + mock_offer_oauth.assert_called_once() + finally: + # Restore saved API keys + for key, value in saved_keys.items(): + os.environ[key] = value def test_model_precedence(self): with GitTemporaryDirectory(): From 4db759310073553aad21395e9af043f8ef40f2a2 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 00:54:10 +0100 Subject: [PATCH 13/50] refactor: parametrize main args tests (Phase 3A.6) Replace single test with 5 internal sub-tests with a parametrized test: - test_main_args now tests 5 scenarios via parametrization - no_auto_commits, auto_commits, defaults, no_dirty_commits, dirty_commits All 92 tests pass. --- tests/basic/test_main.py | 49 ++++++++++++---------------------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 4f029da9d9f..73dcb4c3209 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -347,44 +347,25 @@ def test_add_command_gitignore_files_flag(self): # Verify the ignored file is not in the chat assert abs_ignored_file not in coder.abs_fnames - def test_main_args(self): - with patch("aider.coders.Coder.create") as MockCoder: - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() - # --yes will just ok the git repo without blocking on input - # following calls to main will see the new repo already - main(["--no-auto-commits", "--yes-always"], input=DummyInput()) - _, kwargs = MockCoder.call_args - assert kwargs["auto_commits"] is False - - with patch("aider.coders.Coder.create") as MockCoder: - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() - main(["--auto-commits"], input=DummyInput()) - _, kwargs = MockCoder.call_args - assert kwargs["auto_commits"] is True - - with patch("aider.coders.Coder.create") as MockCoder: - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() - 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: - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() - main(["--no-dirty-commits"], input=DummyInput()) - _, kwargs = MockCoder.call_args - assert kwargs["dirty_commits"] is False - + @pytest.mark.parametrize( + "args,expected_kwargs", + [ + (["--no-auto-commits", "--yes-always"], {"auto_commits": False}), + (["--auto-commits", "--no-git"], {"auto_commits": True}), + (["--no-git"], {"dirty_commits": True, "auto_commits": True}), + (["--no-dirty-commits", "--no-git"], {"dirty_commits": False}), + (["--dirty-commits", "--no-git"], {"dirty_commits": True}), + ], + ids=["no_auto_commits", "auto_commits", "defaults", "no_dirty_commits", "dirty_commits"], + ) + def test_main_args(self, args, expected_kwargs): with patch("aider.coders.Coder.create") as MockCoder: mock_coder_instance = MockCoder.return_value mock_coder_instance._autosave_future = mock_autosave_future() - main(["--dirty-commits"], input=DummyInput()) + main(args, input=DummyInput()) _, kwargs = MockCoder.call_args - assert kwargs["dirty_commits"] is True + for key, expected_value in expected_kwargs.items(): + assert kwargs[key] is expected_value def test_env_file_override(self): with GitTemporaryDirectory() as git_dir: From a464a9329d492c74df58afeebfd55a5664ea62c3 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 00:56:46 +0100 Subject: [PATCH 14/50] refactor: extract dummy_io fixture (Phase 3B.1) Create dummy_io fixture to eliminate 75+ duplicate DummyInput/Output calls. All tests now use **dummy_io instead of explicit input/output parameters. Reduces duplication and improves test maintainability. All 92 tests pass. --- tests/basic/test_main.py | 359 +++++++++++++++++---------------------- 1 file changed, 154 insertions(+), 205 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 73dcb4c3209..e2672ac2dd1 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -74,56 +74,56 @@ def test_env(request): webbrowser_patcher.stop() +@pytest.fixture +def dummy_io(): + """Provide DummyInput and DummyOutput for tests.""" + return {"input": DummyInput(), "output": DummyOutput()} + + class TestMain: - def test_main_with_empty_dir_no_files_on_command(self): - main(["--no-git", "--exit", "--yes-always"], input=DummyInput(), output=DummyOutput()) + def test_main_with_empty_dir_no_files_on_command(self, dummy_io): + main(["--no-git", "--exit", "--yes-always"], **dummy_io) - def test_main_with_emptqy_dir_new_file(self): - main( - ["foo.txt", "--yes-always", "--no-git", "--exit"], - input=DummyInput(), - output=DummyOutput(), - ) + def test_main_with_emptqy_dir_new_file(self, dummy_io): + main(["foo.txt", "--yes-always", "--no-git", "--exit"], **dummy_io) assert os.path.exists("foo.txt") @patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") - def test_main_with_empty_git_dir_new_file(self, _): + def test_main_with_empty_git_dir_new_file(self, _, dummy_io): make_repo() - main(["--yes-always", "foo.txt", "--exit"], input=DummyInput(), output=DummyOutput()) + main(["--yes-always", "foo.txt", "--exit"], **dummy_io) assert os.path.exists("foo.txt") @patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") - def test_main_with_empty_git_dir_new_files(self, _): + def test_main_with_empty_git_dir_new_files(self, _, dummy_io): make_repo() main( ["--yes-always", "foo.txt", "bar.txt", "--exit"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) assert os.path.exists("foo.txt") assert os.path.exists("bar.txt") - def test_main_with_dname_and_fname(self): + def test_main_with_dname_and_fname(self, dummy_io): subdir = Path("subdir") subdir.mkdir() make_repo(str(subdir)) - res = main(["subdir", "foo.txt"], input=DummyInput(), output=DummyOutput()) + res = main(["subdir", "foo.txt"], **dummy_io) assert res is not None @patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") - def test_main_with_subdir_repo_fnames(self, _): + def test_main_with_subdir_repo_fnames(self, _, dummy_io): subdir = Path("subdir") subdir.mkdir() make_repo(str(subdir)) main( ["--yes-always", str(subdir / "foo.txt"), str(subdir / "bar.txt"), "--exit"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) assert (subdir / "foo.txt").exists() assert (subdir / "bar.txt").exists() - def test_main_copy_paste_model_overrides(self): + def test_main_copy_paste_model_overrides(self, dummy_io): overrides = json.dumps({"gpt-4o": {"fast": {"temperature": 0.42}}}) coder = main( [ @@ -135,8 +135,7 @@ def test_main_copy_paste_model_overrides(self): "--model-overrides", overrides, ], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) @@ -146,13 +145,12 @@ def test_main_copy_paste_model_overrides(self): assert coder.main_model.override_kwargs == {"temperature": 0.42} @patch("aider.main.ClipboardWatcher") - def test_main_copy_paste_flag_sets_mode(self, mock_watcher): + def test_main_copy_paste_flag_sets_mode(self, mock_watcher, dummy_io): mock_watcher.return_value = MagicMock() coder = main( ["--no-git", "--exit", "--yes-always", "--copy-paste"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) @@ -162,14 +160,14 @@ def test_main_copy_paste_flag_sets_mode(self, mock_watcher): assert coder.copy_paste_mode assert not coder.manual_copy_paste - def test_main_with_git_config_yml(self): + def test_main_with_git_config_yml(self, dummy_io): make_repo() Path(".aider.conf.yml").write_text("auto-commits: false\n") with patch("aider.coders.Coder.create") as MockCoder: mock_coder_instance = MockCoder.return_value mock_coder_instance._autosave_future = mock_autosave_future() - main(["--yes-always"], input=DummyInput(), output=DummyOutput()) + main(["--yes-always"], **dummy_io) _, kwargs = MockCoder.call_args assert kwargs["auto_commits"] is False @@ -177,11 +175,11 @@ def test_main_with_git_config_yml(self): with patch("aider.coders.Coder.create") as MockCoder: mock_coder_instance = MockCoder.return_value mock_coder_instance._autosave_future = mock_autosave_future() - main([], input=DummyInput(), output=DummyOutput()) + main([], **dummy_io) _, kwargs = MockCoder.call_args assert kwargs["auto_commits"] is True - def test_main_with_empty_git_dir_new_subdir_file(self): + def test_main_with_empty_git_dir_new_subdir_file(self, dummy_io): make_repo() subdir = Path("subdir") subdir.mkdir() @@ -193,9 +191,9 @@ def test_main_with_empty_git_dir_new_subdir_file(self): # 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. - main(["--yes-always", str(fname), "--exit"], input=DummyInput(), output=DummyOutput()) + main(["--yes-always", str(fname), "--exit"], **dummy_io) - def test_setup_git(self): + def test_setup_git(self, dummy_io): io = InputOutput(pretty=False, yes=True) git_root = asyncio.run(setup_git(None, io)) git_root = Path(git_root).resolve() @@ -207,7 +205,7 @@ def test_setup_git(self): assert gitignore.exists() assert ".aider*" == gitignore.read_text().splitlines()[0] - def test_check_gitignore(self): + def test_check_gitignore(self, dummy_io): with GitTemporaryDirectory(): os.environ["GIT_CONFIG_GLOBAL"] = "globalgitconfig" @@ -233,7 +231,7 @@ def test_check_gitignore(self): assert "one\ntwo\n.aider*\n.env\n" == gitignore.read_text() del os.environ["GIT_CONFIG_GLOBAL"] - def test_command_line_gitignore_files_flag(self): + def test_command_line_gitignore_files_flag(self, dummy_io): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) @@ -251,8 +249,7 @@ def test_command_line_gitignore_files_flag(self): # Test without the --add-gitignore-files flag (default: False) coder = main( ["--exit", "--yes-always", abs_ignored_file], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, force_git_root=git_dir, ) @@ -262,8 +259,7 @@ def test_command_line_gitignore_files_flag(self): # Test with --add-gitignore-files set to True coder = main( ["--add-gitignore-files", "--exit", "--yes-always", abs_ignored_file], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, force_git_root=git_dir, ) @@ -273,15 +269,14 @@ def test_command_line_gitignore_files_flag(self): # Test with --add-gitignore-files set to False coder = main( ["--no-add-gitignore-files", "--exit", "--yes-always", abs_ignored_file], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, force_git_root=git_dir, ) # Verify the ignored file is not in the chat assert abs_ignored_file not in coder.abs_fnames - def test_add_command_gitignore_files_flag(self): + def test_add_command_gitignore_files_flag(self, dummy_io): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) @@ -300,8 +295,7 @@ def test_add_command_gitignore_files_flag(self): # Test without the --add-gitignore-files flag (default: False) coder = main( ["--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, force_git_root=git_dir, ) @@ -317,8 +311,7 @@ def test_add_command_gitignore_files_flag(self): # Test with --add-gitignore-files set to True coder = main( ["--add-gitignore-files", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, force_git_root=git_dir, ) @@ -333,8 +326,7 @@ def test_add_command_gitignore_files_flag(self): # Test with --add-gitignore-files set to False coder = main( ["--no-add-gitignore-files", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, force_git_root=git_dir, ) @@ -358,7 +350,7 @@ def test_add_command_gitignore_files_flag(self): ], ids=["no_auto_commits", "auto_commits", "defaults", "no_dirty_commits", "dirty_commits"], ) - def test_main_args(self, args, expected_kwargs): + def test_main_args(self, args, expected_kwargs, dummy_io): with patch("aider.coders.Coder.create") as MockCoder: mock_coder_instance = MockCoder.return_value mock_coder_instance._autosave_future = mock_autosave_future() @@ -367,7 +359,7 @@ def test_main_args(self, args, expected_kwargs): for key, expected_value in expected_kwargs.items(): assert kwargs[key] is expected_value - def test_env_file_override(self): + def test_env_file_override(self, dummy_io): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) git_env = git_dir / ".env" @@ -399,7 +391,7 @@ def test_env_file_override(self): assert os.environ["D"] == "home" assert os.environ["E"] == "existing" - def test_message_file_flag(self): + def test_message_file_flag(self, dummy_io): 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: @@ -418,15 +410,14 @@ async def mock_run(*args, **kwargs): main( ["--yes-always", "--message-file", message_file_path], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) # 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) - def test_encodings_arg(self): + def test_encodings_arg(self, dummy_io): fname = "foo.py" with GitTemporaryDirectory(): @@ -445,31 +436,31 @@ def side_effect(*args, **kwargs): main(["--yes-always", fname, "--encoding", "iso-8859-15"]) - def test_main_exit_calls_version_check(self): + def test_main_exit_calls_version_check(self, dummy_io): with GitTemporaryDirectory(): with ( patch("aider.main.check_version") as mock_check_version, patch("aider.main.InputOutput") as mock_input_output, ): mock_input_output.return_value.confirm_ask = AsyncMock(return_value=True) - main(["--exit", "--check-update"], input=DummyInput(), output=DummyOutput()) + main(["--exit", "--check-update"], **dummy_io) mock_check_version.assert_called_once() mock_input_output.assert_called_once() @patch("aider.main.InputOutput", autospec=True) @patch("aider.coders.base_coder.Coder.run") - def test_main_message_adds_to_input_history(self, mock_run, MockInputOutput): + def test_main_message_adds_to_input_history(self, mock_run, MockInputOutput, dummy_io): test_message = "test message" mock_io_instance = MockInputOutput.return_value mock_io_instance.pretty = True - main(["--message", test_message], input=DummyInput(), output=DummyOutput()) + main(["--message", test_message], **dummy_io) mock_io_instance.add_to_input_history.assert_called_once_with(test_message) @patch("aider.main.InputOutput", autospec=True) @patch("aider.coders.base_coder.Coder.run") - def test_yes(self, mock_run, MockInputOutput): + def test_yes(self, mock_run, MockInputOutput, dummy_io): test_message = "test message" MockInputOutput.return_value.pretty = True @@ -479,7 +470,7 @@ def test_yes(self, mock_run, MockInputOutput): @patch("aider.main.InputOutput", autospec=True) @patch("aider.coders.base_coder.Coder.run") - def test_default_yes(self, mock_run, MockInputOutput): + def test_default_yes(self, mock_run, MockInputOutput, dummy_io): test_message = "test message" MockInputOutput.return_value.pretty = True @@ -495,11 +486,11 @@ def test_default_yes(self, mock_run, MockInputOutput): ], ids=["dark_mode", "light_mode"], ) - def test_mode_sets_code_theme(self, mode_flag, expected_theme): + def test_mode_sets_code_theme(self, mode_flag, expected_theme, dummy_io): # Mock InputOutput to capture the configuration with patch("aider.main.InputOutput") as MockInputOutput: MockInputOutput.return_value.get_input.return_value = None - main([mode_flag, "--no-git", "--exit"], input=DummyInput(), output=DummyOutput()) + main([mode_flag, "--no-git", "--exit"], **dummy_io) # Ensure InputOutput was called MockInputOutput.assert_called_once() # Check if the code_theme setting matches expected @@ -511,54 +502,53 @@ def create_env_file(self, file_name, content): env_file_path.write_text(content) return env_file_path - def test_env_file_flag_sets_automatic_variable(self): + def test_env_file_flag_sets_automatic_variable(self, dummy_io): 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 main( ["--env-file", str(env_file_path), "--no-git", "--exit"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) MockInputOutput.assert_called_once() # Check if the color settings are for dark mode _, kwargs = MockInputOutput.call_args assert kwargs["code_theme"] == "monokai" - def test_default_env_file_sets_automatic_variable(self): + def test_default_env_file_sets_automatic_variable(self, dummy_io): 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 - main(["--no-git", "--exit"], input=DummyInput(), output=DummyOutput()) + main(["--no-git", "--exit"], **dummy_io) # Ensure InputOutput was called MockInputOutput.assert_called_once() # Check if the color settings are for dark mode _, kwargs = MockInputOutput.call_args assert kwargs["code_theme"] == "monokai" - def test_false_vals_in_env_file(self): + def test_false_vals_in_env_file(self, dummy_io): self.create_env_file(".env", "AIDER_SHOW_DIFFS=off") with patch("aider.coders.Coder.create", autospec=True) as MockCoder: mock_coder_instance = MockCoder.return_value mock_coder_instance._autosave_future = mock_autosave_future() - main(["--no-git", "--yes-always"], input=DummyInput(), output=DummyOutput()) + main(["--no-git", "--yes-always"], **dummy_io) MockCoder.assert_called_once() _, kwargs = MockCoder.call_args assert kwargs["show_diffs"] is False - def test_true_vals_in_env_file(self): + def test_true_vals_in_env_file(self, dummy_io): self.create_env_file(".env", "AIDER_SHOW_DIFFS=on") with patch("aider.coders.Coder.create") as MockCoder: mock_coder_instance = MockCoder.return_value mock_coder_instance._autosave_future = mock_autosave_future() - main(["--no-git", "--yes-always"], input=DummyInput(), output=DummyOutput()) + main(["--no-git", "--yes-always"], **dummy_io) MockCoder.assert_called_once() _, kwargs = MockCoder.call_args assert kwargs["show_diffs"] is True - def test_lint_option(self): + def test_lint_option(self, dummy_io): with GitTemporaryDirectory() as git_dir: # Create a dirty file in the root dirty_file = Path("dirty_file.py") @@ -582,7 +572,7 @@ def test_lint_option(self): MockLinter.return_value = "" # Run main with --lint option - main(["--lint", "--yes-always"], input=DummyInput(), output=DummyOutput()) + main(["--lint", "--yes-always"], **dummy_io) # Check if the Linter was called with a filename ending in "dirty_file.py" # but not ending in "subdir/dirty_file.py" @@ -591,7 +581,7 @@ def test_lint_option(self): assert called_arg.endswith("dirty_file.py") assert not called_arg.endswith(f"subdir{os.path.sep}dirty_file.py") - def test_lint_option_with_explicit_files(self): + def test_lint_option_with_explicit_files(self, dummy_io): with GitTemporaryDirectory(): # Create two files file1 = Path("file1.py") @@ -606,8 +596,7 @@ def test_lint_option_with_explicit_files(self): # Run main with --lint and explicit files main( ["--lint", "file1.py", "file2.py", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) # Check if the Linter was called twice (once for each file) @@ -618,7 +607,7 @@ def test_lint_option_with_explicit_files(self): assert any(f.endswith("file1.py") for f in called_files) assert any(f.endswith("file2.py") for f in called_files) - def test_lint_option_with_glob_pattern(self): + def test_lint_option_with_glob_pattern(self, dummy_io): with GitTemporaryDirectory(): # Create multiple Python files file1 = Path("test1.py") @@ -635,8 +624,7 @@ def test_lint_option_with_glob_pattern(self): # Run main with --lint and glob pattern main( ["--lint", "test*.py", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) # Check if the Linter was called for Python files matching the glob @@ -649,13 +637,12 @@ def test_lint_option_with_glob_pattern(self): # Check that non-Python file was not linted assert not any(f.endswith("readme.txt") for f in called_files) - def test_verbose_mode_lists_env_vars(self): + def test_verbose_mode_lists_env_vars(self, dummy_io): self.create_env_file(".env", "AIDER_DARK_MODE=on") with patch("sys.stdout", new_callable=StringIO) as mock_stdout: main( ["--no-git", "--verbose", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) output = mock_stdout.getvalue() relevant_output = "\n".join( @@ -670,7 +657,7 @@ def test_verbose_mode_lists_env_vars(self): assert re.search(r"AIDER_DARK_MODE:\s+on", relevant_output) assert re.search(r"dark_mode:\s+True", relevant_output) - def test_yaml_config_file_loading(self): + def test_yaml_config_file_loading(self, dummy_io): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) @@ -704,8 +691,7 @@ def test_yaml_config_file_loading(self): # Test loading from specified config file main( ["--yes-always", "--exit", "--config", str(named_config)], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) _, kwargs = MockCoder.call_args assert kwargs["main_model"].name == "gpt-4-1106-preview" @@ -713,7 +699,7 @@ def test_yaml_config_file_loading(self): # Test loading from current working directory mock_coder_instance._autosave_future = mock_autosave_future() - main(["--yes-always", "--exit"], input=DummyInput(), output=DummyOutput()) + main(["--yes-always", "--exit"], **dummy_io) _, kwargs = MockCoder.call_args print("kwargs:", kwargs) # Add this line for debugging assert "main_model" in kwargs, "main_model key not found in kwargs" @@ -723,7 +709,7 @@ def test_yaml_config_file_loading(self): # Test loading from git root cwd_config.unlink() mock_coder_instance._autosave_future = mock_autosave_future() - main(["--yes-always", "--exit"], input=DummyInput(), output=DummyOutput()) + main(["--yes-always", "--exit"], **dummy_io) _, kwargs = MockCoder.call_args assert kwargs["main_model"].name == "gpt-4" assert kwargs["map_tokens"] == 2048 @@ -731,48 +717,45 @@ def test_yaml_config_file_loading(self): # Test loading from home directory git_config.unlink() mock_coder_instance._autosave_future = mock_autosave_future() - main(["--yes-always", "--exit"], input=DummyInput(), output=DummyOutput()) + main(["--yes-always", "--exit"], **dummy_io) _, kwargs = MockCoder.call_args assert kwargs["main_model"].name == "gpt-3.5-turbo" assert kwargs["map_tokens"] == 1024 - def test_map_tokens_option(self): + def test_map_tokens_option(self, dummy_io): with GitTemporaryDirectory(): with patch("aider.coders.base_coder.RepoMap") as MockRepoMap: MockRepoMap.return_value.max_map_tokens = 0 main( ["--model", "gpt-4", "--map-tokens", "0", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) MockRepoMap.assert_not_called() - def test_map_tokens_option_with_non_zero_value(self): + def test_map_tokens_option_with_non_zero_value(self, dummy_io): with GitTemporaryDirectory(): with patch("aider.coders.base_coder.RepoMap") as MockRepoMap: MockRepoMap.return_value.max_map_tokens = 1000 main( ["--model", "gpt-4", "--map-tokens", "1000", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) MockRepoMap.assert_called_once() - def test_read_option(self): + def test_read_option(self, dummy_io): with GitTemporaryDirectory(): test_file = "test_file.txt" Path(test_file).touch() coder = main( ["--read", test_file, "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) assert str(Path(test_file).resolve()) in coder.abs_read_only_fnames - def test_read_option_with_external_file(self): + def test_read_option_with_external_file(self, dummy_io): with tempfile.NamedTemporaryFile(mode="w", delete=False) as external_file: external_file.write("External file content") external_file_path = external_file.name @@ -781,8 +764,7 @@ def test_read_option_with_external_file(self): with GitTemporaryDirectory(): coder = main( ["--read", external_file_path, "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) @@ -791,7 +773,7 @@ def test_read_option_with_external_file(self): finally: os.unlink(external_file_path) - def test_model_metadata_file(self): + def test_model_metadata_file(self, dummy_io): # Re-init so we don't have old data lying around from earlier test cases from aider import models @@ -817,14 +799,13 @@ def test_model_metadata_file(self): "--exit", "--yes-always", ], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) assert coder.main_model.info["max_input_tokens"] == 1234 - def test_sonnet_and_cache_options(self): + def test_sonnet_and_cache_options(self, dummy_io): with GitTemporaryDirectory(): with patch("aider.coders.base_coder.RepoMap") as MockRepoMap: mock_repo_map = MagicMock() @@ -833,60 +814,54 @@ def test_sonnet_and_cache_options(self): main( ["--sonnet", "--cache-prompts", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) MockRepoMap.assert_called_once() call_args, call_kwargs = MockRepoMap.call_args assert call_kwargs.get("refresh") == "files" # Check the 'refresh' keyword argument - def test_sonnet_and_cache_prompts_options(self): + def test_sonnet_and_cache_prompts_options(self, dummy_io): with GitTemporaryDirectory(): coder = main( ["--sonnet", "--cache-prompts", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) assert coder.add_cache_headers - def test_4o_and_cache_options(self): + def test_4o_and_cache_options(self, dummy_io): with GitTemporaryDirectory(): coder = main( ["--4o", "--cache-prompts", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) assert not coder.add_cache_headers - def test_return_coder(self): + def test_return_coder(self, dummy_io): with GitTemporaryDirectory(): result = main( ["--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) assert isinstance(result, Coder) result = main( ["--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=False, ) assert result == 0 - def test_map_mul_option(self): + def test_map_mul_option(self, dummy_io): with GitTemporaryDirectory(): coder = main( ["--map-mul", "5", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) assert isinstance(coder, Coder) @@ -911,15 +886,15 @@ def test_map_mul_option(self): "urls_enabled", ], ) - def test_boolean_flags(self, flag_arg, attr_name, expected): + def test_boolean_flags(self, flag_arg, attr_name, expected, dummy_io): with GitTemporaryDirectory(): args = ["--exit", "--yes-always"] if flag_arg: args.insert(0, flag_arg) - coder = main(args, input=DummyInput(), output=DummyOutput(), return_coder=True) + coder = main(args, **dummy_io, return_coder=True) assert getattr(coder, attr_name) == expected - def test_accepts_settings_warnings(self): + def test_accepts_settings_warnings(self, dummy_io): # Test that appropriate warnings are shown based on accepts_settings configuration with GitTemporaryDirectory(): # Test model that accepts the thinking_tokens setting @@ -936,8 +911,7 @@ def test_accepts_settings_warnings(self): "--yes-always", "--exit", ], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) # No warning should be shown as this model accepts thinking_tokens for call in mock_warning.call_args_list: @@ -960,8 +934,7 @@ def test_accepts_settings_warnings(self): "--yes-always", "--exit", ], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) # Warning should be shown warning_shown = False @@ -979,8 +952,7 @@ def test_accepts_settings_warnings(self): ): main( ["--model", "o1", "--reasoning-effort", "3", "--yes-always", "--exit"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) # No warning should be shown as this model accepts reasoning_effort for call in mock_warning.call_args_list: @@ -1002,8 +974,7 @@ def test_accepts_settings_warnings(self): "--yes-always", "--exit", ], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) # Warning should be shown warning_shown = False @@ -1015,7 +986,7 @@ def test_accepts_settings_warnings(self): mock_set_reasoning.assert_not_called() @patch("aider.models.ModelInfoManager.set_verify_ssl") - def test_no_verify_ssl_sets_model_info_manager(self, mock_set_verify_ssl): + def test_no_verify_ssl_sets_model_info_manager(self, mock_set_verify_ssl, dummy_io): with GitTemporaryDirectory(): # Mock Model class to avoid actual model initialization with patch("aider.models.Model") as mock_model: @@ -1031,12 +1002,11 @@ def test_no_verify_ssl_sets_model_info_manager(self, mock_set_verify_ssl): with patch("aider.models.fuzzy_match_models", return_value=[]): main( ["--no-verify-ssl", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) mock_set_verify_ssl.assert_called_once_with(False) - def test_pytest_env_vars(self): + def test_pytest_env_vars(self, dummy_io): # Verify that environment variables from pytest.ini are properly set assert os.environ.get("AIDER_ANALYTICS") == "false" @@ -1066,7 +1036,7 @@ def test_pytest_env_vars(self): ], ids=["single", "multiple", "with_spaces", "invalid_format"], ) - def test_set_env(self, set_env_args, expected_env, expected_result): + def test_set_env(self, set_env_args, expected_env, expected_result, dummy_io): with GitTemporaryDirectory(): args = set_env_args + ["--exit", "--yes-always"] result = main(args) @@ -1096,7 +1066,7 @@ def test_set_env(self, set_env_args, expected_env, expected_result): ], ids=["single", "multiple", "invalid_format"], ) - def test_api_key(self, api_key_args, expected_env, expected_result): + def test_api_key(self, api_key_args, expected_env, expected_result, dummy_io): with GitTemporaryDirectory(): args = api_key_args + ["--exit", "--yes-always"] result = main(args) @@ -1105,7 +1075,7 @@ def test_api_key(self, api_key_args, expected_env, expected_result): for env_var, expected_value in expected_env.items(): assert os.environ.get(env_var) == expected_value - def test_git_config_include(self): + def test_git_config_include(self, dummy_io): # Test that aider respects git config includes for user.name and user.email with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) @@ -1130,7 +1100,7 @@ def test_git_config_include(self): git_config_content = git_config_path.read_text() # Run aider and verify it doesn't change the git config - main(["--yes-always", "--exit"], input=DummyInput(), output=DummyOutput()) + main(["--yes-always", "--exit"], **dummy_io) # 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 @@ -1141,7 +1111,7 @@ def test_git_config_include(self): git_config_content_after = git_config_path.read_text() assert git_config_content == git_config_content_after - def test_git_config_include_directive(self): + def test_git_config_include_directive(self, dummy_io): # Test that aider respects the include directive in git config with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) @@ -1171,7 +1141,7 @@ def test_git_config_include_directive(self): assert repo.git.config("user.email") == "directive@example.com" # Run aider and verify it doesn't change the git config - main(["--yes-always", "--exit"], input=DummyInput(), output=DummyOutput()) + main(["--yes-always", "--exit"], **dummy_io) # Check that the git config file wasn't modified config_after_aider = git_config.read_text() @@ -1182,7 +1152,7 @@ def test_git_config_include_directive(self): assert repo.git.config("user.name") == "Directive User" assert repo.git.config("user.email") == "directive@example.com" - def test_resolve_aiderignore_path(self): + def test_resolve_aiderignore_path(self, dummy_io): # Import the function directly to test it from aider.args import resolve_aiderignore_path @@ -1199,15 +1169,14 @@ def test_resolve_aiderignore_path(self): rel_path = ".aiderignore" assert resolve_aiderignore_path(rel_path) == rel_path - def test_invalid_edit_format(self): + def test_invalid_edit_format(self, dummy_io): with GitTemporaryDirectory(): # Suppress stderr for this test as argparse prints an error message with patch("sys.stderr", new_callable=StringIO) as mock_stderr: with pytest.raises(SystemExit) as cm: _ = main( ["--edit-format", "not-a-real-format", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) # argparse.ArgumentParser.exit() is called with status 2 for invalid choice assert cm.value.code == 2 @@ -1226,7 +1195,7 @@ def test_invalid_edit_format(self): ], ids=["anthropic", "deepseek", "openrouter", "openai", "gemini"], ) - def test_default_model_selection(self, api_key_env, expected_model_substr): + def test_default_model_selection(self, api_key_env, expected_model_substr, dummy_io): with GitTemporaryDirectory(): # Save and clear all API keys to test each one in isolation saved_keys = {} @@ -1246,8 +1215,7 @@ def test_default_model_selection(self, api_key_env, expected_model_substr): os.environ[api_key_env] = "test-key" coder = main( ["--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) assert expected_model_substr in coder.main_model.name.lower() @@ -1258,7 +1226,7 @@ def test_default_model_selection(self, api_key_env, expected_model_substr): for key, value in saved_keys.items(): os.environ[key] = value - def test_default_model_selection_oauth_fallback(self): + def test_default_model_selection_oauth_fallback(self, dummy_io): # Test no API keys - should offer OpenRouter OAuth with GitTemporaryDirectory(): # Clear all API keys to simulate no configured keys @@ -1278,7 +1246,7 @@ def test_default_model_selection_oauth_fallback(self): try: with patch("aider.onboarding.offer_openrouter_oauth") as mock_offer_oauth: mock_offer_oauth.return_value = None # Simulate user declining or failure - result = main(["--exit", "--yes-always"], input=DummyInput(), output=DummyOutput()) + result = main(["--exit", "--yes-always"], **dummy_io) assert result == 1 # Expect failure since no model could be selected mock_offer_oauth.assert_called_once() finally: @@ -1286,22 +1254,21 @@ def test_default_model_selection_oauth_fallback(self): for key, value in saved_keys.items(): os.environ[key] = value - def test_model_precedence(self): + def test_model_precedence(self, dummy_io): with GitTemporaryDirectory(): # Test that earlier API keys take precedence os.environ["ANTHROPIC_API_KEY"] = "test-key" os.environ["OPENAI_API_KEY"] = "test-key" coder = main( ["--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) assert "sonnet" in coder.main_model.name.lower() del os.environ["ANTHROPIC_API_KEY"] del os.environ["OPENAI_API_KEY"] - def test_model_overrides_suffix_applied(self): + def test_model_overrides_suffix_applied(self, dummy_io): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) overrides_file = git_dir / ".aider.model.overrides.yml" @@ -1328,8 +1295,7 @@ def test_model_overrides_suffix_applied(self): main( ["--model", "gpt-4o:fast", "--exit", "--yes-always", "--no-git"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, force_git_root=git_dir, ) @@ -1350,7 +1316,7 @@ def test_model_overrides_suffix_applied(self): " {'temperature': 0.1}" ) - def test_model_overrides_no_match_preserves_model_name(self): + def test_model_overrides_no_match_preserves_model_name(self, dummy_io): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) @@ -1377,8 +1343,7 @@ def test_model_overrides_no_match_preserves_model_name(self): main( ["--model", model_name, "--exit", "--yes-always", "--no-git"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, force_git_root=git_dir, ) @@ -1394,35 +1359,33 @@ def test_model_overrides_no_match_preserves_model_name(self): " override_kwargs" ) - def test_chat_language_spanish(self): + def test_chat_language_spanish(self, dummy_io): with GitTemporaryDirectory(): coder = main( ["--chat-language", "Spanish", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) system_info = coder.get_platform_info() assert "Spanish" in system_info - def test_commit_language_japanese(self): + def test_commit_language_japanese(self, dummy_io): with GitTemporaryDirectory(): coder = main( ["--commit-language", "japanese", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) assert "japanese" in coder.commit_language @patch("git.Repo.init") - def test_main_exit_with_git_command_not_found(self, mock_git_init): + def test_main_exit_with_git_command_not_found(self, mock_git_init, dummy_io): mock_git_init.side_effect = git.exc.GitCommandNotFound("git", "Command 'git' not found") - result = main(["--exit", "--yes-always"], input=DummyInput(), output=DummyOutput()) + result = main(["--exit", "--yes-always"], **dummy_io) assert result == 0, "main() should return 0 (success) when called with --exit" - def test_reasoning_effort_option(self): + def test_reasoning_effort_option(self, dummy_io): coder = main( [ "--reasoning-effort", @@ -1431,22 +1394,20 @@ def test_reasoning_effort_option(self): "--yes-always", "--exit", ], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) assert coder.main_model.extra_params.get("extra_body", {}).get("reasoning_effort") == "3" - def test_thinking_tokens_option(self): + def test_thinking_tokens_option(self, dummy_io): coder = main( ["--model", "sonnet", "--thinking-tokens", "1000", "--yes-always", "--exit"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) assert coder.main_model.extra_params.get("thinking", {}).get("budget_tokens") == 1000 - def test_list_models_includes_metadata_models(self): + def test_list_models_includes_metadata_models(self, dummy_io): # Test that models from model-metadata.json appear in list-models output with GitTemporaryDirectory(): # Create a temporary model-metadata.json with test models @@ -1476,15 +1437,14 @@ def test_list_models_includes_metadata_models(self): "--yes-always", "--no-gitignore", ], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) output = mock_stdout.getvalue() # Check that the unique model name from our metadata file is listed assert "test-provider/unique-model-name" in output - def test_list_models_includes_all_model_sources(self): + def test_list_models_includes_all_model_sources(self, dummy_io): # Test that models from both litellm.model_cost and model-metadata.json # appear in list-models with GitTemporaryDirectory(): @@ -1510,8 +1470,7 @@ def test_list_models_includes_all_model_sources(self): "--yes-always", "--no-gitignore", ], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) output = mock_stdout.getvalue() @@ -1520,7 +1479,7 @@ def test_list_models_includes_all_model_sources(self): # Check that both models appear in the output assert "test-provider/metadata-only-model" in output - def test_check_model_accepts_settings_flag(self): + def test_check_model_accepts_settings_flag(self, dummy_io): # 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 @@ -1535,13 +1494,12 @@ def test_check_model_accepts_settings_flag(self): "--yes-always", "--exit", ], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) # Method should not be called because model doesn't support it and flag is on mock_set_thinking.assert_not_called() - def test_list_models_with_direct_resource_patch(self): + def test_list_models_with_direct_resource_patch(self, dummy_io): # Test that models from resources/model-metadata.json are included in list-models output with GitTemporaryDirectory(): # Create a temporary file with test model metadata @@ -1568,8 +1526,7 @@ def test_list_models_with_direct_resource_patch(self): with patch("sys.stdout", new_callable=StringIO) as mock_stdout: main( ["--list-models", "special", "--yes-always", "--no-gitignore"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) output = mock_stdout.getvalue() @@ -1588,13 +1545,12 @@ def test_list_models_with_direct_resource_patch(self): "--yes-always", "--exit", ], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) # Method should be called because flag is off mock_set_reasoning.assert_called_once_with("3") - def test_model_accepts_settings_attribute(self): + def test_model_accepts_settings_attribute(self, dummy_io): with GitTemporaryDirectory(): # Test with a model where we override the accepts_settings attribute with patch("aider.models.Model") as MockModel: @@ -1623,8 +1579,7 @@ def test_model_accepts_settings_attribute(self): "--yes-always", "--exit", ], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) # Only set_reasoning_effort should be called, not set_thinking_tokens @@ -1632,33 +1587,31 @@ def test_model_accepts_settings_attribute(self): mock_instance.set_thinking_tokens.assert_not_called() @patch("aider.main.InputOutput", autospec=True) - def test_stream_and_cache_warning(self, MockInputOutput): + def test_stream_and_cache_warning(self, MockInputOutput, dummy_io): mock_io_instance = MockInputOutput.return_value mock_io_instance.pretty = True with GitTemporaryDirectory(): main( ["--stream", "--cache-prompts", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) mock_io_instance.tool_warning.assert_called_with( "Cost estimates may be inaccurate when using streaming and caching." ) @patch("aider.main.InputOutput", autospec=True) - def test_stream_without_cache_no_warning(self, MockInputOutput): + def test_stream_without_cache_no_warning(self, MockInputOutput, dummy_io): mock_io_instance = MockInputOutput.return_value mock_io_instance.pretty = True with GitTemporaryDirectory(): main( ["--stream", "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) for call in mock_io_instance.tool_warning.call_args_list: assert "Cost estimates may be inaccurate" not in call[0][0] - def test_argv_file_respects_git(self): + def test_argv_file_respects_git(self, dummy_io): with GitTemporaryDirectory(): fname = Path("not_in_git.txt") fname.touch() @@ -1666,14 +1619,13 @@ def test_argv_file_respects_git(self): f.write("not_in_git.txt") coder = main( argv=["--file", "not_in_git.txt"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, return_coder=True, ) assert "not_in_git.txt" not in str(coder.abs_fnames) assert not asyncio.run(coder.allowed_to_edit("not_in_git.txt")) - def test_load_dotenv_files_override(self): + def test_load_dotenv_files_override(self, dummy_io): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) @@ -1731,20 +1683,19 @@ def test_load_dotenv_files_override(self): os.chdir(original_cwd) @patch("aider.main.InputOutput", autospec=True) - def test_cache_without_stream_no_warning(self, MockInputOutput): + def test_cache_without_stream_no_warning(self, MockInputOutput, dummy_io): mock_io_instance = MockInputOutput.return_value mock_io_instance.pretty = True with GitTemporaryDirectory(): main( ["--cache-prompts", "--exit", "--yes-always", "--no-stream"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) for call in mock_io_instance.tool_warning.call_args_list: assert "Cost estimates may be inaccurate" not in call[0][0] @patch("aider.coders.Coder.create") - def test_mcp_servers_parsing(self, mock_coder_create): + def test_mcp_servers_parsing(self, mock_coder_create, dummy_io): # Setup mock coder mock_coder_instance = MagicMock() mock_coder_instance._autosave_future = mock_autosave_future() @@ -1759,8 +1710,7 @@ def test_mcp_servers_parsing(self, mock_coder_create): "--exit", "--yes-always", ], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) # Verify that Coder.create was called with mcp_servers parameter @@ -1785,8 +1735,7 @@ def test_mcp_servers_parsing(self, mock_coder_create): main( ["--mcp-servers-file", str(mcp_file), "--exit", "--yes-always"], - input=DummyInput(), - output=DummyOutput(), + **dummy_io, ) # Verify that Coder.create was called with mcp_servers parameter From f4191b01071e909800468d320bafc3f40d4134ec Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:01:24 +0100 Subject: [PATCH 15/50] refactor: extract mock_coder fixture (Phase 3B.2) Create mock_coder fixture to eliminate duplicate Coder.create mock setup. Uses pytest-mock's mocker fixture for cleaner mocking. Updated 4 tests to use the new fixture: - test_main_with_git_config_yml - test_main_args (parametrized) - test_false_vals_in_env_file - test_true_vals_in_env_file All 92 tests pass. --- tests/basic/test_main.py | 70 +++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index e2672ac2dd1..e5e8abd006c 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -80,6 +80,15 @@ def dummy_io(): return {"input": DummyInput(), "output": DummyOutput()} +@pytest.fixture +def mock_coder(mocker): + """Provide a properly configured Mock Coder with autosave future.""" + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MockCoder.return_value + mock_coder_instance._autosave_future = mock_autosave_future() + return MockCoder + + class TestMain: def test_main_with_empty_dir_no_files_on_command(self, dummy_io): main(["--no-git", "--exit", "--yes-always"], **dummy_io) @@ -160,24 +169,20 @@ def test_main_copy_paste_flag_sets_mode(self, mock_watcher, dummy_io): assert coder.copy_paste_mode assert not coder.manual_copy_paste - def test_main_with_git_config_yml(self, dummy_io): + def test_main_with_git_config_yml(self, dummy_io, mock_coder): make_repo() Path(".aider.conf.yml").write_text("auto-commits: false\n") - with patch("aider.coders.Coder.create") as MockCoder: - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() - main(["--yes-always"], **dummy_io) - _, kwargs = MockCoder.call_args - assert kwargs["auto_commits"] is False + main(["--yes-always"], **dummy_io) + _, kwargs = mock_coder.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: - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() - main([], **dummy_io) - _, kwargs = MockCoder.call_args - assert kwargs["auto_commits"] is True + mock_coder.reset_mock() + mock_coder.return_value._autosave_future = mock_autosave_future() + main([], **dummy_io) + _, kwargs = mock_coder.call_args + assert kwargs["auto_commits"] is True def test_main_with_empty_git_dir_new_subdir_file(self, dummy_io): make_repo() @@ -350,14 +355,11 @@ def test_add_command_gitignore_files_flag(self, dummy_io): ], ids=["no_auto_commits", "auto_commits", "defaults", "no_dirty_commits", "dirty_commits"], ) - def test_main_args(self, args, expected_kwargs, dummy_io): - with patch("aider.coders.Coder.create") as MockCoder: - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() - main(args, input=DummyInput()) - _, kwargs = MockCoder.call_args - for key, expected_value in expected_kwargs.items(): - assert kwargs[key] is expected_value + def test_main_args(self, args, expected_kwargs, dummy_io, mock_coder): + main(args, **dummy_io) + _, kwargs = mock_coder.call_args + for key, expected_value in expected_kwargs.items(): + assert kwargs[key] is expected_value def test_env_file_override(self, dummy_io): with GitTemporaryDirectory() as git_dir: @@ -528,25 +530,19 @@ def test_default_env_file_sets_automatic_variable(self, dummy_io): _, kwargs = MockInputOutput.call_args assert kwargs["code_theme"] == "monokai" - def test_false_vals_in_env_file(self, dummy_io): + def test_false_vals_in_env_file(self, dummy_io, mock_coder): self.create_env_file(".env", "AIDER_SHOW_DIFFS=off") - with patch("aider.coders.Coder.create", autospec=True) as MockCoder: - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() - main(["--no-git", "--yes-always"], **dummy_io) - MockCoder.assert_called_once() - _, kwargs = MockCoder.call_args - assert kwargs["show_diffs"] is False + main(["--no-git", "--yes-always"], **dummy_io) + mock_coder.assert_called_once() + _, kwargs = mock_coder.call_args + assert kwargs["show_diffs"] is False - def test_true_vals_in_env_file(self, dummy_io): + def test_true_vals_in_env_file(self, dummy_io, mock_coder): self.create_env_file(".env", "AIDER_SHOW_DIFFS=on") - with patch("aider.coders.Coder.create") as MockCoder: - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() - main(["--no-git", "--yes-always"], **dummy_io) - MockCoder.assert_called_once() - _, kwargs = MockCoder.call_args - assert kwargs["show_diffs"] is True + main(["--no-git", "--yes-always"], **dummy_io) + mock_coder.assert_called_once() + _, kwargs = mock_coder.call_args + assert kwargs["show_diffs"] is True def test_lint_option(self, dummy_io): with GitTemporaryDirectory() as git_dir: From e4eaeed650f79c17751f36812179628bfd121880 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:12:23 +0100 Subject: [PATCH 16/50] refactor: add git_temp_dir fixture (Phase 3B.3) Created git_temp_dir fixture to provide temporary git directories: - Added fixture yielding GitTemporaryDirectory as Path object - Added git_temp_dir parameter to 57 test methods that need it - Removed 36 redundant GitTemporaryDirectory() context managers - Tests using self.tempdir (from test_env) excluded from fixture All 92 tests passing. --- tests/basic/test_main.py | 1263 +++++++++++++++++++------------------- 1 file changed, 617 insertions(+), 646 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index e5e8abd006c..a102179cb2d 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -89,6 +89,13 @@ def mock_coder(mocker): return MockCoder +@pytest.fixture +def git_temp_dir(): + """Provide a temporary git directory.""" + with GitTemporaryDirectory() as temp_dir: + yield Path(temp_dir) + + class TestMain: def test_main_with_empty_dir_no_files_on_command(self, dummy_io): main(["--no-git", "--exit", "--yes-always"], **dummy_io) @@ -113,7 +120,7 @@ def test_main_with_empty_git_dir_new_files(self, _, dummy_io): assert os.path.exists("foo.txt") assert os.path.exists("bar.txt") - def test_main_with_dname_and_fname(self, dummy_io): + def test_main_with_dname_and_fname(self, dummy_io, git_temp_dir): subdir = Path("subdir") subdir.mkdir() make_repo(str(subdir)) @@ -121,7 +128,7 @@ def test_main_with_dname_and_fname(self, dummy_io): assert res is not None @patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") - def test_main_with_subdir_repo_fnames(self, _, dummy_io): + def test_main_with_subdir_repo_fnames(self, _, dummy_io, git_temp_dir): subdir = Path("subdir") subdir.mkdir() make_repo(str(subdir)) @@ -132,7 +139,7 @@ def test_main_with_subdir_repo_fnames(self, _, dummy_io): assert (subdir / "foo.txt").exists() assert (subdir / "bar.txt").exists() - def test_main_copy_paste_model_overrides(self, dummy_io): + def test_main_copy_paste_model_overrides(self, dummy_io, git_temp_dir): overrides = json.dumps({"gpt-4o": {"fast": {"temperature": 0.42}}}) coder = main( [ @@ -154,7 +161,7 @@ def test_main_copy_paste_model_overrides(self, dummy_io): assert coder.main_model.override_kwargs == {"temperature": 0.42} @patch("aider.main.ClipboardWatcher") - def test_main_copy_paste_flag_sets_mode(self, mock_watcher, dummy_io): + def test_main_copy_paste_flag_sets_mode(self, mock_watcher, dummy_io, git_temp_dir): mock_watcher.return_value = MagicMock() coder = main( @@ -169,7 +176,7 @@ def test_main_copy_paste_flag_sets_mode(self, mock_watcher, dummy_io): assert coder.copy_paste_mode assert not coder.manual_copy_paste - def test_main_with_git_config_yml(self, dummy_io, mock_coder): + def test_main_with_git_config_yml(self, dummy_io, mock_coder, git_temp_dir): make_repo() Path(".aider.conf.yml").write_text("auto-commits: false\n") @@ -184,7 +191,7 @@ def test_main_with_git_config_yml(self, dummy_io, mock_coder): _, kwargs = mock_coder.call_args assert kwargs["auto_commits"] is True - def test_main_with_empty_git_dir_new_subdir_file(self, dummy_io): + def test_main_with_empty_git_dir_new_subdir_file(self, dummy_io, git_temp_dir): make_repo() subdir = Path("subdir") subdir.mkdir() @@ -210,31 +217,30 @@ def test_setup_git(self, dummy_io): assert gitignore.exists() assert ".aider*" == gitignore.read_text().splitlines()[0] - def test_check_gitignore(self, dummy_io): - with GitTemporaryDirectory(): - os.environ["GIT_CONFIG_GLOBAL"] = "globalgitconfig" + def test_check_gitignore(self, dummy_io, git_temp_dir): + os.environ["GIT_CONFIG_GLOBAL"] = "globalgitconfig" - io = InputOutput(pretty=False, yes=True) - cwd = Path.cwd() - gitignore = cwd / ".gitignore" + io = InputOutput(pretty=False, yes=True) + cwd = Path.cwd() + gitignore = cwd / ".gitignore" - assert not gitignore.exists() - asyncio.run(check_gitignore(cwd, io)) - assert gitignore.exists() + assert not gitignore.exists() + asyncio.run(check_gitignore(cwd, io)) + assert gitignore.exists() - assert ".aider*" == gitignore.read_text().splitlines()[0] + assert ".aider*" == gitignore.read_text().splitlines()[0] - # Test without .env file present - gitignore.write_text("one\ntwo\n") - asyncio.run(check_gitignore(cwd, io)) - assert "one\ntwo\n.aider*\n" == gitignore.read_text() + # Test without .env file present + gitignore.write_text("one\ntwo\n") + asyncio.run(check_gitignore(cwd, io)) + assert "one\ntwo\n.aider*\n" == gitignore.read_text() - # Test with .env file present - env_file = cwd / ".env" - env_file.touch() - asyncio.run(check_gitignore(cwd, io)) - assert "one\ntwo\n.aider*\n.env\n" == gitignore.read_text() - del os.environ["GIT_CONFIG_GLOBAL"] + # Test with .env file present + env_file = cwd / ".env" + env_file.touch() + asyncio.run(check_gitignore(cwd, io)) + assert "one\ntwo\n.aider*\n.env\n" == gitignore.read_text() + del os.environ["GIT_CONFIG_GLOBAL"] def test_command_line_gitignore_files_flag(self, dummy_io): with GitTemporaryDirectory() as git_dir: @@ -355,13 +361,13 @@ def test_add_command_gitignore_files_flag(self, dummy_io): ], ids=["no_auto_commits", "auto_commits", "defaults", "no_dirty_commits", "dirty_commits"], ) - def test_main_args(self, args, expected_kwargs, dummy_io, mock_coder): + def test_main_args(self, args, expected_kwargs, dummy_io, mock_coder, git_temp_dir): main(args, **dummy_io) _, kwargs = mock_coder.call_args for key, expected_value in expected_kwargs.items(): assert kwargs[key] is expected_value - def test_env_file_override(self, dummy_io): + def test_env_file_override(self, dummy_io, git_temp_dir): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) git_env = git_dir / ".env" @@ -393,7 +399,7 @@ def test_env_file_override(self, dummy_io): assert os.environ["D"] == "home" assert os.environ["E"] == "existing" - def test_message_file_flag(self, dummy_io): + def test_message_file_flag(self, dummy_io, git_temp_dir): 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: @@ -419,14 +425,13 @@ async def mock_run(*args, **kwargs): os.remove(message_file_path) - def test_encodings_arg(self, dummy_io): + def test_encodings_arg(self, dummy_io, git_temp_dir): fname = "foo.py" - with GitTemporaryDirectory(): - with patch("aider.coders.Coder.create") as MockCoder: - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() - with patch("aider.main.InputOutput") as MockSend: + with patch("aider.coders.Coder.create") as MockCoder: + mock_coder_instance = MockCoder.return_value + mock_coder_instance._autosave_future = mock_autosave_future() + with patch("aider.main.InputOutput") as MockSend: def side_effect(*args, **kwargs): assert kwargs["encoding"] == "iso-8859-15" @@ -438,16 +443,15 @@ def side_effect(*args, **kwargs): main(["--yes-always", fname, "--encoding", "iso-8859-15"]) - def test_main_exit_calls_version_check(self, dummy_io): - with GitTemporaryDirectory(): - with ( - patch("aider.main.check_version") as mock_check_version, - patch("aider.main.InputOutput") as mock_input_output, - ): - mock_input_output.return_value.confirm_ask = AsyncMock(return_value=True) - main(["--exit", "--check-update"], **dummy_io) - mock_check_version.assert_called_once() - mock_input_output.assert_called_once() + def test_main_exit_calls_version_check(self, dummy_io, git_temp_dir): + with ( + patch("aider.main.check_version") as mock_check_version, + patch("aider.main.InputOutput") as mock_input_output, + ): + mock_input_output.return_value.confirm_ask = AsyncMock(return_value=True) + main(["--exit", "--check-update"], **dummy_io) + mock_check_version.assert_called_once() + mock_input_output.assert_called_once() @patch("aider.main.InputOutput", autospec=True) @patch("aider.coders.base_coder.Coder.run") @@ -488,7 +492,7 @@ def test_default_yes(self, mock_run, MockInputOutput, dummy_io): ], ids=["dark_mode", "light_mode"], ) - def test_mode_sets_code_theme(self, mode_flag, expected_theme, dummy_io): + def test_mode_sets_code_theme(self, mode_flag, expected_theme, dummy_io, git_temp_dir): # Mock InputOutput to capture the configuration with patch("aider.main.InputOutput") as MockInputOutput: MockInputOutput.return_value.get_input.return_value = None @@ -544,7 +548,7 @@ def test_true_vals_in_env_file(self, dummy_io, mock_coder): _, kwargs = mock_coder.call_args assert kwargs["show_diffs"] is True - def test_lint_option(self, dummy_io): + def test_lint_option(self, dummy_io, git_temp_dir): with GitTemporaryDirectory() as git_dir: # Create a dirty file in the root dirty_file = Path("dirty_file.py") @@ -577,61 +581,59 @@ def test_lint_option(self, dummy_io): assert called_arg.endswith("dirty_file.py") assert not called_arg.endswith(f"subdir{os.path.sep}dirty_file.py") - def test_lint_option_with_explicit_files(self, dummy_io): - with GitTemporaryDirectory(): - # Create two files - file1 = Path("file1.py") - file1.write_text("def foo(): pass") - file2 = Path("file2.py") - file2.write_text("def bar(): pass") + def test_lint_option_with_explicit_files(self, dummy_io, git_temp_dir): + # Create two files + file1 = Path("file1.py") + file1.write_text("def foo(): pass") + file2 = Path("file2.py") + file2.write_text("def bar(): pass") - # Mock the Linter class - with patch("aider.linter.Linter.lint") as MockLinter: - MockLinter.return_value = "" + # Mock the Linter class + with patch("aider.linter.Linter.lint") as MockLinter: + MockLinter.return_value = "" - # Run main with --lint and explicit files - main( - ["--lint", "file1.py", "file2.py", "--yes-always"], - **dummy_io, - ) + # Run main with --lint and explicit files + main( + ["--lint", "file1.py", "file2.py", "--yes-always"], + **dummy_io, + ) - # Check if the Linter was called twice (once for each file) - assert MockLinter.call_count == 2 + # Check if the Linter was called twice (once for each file) + assert MockLinter.call_count == 2 - # Check that both files were linted - called_files = [call[0][0] for call in MockLinter.call_args_list] - assert any(f.endswith("file1.py") for f in called_files) - assert any(f.endswith("file2.py") for f in called_files) + # Check that both files were linted + called_files = [call[0][0] for call in MockLinter.call_args_list] + assert any(f.endswith("file1.py") for f in called_files) + assert any(f.endswith("file2.py") for f in called_files) - def test_lint_option_with_glob_pattern(self, dummy_io): - with GitTemporaryDirectory(): - # Create multiple Python files - file1 = Path("test1.py") - file1.write_text("def foo(): pass") - file2 = Path("test2.py") - file2.write_text("def bar(): pass") - file3 = Path("readme.txt") - file3.write_text("not a python file") + def test_lint_option_with_glob_pattern(self, dummy_io, git_temp_dir): + # Create multiple Python files + file1 = Path("test1.py") + file1.write_text("def foo(): pass") + file2 = Path("test2.py") + file2.write_text("def bar(): pass") + file3 = Path("readme.txt") + file3.write_text("not a python file") - # Mock the Linter class - with patch("aider.linter.Linter.lint") as MockLinter: - MockLinter.return_value = "" + # Mock the Linter class + with patch("aider.linter.Linter.lint") as MockLinter: + MockLinter.return_value = "" - # Run main with --lint and glob pattern - main( - ["--lint", "test*.py", "--yes-always"], - **dummy_io, - ) + # Run main with --lint and glob pattern + main( + ["--lint", "test*.py", "--yes-always"], + **dummy_io, + ) - # Check if the Linter was called for Python files matching the glob - assert MockLinter.call_count >= 2 + # Check if the Linter was called for Python files matching the glob + assert MockLinter.call_count >= 2 - # Check that Python files were linted - called_files = [call[0][0] for call in MockLinter.call_args_list] - assert any(f.endswith("test1.py") for f in called_files) - assert any(f.endswith("test2.py") for f in called_files) - # Check that non-Python file was not linted - assert not any(f.endswith("readme.txt") for f in called_files) + # Check that Python files were linted + called_files = [call[0][0] for call in MockLinter.call_args_list] + assert any(f.endswith("test1.py") for f in called_files) + assert any(f.endswith("test2.py") for f in called_files) + # Check that non-Python file was not linted + assert not any(f.endswith("readme.txt") for f in called_files) def test_verbose_mode_lists_env_vars(self, dummy_io): self.create_env_file(".env", "AIDER_DARK_MODE=on") @@ -653,7 +655,7 @@ def test_verbose_mode_lists_env_vars(self, dummy_io): assert re.search(r"AIDER_DARK_MODE:\s+on", relevant_output) assert re.search(r"dark_mode:\s+True", relevant_output) - def test_yaml_config_file_loading(self, dummy_io): + def test_yaml_config_file_loading(self, dummy_io, git_temp_dir): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) @@ -718,58 +720,54 @@ def test_yaml_config_file_loading(self, dummy_io): assert kwargs["main_model"].name == "gpt-3.5-turbo" assert kwargs["map_tokens"] == 1024 - def test_map_tokens_option(self, dummy_io): - with GitTemporaryDirectory(): - with patch("aider.coders.base_coder.RepoMap") as MockRepoMap: - MockRepoMap.return_value.max_map_tokens = 0 - main( - ["--model", "gpt-4", "--map-tokens", "0", "--exit", "--yes-always"], - **dummy_io, - ) - MockRepoMap.assert_not_called() - - def test_map_tokens_option_with_non_zero_value(self, dummy_io): - with GitTemporaryDirectory(): - with patch("aider.coders.base_coder.RepoMap") as MockRepoMap: - MockRepoMap.return_value.max_map_tokens = 1000 - main( - ["--model", "gpt-4", "--map-tokens", "1000", "--exit", "--yes-always"], - **dummy_io, - ) - MockRepoMap.assert_called_once() - - def test_read_option(self, dummy_io): - with GitTemporaryDirectory(): - test_file = "test_file.txt" - Path(test_file).touch() + def test_map_tokens_option(self, dummy_io, git_temp_dir): + with patch("aider.coders.base_coder.RepoMap") as MockRepoMap: + MockRepoMap.return_value.max_map_tokens = 0 + main( + ["--model", "gpt-4", "--map-tokens", "0", "--exit", "--yes-always"], + **dummy_io, + ) + MockRepoMap.assert_not_called() - coder = main( - ["--read", test_file, "--exit", "--yes-always"], + def test_map_tokens_option_with_non_zero_value(self, dummy_io, git_temp_dir): + with patch("aider.coders.base_coder.RepoMap") as MockRepoMap: + MockRepoMap.return_value.max_map_tokens = 1000 + main( + ["--model", "gpt-4", "--map-tokens", "1000", "--exit", "--yes-always"], **dummy_io, - return_coder=True, ) + MockRepoMap.assert_called_once() + + def test_read_option(self, dummy_io, git_temp_dir): + test_file = "test_file.txt" + Path(test_file).touch() - assert str(Path(test_file).resolve()) in coder.abs_read_only_fnames + coder = main( + ["--read", test_file, "--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) + + assert str(Path(test_file).resolve()) in coder.abs_read_only_fnames - def test_read_option_with_external_file(self, dummy_io): + def test_read_option_with_external_file(self, dummy_io, git_temp_dir): 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 = main( - ["--read", external_file_path, "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) + coder = main( + ["--read", external_file_path, "--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) - real_external_file_path = os.path.realpath(external_file_path) - assert real_external_file_path in coder.abs_read_only_fnames + real_external_file_path = os.path.realpath(external_file_path) + assert real_external_file_path in coder.abs_read_only_fnames finally: os.unlink(external_file_path) - def test_model_metadata_file(self, dummy_io): + def test_model_metadata_file(self, dummy_io, git_temp_dir): # Re-init so we don't have old data lying around from earlier test cases from aider import models @@ -779,89 +777,83 @@ def test_model_metadata_file(self, dummy_io): litellm._lazy_module = None - with GitTemporaryDirectory(): - metadata_file = Path(".aider.model.metadata.json") + 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)) + # 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 = main( - [ - "--model", - "deepseek/deepseek-chat", - "--model-metadata-file", - str(metadata_file), - "--exit", - "--yes-always", - ], - **dummy_io, - return_coder=True, - ) - - assert coder.main_model.info["max_input_tokens"] == 1234 - - def test_sonnet_and_cache_options(self, dummy_io): - 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 + coder = main( + [ + "--model", + "deepseek/deepseek-chat", + "--model-metadata-file", + str(metadata_file), + "--exit", + "--yes-always", + ], + **dummy_io, + return_coder=True, + ) - main( - ["--sonnet", "--cache-prompts", "--exit", "--yes-always"], - **dummy_io, - ) + assert coder.main_model.info["max_input_tokens"] == 1234 - MockRepoMap.assert_called_once() - call_args, call_kwargs = MockRepoMap.call_args - assert call_kwargs.get("refresh") == "files" # Check the 'refresh' keyword argument + def test_sonnet_and_cache_options(self, dummy_io, git_temp_dir): + 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 - def test_sonnet_and_cache_prompts_options(self, dummy_io): - with GitTemporaryDirectory(): - coder = main( + main( ["--sonnet", "--cache-prompts", "--exit", "--yes-always"], **dummy_io, - return_coder=True, ) - assert coder.add_cache_headers + MockRepoMap.assert_called_once() + call_args, call_kwargs = MockRepoMap.call_args + assert call_kwargs.get("refresh") == "files" # Check the 'refresh' keyword argument - def test_4o_and_cache_options(self, dummy_io): - with GitTemporaryDirectory(): - coder = main( - ["--4o", "--cache-prompts", "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) + def test_sonnet_and_cache_prompts_options(self, dummy_io, git_temp_dir): + coder = main( + ["--sonnet", "--cache-prompts", "--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) - assert not coder.add_cache_headers + assert coder.add_cache_headers - def test_return_coder(self, dummy_io): - with GitTemporaryDirectory(): - result = main( - ["--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) - assert isinstance(result, Coder) + def test_4o_and_cache_options(self, dummy_io, git_temp_dir): + coder = main( + ["--4o", "--cache-prompts", "--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) - result = main( - ["--exit", "--yes-always"], - **dummy_io, - return_coder=False, - ) - assert result == 0 + assert not coder.add_cache_headers - def test_map_mul_option(self, dummy_io): - with GitTemporaryDirectory(): - coder = main( - ["--map-mul", "5", "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) - assert isinstance(coder, Coder) - assert coder.repo_map.map_mul_no_files == 5 + def test_return_coder(self, dummy_io, git_temp_dir): + result = main( + ["--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) + assert isinstance(result, Coder) + + result = main( + ["--exit", "--yes-always"], + **dummy_io, + return_coder=False, + ) + assert result == 0 + + def test_map_mul_option(self, dummy_io, git_temp_dir): + coder = main( + ["--map-mul", "5", "--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) + assert isinstance(coder, Coder) + assert coder.repo_map.map_mul_no_files == 5 @pytest.mark.parametrize( "flag_arg,attr_name,expected", @@ -882,127 +874,124 @@ def test_map_mul_option(self, dummy_io): "urls_enabled", ], ) - def test_boolean_flags(self, flag_arg, attr_name, expected, dummy_io): - with GitTemporaryDirectory(): - args = ["--exit", "--yes-always"] - if flag_arg: - args.insert(0, flag_arg) - coder = main(args, **dummy_io, return_coder=True) - assert getattr(coder, attr_name) == expected - - def test_accepts_settings_warnings(self, dummy_io): + def test_boolean_flags(self, flag_arg, attr_name, expected, dummy_io, git_temp_dir): + args = ["--exit", "--yes-always"] + if flag_arg: + args.insert(0, flag_arg) + coder = main(args, **dummy_io, return_coder=True) + assert getattr(coder, attr_name) == expected + + def test_accepts_settings_warnings(self, dummy_io, git_temp_dir): # 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, - ): - main( - [ - "--model", - "anthropic/claude-3-7-sonnet-20250219", - "--thinking-tokens", - "1000", - "--yes-always", - "--exit", - ], - **dummy_io, - ) - # No warning should be shown as this model accepts thinking_tokens - for call in mock_warning.call_args_list: - assert "thinking_tokens" not in call[0][0] - # Method should be called - mock_set_thinking.assert_called_once_with("1000") + # 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, + ): + main( + [ + "--model", + "anthropic/claude-3-7-sonnet-20250219", + "--thinking-tokens", + "1000", + "--yes-always", + "--exit", + ], + **dummy_io, + ) + # No warning should be shown as this model accepts thinking_tokens + for call in mock_warning.call_args_list: + assert "thinking_tokens" not in 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, + ): + main( + [ + "--model", + "gpt-4o", + "--thinking-tokens", + "1000", + "--check-model-accepts-settings", + "--yes-always", + "--exit", + ], + **dummy_io, + ) + # 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 + assert 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, + ): + main( + ["--model", "o1", "--reasoning-effort", "3", "--yes-always", "--exit"], + **dummy_io, + ) + # No warning should be shown as this model accepts reasoning_effort + for call in mock_warning.call_args_list: + assert "reasoning_effort" not in 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, + ): + main( + [ + "--model", + "gpt-3.5-turbo", + "--reasoning-effort", + "3", + "--yes-always", + "--exit", + ], + **dummy_io, + ) + # 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 + assert warning_shown + # Method should still be called by default + mock_set_reasoning.assert_not_called() - # 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, - ): - main( - [ - "--model", - "gpt-4o", - "--thinking-tokens", - "1000", - "--check-model-accepts-settings", - "--yes-always", - "--exit", - ], - **dummy_io, - ) - # 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 - assert 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, - ): - main( - ["--model", "o1", "--reasoning-effort", "3", "--yes-always", "--exit"], - **dummy_io, - ) - # No warning should be shown as this model accepts reasoning_effort - for call in mock_warning.call_args_list: - assert "reasoning_effort" not in call[0][0] - # Method should be called - mock_set_reasoning.assert_called_once_with("3") + @patch("aider.models.ModelInfoManager.set_verify_ssl") + def test_no_verify_ssl_sets_model_info_manager(self, mock_set_verify_ssl, dummy_io, git_temp_dir): + # 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": [], + } - # 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, - ): + # Mock fuzzy_match_models to avoid string operations on MagicMock + with patch("aider.models.fuzzy_match_models", return_value=[]): main( - [ - "--model", - "gpt-3.5-turbo", - "--reasoning-effort", - "3", - "--yes-always", - "--exit", - ], + ["--no-verify-ssl", "--exit", "--yes-always"], **dummy_io, ) - # 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 - assert warning_shown - # Method should still be called by default - mock_set_reasoning.assert_not_called() - - @patch("aider.models.ModelInfoManager.set_verify_ssl") - def test_no_verify_ssl_sets_model_info_manager(self, mock_set_verify_ssl, dummy_io): - 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_set_verify_ssl.assert_called_once_with(False) - # Mock fuzzy_match_models to avoid string operations on MagicMock - with patch("aider.models.fuzzy_match_models", return_value=[]): - main( - ["--no-verify-ssl", "--exit", "--yes-always"], - **dummy_io, - ) - mock_set_verify_ssl.assert_called_once_with(False) - - def test_pytest_env_vars(self, dummy_io): + def test_pytest_env_vars(self, dummy_io, git_temp_dir): # Verify that environment variables from pytest.ini are properly set assert os.environ.get("AIDER_ANALYTICS") == "false" @@ -1032,14 +1021,13 @@ def test_pytest_env_vars(self, dummy_io): ], ids=["single", "multiple", "with_spaces", "invalid_format"], ) - def test_set_env(self, set_env_args, expected_env, expected_result, dummy_io): - with GitTemporaryDirectory(): - args = set_env_args + ["--exit", "--yes-always"] - result = main(args) - if expected_result is not None: - assert result == expected_result - for env_var, expected_value in expected_env.items(): - assert os.environ.get(env_var) == expected_value + def test_set_env(self, set_env_args, expected_env, expected_result, dummy_io, git_temp_dir): + args = set_env_args + ["--exit", "--yes-always"] + result = main(args) + if expected_result is not None: + assert result == expected_result + for env_var, expected_value in expected_env.items(): + assert os.environ.get(env_var) == expected_value @pytest.mark.parametrize( "api_key_args,expected_env,expected_result", @@ -1062,16 +1050,15 @@ def test_set_env(self, set_env_args, expected_env, expected_result, dummy_io): ], ids=["single", "multiple", "invalid_format"], ) - def test_api_key(self, api_key_args, expected_env, expected_result, dummy_io): - with GitTemporaryDirectory(): - args = api_key_args + ["--exit", "--yes-always"] - result = main(args) - if expected_result is not None: - assert result == expected_result - for env_var, expected_value in expected_env.items(): - assert os.environ.get(env_var) == expected_value - - def test_git_config_include(self, dummy_io): + def test_api_key(self, api_key_args, expected_env, expected_result, dummy_io, git_temp_dir): + args = api_key_args + ["--exit", "--yes-always"] + result = main(args) + if expected_result is not None: + assert result == expected_result + for env_var, expected_value in expected_env.items(): + assert os.environ.get(env_var) == expected_value + + def test_git_config_include(self, dummy_io, git_temp_dir): # Test that aider respects git config includes for user.name and user.email with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) @@ -1107,7 +1094,7 @@ def test_git_config_include(self, dummy_io): git_config_content_after = git_config_path.read_text() assert git_config_content == git_config_content_after - def test_git_config_include_directive(self, dummy_io): + def test_git_config_include_directive(self, dummy_io, git_temp_dir): # Test that aider respects the include directive in git config with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) @@ -1148,7 +1135,7 @@ def test_git_config_include_directive(self, dummy_io): assert repo.git.config("user.name") == "Directive User" assert repo.git.config("user.email") == "directive@example.com" - def test_resolve_aiderignore_path(self, dummy_io): + def test_resolve_aiderignore_path(self, dummy_io, git_temp_dir): # Import the function directly to test it from aider.args import resolve_aiderignore_path @@ -1165,20 +1152,19 @@ def test_resolve_aiderignore_path(self, dummy_io): rel_path = ".aiderignore" assert resolve_aiderignore_path(rel_path) == rel_path - def test_invalid_edit_format(self, dummy_io): - with GitTemporaryDirectory(): - # Suppress stderr for this test as argparse prints an error message - with patch("sys.stderr", new_callable=StringIO) as mock_stderr: - with pytest.raises(SystemExit) as cm: - _ = main( - ["--edit-format", "not-a-real-format", "--exit", "--yes-always"], - **dummy_io, - ) - # argparse.ArgumentParser.exit() is called with status 2 for invalid choice - assert cm.value.code == 2 - stderr_output = mock_stderr.getvalue() - assert "invalid choice" in stderr_output - assert "not-a-real-format" in stderr_output + def test_invalid_edit_format(self, dummy_io, git_temp_dir): + # Suppress stderr for this test as argparse prints an error message + with patch("sys.stderr", new_callable=StringIO) as mock_stderr: + with pytest.raises(SystemExit) as cm: + _ = main( + ["--edit-format", "not-a-real-format", "--exit", "--yes-always"], + **dummy_io, + ) + # argparse.ArgumentParser.exit() is called with status 2 for invalid choice + assert cm.value.code == 2 + stderr_output = mock_stderr.getvalue() + assert "invalid choice" in stderr_output + assert "not-a-real-format" in stderr_output @pytest.mark.parametrize( "api_key_env,expected_model_substr", @@ -1191,80 +1177,77 @@ def test_invalid_edit_format(self, dummy_io): ], ids=["anthropic", "deepseek", "openrouter", "openai", "gemini"], ) - def test_default_model_selection(self, api_key_env, expected_model_substr, dummy_io): - with GitTemporaryDirectory(): - # Save and clear all API keys to test each one in isolation - saved_keys = {} - api_keys = [ - "ANTHROPIC_API_KEY", - "DEEPSEEK_API_KEY", - "OPENROUTER_API_KEY", - "OPENAI_API_KEY", - "GEMINI_API_KEY", - ] - for key in api_keys: - if key in os.environ: - saved_keys[key] = os.environ[key] - del os.environ[key] - - try: - os.environ[api_key_env] = "test-key" - coder = main( - ["--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) - assert expected_model_substr in coder.main_model.name.lower() - finally: - # Restore saved API keys - if api_key_env in os.environ: - del os.environ[api_key_env] - for key, value in saved_keys.items(): - os.environ[key] = value - - def test_default_model_selection_oauth_fallback(self, dummy_io): - # Test no API keys - should offer OpenRouter OAuth - with GitTemporaryDirectory(): - # Clear all API keys to simulate no configured keys - saved_keys = {} - api_keys = [ - "ANTHROPIC_API_KEY", - "DEEPSEEK_API_KEY", - "OPENROUTER_API_KEY", - "OPENAI_API_KEY", - "GEMINI_API_KEY", - ] - for key in api_keys: - if key in os.environ: - saved_keys[key] = os.environ[key] - del os.environ[key] + def test_default_model_selection(self, api_key_env, expected_model_substr, dummy_io, git_temp_dir): + # Save and clear all API keys to test each one in isolation + saved_keys = {} + api_keys = [ + "ANTHROPIC_API_KEY", + "DEEPSEEK_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + ] + for key in api_keys: + if key in os.environ: + saved_keys[key] = os.environ[key] + del os.environ[key] - try: - with patch("aider.onboarding.offer_openrouter_oauth") as mock_offer_oauth: - mock_offer_oauth.return_value = None # Simulate user declining or failure - result = main(["--exit", "--yes-always"], **dummy_io) - assert result == 1 # Expect failure since no model could be selected - mock_offer_oauth.assert_called_once() - finally: - # Restore saved API keys - for key, value in saved_keys.items(): - os.environ[key] = value - - def test_model_precedence(self, dummy_io): - with GitTemporaryDirectory(): - # Test that earlier API keys take precedence - os.environ["ANTHROPIC_API_KEY"] = "test-key" - os.environ["OPENAI_API_KEY"] = "test-key" + try: + os.environ[api_key_env] = "test-key" coder = main( ["--exit", "--yes-always"], **dummy_io, return_coder=True, ) - assert "sonnet" in coder.main_model.name.lower() - del os.environ["ANTHROPIC_API_KEY"] - del os.environ["OPENAI_API_KEY"] + assert expected_model_substr in coder.main_model.name.lower() + finally: + # Restore saved API keys + if api_key_env in os.environ: + del os.environ[api_key_env] + for key, value in saved_keys.items(): + os.environ[key] = value + + def test_default_model_selection_oauth_fallback(self, dummy_io, git_temp_dir): + # Test no API keys - should offer OpenRouter OAuth + # Clear all API keys to simulate no configured keys + saved_keys = {} + api_keys = [ + "ANTHROPIC_API_KEY", + "DEEPSEEK_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + ] + for key in api_keys: + if key in os.environ: + saved_keys[key] = os.environ[key] + del os.environ[key] - def test_model_overrides_suffix_applied(self, dummy_io): + try: + with patch("aider.onboarding.offer_openrouter_oauth") as mock_offer_oauth: + mock_offer_oauth.return_value = None # Simulate user declining or failure + result = main(["--exit", "--yes-always"], **dummy_io) + assert result == 1 # Expect failure since no model could be selected + mock_offer_oauth.assert_called_once() + finally: + # Restore saved API keys + for key, value in saved_keys.items(): + os.environ[key] = value + + def test_model_precedence(self, dummy_io, git_temp_dir): + # Test that earlier API keys take precedence + os.environ["ANTHROPIC_API_KEY"] = "test-key" + os.environ["OPENAI_API_KEY"] = "test-key" + coder = main( + ["--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) + assert "sonnet" in coder.main_model.name.lower() + del os.environ["ANTHROPIC_API_KEY"] + del os.environ["OPENAI_API_KEY"] + + def test_model_overrides_suffix_applied(self, dummy_io, git_temp_dir): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) overrides_file = git_dir / ".aider.model.overrides.yml" @@ -1312,7 +1295,7 @@ def test_model_overrides_suffix_applied(self, dummy_io): " {'temperature': 0.1}" ) - def test_model_overrides_no_match_preserves_model_name(self, dummy_io): + def test_model_overrides_no_match_preserves_model_name(self, dummy_io, git_temp_dir): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) @@ -1355,33 +1338,31 @@ def test_model_overrides_no_match_preserves_model_name(self, dummy_io): " override_kwargs" ) - def test_chat_language_spanish(self, dummy_io): - with GitTemporaryDirectory(): - coder = main( - ["--chat-language", "Spanish", "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) - system_info = coder.get_platform_info() - assert "Spanish" in system_info + def test_chat_language_spanish(self, dummy_io, git_temp_dir): + coder = main( + ["--chat-language", "Spanish", "--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) + system_info = coder.get_platform_info() + assert "Spanish" in system_info - def test_commit_language_japanese(self, dummy_io): - with GitTemporaryDirectory(): - coder = main( - ["--commit-language", "japanese", "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) - assert "japanese" in coder.commit_language + def test_commit_language_japanese(self, dummy_io, git_temp_dir): + coder = main( + ["--commit-language", "japanese", "--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) + assert "japanese" in coder.commit_language @patch("git.Repo.init") - def test_main_exit_with_git_command_not_found(self, mock_git_init, dummy_io): + def test_main_exit_with_git_command_not_found(self, mock_git_init, dummy_io, git_temp_dir): mock_git_init.side_effect = git.exc.GitCommandNotFound("git", "Command 'git' not found") result = main(["--exit", "--yes-always"], **dummy_io) assert result == 0, "main() should return 0 (success) when called with --exit" - def test_reasoning_effort_option(self, dummy_io): + def test_reasoning_effort_option(self, dummy_io, git_temp_dir): coder = main( [ "--reasoning-effort", @@ -1395,7 +1376,7 @@ def test_reasoning_effort_option(self, dummy_io): ) assert coder.main_model.extra_params.get("extra_body", {}).get("reasoning_effort") == "3" - def test_thinking_tokens_option(self, dummy_io): + def test_thinking_tokens_option(self, dummy_io, git_temp_dir): coder = main( ["--model", "sonnet", "--thinking-tokens", "1000", "--yes-always", "--exit"], **dummy_io, @@ -1403,225 +1384,217 @@ def test_thinking_tokens_option(self, dummy_io): ) assert coder.main_model.extra_params.get("thinking", {}).get("budget_tokens") == 1000 - def test_list_models_includes_metadata_models(self, dummy_io): + def test_list_models_includes_metadata_models(self, dummy_io, git_temp_dir): # 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: - main( - [ - "--list-models", - "unique-model", - "--model-metadata-file", - str(metadata_file), - "--yes-always", - "--no-gitignore", - ], - **dummy_io, - ) - output = mock_stdout.getvalue() + # 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: + main( + [ + "--list-models", + "unique-model", + "--model-metadata-file", + str(metadata_file), + "--yes-always", + "--no-gitignore", + ], + **dummy_io, + ) + output = mock_stdout.getvalue() - # Check that the unique model name from our metadata file is listed - assert "test-provider/unique-model-name" in output + # Check that the unique model name from our metadata file is listed + assert "test-provider/unique-model-name" in output - def test_list_models_includes_all_model_sources(self, dummy_io): + def test_list_models_includes_all_model_sources(self, dummy_io, git_temp_dir): # 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 - } + # 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)) + } + metadata_file.write_text(json.dumps(test_models)) - # Capture stdout to check the output - with patch("sys.stdout", new_callable=StringIO) as mock_stdout: - main( - [ - "--list-models", - "metadata-only-model", - "--model-metadata-file", - str(metadata_file), - "--yes-always", - "--no-gitignore", - ], - **dummy_io, - ) - output = mock_stdout.getvalue() + # Capture stdout to check the output + with patch("sys.stdout", new_callable=StringIO) as mock_stdout: + main( + [ + "--list-models", + "metadata-only-model", + "--model-metadata-file", + str(metadata_file), + "--yes-always", + "--no-gitignore", + ], + **dummy_io, + ) + output = mock_stdout.getvalue() - dump(output) + dump(output) - # Check that both models appear in the output - assert "test-provider/metadata-only-model" in output + # Check that both models appear in the output + assert "test-provider/metadata-only-model" in output - def test_check_model_accepts_settings_flag(self, dummy_io): + def test_check_model_accepts_settings_flag(self, dummy_io, git_temp_dir): # 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: - main( - [ - "--model", - "gpt-4o", - "--thinking-tokens", - "1000", - "--check-model-accepts-settings", - "--yes-always", - "--exit", - ], - **dummy_io, - ) - # Method should not be called because model doesn't support it and flag is on - mock_set_thinking.assert_not_called() + # 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: + main( + [ + "--model", + "gpt-4o", + "--thinking-tokens", + "1000", + "--check-model-accepts-settings", + "--yes-always", + "--exit", + ], + **dummy_io, + ) + # Method should not be called because model doesn't support it and flag is on + mock_set_thinking.assert_not_called() def test_list_models_with_direct_resource_patch(self, dummy_io): # 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", - } + # 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: - main( - ["--list-models", "special", "--yes-always", "--no-gitignore"], - **dummy_io, - ) - output = mock_stdout.getvalue() - - # Check that the resource model appears in the output - assert "resource-provider/special-model" in output - - # When flag is off, setting should be applied regardless of support - with patch("aider.models.Model.set_reasoning_effort") as mock_set_reasoning: - main( - [ - "--model", - "gpt-3.5-turbo", - "--reasoning-effort", - "3", - "--no-check-model-accepts-settings", - "--yes-always", - "--exit", - ], - **dummy_io, - ) - # Method should be called because flag is off - mock_set_reasoning.assert_called_once_with("3") + } + test_file.write_text(json.dumps(test_resource_models)) - def test_model_accepts_settings_attribute(self, dummy_io): - 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 + # 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 - # Run with both settings, but model only accepts reasoning_effort + 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: main( - [ - "--model", - "test-model", - "--reasoning-effort", - "3", - "--thinking-tokens", - "1000", - "--check-model-accepts-settings", - "--yes-always", - "--exit", - ], + ["--list-models", "special", "--yes-always", "--no-gitignore"], **dummy_io, ) + output = mock_stdout.getvalue() - # 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() + # Check that the resource model appears in the output + assert "resource-provider/special-model" in output - @patch("aider.main.InputOutput", autospec=True) - def test_stream_and_cache_warning(self, MockInputOutput, dummy_io): - mock_io_instance = MockInputOutput.return_value - mock_io_instance.pretty = True - with GitTemporaryDirectory(): + # When flag is off, setting should be applied regardless of support + with patch("aider.models.Model.set_reasoning_effort") as mock_set_reasoning: main( - ["--stream", "--cache-prompts", "--exit", "--yes-always"], + [ + "--model", + "gpt-3.5-turbo", + "--reasoning-effort", + "3", + "--no-check-model-accepts-settings", + "--yes-always", + "--exit", + ], **dummy_io, ) + # Method should be called because flag is off + mock_set_reasoning.assert_called_once_with("3") + + def test_model_accepts_settings_attribute(self, dummy_io, git_temp_dir): + # 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 + main( + [ + "--model", + "test-model", + "--reasoning-effort", + "3", + "--thinking-tokens", + "1000", + "--check-model-accepts-settings", + "--yes-always", + "--exit", + ], + **dummy_io, + ) + + # 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", autospec=True) + def test_stream_and_cache_warning(self, MockInputOutput, dummy_io, git_temp_dir): + mock_io_instance = MockInputOutput.return_value + mock_io_instance.pretty = True + main( + ["--stream", "--cache-prompts", "--exit", "--yes-always"], + **dummy_io, + ) mock_io_instance.tool_warning.assert_called_with( "Cost estimates may be inaccurate when using streaming and caching." ) @patch("aider.main.InputOutput", autospec=True) - def test_stream_without_cache_no_warning(self, MockInputOutput, dummy_io): + def test_stream_without_cache_no_warning(self, MockInputOutput, dummy_io, git_temp_dir): mock_io_instance = MockInputOutput.return_value mock_io_instance.pretty = True - with GitTemporaryDirectory(): - main( - ["--stream", "--exit", "--yes-always"], - **dummy_io, - ) + main( + ["--stream", "--exit", "--yes-always"], + **dummy_io, + ) for call in mock_io_instance.tool_warning.call_args_list: assert "Cost estimates may be inaccurate" not in call[0][0] - def test_argv_file_respects_git(self, dummy_io): - with GitTemporaryDirectory(): - fname = Path("not_in_git.txt") - fname.touch() - with open(".gitignore", "w+") as f: - f.write("not_in_git.txt") - coder = main( - argv=["--file", "not_in_git.txt"], - **dummy_io, - return_coder=True, - ) - assert "not_in_git.txt" not in str(coder.abs_fnames) - assert not asyncio.run(coder.allowed_to_edit("not_in_git.txt")) + def test_argv_file_respects_git(self, dummy_io, git_temp_dir): + fname = Path("not_in_git.txt") + fname.touch() + with open(".gitignore", "w+") as f: + f.write("not_in_git.txt") + coder = main( + argv=["--file", "not_in_git.txt"], + **dummy_io, + return_coder=True, + ) + assert "not_in_git.txt" not in str(coder.abs_fnames) + assert not asyncio.run(coder.allowed_to_edit("not_in_git.txt")) - def test_load_dotenv_files_override(self, dummy_io): + def test_load_dotenv_files_override(self, dummy_io, git_temp_dir): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) @@ -1679,45 +1652,43 @@ def test_load_dotenv_files_override(self, dummy_io): os.chdir(original_cwd) @patch("aider.main.InputOutput", autospec=True) - def test_cache_without_stream_no_warning(self, MockInputOutput, dummy_io): + def test_cache_without_stream_no_warning(self, MockInputOutput, dummy_io, git_temp_dir): mock_io_instance = MockInputOutput.return_value mock_io_instance.pretty = True - with GitTemporaryDirectory(): - main( - ["--cache-prompts", "--exit", "--yes-always", "--no-stream"], - **dummy_io, - ) + main( + ["--cache-prompts", "--exit", "--yes-always", "--no-stream"], + **dummy_io, + ) for call in mock_io_instance.tool_warning.call_args_list: assert "Cost estimates may be inaccurate" not in call[0][0] @patch("aider.coders.Coder.create") - def test_mcp_servers_parsing(self, mock_coder_create, dummy_io): + def test_mcp_servers_parsing(self, mock_coder_create, dummy_io, git_temp_dir): # Setup mock coder mock_coder_instance = MagicMock() mock_coder_instance._autosave_future = mock_autosave_future() mock_coder_create.return_value = mock_coder_instance # Test with --mcp-servers option - with GitTemporaryDirectory(): - main( - [ - "--mcp-servers", - '{"mcpServers":{"git":{"command":"uvx","args":["mcp-server-git"]}}}', - "--exit", - "--yes-always", - ], - **dummy_io, - ) + main( + [ + "--mcp-servers", + '{"mcpServers":{"git":{"command":"uvx","args":["mcp-server-git"]}}}', + "--exit", + "--yes-always", + ], + **dummy_io, + ) - # Verify that Coder.create was called with mcp_servers parameter - mock_coder_create.assert_called_once() - _, kwargs = mock_coder_create.call_args - assert "mcp_servers" in kwargs - assert kwargs["mcp_servers"] is not None - # At least one server should be in the list - assert len(kwargs["mcp_servers"]) > 0 - # First server should have a name attribute - assert hasattr(kwargs["mcp_servers"][0], "name") + # Verify that Coder.create was called with mcp_servers parameter + mock_coder_create.assert_called_once() + _, kwargs = mock_coder_create.call_args + assert "mcp_servers" in kwargs + assert kwargs["mcp_servers"] is not None + # At least one server should be in the list + assert len(kwargs["mcp_servers"]) > 0 + # First server should have a name attribute + assert hasattr(kwargs["mcp_servers"][0], "name") # Test with --mcp-servers-file option mock_coder_create.reset_mock() From f3cd1bc782125d2f6602fdeeb60b2d6d6197513f Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:13:57 +0100 Subject: [PATCH 17/50] refactor: convert create_env_file to factory fixture (Phase 3B.4) Created create_env_file factory fixture: - Converted helper method to pytest factory fixture - Uses Path.cwd() to create env files in current test directory - Updated 5 calls across 5 test methods to use fixture - Removed old TestMain.create_env_file method All 92 tests passing. --- tests/basic/test_main.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index a102179cb2d..b8bf3f75075 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -96,6 +96,16 @@ def git_temp_dir(): yield Path(temp_dir) +@pytest.fixture +def create_env_file(): + """Factory fixture to create environment files in the current test directory.""" + def _create_env_file(file_name, content): + env_file_path = Path.cwd() / file_name + env_file_path.write_text(content) + return env_file_path + return _create_env_file + + class TestMain: def test_main_with_empty_dir_no_files_on_command(self, dummy_io): main(["--no-git", "--exit", "--yes-always"], **dummy_io) @@ -503,13 +513,8 @@ def test_mode_sets_code_theme(self, mode_flag, expected_theme, dummy_io, git_tem _, kwargs = MockInputOutput.call_args assert kwargs["code_theme"] == expected_theme - 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 - - def test_env_file_flag_sets_automatic_variable(self, dummy_io): - env_file_path = self.create_env_file(".env.test", "AIDER_DARK_MODE=True") + def test_env_file_flag_sets_automatic_variable(self, dummy_io, create_env_file): + env_file_path = 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 @@ -522,8 +527,8 @@ def test_env_file_flag_sets_automatic_variable(self, dummy_io): _, kwargs = MockInputOutput.call_args assert kwargs["code_theme"] == "monokai" - def test_default_env_file_sets_automatic_variable(self, dummy_io): - self.create_env_file(".env", "AIDER_DARK_MODE=True") + def test_default_env_file_sets_automatic_variable(self, dummy_io, create_env_file): + 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 @@ -534,15 +539,15 @@ def test_default_env_file_sets_automatic_variable(self, dummy_io): _, kwargs = MockInputOutput.call_args assert kwargs["code_theme"] == "monokai" - def test_false_vals_in_env_file(self, dummy_io, mock_coder): - self.create_env_file(".env", "AIDER_SHOW_DIFFS=off") + def test_false_vals_in_env_file(self, dummy_io, mock_coder, create_env_file): + create_env_file(".env", "AIDER_SHOW_DIFFS=off") main(["--no-git", "--yes-always"], **dummy_io) mock_coder.assert_called_once() _, kwargs = mock_coder.call_args assert kwargs["show_diffs"] is False - def test_true_vals_in_env_file(self, dummy_io, mock_coder): - self.create_env_file(".env", "AIDER_SHOW_DIFFS=on") + def test_true_vals_in_env_file(self, dummy_io, mock_coder, create_env_file): + create_env_file(".env", "AIDER_SHOW_DIFFS=on") main(["--no-git", "--yes-always"], **dummy_io) mock_coder.assert_called_once() _, kwargs = mock_coder.call_args @@ -635,8 +640,8 @@ def test_lint_option_with_glob_pattern(self, dummy_io, git_temp_dir): # Check that non-Python file was not linted assert not any(f.endswith("readme.txt") for f in called_files) - def test_verbose_mode_lists_env_vars(self, dummy_io): - self.create_env_file(".env", "AIDER_DARK_MODE=on") + def test_verbose_mode_lists_env_vars(self, dummy_io, create_env_file): + create_env_file(".env", "AIDER_DARK_MODE=on") with patch("sys.stdout", new_callable=StringIO) as mock_stdout: main( ["--no-git", "--verbose", "--exit", "--yes-always"], From 6fce41b3cc84263c865e97959e61fa027991ecbe Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:17:54 +0100 Subject: [PATCH 18/50] refactor: convert @patch decorators to mocker (Phase 3C.1a) Converted all 16 @patch decorators to pytest-mock mocker.patch(): - Removed @patch decorators from test methods - Added mocker parameter to test signatures - Replaced unittest.mock.patch with mocker.patch calls - Tests with multiple decorators now use multiple mocker.patch calls All 92 tests passing. --- tests/basic/test_main.py | 88 ++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index b8bf3f75075..fa52d2ad392 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -114,14 +114,14 @@ def test_main_with_emptqy_dir_new_file(self, dummy_io): main(["foo.txt", "--yes-always", "--no-git", "--exit"], **dummy_io) assert os.path.exists("foo.txt") - @patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") - def test_main_with_empty_git_dir_new_file(self, _, dummy_io): + def test_main_with_empty_git_dir_new_file(self, dummy_io, mocker): + mocker.patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") make_repo() main(["--yes-always", "foo.txt", "--exit"], **dummy_io) assert os.path.exists("foo.txt") - @patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") - def test_main_with_empty_git_dir_new_files(self, _, dummy_io): + def test_main_with_empty_git_dir_new_files(self, dummy_io, mocker): + mocker.patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") make_repo() main( ["--yes-always", "foo.txt", "bar.txt", "--exit"], @@ -137,8 +137,8 @@ def test_main_with_dname_and_fname(self, dummy_io, git_temp_dir): res = main(["subdir", "foo.txt"], **dummy_io) assert res is not None - @patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") - def test_main_with_subdir_repo_fnames(self, _, dummy_io, git_temp_dir): + def test_main_with_subdir_repo_fnames(self, dummy_io, git_temp_dir, mocker): + mocker.patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") subdir = Path("subdir") subdir.mkdir() make_repo(str(subdir)) @@ -170,8 +170,8 @@ def test_main_copy_paste_model_overrides(self, dummy_io, git_temp_dir): assert coder.main_model.copy_paste_transport == "clipboard" assert coder.main_model.override_kwargs == {"temperature": 0.42} - @patch("aider.main.ClipboardWatcher") - def test_main_copy_paste_flag_sets_mode(self, mock_watcher, dummy_io, git_temp_dir): + def test_main_copy_paste_flag_sets_mode(self, dummy_io, git_temp_dir, mocker): + mock_watcher = mocker.patch("aider.main.ClipboardWatcher") mock_watcher.return_value = MagicMock() coder = main( @@ -463,9 +463,9 @@ def test_main_exit_calls_version_check(self, dummy_io, git_temp_dir): mock_check_version.assert_called_once() mock_input_output.assert_called_once() - @patch("aider.main.InputOutput", autospec=True) - @patch("aider.coders.base_coder.Coder.run") - def test_main_message_adds_to_input_history(self, mock_run, MockInputOutput, dummy_io): + def test_main_message_adds_to_input_history(self, dummy_io, mocker): + mock_run = mocker.patch("aider.coders.base_coder.Coder.run") + MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) test_message = "test message" mock_io_instance = MockInputOutput.return_value mock_io_instance.pretty = True @@ -474,9 +474,9 @@ def test_main_message_adds_to_input_history(self, mock_run, MockInputOutput, dum mock_io_instance.add_to_input_history.assert_called_once_with(test_message) - @patch("aider.main.InputOutput", autospec=True) - @patch("aider.coders.base_coder.Coder.run") - def test_yes(self, mock_run, MockInputOutput, dummy_io): + def test_yes(self, dummy_io, mocker): + mock_run = mocker.patch("aider.coders.base_coder.Coder.run") + MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) test_message = "test message" MockInputOutput.return_value.pretty = True @@ -484,9 +484,9 @@ def test_yes(self, mock_run, MockInputOutput, dummy_io): args, kwargs = MockInputOutput.call_args assert args[1] - @patch("aider.main.InputOutput", autospec=True) - @patch("aider.coders.base_coder.Coder.run") - def test_default_yes(self, mock_run, MockInputOutput, dummy_io): + def test_default_yes(self, dummy_io, mocker): + mock_run = mocker.patch("aider.coders.base_coder.Coder.run") + MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) test_message = "test message" MockInputOutput.return_value.pretty = True @@ -976,25 +976,25 @@ def test_accepts_settings_warnings(self, dummy_io, git_temp_dir): # Method should still be called by default mock_set_reasoning.assert_not_called() - @patch("aider.models.ModelInfoManager.set_verify_ssl") - def test_no_verify_ssl_sets_model_info_manager(self, mock_set_verify_ssl, dummy_io, git_temp_dir): + def test_no_verify_ssl_sets_model_info_manager(self, dummy_io, git_temp_dir, mocker): + mock_set_verify_ssl = mocker.patch("aider.models.ModelInfoManager.set_verify_ssl") # 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_model = mocker.patch("aider.models.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=[]): - main( - ["--no-verify-ssl", "--exit", "--yes-always"], - **dummy_io, - ) - mock_set_verify_ssl.assert_called_once_with(False) + # Mock fuzzy_match_models to avoid string operations on MagicMock + mocker.patch("aider.models.fuzzy_match_models", return_value=[]) + main( + ["--no-verify-ssl", "--exit", "--yes-always"], + **dummy_io, + ) + mock_set_verify_ssl.assert_called_once_with(False) def test_pytest_env_vars(self, dummy_io, git_temp_dir): # Verify that environment variables from pytest.ini are properly set @@ -1360,8 +1360,8 @@ def test_commit_language_japanese(self, dummy_io, git_temp_dir): ) assert "japanese" in coder.commit_language - @patch("git.Repo.init") - def test_main_exit_with_git_command_not_found(self, mock_git_init, dummy_io, git_temp_dir): + def test_main_exit_with_git_command_not_found(self, dummy_io, git_temp_dir, mocker): + mock_git_init = mocker.patch("git.Repo.init") mock_git_init.side_effect = git.exc.GitCommandNotFound("git", "Command 'git' not found") result = main(["--exit", "--yes-always"], **dummy_io) @@ -1563,8 +1563,8 @@ def test_model_accepts_settings_attribute(self, dummy_io, git_temp_dir): mock_instance.set_reasoning_effort.assert_called_once_with("3") mock_instance.set_thinking_tokens.assert_not_called() - @patch("aider.main.InputOutput", autospec=True) - def test_stream_and_cache_warning(self, MockInputOutput, dummy_io, git_temp_dir): + def test_stream_and_cache_warning(self, dummy_io, git_temp_dir, mocker): + MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) mock_io_instance = MockInputOutput.return_value mock_io_instance.pretty = True main( @@ -1575,8 +1575,8 @@ def test_stream_and_cache_warning(self, MockInputOutput, dummy_io, git_temp_dir) "Cost estimates may be inaccurate when using streaming and caching." ) - @patch("aider.main.InputOutput", autospec=True) - def test_stream_without_cache_no_warning(self, MockInputOutput, dummy_io, git_temp_dir): + def test_stream_without_cache_no_warning(self, dummy_io, git_temp_dir, mocker): + MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) mock_io_instance = MockInputOutput.return_value mock_io_instance.pretty = True main( @@ -1656,8 +1656,8 @@ def test_load_dotenv_files_override(self, dummy_io, git_temp_dir): # Restore CWD os.chdir(original_cwd) - @patch("aider.main.InputOutput", autospec=True) - def test_cache_without_stream_no_warning(self, MockInputOutput, dummy_io, git_temp_dir): + def test_cache_without_stream_no_warning(self, dummy_io, git_temp_dir, mocker): + MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) mock_io_instance = MockInputOutput.return_value mock_io_instance.pretty = True main( @@ -1667,9 +1667,9 @@ def test_cache_without_stream_no_warning(self, MockInputOutput, dummy_io, git_te for call in mock_io_instance.tool_warning.call_args_list: assert "Cost estimates may be inaccurate" not in call[0][0] - @patch("aider.coders.Coder.create") - def test_mcp_servers_parsing(self, mock_coder_create, dummy_io, git_temp_dir): + def test_mcp_servers_parsing(self, dummy_io, git_temp_dir, mocker): # Setup mock coder + mock_coder_create = mocker.patch("aider.coders.Coder.create") mock_coder_instance = MagicMock() mock_coder_instance._autosave_future = mock_autosave_future() mock_coder_create.return_value = mock_coder_instance From 15725e78419483af0717c54c07667ec163184e50 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:20:10 +0100 Subject: [PATCH 19/50] refactor: convert with patch to mocker (Phase 3C.1b) Converted 5 additional `with patch` context managers to pytest-mock: - test_message_file_flag - test_encodings_arg (nested patches) - test_mode_sets_code_theme - test_env_file_flag_sets_automatic_variable - test_default_env_file_sets_automatic_variable Progress: 21/40 total patch usages converted to mocker. All 92 tests passing. --- tests/basic/test_main.py | 114 +++++++++++++++++++-------------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index fa52d2ad392..476980a93cf 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -409,7 +409,7 @@ def test_env_file_override(self, dummy_io, git_temp_dir): assert os.environ["D"] == "home" assert os.environ["E"] == "existing" - def test_message_file_flag(self, dummy_io, git_temp_dir): + def test_message_file_flag(self, dummy_io, git_temp_dir, mocker): 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: @@ -419,39 +419,39 @@ def test_message_file_flag(self, dummy_io, git_temp_dir): 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() - mock_coder_instance._autosave_future = mock_autosave_future() - MockCoder.return_value = mock_coder_instance + MockCoder = mocker.patch("aider.coders.Coder.create") + # Create a mock coder instance with an async run method + mock_coder_instance = MagicMock() + mock_coder_instance.run = AsyncMock() + mock_coder_instance._autosave_future = mock_autosave_future() + MockCoder.return_value = mock_coder_instance - main( - ["--yes-always", "--message-file", message_file_path], - **dummy_io, - ) - # Check that run was called with the correct message - mock_coder_instance.run.assert_called_once_with(with_message=message_file_content) + main( + ["--yes-always", "--message-file", message_file_path], + **dummy_io, + ) + # 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) - def test_encodings_arg(self, dummy_io, git_temp_dir): + def test_encodings_arg(self, dummy_io, git_temp_dir, mocker): fname = "foo.py" - with patch("aider.coders.Coder.create") as MockCoder: - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() - with patch("aider.main.InputOutput") as MockSend: + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MockCoder.return_value + mock_coder_instance._autosave_future = mock_autosave_future() + MockSend = mocker.patch("aider.main.InputOutput") - def side_effect(*args, **kwargs): - assert kwargs["encoding"] == "iso-8859-15" - mock_io = MagicMock() - mock_io.confirm_ask = AsyncMock(return_value=True) - return mock_io + def side_effect(*args, **kwargs): + assert kwargs["encoding"] == "iso-8859-15" + mock_io = MagicMock() + mock_io.confirm_ask = AsyncMock(return_value=True) + return mock_io - MockSend.side_effect = side_effect + MockSend.side_effect = side_effect - main(["--yes-always", fname, "--encoding", "iso-8859-15"]) + main(["--yes-always", fname, "--encoding", "iso-8859-15"]) def test_main_exit_calls_version_check(self, dummy_io, git_temp_dir): with ( @@ -502,42 +502,42 @@ def test_default_yes(self, dummy_io, mocker): ], ids=["dark_mode", "light_mode"], ) - def test_mode_sets_code_theme(self, mode_flag, expected_theme, dummy_io, git_temp_dir): + def test_mode_sets_code_theme(self, mode_flag, expected_theme, dummy_io, git_temp_dir, mocker): # Mock InputOutput to capture the configuration - with patch("aider.main.InputOutput") as MockInputOutput: - MockInputOutput.return_value.get_input.return_value = None - main([mode_flag, "--no-git", "--exit"], **dummy_io) - # Ensure InputOutput was called - MockInputOutput.assert_called_once() - # Check if the code_theme setting matches expected - _, kwargs = MockInputOutput.call_args - assert kwargs["code_theme"] == expected_theme - - def test_env_file_flag_sets_automatic_variable(self, dummy_io, create_env_file): + MockInputOutput = mocker.patch("aider.main.InputOutput") + MockInputOutput.return_value.get_input.return_value = None + main([mode_flag, "--no-git", "--exit"], **dummy_io) + # Ensure InputOutput was called + MockInputOutput.assert_called_once() + # Check if the code_theme setting matches expected + _, kwargs = MockInputOutput.call_args + assert kwargs["code_theme"] == expected_theme + + def test_env_file_flag_sets_automatic_variable(self, dummy_io, create_env_file, mocker): env_file_path = 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 - main( - ["--env-file", str(env_file_path), "--no-git", "--exit"], - **dummy_io, - ) - MockInputOutput.assert_called_once() - # Check if the color settings are for dark mode - _, kwargs = MockInputOutput.call_args - assert kwargs["code_theme"] == "monokai" + MockInputOutput = mocker.patch("aider.main.InputOutput") + MockInputOutput.return_value.get_input.return_value = None + MockInputOutput.return_value.get_input.confirm_ask = True + main( + ["--env-file", str(env_file_path), "--no-git", "--exit"], + **dummy_io, + ) + MockInputOutput.assert_called_once() + # Check if the color settings are for dark mode + _, kwargs = MockInputOutput.call_args + assert kwargs["code_theme"] == "monokai" - def test_default_env_file_sets_automatic_variable(self, dummy_io, create_env_file): + def test_default_env_file_sets_automatic_variable(self, dummy_io, create_env_file, mocker): 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 - main(["--no-git", "--exit"], **dummy_io) - # Ensure InputOutput was called - MockInputOutput.assert_called_once() - # Check if the color settings are for dark mode - _, kwargs = MockInputOutput.call_args - assert kwargs["code_theme"] == "monokai" + MockInputOutput = mocker.patch("aider.main.InputOutput") + MockInputOutput.return_value.get_input.return_value = None + MockInputOutput.return_value.get_input.confirm_ask = True + main(["--no-git", "--exit"], **dummy_io) + # Ensure InputOutput was called + MockInputOutput.assert_called_once() + # Check if the color settings are for dark mode + _, kwargs = MockInputOutput.call_args + assert kwargs["code_theme"] == "monokai" def test_false_vals_in_env_file(self, dummy_io, mock_coder, create_env_file): create_env_file(".env", "AIDER_SHOW_DIFFS=off") From 5f51ae1e800a1740cb1dc191e10fee70cb777216 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:22:30 +0100 Subject: [PATCH 20/50] refactor: convert more with patch to mocker (Phase 3C.1c) Converted 5 additional `with patch` context managers to pytest-mock: - test_lint_option - test_lint_option_with_explicit_files - test_lint_option_with_glob_pattern - test_map_tokens_option - test_map_tokens_option_with_non_zero_value Progress: 27/40 total patch usages converted to mocker (67.5%). All 92 tests passing. --- tests/basic/test_main.py | 114 +++++++++++++++++++-------------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 476980a93cf..0717d66262b 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -553,7 +553,7 @@ def test_true_vals_in_env_file(self, dummy_io, mock_coder, create_env_file): _, kwargs = mock_coder.call_args assert kwargs["show_diffs"] is True - def test_lint_option(self, dummy_io, git_temp_dir): + def test_lint_option(self, dummy_io, git_temp_dir, mocker): with GitTemporaryDirectory() as git_dir: # Create a dirty file in the root dirty_file = Path("dirty_file.py") @@ -573,20 +573,20 @@ def test_lint_option(self, dummy_io, git_temp_dir): os.chdir(subdir) # Mock the Linter class - with patch("aider.linter.Linter.lint") as MockLinter: - MockLinter.return_value = "" + MockLinter = mocker.patch("aider.linter.Linter.lint") + MockLinter.return_value = "" - # Run main with --lint option - main(["--lint", "--yes-always"], **dummy_io) + # Run main with --lint option + main(["--lint", "--yes-always"], **dummy_io) - # 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] - assert called_arg.endswith("dirty_file.py") - assert not called_arg.endswith(f"subdir{os.path.sep}dirty_file.py") + # 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] + assert called_arg.endswith("dirty_file.py") + assert not called_arg.endswith(f"subdir{os.path.sep}dirty_file.py") - def test_lint_option_with_explicit_files(self, dummy_io, git_temp_dir): + def test_lint_option_with_explicit_files(self, dummy_io, git_temp_dir, mocker): # Create two files file1 = Path("file1.py") file1.write_text("def foo(): pass") @@ -594,24 +594,24 @@ def test_lint_option_with_explicit_files(self, dummy_io, git_temp_dir): file2.write_text("def bar(): pass") # Mock the Linter class - with patch("aider.linter.Linter.lint") as MockLinter: - MockLinter.return_value = "" + MockLinter = mocker.patch("aider.linter.Linter.lint") + MockLinter.return_value = "" - # Run main with --lint and explicit files - main( - ["--lint", "file1.py", "file2.py", "--yes-always"], - **dummy_io, - ) + # Run main with --lint and explicit files + main( + ["--lint", "file1.py", "file2.py", "--yes-always"], + **dummy_io, + ) - # Check if the Linter was called twice (once for each file) - assert MockLinter.call_count == 2 + # Check if the Linter was called twice (once for each file) + assert MockLinter.call_count == 2 - # Check that both files were linted - called_files = [call[0][0] for call in MockLinter.call_args_list] - assert any(f.endswith("file1.py") for f in called_files) - assert any(f.endswith("file2.py") for f in called_files) + # Check that both files were linted + called_files = [call[0][0] for call in MockLinter.call_args_list] + assert any(f.endswith("file1.py") for f in called_files) + assert any(f.endswith("file2.py") for f in called_files) - def test_lint_option_with_glob_pattern(self, dummy_io, git_temp_dir): + def test_lint_option_with_glob_pattern(self, dummy_io, git_temp_dir, mocker): # Create multiple Python files file1 = Path("test1.py") file1.write_text("def foo(): pass") @@ -621,24 +621,24 @@ def test_lint_option_with_glob_pattern(self, dummy_io, git_temp_dir): file3.write_text("not a python file") # Mock the Linter class - with patch("aider.linter.Linter.lint") as MockLinter: - MockLinter.return_value = "" + MockLinter = mocker.patch("aider.linter.Linter.lint") + MockLinter.return_value = "" - # Run main with --lint and glob pattern - main( - ["--lint", "test*.py", "--yes-always"], - **dummy_io, - ) + # Run main with --lint and glob pattern + main( + ["--lint", "test*.py", "--yes-always"], + **dummy_io, + ) - # Check if the Linter was called for Python files matching the glob - assert MockLinter.call_count >= 2 + # Check if the Linter was called for Python files matching the glob + assert MockLinter.call_count >= 2 - # Check that Python files were linted - called_files = [call[0][0] for call in MockLinter.call_args_list] - assert any(f.endswith("test1.py") for f in called_files) - assert any(f.endswith("test2.py") for f in called_files) - # Check that non-Python file was not linted - assert not any(f.endswith("readme.txt") for f in called_files) + # Check that Python files were linted + called_files = [call[0][0] for call in MockLinter.call_args_list] + assert any(f.endswith("test1.py") for f in called_files) + assert any(f.endswith("test2.py") for f in called_files) + # Check that non-Python file was not linted + assert not any(f.endswith("readme.txt") for f in called_files) def test_verbose_mode_lists_env_vars(self, dummy_io, create_env_file): create_env_file(".env", "AIDER_DARK_MODE=on") @@ -725,23 +725,23 @@ def test_yaml_config_file_loading(self, dummy_io, git_temp_dir): assert kwargs["main_model"].name == "gpt-3.5-turbo" assert kwargs["map_tokens"] == 1024 - def test_map_tokens_option(self, dummy_io, git_temp_dir): - with patch("aider.coders.base_coder.RepoMap") as MockRepoMap: - MockRepoMap.return_value.max_map_tokens = 0 - main( - ["--model", "gpt-4", "--map-tokens", "0", "--exit", "--yes-always"], - **dummy_io, - ) - MockRepoMap.assert_not_called() + def test_map_tokens_option(self, dummy_io, git_temp_dir, mocker): + MockRepoMap = mocker.patch("aider.coders.base_coder.RepoMap") + MockRepoMap.return_value.max_map_tokens = 0 + main( + ["--model", "gpt-4", "--map-tokens", "0", "--exit", "--yes-always"], + **dummy_io, + ) + MockRepoMap.assert_not_called() - def test_map_tokens_option_with_non_zero_value(self, dummy_io, git_temp_dir): - with patch("aider.coders.base_coder.RepoMap") as MockRepoMap: - MockRepoMap.return_value.max_map_tokens = 1000 - main( - ["--model", "gpt-4", "--map-tokens", "1000", "--exit", "--yes-always"], - **dummy_io, - ) - MockRepoMap.assert_called_once() + def test_map_tokens_option_with_non_zero_value(self, dummy_io, git_temp_dir, mocker): + MockRepoMap = mocker.patch("aider.coders.base_coder.RepoMap") + MockRepoMap.return_value.max_map_tokens = 1000 + main( + ["--model", "gpt-4", "--map-tokens", "1000", "--exit", "--yes-always"], + **dummy_io, + ) + MockRepoMap.assert_called_once() def test_read_option(self, dummy_io, git_temp_dir): test_file = "test_file.txt" From 6f1c4df10d4bf4c91a04f02a6e056798ac4c1714 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:24:13 +0100 Subject: [PATCH 21/50] refactor: convert additional with patch to mocker (Phase 3C.1d) Converted 2 more `with patch` context managers to pytest-mock: - test_sonnet_and_cache_options (RepoMap) - test_verbose_mode_lists_env_vars (sys.stdout with StringIO) Progress: 29/40 total patch usages converted to mocker (72.5%). All 92 tests passing. --- tests/basic/test_main.py | 60 ++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 0717d66262b..b538f043a95 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -640,25 +640,25 @@ def test_lint_option_with_glob_pattern(self, dummy_io, git_temp_dir, mocker): # Check that non-Python file was not linted assert not any(f.endswith("readme.txt") for f in called_files) - def test_verbose_mode_lists_env_vars(self, dummy_io, create_env_file): + def test_verbose_mode_lists_env_vars(self, dummy_io, create_env_file, mocker): create_env_file(".env", "AIDER_DARK_MODE=on") - with patch("sys.stdout", new_callable=StringIO) as mock_stdout: - main( - ["--no-git", "--verbose", "--exit", "--yes-always"], - **dummy_io, - ) - 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 - assert "AIDER_DARK_MODE" in relevant_output - assert "dark_mode" in relevant_output - import re - - assert re.search(r"AIDER_DARK_MODE:\s+on", relevant_output) - assert re.search(r"dark_mode:\s+True", relevant_output) + mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) + main( + ["--no-git", "--verbose", "--exit", "--yes-always"], + **dummy_io, + ) + 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 + assert "AIDER_DARK_MODE" in relevant_output + assert "dark_mode" in relevant_output + import re + + assert re.search(r"AIDER_DARK_MODE:\s+on", relevant_output) + assert re.search(r"dark_mode:\s+True", relevant_output) def test_yaml_config_file_loading(self, dummy_io, git_temp_dir): with GitTemporaryDirectory() as git_dir: @@ -803,20 +803,20 @@ def test_model_metadata_file(self, dummy_io, git_temp_dir): assert coder.main_model.info["max_input_tokens"] == 1234 - def test_sonnet_and_cache_options(self, dummy_io, git_temp_dir): - 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 + def test_sonnet_and_cache_options(self, dummy_io, git_temp_dir, mocker): + MockRepoMap = mocker.patch("aider.coders.base_coder.RepoMap") + mock_repo_map = MagicMock() + mock_repo_map.max_map_tokens = 1000 # Set a specific value + MockRepoMap.return_value = mock_repo_map - main( - ["--sonnet", "--cache-prompts", "--exit", "--yes-always"], - **dummy_io, - ) + main( + ["--sonnet", "--cache-prompts", "--exit", "--yes-always"], + **dummy_io, + ) - MockRepoMap.assert_called_once() - call_args, call_kwargs = MockRepoMap.call_args - assert call_kwargs.get("refresh") == "files" # Check the 'refresh' keyword argument + MockRepoMap.assert_called_once() + call_args, call_kwargs = MockRepoMap.call_args + assert call_kwargs.get("refresh") == "files" # Check the 'refresh' keyword argument def test_sonnet_and_cache_prompts_options(self, dummy_io, git_temp_dir): coder = main( From 803d96f9a46a7e576026c22b01fe4638a8e08135 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:25:39 +0100 Subject: [PATCH 22/50] refactor: convert more with patch to mocker (Phase 3C.1e) Converted 2 more `with patch` context managers to pytest-mock: - test_invalid_edit_format (sys.stderr with StringIO) - test_list_models_includes_metadata_models (sys.stdout) Progress: 31/40 total patch usages converted (77.5%). All 92 tests passing. --- tests/basic/test_main.py | 56 ++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index b538f043a95..3c7fd591853 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -1157,19 +1157,19 @@ def test_resolve_aiderignore_path(self, dummy_io, git_temp_dir): rel_path = ".aiderignore" assert resolve_aiderignore_path(rel_path) == rel_path - def test_invalid_edit_format(self, dummy_io, git_temp_dir): + def test_invalid_edit_format(self, dummy_io, git_temp_dir, mocker): # Suppress stderr for this test as argparse prints an error message - with patch("sys.stderr", new_callable=StringIO) as mock_stderr: - with pytest.raises(SystemExit) as cm: - _ = main( - ["--edit-format", "not-a-real-format", "--exit", "--yes-always"], - **dummy_io, - ) - # argparse.ArgumentParser.exit() is called with status 2 for invalid choice - assert cm.value.code == 2 - stderr_output = mock_stderr.getvalue() - assert "invalid choice" in stderr_output - assert "not-a-real-format" in stderr_output + mock_stderr = mocker.patch("sys.stderr", new_callable=StringIO) + with pytest.raises(SystemExit) as cm: + _ = main( + ["--edit-format", "not-a-real-format", "--exit", "--yes-always"], + **dummy_io, + ) + # argparse.ArgumentParser.exit() is called with status 2 for invalid choice + assert cm.value.code == 2 + stderr_output = mock_stderr.getvalue() + assert "invalid choice" in stderr_output + assert "not-a-real-format" in stderr_output @pytest.mark.parametrize( "api_key_env,expected_model_substr", @@ -1389,7 +1389,7 @@ def test_thinking_tokens_option(self, dummy_io, git_temp_dir): ) assert coder.main_model.extra_params.get("thinking", {}).get("budget_tokens") == 1000 - def test_list_models_includes_metadata_models(self, dummy_io, git_temp_dir): + def test_list_models_includes_metadata_models(self, dummy_io, git_temp_dir, mocker): # Test that models from model-metadata.json appear in list-models output # Create a temporary model-metadata.json with test models metadata_file = Path(".aider.model.metadata.json") @@ -1408,22 +1408,22 @@ def test_list_models_includes_metadata_models(self, dummy_io, git_temp_dir): metadata_file.write_text(json.dumps(test_models)) # Capture stdout to check the output - with patch("sys.stdout", new_callable=StringIO) as mock_stdout: - main( - [ - "--list-models", - "unique-model", - "--model-metadata-file", - str(metadata_file), - "--yes-always", - "--no-gitignore", - ], - **dummy_io, - ) - output = mock_stdout.getvalue() + mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) + main( + [ + "--list-models", + "unique-model", + "--model-metadata-file", + str(metadata_file), + "--yes-always", + "--no-gitignore", + ], + **dummy_io, + ) + output = mock_stdout.getvalue() - # Check that the unique model name from our metadata file is listed - assert "test-provider/unique-model-name" in output + # Check that the unique model name from our metadata file is listed + assert "test-provider/unique-model-name" in output def test_list_models_includes_all_model_sources(self, dummy_io, git_temp_dir): # Test that models from both litellm.model_cost and model-metadata.json From 8bedf34378ddf2c7ab641d622e1000296ac503ae Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:27:48 +0100 Subject: [PATCH 23/50] refactor: convert more with patch to mocker (Phase 3C.1f) Converted 4 more `with patch` context managers to pytest-mock: - test_list_models_includes_all_model_sources (sys.stdout) - test_check_model_accepts_settings_flag (Model.set_thinking_tokens) - test_list_models_with_direct_resource_patch (3 patches: importlib_resources.files, sys.stdout, Model.set_reasoning_effort) Progress: 36/40 total patch usages converted (90%). All 92 tests passing. --- tests/basic/test_main.py | 118 +++++++++++++++++++-------------------- 1 file changed, 59 insertions(+), 59 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 3c7fd591853..62d36879e26 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -1425,7 +1425,7 @@ def test_list_models_includes_metadata_models(self, dummy_io, git_temp_dir, mock # Check that the unique model name from our metadata file is listed assert "test-provider/unique-model-name" in output - def test_list_models_includes_all_model_sources(self, dummy_io, git_temp_dir): + def test_list_models_includes_all_model_sources(self, dummy_io, git_temp_dir, mocker): # Test that models from both litellm.model_cost and model-metadata.json # appear in list-models # Create a temporary model-metadata.json with test models @@ -1440,45 +1440,45 @@ def test_list_models_includes_all_model_sources(self, dummy_io, git_temp_dir): metadata_file.write_text(json.dumps(test_models)) # Capture stdout to check the output - with patch("sys.stdout", new_callable=StringIO) as mock_stdout: - main( - [ - "--list-models", - "metadata-only-model", - "--model-metadata-file", - str(metadata_file), - "--yes-always", - "--no-gitignore", - ], - **dummy_io, - ) - output = mock_stdout.getvalue() + mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) + main( + [ + "--list-models", + "metadata-only-model", + "--model-metadata-file", + str(metadata_file), + "--yes-always", + "--no-gitignore", + ], + **dummy_io, + ) + output = mock_stdout.getvalue() - dump(output) + dump(output) - # Check that both models appear in the output - assert "test-provider/metadata-only-model" in output + # Check that both models appear in the output + assert "test-provider/metadata-only-model" in output - def test_check_model_accepts_settings_flag(self, dummy_io, git_temp_dir): + def test_check_model_accepts_settings_flag(self, dummy_io, git_temp_dir, mocker): # Test that --check-model-accepts-settings affects whether settings are applied # 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: - main( - [ - "--model", - "gpt-4o", - "--thinking-tokens", - "1000", - "--check-model-accepts-settings", - "--yes-always", - "--exit", - ], - **dummy_io, - ) - # Method should not be called because model doesn't support it and flag is on - mock_set_thinking.assert_not_called() + mock_set_thinking = mocker.patch("aider.models.Model.set_thinking_tokens") + main( + [ + "--model", + "gpt-4o", + "--thinking-tokens", + "1000", + "--check-model-accepts-settings", + "--yes-always", + "--exit", + ], + **dummy_io, + ) + # Method should not be called because model doesn't support it and flag is on + mock_set_thinking.assert_not_called() - def test_list_models_with_direct_resource_patch(self, dummy_io): + def test_list_models_with_direct_resource_patch(self, dummy_io, mocker): # Test that models from resources/model-metadata.json are included in list-models output # Create a temporary file with test model metadata test_file = Path(self.tempdir) / "test-model-metadata.json" @@ -1499,34 +1499,34 @@ def test_list_models_with_direct_resource_patch(self, dummy_io): 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: - main( - ["--list-models", "special", "--yes-always", "--no-gitignore"], - **dummy_io, - ) - output = mock_stdout.getvalue() + mocker.patch("aider.main.importlib_resources.files", return_value=mock_files) + # Capture stdout to check the output + mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) + main( + ["--list-models", "special", "--yes-always", "--no-gitignore"], + **dummy_io, + ) + output = mock_stdout.getvalue() - # Check that the resource model appears in the output - assert "resource-provider/special-model" in output + # Check that the resource model appears in the output + assert "resource-provider/special-model" in output # When flag is off, setting should be applied regardless of support - with patch("aider.models.Model.set_reasoning_effort") as mock_set_reasoning: - main( - [ - "--model", - "gpt-3.5-turbo", - "--reasoning-effort", - "3", - "--no-check-model-accepts-settings", - "--yes-always", - "--exit", - ], - **dummy_io, - ) - # Method should be called because flag is off - mock_set_reasoning.assert_called_once_with("3") + mock_set_reasoning = mocker.patch("aider.models.Model.set_reasoning_effort") + main( + [ + "--model", + "gpt-3.5-turbo", + "--reasoning-effort", + "3", + "--no-check-model-accepts-settings", + "--yes-always", + "--exit", + ], + **dummy_io, + ) + # Method should be called because flag is off + mock_set_reasoning.assert_called_once_with("3") def test_model_accepts_settings_attribute(self, dummy_io, git_temp_dir): # Test with a model where we override the accepts_settings attribute From e4653a03d53ebaee4d2c5ff81bb73e27e037c856 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:35:39 +0100 Subject: [PATCH 24/50] refactor: complete pytest-mock adoption (Phase 3C.1) Replace all unittest.mock.patch usage with pytest-mock's mocker: - Converted test_env autouse fixture to use mocker instead of patch - Converted 5 remaining tests using with patch() context managers: - test_main_exit_calls_version_check - test_yaml_config_file_loading - test_accepts_settings_warnings - test_model_overrides_suffix_applied - test_model_overrides_no_match_preserves_model_name - Removed patch import from unittest.mock All 92 tests passing. Code now fully uses pytest-mock for all mocking. --- tests/basic/test_main.py | 544 +++++++++++++++++++-------------------- 1 file changed, 261 insertions(+), 283 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 62d36879e26..053f3f2e952 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -5,7 +5,7 @@ import tempfile from io import StringIO from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import git import pytest @@ -30,7 +30,7 @@ def mock_autosave_future(): @pytest.fixture(autouse=True) -def test_env(request): +def test_env(request, mocker): """Autouse fixture providing test environment (replaces setUp/tearDown).""" # Setup (formerly setUp) original_env = os.environ.copy() @@ -45,10 +45,8 @@ def test_env(request): homedir_obj = IgnorantTemporaryDirectory() os.environ["HOME"] = homedir_obj.name - input_patcher = patch("builtins.input", return_value=None) - mock_input = input_patcher.start() - webbrowser_patcher = patch("aider.io.webbrowser.open") - mock_webbrowser = webbrowser_patcher.start() + mock_input = mocker.patch("builtins.input", return_value=None) + mock_webbrowser = mocker.patch("aider.io.webbrowser.open") # Make values available to tests via request.instance if request.instance: @@ -59,8 +57,6 @@ def test_env(request): request.instance.original_cwd = original_cwd request.instance.mock_input = mock_input request.instance.mock_webbrowser = mock_webbrowser - request.instance.input_patcher = input_patcher - request.instance.webbrowser_patcher = webbrowser_patcher yield @@ -70,8 +66,6 @@ def test_env(request): homedir_obj.cleanup() os.environ.clear() os.environ.update(original_env) - input_patcher.stop() - webbrowser_patcher.stop() @pytest.fixture @@ -377,7 +371,7 @@ def test_main_args(self, args, expected_kwargs, dummy_io, mock_coder, git_temp_d for key, expected_value in expected_kwargs.items(): assert kwargs[key] is expected_value - def test_env_file_override(self, dummy_io, git_temp_dir): + def test_env_file_override(self, dummy_io, git_temp_dir, mocker): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) git_env = git_dir / ".env" @@ -400,8 +394,8 @@ def test_env_file_override(self, dummy_io, git_temp_dir): cwd_env.write_text("A=cwd\nB=cwd") named_env.write_text("A=named") - with patch("pathlib.Path.home", return_value=fake_home): - main(["--yes-always", "--exit", "--env-file", str(named_env)]) + mocker.patch("pathlib.Path.home", return_value=fake_home) + main(["--yes-always", "--exit", "--env-file", str(named_env)]) assert os.environ["A"] == "named" assert os.environ["B"] == "cwd" @@ -453,15 +447,13 @@ def side_effect(*args, **kwargs): main(["--yes-always", fname, "--encoding", "iso-8859-15"]) - def test_main_exit_calls_version_check(self, dummy_io, git_temp_dir): - with ( - patch("aider.main.check_version") as mock_check_version, - patch("aider.main.InputOutput") as mock_input_output, - ): - mock_input_output.return_value.confirm_ask = AsyncMock(return_value=True) - main(["--exit", "--check-update"], **dummy_io) - mock_check_version.assert_called_once() - mock_input_output.assert_called_once() + def test_main_exit_calls_version_check(self, dummy_io, git_temp_dir, mocker): + mock_check_version = mocker.patch("aider.main.check_version") + mock_input_output = mocker.patch("aider.main.InputOutput") + mock_input_output.return_value.confirm_ask = AsyncMock(return_value=True) + main(["--exit", "--check-update"], **dummy_io) + mock_check_version.assert_called_once() + mock_input_output.assert_called_once() def test_main_message_adds_to_input_history(self, dummy_io, mocker): mock_run = mocker.patch("aider.coders.base_coder.Coder.run") @@ -660,7 +652,7 @@ def test_verbose_mode_lists_env_vars(self, dummy_io, create_env_file, mocker): assert re.search(r"AIDER_DARK_MODE:\s+on", relevant_output) assert re.search(r"dark_mode:\s+True", relevant_output) - def test_yaml_config_file_loading(self, dummy_io, git_temp_dir): + def test_yaml_config_file_loading(self, dummy_io, git_temp_dir, mocker): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) @@ -685,45 +677,43 @@ def test_yaml_config_file_loading(self, dummy_io, git_temp_dir): 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, - ): - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() - # Test loading from specified config file - main( - ["--yes-always", "--exit", "--config", str(named_config)], - **dummy_io, - ) - _, kwargs = MockCoder.call_args - assert kwargs["main_model"].name == "gpt-4-1106-preview" - assert kwargs["map_tokens"] == 8192 - - # Test loading from current working directory - mock_coder_instance._autosave_future = mock_autosave_future() - main(["--yes-always", "--exit"], **dummy_io) - _, kwargs = MockCoder.call_args - print("kwargs:", kwargs) # Add this line for debugging - assert "main_model" in kwargs, "main_model key not found in kwargs" - assert kwargs["main_model"].name == "gpt-4-32k" - assert kwargs["map_tokens"] == 4096 - - # Test loading from git root - cwd_config.unlink() - mock_coder_instance._autosave_future = mock_autosave_future() - main(["--yes-always", "--exit"], **dummy_io) - _, kwargs = MockCoder.call_args - assert kwargs["main_model"].name == "gpt-4" - assert kwargs["map_tokens"] == 2048 - - # Test loading from home directory - git_config.unlink() - mock_coder_instance._autosave_future = mock_autosave_future() - main(["--yes-always", "--exit"], **dummy_io) - _, kwargs = MockCoder.call_args - assert kwargs["main_model"].name == "gpt-3.5-turbo" - assert kwargs["map_tokens"] == 1024 + mocker.patch("pathlib.Path.home", return_value=fake_home) + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MockCoder.return_value + mock_coder_instance._autosave_future = mock_autosave_future() + # Test loading from specified config file + main( + ["--yes-always", "--exit", "--config", str(named_config)], + **dummy_io, + ) + _, kwargs = MockCoder.call_args + assert kwargs["main_model"].name == "gpt-4-1106-preview" + assert kwargs["map_tokens"] == 8192 + + # Test loading from current working directory + mock_coder_instance._autosave_future = mock_autosave_future() + main(["--yes-always", "--exit"], **dummy_io) + _, kwargs = MockCoder.call_args + print("kwargs:", kwargs) # Add this line for debugging + assert "main_model" in kwargs, "main_model key not found in kwargs" + assert kwargs["main_model"].name == "gpt-4-32k" + assert kwargs["map_tokens"] == 4096 + + # Test loading from git root + cwd_config.unlink() + mock_coder_instance._autosave_future = mock_autosave_future() + main(["--yes-always", "--exit"], **dummy_io) + _, kwargs = MockCoder.call_args + assert kwargs["main_model"].name == "gpt-4" + assert kwargs["map_tokens"] == 2048 + + # Test loading from home directory + git_config.unlink() + mock_coder_instance._autosave_future = mock_autosave_future() + main(["--yes-always", "--exit"], **dummy_io) + _, kwargs = MockCoder.call_args + assert kwargs["main_model"].name == "gpt-3.5-turbo" + assert kwargs["map_tokens"] == 1024 def test_map_tokens_option(self, dummy_io, git_temp_dir, mocker): MockRepoMap = mocker.patch("aider.coders.base_coder.RepoMap") @@ -886,95 +876,87 @@ def test_boolean_flags(self, flag_arg, attr_name, expected, dummy_io, git_temp_d coder = main(args, **dummy_io, return_coder=True) assert getattr(coder, attr_name) == expected - def test_accepts_settings_warnings(self, dummy_io, git_temp_dir): + def test_accepts_settings_warnings(self, dummy_io, git_temp_dir, mocker): # Test that appropriate warnings are shown based on accepts_settings configuration # 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, - ): - main( - [ - "--model", - "anthropic/claude-3-7-sonnet-20250219", - "--thinking-tokens", - "1000", - "--yes-always", - "--exit", - ], - **dummy_io, - ) - # No warning should be shown as this model accepts thinking_tokens - for call in mock_warning.call_args_list: - assert "thinking_tokens" not in call[0][0] - # Method should be called - mock_set_thinking.assert_called_once_with("1000") + mock_warning = mocker.patch("aider.io.InputOutput.tool_warning") + mock_set_thinking = mocker.patch("aider.models.Model.set_thinking_tokens") + main( + [ + "--model", + "anthropic/claude-3-7-sonnet-20250219", + "--thinking-tokens", + "1000", + "--yes-always", + "--exit", + ], + **dummy_io, + ) + # No warning should be shown as this model accepts thinking_tokens + for call in mock_warning.call_args_list: + assert "thinking_tokens" not in 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, - ): - main( - [ - "--model", - "gpt-4o", - "--thinking-tokens", - "1000", - "--check-model-accepts-settings", - "--yes-always", - "--exit", - ], - **dummy_io, - ) - # 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 - assert warning_shown - # Method should NOT be called because model doesn't support it and check flag is on - mock_set_thinking.assert_not_called() + mock_warning.reset_mock() + mock_set_thinking.reset_mock() + main( + [ + "--model", + "gpt-4o", + "--thinking-tokens", + "1000", + "--check-model-accepts-settings", + "--yes-always", + "--exit", + ], + **dummy_io, + ) + # 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 + assert 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, - ): - main( - ["--model", "o1", "--reasoning-effort", "3", "--yes-always", "--exit"], - **dummy_io, - ) - # No warning should be shown as this model accepts reasoning_effort - for call in mock_warning.call_args_list: - assert "reasoning_effort" not in call[0][0] - # Method should be called - mock_set_reasoning.assert_called_once_with("3") + mock_warning.reset_mock() + mock_set_reasoning = mocker.patch("aider.models.Model.set_reasoning_effort") + main( + ["--model", "o1", "--reasoning-effort", "3", "--yes-always", "--exit"], + **dummy_io, + ) + # No warning should be shown as this model accepts reasoning_effort + for call in mock_warning.call_args_list: + assert "reasoning_effort" not in 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, - ): - main( - [ - "--model", - "gpt-3.5-turbo", - "--reasoning-effort", - "3", - "--yes-always", - "--exit", - ], - **dummy_io, - ) - # 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 - assert warning_shown - # Method should still be called by default - mock_set_reasoning.assert_not_called() + mock_warning.reset_mock() + mock_set_reasoning.reset_mock() + main( + [ + "--model", + "gpt-3.5-turbo", + "--reasoning-effort", + "3", + "--yes-always", + "--exit", + ], + **dummy_io, + ) + # 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 + assert warning_shown + # Method should still be called by default + mock_set_reasoning.assert_not_called() def test_no_verify_ssl_sets_model_info_manager(self, dummy_io, git_temp_dir, mocker): mock_set_verify_ssl = mocker.patch("aider.models.ModelInfoManager.set_verify_ssl") @@ -1212,7 +1194,7 @@ def test_default_model_selection(self, api_key_env, expected_model_substr, dummy for key, value in saved_keys.items(): os.environ[key] = value - def test_default_model_selection_oauth_fallback(self, dummy_io, git_temp_dir): + def test_default_model_selection_oauth_fallback(self, dummy_io, git_temp_dir, mocker): # Test no API keys - should offer OpenRouter OAuth # Clear all API keys to simulate no configured keys saved_keys = {} @@ -1229,11 +1211,11 @@ def test_default_model_selection_oauth_fallback(self, dummy_io, git_temp_dir): del os.environ[key] try: - with patch("aider.onboarding.offer_openrouter_oauth") as mock_offer_oauth: - mock_offer_oauth.return_value = None # Simulate user declining or failure - result = main(["--exit", "--yes-always"], **dummy_io) - assert result == 1 # Expect failure since no model could be selected - mock_offer_oauth.assert_called_once() + mock_offer_oauth = mocker.patch("aider.onboarding.offer_openrouter_oauth") + mock_offer_oauth.return_value = None # Simulate user declining or failure + result = main(["--exit", "--yes-always"], **dummy_io) + assert result == 1 # Expect failure since no model could be selected + mock_offer_oauth.assert_called_once() finally: # Restore saved API keys for key, value in saved_keys.items(): @@ -1252,96 +1234,92 @@ def test_model_precedence(self, dummy_io, git_temp_dir): del os.environ["ANTHROPIC_API_KEY"] del os.environ["OPENAI_API_KEY"] - def test_model_overrides_suffix_applied(self, dummy_io, git_temp_dir): + def test_model_overrides_suffix_applied(self, dummy_io, git_temp_dir, mocker): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) overrides_file = git_dir / ".aider.model.overrides.yml" overrides_file.write_text("gpt-4o:\n fast:\n temperature: 0.1\n") - with ( - patch("aider.models.Model") as MockModel, - patch("aider.coders.Coder.create") as MockCoder, - ): - mock_coder_instance = MagicMock() - mock_coder_instance._autosave_future = mock_autosave_future() - MockCoder.return_value = mock_coder_instance - - mock_instance = MockModel.return_value - mock_instance.info = {} - mock_instance.name = "gpt-4o" - mock_instance.validate_environment.return_value = { - "missing_keys": [], - "keys_in_environment": [], - } - mock_instance.accepts_settings = [] - mock_instance.weak_model_name = None - mock_instance.get_weak_model.return_value = None - - main( - ["--model", "gpt-4o:fast", "--exit", "--yes-always", "--no-git"], - **dummy_io, - force_git_root=git_dir, - ) - - # Find the call that constructed the main model with overrides - matched_call_found = False - for call_args in MockModel.call_args_list: - args, kwargs = call_args - if ( - args - and args[0] == "gpt-4o" - and kwargs.get("override_kwargs") == {"temperature": 0.1} - ): - matched_call_found = True - break - - assert matched_call_found, ( - "Expected a Model call with base name 'gpt-4o' and override_kwargs" - " {'temperature': 0.1}" - ) - - def test_model_overrides_no_match_preserves_model_name(self, dummy_io, git_temp_dir): + MockModel = mocker.patch("aider.models.Model") + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MagicMock() + mock_coder_instance._autosave_future = mock_autosave_future() + MockCoder.return_value = mock_coder_instance + + mock_instance = MockModel.return_value + mock_instance.info = {} + mock_instance.name = "gpt-4o" + mock_instance.validate_environment.return_value = { + "missing_keys": [], + "keys_in_environment": [], + } + mock_instance.accepts_settings = [] + mock_instance.weak_model_name = None + mock_instance.get_weak_model.return_value = None + + main( + ["--model", "gpt-4o:fast", "--exit", "--yes-always", "--no-git"], + **dummy_io, + force_git_root=git_dir, + ) + + # Find the call that constructed the main model with overrides + matched_call_found = False + for call_args in MockModel.call_args_list: + args, kwargs = call_args + if ( + args + and args[0] == "gpt-4o" + and kwargs.get("override_kwargs") == {"temperature": 0.1} + ): + matched_call_found = True + break + + assert matched_call_found, ( + "Expected a Model call with base name 'gpt-4o' and override_kwargs" + " {'temperature': 0.1}" + ) + + def test_model_overrides_no_match_preserves_model_name(self, dummy_io, git_temp_dir, mocker): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) - with ( - patch("aider.models.Model") as MockModel, - patch("aider.coders.Coder.create") as MockCoder, - ): - mock_coder_instance = MagicMock() - mock_coder_instance._autosave_future = mock_autosave_future() - MockCoder.return_value = mock_coder_instance - - mock_instance = MockModel.return_value - mock_instance.info = {} - mock_instance.name = "test-model" - mock_instance.validate_environment.return_value = { - "missing_keys": [], - "keys_in_environment": [], - } - mock_instance.accepts_settings = [] - mock_instance.weak_model_name = None - mock_instance.get_weak_model.return_value = None - - model_name = "hf:moonshotai/Kimi-K2-Thinking" - - main( - ["--model", model_name, "--exit", "--yes-always", "--no-git"], - **dummy_io, - force_git_root=git_dir, - ) - - matched_call_found = False - for call_args in MockModel.call_args_list: - args, kwargs = call_args - if args and args[0] == model_name and kwargs.get("override_kwargs") == {}: - matched_call_found = True - break - - assert matched_call_found, ( - "Expected a Model call with the full model name preserved and empty" - " override_kwargs" - ) + MockModel = mocker.patch("aider.models.Model") + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MagicMock() + mock_coder_instance._autosave_future = mock_autosave_future() + MockCoder.return_value = mock_coder_instance + + mock_instance = MockModel.return_value + mock_instance.info = {} + mock_instance.name = "test-model" + mock_instance.validate_environment.return_value = { + "missing_keys": [], + "keys_in_environment": [], + } + mock_instance.accepts_settings = [] + mock_instance.weak_model_name = None + mock_instance.get_weak_model.return_value = None + + model_name = "hf:moonshotai/Kimi-K2-Thinking" + + main( + ["--model", model_name, "--exit", "--yes-always", "--no-git"], + **dummy_io, + force_git_root=git_dir, + ) + + matched_call_found = False + for call_args in MockModel.call_args_list: + args, kwargs = call_args + if args and args[0] == model_name and kwargs.get("override_kwargs") == {}: + matched_call_found = True + break + + assert matched_call_found, ( + "Expected a Model call with the full model name preserved and empty" + " override_kwargs" + ) def test_chat_language_spanish(self, dummy_io, git_temp_dir): coder = main( @@ -1528,40 +1506,40 @@ def test_list_models_with_direct_resource_patch(self, dummy_io, mocker): # Method should be called because flag is off mock_set_reasoning.assert_called_once_with("3") - def test_model_accepts_settings_attribute(self, dummy_io, git_temp_dir): + def test_model_accepts_settings_attribute(self, dummy_io, git_temp_dir, mocker): # 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 + MockModel = mocker.patch("aider.models.Model") + # 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 - main( - [ - "--model", - "test-model", - "--reasoning-effort", - "3", - "--thinking-tokens", - "1000", - "--check-model-accepts-settings", - "--yes-always", - "--exit", - ], - **dummy_io, - ) + # Run with both settings, but model only accepts reasoning_effort + main( + [ + "--model", + "test-model", + "--reasoning-effort", + "3", + "--thinking-tokens", + "1000", + "--check-model-accepts-settings", + "--yes-always", + "--exit", + ], + **dummy_io, + ) - # 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() + # 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() def test_stream_and_cache_warning(self, dummy_io, git_temp_dir, mocker): MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) @@ -1599,7 +1577,7 @@ def test_argv_file_respects_git(self, dummy_io, git_temp_dir): assert "not_in_git.txt" not in str(coder.abs_fnames) assert not asyncio.run(coder.allowed_to_edit("not_in_git.txt")) - def test_load_dotenv_files_override(self, dummy_io, git_temp_dir): + def test_load_dotenv_files_override(self, dummy_io, git_temp_dir, mocker): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) @@ -1632,26 +1610,26 @@ def test_load_dotenv_files_override(self, dummy_io, git_temp_dir): 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) - assert str(oauth_keys_file.resolve()) in loaded_files - assert str(git_root_env.resolve()) in loaded_files - assert str(cwd_env.resolve()) in loaded_files - assert loaded_files.index(str(oauth_keys_file.resolve())) < loaded_files.index( - str(git_root_env.resolve()) - ) - assert loaded_files.index(str(git_root_env.resolve())) < loaded_files.index( - str(cwd_env.resolve()) - ) - - # Assert environment variables reflect the override order - assert os.environ.get("OAUTH_VAR") == "oauth_val" - assert os.environ.get("GIT_VAR") == "git_val" - assert os.environ.get("CWD_VAR") == "cwd_val" - # SHARED_VAR should be overridden by the last loaded file (cwd .env) - assert os.environ.get("SHARED_VAR") == "cwd_shared" + mocker.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) + assert str(oauth_keys_file.resolve()) in loaded_files + assert str(git_root_env.resolve()) in loaded_files + assert str(cwd_env.resolve()) in loaded_files + assert loaded_files.index(str(oauth_keys_file.resolve())) < loaded_files.index( + str(git_root_env.resolve()) + ) + assert loaded_files.index(str(git_root_env.resolve())) < loaded_files.index( + str(cwd_env.resolve()) + ) + + # Assert environment variables reflect the override order + assert os.environ.get("OAUTH_VAR") == "oauth_val" + assert os.environ.get("GIT_VAR") == "git_val" + assert os.environ.get("CWD_VAR") == "cwd_val" + # SHARED_VAR should be overridden by the last loaded file (cwd .env) + assert os.environ.get("SHARED_VAR") == "cwd_shared" # Restore CWD os.chdir(original_cwd) From 33d6cc92e5178daac53a53c4eae7adb5d7303203 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:37:43 +0100 Subject: [PATCH 25/50] refactor: modernize test_env fixture (Phase 3C.3) Remove unittest legacy patterns from test_env fixture: - Removed request.instance pattern and all instance variable assignments - Removed request parameter (no longer needed) - Replaced self.tempdir with os.getcwd() in 3 locations: - test_setup_git (2 uses) - test_list_models_with_direct_resource_patch (1 use) - Simplified fixture to only use mocker parameter All 92 tests passing. Fixture is now more idiomatic pytest. --- tests/basic/test_main.py | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 053f3f2e952..ef31a1f3590 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -30,9 +30,9 @@ def mock_autosave_future(): @pytest.fixture(autouse=True) -def test_env(request, mocker): - """Autouse fixture providing test environment (replaces setUp/tearDown).""" - # Setup (formerly setUp) +def test_env(mocker): + """Autouse fixture providing test environment.""" + # Setup original_env = os.environ.copy() os.environ["OPENAI_API_KEY"] = "deadbeef" os.environ["AIDER_CHECK_UPDATE"] = "false" @@ -45,22 +45,12 @@ def test_env(request, mocker): homedir_obj = IgnorantTemporaryDirectory() os.environ["HOME"] = homedir_obj.name - mock_input = mocker.patch("builtins.input", return_value=None) - mock_webbrowser = mocker.patch("aider.io.webbrowser.open") - - # Make values available to tests via request.instance - if request.instance: - request.instance.tempdir = tempdir - request.instance.tempdir_obj = tempdir_obj - request.instance.homedir_obj = homedir_obj - request.instance.original_env = original_env - request.instance.original_cwd = original_cwd - request.instance.mock_input = mock_input - request.instance.mock_webbrowser = mock_webbrowser + mocker.patch("builtins.input", return_value=None) + mocker.patch("aider.io.webbrowser.open") yield - # Teardown (formerly tearDown) + # Teardown os.chdir(original_cwd) tempdir_obj.cleanup() homedir_obj.cleanup() @@ -213,9 +203,9 @@ def test_setup_git(self, dummy_io): io = InputOutput(pretty=False, yes=True) git_root = asyncio.run(setup_git(None, io)) git_root = Path(git_root).resolve() - assert git_root == Path(self.tempdir).resolve() + assert git_root == Path(os.getcwd()).resolve() - assert git.Repo(self.tempdir) + assert git.Repo(os.getcwd()) gitignore = Path.cwd() / ".gitignore" assert gitignore.exists() @@ -1459,7 +1449,7 @@ def test_check_model_accepts_settings_flag(self, dummy_io, git_temp_dir, mocker) def test_list_models_with_direct_resource_patch(self, dummy_io, mocker): # Test that models from resources/model-metadata.json are included in list-models output # Create a temporary file with test model metadata - test_file = Path(self.tempdir) / "test-model-metadata.json" + test_file = Path(os.getcwd()) / "test-model-metadata.json" test_resource_models = { "special-model": { "max_input_tokens": 8192, From 4c46a44b265ed50312299efc19e3dd9085f45ee3 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:39:42 +0100 Subject: [PATCH 26/50] refactor: adopt monkeypatch for env vars (Phase 3C.4) Replace manual environment variable manipulation with pytest's monkeypatch: - test_check_gitignore: Use monkeypatch.setenv for GIT_CONFIG_GLOBAL - test_env_file_override: Use monkeypatch.setenv for HOME and E - test_yaml_config_file_loading: Use monkeypatch.setenv for HOME - test_model_precedence: Use monkeypatch.setenv for API keys Benefits: - Automatic cleanup (no more del os.environ) - More explicit and idiomatic pytest - Cleaner code with fewer lines All 92 tests passing. Phase 3C complete. --- tests/basic/test_main.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index ef31a1f3590..d2413e37c6c 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -211,8 +211,8 @@ def test_setup_git(self, dummy_io): assert gitignore.exists() assert ".aider*" == gitignore.read_text().splitlines()[0] - def test_check_gitignore(self, dummy_io, git_temp_dir): - os.environ["GIT_CONFIG_GLOBAL"] = "globalgitconfig" + def test_check_gitignore(self, dummy_io, git_temp_dir, monkeypatch): + monkeypatch.setenv("GIT_CONFIG_GLOBAL", "globalgitconfig") io = InputOutput(pretty=False, yes=True) cwd = Path.cwd() @@ -234,7 +234,6 @@ def test_check_gitignore(self, dummy_io, git_temp_dir): env_file.touch() asyncio.run(check_gitignore(cwd, io)) assert "one\ntwo\n.aider*\n.env\n" == gitignore.read_text() - del os.environ["GIT_CONFIG_GLOBAL"] def test_command_line_gitignore_files_flag(self, dummy_io): with GitTemporaryDirectory() as git_dir: @@ -361,14 +360,14 @@ def test_main_args(self, args, expected_kwargs, dummy_io, mock_coder, git_temp_d for key, expected_value in expected_kwargs.items(): assert kwargs[key] is expected_value - def test_env_file_override(self, dummy_io, git_temp_dir, mocker): + def test_env_file_override(self, dummy_io, git_temp_dir, mocker, monkeypatch): 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) + monkeypatch.setenv("HOME", str(fake_home)) home_env = fake_home / ".env" cwd = git_dir / "subdir" @@ -378,7 +377,7 @@ def test_env_file_override(self, dummy_io, git_temp_dir, mocker): named_env = git_dir / "named.env" - os.environ["E"] = "existing" + monkeypatch.setenv("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") @@ -642,14 +641,14 @@ def test_verbose_mode_lists_env_vars(self, dummy_io, create_env_file, mocker): assert re.search(r"AIDER_DARK_MODE:\s+on", relevant_output) assert re.search(r"dark_mode:\s+True", relevant_output) - def test_yaml_config_file_loading(self, dummy_io, git_temp_dir, mocker): + def test_yaml_config_file_loading(self, dummy_io, git_temp_dir, mocker, monkeypatch): 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) + monkeypatch.setenv("HOME", str(fake_home)) # Create subdirectory as current working directory cwd = git_dir / "subdir" @@ -1211,18 +1210,16 @@ def test_default_model_selection_oauth_fallback(self, dummy_io, git_temp_dir, mo for key, value in saved_keys.items(): os.environ[key] = value - def test_model_precedence(self, dummy_io, git_temp_dir): + def test_model_precedence(self, dummy_io, git_temp_dir, monkeypatch): # Test that earlier API keys take precedence - os.environ["ANTHROPIC_API_KEY"] = "test-key" - os.environ["OPENAI_API_KEY"] = "test-key" + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + monkeypatch.setenv("OPENAI_API_KEY", "test-key") coder = main( ["--exit", "--yes-always"], **dummy_io, return_coder=True, ) assert "sonnet" in coder.main_model.name.lower() - del os.environ["ANTHROPIC_API_KEY"] - del os.environ["OPENAI_API_KEY"] def test_model_overrides_suffix_applied(self, dummy_io, git_temp_dir, mocker): with GitTemporaryDirectory() as git_dir: From ebc4bd070bc5c6ffda0fe034f86ae347f0c92527 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:43:31 +0100 Subject: [PATCH 27/50] refactor: convert to function-based tests (Phase 3D) Complete transformation to idiomatic pytest: - Removed TestMain class wrapper - Converted all 74 test methods to standalone functions - Removed 'self' parameter from all test signatures - Added comprehensive module docstring - Enhanced test_env fixture documentation Code is now fully idiomatic pytest with: - Function-based tests (no class wrapper) - Pytest fixtures for dependency injection - Parametrized tests for reducing duplication - pytest-mock for all mocking - monkeypatch for environment variables All 92 tests passing. --- tests/basic/test_main.py | 2934 +++++++++++++++++++------------------- 1 file changed, 1479 insertions(+), 1455 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index d2413e37c6c..fb228e51d50 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -1,3 +1,19 @@ +"""Comprehensive tests for aider.main module. + +This test suite validates the main() function and its integration with various +aider components including configuration loading, model selection, git operations, +and command-line argument parsing. + +Test coverage includes: +- Command-line argument parsing and validation +- Configuration file loading (.aider.conf.yml, .env files) +- Model selection and API key management +- Git repository operations and setup +- Environment variable handling +- Feature flags and boolean options +- Model overrides and metadata +- MCP server configuration +""" import asyncio import json import os @@ -31,7 +47,16 @@ def mock_autosave_future(): @pytest.fixture(autouse=True) def test_env(mocker): - """Autouse fixture providing test environment.""" + """Provide isolated test environment for all tests. + + Automatically sets up and tears down: + - Fake API keys and environment variables + - Temporary working directory + - Fake home directory to prevent ~/.aider.conf.yml interference + - Mocked user input and browser opening + + All environment changes are automatically cleaned up after each test. + """ # Setup original_env = os.environ.copy() os.environ["OPENAI_API_KEY"] = "deadbeef" @@ -90,1563 +115,1587 @@ def _create_env_file(file_name, content): return _create_env_file -class TestMain: - def test_main_with_empty_dir_no_files_on_command(self, dummy_io): - main(["--no-git", "--exit", "--yes-always"], **dummy_io) +def test_main_with_empty_dir_no_files_on_command(dummy_io): + main(["--no-git", "--exit", "--yes-always"], **dummy_io) - def test_main_with_emptqy_dir_new_file(self, dummy_io): - main(["foo.txt", "--yes-always", "--no-git", "--exit"], **dummy_io) - assert os.path.exists("foo.txt") +def test_main_with_emptqy_dir_new_file(dummy_io): + main(["foo.txt", "--yes-always", "--no-git", "--exit"], **dummy_io) + assert os.path.exists("foo.txt") - def test_main_with_empty_git_dir_new_file(self, dummy_io, mocker): - mocker.patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") - make_repo() - main(["--yes-always", "foo.txt", "--exit"], **dummy_io) - assert os.path.exists("foo.txt") +def test_main_with_empty_git_dir_new_file(dummy_io, mocker): + mocker.patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") + make_repo() + main(["--yes-always", "foo.txt", "--exit"], **dummy_io) + assert os.path.exists("foo.txt") - def test_main_with_empty_git_dir_new_files(self, dummy_io, mocker): - mocker.patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") - make_repo() - main( - ["--yes-always", "foo.txt", "bar.txt", "--exit"], +def test_main_with_empty_git_dir_new_files(dummy_io, mocker): + mocker.patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") + make_repo() + main( + ["--yes-always", "foo.txt", "bar.txt", "--exit"], + **dummy_io, + ) + assert os.path.exists("foo.txt") + assert os.path.exists("bar.txt") + +def test_main_with_dname_and_fname(dummy_io, git_temp_dir): + subdir = Path("subdir") + subdir.mkdir() + make_repo(str(subdir)) + res = main(["subdir", "foo.txt"], **dummy_io) + assert res is not None + +def test_main_with_subdir_repo_fnames(dummy_io, git_temp_dir, mocker): + mocker.patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") + subdir = Path("subdir") + subdir.mkdir() + make_repo(str(subdir)) + main( + ["--yes-always", str(subdir / "foo.txt"), str(subdir / "bar.txt"), "--exit"], + **dummy_io, + ) + assert (subdir / "foo.txt").exists() + assert (subdir / "bar.txt").exists() + +def test_main_copy_paste_model_overrides(dummy_io, git_temp_dir): + overrides = json.dumps({"gpt-4o": {"fast": {"temperature": 0.42}}}) + coder = main( + [ + "--no-git", + "--exit", + "--yes-always", + "--model", + "cp:gpt-4o:fast", + "--model-overrides", + overrides, + ], + **dummy_io, + return_coder=True, + ) + + assert isinstance(coder, CopyPasteCoder) + assert coder.main_model.copy_paste_mode + assert coder.main_model.copy_paste_transport == "clipboard" + assert coder.main_model.override_kwargs == {"temperature": 0.42} + +def test_main_copy_paste_flag_sets_mode(dummy_io, git_temp_dir, mocker): + mock_watcher = mocker.patch("aider.main.ClipboardWatcher") + mock_watcher.return_value = MagicMock() + + coder = main( + ["--no-git", "--exit", "--yes-always", "--copy-paste"], + **dummy_io, + return_coder=True, + ) + + assert not isinstance(coder, CopyPasteCoder) + assert coder.main_model.copy_paste_mode + assert coder.main_model.copy_paste_transport == "api" + assert coder.copy_paste_mode + assert not coder.manual_copy_paste + +def test_main_with_git_config_yml(dummy_io, mock_coder, git_temp_dir): + make_repo() + + Path(".aider.conf.yml").write_text("auto-commits: false\n") + main(["--yes-always"], **dummy_io) + _, kwargs = mock_coder.call_args + assert kwargs["auto_commits"] is False + + Path(".aider.conf.yml").write_text("auto-commits: true\n") + mock_coder.reset_mock() + mock_coder.return_value._autosave_future = mock_autosave_future() + main([], **dummy_io) + _, kwargs = mock_coder.call_args + assert kwargs["auto_commits"] is True + +def test_main_with_empty_git_dir_new_subdir_file(dummy_io, git_temp_dir): + 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. + main(["--yes-always", str(fname), "--exit"], **dummy_io) + +def test_setup_git(dummy_io): + io = InputOutput(pretty=False, yes=True) + git_root = asyncio.run(setup_git(None, io)) + git_root = Path(git_root).resolve() + assert git_root == Path(os.getcwd()).resolve() + + assert git.Repo(os.getcwd()) + + gitignore = Path.cwd() / ".gitignore" + assert gitignore.exists() + assert ".aider*" == gitignore.read_text().splitlines()[0] + +def test_check_gitignore(dummy_io, git_temp_dir, monkeypatch): + monkeypatch.setenv("GIT_CONFIG_GLOBAL", "globalgitconfig") + + io = InputOutput(pretty=False, yes=True) + cwd = Path.cwd() + gitignore = cwd / ".gitignore" + + assert not gitignore.exists() + asyncio.run(check_gitignore(cwd, io)) + assert gitignore.exists() + + assert ".aider*" == gitignore.read_text().splitlines()[0] + + # Test without .env file present + gitignore.write_text("one\ntwo\n") + asyncio.run(check_gitignore(cwd, io)) + assert "one\ntwo\n.aider*\n" == gitignore.read_text() + + # Test with .env file present + env_file = cwd / ".env" + env_file.touch() + asyncio.run(check_gitignore(cwd, io)) + assert "one\ntwo\n.aider*\n.env\n" == gitignore.read_text() + +def test_command_line_gitignore_files_flag(dummy_io): + 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 = main( + ["--exit", "--yes-always", abs_ignored_file], **dummy_io, + return_coder=True, + force_git_root=git_dir, ) - assert os.path.exists("foo.txt") - assert os.path.exists("bar.txt") - - def test_main_with_dname_and_fname(self, dummy_io, git_temp_dir): - subdir = Path("subdir") - subdir.mkdir() - make_repo(str(subdir)) - res = main(["subdir", "foo.txt"], **dummy_io) - assert res is not None + # Verify the ignored file is not in the chat + assert abs_ignored_file not in coder.abs_fnames - def test_main_with_subdir_repo_fnames(self, dummy_io, git_temp_dir, mocker): - mocker.patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") - subdir = Path("subdir") - subdir.mkdir() - make_repo(str(subdir)) - main( - ["--yes-always", str(subdir / "foo.txt"), str(subdir / "bar.txt"), "--exit"], + # Test with --add-gitignore-files set to True + coder = main( + ["--add-gitignore-files", "--exit", "--yes-always", abs_ignored_file], **dummy_io, + return_coder=True, + force_git_root=git_dir, ) - assert (subdir / "foo.txt").exists() - assert (subdir / "bar.txt").exists() + # Verify the ignored file is in the chat + assert abs_ignored_file in coder.abs_fnames - def test_main_copy_paste_model_overrides(self, dummy_io, git_temp_dir): - overrides = json.dumps({"gpt-4o": {"fast": {"temperature": 0.42}}}) + # Test with --add-gitignore-files set to False coder = main( - [ - "--no-git", - "--exit", - "--yes-always", - "--model", - "cp:gpt-4o:fast", - "--model-overrides", - overrides, - ], + ["--no-add-gitignore-files", "--exit", "--yes-always", abs_ignored_file], **dummy_io, return_coder=True, + force_git_root=git_dir, ) + # Verify the ignored file is not in the chat + assert abs_ignored_file not in coder.abs_fnames - assert isinstance(coder, CopyPasteCoder) - assert coder.main_model.copy_paste_mode - assert coder.main_model.copy_paste_transport == "clipboard" - assert coder.main_model.override_kwargs == {"temperature": 0.42} +def test_add_command_gitignore_files_flag(dummy_io): + with GitTemporaryDirectory() as git_dir: + git_dir = Path(git_dir) - def test_main_copy_paste_flag_sets_mode(self, dummy_io, git_temp_dir, mocker): - mock_watcher = mocker.patch("aider.main.ClipboardWatcher") - mock_watcher.return_value = MagicMock() + # 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 = main( - ["--no-git", "--exit", "--yes-always", "--copy-paste"], + ["--exit", "--yes-always"], **dummy_io, return_coder=True, + force_git_root=git_dir, ) - assert not isinstance(coder, CopyPasteCoder) - assert coder.main_model.copy_paste_mode - assert coder.main_model.copy_paste_transport == "api" - assert coder.copy_paste_mode - assert not coder.manual_copy_paste - - def test_main_with_git_config_yml(self, dummy_io, mock_coder, git_temp_dir): - make_repo() - - Path(".aider.conf.yml").write_text("auto-commits: false\n") - main(["--yes-always"], **dummy_io) - _, kwargs = mock_coder.call_args - assert kwargs["auto_commits"] is False - - Path(".aider.conf.yml").write_text("auto-commits: true\n") - mock_coder.reset_mock() - mock_coder.return_value._autosave_future = mock_autosave_future() - main([], **dummy_io) - _, kwargs = mock_coder.call_args - assert kwargs["auto_commits"] is True - - def test_main_with_empty_git_dir_new_subdir_file(self, dummy_io, git_temp_dir): - 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. - main(["--yes-always", str(fname), "--exit"], **dummy_io) - - def test_setup_git(self, dummy_io): - io = InputOutput(pretty=False, yes=True) - git_root = asyncio.run(setup_git(None, io)) - git_root = Path(git_root).resolve() - assert git_root == Path(os.getcwd()).resolve() - - assert git.Repo(os.getcwd()) - - gitignore = Path.cwd() / ".gitignore" - assert gitignore.exists() - assert ".aider*" == gitignore.read_text().splitlines()[0] - - def test_check_gitignore(self, dummy_io, git_temp_dir, monkeypatch): - monkeypatch.setenv("GIT_CONFIG_GLOBAL", "globalgitconfig") - - io = InputOutput(pretty=False, yes=True) - cwd = Path.cwd() - gitignore = cwd / ".gitignore" - - assert not gitignore.exists() - asyncio.run(check_gitignore(cwd, io)) - assert gitignore.exists() - - assert ".aider*" == gitignore.read_text().splitlines()[0] - - # Test without .env file present - gitignore.write_text("one\ntwo\n") - asyncio.run(check_gitignore(cwd, io)) - assert "one\ntwo\n.aider*\n" == gitignore.read_text() - - # Test with .env file present - env_file = cwd / ".env" - env_file.touch() - asyncio.run(check_gitignore(cwd, io)) - assert "one\ntwo\n.aider*\n.env\n" == gitignore.read_text() - - def test_command_line_gitignore_files_flag(self, dummy_io): - 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 = main( - ["--exit", "--yes-always", abs_ignored_file], - **dummy_io, - return_coder=True, - force_git_root=git_dir, - ) - # Verify the ignored file is not in the chat - assert abs_ignored_file not in coder.abs_fnames - - # Test with --add-gitignore-files set to True - coder = main( - ["--add-gitignore-files", "--exit", "--yes-always", abs_ignored_file], - **dummy_io, - return_coder=True, - force_git_root=git_dir, - ) - # Verify the ignored file is in the chat - assert abs_ignored_file in coder.abs_fnames - - # Test with --add-gitignore-files set to False - coder = main( - ["--no-add-gitignore-files", "--exit", "--yes-always", abs_ignored_file], - **dummy_io, - return_coder=True, - force_git_root=git_dir, - ) - # Verify the ignored file is not in the chat - assert abs_ignored_file not in coder.abs_fnames - - def test_add_command_gitignore_files_flag(self, dummy_io): - 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 = main( - ["--exit", "--yes-always"], - **dummy_io, - return_coder=True, - force_git_root=git_dir, - ) - - try: - asyncio.run(coder.commands.do_run("add", rel_ignored_file)) - except SwitchCoder: - pass - - # Verify the ignored file is not in the chat - assert abs_ignored_file not in coder.abs_fnames - - # Test with --add-gitignore-files set to True - coder = main( - ["--add-gitignore-files", "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - force_git_root=git_dir, - ) - try: - asyncio.run(coder.commands.do_run("add", rel_ignored_file)) - except SwitchCoder: - pass - - # Verify the ignored file is in the chat - assert abs_ignored_file in coder.abs_fnames - - # Test with --add-gitignore-files set to False - coder = main( - ["--no-add-gitignore-files", "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - force_git_root=git_dir, - ) - - try: - asyncio.run(coder.commands.do_run("add", rel_ignored_file)) - except SwitchCoder: - pass - - # Verify the ignored file is not in the chat - assert abs_ignored_file not in coder.abs_fnames - - @pytest.mark.parametrize( - "args,expected_kwargs", - [ - (["--no-auto-commits", "--yes-always"], {"auto_commits": False}), - (["--auto-commits", "--no-git"], {"auto_commits": True}), - (["--no-git"], {"dirty_commits": True, "auto_commits": True}), - (["--no-dirty-commits", "--no-git"], {"dirty_commits": False}), - (["--dirty-commits", "--no-git"], {"dirty_commits": True}), - ], - ids=["no_auto_commits", "auto_commits", "defaults", "no_dirty_commits", "dirty_commits"], - ) - def test_main_args(self, args, expected_kwargs, dummy_io, mock_coder, git_temp_dir): - main(args, **dummy_io) - _, kwargs = mock_coder.call_args - for key, expected_value in expected_kwargs.items(): - assert kwargs[key] is expected_value - - def test_env_file_override(self, dummy_io, git_temp_dir, mocker, monkeypatch): - with GitTemporaryDirectory() as git_dir: - git_dir = Path(git_dir) - git_env = git_dir / ".env" - - fake_home = git_dir / "fake_home" - fake_home.mkdir() - monkeypatch.setenv("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" - - monkeypatch.setenv("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") - - mocker.patch("pathlib.Path.home", return_value=fake_home) - main(["--yes-always", "--exit", "--env-file", str(named_env)]) - - assert os.environ["A"] == "named" - assert os.environ["B"] == "cwd" - assert os.environ["C"] == "git" - assert os.environ["D"] == "home" - assert os.environ["E"] == "existing" - - def test_message_file_flag(self, dummy_io, git_temp_dir, mocker): - 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): + try: + asyncio.run(coder.commands.do_run("add", rel_ignored_file)) + except SwitchCoder: pass - MockCoder = mocker.patch("aider.coders.Coder.create") - # Create a mock coder instance with an async run method - mock_coder_instance = MagicMock() - mock_coder_instance.run = AsyncMock() - mock_coder_instance._autosave_future = mock_autosave_future() - MockCoder.return_value = mock_coder_instance + # Verify the ignored file is not in the chat + assert abs_ignored_file not in coder.abs_fnames - main( - ["--yes-always", "--message-file", message_file_path], + # Test with --add-gitignore-files set to True + coder = main( + ["--add-gitignore-files", "--exit", "--yes-always"], **dummy_io, + return_coder=True, + force_git_root=git_dir, ) - # 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) + try: + asyncio.run(coder.commands.do_run("add", rel_ignored_file)) + except SwitchCoder: + pass - def test_encodings_arg(self, dummy_io, git_temp_dir, mocker): - fname = "foo.py" + # Verify the ignored file is in the chat + assert abs_ignored_file in coder.abs_fnames - MockCoder = mocker.patch("aider.coders.Coder.create") - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() - MockSend = mocker.patch("aider.main.InputOutput") - - def side_effect(*args, **kwargs): - assert kwargs["encoding"] == "iso-8859-15" - mock_io = MagicMock() - mock_io.confirm_ask = AsyncMock(return_value=True) - return mock_io - - MockSend.side_effect = side_effect - - main(["--yes-always", fname, "--encoding", "iso-8859-15"]) - - def test_main_exit_calls_version_check(self, dummy_io, git_temp_dir, mocker): - mock_check_version = mocker.patch("aider.main.check_version") - mock_input_output = mocker.patch("aider.main.InputOutput") - mock_input_output.return_value.confirm_ask = AsyncMock(return_value=True) - main(["--exit", "--check-update"], **dummy_io) - mock_check_version.assert_called_once() - mock_input_output.assert_called_once() - - def test_main_message_adds_to_input_history(self, dummy_io, mocker): - mock_run = mocker.patch("aider.coders.base_coder.Coder.run") - MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) - test_message = "test message" - mock_io_instance = MockInputOutput.return_value - mock_io_instance.pretty = True - - main(["--message", test_message], **dummy_io) - - mock_io_instance.add_to_input_history.assert_called_once_with(test_message) - - def test_yes(self, dummy_io, mocker): - mock_run = mocker.patch("aider.coders.base_coder.Coder.run") - MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) - test_message = "test message" - MockInputOutput.return_value.pretty = True - - main(["--yes-always", "--message", test_message]) - args, kwargs = MockInputOutput.call_args - assert args[1] - - def test_default_yes(self, dummy_io, mocker): - mock_run = mocker.patch("aider.coders.base_coder.Coder.run") - MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) - test_message = "test message" - MockInputOutput.return_value.pretty = True - - main(["--message", test_message]) - args, kwargs = MockInputOutput.call_args - assert args[1] is None - - @pytest.mark.parametrize( - "mode_flag,expected_theme", - [ - ("--dark-mode", "monokai"), - ("--light-mode", "default"), - ], - ids=["dark_mode", "light_mode"], - ) - def test_mode_sets_code_theme(self, mode_flag, expected_theme, dummy_io, git_temp_dir, mocker): - # Mock InputOutput to capture the configuration - MockInputOutput = mocker.patch("aider.main.InputOutput") - MockInputOutput.return_value.get_input.return_value = None - main([mode_flag, "--no-git", "--exit"], **dummy_io) - # Ensure InputOutput was called - MockInputOutput.assert_called_once() - # Check if the code_theme setting matches expected - _, kwargs = MockInputOutput.call_args - assert kwargs["code_theme"] == expected_theme - - def test_env_file_flag_sets_automatic_variable(self, dummy_io, create_env_file, mocker): - env_file_path = create_env_file(".env.test", "AIDER_DARK_MODE=True") - MockInputOutput = mocker.patch("aider.main.InputOutput") - MockInputOutput.return_value.get_input.return_value = None - MockInputOutput.return_value.get_input.confirm_ask = True - main( - ["--env-file", str(env_file_path), "--no-git", "--exit"], + # Test with --add-gitignore-files set to False + coder = main( + ["--no-add-gitignore-files", "--exit", "--yes-always"], **dummy_io, + return_coder=True, + force_git_root=git_dir, ) - MockInputOutput.assert_called_once() - # Check if the color settings are for dark mode - _, kwargs = MockInputOutput.call_args - assert kwargs["code_theme"] == "monokai" - - def test_default_env_file_sets_automatic_variable(self, dummy_io, create_env_file, mocker): - create_env_file(".env", "AIDER_DARK_MODE=True") - MockInputOutput = mocker.patch("aider.main.InputOutput") - MockInputOutput.return_value.get_input.return_value = None - MockInputOutput.return_value.get_input.confirm_ask = True - main(["--no-git", "--exit"], **dummy_io) - # Ensure InputOutput was called - MockInputOutput.assert_called_once() - # Check if the color settings are for dark mode - _, kwargs = MockInputOutput.call_args - assert kwargs["code_theme"] == "monokai" - - def test_false_vals_in_env_file(self, dummy_io, mock_coder, create_env_file): - create_env_file(".env", "AIDER_SHOW_DIFFS=off") - main(["--no-git", "--yes-always"], **dummy_io) - mock_coder.assert_called_once() - _, kwargs = mock_coder.call_args - assert kwargs["show_diffs"] is False - - def test_true_vals_in_env_file(self, dummy_io, mock_coder, create_env_file): - create_env_file(".env", "AIDER_SHOW_DIFFS=on") - main(["--no-git", "--yes-always"], **dummy_io) - mock_coder.assert_called_once() - _, kwargs = mock_coder.call_args - assert kwargs["show_diffs"] is True - - def test_lint_option(self, dummy_io, git_temp_dir, mocker): - 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 - MockLinter = mocker.patch("aider.linter.Linter.lint") - MockLinter.return_value = "" - - # Run main with --lint option - main(["--lint", "--yes-always"], **dummy_io) - - # 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] - assert called_arg.endswith("dirty_file.py") - assert not called_arg.endswith(f"subdir{os.path.sep}dirty_file.py") - - def test_lint_option_with_explicit_files(self, dummy_io, git_temp_dir, mocker): - # Create two files - file1 = Path("file1.py") - file1.write_text("def foo(): pass") - file2 = Path("file2.py") - file2.write_text("def bar(): pass") - # Mock the Linter class - MockLinter = mocker.patch("aider.linter.Linter.lint") - MockLinter.return_value = "" + try: + asyncio.run(coder.commands.do_run("add", rel_ignored_file)) + except SwitchCoder: + pass - # Run main with --lint and explicit files - main( - ["--lint", "file1.py", "file2.py", "--yes-always"], - **dummy_io, - ) + # Verify the ignored file is not in the chat + assert abs_ignored_file not in coder.abs_fnames + +@pytest.mark.parametrize( + "args,expected_kwargs", + [ + (["--no-auto-commits", "--yes-always"], {"auto_commits": False}), + (["--auto-commits", "--no-git"], {"auto_commits": True}), + (["--no-git"], {"dirty_commits": True, "auto_commits": True}), + (["--no-dirty-commits", "--no-git"], {"dirty_commits": False}), + (["--dirty-commits", "--no-git"], {"dirty_commits": True}), + ], + ids=["no_auto_commits", "auto_commits", "defaults", "no_dirty_commits", "dirty_commits"], +) +def test_main_args(args, expected_kwargs, dummy_io, mock_coder, git_temp_dir): + main(args, **dummy_io) + _, kwargs = mock_coder.call_args + for key, expected_value in expected_kwargs.items(): + assert kwargs[key] is expected_value + +def test_env_file_override(dummy_io, git_temp_dir, mocker, monkeypatch): + with GitTemporaryDirectory() as git_dir: + git_dir = Path(git_dir) + git_env = git_dir / ".env" + + fake_home = git_dir / "fake_home" + fake_home.mkdir() + monkeypatch.setenv("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" + + monkeypatch.setenv("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") + + mocker.patch("pathlib.Path.home", return_value=fake_home) + main(["--yes-always", "--exit", "--env-file", str(named_env)]) + + assert os.environ["A"] == "named" + assert os.environ["B"] == "cwd" + assert os.environ["C"] == "git" + assert os.environ["D"] == "home" + assert os.environ["E"] == "existing" + +def test_message_file_flag(dummy_io, git_temp_dir, mocker): + 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 + + MockCoder = mocker.patch("aider.coders.Coder.create") + # Create a mock coder instance with an async run method + mock_coder_instance = MagicMock() + mock_coder_instance.run = AsyncMock() + mock_coder_instance._autosave_future = mock_autosave_future() + MockCoder.return_value = mock_coder_instance + + main( + ["--yes-always", "--message-file", message_file_path], + **dummy_io, + ) + # Check that run was called with the correct message + mock_coder_instance.run.assert_called_once_with(with_message=message_file_content) - # Check if the Linter was called twice (once for each file) - assert MockLinter.call_count == 2 + os.remove(message_file_path) - # Check that both files were linted - called_files = [call[0][0] for call in MockLinter.call_args_list] - assert any(f.endswith("file1.py") for f in called_files) - assert any(f.endswith("file2.py") for f in called_files) +def test_encodings_arg(dummy_io, git_temp_dir, mocker): + fname = "foo.py" - def test_lint_option_with_glob_pattern(self, dummy_io, git_temp_dir, mocker): - # Create multiple Python files - file1 = Path("test1.py") - file1.write_text("def foo(): pass") - file2 = Path("test2.py") - file2.write_text("def bar(): pass") - file3 = Path("readme.txt") - file3.write_text("not a python file") + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MockCoder.return_value + mock_coder_instance._autosave_future = mock_autosave_future() + MockSend = mocker.patch("aider.main.InputOutput") + + def side_effect(*args, **kwargs): + assert kwargs["encoding"] == "iso-8859-15" + mock_io = MagicMock() + mock_io.confirm_ask = AsyncMock(return_value=True) + return mock_io + + MockSend.side_effect = side_effect + + main(["--yes-always", fname, "--encoding", "iso-8859-15"]) + +def test_main_exit_calls_version_check(dummy_io, git_temp_dir, mocker): + mock_check_version = mocker.patch("aider.main.check_version") + mock_input_output = mocker.patch("aider.main.InputOutput") + mock_input_output.return_value.confirm_ask = AsyncMock(return_value=True) + main(["--exit", "--check-update"], **dummy_io) + mock_check_version.assert_called_once() + mock_input_output.assert_called_once() + +def test_main_message_adds_to_input_history(dummy_io, mocker): + mock_run = mocker.patch("aider.coders.base_coder.Coder.run") + MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) + test_message = "test message" + mock_io_instance = MockInputOutput.return_value + mock_io_instance.pretty = True + + main(["--message", test_message], **dummy_io) + + mock_io_instance.add_to_input_history.assert_called_once_with(test_message) + +def test_yes(dummy_io, mocker): + mock_run = mocker.patch("aider.coders.base_coder.Coder.run") + MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) + test_message = "test message" + MockInputOutput.return_value.pretty = True + + main(["--yes-always", "--message", test_message]) + args, kwargs = MockInputOutput.call_args + assert args[1] + +def test_default_yes(dummy_io, mocker): + mock_run = mocker.patch("aider.coders.base_coder.Coder.run") + MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) + test_message = "test message" + MockInputOutput.return_value.pretty = True + + main(["--message", test_message]) + args, kwargs = MockInputOutput.call_args + assert args[1] is None + +@pytest.mark.parametrize( + "mode_flag,expected_theme", + [ + ("--dark-mode", "monokai"), + ("--light-mode", "default"), + ], + ids=["dark_mode", "light_mode"], +) +def test_mode_sets_code_theme(mode_flag, expected_theme, dummy_io, git_temp_dir, mocker): + # Mock InputOutput to capture the configuration + MockInputOutput = mocker.patch("aider.main.InputOutput") + MockInputOutput.return_value.get_input.return_value = None + main([mode_flag, "--no-git", "--exit"], **dummy_io) + # Ensure InputOutput was called + MockInputOutput.assert_called_once() + # Check if the code_theme setting matches expected + _, kwargs = MockInputOutput.call_args + assert kwargs["code_theme"] == expected_theme + +def test_env_file_flag_sets_automatic_variable(dummy_io, create_env_file, mocker): + env_file_path = create_env_file(".env.test", "AIDER_DARK_MODE=True") + MockInputOutput = mocker.patch("aider.main.InputOutput") + MockInputOutput.return_value.get_input.return_value = None + MockInputOutput.return_value.get_input.confirm_ask = True + main( + ["--env-file", str(env_file_path), "--no-git", "--exit"], + **dummy_io, + ) + MockInputOutput.assert_called_once() + # Check if the color settings are for dark mode + _, kwargs = MockInputOutput.call_args + assert kwargs["code_theme"] == "monokai" + +def test_default_env_file_sets_automatic_variable(dummy_io, create_env_file, mocker): + create_env_file(".env", "AIDER_DARK_MODE=True") + MockInputOutput = mocker.patch("aider.main.InputOutput") + MockInputOutput.return_value.get_input.return_value = None + MockInputOutput.return_value.get_input.confirm_ask = True + main(["--no-git", "--exit"], **dummy_io) + # Ensure InputOutput was called + MockInputOutput.assert_called_once() + # Check if the color settings are for dark mode + _, kwargs = MockInputOutput.call_args + assert kwargs["code_theme"] == "monokai" + +def test_false_vals_in_env_file(dummy_io, mock_coder, create_env_file): + create_env_file(".env", "AIDER_SHOW_DIFFS=off") + main(["--no-git", "--yes-always"], **dummy_io) + mock_coder.assert_called_once() + _, kwargs = mock_coder.call_args + assert kwargs["show_diffs"] is False + +def test_true_vals_in_env_file(dummy_io, mock_coder, create_env_file): + create_env_file(".env", "AIDER_SHOW_DIFFS=on") + main(["--no-git", "--yes-always"], **dummy_io) + mock_coder.assert_called_once() + _, kwargs = mock_coder.call_args + assert kwargs["show_diffs"] is True + +def test_lint_option(dummy_io, git_temp_dir, mocker): + 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 MockLinter = mocker.patch("aider.linter.Linter.lint") MockLinter.return_value = "" - # Run main with --lint and glob pattern + # Run main with --lint option + main(["--lint", "--yes-always"], **dummy_io) + + # 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] + assert called_arg.endswith("dirty_file.py") + assert not called_arg.endswith(f"subdir{os.path.sep}dirty_file.py") + +def test_lint_option_with_explicit_files(dummy_io, git_temp_dir, mocker): + # Create two files + file1 = Path("file1.py") + file1.write_text("def foo(): pass") + file2 = Path("file2.py") + file2.write_text("def bar(): pass") + + # Mock the Linter class + MockLinter = mocker.patch("aider.linter.Linter.lint") + MockLinter.return_value = "" + + # Run main with --lint and explicit files + main( + ["--lint", "file1.py", "file2.py", "--yes-always"], + **dummy_io, + ) + + # Check if the Linter was called twice (once for each file) + assert MockLinter.call_count == 2 + + # Check that both files were linted + called_files = [call[0][0] for call in MockLinter.call_args_list] + assert any(f.endswith("file1.py") for f in called_files) + assert any(f.endswith("file2.py") for f in called_files) + +def test_lint_option_with_glob_pattern(dummy_io, git_temp_dir, mocker): + # Create multiple Python files + file1 = Path("test1.py") + file1.write_text("def foo(): pass") + file2 = Path("test2.py") + file2.write_text("def bar(): pass") + file3 = Path("readme.txt") + file3.write_text("not a python file") + + # Mock the Linter class + MockLinter = mocker.patch("aider.linter.Linter.lint") + MockLinter.return_value = "" + + # Run main with --lint and glob pattern + main( + ["--lint", "test*.py", "--yes-always"], + **dummy_io, + ) + + # Check if the Linter was called for Python files matching the glob + assert MockLinter.call_count >= 2 + + # Check that Python files were linted + called_files = [call[0][0] for call in MockLinter.call_args_list] + assert any(f.endswith("test1.py") for f in called_files) + assert any(f.endswith("test2.py") for f in called_files) + # Check that non-Python file was not linted + assert not any(f.endswith("readme.txt") for f in called_files) + +def test_verbose_mode_lists_env_vars(dummy_io, create_env_file, mocker): + create_env_file(".env", "AIDER_DARK_MODE=on") + mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) + main( + ["--no-git", "--verbose", "--exit", "--yes-always"], + **dummy_io, + ) + 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 + assert "AIDER_DARK_MODE" in relevant_output + assert "dark_mode" in relevant_output + import re + + assert re.search(r"AIDER_DARK_MODE:\s+on", relevant_output) + assert re.search(r"dark_mode:\s+True", relevant_output) + +def test_yaml_config_file_loading(dummy_io, git_temp_dir, mocker, monkeypatch): + with GitTemporaryDirectory() as git_dir: + git_dir = Path(git_dir) + + # Create fake home directory + fake_home = git_dir / "fake_home" + fake_home.mkdir() + monkeypatch.setenv("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") + + mocker.patch("pathlib.Path.home", return_value=fake_home) + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MockCoder.return_value + mock_coder_instance._autosave_future = mock_autosave_future() + # Test loading from specified config file main( - ["--lint", "test*.py", "--yes-always"], + ["--yes-always", "--exit", "--config", str(named_config)], **dummy_io, ) + _, kwargs = MockCoder.call_args + assert kwargs["main_model"].name == "gpt-4-1106-preview" + assert kwargs["map_tokens"] == 8192 - # Check if the Linter was called for Python files matching the glob - assert MockLinter.call_count >= 2 + # Test loading from current working directory + mock_coder_instance._autosave_future = mock_autosave_future() + main(["--yes-always", "--exit"], **dummy_io) + _, kwargs = MockCoder.call_args + print("kwargs:", kwargs) # Add this line for debugging + assert "main_model" in kwargs, "main_model key not found in kwargs" + assert kwargs["main_model"].name == "gpt-4-32k" + assert kwargs["map_tokens"] == 4096 + + # Test loading from git root + cwd_config.unlink() + mock_coder_instance._autosave_future = mock_autosave_future() + main(["--yes-always", "--exit"], **dummy_io) + _, kwargs = MockCoder.call_args + assert kwargs["main_model"].name == "gpt-4" + assert kwargs["map_tokens"] == 2048 - # Check that Python files were linted - called_files = [call[0][0] for call in MockLinter.call_args_list] - assert any(f.endswith("test1.py") for f in called_files) - assert any(f.endswith("test2.py") for f in called_files) - # Check that non-Python file was not linted - assert not any(f.endswith("readme.txt") for f in called_files) + # Test loading from home directory + git_config.unlink() + mock_coder_instance._autosave_future = mock_autosave_future() + main(["--yes-always", "--exit"], **dummy_io) + _, kwargs = MockCoder.call_args + assert kwargs["main_model"].name == "gpt-3.5-turbo" + assert kwargs["map_tokens"] == 1024 + +def test_map_tokens_option(dummy_io, git_temp_dir, mocker): + MockRepoMap = mocker.patch("aider.coders.base_coder.RepoMap") + MockRepoMap.return_value.max_map_tokens = 0 + main( + ["--model", "gpt-4", "--map-tokens", "0", "--exit", "--yes-always"], + **dummy_io, + ) + MockRepoMap.assert_not_called() + +def test_map_tokens_option_with_non_zero_value(dummy_io, git_temp_dir, mocker): + MockRepoMap = mocker.patch("aider.coders.base_coder.RepoMap") + MockRepoMap.return_value.max_map_tokens = 1000 + main( + ["--model", "gpt-4", "--map-tokens", "1000", "--exit", "--yes-always"], + **dummy_io, + ) + MockRepoMap.assert_called_once() - def test_verbose_mode_lists_env_vars(self, dummy_io, create_env_file, mocker): - create_env_file(".env", "AIDER_DARK_MODE=on") - mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) - main( - ["--no-git", "--verbose", "--exit", "--yes-always"], - **dummy_io, - ) - 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 - assert "AIDER_DARK_MODE" in relevant_output - assert "dark_mode" in relevant_output - import re - - assert re.search(r"AIDER_DARK_MODE:\s+on", relevant_output) - assert re.search(r"dark_mode:\s+True", relevant_output) - - def test_yaml_config_file_loading(self, dummy_io, git_temp_dir, mocker, monkeypatch): - with GitTemporaryDirectory() as git_dir: - git_dir = Path(git_dir) - - # Create fake home directory - fake_home = git_dir / "fake_home" - fake_home.mkdir() - monkeypatch.setenv("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") - - mocker.patch("pathlib.Path.home", return_value=fake_home) - MockCoder = mocker.patch("aider.coders.Coder.create") - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() - # Test loading from specified config file - main( - ["--yes-always", "--exit", "--config", str(named_config)], - **dummy_io, - ) - _, kwargs = MockCoder.call_args - assert kwargs["main_model"].name == "gpt-4-1106-preview" - assert kwargs["map_tokens"] == 8192 - - # Test loading from current working directory - mock_coder_instance._autosave_future = mock_autosave_future() - main(["--yes-always", "--exit"], **dummy_io) - _, kwargs = MockCoder.call_args - print("kwargs:", kwargs) # Add this line for debugging - assert "main_model" in kwargs, "main_model key not found in kwargs" - assert kwargs["main_model"].name == "gpt-4-32k" - assert kwargs["map_tokens"] == 4096 - - # Test loading from git root - cwd_config.unlink() - mock_coder_instance._autosave_future = mock_autosave_future() - main(["--yes-always", "--exit"], **dummy_io) - _, kwargs = MockCoder.call_args - assert kwargs["main_model"].name == "gpt-4" - assert kwargs["map_tokens"] == 2048 - - # Test loading from home directory - git_config.unlink() - mock_coder_instance._autosave_future = mock_autosave_future() - main(["--yes-always", "--exit"], **dummy_io) - _, kwargs = MockCoder.call_args - assert kwargs["main_model"].name == "gpt-3.5-turbo" - assert kwargs["map_tokens"] == 1024 - - def test_map_tokens_option(self, dummy_io, git_temp_dir, mocker): - MockRepoMap = mocker.patch("aider.coders.base_coder.RepoMap") - MockRepoMap.return_value.max_map_tokens = 0 - main( - ["--model", "gpt-4", "--map-tokens", "0", "--exit", "--yes-always"], - **dummy_io, - ) - MockRepoMap.assert_not_called() +def test_read_option(dummy_io, git_temp_dir): + test_file = "test_file.txt" + Path(test_file).touch() - def test_map_tokens_option_with_non_zero_value(self, dummy_io, git_temp_dir, mocker): - MockRepoMap = mocker.patch("aider.coders.base_coder.RepoMap") - MockRepoMap.return_value.max_map_tokens = 1000 - main( - ["--model", "gpt-4", "--map-tokens", "1000", "--exit", "--yes-always"], - **dummy_io, - ) - MockRepoMap.assert_called_once() + coder = main( + ["--read", test_file, "--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) + + assert str(Path(test_file).resolve()) in coder.abs_read_only_fnames - def test_read_option(self, dummy_io, git_temp_dir): - test_file = "test_file.txt" - Path(test_file).touch() +def test_read_option_with_external_file(dummy_io, git_temp_dir): + with tempfile.NamedTemporaryFile(mode="w", delete=False) as external_file: + external_file.write("External file content") + external_file_path = external_file.name + try: coder = main( - ["--read", test_file, "--exit", "--yes-always"], + ["--read", external_file_path, "--exit", "--yes-always"], **dummy_io, return_coder=True, ) - assert str(Path(test_file).resolve()) in coder.abs_read_only_fnames - - def test_read_option_with_external_file(self, dummy_io, git_temp_dir): - with tempfile.NamedTemporaryFile(mode="w", delete=False) as external_file: - external_file.write("External file content") - external_file_path = external_file.name - - try: - coder = main( - ["--read", external_file_path, "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) - - real_external_file_path = os.path.realpath(external_file_path) - assert real_external_file_path in coder.abs_read_only_fnames - finally: - os.unlink(external_file_path) - - def test_model_metadata_file(self, dummy_io, git_temp_dir): - # 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() + real_external_file_path = os.path.realpath(external_file_path) + assert real_external_file_path in coder.abs_read_only_fnames + finally: + os.unlink(external_file_path) - from aider.llm import litellm +def test_model_metadata_file(dummy_io, git_temp_dir): + # Re-init so we don't have old data lying around from earlier test cases + from aider import models - litellm._lazy_module = None + models.model_info_manager = models.ModelInfoManager() - metadata_file = Path(".aider.model.metadata.json") + from aider.llm import litellm - # 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)) + litellm._lazy_module = None - coder = main( - [ - "--model", - "deepseek/deepseek-chat", - "--model-metadata-file", - str(metadata_file), - "--exit", - "--yes-always", - ], - **dummy_io, - return_coder=True, - ) - - assert coder.main_model.info["max_input_tokens"] == 1234 + metadata_file = Path(".aider.model.metadata.json") - def test_sonnet_and_cache_options(self, dummy_io, git_temp_dir, mocker): - MockRepoMap = mocker.patch("aider.coders.base_coder.RepoMap") - mock_repo_map = MagicMock() - mock_repo_map.max_map_tokens = 1000 # Set a specific value - MockRepoMap.return_value = mock_repo_map + # 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)) - main( - ["--sonnet", "--cache-prompts", "--exit", "--yes-always"], - **dummy_io, - ) + coder = main( + [ + "--model", + "deepseek/deepseek-chat", + "--model-metadata-file", + str(metadata_file), + "--exit", + "--yes-always", + ], + **dummy_io, + return_coder=True, + ) - MockRepoMap.assert_called_once() - call_args, call_kwargs = MockRepoMap.call_args - assert call_kwargs.get("refresh") == "files" # Check the 'refresh' keyword argument + assert coder.main_model.info["max_input_tokens"] == 1234 - def test_sonnet_and_cache_prompts_options(self, dummy_io, git_temp_dir): - coder = main( - ["--sonnet", "--cache-prompts", "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) +def test_sonnet_and_cache_options(dummy_io, git_temp_dir, mocker): + MockRepoMap = mocker.patch("aider.coders.base_coder.RepoMap") + mock_repo_map = MagicMock() + mock_repo_map.max_map_tokens = 1000 # Set a specific value + MockRepoMap.return_value = mock_repo_map - assert coder.add_cache_headers + main( + ["--sonnet", "--cache-prompts", "--exit", "--yes-always"], + **dummy_io, + ) - def test_4o_and_cache_options(self, dummy_io, git_temp_dir): - coder = main( - ["--4o", "--cache-prompts", "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) + MockRepoMap.assert_called_once() + call_args, call_kwargs = MockRepoMap.call_args + assert call_kwargs.get("refresh") == "files" # Check the 'refresh' keyword argument - assert not coder.add_cache_headers +def test_sonnet_and_cache_prompts_options(dummy_io, git_temp_dir): + coder = main( + ["--sonnet", "--cache-prompts", "--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) - def test_return_coder(self, dummy_io, git_temp_dir): - result = main( - ["--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) - assert isinstance(result, Coder) + assert coder.add_cache_headers - result = main( - ["--exit", "--yes-always"], - **dummy_io, - return_coder=False, - ) - assert result == 0 +def test_4o_and_cache_options(dummy_io, git_temp_dir): + coder = main( + ["--4o", "--cache-prompts", "--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) - def test_map_mul_option(self, dummy_io, git_temp_dir): - coder = main( - ["--map-mul", "5", "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) - assert isinstance(coder, Coder) - assert coder.repo_map.map_mul_no_files == 5 + assert not coder.add_cache_headers - @pytest.mark.parametrize( - "flag_arg,attr_name,expected", - [ - (None, "suggest_shell_commands", True), - ("--no-suggest-shell-commands", "suggest_shell_commands", False), - ("--suggest-shell-commands", "suggest_shell_commands", True), - (None, "detect_urls", True), - ("--no-detect-urls", "detect_urls", False), - ("--detect-urls", "detect_urls", True), - ], - ids=[ - "suggest_default", - "suggest_disabled", - "suggest_enabled", - "urls_default", - "urls_disabled", - "urls_enabled", - ], +def test_return_coder(dummy_io, git_temp_dir): + result = main( + ["--exit", "--yes-always"], + **dummy_io, + return_coder=True, ) - def test_boolean_flags(self, flag_arg, attr_name, expected, dummy_io, git_temp_dir): - args = ["--exit", "--yes-always"] - if flag_arg: - args.insert(0, flag_arg) - coder = main(args, **dummy_io, return_coder=True) - assert getattr(coder, attr_name) == expected - - def test_accepts_settings_warnings(self, dummy_io, git_temp_dir, mocker): - # Test that appropriate warnings are shown based on accepts_settings configuration - # Test model that accepts the thinking_tokens setting - mock_warning = mocker.patch("aider.io.InputOutput.tool_warning") - mock_set_thinking = mocker.patch("aider.models.Model.set_thinking_tokens") - main( - [ - "--model", - "anthropic/claude-3-7-sonnet-20250219", - "--thinking-tokens", - "1000", - "--yes-always", - "--exit", - ], - **dummy_io, - ) - # No warning should be shown as this model accepts thinking_tokens - for call in mock_warning.call_args_list: - assert "thinking_tokens" not in 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 - mock_warning.reset_mock() - mock_set_thinking.reset_mock() - main( - [ - "--model", - "gpt-4o", - "--thinking-tokens", - "1000", - "--check-model-accepts-settings", - "--yes-always", - "--exit", - ], - **dummy_io, - ) - # 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 - assert 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 - mock_warning.reset_mock() - mock_set_reasoning = mocker.patch("aider.models.Model.set_reasoning_effort") - main( - ["--model", "o1", "--reasoning-effort", "3", "--yes-always", "--exit"], - **dummy_io, - ) - # No warning should be shown as this model accepts reasoning_effort - for call in mock_warning.call_args_list: - assert "reasoning_effort" not in 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 - mock_warning.reset_mock() - mock_set_reasoning.reset_mock() - main( - [ - "--model", - "gpt-3.5-turbo", - "--reasoning-effort", - "3", - "--yes-always", - "--exit", - ], - **dummy_io, - ) - # 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 - assert warning_shown - # Method should still be called by default - mock_set_reasoning.assert_not_called() - - def test_no_verify_ssl_sets_model_info_manager(self, dummy_io, git_temp_dir, mocker): - mock_set_verify_ssl = mocker.patch("aider.models.ModelInfoManager.set_verify_ssl") - # Mock Model class to avoid actual model initialization - mock_model = mocker.patch("aider.models.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 - mocker.patch("aider.models.fuzzy_match_models", return_value=[]) - main( - ["--no-verify-ssl", "--exit", "--yes-always"], - **dummy_io, - ) - mock_set_verify_ssl.assert_called_once_with(False) + assert isinstance(result, Coder) - def test_pytest_env_vars(self, dummy_io, git_temp_dir): - # Verify that environment variables from pytest.ini are properly set - assert os.environ.get("AIDER_ANALYTICS") == "false" + result = main( + ["--exit", "--yes-always"], + **dummy_io, + return_coder=False, + ) + assert result == 0 - @pytest.mark.parametrize( - "set_env_args,expected_env,expected_result", +def test_map_mul_option(dummy_io, git_temp_dir): + coder = main( + ["--map-mul", "5", "--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) + assert isinstance(coder, Coder) + assert coder.repo_map.map_mul_no_files == 5 + +@pytest.mark.parametrize( + "flag_arg,attr_name,expected", + [ + (None, "suggest_shell_commands", True), + ("--no-suggest-shell-commands", "suggest_shell_commands", False), + ("--suggest-shell-commands", "suggest_shell_commands", True), + (None, "detect_urls", True), + ("--no-detect-urls", "detect_urls", False), + ("--detect-urls", "detect_urls", True), + ], + ids=[ + "suggest_default", + "suggest_disabled", + "suggest_enabled", + "urls_default", + "urls_disabled", + "urls_enabled", + ], +) +def test_boolean_flags(flag_arg, attr_name, expected, dummy_io, git_temp_dir): + args = ["--exit", "--yes-always"] + if flag_arg: + args.insert(0, flag_arg) + coder = main(args, **dummy_io, return_coder=True) + assert getattr(coder, attr_name) == expected + +def test_accepts_settings_warnings(dummy_io, git_temp_dir, mocker): + # Test that appropriate warnings are shown based on accepts_settings configuration + # Test model that accepts the thinking_tokens setting + mock_warning = mocker.patch("aider.io.InputOutput.tool_warning") + mock_set_thinking = mocker.patch("aider.models.Model.set_thinking_tokens") + main( [ - ( - ["--set-env", "TEST_VAR=test_value"], - {"TEST_VAR": "test_value"}, - None, - ), - ( - ["--set-env", "TEST_VAR1=value1", "--set-env", "TEST_VAR2=value2"], - {"TEST_VAR1": "value1", "TEST_VAR2": "value2"}, - None, - ), - ( - ["--set-env", "TEST_VAR=test value with spaces"], - {"TEST_VAR": "test value with spaces"}, - None, - ), - ( - ["--set-env", "INVALID_FORMAT"], - {}, - 1, - ), + "--model", + "anthropic/claude-3-7-sonnet-20250219", + "--thinking-tokens", + "1000", + "--yes-always", + "--exit", ], - ids=["single", "multiple", "with_spaces", "invalid_format"], + **dummy_io, ) - def test_set_env(self, set_env_args, expected_env, expected_result, dummy_io, git_temp_dir): - args = set_env_args + ["--exit", "--yes-always"] - result = main(args) - if expected_result is not None: - assert result == expected_result - for env_var, expected_value in expected_env.items(): - assert os.environ.get(env_var) == expected_value - - @pytest.mark.parametrize( - "api_key_args,expected_env,expected_result", + # No warning should be shown as this model accepts thinking_tokens + for call in mock_warning.call_args_list: + assert "thinking_tokens" not in 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 + mock_warning.reset_mock() + mock_set_thinking.reset_mock() + main( [ - ( - ["--api-key", "anthropic=test-key"], - {"ANTHROPIC_API_KEY": "test-key"}, - None, - ), - ( - ["--api-key", "anthropic=key1", "--api-key", "openai=key2"], - {"ANTHROPIC_API_KEY": "key1", "OPENAI_API_KEY": "key2"}, - None, - ), - ( - ["--api-key", "INVALID_FORMAT"], - {}, - 1, - ), + "--model", + "gpt-4o", + "--thinking-tokens", + "1000", + "--check-model-accepts-settings", + "--yes-always", + "--exit", ], - ids=["single", "multiple", "invalid_format"], + **dummy_io, ) - def test_api_key(self, api_key_args, expected_env, expected_result, dummy_io, git_temp_dir): - args = api_key_args + ["--exit", "--yes-always"] - result = main(args) - if expected_result is not None: - assert result == expected_result - for env_var, expected_value in expected_env.items(): - assert os.environ.get(env_var) == expected_value - - def test_git_config_include(self, dummy_io, git_temp_dir): - # 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 - assert repo.git.config("user.name") == "Included User" - assert 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 - main(["--yes-always", "--exit"], **dummy_io) - - # 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 - assert repo.git.config("user.name") == "Included User" - assert 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() - assert git_config_content == git_config_content_after - - def test_git_config_include_directive(self, dummy_io, git_temp_dir): - # 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 - assert "[include]" in modified_config_content - - # Verify the config is set up correctly using git command - repo = git.Repo(git_dir) - assert repo.git.config("user.name") == "Directive User" - assert repo.git.config("user.email") == "directive@example.com" - - # Run aider and verify it doesn't change the git config - main(["--yes-always", "--exit"], **dummy_io) - - # Check that the git config file wasn't modified - config_after_aider = git_config.read_text() - assert 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 - assert repo.git.config("user.name") == "Directive User" - assert repo.git.config("user.email") == "directive@example.com" - - def test_resolve_aiderignore_path(self, dummy_io, git_temp_dir): - # 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") - assert resolve_aiderignore_path(abs_path) == abs_path - - # Test with relative path and git root - git_root = "/path/to/git/root" - rel_path = ".aiderignore" - assert resolve_aiderignore_path(rel_path, git_root) == str(Path(git_root) / rel_path) - - # Test with relative path and no git root - rel_path = ".aiderignore" - assert resolve_aiderignore_path(rel_path) == rel_path - - def test_invalid_edit_format(self, dummy_io, git_temp_dir, mocker): - # Suppress stderr for this test as argparse prints an error message - mock_stderr = mocker.patch("sys.stderr", new_callable=StringIO) - with pytest.raises(SystemExit) as cm: - _ = main( - ["--edit-format", "not-a-real-format", "--exit", "--yes-always"], - **dummy_io, - ) - # argparse.ArgumentParser.exit() is called with status 2 for invalid choice - assert cm.value.code == 2 - stderr_output = mock_stderr.getvalue() - assert "invalid choice" in stderr_output - assert "not-a-real-format" in stderr_output - - @pytest.mark.parametrize( - "api_key_env,expected_model_substr", + # 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 + assert 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 + mock_warning.reset_mock() + mock_set_reasoning = mocker.patch("aider.models.Model.set_reasoning_effort") + main( + ["--model", "o1", "--reasoning-effort", "3", "--yes-always", "--exit"], + **dummy_io, + ) + # No warning should be shown as this model accepts reasoning_effort + for call in mock_warning.call_args_list: + assert "reasoning_effort" not in 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 + mock_warning.reset_mock() + mock_set_reasoning.reset_mock() + main( [ - ("ANTHROPIC_API_KEY", "sonnet"), - ("DEEPSEEK_API_KEY", "deepseek"), - ("OPENROUTER_API_KEY", "openrouter/"), - ("OPENAI_API_KEY", "gpt-4"), - ("GEMINI_API_KEY", "gemini"), + "--model", + "gpt-3.5-turbo", + "--reasoning-effort", + "3", + "--yes-always", + "--exit", ], - ids=["anthropic", "deepseek", "openrouter", "openai", "gemini"], + **dummy_io, + ) + # 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 + assert warning_shown + # Method should still be called by default + mock_set_reasoning.assert_not_called() + +def test_no_verify_ssl_sets_model_info_manager(dummy_io, git_temp_dir, mocker): + mock_set_verify_ssl = mocker.patch("aider.models.ModelInfoManager.set_verify_ssl") + # Mock Model class to avoid actual model initialization + mock_model = mocker.patch("aider.models.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 + mocker.patch("aider.models.fuzzy_match_models", return_value=[]) + main( + ["--no-verify-ssl", "--exit", "--yes-always"], + **dummy_io, ) - def test_default_model_selection(self, api_key_env, expected_model_substr, dummy_io, git_temp_dir): - # Save and clear all API keys to test each one in isolation - saved_keys = {} - api_keys = [ - "ANTHROPIC_API_KEY", - "DEEPSEEK_API_KEY", - "OPENROUTER_API_KEY", - "OPENAI_API_KEY", - "GEMINI_API_KEY", - ] - for key in api_keys: - if key in os.environ: - saved_keys[key] = os.environ[key] - del os.environ[key] + mock_set_verify_ssl.assert_called_once_with(False) + +def test_pytest_env_vars(dummy_io, git_temp_dir): + # Verify that environment variables from pytest.ini are properly set + assert os.environ.get("AIDER_ANALYTICS") == "false" + +@pytest.mark.parametrize( + "set_env_args,expected_env,expected_result", + [ + ( + ["--set-env", "TEST_VAR=test_value"], + {"TEST_VAR": "test_value"}, + None, + ), + ( + ["--set-env", "TEST_VAR1=value1", "--set-env", "TEST_VAR2=value2"], + {"TEST_VAR1": "value1", "TEST_VAR2": "value2"}, + None, + ), + ( + ["--set-env", "TEST_VAR=test value with spaces"], + {"TEST_VAR": "test value with spaces"}, + None, + ), + ( + ["--set-env", "INVALID_FORMAT"], + {}, + 1, + ), + ], + ids=["single", "multiple", "with_spaces", "invalid_format"], +) +def test_set_env(set_env_args, expected_env, expected_result, dummy_io, git_temp_dir): + args = set_env_args + ["--exit", "--yes-always"] + result = main(args) + if expected_result is not None: + assert result == expected_result + for env_var, expected_value in expected_env.items(): + assert os.environ.get(env_var) == expected_value + +@pytest.mark.parametrize( + "api_key_args,expected_env,expected_result", + [ + ( + ["--api-key", "anthropic=test-key"], + {"ANTHROPIC_API_KEY": "test-key"}, + None, + ), + ( + ["--api-key", "anthropic=key1", "--api-key", "openai=key2"], + {"ANTHROPIC_API_KEY": "key1", "OPENAI_API_KEY": "key2"}, + None, + ), + ( + ["--api-key", "INVALID_FORMAT"], + {}, + 1, + ), + ], + ids=["single", "multiple", "invalid_format"], +) +def test_api_key(api_key_args, expected_env, expected_result, dummy_io, git_temp_dir): + args = api_key_args + ["--exit", "--yes-always"] + result = main(args) + if expected_result is not None: + assert result == expected_result + for env_var, expected_value in expected_env.items(): + assert os.environ.get(env_var) == expected_value + +def test_git_config_include(dummy_io, git_temp_dir): + # 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" + ) - try: - os.environ[api_key_env] = "test-key" - coder = main( - ["--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) - assert expected_model_substr in coder.main_model.name.lower() - finally: - # Restore saved API keys - if api_key_env in os.environ: - del os.environ[api_key_env] - for key, value in saved_keys.items(): - os.environ[key] = value - - def test_default_model_selection_oauth_fallback(self, dummy_io, git_temp_dir, mocker): - # Test no API keys - should offer OpenRouter OAuth - # Clear all API keys to simulate no configured keys - saved_keys = {} - api_keys = [ - "ANTHROPIC_API_KEY", - "DEEPSEEK_API_KEY", - "OPENROUTER_API_KEY", - "OPENAI_API_KEY", - "GEMINI_API_KEY", - ] - for key in api_keys: - if key in os.environ: - saved_keys[key] = os.environ[key] - del os.environ[key] + # 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)) - try: - mock_offer_oauth = mocker.patch("aider.onboarding.offer_openrouter_oauth") - mock_offer_oauth.return_value = None # Simulate user declining or failure - result = main(["--exit", "--yes-always"], **dummy_io) - assert result == 1 # Expect failure since no model could be selected - mock_offer_oauth.assert_called_once() - finally: - # Restore saved API keys - for key, value in saved_keys.items(): - os.environ[key] = value - - def test_model_precedence(self, dummy_io, git_temp_dir, monkeypatch): - # Test that earlier API keys take precedence - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") - monkeypatch.setenv("OPENAI_API_KEY", "test-key") - coder = main( - ["--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) - assert "sonnet" in coder.main_model.name.lower() - - def test_model_overrides_suffix_applied(self, dummy_io, git_temp_dir, mocker): - with GitTemporaryDirectory() as git_dir: - git_dir = Path(git_dir) - overrides_file = git_dir / ".aider.model.overrides.yml" - overrides_file.write_text("gpt-4o:\n fast:\n temperature: 0.1\n") - - MockModel = mocker.patch("aider.models.Model") - MockCoder = mocker.patch("aider.coders.Coder.create") - mock_coder_instance = MagicMock() - mock_coder_instance._autosave_future = mock_autosave_future() - MockCoder.return_value = mock_coder_instance - - mock_instance = MockModel.return_value - mock_instance.info = {} - mock_instance.name = "gpt-4o" - mock_instance.validate_environment.return_value = { - "missing_keys": [], - "keys_in_environment": [], - } - mock_instance.accepts_settings = [] - mock_instance.weak_model_name = None - mock_instance.get_weak_model.return_value = None - - main( - ["--model", "gpt-4o:fast", "--exit", "--yes-always", "--no-git"], - **dummy_io, - force_git_root=git_dir, - ) - - # Find the call that constructed the main model with overrides - matched_call_found = False - for call_args in MockModel.call_args_list: - args, kwargs = call_args - if ( - args - and args[0] == "gpt-4o" - and kwargs.get("override_kwargs") == {"temperature": 0.1} - ): - matched_call_found = True - break - - assert matched_call_found, ( - "Expected a Model call with base name 'gpt-4o' and override_kwargs" - " {'temperature': 0.1}" - ) - - def test_model_overrides_no_match_preserves_model_name(self, dummy_io, git_temp_dir, mocker): - with GitTemporaryDirectory() as git_dir: - git_dir = Path(git_dir) - - MockModel = mocker.patch("aider.models.Model") - MockCoder = mocker.patch("aider.coders.Coder.create") - mock_coder_instance = MagicMock() - mock_coder_instance._autosave_future = mock_autosave_future() - MockCoder.return_value = mock_coder_instance - - mock_instance = MockModel.return_value - mock_instance.info = {} - mock_instance.name = "test-model" - mock_instance.validate_environment.return_value = { - "missing_keys": [], - "keys_in_environment": [], - } - mock_instance.accepts_settings = [] - mock_instance.weak_model_name = None - mock_instance.get_weak_model.return_value = None - - model_name = "hf:moonshotai/Kimi-K2-Thinking" - - main( - ["--model", model_name, "--exit", "--yes-always", "--no-git"], - **dummy_io, - force_git_root=git_dir, - ) - - matched_call_found = False - for call_args in MockModel.call_args_list: - args, kwargs = call_args - if args and args[0] == model_name and kwargs.get("override_kwargs") == {}: - matched_call_found = True - break - - assert matched_call_found, ( - "Expected a Model call with the full model name preserved and empty" - " override_kwargs" - ) - - def test_chat_language_spanish(self, dummy_io, git_temp_dir): - coder = main( - ["--chat-language", "Spanish", "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) - system_info = coder.get_platform_info() - assert "Spanish" in system_info + # Verify the config is set up correctly using git command + assert repo.git.config("user.name") == "Included User" + assert repo.git.config("user.email") == "included@example.com" - def test_commit_language_japanese(self, dummy_io, git_temp_dir): - coder = main( - ["--commit-language", "japanese", "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) - assert "japanese" in coder.commit_language + # 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() - def test_main_exit_with_git_command_not_found(self, dummy_io, git_temp_dir, mocker): - mock_git_init = mocker.patch("git.Repo.init") - mock_git_init.side_effect = git.exc.GitCommandNotFound("git", "Command 'git' not found") + # Run aider and verify it doesn't change the git config + main(["--yes-always", "--exit"], **dummy_io) - result = main(["--exit", "--yes-always"], **dummy_io) - assert result == 0, "main() should return 0 (success) when called with --exit" + # 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 + assert repo.git.config("user.name") == "Included User" + assert repo.git.config("user.email") == "included@example.com" - def test_reasoning_effort_option(self, dummy_io, git_temp_dir): - coder = main( - [ - "--reasoning-effort", - "3", - "--no-check-model-accepts-settings", - "--yes-always", - "--exit", - ], - **dummy_io, - return_coder=True, - ) - assert coder.main_model.extra_params.get("extra_body", {}).get("reasoning_effort") == "3" + # Manually check the git config file again to ensure it wasn't modified + git_config_content_after = git_config_path.read_text() + assert git_config_content == git_config_content_after - def test_thinking_tokens_option(self, dummy_io, git_temp_dir): - coder = main( - ["--model", "sonnet", "--thinking-tokens", "1000", "--yes-always", "--exit"], - **dummy_io, - return_coder=True, +def test_git_config_include_directive(dummy_io, git_temp_dir): + # 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" ) - assert coder.main_model.extra_params.get("thinking", {}).get("budget_tokens") == 1000 - - def test_list_models_includes_metadata_models(self, dummy_io, git_temp_dir, mocker): - # Test that models from model-metadata.json appear in list-models output - # 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 - mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) - main( - [ - "--list-models", - "unique-model", - "--model-metadata-file", - str(metadata_file), - "--yes-always", - "--no-gitignore", - ], + # 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 + assert "[include]" in modified_config_content + + # Verify the config is set up correctly using git command + repo = git.Repo(git_dir) + assert repo.git.config("user.name") == "Directive User" + assert repo.git.config("user.email") == "directive@example.com" + + # Run aider and verify it doesn't change the git config + main(["--yes-always", "--exit"], **dummy_io) + + # Check that the git config file wasn't modified + config_after_aider = git_config.read_text() + assert 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 + assert repo.git.config("user.name") == "Directive User" + assert repo.git.config("user.email") == "directive@example.com" + +def test_resolve_aiderignore_path(dummy_io, git_temp_dir): + # 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") + assert resolve_aiderignore_path(abs_path) == abs_path + + # Test with relative path and git root + git_root = "/path/to/git/root" + rel_path = ".aiderignore" + assert resolve_aiderignore_path(rel_path, git_root) == str(Path(git_root) / rel_path) + + # Test with relative path and no git root + rel_path = ".aiderignore" + assert resolve_aiderignore_path(rel_path) == rel_path + +def test_invalid_edit_format(dummy_io, git_temp_dir, mocker): + # Suppress stderr for this test as argparse prints an error message + mock_stderr = mocker.patch("sys.stderr", new_callable=StringIO) + with pytest.raises(SystemExit) as cm: + _ = main( + ["--edit-format", "not-a-real-format", "--exit", "--yes-always"], **dummy_io, ) - output = mock_stdout.getvalue() - - # Check that the unique model name from our metadata file is listed - assert "test-provider/unique-model-name" in output - - def test_list_models_includes_all_model_sources(self, dummy_io, git_temp_dir, mocker): - # Test that models from both litellm.model_cost and model-metadata.json - # appear in list-models - # 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 - mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) - main( - [ - "--list-models", - "metadata-only-model", - "--model-metadata-file", - str(metadata_file), - "--yes-always", - "--no-gitignore", - ], + # argparse.ArgumentParser.exit() is called with status 2 for invalid choice + assert cm.value.code == 2 + stderr_output = mock_stderr.getvalue() + assert "invalid choice" in stderr_output + assert "not-a-real-format" in stderr_output + +@pytest.mark.parametrize( + "api_key_env,expected_model_substr", + [ + ("ANTHROPIC_API_KEY", "sonnet"), + ("DEEPSEEK_API_KEY", "deepseek"), + ("OPENROUTER_API_KEY", "openrouter/"), + ("OPENAI_API_KEY", "gpt-4"), + ("GEMINI_API_KEY", "gemini"), + ], + ids=["anthropic", "deepseek", "openrouter", "openai", "gemini"], +) +def test_default_model_selection(api_key_env, expected_model_substr, dummy_io, git_temp_dir): + # Save and clear all API keys to test each one in isolation + saved_keys = {} + api_keys = [ + "ANTHROPIC_API_KEY", + "DEEPSEEK_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + ] + for key in api_keys: + if key in os.environ: + saved_keys[key] = os.environ[key] + del os.environ[key] + + try: + os.environ[api_key_env] = "test-key" + coder = main( + ["--exit", "--yes-always"], **dummy_io, + return_coder=True, ) - output = mock_stdout.getvalue() + assert expected_model_substr in coder.main_model.name.lower() + finally: + # Restore saved API keys + if api_key_env in os.environ: + del os.environ[api_key_env] + for key, value in saved_keys.items(): + os.environ[key] = value + +def test_default_model_selection_oauth_fallback(dummy_io, git_temp_dir, mocker): + # Test no API keys - should offer OpenRouter OAuth + # Clear all API keys to simulate no configured keys + saved_keys = {} + api_keys = [ + "ANTHROPIC_API_KEY", + "DEEPSEEK_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + ] + for key in api_keys: + if key in os.environ: + saved_keys[key] = os.environ[key] + del os.environ[key] + + try: + mock_offer_oauth = mocker.patch("aider.onboarding.offer_openrouter_oauth") + mock_offer_oauth.return_value = None # Simulate user declining or failure + result = main(["--exit", "--yes-always"], **dummy_io) + assert result == 1 # Expect failure since no model could be selected + mock_offer_oauth.assert_called_once() + finally: + # Restore saved API keys + for key, value in saved_keys.items(): + os.environ[key] = value + +def test_model_precedence(dummy_io, git_temp_dir, monkeypatch): + # Test that earlier API keys take precedence + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + coder = main( + ["--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) + assert "sonnet" in coder.main_model.name.lower() - dump(output) +def test_model_overrides_suffix_applied(dummy_io, git_temp_dir, mocker): + with GitTemporaryDirectory() as git_dir: + git_dir = Path(git_dir) + overrides_file = git_dir / ".aider.model.overrides.yml" + overrides_file.write_text("gpt-4o:\n fast:\n temperature: 0.1\n") - # Check that both models appear in the output - assert "test-provider/metadata-only-model" in output + MockModel = mocker.patch("aider.models.Model") + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MagicMock() + mock_coder_instance._autosave_future = mock_autosave_future() + MockCoder.return_value = mock_coder_instance - def test_check_model_accepts_settings_flag(self, dummy_io, git_temp_dir, mocker): - # Test that --check-model-accepts-settings affects whether settings are applied - # When flag is on, setting shouldn't be applied to non-supporting model - mock_set_thinking = mocker.patch("aider.models.Model.set_thinking_tokens") - main( - [ - "--model", - "gpt-4o", - "--thinking-tokens", - "1000", - "--check-model-accepts-settings", - "--yes-always", - "--exit", - ], - **dummy_io, - ) - # Method should not be called because model doesn't support it and flag is on - mock_set_thinking.assert_not_called() - - def test_list_models_with_direct_resource_patch(self, dummy_io, mocker): - # Test that models from resources/model-metadata.json are included in list-models output - # Create a temporary file with test model metadata - test_file = Path(os.getcwd()) / "test-model-metadata.json" - test_resource_models = { - "special-model": { - "max_input_tokens": 8192, - "litellm_provider": "resource-provider", - "mode": "chat", - } + mock_instance = MockModel.return_value + mock_instance.info = {} + mock_instance.name = "gpt-4o" + mock_instance.validate_environment.return_value = { + "missing_keys": [], + "keys_in_environment": [], } - 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 + mock_instance.accepts_settings = [] + mock_instance.weak_model_name = None + mock_instance.get_weak_model.return_value = None - mocker.patch("aider.main.importlib_resources.files", return_value=mock_files) - # Capture stdout to check the output - mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) main( - ["--list-models", "special", "--yes-always", "--no-gitignore"], + ["--model", "gpt-4o:fast", "--exit", "--yes-always", "--no-git"], **dummy_io, + force_git_root=git_dir, ) - output = mock_stdout.getvalue() - # Check that the resource model appears in the output - assert "resource-provider/special-model" in output - - # When flag is off, setting should be applied regardless of support - mock_set_reasoning = mocker.patch("aider.models.Model.set_reasoning_effort") - main( - [ - "--model", - "gpt-3.5-turbo", - "--reasoning-effort", - "3", - "--no-check-model-accepts-settings", - "--yes-always", - "--exit", - ], - **dummy_io, + # Find the call that constructed the main model with overrides + matched_call_found = False + for call_args in MockModel.call_args_list: + args, kwargs = call_args + if ( + args + and args[0] == "gpt-4o" + and kwargs.get("override_kwargs") == {"temperature": 0.1} + ): + matched_call_found = True + break + + assert matched_call_found, ( + "Expected a Model call with base name 'gpt-4o' and override_kwargs" + " {'temperature': 0.1}" ) - # Method should be called because flag is off - mock_set_reasoning.assert_called_once_with("3") - def test_model_accepts_settings_attribute(self, dummy_io, git_temp_dir, mocker): - # Test with a model where we override the accepts_settings attribute +def test_model_overrides_no_match_preserves_model_name(dummy_io, git_temp_dir, mocker): + with GitTemporaryDirectory() as git_dir: + git_dir = Path(git_dir) + MockModel = mocker.patch("aider.models.Model") - # Setup mock model instance to simulate accepts_settings attribute + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MagicMock() + mock_coder_instance._autosave_future = mock_autosave_future() + MockCoder.return_value = mock_coder_instance + mock_instance = MockModel.return_value + mock_instance.info = {} 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.accepts_settings = [] mock_instance.weak_model_name = None mock_instance.get_weak_model.return_value = None - # Run with both settings, but model only accepts reasoning_effort + model_name = "hf:moonshotai/Kimi-K2-Thinking" + main( - [ - "--model", - "test-model", - "--reasoning-effort", - "3", - "--thinking-tokens", - "1000", - "--check-model-accepts-settings", - "--yes-always", - "--exit", - ], + ["--model", model_name, "--exit", "--yes-always", "--no-git"], **dummy_io, + force_git_root=git_dir, ) - # 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() + matched_call_found = False + for call_args in MockModel.call_args_list: + args, kwargs = call_args + if args and args[0] == model_name and kwargs.get("override_kwargs") == {}: + matched_call_found = True + break - def test_stream_and_cache_warning(self, dummy_io, git_temp_dir, mocker): - MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) - mock_io_instance = MockInputOutput.return_value - mock_io_instance.pretty = True - main( - ["--stream", "--cache-prompts", "--exit", "--yes-always"], - **dummy_io, - ) - mock_io_instance.tool_warning.assert_called_with( - "Cost estimates may be inaccurate when using streaming and caching." + assert matched_call_found, ( + "Expected a Model call with the full model name preserved and empty" + " override_kwargs" ) - def test_stream_without_cache_no_warning(self, dummy_io, git_temp_dir, mocker): - MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) - mock_io_instance = MockInputOutput.return_value - mock_io_instance.pretty = True - main( - ["--stream", "--exit", "--yes-always"], - **dummy_io, - ) - for call in mock_io_instance.tool_warning.call_args_list: - assert "Cost estimates may be inaccurate" not in call[0][0] - - def test_argv_file_respects_git(self, dummy_io, git_temp_dir): - fname = Path("not_in_git.txt") - fname.touch() - with open(".gitignore", "w+") as f: - f.write("not_in_git.txt") - coder = main( - argv=["--file", "not_in_git.txt"], - **dummy_io, - return_coder=True, +def test_chat_language_spanish(dummy_io, git_temp_dir): + coder = main( + ["--chat-language", "Spanish", "--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) + system_info = coder.get_platform_info() + assert "Spanish" in system_info + +def test_commit_language_japanese(dummy_io, git_temp_dir): + coder = main( + ["--commit-language", "japanese", "--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) + assert "japanese" in coder.commit_language + +def test_main_exit_with_git_command_not_found(dummy_io, git_temp_dir, mocker): + mock_git_init = mocker.patch("git.Repo.init") + mock_git_init.side_effect = git.exc.GitCommandNotFound("git", "Command 'git' not found") + + result = main(["--exit", "--yes-always"], **dummy_io) + assert result == 0, "main() should return 0 (success) when called with --exit" + +def test_reasoning_effort_option(dummy_io, git_temp_dir): + coder = main( + [ + "--reasoning-effort", + "3", + "--no-check-model-accepts-settings", + "--yes-always", + "--exit", + ], + **dummy_io, + return_coder=True, + ) + assert coder.main_model.extra_params.get("extra_body", {}).get("reasoning_effort") == "3" + +def test_thinking_tokens_option(dummy_io, git_temp_dir): + coder = main( + ["--model", "sonnet", "--thinking-tokens", "1000", "--yes-always", "--exit"], + **dummy_io, + return_coder=True, + ) + assert coder.main_model.extra_params.get("thinking", {}).get("budget_tokens") == 1000 + +def test_list_models_includes_metadata_models(dummy_io, git_temp_dir, mocker): + # Test that models from model-metadata.json appear in list-models output + # 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 + mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) + main( + [ + "--list-models", + "unique-model", + "--model-metadata-file", + str(metadata_file), + "--yes-always", + "--no-gitignore", + ], + **dummy_io, + ) + output = mock_stdout.getvalue() + + # Check that the unique model name from our metadata file is listed + assert "test-provider/unique-model-name" in output + +def test_list_models_includes_all_model_sources(dummy_io, git_temp_dir, mocker): + # Test that models from both litellm.model_cost and model-metadata.json + # appear in list-models + # 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 + mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) + main( + [ + "--list-models", + "metadata-only-model", + "--model-metadata-file", + str(metadata_file), + "--yes-always", + "--no-gitignore", + ], + **dummy_io, + ) + output = mock_stdout.getvalue() + + dump(output) + + # Check that both models appear in the output + assert "test-provider/metadata-only-model" in output + +def test_check_model_accepts_settings_flag(dummy_io, git_temp_dir, mocker): + # Test that --check-model-accepts-settings affects whether settings are applied + # When flag is on, setting shouldn't be applied to non-supporting model + mock_set_thinking = mocker.patch("aider.models.Model.set_thinking_tokens") + main( + [ + "--model", + "gpt-4o", + "--thinking-tokens", + "1000", + "--check-model-accepts-settings", + "--yes-always", + "--exit", + ], + **dummy_io, + ) + # Method should not be called because model doesn't support it and flag is on + mock_set_thinking.assert_not_called() + +def test_list_models_with_direct_resource_patch(dummy_io, mocker): + # Test that models from resources/model-metadata.json are included in list-models output + # Create a temporary file with test model metadata + test_file = Path(os.getcwd()) / "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 + + mocker.patch("aider.main.importlib_resources.files", return_value=mock_files) + # Capture stdout to check the output + mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) + main( + ["--list-models", "special", "--yes-always", "--no-gitignore"], + **dummy_io, + ) + output = mock_stdout.getvalue() + + # Check that the resource model appears in the output + assert "resource-provider/special-model" in output + + # When flag is off, setting should be applied regardless of support + mock_set_reasoning = mocker.patch("aider.models.Model.set_reasoning_effort") + main( + [ + "--model", + "gpt-3.5-turbo", + "--reasoning-effort", + "3", + "--no-check-model-accepts-settings", + "--yes-always", + "--exit", + ], + **dummy_io, + ) + # Method should be called because flag is off + mock_set_reasoning.assert_called_once_with("3") + +def test_model_accepts_settings_attribute(dummy_io, git_temp_dir, mocker): + # Test with a model where we override the accepts_settings attribute + MockModel = mocker.patch("aider.models.Model") + # 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 + main( + [ + "--model", + "test-model", + "--reasoning-effort", + "3", + "--thinking-tokens", + "1000", + "--check-model-accepts-settings", + "--yes-always", + "--exit", + ], + **dummy_io, + ) + + # 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() + +def test_stream_and_cache_warning(dummy_io, git_temp_dir, mocker): + MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) + mock_io_instance = MockInputOutput.return_value + mock_io_instance.pretty = True + main( + ["--stream", "--cache-prompts", "--exit", "--yes-always"], + **dummy_io, + ) + mock_io_instance.tool_warning.assert_called_with( + "Cost estimates may be inaccurate when using streaming and caching." + ) + +def test_stream_without_cache_no_warning(dummy_io, git_temp_dir, mocker): + MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) + mock_io_instance = MockInputOutput.return_value + mock_io_instance.pretty = True + main( + ["--stream", "--exit", "--yes-always"], + **dummy_io, + ) + for call in mock_io_instance.tool_warning.call_args_list: + assert "Cost estimates may be inaccurate" not in call[0][0] + +def test_argv_file_respects_git(dummy_io, git_temp_dir): + fname = Path("not_in_git.txt") + fname.touch() + with open(".gitignore", "w+") as f: + f.write("not_in_git.txt") + coder = main( + argv=["--file", "not_in_git.txt"], + **dummy_io, + return_coder=True, + ) + assert "not_in_git.txt" not in str(coder.abs_fnames) + assert not asyncio.run(coder.allowed_to_edit("not_in_git.txt")) + +def test_load_dotenv_files_override(dummy_io, git_temp_dir, mocker): + 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] + + mocker.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) + assert str(oauth_keys_file.resolve()) in loaded_files + assert str(git_root_env.resolve()) in loaded_files + assert str(cwd_env.resolve()) in loaded_files + assert loaded_files.index(str(oauth_keys_file.resolve())) < loaded_files.index( + str(git_root_env.resolve()) ) - assert "not_in_git.txt" not in str(coder.abs_fnames) - assert not asyncio.run(coder.allowed_to_edit("not_in_git.txt")) - - def test_load_dotenv_files_override(self, dummy_io, git_temp_dir, mocker): - 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] - - mocker.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) - assert str(oauth_keys_file.resolve()) in loaded_files - assert str(git_root_env.resolve()) in loaded_files - assert str(cwd_env.resolve()) in loaded_files - assert loaded_files.index(str(oauth_keys_file.resolve())) < loaded_files.index( - str(git_root_env.resolve()) - ) - assert loaded_files.index(str(git_root_env.resolve())) < loaded_files.index( - str(cwd_env.resolve()) - ) - - # Assert environment variables reflect the override order - assert os.environ.get("OAUTH_VAR") == "oauth_val" - assert os.environ.get("GIT_VAR") == "git_val" - assert os.environ.get("CWD_VAR") == "cwd_val" - # SHARED_VAR should be overridden by the last loaded file (cwd .env) - assert os.environ.get("SHARED_VAR") == "cwd_shared" - - # Restore CWD - os.chdir(original_cwd) - - def test_cache_without_stream_no_warning(self, dummy_io, git_temp_dir, mocker): - MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) - mock_io_instance = MockInputOutput.return_value - mock_io_instance.pretty = True - main( - ["--cache-prompts", "--exit", "--yes-always", "--no-stream"], - **dummy_io, + assert loaded_files.index(str(git_root_env.resolve())) < loaded_files.index( + str(cwd_env.resolve()) ) - for call in mock_io_instance.tool_warning.call_args_list: - assert "Cost estimates may be inaccurate" not in call[0][0] - def test_mcp_servers_parsing(self, dummy_io, git_temp_dir, mocker): - # Setup mock coder - mock_coder_create = mocker.patch("aider.coders.Coder.create") - mock_coder_instance = MagicMock() - mock_coder_instance._autosave_future = mock_autosave_future() - mock_coder_create.return_value = mock_coder_instance + # Assert environment variables reflect the override order + assert os.environ.get("OAUTH_VAR") == "oauth_val" + assert os.environ.get("GIT_VAR") == "git_val" + assert os.environ.get("CWD_VAR") == "cwd_val" + # SHARED_VAR should be overridden by the last loaded file (cwd .env) + assert os.environ.get("SHARED_VAR") == "cwd_shared" + + # Restore CWD + os.chdir(original_cwd) + +def test_cache_without_stream_no_warning(dummy_io, git_temp_dir, mocker): + MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) + mock_io_instance = MockInputOutput.return_value + mock_io_instance.pretty = True + main( + ["--cache-prompts", "--exit", "--yes-always", "--no-stream"], + **dummy_io, + ) + for call in mock_io_instance.tool_warning.call_args_list: + assert "Cost estimates may be inaccurate" not in call[0][0] + +def test_mcp_servers_parsing(dummy_io, git_temp_dir, mocker): + # Setup mock coder + mock_coder_create = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MagicMock() + mock_coder_instance._autosave_future = mock_autosave_future() + mock_coder_create.return_value = mock_coder_instance + + # Test with --mcp-servers option + main( + [ + "--mcp-servers", + '{"mcpServers":{"git":{"command":"uvx","args":["mcp-server-git"]}}}', + "--exit", + "--yes-always", + ], + **dummy_io, + ) + + # Verify that Coder.create was called with mcp_servers parameter + mock_coder_create.assert_called_once() + _, kwargs = mock_coder_create.call_args + assert "mcp_servers" in kwargs + assert kwargs["mcp_servers"] is not None + # At least one server should be in the list + assert len(kwargs["mcp_servers"]) > 0 + # First server should have a name attribute + assert hasattr(kwargs["mcp_servers"][0], "name") + + # Test with --mcp-servers-file option + mock_coder_create.reset_mock() + mock_coder_instance._autosave_future = mock_autosave_future() + + 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)) - # Test with --mcp-servers option main( - [ - "--mcp-servers", - '{"mcpServers":{"git":{"command":"uvx","args":["mcp-server-git"]}}}', - "--exit", - "--yes-always", - ], + ["--mcp-servers-file", str(mcp_file), "--exit", "--yes-always"], **dummy_io, ) @@ -1659,28 +1708,3 @@ def test_mcp_servers_parsing(self, dummy_io, git_temp_dir, mocker): assert len(kwargs["mcp_servers"]) > 0 # First server should have a name attribute assert hasattr(kwargs["mcp_servers"][0], "name") - - # Test with --mcp-servers-file option - mock_coder_create.reset_mock() - mock_coder_instance._autosave_future = mock_autosave_future() - - 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)) - - main( - ["--mcp-servers-file", str(mcp_file), "--exit", "--yes-always"], - **dummy_io, - ) - - # Verify that Coder.create was called with mcp_servers parameter - mock_coder_create.assert_called_once() - _, kwargs = mock_coder_create.call_args - assert "mcp_servers" in kwargs - assert kwargs["mcp_servers"] is not None - # At least one server should be in the list - assert len(kwargs["mcp_servers"]) > 0 - # First server should have a name attribute - assert hasattr(kwargs["mcp_servers"][0], "name") From c8b3252f6d283ac7ed1f5082222e3eee17617e16 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:48:32 +0100 Subject: [PATCH 28/50] refactor: consolidate smoke tests into test_main.py (Phase 3E) Merge test_main_smoke.py into test_main.py to eliminate fixture duplication: - Added 2 smoke tests (test_main_executes, test_main_async_executes) - Updated smoke tests to use dummy_io fixture for consistency - Deleted tests/basic/test_main_smoke.py - Updated module docstring to reflect consolidated suite Benefits: - Single source of truth for main() tests - No fixture duplication between files - Simpler test organization Total: 94 tests (92 comprehensive + 2 smoke tests) All tests passing. --- tests/basic/test_main.py | 21 +++++++++++++--- tests/basic/test_main_smoke.py | 44 ---------------------------------- 2 files changed, 18 insertions(+), 47 deletions(-) delete mode 100644 tests/basic/test_main_smoke.py diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index fb228e51d50..58936cb3727 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -1,10 +1,11 @@ """Comprehensive tests for aider.main module. -This test suite validates the main() function and its integration with various -aider components including configuration loading, model selection, git operations, -and command-line argument parsing. +This test suite validates the main() and main_async() functions and their integration +with various aider components including configuration loading, model selection, git +operations, and command-line argument parsing. Test coverage includes: +- Smoke tests for main() and main_async() execution - Command-line argument parsing and validation - Configuration file loading (.aider.conf.yml, .env files) - Model selection and API key management @@ -13,6 +14,8 @@ - Feature flags and boolean options - Model overrides and metadata - MCP server configuration + +Total: 94 tests (92 comprehensive + 2 smoke tests) """ import asyncio import json @@ -115,6 +118,18 @@ def _create_env_file(file_name, content): return _create_env_file +# Smoke tests - quick validation that main() and main_async() execute +async def test_main_async_executes(dummy_io): + """Smoke test: Verify main_async() executes without errors.""" + from aider.main import main_async + await main_async(["--exit", "--yes-always"], **dummy_io) + + +def test_main_executes(dummy_io): + """Smoke test: Verify main() executes without errors.""" + main(["--exit", "--yes-always"], **dummy_io) + + def test_main_with_empty_dir_no_files_on_command(dummy_io): main(["--no-git", "--exit", "--yes-always"], **dummy_io) diff --git a/tests/basic/test_main_smoke.py b/tests/basic/test_main_smoke.py deleted file mode 100644 index 1586d88a3eb..00000000000 --- a/tests/basic/test_main_smoke.py +++ /dev/null @@ -1,44 +0,0 @@ -import os -import platform - -import pytest -from prompt_toolkit.input import DummyInput -from prompt_toolkit.output import DummyOutput - -from aider.main import main, main_async - - -@pytest.fixture(autouse=True) -def isolated_env(tmp_path, monkeypatch, mocker): - """Completely isolated test environment with no real API keys.""" - fake_home = tmp_path / "home" - fake_home.mkdir() - - clean_env = { - "OPENAI_API_KEY": "test-key", - "AIDER_CHECK_UPDATE": "false", - "AIDER_ANALYTICS": "false", - } - - if platform.system() == "Windows": - clean_env["USERPROFILE"] = str(fake_home) - else: - clean_env["HOME"] = str(fake_home) - - mocker.patch.dict(os.environ, clean_env, clear=True) - mocker.patch( - "aider.io.webbrowser.open", - side_effect=AssertionError("Browser should not open during tests"), - ) - mocker.patch("builtins.input", return_value=None) - monkeypatch.chdir(tmp_path) - - yield tmp_path - - -async def test_main_async_executes(): - await main_async(["--exit", "--yes-always"], input=DummyInput(), output=DummyOutput()) - - -def test_main_executes(): - main(["--exit", "--yes-always"], input=DummyInput(), output=DummyOutput()) From 9b6962665f129f1e568dc8e62cead079901b6437 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:51:52 +0100 Subject: [PATCH 29/50] refactor: remove redundant smoke test Remove test_main_executes as it duplicates existing coverage: - test_main_with_empty_dir_no_files_on_command already tests main() execution - 90+ other tests call main() with various arguments - No unique value added by the redundant smoke test Keep test_main_async_executes as it's the ONLY test for main_async(). Applying clean code principles: avoid test duplication. Total: 93 tests (92 comprehensive + 1 async smoke test) All tests passing. --- tests/basic/test_main.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 58936cb3727..b1d62d491aa 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -5,7 +5,7 @@ operations, and command-line argument parsing. Test coverage includes: -- Smoke tests for main() and main_async() execution +- Smoke test for main_async() execution - Command-line argument parsing and validation - Configuration file loading (.aider.conf.yml, .env files) - Model selection and API key management @@ -15,7 +15,7 @@ - Model overrides and metadata - MCP server configuration -Total: 94 tests (92 comprehensive + 2 smoke tests) +Total: 93 tests (92 comprehensive + 1 async smoke test) """ import asyncio import json @@ -118,18 +118,13 @@ def _create_env_file(file_name, content): return _create_env_file -# Smoke tests - quick validation that main() and main_async() execute +# Smoke test - quick validation that main_async() executes async def test_main_async_executes(dummy_io): """Smoke test: Verify main_async() executes without errors.""" from aider.main import main_async await main_async(["--exit", "--yes-always"], **dummy_io) -def test_main_executes(dummy_io): - """Smoke test: Verify main() executes without errors.""" - main(["--exit", "--yes-always"], **dummy_io) - - def test_main_with_empty_dir_no_files_on_command(dummy_io): main(["--no-git", "--exit", "--yes-always"], **dummy_io) From 3dc217b1a288cd872b8846c1f17abf1d27ae0041 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 01:55:22 +0100 Subject: [PATCH 30/50] refactor: remove redundant async smoke test Remove test_main_async_executes as it provides zero additional coverage: - main() is just a thin wrapper: asyncio.run(main_async(...)) - All 92 tests calling main() already test main_async() indirectly - All business logic lives in main_async(), tested via main() Updated module docstring to clarify that tests cover both entry points. Clean code principle: eliminated all test duplication. Total: 92 comprehensive tests All tests passing. --- tests/basic/test_main.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index b1d62d491aa..3d57868d241 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -1,11 +1,13 @@ """Comprehensive tests for aider.main module. -This test suite validates the main() and main_async() functions and their integration -with various aider components including configuration loading, model selection, git -operations, and command-line argument parsing. +This test suite validates the main() function and its integration with various +aider components including configuration loading, model selection, git operations, +and command-line argument parsing. + +Note: main() is a thin wrapper around main_async() that uses asyncio.run(), so +these tests validate both the synchronous and asynchronous entry points. Test coverage includes: -- Smoke test for main_async() execution - Command-line argument parsing and validation - Configuration file loading (.aider.conf.yml, .env files) - Model selection and API key management @@ -15,7 +17,7 @@ - Model overrides and metadata - MCP server configuration -Total: 93 tests (92 comprehensive + 1 async smoke test) +Total: 92 tests """ import asyncio import json @@ -118,13 +120,6 @@ def _create_env_file(file_name, content): return _create_env_file -# Smoke test - quick validation that main_async() executes -async def test_main_async_executes(dummy_io): - """Smoke test: Verify main_async() executes without errors.""" - from aider.main import main_async - await main_async(["--exit", "--yes-always"], **dummy_io) - - def test_main_with_empty_dir_no_files_on_command(dummy_io): main(["--no-git", "--exit", "--yes-always"], **dummy_io) From fb4526f1c8bd98ab9b1a26ab6f5fc131f4bf65a5 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 09:19:11 +0100 Subject: [PATCH 31/50] fix: correct typo in test function name (emptqy -> empty) --- tests/basic/test_main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 3d57868d241..81e446567cc 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -123,7 +123,7 @@ def _create_env_file(file_name, content): def test_main_with_empty_dir_no_files_on_command(dummy_io): main(["--no-git", "--exit", "--yes-always"], **dummy_io) -def test_main_with_emptqy_dir_new_file(dummy_io): +def test_main_with_empty_dir_new_file(dummy_io): main(["foo.txt", "--yes-always", "--no-git", "--exit"], **dummy_io) assert os.path.exists("foo.txt") From 6ba31544cc30d48b04a6846389acb47d4cbad89d Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 09:30:31 +0100 Subject: [PATCH 32/50] refactor: apply clean code principles to test_main.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major improvements for readability, maintainability, and test clarity: **Priority 1 - Critical Fixes:** - Split test_list_models_with_direct_resource_patch into two separate tests (was testing two completely unrelated behaviors) - Remove debug print statement from test_yaml_config_file_loading **Priority 2 - Complexity Reduction:** - Parametrize test_accepts_settings_warnings (81 lines → 65 lines) 4 scenarios now explicit and independently testable - Split test_yaml_config_file_loading into 4 independent tests (62 lines → 4 focused tests, eliminates sequential dependencies) - Add assert_warning_contains() helper for consistent warning verification **Priority 3 - Eliminate Logical Duplication:** - Consolidate gitignore flag tests (107 lines → 53 lines) 6 parametrized test cases covering both command-line and /add command - Consolidate env file variable tests (40 lines → 43 lines) 4 parametrized test cases for dark mode and boolean parsing - Consolidate cache/streaming warning tests (33 lines → 26 lines) 3 parametrized test cases for flag combinations **Test Count Changes:** - Before: 92 tests (some monolithic) - After: 103 tests (better granularity) - Net: +11 tests from parametrization (better debugging) **LOC Changes:** - Before: 1,715 lines - After: 1,694 lines (-21 lines) - Reduction: ~1.2% **Clean Code Principles Applied:** - Single Responsibility: Each test now tests one clear behavior - DRY: Eliminated duplicate test setup code - Readability: Clear parametrize IDs make test intent obvious - Maintainability: Helper function for common assertions All 103 tests pass ✓ --- tests/basic/test_main.py | 505 +++++++++++++++++++-------------------- 1 file changed, 242 insertions(+), 263 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 81e446567cc..cb55fc5b74c 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -120,6 +120,22 @@ def _create_env_file(file_name, content): return _create_env_file +def assert_warning_contains(mock_warning, text, should_contain=True): + """Helper to assert whether a warning message contains specific text. + + Args: + mock_warning: Mocked InputOutput.tool_warning function + text: Text to search for in warning messages + should_contain: If True, asserts text is found; if False, asserts it's not found + """ + warnings = [call[0][0] for call in mock_warning.call_args_list] + contains = any(text in w for w in warnings) + if should_contain: + assert contains, f"Expected warning containing '{text}' but got: {warnings}" + else: + assert not contains, f"Unexpected warning containing '{text}' in: {warnings}" + + def test_main_with_empty_dir_no_files_on_command(dummy_io): main(["--no-git", "--exit", "--yes-always"], **dummy_io) @@ -264,113 +280,59 @@ def test_check_gitignore(dummy_io, git_temp_dir, monkeypatch): asyncio.run(check_gitignore(cwd, io)) assert "one\ntwo\n.aider*\n.env\n" == gitignore.read_text() -def test_command_line_gitignore_files_flag(dummy_io): - 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 = main( - ["--exit", "--yes-always", abs_ignored_file], - **dummy_io, - return_coder=True, - force_git_root=git_dir, - ) - # Verify the ignored file is not in the chat - assert abs_ignored_file not in coder.abs_fnames - - # Test with --add-gitignore-files set to True - coder = main( - ["--add-gitignore-files", "--exit", "--yes-always", abs_ignored_file], - **dummy_io, - return_coder=True, - force_git_root=git_dir, - ) - # Verify the ignored file is in the chat - assert abs_ignored_file in coder.abs_fnames - - # Test with --add-gitignore-files set to False - coder = main( - ["--no-add-gitignore-files", "--exit", "--yes-always", abs_ignored_file], - **dummy_io, - return_coder=True, - force_git_root=git_dir, - ) - # Verify the ignored file is not in the chat - assert abs_ignored_file not in coder.abs_fnames - -def test_add_command_gitignore_files_flag(dummy_io): +@pytest.mark.parametrize( + "method,flag,should_include", + [ + ("command_line", None, False), + ("command_line", "--add-gitignore-files", True), + ("command_line", "--no-add-gitignore-files", False), + ("add_command", None, False), + ("add_command", "--add-gitignore-files", True), + ("add_command", "--no-add-gitignore-files", False), + ], + ids=[ + "cli_default", + "cli_enabled", + "cli_disabled", + "cmd_default", + "cmd_enabled", + "cmd_disabled", + ], +) +def test_gitignore_files_flag(dummy_io, method, flag, should_include): + """Test --add-gitignore-files flag with command-line and /add command.""" with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) - # Create a .gitignore file + # Create a .gitignore file and an ignored 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 = main( - ["--exit", "--yes-always"], - **dummy_io, - return_coder=True, - force_git_root=git_dir, - ) - - try: - asyncio.run(coder.commands.do_run("add", rel_ignored_file)) - except SwitchCoder: - pass - # Verify the ignored file is not in the chat - assert abs_ignored_file not in coder.abs_fnames - - # Test with --add-gitignore-files set to True - coder = main( - ["--add-gitignore-files", "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - force_git_root=git_dir, - ) - try: - asyncio.run(coder.commands.do_run("add", rel_ignored_file)) - except SwitchCoder: - pass - - # Verify the ignored file is in the chat - assert abs_ignored_file in coder.abs_fnames - - # Test with --add-gitignore-files set to False - coder = main( - ["--no-add-gitignore-files", "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - force_git_root=git_dir, - ) - - try: - asyncio.run(coder.commands.do_run("add", rel_ignored_file)) - except SwitchCoder: - pass - - # Verify the ignored file is not in the chat - assert abs_ignored_file not in coder.abs_fnames + # Build args list with optional flag + args = ["--exit", "--yes-always"] + if flag: + args.insert(0, flag) + + if method == "command_line": + # Add file via command line argument + args.append(abs_ignored_file) + coder = main(args, **dummy_io, return_coder=True, force_git_root=git_dir) + else: + # Add file via /add command + coder = main(args, **dummy_io, return_coder=True, force_git_root=git_dir) + try: + asyncio.run(coder.commands.do_run("add", "ignored.txt")) + except SwitchCoder: + pass + + # Verify file is included or excluded as expected + if should_include: + assert abs_ignored_file in coder.abs_fnames + else: + assert abs_ignored_file not in coder.abs_fnames @pytest.mark.parametrize( "args,expected_kwargs", @@ -523,45 +485,49 @@ def test_mode_sets_code_theme(mode_flag, expected_theme, dummy_io, git_temp_dir, _, kwargs = MockInputOutput.call_args assert kwargs["code_theme"] == expected_theme -def test_env_file_flag_sets_automatic_variable(dummy_io, create_env_file, mocker): - env_file_path = create_env_file(".env.test", "AIDER_DARK_MODE=True") - MockInputOutput = mocker.patch("aider.main.InputOutput") - MockInputOutput.return_value.get_input.return_value = None - MockInputOutput.return_value.get_input.confirm_ask = True - main( - ["--env-file", str(env_file_path), "--no-git", "--exit"], - **dummy_io, - ) - MockInputOutput.assert_called_once() - # Check if the color settings are for dark mode - _, kwargs = MockInputOutput.call_args - assert kwargs["code_theme"] == "monokai" +@pytest.mark.parametrize( + "env_file,env_content,check_attribute,expected_value,use_flag", + [ + (".env.test", "AIDER_DARK_MODE=True", "code_theme", "monokai", True), + (".env", "AIDER_DARK_MODE=True", "code_theme", "monokai", False), + (".env", "AIDER_SHOW_DIFFS=off", "show_diffs", False, False), + (".env", "AIDER_SHOW_DIFFS=on", "show_diffs", True, False), + ], + ids=[ + "dark_mode_with_flag", + "dark_mode_default", + "bool_false", + "bool_true", + ], +) +def test_env_file_variables( + dummy_io, create_env_file, mocker, mock_coder, env_file, env_content, check_attribute, expected_value, use_flag +): + """Test environment file variable loading and parsing.""" + env_file_path = create_env_file(env_file, env_content) -def test_default_env_file_sets_automatic_variable(dummy_io, create_env_file, mocker): - create_env_file(".env", "AIDER_DARK_MODE=True") - MockInputOutput = mocker.patch("aider.main.InputOutput") - MockInputOutput.return_value.get_input.return_value = None - MockInputOutput.return_value.get_input.confirm_ask = True - main(["--no-git", "--exit"], **dummy_io) - # Ensure InputOutput was called - MockInputOutput.assert_called_once() - # Check if the color settings are for dark mode - _, kwargs = MockInputOutput.call_args - assert kwargs["code_theme"] == "monokai" + # Dark mode tests check InputOutput kwargs, other tests check Coder kwargs + is_dark_mode_test = check_attribute == "code_theme" -def test_false_vals_in_env_file(dummy_io, mock_coder, create_env_file): - create_env_file(".env", "AIDER_SHOW_DIFFS=off") - main(["--no-git", "--yes-always"], **dummy_io) - mock_coder.assert_called_once() - _, kwargs = mock_coder.call_args - assert kwargs["show_diffs"] is False + if is_dark_mode_test: + MockInputOutput = mocker.patch("aider.main.InputOutput") + MockInputOutput.return_value.get_input.return_value = None + MockInputOutput.return_value.get_input.confirm_ask = True -def test_true_vals_in_env_file(dummy_io, mock_coder, create_env_file): - create_env_file(".env", "AIDER_SHOW_DIFFS=on") - main(["--no-git", "--yes-always"], **dummy_io) - mock_coder.assert_called_once() - _, kwargs = mock_coder.call_args - assert kwargs["show_diffs"] is True + args = ["--no-git", "--exit" if is_dark_mode_test else "--yes-always"] + if use_flag: + args.extend(["--env-file", str(env_file_path)]) + + main(args, **dummy_io) + + if is_dark_mode_test: + MockInputOutput.assert_called_once() + _, kwargs = MockInputOutput.call_args + else: + mock_coder.assert_called_once() + _, kwargs = mock_coder.call_args + + assert kwargs[check_attribute] == expected_value def test_lint_option(dummy_io, git_temp_dir, mocker): with GitTemporaryDirectory() as git_dir: @@ -670,65 +636,100 @@ def test_verbose_mode_lists_env_vars(dummy_io, create_env_file, mocker): assert re.search(r"AIDER_DARK_MODE:\s+on", relevant_output) assert re.search(r"dark_mode:\s+True", relevant_output) -def test_yaml_config_file_loading(dummy_io, git_temp_dir, mocker, monkeypatch): +def test_yaml_config_loads_from_named_file(dummy_io, git_temp_dir, mocker, monkeypatch): with GitTemporaryDirectory() as git_dir: git_dir = Path(git_dir) + fake_home = git_dir / "fake_home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + mocker.patch("pathlib.Path.home", return_value=fake_home) + + named_config = git_dir / "named.aider.conf.yml" + named_config.write_text("model: gpt-4-1106-preview\nmap-tokens: 8192\n") - # Create fake home directory + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MockCoder.return_value + mock_coder_instance._autosave_future = mock_autosave_future() + + main(["--yes-always", "--exit", "--config", str(named_config)], **dummy_io) + + _, kwargs = MockCoder.call_args + assert kwargs["main_model"].name == "gpt-4-1106-preview" + assert kwargs["map_tokens"] == 8192 + +def test_yaml_config_loads_from_cwd(dummy_io, git_temp_dir, mocker, monkeypatch): + with GitTemporaryDirectory() as git_dir: + git_dir = Path(git_dir) fake_home = git_dir / "fake_home" fake_home.mkdir() monkeypatch.setenv("HOME", str(fake_home)) + mocker.patch("pathlib.Path.home", return_value=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") - mocker.patch("pathlib.Path.home", return_value=fake_home) MockCoder = mocker.patch("aider.coders.Coder.create") mock_coder_instance = MockCoder.return_value mock_coder_instance._autosave_future = mock_autosave_future() - # Test loading from specified config file - main( - ["--yes-always", "--exit", "--config", str(named_config)], - **dummy_io, - ) - _, kwargs = MockCoder.call_args - assert kwargs["main_model"].name == "gpt-4-1106-preview" - assert kwargs["map_tokens"] == 8192 - # Test loading from current working directory - mock_coder_instance._autosave_future = mock_autosave_future() main(["--yes-always", "--exit"], **dummy_io) + _, kwargs = MockCoder.call_args - print("kwargs:", kwargs) # Add this line for debugging - assert "main_model" in kwargs, "main_model key not found in kwargs" assert kwargs["main_model"].name == "gpt-4-32k" assert kwargs["map_tokens"] == 4096 - # Test loading from git root - cwd_config.unlink() +def test_yaml_config_loads_from_git_root(dummy_io, git_temp_dir, mocker, monkeypatch): + with GitTemporaryDirectory() as git_dir: + git_dir = Path(git_dir) + fake_home = git_dir / "fake_home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + mocker.patch("pathlib.Path.home", return_value=fake_home) + + cwd = git_dir / "subdir" + cwd.mkdir() + os.chdir(cwd) + + # Create config only at git root, not in cwd + git_config = git_dir / ".aider.conf.yml" + git_config.write_text("model: gpt-4\nmap-tokens: 2048\n") + + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MockCoder.return_value mock_coder_instance._autosave_future = mock_autosave_future() + main(["--yes-always", "--exit"], **dummy_io) + _, kwargs = MockCoder.call_args assert kwargs["main_model"].name == "gpt-4" assert kwargs["map_tokens"] == 2048 - # Test loading from home directory - git_config.unlink() +def test_yaml_config_loads_from_home(dummy_io, git_temp_dir, mocker, monkeypatch): + with GitTemporaryDirectory() as git_dir: + git_dir = Path(git_dir) + fake_home = git_dir / "fake_home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + mocker.patch("pathlib.Path.home", return_value=fake_home) + + cwd = git_dir / "subdir" + cwd.mkdir() + os.chdir(cwd) + + # Create config only in home directory + home_config = fake_home / ".aider.conf.yml" + home_config.write_text("model: gpt-3.5-turbo\nmap-tokens: 1024\n") + + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MockCoder.return_value mock_coder_instance._autosave_future = mock_autosave_future() + main(["--yes-always", "--exit"], **dummy_io) + _, kwargs = MockCoder.call_args assert kwargs["main_model"].name == "gpt-3.5-turbo" assert kwargs["map_tokens"] == 1024 @@ -894,87 +895,71 @@ def test_boolean_flags(flag_arg, attr_name, expected, dummy_io, git_temp_dir): coder = main(args, **dummy_io, return_coder=True) assert getattr(coder, attr_name) == expected -def test_accepts_settings_warnings(dummy_io, git_temp_dir, mocker): - # Test that appropriate warnings are shown based on accepts_settings configuration - # Test model that accepts the thinking_tokens setting - mock_warning = mocker.patch("aider.io.InputOutput.tool_warning") - mock_set_thinking = mocker.patch("aider.models.Model.set_thinking_tokens") - main( - [ - "--model", +@pytest.mark.parametrize( + "model,setting_flag,setting_value,method_name,check_flag,should_warn,should_call", + [ + ( "anthropic/claude-3-7-sonnet-20250219", "--thinking-tokens", "1000", - "--yes-always", - "--exit", - ], - **dummy_io, - ) - # No warning should be shown as this model accepts thinking_tokens - for call in mock_warning.call_args_list: - assert "thinking_tokens" not in 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 - mock_warning.reset_mock() - mock_set_thinking.reset_mock() - main( - [ - "--model", + "set_thinking_tokens", + None, + False, + True, + ), + ( "gpt-4o", "--thinking-tokens", "1000", + "set_thinking_tokens", "--check-model-accepts-settings", - "--yes-always", - "--exit", - ], - **dummy_io, - ) - # 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 - assert 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 - mock_warning.reset_mock() - mock_set_reasoning = mocker.patch("aider.models.Model.set_reasoning_effort") - main( - ["--model", "o1", "--reasoning-effort", "3", "--yes-always", "--exit"], - **dummy_io, - ) - # No warning should be shown as this model accepts reasoning_effort - for call in mock_warning.call_args_list: - assert "reasoning_effort" not in 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 - mock_warning.reset_mock() - mock_set_reasoning.reset_mock() - main( - [ - "--model", + True, + False, + ), + ("o1", "--reasoning-effort", "3", "set_reasoning_effort", None, False, True), + ( "gpt-3.5-turbo", "--reasoning-effort", "3", - "--yes-always", - "--exit", - ], - **dummy_io, + "set_reasoning_effort", + None, + True, + False, + ), + ], + ids=[ + "thinking_tokens_accepted", + "thinking_tokens_rejected", + "reasoning_effort_accepted", + "reasoning_effort_rejected", + ], +) +def test_accepts_settings_warnings( + dummy_io, git_temp_dir, mocker, model, setting_flag, setting_value, method_name, check_flag, should_warn, should_call +): + # Test that appropriate warnings are shown based on accepts_settings configuration + mock_warning = mocker.patch("aider.io.InputOutput.tool_warning") + mock_method = mocker.patch(f"aider.models.Model.{method_name}") + + args = ["--model", model, setting_flag, setting_value, "--yes-always", "--exit"] + if check_flag: + args.insert(4, check_flag) + + main(args, **dummy_io) + + # Check if warning was shown + setting_name = setting_flag.lstrip("--").replace("-", "_") + warnings = [call[0][0] for call in mock_warning.call_args_list] + warning_shown = any(setting_name in w for w in warnings) + assert warning_shown == should_warn, ( + f"Expected warning={should_warn} for {setting_name} but got {warning_shown}" ) - # 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 - assert warning_shown - # Method should still be called by default - mock_set_reasoning.assert_not_called() + + # Check if method was called + if should_call: + mock_method.assert_called_once_with(setting_value) + else: + mock_method.assert_not_called() def test_no_verify_ssl_sets_model_info_manager(dummy_io, git_temp_dir, mocker): mock_set_verify_ssl = mocker.patch("aider.models.ModelInfoManager.set_verify_ssl") @@ -1505,7 +1490,9 @@ def test_list_models_with_direct_resource_patch(dummy_io, mocker): # Check that the resource model appears in the output assert "resource-provider/special-model" in output - # When flag is off, setting should be applied regardless of support +def test_reasoning_effort_applied_without_check_flag(dummy_io, mocker): + # When --no-check-model-accepts-settings flag is used, settings should be applied + # regardless of whether the model supports them mock_set_reasoning = mocker.patch("aider.models.Model.set_reasoning_effort") main( [ @@ -1519,7 +1506,7 @@ def test_list_models_with_direct_resource_patch(dummy_io, mocker): ], **dummy_io, ) - # Method should be called because flag is off + # Method should be called because check flag is off mock_set_reasoning.assert_called_once_with("3") def test_model_accepts_settings_attribute(dummy_io, git_temp_dir, mocker): @@ -1557,28 +1544,31 @@ def test_model_accepts_settings_attribute(dummy_io, git_temp_dir, mocker): mock_instance.set_reasoning_effort.assert_called_once_with("3") mock_instance.set_thinking_tokens.assert_not_called() -def test_stream_and_cache_warning(dummy_io, git_temp_dir, mocker): +@pytest.mark.parametrize( + "flags,should_warn", + [ + (["--stream", "--cache-prompts"], True), + (["--stream"], False), + (["--cache-prompts", "--no-stream"], False), + ], + ids=["stream_and_cache", "stream_only", "cache_only"], +) +def test_stream_cache_warning(dummy_io, git_temp_dir, mocker, flags, should_warn): + """Test warning shown only when both streaming and caching are enabled.""" MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) mock_io_instance = MockInputOutput.return_value mock_io_instance.pretty = True - main( - ["--stream", "--cache-prompts", "--exit", "--yes-always"], - **dummy_io, - ) - mock_io_instance.tool_warning.assert_called_with( - "Cost estimates may be inaccurate when using streaming and caching." - ) -def test_stream_without_cache_no_warning(dummy_io, git_temp_dir, mocker): - MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) - mock_io_instance = MockInputOutput.return_value - mock_io_instance.pretty = True - main( - ["--stream", "--exit", "--yes-always"], - **dummy_io, - ) - for call in mock_io_instance.tool_warning.call_args_list: - assert "Cost estimates may be inaccurate" not in call[0][0] + args = flags + ["--exit", "--yes-always"] + main(args, **dummy_io) + + if should_warn: + mock_io_instance.tool_warning.assert_called_with( + "Cost estimates may be inaccurate when using streaming and caching." + ) + else: + for call in mock_io_instance.tool_warning.call_args_list: + assert "Cost estimates may be inaccurate" not in call[0][0] def test_argv_file_respects_git(dummy_io, git_temp_dir): fname = Path("not_in_git.txt") @@ -1650,17 +1640,6 @@ def test_load_dotenv_files_override(dummy_io, git_temp_dir, mocker): # Restore CWD os.chdir(original_cwd) -def test_cache_without_stream_no_warning(dummy_io, git_temp_dir, mocker): - MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) - mock_io_instance = MockInputOutput.return_value - mock_io_instance.pretty = True - main( - ["--cache-prompts", "--exit", "--yes-always", "--no-stream"], - **dummy_io, - ) - for call in mock_io_instance.tool_warning.call_args_list: - assert "Cost estimates may be inaccurate" not in call[0][0] - def test_mcp_servers_parsing(dummy_io, git_temp_dir, mocker): # Setup mock coder mock_coder_create = mocker.patch("aider.coders.Coder.create") From fe15fd676af50752df95fe7987e18ba289ec1cf8 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 10:01:24 +0100 Subject: [PATCH 33/50] refactor: eliminate redundant GitTemporaryDirectory usage in 13 tests Remove unnecessary `with GitTemporaryDirectory()` context managers from tests that already use the `git_temp_dir` fixture. The fixture already creates a temp directory and changes into it (via ChdirTemporaryDirectory), so using the context manager again creates a redundant nested temp directory. Tests refactored: - test_yaml_config_loads_from_named_file - test_yaml_config_loads_from_cwd - test_yaml_config_loads_from_git_root - test_yaml_config_loads_from_home - test_git_config_include - test_git_config_include_directive - test_model_overrides_suffix_applied - test_model_overrides_no_match_preserves_model_name - test_env_file_override - test_lint_option - test_load_dotenv_files_override - test_mcp_servers_parsing - test_gitignore_files_flag All 103 tests pass. --- tests/basic/test_main.py | 688 +++++++++++++++++++-------------------- 1 file changed, 330 insertions(+), 358 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index cb55fc5b74c..19e7ae9a1ed 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -299,40 +299,37 @@ def test_check_gitignore(dummy_io, git_temp_dir, monkeypatch): "cmd_disabled", ], ) -def test_gitignore_files_flag(dummy_io, method, flag, should_include): +def test_gitignore_files_flag(dummy_io, git_temp_dir, method, flag, should_include): """Test --add-gitignore-files flag with command-line and /add command.""" - with GitTemporaryDirectory() as git_dir: - git_dir = Path(git_dir) - - # Create a .gitignore file and an ignored file - gitignore_file = git_dir / ".gitignore" - gitignore_file.write_text("ignored.txt\n") - ignored_file = git_dir / "ignored.txt" - ignored_file.write_text("This file should be ignored.") - abs_ignored_file = str(ignored_file.resolve()) - - # Build args list with optional flag - args = ["--exit", "--yes-always"] - if flag: - args.insert(0, flag) - - if method == "command_line": - # Add file via command line argument - args.append(abs_ignored_file) - coder = main(args, **dummy_io, return_coder=True, force_git_root=git_dir) - else: - # Add file via /add command - coder = main(args, **dummy_io, return_coder=True, force_git_root=git_dir) - try: - asyncio.run(coder.commands.do_run("add", "ignored.txt")) - except SwitchCoder: - pass - - # Verify file is included or excluded as expected - if should_include: - assert abs_ignored_file in coder.abs_fnames - else: - assert abs_ignored_file not in coder.abs_fnames + # Create a .gitignore file and an ignored file + gitignore_file = git_temp_dir / ".gitignore" + gitignore_file.write_text("ignored.txt\n") + ignored_file = git_temp_dir / "ignored.txt" + ignored_file.write_text("This file should be ignored.") + abs_ignored_file = str(ignored_file.resolve()) + + # Build args list with optional flag + args = ["--exit", "--yes-always"] + if flag: + args.insert(0, flag) + + if method == "command_line": + # Add file via command line argument + args.append(abs_ignored_file) + coder = main(args, **dummy_io, return_coder=True, force_git_root=git_temp_dir) + else: + # Add file via /add command + coder = main(args, **dummy_io, return_coder=True, force_git_root=git_temp_dir) + try: + asyncio.run(coder.commands.do_run("add", "ignored.txt")) + except SwitchCoder: + pass + + # Verify file is included or excluded as expected + if should_include: + assert abs_ignored_file in coder.abs_fnames + else: + assert abs_ignored_file not in coder.abs_fnames @pytest.mark.parametrize( "args,expected_kwargs", @@ -352,36 +349,34 @@ def test_main_args(args, expected_kwargs, dummy_io, mock_coder, git_temp_dir): assert kwargs[key] is expected_value def test_env_file_override(dummy_io, git_temp_dir, mocker, monkeypatch): - with GitTemporaryDirectory() as git_dir: - git_dir = Path(git_dir) - git_env = git_dir / ".env" + git_env = git_temp_dir / ".env" - fake_home = git_dir / "fake_home" - fake_home.mkdir() - monkeypatch.setenv("HOME", str(fake_home)) - home_env = fake_home / ".env" + fake_home = git_temp_dir / "fake_home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + home_env = fake_home / ".env" - cwd = git_dir / "subdir" - cwd.mkdir() - os.chdir(cwd) - cwd_env = cwd / ".env" + cwd = git_temp_dir / "subdir" + cwd.mkdir() + os.chdir(cwd) + cwd_env = cwd / ".env" - named_env = git_dir / "named.env" + named_env = git_temp_dir / "named.env" - monkeypatch.setenv("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") + monkeypatch.setenv("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") - mocker.patch("pathlib.Path.home", return_value=fake_home) - main(["--yes-always", "--exit", "--env-file", str(named_env)]) + mocker.patch("pathlib.Path.home", return_value=fake_home) + main(["--yes-always", "--exit", "--env-file", str(named_env)]) - assert os.environ["A"] == "named" - assert os.environ["B"] == "cwd" - assert os.environ["C"] == "git" - assert os.environ["D"] == "home" - assert os.environ["E"] == "existing" + assert os.environ["A"] == "named" + assert os.environ["B"] == "cwd" + assert os.environ["C"] == "git" + assert os.environ["D"] == "home" + assert os.environ["E"] == "existing" def test_message_file_flag(dummy_io, git_temp_dir, mocker): message_file_content = "This is a test message from a file." @@ -530,37 +525,36 @@ def test_env_file_variables( assert kwargs[check_attribute] == expected_value def test_lint_option(dummy_io, git_temp_dir, mocker): - 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'") + # 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") + repo = git.Repo(".") + repo.git.add(str(dirty_file)) + repo.git.commit("-m", "new") - dirty_file.write_text("def foo():\n return '!!!!!'") + dirty_file.write_text("def foo():\n return '!!!!!'") - # Create a subdirectory - subdir = Path(git_dir) / "subdir" - subdir.mkdir() + # Create a subdirectory + subdir = git_temp_dir / "subdir" + subdir.mkdir() - # Change to the subdirectory - os.chdir(subdir) + # Change to the subdirectory + os.chdir(subdir) - # Mock the Linter class - MockLinter = mocker.patch("aider.linter.Linter.lint") - MockLinter.return_value = "" + # Mock the Linter class + MockLinter = mocker.patch("aider.linter.Linter.lint") + MockLinter.return_value = "" - # Run main with --lint option - main(["--lint", "--yes-always"], **dummy_io) + # Run main with --lint option + main(["--lint", "--yes-always"], **dummy_io) - # 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] - assert called_arg.endswith("dirty_file.py") - assert not called_arg.endswith(f"subdir{os.path.sep}dirty_file.py") + # 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] + assert called_arg.endswith("dirty_file.py") + assert not called_arg.endswith(f"subdir{os.path.sep}dirty_file.py") def test_lint_option_with_explicit_files(dummy_io, git_temp_dir, mocker): # Create two files @@ -637,102 +631,95 @@ def test_verbose_mode_lists_env_vars(dummy_io, create_env_file, mocker): assert re.search(r"dark_mode:\s+True", relevant_output) def test_yaml_config_loads_from_named_file(dummy_io, git_temp_dir, mocker, monkeypatch): - with GitTemporaryDirectory() as git_dir: - git_dir = Path(git_dir) - fake_home = git_dir / "fake_home" - fake_home.mkdir() - monkeypatch.setenv("HOME", str(fake_home)) - mocker.patch("pathlib.Path.home", return_value=fake_home) + # git_temp_dir fixture already changed into the temp directory + fake_home = git_temp_dir / "fake_home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + mocker.patch("pathlib.Path.home", return_value=fake_home) - named_config = git_dir / "named.aider.conf.yml" - named_config.write_text("model: gpt-4-1106-preview\nmap-tokens: 8192\n") + named_config = git_temp_dir / "named.aider.conf.yml" + named_config.write_text("model: gpt-4-1106-preview\nmap-tokens: 8192\n") - MockCoder = mocker.patch("aider.coders.Coder.create") - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MockCoder.return_value + mock_coder_instance._autosave_future = mock_autosave_future() - main(["--yes-always", "--exit", "--config", str(named_config)], **dummy_io) + main(["--yes-always", "--exit", "--config", str(named_config)], **dummy_io) - _, kwargs = MockCoder.call_args - assert kwargs["main_model"].name == "gpt-4-1106-preview" - assert kwargs["map_tokens"] == 8192 + _, kwargs = MockCoder.call_args + assert kwargs["main_model"].name == "gpt-4-1106-preview" + assert kwargs["map_tokens"] == 8192 def test_yaml_config_loads_from_cwd(dummy_io, git_temp_dir, mocker, monkeypatch): - with GitTemporaryDirectory() as git_dir: - git_dir = Path(git_dir) - fake_home = git_dir / "fake_home" - fake_home.mkdir() - monkeypatch.setenv("HOME", str(fake_home)) - mocker.patch("pathlib.Path.home", return_value=fake_home) + fake_home = git_temp_dir / "fake_home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + mocker.patch("pathlib.Path.home", return_value=fake_home) - cwd = git_dir / "subdir" - cwd.mkdir() - os.chdir(cwd) + cwd = git_temp_dir / "subdir" + cwd.mkdir() + os.chdir(cwd) - cwd_config = cwd / ".aider.conf.yml" - cwd_config.write_text("model: gpt-4-32k\nmap-tokens: 4096\n") + cwd_config = cwd / ".aider.conf.yml" + cwd_config.write_text("model: gpt-4-32k\nmap-tokens: 4096\n") - MockCoder = mocker.patch("aider.coders.Coder.create") - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MockCoder.return_value + mock_coder_instance._autosave_future = mock_autosave_future() - main(["--yes-always", "--exit"], **dummy_io) + main(["--yes-always", "--exit"], **dummy_io) - _, kwargs = MockCoder.call_args - assert kwargs["main_model"].name == "gpt-4-32k" - assert kwargs["map_tokens"] == 4096 + _, kwargs = MockCoder.call_args + assert kwargs["main_model"].name == "gpt-4-32k" + assert kwargs["map_tokens"] == 4096 def test_yaml_config_loads_from_git_root(dummy_io, git_temp_dir, mocker, monkeypatch): - with GitTemporaryDirectory() as git_dir: - git_dir = Path(git_dir) - fake_home = git_dir / "fake_home" - fake_home.mkdir() - monkeypatch.setenv("HOME", str(fake_home)) - mocker.patch("pathlib.Path.home", return_value=fake_home) + fake_home = git_temp_dir / "fake_home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + mocker.patch("pathlib.Path.home", return_value=fake_home) - cwd = git_dir / "subdir" - cwd.mkdir() - os.chdir(cwd) + cwd = git_temp_dir / "subdir" + cwd.mkdir() + os.chdir(cwd) - # Create config only at git root, not in cwd - git_config = git_dir / ".aider.conf.yml" - git_config.write_text("model: gpt-4\nmap-tokens: 2048\n") + # Create config only at git root, not in cwd + git_config = git_temp_dir / ".aider.conf.yml" + git_config.write_text("model: gpt-4\nmap-tokens: 2048\n") - MockCoder = mocker.patch("aider.coders.Coder.create") - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MockCoder.return_value + mock_coder_instance._autosave_future = mock_autosave_future() - main(["--yes-always", "--exit"], **dummy_io) + main(["--yes-always", "--exit"], **dummy_io) - _, kwargs = MockCoder.call_args - assert kwargs["main_model"].name == "gpt-4" - assert kwargs["map_tokens"] == 2048 + _, kwargs = MockCoder.call_args + assert kwargs["main_model"].name == "gpt-4" + assert kwargs["map_tokens"] == 2048 def test_yaml_config_loads_from_home(dummy_io, git_temp_dir, mocker, monkeypatch): - with GitTemporaryDirectory() as git_dir: - git_dir = Path(git_dir) - fake_home = git_dir / "fake_home" - fake_home.mkdir() - monkeypatch.setenv("HOME", str(fake_home)) - mocker.patch("pathlib.Path.home", return_value=fake_home) + fake_home = git_temp_dir / "fake_home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + mocker.patch("pathlib.Path.home", return_value=fake_home) - cwd = git_dir / "subdir" - cwd.mkdir() - os.chdir(cwd) + cwd = git_temp_dir / "subdir" + cwd.mkdir() + os.chdir(cwd) - # Create config only in home directory - home_config = fake_home / ".aider.conf.yml" - home_config.write_text("model: gpt-3.5-turbo\nmap-tokens: 1024\n") + # Create config only in home directory + home_config = fake_home / ".aider.conf.yml" + home_config.write_text("model: gpt-3.5-turbo\nmap-tokens: 1024\n") - MockCoder = mocker.patch("aider.coders.Coder.create") - mock_coder_instance = MockCoder.return_value - mock_coder_instance._autosave_future = mock_autosave_future() + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MockCoder.return_value + mock_coder_instance._autosave_future = mock_autosave_future() - main(["--yes-always", "--exit"], **dummy_io) + main(["--yes-always", "--exit"], **dummy_io) - _, kwargs = MockCoder.call_args - assert kwargs["main_model"].name == "gpt-3.5-turbo" - assert kwargs["map_tokens"] == 1024 + _, kwargs = MockCoder.call_args + assert kwargs["main_model"].name == "gpt-3.5-turbo" + assert kwargs["map_tokens"] == 1024 def test_map_tokens_option(dummy_io, git_temp_dir, mocker): MockRepoMap = mocker.patch("aider.coders.base_coder.RepoMap") @@ -1050,80 +1037,74 @@ def test_api_key(api_key_args, expected_env, expected_result, dummy_io, git_temp def test_git_config_include(dummy_io, git_temp_dir): # 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" - ) + # Create an includable config file with user settings + include_config = git_temp_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)) + # Set up main git config to include the other file + repo = git.Repo(git_temp_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 - assert repo.git.config("user.name") == "Included User" - assert repo.git.config("user.email") == "included@example.com" + # Verify the config is set up correctly using git command + assert repo.git.config("user.name") == "Included User" + assert 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() + # Manually check the git config file to confirm include directive + git_config_path = git_temp_dir / ".git" / "config" + git_config_content = git_config_path.read_text() - # Run aider and verify it doesn't change the git config - main(["--yes-always", "--exit"], **dummy_io) + # Run aider and verify it doesn't change the git config + main(["--yes-always", "--exit"], **dummy_io) - # 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 - assert repo.git.config("user.name") == "Included User" - assert repo.git.config("user.email") == "included@example.com" + # Check that the user settings are still the same using git command + repo = git.Repo(git_temp_dir) # Re-open repo to ensure we get fresh config + assert repo.git.config("user.name") == "Included User" + assert 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() - assert git_config_content == git_config_content_after + # Manually check the git config file again to ensure it wasn't modified + git_config_content_after = git_config_path.read_text() + assert git_config_content == git_config_content_after def test_git_config_include_directive(dummy_io, git_temp_dir): # 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" - ) + # Create an includable config file with user settings + include_config = git_temp_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") + # Set up main git config with include directive + git_config = git_temp_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() + # Read the modified config file + modified_config_content = git_config.read_text() - # Verify the include directive was added correctly - assert "[include]" in modified_config_content + # Verify the include directive was added correctly + assert "[include]" in modified_config_content - # Verify the config is set up correctly using git command - repo = git.Repo(git_dir) - assert repo.git.config("user.name") == "Directive User" - assert repo.git.config("user.email") == "directive@example.com" + # Verify the config is set up correctly using git command + repo = git.Repo(git_temp_dir) + assert repo.git.config("user.name") == "Directive User" + assert repo.git.config("user.email") == "directive@example.com" - # Run aider and verify it doesn't change the git config - main(["--yes-always", "--exit"], **dummy_io) + # Run aider and verify it doesn't change the git config + main(["--yes-always", "--exit"], **dummy_io) - # Check that the git config file wasn't modified - config_after_aider = git_config.read_text() - assert modified_config_content == config_after_aider + # Check that the git config file wasn't modified + config_after_aider = git_config.read_text() + assert 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 - assert repo.git.config("user.name") == "Directive User" - assert repo.git.config("user.email") == "directive@example.com" + # Check that the user settings are still the same using git command + repo = git.Repo(git_temp_dir) # Re-open repo to ensure we get fresh config + assert repo.git.config("user.name") == "Directive User" + assert repo.git.config("user.email") == "directive@example.com" def test_resolve_aiderignore_path(dummy_io, git_temp_dir): # Import the function directly to test it @@ -1236,91 +1217,86 @@ def test_model_precedence(dummy_io, git_temp_dir, monkeypatch): assert "sonnet" in coder.main_model.name.lower() def test_model_overrides_suffix_applied(dummy_io, git_temp_dir, mocker): - with GitTemporaryDirectory() as git_dir: - git_dir = Path(git_dir) - overrides_file = git_dir / ".aider.model.overrides.yml" - overrides_file.write_text("gpt-4o:\n fast:\n temperature: 0.1\n") - - MockModel = mocker.patch("aider.models.Model") - MockCoder = mocker.patch("aider.coders.Coder.create") - mock_coder_instance = MagicMock() - mock_coder_instance._autosave_future = mock_autosave_future() - MockCoder.return_value = mock_coder_instance - - mock_instance = MockModel.return_value - mock_instance.info = {} - mock_instance.name = "gpt-4o" - mock_instance.validate_environment.return_value = { - "missing_keys": [], - "keys_in_environment": [], - } - mock_instance.accepts_settings = [] - mock_instance.weak_model_name = None - mock_instance.get_weak_model.return_value = None + overrides_file = git_temp_dir / ".aider.model.overrides.yml" + overrides_file.write_text("gpt-4o:\n fast:\n temperature: 0.1\n") - main( - ["--model", "gpt-4o:fast", "--exit", "--yes-always", "--no-git"], - **dummy_io, - force_git_root=git_dir, - ) + MockModel = mocker.patch("aider.models.Model") + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MagicMock() + mock_coder_instance._autosave_future = mock_autosave_future() + MockCoder.return_value = mock_coder_instance - # Find the call that constructed the main model with overrides - matched_call_found = False - for call_args in MockModel.call_args_list: - args, kwargs = call_args - if ( - args - and args[0] == "gpt-4o" - and kwargs.get("override_kwargs") == {"temperature": 0.1} - ): - matched_call_found = True - break - - assert matched_call_found, ( - "Expected a Model call with base name 'gpt-4o' and override_kwargs" - " {'temperature': 0.1}" - ) + mock_instance = MockModel.return_value + mock_instance.info = {} + mock_instance.name = "gpt-4o" + mock_instance.validate_environment.return_value = { + "missing_keys": [], + "keys_in_environment": [], + } + mock_instance.accepts_settings = [] + mock_instance.weak_model_name = None + mock_instance.get_weak_model.return_value = None + + main( + ["--model", "gpt-4o:fast", "--exit", "--yes-always", "--no-git"], + **dummy_io, + force_git_root=git_temp_dir, + ) + + # Find the call that constructed the main model with overrides + matched_call_found = False + for call_args in MockModel.call_args_list: + args, kwargs = call_args + if ( + args + and args[0] == "gpt-4o" + and kwargs.get("override_kwargs") == {"temperature": 0.1} + ): + matched_call_found = True + break + + assert matched_call_found, ( + "Expected a Model call with base name 'gpt-4o' and override_kwargs" + " {'temperature': 0.1}" + ) def test_model_overrides_no_match_preserves_model_name(dummy_io, git_temp_dir, mocker): - with GitTemporaryDirectory() as git_dir: - git_dir = Path(git_dir) - - MockModel = mocker.patch("aider.models.Model") - MockCoder = mocker.patch("aider.coders.Coder.create") - mock_coder_instance = MagicMock() - mock_coder_instance._autosave_future = mock_autosave_future() - MockCoder.return_value = mock_coder_instance - - mock_instance = MockModel.return_value - mock_instance.info = {} - mock_instance.name = "test-model" - mock_instance.validate_environment.return_value = { - "missing_keys": [], - "keys_in_environment": [], - } - mock_instance.accepts_settings = [] - mock_instance.weak_model_name = None - mock_instance.get_weak_model.return_value = None + MockModel = mocker.patch("aider.models.Model") + MockCoder = mocker.patch("aider.coders.Coder.create") + mock_coder_instance = MagicMock() + mock_coder_instance._autosave_future = mock_autosave_future() + MockCoder.return_value = mock_coder_instance - model_name = "hf:moonshotai/Kimi-K2-Thinking" + mock_instance = MockModel.return_value + mock_instance.info = {} + mock_instance.name = "test-model" + mock_instance.validate_environment.return_value = { + "missing_keys": [], + "keys_in_environment": [], + } + mock_instance.accepts_settings = [] + mock_instance.weak_model_name = None + mock_instance.get_weak_model.return_value = None - main( - ["--model", model_name, "--exit", "--yes-always", "--no-git"], - **dummy_io, - force_git_root=git_dir, - ) + model_name = "hf:moonshotai/Kimi-K2-Thinking" + + main( + ["--model", model_name, "--exit", "--yes-always", "--no-git"], + **dummy_io, + force_git_root=git_temp_dir, + ) - matched_call_found = False - for call_args in MockModel.call_args_list: - args, kwargs = call_args - if args and args[0] == model_name and kwargs.get("override_kwargs") == {}: - matched_call_found = True - break + matched_call_found = False + for call_args in MockModel.call_args_list: + args, kwargs = call_args + if args and args[0] == model_name and kwargs.get("override_kwargs") == {}: + matched_call_found = True + break - assert matched_call_found, ( - "Expected a Model call with the full model name preserved and empty" - " override_kwargs" - ) + assert matched_call_found, ( + "Expected a Model call with the full model name preserved and empty" + " override_kwargs" + ) def test_chat_language_spanish(dummy_io, git_temp_dir): coder = main( @@ -1584,61 +1560,58 @@ def test_argv_file_respects_git(dummy_io, git_temp_dir): assert not asyncio.run(coder.allowed_to_edit("not_in_git.txt")) def test_load_dotenv_files_override(dummy_io, git_temp_dir, mocker): - 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] - - mocker.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) - assert str(oauth_keys_file.resolve()) in loaded_files - assert str(git_root_env.resolve()) in loaded_files - assert str(cwd_env.resolve()) in loaded_files - assert loaded_files.index(str(oauth_keys_file.resolve())) < loaded_files.index( - str(git_root_env.resolve()) - ) - assert loaded_files.index(str(git_root_env.resolve())) < loaded_files.index( - str(cwd_env.resolve()) - ) + # Create fake home and .aider directory + fake_home = git_temp_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_temp_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_temp_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] + + mocker.patch("pathlib.Path.home", return_value=fake_home) + loaded_files = load_dotenv_files(str(git_temp_dir), None) + + # Assert files were loaded in expected order (oauth first) + assert str(oauth_keys_file.resolve()) in loaded_files + assert str(git_root_env.resolve()) in loaded_files + assert str(cwd_env.resolve()) in loaded_files + assert loaded_files.index(str(oauth_keys_file.resolve())) < loaded_files.index( + str(git_root_env.resolve()) + ) + assert loaded_files.index(str(git_root_env.resolve())) < loaded_files.index( + str(cwd_env.resolve()) + ) - # Assert environment variables reflect the override order - assert os.environ.get("OAUTH_VAR") == "oauth_val" - assert os.environ.get("GIT_VAR") == "git_val" - assert os.environ.get("CWD_VAR") == "cwd_val" - # SHARED_VAR should be overridden by the last loaded file (cwd .env) - assert os.environ.get("SHARED_VAR") == "cwd_shared" + # Assert environment variables reflect the override order + assert os.environ.get("OAUTH_VAR") == "oauth_val" + assert os.environ.get("GIT_VAR") == "git_val" + assert os.environ.get("CWD_VAR") == "cwd_val" + # SHARED_VAR should be overridden by the last loaded file (cwd .env) + assert os.environ.get("SHARED_VAR") == "cwd_shared" - # Restore CWD - os.chdir(original_cwd) + # Restore CWD + os.chdir(original_cwd) def test_mcp_servers_parsing(dummy_io, git_temp_dir, mocker): # Setup mock coder @@ -1672,23 +1645,22 @@ def test_mcp_servers_parsing(dummy_io, git_temp_dir, mocker): mock_coder_create.reset_mock() mock_coder_instance._autosave_future = mock_autosave_future() - 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)) + # 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)) - main( - ["--mcp-servers-file", str(mcp_file), "--exit", "--yes-always"], - **dummy_io, - ) + main( + ["--mcp-servers-file", str(mcp_file), "--exit", "--yes-always"], + **dummy_io, + ) - # Verify that Coder.create was called with mcp_servers parameter - mock_coder_create.assert_called_once() - _, kwargs = mock_coder_create.call_args - assert "mcp_servers" in kwargs - assert kwargs["mcp_servers"] is not None - # At least one server should be in the list - assert len(kwargs["mcp_servers"]) > 0 - # First server should have a name attribute - assert hasattr(kwargs["mcp_servers"][0], "name") + # Verify that Coder.create was called with mcp_servers parameter + mock_coder_create.assert_called_once() + _, kwargs = mock_coder_create.call_args + assert "mcp_servers" in kwargs + assert kwargs["mcp_servers"] is not None + # At least one server should be in the list + assert len(kwargs["mcp_servers"]) > 0 + # First server should have a name attribute + assert hasattr(kwargs["mcp_servers"][0], "name") From 2e57cfcc653580baa13e8bf689cf9146401f949f Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 10:07:30 +0100 Subject: [PATCH 34/50] refactor: use mocker.patch.dict for environment isolation Replace manual os.environ manipulation in test_env fixture with mocker.patch.dict(os.environ, clean_env, clear=True) for complete environment isolation. Improvements: - Completely replaces os.environ instead of modifying it - Automatic cleanup via mocker (no manual restore needed) - Add Windows compatibility (USERPROFILE vs HOME) - More idiomatic pytest-mock usage - Cleaner, more maintainable code Pattern adopted from the old test_main_smoke.py isolated_env fixture. All 103 tests pass. --- tests/basic/test_main.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 19e7ae9a1ed..93208a56a66 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -22,6 +22,7 @@ import asyncio import json import os +import platform import subprocess import tempfile from io import StringIO @@ -55,26 +56,40 @@ def test_env(mocker): """Provide isolated test environment for all tests. Automatically sets up and tears down: - - Fake API keys and environment variables + - Fake API keys and environment variables (completely isolated) - Temporary working directory - Fake home directory to prevent ~/.aider.conf.yml interference - Mocked user input and browser opening + - Windows compatibility (USERPROFILE vs HOME) All environment changes are automatically cleaned up after each test. """ - # Setup - original_env = os.environ.copy() - os.environ["OPENAI_API_KEY"] = "deadbeef" - os.environ["AIDER_CHECK_UPDATE"] = "false" - os.environ["AIDER_ANALYTICS"] = "false" + # Setup temporary directories (using IgnorantTemporaryDirectory for Windows compatibility) original_cwd = os.getcwd() tempdir_obj = IgnorantTemporaryDirectory() tempdir = tempdir_obj.name os.chdir(tempdir) - # Fake home directory prevents tests from using the real ~/.aider.conf.yml file: + + # Fake home directory prevents tests from using the real ~/.aider.conf.yml file homedir_obj = IgnorantTemporaryDirectory() - os.environ["HOME"] = homedir_obj.name + # Create completely isolated environment + clean_env = { + "OPENAI_API_KEY": "deadbeef", + "AIDER_CHECK_UPDATE": "false", + "AIDER_ANALYTICS": "false", + } + + # Windows uses USERPROFILE instead of HOME + if platform.system() == "Windows": + clean_env["USERPROFILE"] = homedir_obj.name + else: + clean_env["HOME"] = homedir_obj.name + + # Completely replace os.environ with clean isolated environment + mocker.patch.dict(os.environ, clean_env, clear=True) + + # Mock user interaction mocker.patch("builtins.input", return_value=None) mocker.patch("aider.io.webbrowser.open") @@ -84,8 +99,6 @@ def test_env(mocker): os.chdir(original_cwd) tempdir_obj.cleanup() homedir_obj.cleanup() - os.environ.clear() - os.environ.update(original_env) @pytest.fixture From 65d718c7369b5b6f7c5d572bb56bf4b51325ba5e Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 10:10:21 +0100 Subject: [PATCH 35/50] style: remove obvious comments from test_env fixture --- tests/basic/test_main.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 93208a56a66..e0d82f93049 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -64,38 +64,31 @@ def test_env(mocker): All environment changes are automatically cleaned up after each test. """ - # Setup temporary directories (using IgnorantTemporaryDirectory for Windows compatibility) + # Using IgnorantTemporaryDirectory for Windows cleanup compatibility original_cwd = os.getcwd() tempdir_obj = IgnorantTemporaryDirectory() tempdir = tempdir_obj.name os.chdir(tempdir) - # Fake home directory prevents tests from using the real ~/.aider.conf.yml file homedir_obj = IgnorantTemporaryDirectory() - # Create completely isolated environment clean_env = { "OPENAI_API_KEY": "deadbeef", "AIDER_CHECK_UPDATE": "false", "AIDER_ANALYTICS": "false", } - # Windows uses USERPROFILE instead of HOME if platform.system() == "Windows": clean_env["USERPROFILE"] = homedir_obj.name else: clean_env["HOME"] = homedir_obj.name - # Completely replace os.environ with clean isolated environment mocker.patch.dict(os.environ, clean_env, clear=True) - - # Mock user interaction mocker.patch("builtins.input", return_value=None) mocker.patch("aider.io.webbrowser.open") yield - # Teardown os.chdir(original_cwd) tempdir_obj.cleanup() homedir_obj.cleanup() From 11696ce47c6ec268e7f6b2ed4d8bc90b9b0bf54c Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 10:13:17 +0100 Subject: [PATCH 36/50] refactor: use context managers for IgnorantTemporaryDirectory Use 'with' statements for both temporary directories to leverage automatic cleanup via __exit__. More idiomatic and eliminates manual cleanup() calls. All 103 tests pass. --- tests/basic/test_main.py | 41 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index e0d82f93049..9172fe49665 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -64,34 +64,31 @@ def test_env(mocker): All environment changes are automatically cleaned up after each test. """ - # Using IgnorantTemporaryDirectory for Windows cleanup compatibility original_cwd = os.getcwd() - tempdir_obj = IgnorantTemporaryDirectory() - tempdir = tempdir_obj.name - os.chdir(tempdir) - - homedir_obj = IgnorantTemporaryDirectory() - clean_env = { - "OPENAI_API_KEY": "deadbeef", - "AIDER_CHECK_UPDATE": "false", - "AIDER_ANALYTICS": "false", - } + # Using IgnorantTemporaryDirectory for Windows cleanup compatibility + with IgnorantTemporaryDirectory() as tempdir, \ + IgnorantTemporaryDirectory() as homedir: + os.chdir(tempdir) + + clean_env = { + "OPENAI_API_KEY": "deadbeef", + "AIDER_CHECK_UPDATE": "false", + "AIDER_ANALYTICS": "false", + } - if platform.system() == "Windows": - clean_env["USERPROFILE"] = homedir_obj.name - else: - clean_env["HOME"] = homedir_obj.name + if platform.system() == "Windows": + clean_env["USERPROFILE"] = homedir + else: + clean_env["HOME"] = homedir - mocker.patch.dict(os.environ, clean_env, clear=True) - mocker.patch("builtins.input", return_value=None) - mocker.patch("aider.io.webbrowser.open") + mocker.patch.dict(os.environ, clean_env, clear=True) + mocker.patch("builtins.input", return_value=None) + mocker.patch("aider.io.webbrowser.open") - yield + yield - os.chdir(original_cwd) - tempdir_obj.cleanup() - homedir_obj.cleanup() + os.chdir(original_cwd) @pytest.fixture From a55a2faed93375aa7fd74cd208646dd3ebb3c51a Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 10:18:53 +0100 Subject: [PATCH 37/50] refactor: use ChdirTemporaryDirectory for automatic chdir management Replace IgnorantTemporaryDirectory + manual os.chdir() calls with ChdirTemporaryDirectory, which automatically: - Changes to temp directory in __enter__ - Changes back to original directory in __exit__ Eliminates both manual os.chdir(tempdir) and os.chdir(original_cwd) calls. Cleaner and leverages the inheritance hierarchy correctly. All 103 tests pass. --- tests/basic/test_main.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 9172fe49665..51f72297e46 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -39,7 +39,7 @@ 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 +from aider.utils import ChdirTemporaryDirectory, GitTemporaryDirectory, IgnorantTemporaryDirectory, make_repo def mock_autosave_future(): @@ -57,20 +57,15 @@ def test_env(mocker): Automatically sets up and tears down: - Fake API keys and environment variables (completely isolated) - - Temporary working directory + - Temporary working directory (with automatic chdir) - Fake home directory to prevent ~/.aider.conf.yml interference - Mocked user input and browser opening - Windows compatibility (USERPROFILE vs HOME) All environment changes are automatically cleaned up after each test. """ - original_cwd = os.getcwd() - - # Using IgnorantTemporaryDirectory for Windows cleanup compatibility - with IgnorantTemporaryDirectory() as tempdir, \ + with ChdirTemporaryDirectory(), \ IgnorantTemporaryDirectory() as homedir: - os.chdir(tempdir) - clean_env = { "OPENAI_API_KEY": "deadbeef", "AIDER_CHECK_UPDATE": "false", @@ -88,8 +83,6 @@ def test_env(mocker): yield - os.chdir(original_cwd) - @pytest.fixture def dummy_io(): From 1e4ee930764568e518af10285aeb91f84a6deb36 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 10:23:08 +0100 Subject: [PATCH 38/50] refactor: extract temp_home fixture for better separation of concerns Create dedicated temp_home fixture to manage temporary home directory, making it reusable and separating concerns. The test_env fixture now depends on temp_home for cleaner fixture composition. Benefits: - Single responsibility: each fixture manages one resource - Reusable: temp_home can be used by other tests if needed - Cleaner structure and dependency injection All 103 tests pass. --- tests/basic/test_main.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 51f72297e46..9dac74e20e9 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -51,8 +51,15 @@ def mock_autosave_future(): return AsyncMock()() +@pytest.fixture +def temp_home(): + """Provide a temporary home directory.""" + with IgnorantTemporaryDirectory() as homedir: + yield homedir + + @pytest.fixture(autouse=True) -def test_env(mocker): +def test_env(mocker, temp_home): """Provide isolated test environment for all tests. Automatically sets up and tears down: @@ -64,8 +71,7 @@ def test_env(mocker): All environment changes are automatically cleaned up after each test. """ - with ChdirTemporaryDirectory(), \ - IgnorantTemporaryDirectory() as homedir: + with ChdirTemporaryDirectory(): clean_env = { "OPENAI_API_KEY": "deadbeef", "AIDER_CHECK_UPDATE": "false", @@ -73,9 +79,9 @@ def test_env(mocker): } if platform.system() == "Windows": - clean_env["USERPROFILE"] = homedir + clean_env["USERPROFILE"] = temp_home else: - clean_env["HOME"] = homedir + clean_env["HOME"] = temp_home mocker.patch.dict(os.environ, clean_env, clear=True) mocker.patch("builtins.input", return_value=None) From 106d5554c37af3dac74edb43f4bcd715c06f2f37 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 10:24:51 +0100 Subject: [PATCH 39/50] refactor: extract temp_cwd fixture for working directory management Create dedicated temp_cwd fixture to manage temporary working directory with automatic chdir. Now we have three composable fixtures: - temp_cwd: temporary current working directory (auto chdir) - temp_home: temporary home directory - test_env: composes the above with environment isolation Benefits: - Complete separation of concerns - Each fixture manages exactly one resource - Clean fixture composition through dependency injection - Reusable fixtures for other tests All 103 tests pass. --- tests/basic/test_main.py | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 9dac74e20e9..591a7799500 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -51,6 +51,13 @@ def mock_autosave_future(): return AsyncMock()() +@pytest.fixture +def temp_cwd(): + """Provide a temporary current working directory with automatic chdir.""" + with ChdirTemporaryDirectory() as tempdir: + yield tempdir + + @pytest.fixture def temp_home(): """Provide a temporary home directory.""" @@ -59,7 +66,7 @@ def temp_home(): @pytest.fixture(autouse=True) -def test_env(mocker, temp_home): +def test_env(mocker, temp_cwd, temp_home): """Provide isolated test environment for all tests. Automatically sets up and tears down: @@ -71,23 +78,22 @@ def test_env(mocker, temp_home): All environment changes are automatically cleaned up after each test. """ - with ChdirTemporaryDirectory(): - clean_env = { - "OPENAI_API_KEY": "deadbeef", - "AIDER_CHECK_UPDATE": "false", - "AIDER_ANALYTICS": "false", - } + clean_env = { + "OPENAI_API_KEY": "deadbeef", + "AIDER_CHECK_UPDATE": "false", + "AIDER_ANALYTICS": "false", + } - if platform.system() == "Windows": - clean_env["USERPROFILE"] = temp_home - else: - clean_env["HOME"] = temp_home + if platform.system() == "Windows": + clean_env["USERPROFILE"] = temp_home + else: + clean_env["HOME"] = temp_home - mocker.patch.dict(os.environ, clean_env, clear=True) - mocker.patch("builtins.input", return_value=None) - mocker.patch("aider.io.webbrowser.open") + mocker.patch.dict(os.environ, clean_env, clear=True) + mocker.patch("builtins.input", return_value=None) + mocker.patch("aider.io.webbrowser.open") - yield + yield @pytest.fixture From b29a5f7f9439dd7c1734e52cdbf7e1d1c31c67ac Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 10:26:03 +0100 Subject: [PATCH 40/50] refactor: remove unnecessary yield from test_env fixture The yield was serving no purpose since: - No teardown code after yield - All cleanup handled by dependency fixtures (temp_cwd, temp_home) - mocker automatically cleans up all patches This makes it clear that test_env is a setup-only fixture that composes other fixtures for resource management. All 103 tests pass. --- tests/basic/test_main.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 591a7799500..5feb9b38786 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -69,14 +69,14 @@ def temp_home(): def test_env(mocker, temp_cwd, temp_home): """Provide isolated test environment for all tests. - Automatically sets up and tears down: + Automatically sets up: - Fake API keys and environment variables (completely isolated) - Temporary working directory (with automatic chdir) - Fake home directory to prevent ~/.aider.conf.yml interference - Mocked user input and browser opening - Windows compatibility (USERPROFILE vs HOME) - All environment changes are automatically cleaned up after each test. + All resources are automatically cleaned up by dependency fixtures and mocker. """ clean_env = { "OPENAI_API_KEY": "deadbeef", @@ -93,8 +93,6 @@ def test_env(mocker, temp_cwd, temp_home): mocker.patch("builtins.input", return_value=None) mocker.patch("aider.io.webbrowser.open") - yield - @pytest.fixture def dummy_io(): From 6d00340272df2020cd41c714ef10c6af42da9047 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 10:34:24 +0100 Subject: [PATCH 41/50] refactor: remove unnecessary create_env_file fixture Replace create_env_file fixture with direct Path operations since it was just a trivial wrapper around Path().write_text() with no real value added: - No resource management or cleanup - No fixture dependencies - No pytest-specific functionality - Just 2 lines wrapped in complexity Replaced 2 usages: 1. env_file_path = Path(env_file); env_file_path.write_text(content) 2. Path(".env").write_text("AIDER_DARK_MODE=on") All 103 tests pass. --- tests/basic/test_main.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 5feb9b38786..f04f30fa9bf 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -116,16 +116,6 @@ def git_temp_dir(): yield Path(temp_dir) -@pytest.fixture -def create_env_file(): - """Factory fixture to create environment files in the current test directory.""" - def _create_env_file(file_name, content): - env_file_path = Path.cwd() / file_name - env_file_path.write_text(content) - return env_file_path - return _create_env_file - - def assert_warning_contains(mock_warning, text, should_contain=True): """Helper to assert whether a warning message contains specific text. @@ -502,10 +492,11 @@ def test_mode_sets_code_theme(mode_flag, expected_theme, dummy_io, git_temp_dir, ], ) def test_env_file_variables( - dummy_io, create_env_file, mocker, mock_coder, env_file, env_content, check_attribute, expected_value, use_flag + dummy_io, mocker, mock_coder, env_file, env_content, check_attribute, expected_value, use_flag ): """Test environment file variable loading and parsing.""" - env_file_path = create_env_file(env_file, env_content) + env_file_path = Path(env_file) + env_file_path.write_text(env_content) # Dark mode tests check InputOutput kwargs, other tests check Coder kwargs is_dark_mode_test = check_attribute == "code_theme" @@ -616,8 +607,8 @@ def test_lint_option_with_glob_pattern(dummy_io, git_temp_dir, mocker): # Check that non-Python file was not linted assert not any(f.endswith("readme.txt") for f in called_files) -def test_verbose_mode_lists_env_vars(dummy_io, create_env_file, mocker): - create_env_file(".env", "AIDER_DARK_MODE=on") +def test_verbose_mode_lists_env_vars(dummy_io, mocker): + Path(".env").write_text("AIDER_DARK_MODE=on") mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) main( ["--no-git", "--verbose", "--exit", "--yes-always"], From 6a541755310f68d7c69926c53c511d5062e0328a Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 10:36:48 +0100 Subject: [PATCH 42/50] refactor: remove unused assert_warning_contains helper Remove dead code that was never called anywhere in the tests. Tests that check warnings do the same logic inline: - test_accepts_settings_warnings (lines 936-940) - test_stream_cache_warning (lines 1543-1544) If a helper is needed in the future, it can be extracted from actual usage patterns rather than maintaining unused code. All 103 tests pass. --- tests/basic/test_main.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index f04f30fa9bf..b61f2e78ad6 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -116,22 +116,6 @@ def git_temp_dir(): yield Path(temp_dir) -def assert_warning_contains(mock_warning, text, should_contain=True): - """Helper to assert whether a warning message contains specific text. - - Args: - mock_warning: Mocked InputOutput.tool_warning function - text: Text to search for in warning messages - should_contain: If True, asserts text is found; if False, asserts it's not found - """ - warnings = [call[0][0] for call in mock_warning.call_args_list] - contains = any(text in w for w in warnings) - if should_contain: - assert contains, f"Expected warning containing '{text}' but got: {warnings}" - else: - assert not contains, f"Unexpected warning containing '{text}' in: {warnings}" - - def test_main_with_empty_dir_no_files_on_command(dummy_io): main(["--no-git", "--exit", "--yes-always"], **dummy_io) From 501487109ef125e9209ae28e25f993f5c1ce37e1 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 10:42:49 +0100 Subject: [PATCH 43/50] refactor: split test_gitignore_files_flag into two focused tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split single test with 'if method' branching into two separate tests: - test_gitignore_files_flag_command_line: Tests CLI argument parsing - test_gitignore_files_flag_add_command: Tests /add command behavior Benefits: - No more if statement for method branching - Clear separation of concerns - Each test has single focus (one way to add files) - Better test names reveal intent - Easier to debug failures - Shared setup via _create_gitignore_test_files() helper Still 6 test cases total (2 methods × 3 flag variations), now with better organization. All 103 tests pass. --- tests/basic/test_main.py | 84 +++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 35 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index b61f2e78ad6..2d439151d04 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -261,56 +261,70 @@ def test_check_gitignore(dummy_io, git_temp_dir, monkeypatch): assert "one\ntwo\n.aider*\n.env\n" == gitignore.read_text() @pytest.mark.parametrize( - "method,flag,should_include", + "flag,should_include", [ - ("command_line", None, False), - ("command_line", "--add-gitignore-files", True), - ("command_line", "--no-add-gitignore-files", False), - ("add_command", None, False), - ("add_command", "--add-gitignore-files", True), - ("add_command", "--no-add-gitignore-files", False), - ], - ids=[ - "cli_default", - "cli_enabled", - "cli_disabled", - "cmd_default", - "cmd_enabled", - "cmd_disabled", + (None, False), + ("--add-gitignore-files", True), + ("--no-add-gitignore-files", False), ], + ids=["default", "enabled", "disabled"], ) -def test_gitignore_files_flag(dummy_io, git_temp_dir, method, flag, should_include): - """Test --add-gitignore-files flag with command-line and /add command.""" - # Create a .gitignore file and an ignored file - gitignore_file = git_temp_dir / ".gitignore" - gitignore_file.write_text("ignored.txt\n") - ignored_file = git_temp_dir / "ignored.txt" - ignored_file.write_text("This file should be ignored.") +def test_gitignore_files_flag_command_line(dummy_io, git_temp_dir, flag, should_include): + """Test --add-gitignore-files flag with command-line arguments.""" + ignored_file = _create_gitignore_test_files(git_temp_dir) abs_ignored_file = str(ignored_file.resolve()) - # Build args list with optional flag args = ["--exit", "--yes-always"] if flag: args.insert(0, flag) + args.append(abs_ignored_file) - if method == "command_line": - # Add file via command line argument - args.append(abs_ignored_file) - coder = main(args, **dummy_io, return_coder=True, force_git_root=git_temp_dir) + coder = main(args, **dummy_io, return_coder=True, force_git_root=git_temp_dir) + + if should_include: + assert abs_ignored_file in coder.abs_fnames else: - # Add file via /add command - coder = main(args, **dummy_io, return_coder=True, force_git_root=git_temp_dir) - try: - asyncio.run(coder.commands.do_run("add", "ignored.txt")) - except SwitchCoder: - pass - - # Verify file is included or excluded as expected + assert abs_ignored_file not in coder.abs_fnames + + +@pytest.mark.parametrize( + "flag,should_include", + [ + (None, False), + ("--add-gitignore-files", True), + ("--no-add-gitignore-files", False), + ], + ids=["default", "enabled", "disabled"], +) +def test_gitignore_files_flag_add_command(dummy_io, git_temp_dir, flag, should_include): + """Test --add-gitignore-files flag with /add command.""" + ignored_file = _create_gitignore_test_files(git_temp_dir) + abs_ignored_file = str(ignored_file.resolve()) + + args = ["--exit", "--yes-always"] + if flag: + args.insert(0, flag) + + coder = main(args, **dummy_io, return_coder=True, force_git_root=git_temp_dir) + try: + asyncio.run(coder.commands.do_run("add", "ignored.txt")) + except SwitchCoder: + pass + if should_include: assert abs_ignored_file in coder.abs_fnames else: assert abs_ignored_file not in coder.abs_fnames + +def _create_gitignore_test_files(git_temp_dir): + """Helper to create gitignore test files.""" + gitignore_file = git_temp_dir / ".gitignore" + gitignore_file.write_text("ignored.txt\n") + ignored_file = git_temp_dir / "ignored.txt" + ignored_file.write_text("This file should be ignored.") + return ignored_file + @pytest.mark.parametrize( "args,expected_kwargs", [ From 3da705c1327c3749da27682474c250b6f56adcba Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 10:43:31 +0100 Subject: [PATCH 44/50] refactor: remove module docstring Remove boilerplate module docstring that just restates what's obvious from the file name and test names. --- tests/basic/test_main.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 2d439151d04..3a8676baad3 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -1,24 +1,3 @@ -"""Comprehensive tests for aider.main module. - -This test suite validates the main() function and its integration with various -aider components including configuration loading, model selection, git operations, -and command-line argument parsing. - -Note: main() is a thin wrapper around main_async() that uses asyncio.run(), so -these tests validate both the synchronous and asynchronous entry points. - -Test coverage includes: -- Command-line argument parsing and validation -- Configuration file loading (.aider.conf.yml, .env files) -- Model selection and API key management -- Git repository operations and setup -- Environment variable handling -- Feature flags and boolean options -- Model overrides and metadata -- MCP server configuration - -Total: 92 tests -""" import asyncio import json import os From 9c9e3e5c184de6c92745738653709ae6f6f49386 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 10:51:57 +0100 Subject: [PATCH 45/50] style: Ensure that linting rules are followed --- tests/basic/test_main.py | 124 +++++++++++++++++++++++++++++++-------- 1 file changed, 100 insertions(+), 24 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 3a8676baad3..45b46742a08 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -18,7 +18,12 @@ 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 ChdirTemporaryDirectory, GitTemporaryDirectory, IgnorantTemporaryDirectory, make_repo +from aider.utils import ( + ChdirTemporaryDirectory, + GitTemporaryDirectory, + IgnorantTemporaryDirectory, + make_repo, +) def mock_autosave_future(): @@ -98,16 +103,19 @@ def git_temp_dir(): def test_main_with_empty_dir_no_files_on_command(dummy_io): main(["--no-git", "--exit", "--yes-always"], **dummy_io) + def test_main_with_empty_dir_new_file(dummy_io): main(["foo.txt", "--yes-always", "--no-git", "--exit"], **dummy_io) assert os.path.exists("foo.txt") + def test_main_with_empty_git_dir_new_file(dummy_io, mocker): mocker.patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") make_repo() main(["--yes-always", "foo.txt", "--exit"], **dummy_io) assert os.path.exists("foo.txt") + def test_main_with_empty_git_dir_new_files(dummy_io, mocker): mocker.patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") make_repo() @@ -118,6 +126,7 @@ def test_main_with_empty_git_dir_new_files(dummy_io, mocker): assert os.path.exists("foo.txt") assert os.path.exists("bar.txt") + def test_main_with_dname_and_fname(dummy_io, git_temp_dir): subdir = Path("subdir") subdir.mkdir() @@ -125,6 +134,7 @@ def test_main_with_dname_and_fname(dummy_io, git_temp_dir): res = main(["subdir", "foo.txt"], **dummy_io) assert res is not None + def test_main_with_subdir_repo_fnames(dummy_io, git_temp_dir, mocker): mocker.patch("aider.repo.GitRepo.get_commit_message", return_value="mock commit message") subdir = Path("subdir") @@ -137,6 +147,7 @@ def test_main_with_subdir_repo_fnames(dummy_io, git_temp_dir, mocker): assert (subdir / "foo.txt").exists() assert (subdir / "bar.txt").exists() + def test_main_copy_paste_model_overrides(dummy_io, git_temp_dir): overrides = json.dumps({"gpt-4o": {"fast": {"temperature": 0.42}}}) coder = main( @@ -158,6 +169,7 @@ def test_main_copy_paste_model_overrides(dummy_io, git_temp_dir): assert coder.main_model.copy_paste_transport == "clipboard" assert coder.main_model.override_kwargs == {"temperature": 0.42} + def test_main_copy_paste_flag_sets_mode(dummy_io, git_temp_dir, mocker): mock_watcher = mocker.patch("aider.main.ClipboardWatcher") mock_watcher.return_value = MagicMock() @@ -174,6 +186,7 @@ def test_main_copy_paste_flag_sets_mode(dummy_io, git_temp_dir, mocker): assert coder.copy_paste_mode assert not coder.manual_copy_paste + def test_main_with_git_config_yml(dummy_io, mock_coder, git_temp_dir): make_repo() @@ -189,6 +202,7 @@ def test_main_with_git_config_yml(dummy_io, mock_coder, git_temp_dir): _, kwargs = mock_coder.call_args assert kwargs["auto_commits"] is True + def test_main_with_empty_git_dir_new_subdir_file(dummy_io, git_temp_dir): make_repo() subdir = Path("subdir") @@ -203,6 +217,7 @@ def test_main_with_empty_git_dir_new_subdir_file(dummy_io, git_temp_dir): # Because aider will try and `git add` a file that's already in the repo. main(["--yes-always", str(fname), "--exit"], **dummy_io) + def test_setup_git(dummy_io): io = InputOutput(pretty=False, yes=True) git_root = asyncio.run(setup_git(None, io)) @@ -215,6 +230,7 @@ def test_setup_git(dummy_io): assert gitignore.exists() assert ".aider*" == gitignore.read_text().splitlines()[0] + def test_check_gitignore(dummy_io, git_temp_dir, monkeypatch): monkeypatch.setenv("GIT_CONFIG_GLOBAL", "globalgitconfig") @@ -239,6 +255,7 @@ def test_check_gitignore(dummy_io, git_temp_dir, monkeypatch): asyncio.run(check_gitignore(cwd, io)) assert "one\ntwo\n.aider*\n.env\n" == gitignore.read_text() + @pytest.mark.parametrize( "flag,should_include", [ @@ -304,6 +321,7 @@ def _create_gitignore_test_files(git_temp_dir): ignored_file.write_text("This file should be ignored.") return ignored_file + @pytest.mark.parametrize( "args,expected_kwargs", [ @@ -321,6 +339,7 @@ def test_main_args(args, expected_kwargs, dummy_io, mock_coder, git_temp_dir): for key, expected_value in expected_kwargs.items(): assert kwargs[key] is expected_value + def test_env_file_override(dummy_io, git_temp_dir, mocker, monkeypatch): git_env = git_temp_dir / ".env" @@ -351,6 +370,7 @@ def test_env_file_override(dummy_io, git_temp_dir, mocker, monkeypatch): assert os.environ["D"] == "home" assert os.environ["E"] == "existing" + def test_message_file_flag(dummy_io, git_temp_dir, mocker): message_file_content = "This is a test message from a file." message_file_path = tempfile.mktemp() @@ -377,6 +397,7 @@ async def mock_run(*args, **kwargs): os.remove(message_file_path) + def test_encodings_arg(dummy_io, git_temp_dir, mocker): fname = "foo.py" @@ -395,6 +416,7 @@ def side_effect(*args, **kwargs): main(["--yes-always", fname, "--encoding", "iso-8859-15"]) + def test_main_exit_calls_version_check(dummy_io, git_temp_dir, mocker): mock_check_version = mocker.patch("aider.main.check_version") mock_input_output = mocker.patch("aider.main.InputOutput") @@ -403,8 +425,9 @@ def test_main_exit_calls_version_check(dummy_io, git_temp_dir, mocker): mock_check_version.assert_called_once() mock_input_output.assert_called_once() + def test_main_message_adds_to_input_history(dummy_io, mocker): - mock_run = mocker.patch("aider.coders.base_coder.Coder.run") + mocker.patch("aider.coders.base_coder.Coder.run") MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) test_message = "test message" mock_io_instance = MockInputOutput.return_value @@ -414,8 +437,9 @@ def test_main_message_adds_to_input_history(dummy_io, mocker): mock_io_instance.add_to_input_history.assert_called_once_with(test_message) + def test_yes(dummy_io, mocker): - mock_run = mocker.patch("aider.coders.base_coder.Coder.run") + mocker.patch("aider.coders.base_coder.Coder.run") MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) test_message = "test message" MockInputOutput.return_value.pretty = True @@ -424,8 +448,9 @@ def test_yes(dummy_io, mocker): args, kwargs = MockInputOutput.call_args assert args[1] + def test_default_yes(dummy_io, mocker): - mock_run = mocker.patch("aider.coders.base_coder.Coder.run") + mocker.patch("aider.coders.base_coder.Coder.run") MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) test_message = "test message" MockInputOutput.return_value.pretty = True @@ -434,6 +459,7 @@ def test_default_yes(dummy_io, mocker): args, kwargs = MockInputOutput.call_args assert args[1] is None + @pytest.mark.parametrize( "mode_flag,expected_theme", [ @@ -453,6 +479,7 @@ def test_mode_sets_code_theme(mode_flag, expected_theme, dummy_io, git_temp_dir, _, kwargs = MockInputOutput.call_args assert kwargs["code_theme"] == expected_theme + @pytest.mark.parametrize( "env_file,env_content,check_attribute,expected_value,use_flag", [ @@ -498,6 +525,7 @@ def test_env_file_variables( assert kwargs[check_attribute] == expected_value + def test_lint_option(dummy_io, git_temp_dir, mocker): # Create a dirty file in the root dirty_file = Path("dirty_file.py") @@ -530,6 +558,7 @@ def test_lint_option(dummy_io, git_temp_dir, mocker): assert called_arg.endswith("dirty_file.py") assert not called_arg.endswith(f"subdir{os.path.sep}dirty_file.py") + def test_lint_option_with_explicit_files(dummy_io, git_temp_dir, mocker): # Create two files file1 = Path("file1.py") @@ -555,6 +584,7 @@ def test_lint_option_with_explicit_files(dummy_io, git_temp_dir, mocker): assert any(f.endswith("file1.py") for f in called_files) assert any(f.endswith("file2.py") for f in called_files) + def test_lint_option_with_glob_pattern(dummy_io, git_temp_dir, mocker): # Create multiple Python files file1 = Path("test1.py") @@ -584,6 +614,7 @@ def test_lint_option_with_glob_pattern(dummy_io, git_temp_dir, mocker): # Check that non-Python file was not linted assert not any(f.endswith("readme.txt") for f in called_files) + def test_verbose_mode_lists_env_vars(dummy_io, mocker): Path(".env").write_text("AIDER_DARK_MODE=on") mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) @@ -593,9 +624,7 @@ def test_verbose_mode_lists_env_vars(dummy_io, mocker): ) 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 + 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 assert "AIDER_DARK_MODE" in relevant_output assert "dark_mode" in relevant_output @@ -604,6 +633,7 @@ def test_verbose_mode_lists_env_vars(dummy_io, mocker): assert re.search(r"AIDER_DARK_MODE:\s+on", relevant_output) assert re.search(r"dark_mode:\s+True", relevant_output) + def test_yaml_config_loads_from_named_file(dummy_io, git_temp_dir, mocker, monkeypatch): # git_temp_dir fixture already changed into the temp directory fake_home = git_temp_dir / "fake_home" @@ -624,6 +654,7 @@ def test_yaml_config_loads_from_named_file(dummy_io, git_temp_dir, mocker, monke assert kwargs["main_model"].name == "gpt-4-1106-preview" assert kwargs["map_tokens"] == 8192 + def test_yaml_config_loads_from_cwd(dummy_io, git_temp_dir, mocker, monkeypatch): fake_home = git_temp_dir / "fake_home" fake_home.mkdir() @@ -647,6 +678,7 @@ def test_yaml_config_loads_from_cwd(dummy_io, git_temp_dir, mocker, monkeypatch) assert kwargs["main_model"].name == "gpt-4-32k" assert kwargs["map_tokens"] == 4096 + def test_yaml_config_loads_from_git_root(dummy_io, git_temp_dir, mocker, monkeypatch): fake_home = git_temp_dir / "fake_home" fake_home.mkdir() @@ -671,6 +703,7 @@ def test_yaml_config_loads_from_git_root(dummy_io, git_temp_dir, mocker, monkeyp assert kwargs["main_model"].name == "gpt-4" assert kwargs["map_tokens"] == 2048 + def test_yaml_config_loads_from_home(dummy_io, git_temp_dir, mocker, monkeypatch): fake_home = git_temp_dir / "fake_home" fake_home.mkdir() @@ -695,6 +728,7 @@ def test_yaml_config_loads_from_home(dummy_io, git_temp_dir, mocker, monkeypatch assert kwargs["main_model"].name == "gpt-3.5-turbo" assert kwargs["map_tokens"] == 1024 + def test_map_tokens_option(dummy_io, git_temp_dir, mocker): MockRepoMap = mocker.patch("aider.coders.base_coder.RepoMap") MockRepoMap.return_value.max_map_tokens = 0 @@ -704,6 +738,7 @@ def test_map_tokens_option(dummy_io, git_temp_dir, mocker): ) MockRepoMap.assert_not_called() + def test_map_tokens_option_with_non_zero_value(dummy_io, git_temp_dir, mocker): MockRepoMap = mocker.patch("aider.coders.base_coder.RepoMap") MockRepoMap.return_value.max_map_tokens = 1000 @@ -713,6 +748,7 @@ def test_map_tokens_option_with_non_zero_value(dummy_io, git_temp_dir, mocker): ) MockRepoMap.assert_called_once() + def test_read_option(dummy_io, git_temp_dir): test_file = "test_file.txt" Path(test_file).touch() @@ -725,6 +761,7 @@ def test_read_option(dummy_io, git_temp_dir): assert str(Path(test_file).resolve()) in coder.abs_read_only_fnames + def test_read_option_with_external_file(dummy_io, git_temp_dir): with tempfile.NamedTemporaryFile(mode="w", delete=False) as external_file: external_file.write("External file content") @@ -742,6 +779,7 @@ def test_read_option_with_external_file(dummy_io, git_temp_dir): finally: os.unlink(external_file_path) + def test_model_metadata_file(dummy_io, git_temp_dir): # Re-init so we don't have old data lying around from earlier test cases from aider import models @@ -773,6 +811,7 @@ def test_model_metadata_file(dummy_io, git_temp_dir): assert coder.main_model.info["max_input_tokens"] == 1234 + def test_sonnet_and_cache_options(dummy_io, git_temp_dir, mocker): MockRepoMap = mocker.patch("aider.coders.base_coder.RepoMap") mock_repo_map = MagicMock() @@ -788,6 +827,7 @@ def test_sonnet_and_cache_options(dummy_io, git_temp_dir, mocker): call_args, call_kwargs = MockRepoMap.call_args assert call_kwargs.get("refresh") == "files" # Check the 'refresh' keyword argument + def test_sonnet_and_cache_prompts_options(dummy_io, git_temp_dir): coder = main( ["--sonnet", "--cache-prompts", "--exit", "--yes-always"], @@ -797,6 +837,7 @@ def test_sonnet_and_cache_prompts_options(dummy_io, git_temp_dir): assert coder.add_cache_headers + def test_4o_and_cache_options(dummy_io, git_temp_dir): coder = main( ["--4o", "--cache-prompts", "--exit", "--yes-always"], @@ -806,6 +847,7 @@ def test_4o_and_cache_options(dummy_io, git_temp_dir): assert not coder.add_cache_headers + def test_return_coder(dummy_io, git_temp_dir): result = main( ["--exit", "--yes-always"], @@ -821,6 +863,7 @@ def test_return_coder(dummy_io, git_temp_dir): ) assert result == 0 + def test_map_mul_option(dummy_io, git_temp_dir): coder = main( ["--map-mul", "5", "--exit", "--yes-always"], @@ -830,6 +873,7 @@ def test_map_mul_option(dummy_io, git_temp_dir): assert isinstance(coder, Coder) assert coder.repo_map.map_mul_no_files == 5 + @pytest.mark.parametrize( "flag_arg,attr_name,expected", [ @@ -856,6 +900,7 @@ def test_boolean_flags(flag_arg, attr_name, expected, dummy_io, git_temp_dir): coder = main(args, **dummy_io, return_coder=True) assert getattr(coder, attr_name) == expected + @pytest.mark.parametrize( "model,setting_flag,setting_value,method_name,check_flag,should_warn,should_call", [ @@ -896,7 +941,16 @@ def test_boolean_flags(flag_arg, attr_name, expected, dummy_io, git_temp_dir): ], ) def test_accepts_settings_warnings( - dummy_io, git_temp_dir, mocker, model, setting_flag, setting_value, method_name, check_flag, should_warn, should_call + dummy_io, + git_temp_dir, + mocker, + model, + setting_flag, + setting_value, + method_name, + check_flag, + should_warn, + should_call, ): # Test that appropriate warnings are shown based on accepts_settings configuration mock_warning = mocker.patch("aider.io.InputOutput.tool_warning") @@ -912,9 +966,9 @@ def test_accepts_settings_warnings( setting_name = setting_flag.lstrip("--").replace("-", "_") warnings = [call[0][0] for call in mock_warning.call_args_list] warning_shown = any(setting_name in w for w in warnings) - assert warning_shown == should_warn, ( - f"Expected warning={should_warn} for {setting_name} but got {warning_shown}" - ) + assert ( + warning_shown == should_warn + ), f"Expected warning={should_warn} for {setting_name} but got {warning_shown}" # Check if method was called if should_call: @@ -922,6 +976,7 @@ def test_accepts_settings_warnings( else: mock_method.assert_not_called() + def test_no_verify_ssl_sets_model_info_manager(dummy_io, git_temp_dir, mocker): mock_set_verify_ssl = mocker.patch("aider.models.ModelInfoManager.set_verify_ssl") # Mock Model class to avoid actual model initialization @@ -942,10 +997,12 @@ def test_no_verify_ssl_sets_model_info_manager(dummy_io, git_temp_dir, mocker): ) mock_set_verify_ssl.assert_called_once_with(False) + def test_pytest_env_vars(dummy_io, git_temp_dir): # Verify that environment variables from pytest.ini are properly set assert os.environ.get("AIDER_ANALYTICS") == "false" + @pytest.mark.parametrize( "set_env_args,expected_env,expected_result", [ @@ -980,6 +1037,7 @@ def test_set_env(set_env_args, expected_env, expected_result, dummy_io, git_temp for env_var, expected_value in expected_env.items(): assert os.environ.get(env_var) == expected_value + @pytest.mark.parametrize( "api_key_args,expected_env,expected_result", [ @@ -1009,6 +1067,7 @@ def test_api_key(api_key_args, expected_env, expected_result, dummy_io, git_temp for env_var, expected_value in expected_env.items(): assert os.environ.get(env_var) == expected_value + def test_git_config_include(dummy_io, git_temp_dir): # Test that aider respects git config includes for user.name and user.email # Create an includable config file with user settings @@ -1042,6 +1101,7 @@ def test_git_config_include(dummy_io, git_temp_dir): git_config_content_after = git_config_path.read_text() assert git_config_content == git_config_content_after + def test_git_config_include_directive(dummy_io, git_temp_dir): # Test that aider respects the include directive in git config # Create an includable config file with user settings @@ -1080,6 +1140,7 @@ def test_git_config_include_directive(dummy_io, git_temp_dir): assert repo.git.config("user.name") == "Directive User" assert repo.git.config("user.email") == "directive@example.com" + def test_resolve_aiderignore_path(dummy_io, git_temp_dir): # Import the function directly to test it from aider.args import resolve_aiderignore_path @@ -1097,6 +1158,7 @@ def test_resolve_aiderignore_path(dummy_io, git_temp_dir): rel_path = ".aiderignore" assert resolve_aiderignore_path(rel_path) == rel_path + def test_invalid_edit_format(dummy_io, git_temp_dir, mocker): # Suppress stderr for this test as argparse prints an error message mock_stderr = mocker.patch("sys.stderr", new_callable=StringIO) @@ -1111,6 +1173,7 @@ def test_invalid_edit_format(dummy_io, git_temp_dir, mocker): assert "invalid choice" in stderr_output assert "not-a-real-format" in stderr_output + @pytest.mark.parametrize( "api_key_env,expected_model_substr", [ @@ -1152,6 +1215,7 @@ def test_default_model_selection(api_key_env, expected_model_substr, dummy_io, g for key, value in saved_keys.items(): os.environ[key] = value + def test_default_model_selection_oauth_fallback(dummy_io, git_temp_dir, mocker): # Test no API keys - should offer OpenRouter OAuth # Clear all API keys to simulate no configured keys @@ -1179,6 +1243,7 @@ def test_default_model_selection_oauth_fallback(dummy_io, git_temp_dir, mocker): for key, value in saved_keys.items(): os.environ[key] = value + def test_model_precedence(dummy_io, git_temp_dir, monkeypatch): # Test that earlier API keys take precedence monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") @@ -1190,6 +1255,7 @@ def test_model_precedence(dummy_io, git_temp_dir, monkeypatch): ) assert "sonnet" in coder.main_model.name.lower() + def test_model_overrides_suffix_applied(dummy_io, git_temp_dir, mocker): overrides_file = git_temp_dir / ".aider.model.overrides.yml" overrides_file.write_text("gpt-4o:\n fast:\n temperature: 0.1\n") @@ -1221,18 +1287,14 @@ def test_model_overrides_suffix_applied(dummy_io, git_temp_dir, mocker): matched_call_found = False for call_args in MockModel.call_args_list: args, kwargs = call_args - if ( - args - and args[0] == "gpt-4o" - and kwargs.get("override_kwargs") == {"temperature": 0.1} - ): + if args and args[0] == "gpt-4o" and kwargs.get("override_kwargs") == {"temperature": 0.1}: matched_call_found = True break - assert matched_call_found, ( - "Expected a Model call with base name 'gpt-4o' and override_kwargs" - " {'temperature': 0.1}" - ) + assert ( + matched_call_found + ), "Expected a Model call with base name 'gpt-4o' and override_kwargs {'temperature': 0.1}" + def test_model_overrides_no_match_preserves_model_name(dummy_io, git_temp_dir, mocker): MockModel = mocker.patch("aider.models.Model") @@ -1267,10 +1329,10 @@ def test_model_overrides_no_match_preserves_model_name(dummy_io, git_temp_dir, m matched_call_found = True break - assert matched_call_found, ( - "Expected a Model call with the full model name preserved and empty" - " override_kwargs" - ) + assert ( + matched_call_found + ), "Expected a Model call with the full model name preserved and empty override_kwargs" + def test_chat_language_spanish(dummy_io, git_temp_dir): coder = main( @@ -1281,6 +1343,7 @@ def test_chat_language_spanish(dummy_io, git_temp_dir): system_info = coder.get_platform_info() assert "Spanish" in system_info + def test_commit_language_japanese(dummy_io, git_temp_dir): coder = main( ["--commit-language", "japanese", "--exit", "--yes-always"], @@ -1289,6 +1352,7 @@ def test_commit_language_japanese(dummy_io, git_temp_dir): ) assert "japanese" in coder.commit_language + def test_main_exit_with_git_command_not_found(dummy_io, git_temp_dir, mocker): mock_git_init = mocker.patch("git.Repo.init") mock_git_init.side_effect = git.exc.GitCommandNotFound("git", "Command 'git' not found") @@ -1296,6 +1360,7 @@ def test_main_exit_with_git_command_not_found(dummy_io, git_temp_dir, mocker): result = main(["--exit", "--yes-always"], **dummy_io) assert result == 0, "main() should return 0 (success) when called with --exit" + def test_reasoning_effort_option(dummy_io, git_temp_dir): coder = main( [ @@ -1310,6 +1375,7 @@ def test_reasoning_effort_option(dummy_io, git_temp_dir): ) assert coder.main_model.extra_params.get("extra_body", {}).get("reasoning_effort") == "3" + def test_thinking_tokens_option(dummy_io, git_temp_dir): coder = main( ["--model", "sonnet", "--thinking-tokens", "1000", "--yes-always", "--exit"], @@ -1318,6 +1384,7 @@ def test_thinking_tokens_option(dummy_io, git_temp_dir): ) assert coder.main_model.extra_params.get("thinking", {}).get("budget_tokens") == 1000 + def test_list_models_includes_metadata_models(dummy_io, git_temp_dir, mocker): # Test that models from model-metadata.json appear in list-models output # Create a temporary model-metadata.json with test models @@ -1354,6 +1421,7 @@ def test_list_models_includes_metadata_models(dummy_io, git_temp_dir, mocker): # Check that the unique model name from our metadata file is listed assert "test-provider/unique-model-name" in output + def test_list_models_includes_all_model_sources(dummy_io, git_temp_dir, mocker): # Test that models from both litellm.model_cost and model-metadata.json # appear in list-models @@ -1388,6 +1456,7 @@ def test_list_models_includes_all_model_sources(dummy_io, git_temp_dir, mocker): # Check that both models appear in the output assert "test-provider/metadata-only-model" in output + def test_check_model_accepts_settings_flag(dummy_io, git_temp_dir, mocker): # Test that --check-model-accepts-settings affects whether settings are applied # When flag is on, setting shouldn't be applied to non-supporting model @@ -1407,6 +1476,7 @@ def test_check_model_accepts_settings_flag(dummy_io, git_temp_dir, mocker): # Method should not be called because model doesn't support it and flag is on mock_set_thinking.assert_not_called() + def test_list_models_with_direct_resource_patch(dummy_io, mocker): # Test that models from resources/model-metadata.json are included in list-models output # Create a temporary file with test model metadata @@ -1440,6 +1510,7 @@ def test_list_models_with_direct_resource_patch(dummy_io, mocker): # Check that the resource model appears in the output assert "resource-provider/special-model" in output + def test_reasoning_effort_applied_without_check_flag(dummy_io, mocker): # When --no-check-model-accepts-settings flag is used, settings should be applied # regardless of whether the model supports them @@ -1459,6 +1530,7 @@ def test_reasoning_effort_applied_without_check_flag(dummy_io, mocker): # Method should be called because check flag is off mock_set_reasoning.assert_called_once_with("3") + def test_model_accepts_settings_attribute(dummy_io, git_temp_dir, mocker): # Test with a model where we override the accepts_settings attribute MockModel = mocker.patch("aider.models.Model") @@ -1494,6 +1566,7 @@ def test_model_accepts_settings_attribute(dummy_io, git_temp_dir, mocker): mock_instance.set_reasoning_effort.assert_called_once_with("3") mock_instance.set_thinking_tokens.assert_not_called() + @pytest.mark.parametrize( "flags,should_warn", [ @@ -1520,6 +1593,7 @@ def test_stream_cache_warning(dummy_io, git_temp_dir, mocker, flags, should_warn for call in mock_io_instance.tool_warning.call_args_list: assert "Cost estimates may be inaccurate" not in call[0][0] + def test_argv_file_respects_git(dummy_io, git_temp_dir): fname = Path("not_in_git.txt") fname.touch() @@ -1533,6 +1607,7 @@ def test_argv_file_respects_git(dummy_io, git_temp_dir): assert "not_in_git.txt" not in str(coder.abs_fnames) assert not asyncio.run(coder.allowed_to_edit("not_in_git.txt")) + def test_load_dotenv_files_override(dummy_io, git_temp_dir, mocker): # Create fake home and .aider directory fake_home = git_temp_dir / "fake_home" @@ -1587,6 +1662,7 @@ def test_load_dotenv_files_override(dummy_io, git_temp_dir, mocker): # Restore CWD os.chdir(original_cwd) + def test_mcp_servers_parsing(dummy_io, git_temp_dir, mocker): # Setup mock coder mock_coder_create = mocker.patch("aider.coders.Coder.create") From 72f92fbfce7bd3de8599c7561e0c7fa022412d0e Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 11:31:21 +0100 Subject: [PATCH 46/50] fix: remove clear=True from test_env to preserve PATH on Windows The test_env fixture was using mocker.patch.dict(os.environ, ..., clear=True) which cleared ALL environment variables including PATH. This broke git operations on Windows where git.exe cannot be found without PATH. Since tests are already protected by comprehensive mocking (Coder.create, InputOutput, --exit flags), clearing the environment is unnecessary security theater. The real protection is the mocking layer, not environment isolation. Benefits of this change: - Fixes ~55 test failures on Windows (git command not found) - Allows debugging with environment variables (AIDER_VERBOSE=1, etc.) - Simpler fixture without platform-specific whitelisting - Relies on existing mock boundaries for API call prevention All 104 tests passing on macOS. --- tests/basic/test_main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index e40ffcaebe2..8b57f5dabcb 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -63,18 +63,18 @@ def test_env(mocker, temp_cwd, temp_home): All resources are automatically cleaned up by dependency fixtures and mocker. """ - clean_env = { + test_env_vars = { "OPENAI_API_KEY": "deadbeef", "AIDER_CHECK_UPDATE": "false", "AIDER_ANALYTICS": "false", } if platform.system() == "Windows": - clean_env["USERPROFILE"] = temp_home + test_env_vars["USERPROFILE"] = temp_home else: - clean_env["HOME"] = temp_home + test_env_vars["HOME"] = temp_home - mocker.patch.dict(os.environ, clean_env, clear=True) + mocker.patch.dict(os.environ, test_env_vars) mocker.patch("builtins.input", return_value=None) mocker.patch("aider.io.webbrowser.open") From f91c5b5570b5fe7ada211f06b2a605cd3f2f54de Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 13:54:21 +0100 Subject: [PATCH 47/50] refactor: rename tests for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_main_with_dname_and_fname → test_main_with_subdir_and_fname - test_yes → test_yes_always - test_default_yes → test_default_of_yes_all_is_none Improves test naming consistency and readability. --- tests/basic/test_main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 8b57f5dabcb..ae74c064917 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -128,7 +128,7 @@ def test_main_with_empty_git_dir_new_files(dummy_io, mocker): assert os.path.exists("bar.txt") -def test_main_with_dname_and_fname(dummy_io, git_temp_dir): +def test_main_with_subdir_and_fname(dummy_io, git_temp_dir): subdir = Path("subdir") subdir.mkdir() make_repo(str(subdir)) @@ -439,7 +439,7 @@ def test_main_message_adds_to_input_history(dummy_io, mocker): mock_io_instance.add_to_input_history.assert_called_once_with(test_message) -def test_yes(dummy_io, mocker): +def test_yes_always(dummy_io, mocker): mocker.patch("aider.coders.base_coder.Coder.run") MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) test_message = "test message" @@ -450,7 +450,7 @@ def test_yes(dummy_io, mocker): assert args[1] -def test_default_yes(dummy_io, mocker): +def test_default_of_yes_all_is_none(dummy_io, mocker): mocker.patch("aider.coders.base_coder.Coder.run") MockInputOutput = mocker.patch("aider.main.InputOutput", autospec=True) test_message = "test message" From 5a42d6dc114d23fd44b8f080f8babe3cadbdee08 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 14:42:25 +0100 Subject: [PATCH 48/50] refactor: use capsys fixture for stdout capture Replace manual StringIO mocking with pytest's built-in capsys fixture in 6 tests: - test_verbose_mode_lists_env_vars - test_invalid_edit_format (stderr) - test_list_models_includes_metadata_models - test_list_models_includes_all_model_sources - test_list_models_includes_openai_provider - test_list_models_with_direct_resource_patch Removes StringIO import dependency and uses standard pytest pattern for cleaner, more maintainable test code. --- tests/basic/test_main.py | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index ae74c064917..e8f315627ff 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -5,7 +5,6 @@ import subprocess import tempfile import types -from io import StringIO from pathlib import Path from unittest.mock import AsyncMock, MagicMock @@ -616,14 +615,14 @@ def test_lint_option_with_glob_pattern(dummy_io, git_temp_dir, mocker): assert not any(f.endswith("readme.txt") for f in called_files) -def test_verbose_mode_lists_env_vars(dummy_io, mocker): +def test_verbose_mode_lists_env_vars(dummy_io, mocker, capsys): Path(".env").write_text("AIDER_DARK_MODE=on") - mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) main( ["--no-git", "--verbose", "--exit", "--yes-always"], **dummy_io, ) - output = mock_stdout.getvalue() + captured = capsys.readouterr() + output = captured.out 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 @@ -1160,9 +1159,8 @@ def test_resolve_aiderignore_path(dummy_io, git_temp_dir): assert resolve_aiderignore_path(rel_path) == rel_path -def test_invalid_edit_format(dummy_io, git_temp_dir, mocker): +def test_invalid_edit_format(dummy_io, git_temp_dir, mocker, capsys): # Suppress stderr for this test as argparse prints an error message - mock_stderr = mocker.patch("sys.stderr", new_callable=StringIO) with pytest.raises(SystemExit) as cm: _ = main( ["--edit-format", "not-a-real-format", "--exit", "--yes-always"], @@ -1170,7 +1168,8 @@ def test_invalid_edit_format(dummy_io, git_temp_dir, mocker): ) # argparse.ArgumentParser.exit() is called with status 2 for invalid choice assert cm.value.code == 2 - stderr_output = mock_stderr.getvalue() + captured = capsys.readouterr() + stderr_output = captured.err assert "invalid choice" in stderr_output assert "not-a-real-format" in stderr_output @@ -1386,7 +1385,7 @@ def test_thinking_tokens_option(dummy_io, git_temp_dir): assert coder.main_model.extra_params.get("thinking", {}).get("budget_tokens") == 1000 -def test_list_models_includes_metadata_models(dummy_io, git_temp_dir, mocker): +def test_list_models_includes_metadata_models(dummy_io, git_temp_dir, mocker, capsys): # Test that models from model-metadata.json appear in list-models output # Create a temporary model-metadata.json with test models metadata_file = Path(".aider.model.metadata.json") @@ -1404,8 +1403,6 @@ def test_list_models_includes_metadata_models(dummy_io, git_temp_dir, mocker): } metadata_file.write_text(json.dumps(test_models)) - # Capture stdout to check the output - mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) main( [ "--list-models", @@ -1417,13 +1414,14 @@ def test_list_models_includes_metadata_models(dummy_io, git_temp_dir, mocker): ], **dummy_io, ) - output = mock_stdout.getvalue() + captured = capsys.readouterr() + output = captured.out # Check that the unique model name from our metadata file is listed assert "test-provider/unique-model-name" in output -def test_list_models_includes_all_model_sources(dummy_io, git_temp_dir, mocker): +def test_list_models_includes_all_model_sources(dummy_io, git_temp_dir, mocker, capsys): # Test that models from both litellm.model_cost and model-metadata.json # appear in list-models # Create a temporary model-metadata.json with test models @@ -1437,8 +1435,6 @@ def test_list_models_includes_all_model_sources(dummy_io, git_temp_dir, mocker): } metadata_file.write_text(json.dumps(test_models)) - # Capture stdout to check the output - mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) main( [ "--list-models", @@ -1450,7 +1446,8 @@ def test_list_models_includes_all_model_sources(dummy_io, git_temp_dir, mocker): ], **dummy_io, ) - output = mock_stdout.getvalue() + captured = capsys.readouterr() + output = captured.out dump(output) @@ -1458,7 +1455,7 @@ def test_list_models_includes_all_model_sources(dummy_io, git_temp_dir, mocker): assert "test-provider/metadata-only-model" in output -def test_list_models_includes_openai_provider(dummy_io, git_temp_dir, mocker): +def test_list_models_includes_openai_provider(dummy_io, git_temp_dir, mocker, capsys): import aider.models as models_module provider_name = "openai" @@ -1497,13 +1494,13 @@ def _fake_get(url, *, headers=None, timeout=None, verify=None): try: mocker.patch("requests.get", _fake_get) - mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) main( ["--list-models", "openai/demo/foo", "--yes", "--no-gitignore"], **dummy_io, ) - output = mock_stdout.getvalue() + captured = capsys.readouterr() + output = captured.out assert "openai/demo/foo" in output finally: if had_config: @@ -1542,7 +1539,7 @@ def test_check_model_accepts_settings_flag(dummy_io, git_temp_dir, mocker): mock_set_thinking.assert_not_called() -def test_list_models_with_direct_resource_patch(dummy_io, mocker): +def test_list_models_with_direct_resource_patch(dummy_io, mocker, capsys): # Test that models from resources/model-metadata.json are included in list-models output # Create a temporary file with test model metadata test_file = Path(os.getcwd()) / "test-model-metadata.json" @@ -1564,13 +1561,12 @@ def test_list_models_with_direct_resource_patch(dummy_io, mocker): mock_files.joinpath.return_value = mock_resource_path mocker.patch("aider.main.importlib_resources.files", return_value=mock_files) - # Capture stdout to check the output - mock_stdout = mocker.patch("sys.stdout", new_callable=StringIO) main( ["--list-models", "special", "--yes-always", "--no-gitignore"], **dummy_io, ) - output = mock_stdout.getvalue() + captured = capsys.readouterr() + output = captured.out # Check that the resource model appears in the output assert "resource-provider/special-model" in output From da0eb658bbc2aa3350a0ace19edd030952481268 Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 16:48:19 +0100 Subject: [PATCH 49/50] refactor: use tmp_path fixture for temporary files Replace tempfile operations with pytest's tmp_path fixture in 2 tests: - test_message_file_flag: Replace tempfile.mktemp() with tmp_path - test_read_option_with_external_file: Replace NamedTemporaryFile with tmp_path Benefits: - Removes tempfile import dependency - Automatic cleanup (no manual os.unlink/os.remove needed) - Safer and more reliable (no deprecated mktemp) - Standard pytest pattern for better maintainability - Eliminates try/finally blocks for cleanup --- tests/basic/test_main.py | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index e8f315627ff..dd1b21f29fd 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -3,7 +3,6 @@ import os import platform import subprocess -import tempfile import types from pathlib import Path from unittest.mock import AsyncMock, MagicMock @@ -371,11 +370,10 @@ def test_env_file_override(dummy_io, git_temp_dir, mocker, monkeypatch): assert os.environ["E"] == "existing" -def test_message_file_flag(dummy_io, git_temp_dir, mocker): +def test_message_file_flag(dummy_io, git_temp_dir, mocker, tmp_path): 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) + message_file = tmp_path / "message.txt" + message_file.write_text(message_file_content, encoding="utf-8") # Create a mock async function for the run method async def mock_run(*args, **kwargs): @@ -389,14 +387,12 @@ async def mock_run(*args, **kwargs): MockCoder.return_value = mock_coder_instance main( - ["--yes-always", "--message-file", message_file_path], + ["--yes-always", "--message-file", str(message_file)], **dummy_io, ) # 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) - def test_encodings_arg(dummy_io, git_temp_dir, mocker): fname = "foo.py" @@ -762,22 +758,18 @@ def test_read_option(dummy_io, git_temp_dir): assert str(Path(test_file).resolve()) in coder.abs_read_only_fnames -def test_read_option_with_external_file(dummy_io, git_temp_dir): - with tempfile.NamedTemporaryFile(mode="w", delete=False) as external_file: - external_file.write("External file content") - external_file_path = external_file.name +def test_read_option_with_external_file(dummy_io, git_temp_dir, tmp_path): + external_file = tmp_path / "external_file.txt" + external_file.write_text("External file content") - try: - coder = main( - ["--read", external_file_path, "--exit", "--yes-always"], - **dummy_io, - return_coder=True, - ) + coder = main( + ["--read", str(external_file), "--exit", "--yes-always"], + **dummy_io, + return_coder=True, + ) - real_external_file_path = os.path.realpath(external_file_path) - assert real_external_file_path in coder.abs_read_only_fnames - finally: - os.unlink(external_file_path) + real_external_file_path = os.path.realpath(str(external_file)) + assert real_external_file_path in coder.abs_read_only_fnames def test_model_metadata_file(dummy_io, git_temp_dir): From 535c3ed201d05fa71e8de9232ca004c3debc5ccc Mon Sep 17 00:00:00 2001 From: Johannes Bornhold Date: Tue, 30 Dec 2025 17:08:46 +0100 Subject: [PATCH 50/50] refactor: use Path.resolve() --- tests/basic/test_main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index dd1b21f29fd..d32e384b2aa 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -768,7 +768,7 @@ def test_read_option_with_external_file(dummy_io, git_temp_dir, tmp_path): return_coder=True, ) - real_external_file_path = os.path.realpath(str(external_file)) + real_external_file_path = str(external_file.resolve()) assert real_external_file_path in coder.abs_read_only_fnames