From 0e1e97c1b8cd7b97cf82f76a5383e97fe5779899 Mon Sep 17 00:00:00 2001 From: Sarthak816 Date: Mon, 29 Jun 2026 23:02:58 +0530 Subject: [PATCH 1/7] fix: bump tree-sitter-c-sharp to 0.23.5 to fix source build on OpenBSD Fixes #5307 - on OpenBSD and other platforms without pre-built wheels, tree-sitter-c-sharp builds from source. Versions 0.23.1-0.23.4 do not bundle the tree_sitter/parser.h header, causing the C compiler to fail with 'fatal error: tree_sitter/parser.h not found'. Updated tree-sitter-c-sharp to 0.23.5 which includes the necessary headers for source builds. Changes: - requirements.txt: 0.23.1 -> 0.23.5 - requirements/common-constraints.txt: 0.23.1 -> 0.23.5 - requirements/tree-sitter.in: added tree-sitter-c-sharp>=0.23.5 constraint with explanatory comment --- requirements.txt | 2 +- requirements/common-constraints.txt | 2 +- requirements/tree-sitter.in | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index cddf4138262..b158c9ad32f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -411,7 +411,7 @@ tqdm==4.67.3 # via # -c requirements/common-constraints.txt # tree-sitter-language-pack -tree-sitter-c-sharp==0.23.1 +tree-sitter-c-sharp==0.23.5 # via # -c requirements/common-constraints.txt # tree-sitter-language-pack diff --git a/requirements/common-constraints.txt b/requirements/common-constraints.txt index 30986eaec3d..87e9b6f1091 100644 --- a/requirements/common-constraints.txt +++ b/requirements/common-constraints.txt @@ -560,7 +560,7 @@ transformers==5.3.0 # via sentence-transformers tree-sitter==0.25.2 # via tree-sitter-language-pack -tree-sitter-c-sharp==0.23.1 +tree-sitter-c-sharp==0.23.5 # via tree-sitter-language-pack tree-sitter-embedded-template==0.25.0 # via tree-sitter-language-pack diff --git a/requirements/tree-sitter.in b/requirements/tree-sitter.in index 64516344393..442148ff56b 100644 --- a/requirements/tree-sitter.in +++ b/requirements/tree-sitter.in @@ -1,3 +1,8 @@ tree-sitter==0.23.2; python_version < "3.10" tree-sitter==0.25.2; python_version >= "3.10" + +# Versions 0.23.1-0.23.4 of tree-sitter-c-sharp do not bundle the +# tree_sitter/parser.h header, causing source builds to fail on platforms +# without pre-built wheels (e.g. OpenBSD). +tree-sitter-c-sharp>=0.23.5 From 31a53cda9a16351a46ead2f0bae6da89d87bcab0 Mon Sep 17 00:00:00 2001 From: Sarthak816 Date: Tue, 30 Jun 2026 23:22:03 +0530 Subject: [PATCH 2/7] feat: add type hints to public API functions Fixes #5358 - adds type annotations to all public functions across 4 core modules: - aider/main.py: ~20 functions typed (entry points, argument parsing, git setup) - aider/commands.py: ~45 methods typed (all user-facing / commands + helpers) - aider/io.py: ~25 methods typed (InputOutput class + helpers) - aider/models.py: ~40 methods typed (ModelInfoManager, Model class, module-level functions) Key implementation decisions: - Added from __future__ import annotations to all files for forward reference support - Used Optional, Union, Any, Callable, TextIO from typing as appropriate - NoRuntime for functions that always raise exceptions (Sys.exit, SwitchCoder) - Used None return type for functions that sometimes return and sometimes raise --- aider/commands.py | 189 +++++++++++++++++++++++---------------------- aider/io.py | 191 ++++++++++++++++++++++++---------------------- aider/main.py | 72 ++++++++++------- aider/models.py | 107 ++++++++++++++------------ 4 files changed, 302 insertions(+), 257 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index 3881403c5c1..4f6f16205f4 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import glob import os import re @@ -7,6 +9,7 @@ from collections import OrderedDict from os.path import expanduser from pathlib import Path +from typing import Any, Callable, Optional, Union import pyperclip from PIL import Image, ImageGrab @@ -28,7 +31,7 @@ class SwitchCoder(Exception): - def __init__(self, placeholder=None, **kwargs): + def __init__(self, placeholder: Optional[str] = None, **kwargs: Any): self.kwargs = kwargs self.placeholder = placeholder @@ -37,7 +40,7 @@ class Commands: voice = None scraper = None - def clone(self): + def clone(self) -> Commands: return Commands( self.io, None, @@ -54,15 +57,15 @@ def __init__( self, io, coder, - voice_language=None, - voice_input_device=None, - voice_format=None, - verify_ssl=True, - args=None, - parser=None, - verbose=False, - editor=None, - original_read_only_fnames=None, + voice_language: Optional[str] = None, + voice_input_device: Optional[str] = None, + voice_format: Optional[str] = None, + verify_ssl: bool = True, + args: Optional[Any] = None, + parser: Optional[Any] = None, + verbose: bool = False, + editor: Optional[str] = None, + original_read_only_fnames: Optional[Union[set, list]] = None, ): self.io = io self.coder = coder @@ -84,7 +87,7 @@ def __init__( # Store the original read-only filenames provided via args.read self.original_read_only_fnames = set(original_read_only_fnames or []) - def cmd_model(self, args): + def cmd_model(self, args: str) -> None: "Switch the Main Model to a new LLM" model_name = args.strip() @@ -111,7 +114,7 @@ def cmd_model(self, args): raise SwitchCoder(main_model=model, edit_format=new_edit_format) - def cmd_editor_model(self, args): + def cmd_editor_model(self, args: str) -> None: "Switch the Editor Model to a new LLM" model_name = args.strip() @@ -123,7 +126,7 @@ def cmd_editor_model(self, args): models.sanity_check_models(self.io, model) raise SwitchCoder(main_model=model) - def cmd_weak_model(self, args): + def cmd_weak_model(self, args: str) -> None: "Switch the Weak Model to a new LLM" model_name = args.strip() @@ -135,7 +138,7 @@ def cmd_weak_model(self, args): models.sanity_check_models(self.io, model) raise SwitchCoder(main_model=model) - def cmd_chat_mode(self, args): + def cmd_chat_mode(self, args: str) -> None: "Switch to a new chat mode" from aider import coders @@ -202,11 +205,11 @@ def cmd_chat_mode(self, args): summarize_from_coder=summarize_from_coder, ) - def completions_model(self): - models = litellm.model_cost.keys() - return models + def completions_model(self) -> list: + model_keys = litellm.model_cost.keys() + return model_keys - def cmd_models(self, args): + def cmd_models(self, args: str) -> None: "Search the list of available models" args = args.strip() @@ -216,13 +219,13 @@ def cmd_models(self, args): else: self.io.tool_output("Please provide a partial model name to search for.") - def cmd_web(self, args, return_content=False): + def cmd_web(self, args: str, return_content: bool = False) -> Optional[str]: "Scrape a webpage, convert to markdown and send in a message" url = args.strip() if not url: self.io.tool_error("Please provide a URL to scrape.") - return + return None self.io.tool_output(f"Scraping {url}...") if not self.scraper: @@ -251,11 +254,12 @@ def cmd_web(self, args, return_content=False): dict(role="user", content=content), dict(role="assistant", content="Ok."), ] + return None - def is_command(self, inp): + def is_command(self, inp: str) -> bool: return inp[0] in "/!" - def get_raw_completions(self, cmd): + def get_raw_completions(self, cmd: str) -> Optional[Callable]: assert cmd.startswith("/") cmd = cmd[1:] cmd = cmd.replace("-", "_") @@ -263,17 +267,17 @@ def get_raw_completions(self, cmd): raw_completer = getattr(self, f"completions_raw_{cmd}", None) return raw_completer - def get_completions(self, cmd): + def get_completions(self, cmd: str) -> Optional[list]: assert cmd.startswith("/") cmd = cmd[1:] cmd = cmd.replace("-", "_") fun = getattr(self, f"completions_{cmd}", None) if not fun: - return + return None return sorted(fun()) - def get_commands(self): + def get_commands(self) -> list: commands = [] for attr in dir(self): if not attr.startswith("cmd_"): @@ -284,23 +288,24 @@ def get_commands(self): return commands - def do_run(self, cmd_name, args): + def do_run(self, cmd_name: str, args: str) -> Optional[Any]: cmd_name = cmd_name.replace("-", "_") cmd_method_name = f"cmd_{cmd_name}" cmd_method = getattr(self, cmd_method_name, None) if not cmd_method: self.io.tool_output(f"Error: Command {cmd_name} not found.") - return + return None try: return cmd_method(args) except ANY_GIT_ERROR as err: self.io.tool_error(f"Unable to complete {cmd_name}: {err}") + return None - def matching_commands(self, inp): + def matching_commands(self, inp: str) -> Optional[tuple]: words = inp.strip().split() if not words: - return + return None first_word = words[0] rest_inp = inp[len(words[0]) :].strip() @@ -309,14 +314,14 @@ def matching_commands(self, inp): matching_commands = [cmd for cmd in all_commands if cmd.startswith(first_word)] return matching_commands, first_word, rest_inp - def run(self, inp): + def run(self, inp: str) -> Optional[Any]: if inp.startswith("!"): self.coder.event("command_run") return self.do_run("run", inp[1:]) res = self.matching_commands(inp) if res is None: - return + return None matching_commands, first_word, rest_inp = res if len(matching_commands) == 1: command = matching_commands[0][1:] @@ -328,20 +333,22 @@ def run(self, inp): return self.do_run(command, rest_inp) elif len(matching_commands) > 1: self.io.tool_error(f"Ambiguous command: {', '.join(matching_commands)}") + return None else: self.io.tool_error(f"Invalid command: {first_word}") + return None # any method called cmd_xxx becomes a command automatically. # each one must take an args param. - def cmd_commit(self, args=None): + def cmd_commit(self, args: Optional[str] = None) -> None: "Commit edits to the repo made outside the chat (commit message optional)" try: self.raw_cmd_commit(args) except ANY_GIT_ERROR as err: self.io.tool_error(f"Unable to complete commit: {err}") - def raw_cmd_commit(self, args=None): + def raw_cmd_commit(self, args: Optional[str] = None) -> None: if not self.coder.repo: self.io.tool_error("No git repository found.") return @@ -353,7 +360,7 @@ def raw_cmd_commit(self, args=None): commit_message = args.strip() if args else None self.coder.repo.commit(message=commit_message, coder=self.coder) - def cmd_lint(self, args="", fnames=None): + def cmd_lint(self, args: str = "", fnames: Optional[list] = None) -> None: "Lint and fix in-chat files or all dirty files if none in chat" if not self.coder.repo: @@ -408,13 +415,13 @@ def cmd_lint(self, args="", fnames=None): if lint_coder and self.coder.repo.is_dirty() and self.coder.auto_commits: self.cmd_commit("") - def cmd_clear(self, args): + def cmd_clear(self, args: str) -> None: "Clear the chat history" self._clear_chat_history() self.io.tool_output("All chat history cleared.") - def _drop_all_files(self): + def _drop_all_files(self) -> None: self.coder.abs_fnames = set() # When dropping all files, keep those that were originally provided via args.read @@ -432,17 +439,17 @@ def _drop_all_files(self): else: self.coder.abs_read_only_fnames = set() - def _clear_chat_history(self): + def _clear_chat_history(self) -> None: self.coder.done_messages = [] self.coder.cur_messages = [] - def cmd_reset(self, args): + def cmd_reset(self, args: str) -> None: "Drop all files and clear the chat history" self._drop_all_files() self._clear_chat_history() self.io.tool_output("All files dropped and chat history cleared.") - def cmd_tokens(self, args): + def cmd_tokens(self, args: str) -> None: "Report on the number of tokens used by the current chat context" res = [] @@ -550,14 +557,14 @@ def fmt(v): ) self.io.tool_output(f"{cost_pad}{fmt(limit)} tokens max context window size") - def cmd_undo(self, args): + def cmd_undo(self, args: str) -> Optional[str]: "Undo the last git commit if it was done by aider" try: self.raw_cmd_undo(args) except ANY_GIT_ERROR as err: self.io.tool_error(f"Unable to complete undo: {err}") - def raw_cmd_undo(self, args): + def raw_cmd_undo(self, args: str) -> Optional[str]: if not self.coder.repo: self.io.tool_error("No git repository found.") return @@ -654,14 +661,14 @@ def raw_cmd_undo(self, args): if self.coder.main_model.send_undo_reply: return prompts.undo_command_reply - def cmd_diff(self, args=""): + def cmd_diff(self, args: str = "") -> None: "Display the diff of changes since the last message" try: self.raw_cmd_diff(args) except ANY_GIT_ERROR as err: self.io.tool_error(f"Unable to complete diff: {err}") - def raw_cmd_diff(self, args=""): + def raw_cmd_diff(self, args: str = "") -> None: if not self.coder.repo: self.io.tool_error("No git repository found.") return @@ -694,7 +701,7 @@ def raw_cmd_diff(self, args=""): self.io.print(diff) - def quote_fname(self, fname): + def quote_fname(self, fname: str) -> str: if " " in fname and '"' not in fname: fname = f'"{fname}"' return fname @@ -756,13 +763,13 @@ def get_paths(): for completion in sorted_completions: yield completion - def completions_add(self): + def completions_add(self) -> list: files = set(self.coder.get_all_relative_files()) files = files - set(self.coder.get_inchat_relative_files()) files = [self.quote_fname(fn) for fn in files] return files - def glob_filtered_to_repo(self, pattern): + def glob_filtered_to_repo(self, pattern: str) -> list: if not pattern.strip(): return [] try: @@ -796,7 +803,7 @@ def glob_filtered_to_repo(self, pattern): res = list(map(str, matched_files)) return res - def cmd_add(self, args): + def cmd_add(self, args: str) -> None: "Add files to the chat so aider can edit them or review them in detail" all_matched_files = set() @@ -902,14 +909,14 @@ def cmd_add(self, args): self.io.tool_output(f"Added {fname} to the chat") self.coder.check_added_files() - def completions_drop(self): + def completions_drop(self) -> list: files = self.coder.get_inchat_relative_files() read_only_files = [self.coder.get_rel_fname(fn) for fn in self.coder.abs_read_only_fnames] all_files = files + read_only_files all_files = [self.quote_fname(fn) for fn in all_files] return all_files - def cmd_drop(self, args=""): + def cmd_drop(self, args: str = "") -> None: "Remove files from the chat session to free up context space" if not args.strip(): @@ -964,7 +971,7 @@ def cmd_drop(self, args=""): self.coder.abs_fnames.remove(abs_fname) self.io.tool_output(f"Removed {matched_file} from the chat") - def cmd_git(self, args): + def cmd_git(self, args: str) -> None: "Run a git command (output excluded from chat)" combined_output = None try: @@ -990,7 +997,7 @@ def cmd_git(self, args): self.io.tool_output(combined_output) - def cmd_test(self, args): + def cmd_test(self, args: str) -> Optional[Union[str, Any]]: "Run a shell command and add the output to the chat on non-zero exit code" if not args and self.coder.test_cmd: args = self.coder.test_cmd @@ -1010,7 +1017,7 @@ def cmd_test(self, args): self.io.tool_output(errors) return errors - def cmd_run(self, args, add_on_nonzero_exit=False): + def cmd_run(self, args: str, add_on_nonzero_exit: bool = False) -> Optional[str]: "Run a shell command and optionally add the output to the chat (alias: !)" exit_status, combined_output = run_cmd( args, verbose=self.verbose, error_print=self.io.tool_error, cwd=self.coder.root @@ -1052,16 +1059,16 @@ def cmd_run(self, args, add_on_nonzero_exit=False): # Return None if output wasn't added or command succeeded return None - def cmd_exit(self, args): + def cmd_exit(self, args: str) -> None: "Exit the application" self.coder.event("exit", reason="/exit") sys.exit() - def cmd_quit(self, args): + def cmd_quit(self, args: str) -> None: "Exit the application" self.cmd_exit(args) - def cmd_ls(self, args): + def cmd_ls(self, args: str) -> None: "List all known files and indicate which are included in the chat session" files = self.coder.get_all_relative_files() @@ -1100,7 +1107,7 @@ def cmd_ls(self, args): for file in chat_files: self.io.tool_output(f" {file}") - def basic_help(self): + def basic_help(self) -> None: commands = sorted(self.get_commands()) pad = max(len(cmd) for cmd in commands) pad = "{cmd:" + str(pad) + "}" @@ -1116,7 +1123,7 @@ def basic_help(self): self.io.tool_output() self.io.tool_output("Use `/help ` to ask questions about how to use aider.") - def cmd_help(self, args): + def cmd_help(self, args: str) -> None: "Ask questions about aider" if not args.strip(): @@ -1167,35 +1174,35 @@ def cmd_help(self, args): show_announcements=False, ) - def completions_ask(self): + def completions_ask(self) -> None: raise CommandCompletionException() - def completions_code(self): + def completions_code(self) -> None: raise CommandCompletionException() - def completions_architect(self): + def completions_architect(self) -> None: raise CommandCompletionException() - def completions_context(self): + def completions_context(self) -> None: raise CommandCompletionException() - def cmd_ask(self, args): + def cmd_ask(self, args: str) -> None: """Ask questions about the code base without editing any files. If no prompt provided, switches to ask mode.""" # noqa return self._generic_chat_command(args, "ask") - def cmd_code(self, args): + def cmd_code(self, args: str) -> None: """Ask for changes to your code. If no prompt provided, switches to code mode.""" # noqa return self._generic_chat_command(args, self.coder.main_model.edit_format) - def cmd_architect(self, args): + def cmd_architect(self, args: str) -> None: """Enter architect/editor mode using 2 different models. If no prompt provided, switches to architect/editor mode.""" # noqa return self._generic_chat_command(args, "architect") - def cmd_context(self, args): + def cmd_context(self, args: str) -> None: """Enter context mode to see surrounding code context. If no prompt provided, switches to context mode.""" # noqa return self._generic_chat_command(args, "context", placeholder=args.strip() or None) - def cmd_ok(self, args): + def cmd_ok(self, args: str) -> None: "Alias for `/code Ok, please go ahead and make those changes.` (any args are appended)" msg = "Ok, please go ahead and make those changes." extra = (args or "").strip() @@ -1203,7 +1210,7 @@ def cmd_ok(self, args): msg = f"{msg} {extra}" return self.cmd_code(msg) - def _generic_chat_command(self, args, edit_format, placeholder=None): + def _generic_chat_command(self, args: str, edit_format: str, placeholder: Optional[str] = None) -> None: if not args.strip(): # Switch to the corresponding chat mode if no args provided return self.cmd_chat_mode(edit_format) @@ -1229,7 +1236,7 @@ def _generic_chat_command(self, args, edit_format, placeholder=None): placeholder=placeholder, ) - def get_help_md(self): + def get_help_md(self) -> str: "Show help about all commands in markdown" res = """ @@ -1249,7 +1256,7 @@ def get_help_md(self): res += "\n" return res - def cmd_voice(self, args): + def cmd_voice(self, args: str) -> None: "Record and transcribe voice input" if not self.voice: @@ -1275,7 +1282,7 @@ def cmd_voice(self, args): if text: self.io.placeholder = text - def cmd_paste(self, args): + def cmd_paste(self, args: str) -> Optional[str]: """Paste image/text from the clipboard into the chat.\ Optionally provide a name for the image.""" try: @@ -1325,7 +1332,7 @@ def cmd_paste(self, args): except Exception as e: self.io.tool_error(f"Error processing clipboard content: {e}") - def cmd_read_only(self, args): + def cmd_read_only(self, args: str) -> None: "Add files to the chat that are for reference only, or turn added files to read-only" if not args.strip(): # Convert all files in chat to read-only @@ -1375,7 +1382,7 @@ def cmd_read_only(self, args): else: self.io.tool_error(f"Not a file or directory: {abs_path}") - def _add_read_only_file(self, abs_path, original_name): + def _add_read_only_file(self, abs_path: str, original_name: Path) -> None: if is_image_file(original_name) and not self.coder.main_model.info.get("supports_vision"): self.io.tool_error( f"Cannot add image file {original_name} as the" @@ -1396,7 +1403,7 @@ def _add_read_only_file(self, abs_path, original_name): self.coder.abs_read_only_fnames.add(abs_path) self.io.tool_output(f"Added {original_name} to read-only files.") - def _add_read_only_directory(self, abs_path, original_name): + def _add_read_only_directory(self, abs_path: str, original_name: Path) -> None: added_files = 0 for root, _, files in os.walk(abs_path): for file in files: @@ -1415,7 +1422,7 @@ def _add_read_only_directory(self, abs_path, original_name): else: self.io.tool_output(f"No new files added from directory {original_name}.") - def cmd_map(self, args): + def cmd_map(self, args: str) -> None: "Print out the current repository map" repo_map = self.coder.get_repo_map() if repo_map: @@ -1423,13 +1430,13 @@ def cmd_map(self, args): else: self.io.tool_output("No repository map available.") - def cmd_map_refresh(self, args): + def cmd_map_refresh(self, args: str) -> None: "Force a refresh of the repository map" repo_map = self.coder.get_repo_map(force_refresh=True) if repo_map: self.io.tool_output("The repo map has been refreshed, use /map to view it.") - def cmd_settings(self, args): + def cmd_settings(self, args: str) -> None: "Print out the current settings" settings = format_settings(self.parser, self.args) announcements = "\n".join(self.coder.get_announcements()) @@ -1462,7 +1469,7 @@ def cmd_settings(self, args): def completions_raw_load(self, document, complete_event): return self.completions_raw_read_only(document, complete_event) - def cmd_load(self, args): + def cmd_load(self, args: str) -> None: "Load and execute commands from a file" if not args.strip(): self.io.tool_error("Please provide a filename containing commands to load.") @@ -1494,7 +1501,7 @@ def cmd_load(self, args): def completions_raw_save(self, document, complete_event): return self.completions_raw_read_only(document, complete_event) - def cmd_save(self, args): + def cmd_save(self, args: str) -> None: "Save commands to a file that can reconstruct the current chat session's files" if not args.strip(): self.io.tool_error("Please provide a filename to save the commands to.") @@ -1521,11 +1528,11 @@ def cmd_save(self, args): except Exception as e: self.io.tool_error(f"Error saving commands to file: {e}") - def cmd_multiline_mode(self, args): + def cmd_multiline_mode(self, args: str) -> None: "Toggle multiline mode (swaps behavior of Enter and Meta+Enter)" self.io.toggle_multiline_mode() - def cmd_copy(self, args): + def cmd_copy(self, args: str) -> None: "Copy the last assistant message to the clipboard" all_messages = self.coder.done_messages + self.coder.cur_messages assistant_messages = [msg for msg in reversed(all_messages) if msg["role"] == "assistant"] @@ -1552,7 +1559,7 @@ def cmd_copy(self, args): except Exception as e: self.io.tool_error(f"An unexpected error occurred while copying to clipboard: {str(e)}") - def cmd_report(self, args): + def cmd_report(self, args: str) -> None: "Report a problem by opening a GitHub Issue" from aider.report import report_github_issue @@ -1566,18 +1573,18 @@ def cmd_report(self, args): report_github_issue(issue_text, title=title, confirm=False) - def cmd_editor(self, initial_content=""): + def cmd_editor(self, initial_content: str = "") -> None: "Open an editor to write a prompt" user_input = pipe_editor(initial_content, suffix="md", editor=self.editor) if user_input.strip(): self.io.set_placeholder(user_input.rstrip()) - def cmd_edit(self, args=""): + def cmd_edit(self, args: str = "") -> None: "Alias for /editor: Open an editor to write a prompt" return self.cmd_editor(args) - def cmd_think_tokens(self, args): + def cmd_think_tokens(self, args: str) -> None: """Set the thinking token budget, eg: 8096, 8k, 10.5k, 0.5M, or 0 to disable.""" model = self.coder.main_model @@ -1612,7 +1619,7 @@ def cmd_think_tokens(self, args): announcements = "\n".join(self.coder.get_announcements()) self.io.tool_output(announcements) - def cmd_reasoning_effort(self, args): + def cmd_reasoning_effort(self, args: str) -> None: "Set the reasoning effort level (values: number or low/medium/high depending on model)" model = self.coder.main_model @@ -1635,7 +1642,7 @@ def cmd_reasoning_effort(self, args): announcements = "\n".join(self.coder.get_announcements()) self.io.tool_output(announcements) - def cmd_copy_context(self, args=None): + def cmd_copy_context(self, args: Optional[str] = None) -> None: """Copy the current chat context as markdown, suitable to paste into a web UI""" chunks = self.coder.format_chat_chunks() @@ -1680,7 +1687,7 @@ def cmd_copy_context(self, args=None): self.io.tool_error(f"An unexpected error occurred while copying to clipboard: {str(e)}") -def expand_subdir(file_path): +def expand_subdir(file_path: Path) -> Any: if file_path.is_file(): yield file_path return @@ -1691,18 +1698,18 @@ def expand_subdir(file_path): yield file -def parse_quoted_filenames(args): +def parse_quoted_filenames(args: str) -> list: filenames = re.findall(r"\"(.+?)\"|(\S+)", args) filenames = [name for sublist in filenames for name in sublist if name] return filenames -def get_help_md(): +def get_help_md() -> str: md = Commands(None, None).get_help_md() return md -def main(): +def main() -> None: md = get_help_md() print(md) diff --git a/aider/io.py b/aider/io.py index ed6f22d51ae..7a7345135f0 100644 --- a/aider/io.py +++ b/aider/io.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import base64 import functools import os @@ -11,6 +13,7 @@ from datetime import datetime from io import StringIO from pathlib import Path +from typing import Any, Callable, Optional, TextIO, Union from prompt_toolkit.completion import Completer, Completion, ThreadedCompleter from prompt_toolkit.cursor_shapes import ModalCursorShapeConfig @@ -43,7 +46,7 @@ NOTIFICATION_MESSAGE = "Aider is waiting for your input" -def ensure_hash_prefix(color): +def ensure_hash_prefix(color: Optional[str]) -> Optional[str]: """Ensure hex color values have a # prefix.""" if not color: return color @@ -54,7 +57,7 @@ def ensure_hash_prefix(color): return color -def restore_multiline(func): +def restore_multiline(func: Callable) -> Callable: """Decorator to restore multiline mode after function execution""" @functools.wraps(func) @@ -83,14 +86,20 @@ class ConfirmGroup: preference: str = None show_group: bool = True - def __init__(self, items=None): + def __init__(self, items: Optional[list] = None): if items is not None: self.show_group = len(items) > 1 class AutoCompleter(Completer): def __init__( - self, root, rel_fnames, addable_rel_fnames, commands, encoding, abs_read_only_fnames=None + self, + root: str, + rel_fnames: list, + addable_rel_fnames: list, + commands: Optional[Any], + encoding: str, + abs_read_only_fnames: Optional[list] = None, ): self.addable_rel_fnames = addable_rel_fnames self.rel_fnames = rel_fnames @@ -124,7 +133,7 @@ def __init__( self.all_fnames = all_fnames self.tokenized = False - def tokenize(self): + def tokenize(self) -> None: if self.tokenized: return self.tokenized = True @@ -228,42 +237,42 @@ def get_completions(self, document, complete_event): class InputOutput: - num_error_outputs = 0 - num_user_asks = 0 - clipboard_watcher = None - bell_on_next_input = False - notifications_command = None + num_error_outputs: int = 0 + num_user_asks: int = 0 + clipboard_watcher: Optional[Any] = None + bell_on_next_input: bool = False + notifications_command: Optional[str] = None def __init__( self, - pretty=True, - yes=None, - input_history_file=None, - chat_history_file=None, - input=None, - output=None, - user_input_color="blue", - tool_output_color=None, - tool_error_color="red", - tool_warning_color="#FFA500", - assistant_output_color="blue", - completion_menu_color=None, - completion_menu_bg_color=None, - completion_menu_current_color=None, - completion_menu_current_bg_color=None, - code_theme="default", - encoding="utf-8", - line_endings="platform", - dry_run=False, - llm_history_file=None, - editingmode=EditingMode.EMACS, - fancy_input=True, - file_watcher=None, - multiline_mode=False, - root=".", - notifications=False, - notifications_command=None, - ): + pretty: bool = True, + yes: Optional[Union[bool, str]] = None, + input_history_file: Optional[str] = None, + chat_history_file: Optional[str] = None, + input: Optional[TextIO] = None, + output: Optional[TextIO] = None, + user_input_color: Optional[str] = "blue", + tool_output_color: Optional[str] = None, + tool_error_color: Optional[str] = "red", + tool_warning_color: Optional[str] = "#FFA500", + assistant_output_color: Optional[str] = "blue", + completion_menu_color: Optional[str] = None, + completion_menu_bg_color: Optional[str] = None, + completion_menu_current_color: Optional[str] = None, + completion_menu_current_bg_color: Optional[str] = None, + code_theme: str = "default", + encoding: str = "utf-8", + line_endings: str = "platform", + dry_run: bool = False, + llm_history_file: Optional[str] = None, + editingmode: EditingMode = EditingMode.EMACS, + fancy_input: bool = True, + file_watcher: Optional[Any] = None, + multiline_mode: bool = False, + root: str = ".", + notifications: bool = False, + notifications_command: Optional[str] = None, + ) -> None: self.placeholder = None self.interrupted = False self.never_prompts = set() @@ -371,7 +380,7 @@ def __init__( # Validate color settings after console is initialized self._validate_color_settings() - def _validate_color_settings(self): + def _validate_color_settings(self) -> None: """Validate configured color strings and reset invalid ones.""" color_attributes = [ "user_input_color", @@ -397,7 +406,7 @@ def _validate_color_settings(self): ) setattr(self, attr_name, None) # Reset invalid color to None - def _get_style(self): + def _get_style(self) -> Style: style_dict = {} if not self.pretty: return Style.from_dict(style_dict) @@ -432,25 +441,25 @@ def _get_style(self): return Style.from_dict(style_dict) - def read_image(self, filename): + def read_image(self, filename: Union[str, Path]) -> Optional[str]: try: with open(str(filename), "rb") as image_file: encoded_string = base64.b64encode(image_file.read()) return encoded_string.decode("utf-8") except OSError as err: self.tool_error(f"{filename}: unable to read: {err}") - return + return None except FileNotFoundError: self.tool_error(f"{filename}: file not found error") - return + return None except IsADirectoryError: self.tool_error(f"{filename}: is a directory") - return + return None except Exception as e: self.tool_error(f"{filename}: {e}") - return + return None - def read_text(self, filename, silent=False): + def read_text(self, filename: Union[str, Path], silent: bool = False) -> Optional[str]: if is_image_file(filename): return self.read_image(filename) @@ -460,22 +469,24 @@ def read_text(self, filename, silent=False): except FileNotFoundError: if not silent: self.tool_error(f"{filename}: file not found error") - return + return None except IsADirectoryError: if not silent: self.tool_error(f"{filename}: is a directory") - return + return None except OSError as err: if not silent: self.tool_error(f"{filename}: unable to read: {err}") - return + return None except UnicodeError as e: if not silent: self.tool_error(f"{filename}: {e}") self.tool_error("Use --encoding to set the unicode encoding.") - return + return None - def write_text(self, filename, content, max_retries=5, initial_delay=0.1): + def write_text( + self, filename: Union[str, Path], content: str, max_retries: int = 5, initial_delay: float = 0.1 + ) -> None: """ Writes content to a file, retrying with progressive backoff if the file is locked. @@ -506,14 +517,14 @@ def write_text(self, filename, content, max_retries=5, initial_delay=0.1): self.tool_error(f"Unable to write file {filename}: {err}") raise - def rule(self): + def rule(self) -> None: if self.pretty: style = dict(style=self.user_input_color) if self.user_input_color else dict() self.console.rule(**style) else: print() - def interrupt_input(self): + def interrupt_input(self) -> None: if self.prompt_session and self.prompt_session.app: # Store any partial input before interrupting self.placeholder = self.prompt_session.app.current_buffer.text @@ -522,13 +533,13 @@ def interrupt_input(self): def get_input( self, - root, - rel_fnames, - addable_rel_fnames, - commands, - abs_read_only_fnames=None, - edit_format=None, - ): + root: str, + rel_fnames: set, + addable_rel_fnames: list, + commands: Optional[Any], + abs_read_only_fnames: Optional[list] = None, + edit_format: Optional[str] = None, + ) -> Optional[str]: self.rule() # Ring the bell if needed @@ -733,7 +744,7 @@ def get_continuation(width, line_number, is_soft_wrap): self.user_input(inp) return inp - def add_to_input_history(self, inp): + def add_to_input_history(self, inp: str) -> None: if not self.input_history_file: return try: @@ -744,14 +755,14 @@ def add_to_input_history(self, inp): except OSError as err: self.tool_warning(f"Unable to write to input history file: {err}") - def get_input_history(self): + def get_input_history(self) -> list: if not self.input_history_file: return [] fh = FileHistory(self.input_history_file) return fh.load_history_strings() - def log_llm_history(self, role, content): + def log_llm_history(self, role: str, content: str) -> None: if not self.llm_history_file: return timestamp = datetime.now().isoformat(timespec="seconds") @@ -764,7 +775,7 @@ def log_llm_history(self, role, content): self.tool_warning(f"Unable to write to llm history file {self.llm_history_file}: {err}") self.llm_history_file = None - def display_user_input(self, inp): + def display_user_input(self, inp: str) -> None: if self.pretty and self.user_input_color: style = dict(style=self.user_input_color) else: @@ -772,7 +783,7 @@ def display_user_input(self, inp): self.console.print(Text(inp), **style) - def user_input(self, inp, log_only=True): + def user_input(self, inp: str, log_only: bool = True) -> None: if not log_only: self.display_user_input(inp) @@ -790,11 +801,11 @@ def user_input(self, inp, log_only=True): # OUTPUT - def ai_output(self, content): + def ai_output(self, content: str) -> None: hist = "\n" + content.strip() + "\n\n" self.append_chat_history(hist) - def offer_url(self, url, prompt="Open URL for more info?", allow_never=True): + def offer_url(self, url: str, prompt: str = "Open URL for more info?", allow_never: bool = True) -> bool: """Offer to open a URL in the browser, returns True if opened.""" if url in self.never_prompts: return False @@ -806,13 +817,13 @@ def offer_url(self, url, prompt="Open URL for more info?", allow_never=True): @restore_multiline def confirm_ask( self, - question, - default="y", - subject=None, - explicit_yes_required=False, - group=None, - allow_never=False, - ): + question: str, + default: str = "y", + subject: Optional[str] = None, + explicit_yes_required: bool = False, + group: Optional[ConfirmGroup] = None, + allow_never: bool = False, + ) -> bool: self.num_user_asks += 1 # Ring the bell if needed @@ -925,7 +936,7 @@ def is_valid_response(text): return is_yes @restore_multiline - def prompt_ask(self, question, default="", subject=None): + def prompt_ask(self, question: str, default: str = "", subject: Optional[str] = None) -> str: self.num_user_asks += 1 # Ring the bell if needed @@ -963,7 +974,7 @@ def prompt_ask(self, question, default="", subject=None): return res - def _tool_message(self, message="", strip=True, color=None): + def _tool_message(self, message: Union[str, Text] = "", strip: bool = True, color: Optional[str] = None) -> None: if message.strip(): if "\n" in message: for line in message.splitlines(): @@ -985,14 +996,14 @@ def _tool_message(self, message="", strip=True, color=None): message = str(message).encode("ascii", errors="replace").decode("ascii") self.console.print(message, **style) - def tool_error(self, message="", strip=True): + def tool_error(self, message: Union[str, Text] = "", strip: bool = True) -> None: self.num_error_outputs += 1 self._tool_message(message, strip, self.tool_error_color) - def tool_warning(self, message="", strip=True): + def tool_warning(self, message: Union[str, Text] = "", strip: bool = True) -> None: self._tool_message(message, strip, self.tool_warning_color) - def tool_output(self, *messages, log_only=False, bold=False): + def tool_output(self, *messages: str, log_only: bool = False, bold: bool = False) -> None: if messages: hist = " ".join(messages) hist = f"{hist.strip()}" @@ -1011,7 +1022,7 @@ def tool_output(self, *messages, log_only=False, bold=False): style = RichStyle(**style) self.console.print(*messages, style=style) - def get_assistant_mdstream(self): + def get_assistant_mdstream(self) -> MarkdownStream: mdargs = dict( style=self.assistant_output_color, code_theme=self.code_theme, @@ -1020,7 +1031,7 @@ def get_assistant_mdstream(self): mdStream = MarkdownStream(mdargs=mdargs) return mdStream - def assistant_output(self, message, pretty=None): + def assistant_output(self, message: str, pretty: Optional[bool] = None) -> None: if not message: self.tool_warning("Empty response received from LLM. Check your provider account?") return @@ -1040,18 +1051,18 @@ def assistant_output(self, message, pretty=None): self.console.print(show_resp) - def set_placeholder(self, placeholder): + def set_placeholder(self, placeholder: Optional[str]) -> None: """Set a one-time placeholder text for the next input prompt.""" self.placeholder = placeholder - def print(self, message=""): + def print(self, message: str = "") -> None: print(message) - def llm_started(self): + def llm_started(self) -> None: """Mark that the LLM has started processing, so we should ring the bell on next input""" self.bell_on_next_input = True - def get_default_notification_command(self): + def get_default_notification_command(self) -> Optional[str]: """Return a default notification command based on the operating system.""" import platform @@ -1085,7 +1096,7 @@ def get_default_notification_command(self): return None # Unknown system - def ring_bell(self): + def ring_bell(self) -> None: """Ring the terminal bell if needed and clear the flag""" if self.bell_on_next_input and self.notifications: if self.notifications_command: @@ -1102,7 +1113,7 @@ def ring_bell(self): print("\a", end="", flush=True) # Ring the bell self.bell_on_next_input = False # Clear the flag - def toggle_multiline_mode(self): + def toggle_multiline_mode(self) -> None: """Toggle between normal and multiline input modes""" self.multiline_mode = not self.multiline_mode if self.multiline_mode: @@ -1114,7 +1125,7 @@ def toggle_multiline_mode(self): "Multiline mode: Disabled. Alt-Enter inserts newline, Enter submits text" ) - def append_chat_history(self, text, linebreak=False, blockquote=False, strip=True): + def append_chat_history(self, text: str, linebreak: bool = False, blockquote: bool = False, strip: bool = True) -> None: if blockquote: if strip: text = text.strip() @@ -1135,7 +1146,7 @@ def append_chat_history(self, text, linebreak=False, blockquote=False, strip=Tru print(err) self.chat_history_file = None # Disable further attempts to write - def format_files_for_input(self, rel_fnames, rel_read_only_fnames): + def format_files_for_input(self, rel_fnames: list, rel_read_only_fnames: list) -> str: if not self.pretty: read_only_files = [] for full_path in sorted(rel_read_only_fnames or []): @@ -1184,7 +1195,7 @@ def format_files_for_input(self, rel_fnames, rel_read_only_fnames): return output.getvalue() -def get_rel_fname(fname, root): +def get_rel_fname(fname: str, root: str) -> str: try: return os.path.relpath(fname, root) except ValueError: diff --git a/aider/main.py b/aider/main.py index afb3f836624..ce0314f5e8a 100644 --- a/aider/main.py +++ b/aider/main.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import os import re @@ -5,8 +7,10 @@ import threading import traceback import webbrowser +from argparse import Namespace from dataclasses import fields from pathlib import Path +from typing import Any, Callable, Optional, TextIO, Union try: import git @@ -40,7 +44,7 @@ from .dump import dump # noqa: F401 -def check_config_files_for_yes(config_files): +def check_config_files_for_yes(config_files: list) -> bool: found = False for config_file in config_files: if Path(config_file).exists(): @@ -57,7 +61,7 @@ def check_config_files_for_yes(config_files): return found -def get_git_root(): +def get_git_root() -> Optional[str]: """Try and guess the git repo, since the conf.yml can be at the repo root""" try: repo = git.Repo(search_parent_directories=True) @@ -66,13 +70,15 @@ def get_git_root(): return None -def guessed_wrong_repo(io, git_root, fnames, git_dname): +def guessed_wrong_repo( + io: InputOutput, git_root: Optional[str], fnames: list, git_dname: Optional[str] +) -> Optional[str]: """After we parse the args, we can determine the real repo. Did we guess wrong?""" try: check_repo = Path(GitRepo(io, fnames, git_dname).root).resolve() except (OSError,) + ANY_GIT_ERROR: - return + return None # we had no guess, rely on the "true" repo result if not git_root: @@ -80,27 +86,27 @@ def guessed_wrong_repo(io, git_root, fnames, git_dname): git_root = Path(git_root).resolve() if check_repo == git_root: - return + return None return str(check_repo) -def make_new_repo(git_root, io): +def make_new_repo(git_root: str, io: InputOutput) -> Optional[Any]: try: repo = git.Repo.init(git_root) check_gitignore(git_root, io, False) except ANY_GIT_ERROR as err: # issue #1233 io.tool_error(f"Unable to create git repo in {git_root}") io.tool_output(str(err)) - return + return None io.tool_output(f"Git repository created in {git_root}") return repo -def setup_git(git_root, io): +def setup_git(git_root: Optional[str], io: InputOutput) -> Optional[str]: if git is None: - return + return None try: cwd = Path.cwd() @@ -118,7 +124,7 @@ def setup_git(git_root, io): io.tool_warning( "You should probably run aider in your project's directory, not your home dir." ) - return + return None elif cwd and io.confirm_ask( "No git repo found, create one to track aider's changes (recommended)?" ): @@ -126,7 +132,7 @@ def setup_git(git_root, io): repo = make_new_repo(git_root, io) if not repo: - return + return None try: user_name = repo.git.config("--get", "user.name") or None @@ -152,7 +158,7 @@ def setup_git(git_root, io): return repo.working_tree_dir -def check_gitignore(git_root, io, ask=True): +def check_gitignore(git_root: Optional[str], io: InputOutput, ask: bool = True) -> None: if not git_root: return @@ -205,7 +211,7 @@ def check_gitignore(git_root, io, ask=True): io.tool_output(f" {pattern}") -def check_streamlit_install(io): +def check_streamlit_install(io: InputOutput) -> bool: return utils.check_pip_install_extra( io, "streamlit", @@ -214,7 +220,7 @@ def check_streamlit_install(io): ) -def write_streamlit_credentials(): +def write_streamlit_credentials() -> None: from streamlit.file_util import get_streamlit_file_path # See https://github.com/Aider-AI/aider/issues/772 @@ -230,7 +236,7 @@ def write_streamlit_credentials(): print("Streamlit credentials already exist.") -def launch_gui(args): +def launch_gui(args: list) -> None: from streamlit.web import cli from aider import gui @@ -275,7 +281,7 @@ def launch_gui(args): # sys.argv = ['streamlit', 'run', '--'] + args -def parse_lint_cmds(lint_cmds, io): +def parse_lint_cmds(lint_cmds: list, io: InputOutput) -> Optional[dict]: err = False res = dict() for lint_cmd in lint_cmds: @@ -298,11 +304,13 @@ def parse_lint_cmds(lint_cmds, io): io.tool_output('For example: --lint-cmd "python: flake8 --select=E9"') err = True if err: - return + return None return res -def generate_search_path_list(default_file, git_root, command_line_file): +def generate_search_path_list( + default_file: str, git_root: Optional[str], command_line_file: Optional[str] +) -> list: files = [] files.append(Path.home() / default_file) # homedir if git_root: @@ -332,7 +340,9 @@ def generate_search_path_list(default_file, git_root, command_line_file): return files -def register_models(git_root, model_settings_fname, io, verbose=False): +def register_models( + git_root: Optional[str], model_settings_fname: Optional[str], io: InputOutput, verbose: bool = False +) -> Optional[int]: model_settings_files = generate_search_path_list( ".aider.model.settings.yml", git_root, model_settings_fname ) @@ -358,7 +368,9 @@ def register_models(git_root, model_settings_fname, io, verbose=False): return None -def load_dotenv_files(git_root, dotenv_fname, encoding="utf-8"): +def load_dotenv_files( + git_root: Optional[str], dotenv_fname: Optional[str], encoding: str = "utf-8" +) -> list: # Standard .env file search path dotenv_files = generate_search_path_list( ".env", @@ -387,7 +399,9 @@ def load_dotenv_files(git_root, dotenv_fname, encoding="utf-8"): return loaded -def register_litellm_models(git_root, model_metadata_fname, io, verbose=False): +def register_litellm_models( + git_root: Optional[str], model_metadata_fname: Optional[str], io: InputOutput, verbose: bool = False +) -> Optional[int]: model_metadata_files = [] # Add the resource file path @@ -409,7 +423,7 @@ def register_litellm_models(git_root, model_metadata_fname, io, verbose=False): return 1 -def sanity_check_repo(repo, io): +def sanity_check_repo(repo: Optional[GitRepo], io: InputOutput) -> bool: if not repo: return True @@ -448,7 +462,13 @@ def sanity_check_repo(repo, io): return False -def main(argv=None, input=None, output=None, force_git_root=None, return_coder=False): +def main( + argv: Optional[list] = None, + input: Optional[TextIO] = None, + output: Optional[TextIO] = None, + force_git_root: Optional[str] = None, + return_coder: bool = False, +) -> Optional[Union[int, Any]]: report_uncaught_exceptions() if argv is None: @@ -1180,7 +1200,7 @@ def get_io(pretty): coder.show_announcements() -def is_first_run_of_new_version(io, verbose=False): +def is_first_run_of_new_version(io: InputOutput, verbose: bool = False) -> bool: """Check if this is the first run of a new version/executable combination""" installs_file = Path.home() / ".aider" / "installs.json" key = (__version__, sys.executable) @@ -1223,7 +1243,7 @@ def is_first_run_of_new_version(io, verbose=False): return True # Safer to assume it's a first run if we hit an error -def check_and_load_imports(io, is_first_run, verbose=False): +def check_and_load_imports(io: InputOutput, is_first_run: bool, verbose: bool = False) -> None: try: if is_first_run: if verbose: @@ -1253,7 +1273,7 @@ def check_and_load_imports(io, is_first_run, verbose=False): io.tool_output(f"Full exception details: {traceback.format_exc()}") -def load_slow_imports(swallow=True): +def load_slow_imports(swallow: bool = True) -> None: # These imports are deferred in various ways to # improve startup time. # This func is called either synchronously or in a thread diff --git a/aider/models.py b/aider/models.py index a6f4559d58f..338901c3170 100644 --- a/aider/models.py +++ b/aider/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import difflib import hashlib import importlib.resources @@ -10,7 +12,7 @@ from dataclasses import dataclass, fields from datetime import datetime from pathlib import Path -from typing import Optional, Union +from typing import Any, Callable, Optional, Union import json5 import yaml @@ -163,9 +165,9 @@ class ModelInfoManager: "https://raw.githubusercontent.com/BerriAI/litellm/main/" "model_prices_and_context_window.json" ) - CACHE_TTL = 60 * 60 * 24 # 24 hours + CACHE_TTL: int = 60 * 60 * 24 # 24 hours - def __init__(self): + def __init__(self) -> None: self.cache_dir = Path.home() / ".aider" / "caches" self.cache_file = self.cache_dir / "model_prices_and_context_window.json" self.content = None @@ -176,12 +178,12 @@ def __init__(self): # Manager for the cached OpenRouter model database self.openrouter_manager = OpenRouterModelManager() - def set_verify_ssl(self, verify_ssl): + def set_verify_ssl(self, verify_ssl: bool) -> None: self.verify_ssl = verify_ssl if hasattr(self, "openrouter_manager"): self.openrouter_manager.set_verify_ssl(verify_ssl) - def _load_cache(self): + def _load_cache(self) -> None: if self._cache_loaded: return @@ -200,7 +202,7 @@ def _load_cache(self): self._cache_loaded = True - def _update_cache(self): + def _update_cache(self) -> None: try: import requests @@ -220,7 +222,7 @@ def _update_cache(self): except OSError: pass - def get_model_from_cached_json_db(self, model): + def get_model_from_cached_json_db(self, model: str) -> dict: data = self.local_model_metadata.get(model) if data: return data @@ -246,7 +248,7 @@ def get_model_from_cached_json_db(self, model): return dict() - def get_model_info(self, model): + def get_model_info(self, model: str) -> dict: cached_info = self.get_model_from_cached_json_db(model) litellm_info = None @@ -273,7 +275,7 @@ def get_model_info(self, model): return cached_info - def fetch_openrouter_model_info(self, model): + def fetch_openrouter_model_info(self, model: str) -> dict: """ Fetch model info by scraping the openrouter model page. Expected URL: https://openrouter.ai/ @@ -323,13 +325,18 @@ def fetch_openrouter_model_info(self, model): return {} -model_info_manager = ModelInfoManager() +model_info_manager: ModelInfoManager = ModelInfoManager() class Model(ModelSettings): def __init__( - self, model, weak_model=None, editor_model=None, editor_edit_format=None, verbose=False - ): + self, + model: str, + weak_model: Optional[Union[str, bool]] = None, + editor_model: Optional[Union[str, bool]] = None, + editor_edit_format: Optional[str] = None, + verbose: bool = False, + ) -> None: # Map any alias to its canonical name model = MODEL_ALIASES.get(model, model) @@ -368,10 +375,10 @@ def __init__( else: self.get_editor_model(editor_model, editor_edit_format) - def get_model_info(self, model): + def get_model_info(self, model: str) -> dict: return model_info_manager.get_model_info(model) - def _copy_fields(self, source): + def _copy_fields(self, source: ModelSettings) -> None: """Helper to copy fields from a ModelSettings instance to self""" for field in fields(ModelSettings): val = getattr(source, field.name) @@ -382,7 +389,7 @@ def _copy_fields(self, source): if self.reasoning_tag is None and self.remove_reasoning is not None: self.reasoning_tag = self.remove_reasoning - def configure_model_settings(self, model): + def configure_model_settings(self, model: str) -> None: # Look for exact model match exact_match = False for ms in MODEL_SETTINGS: @@ -434,7 +441,7 @@ def configure_model_settings(self, model): if "reasoning_effort" not in self.accepts_settings: self.accepts_settings.append("reasoning_effort") - def apply_generic_model_settings(self, model): + def apply_generic_model_settings(self, model: str) -> None: if "/o3-mini" in model: self.edit_format = "diff" self.use_repo_map = True @@ -597,10 +604,10 @@ def apply_generic_model_settings(self, model): self.use_repo_map = True return # <-- - def __str__(self): + def __str__(self) -> str: return self.name - def get_weak_model(self, provided_weak_model_name): + def get_weak_model(self, provided_weak_model_name: Optional[str]) -> Optional[Model]: # If weak_model_name is provided, override the model settings if provided_weak_model_name: self.weak_model_name = provided_weak_model_name @@ -619,10 +626,10 @@ def get_weak_model(self, provided_weak_model_name): ) return self.weak_model - def commit_message_models(self): + def commit_message_models(self) -> list: return [self.weak_model, self] - def get_editor_model(self, provided_editor_model_name, editor_edit_format): + def get_editor_model(self, provided_editor_model_name: Optional[str], editor_edit_format: Optional[str]) -> Optional[Model]: # If editor_model_name is provided, override the model settings if provided_editor_model_name: self.editor_model_name = provided_editor_model_name @@ -644,10 +651,10 @@ def get_editor_model(self, provided_editor_model_name, editor_edit_format): return self.editor_model - def tokenizer(self, text): + def tokenizer(self, text: str) -> Any: return litellm.encode(model=self.name, text=text) - def token_count(self, messages): + def token_count(self, messages: Any) -> int: if type(messages) is list: try: return litellm.token_counter(model=self.name, messages=messages) @@ -656,7 +663,7 @@ def token_count(self, messages): return 0 if not self.tokenizer: - return + return 0 if type(messages) is str: msgs = messages @@ -669,7 +676,7 @@ def token_count(self, messages): print(f"Unable to count tokens: {err}") return 0 - def token_count_for_image(self, fname): + def token_count_for_image(self, fname: str) -> int: """ Calculate the token cost for an image assuming high detail. The token cost is determined by the size of the image. @@ -700,7 +707,7 @@ def token_count_for_image(self, fname): token_cost = num_tiles * 170 + 85 return token_cost - def get_image_size(self, fname): + def get_image_size(self, fname: str) -> tuple: """ Retrieve the size of an image. :param fname: The filename of the image. @@ -709,7 +716,7 @@ def get_image_size(self, fname): with Image.open(fname) as img: return img.size - def fast_validate_environment(self): + def fast_validate_environment(self) -> Optional[dict]: """Fast path for common models. Avoids forcing litellm import.""" model = self.name @@ -740,7 +747,7 @@ def fast_validate_environment(self): if var and os.environ.get(var): return dict(keys_in_environment=[var], missing_keys=[]) - def validate_environment(self): + def validate_environment(self) -> dict: res = self.fast_validate_environment() if res: return res @@ -779,7 +786,7 @@ def validate_environment(self): return res - def get_repo_map_tokens(self): + def get_repo_map_tokens(self) -> int: map_tokens = 1024 max_inp_tokens = self.info.get("max_input_tokens") if max_inp_tokens: @@ -788,7 +795,7 @@ def get_repo_map_tokens(self): map_tokens = max(map_tokens, 1024) return map_tokens - def set_reasoning_effort(self, effort): + def set_reasoning_effort(self, effort: str) -> None: """Set the reasoning effort parameter for models that support it""" if effort is not None: if self.name.startswith("openrouter/"): @@ -804,7 +811,7 @@ def set_reasoning_effort(self, effort): self.extra_params["extra_body"] = {} self.extra_params["extra_body"]["reasoning_effort"] = effort - def parse_token_value(self, value): + def parse_token_value(self, value: Union[str, int]) -> int: """ Parse a token value string into an integer. Accepts formats: 8096, "8k", "10.5k", "0.5M", "10K", etc. @@ -835,7 +842,7 @@ def parse_token_value(self, value): # Convert to float first to handle decimal values like "10.5k" return int(float(value) * multiplier) - def set_thinking_tokens(self, value): + def set_thinking_tokens(self, value: Union[str, int]) -> None: """ Set the thinking token budget for models that support it. Accepts formats: 8096, "8k", "10.5k", "0.5M", "10K", etc. @@ -863,7 +870,7 @@ def set_thinking_tokens(self, value): if "thinking" in self.extra_params: del self.extra_params["thinking"] - def get_raw_thinking_tokens(self): + def get_raw_thinking_tokens(self) -> Optional[int]: """Get formatted thinking token budget if available""" budget = None @@ -884,7 +891,7 @@ def get_raw_thinking_tokens(self): return budget - def get_thinking_tokens(self): + def get_thinking_tokens(self) -> Optional[str]: budget = self.get_raw_thinking_tokens() if budget is not None: @@ -903,7 +910,7 @@ def get_thinking_tokens(self): return f"{value:.1f}k" return None - def get_reasoning_effort(self): + def get_reasoning_effort(self) -> Optional[str]: """Get reasoning effort value if available""" if self.extra_params: # Check for OpenRouter reasoning format @@ -922,16 +929,16 @@ def get_reasoning_effort(self): return self.extra_params["extra_body"]["reasoning_effort"] return None - def is_deepseek_r1(self): + def is_deepseek_r1(self) -> Optional[bool]: name = self.name.lower() if "deepseek" not in name: - return + return None return "r1" in name or "reasoner" in name - def is_ollama(self): + def is_ollama(self) -> bool: return self.name.startswith("ollama/") or self.name.startswith("ollama_chat/") - def github_copilot_token_to_open_ai_key(self, extra_headers): + def github_copilot_token_to_open_ai_key(self, extra_headers: dict) -> None: # check to see if there's an openai api key # If so, check to see if it's expire openai_api_key = "OPENAI_API_KEY" @@ -982,7 +989,7 @@ class GitHubCopilotTokenError(Exception): os.environ[openai_api_key] = token - def send_completion(self, messages, functions, stream, temperature=None): + def send_completion(self, messages: list, functions: Optional[list], stream: bool, temperature: Optional[float] = None) -> tuple: if os.environ.get("AIDER_SANITY_CHECK_TURNS"): sanity_check_messages(messages) @@ -1036,7 +1043,7 @@ def send_completion(self, messages, functions, stream, temperature=None): res = litellm.completion(**kwargs) return hash_object, res - def simple_send_with_retries(self, messages): + def simple_send_with_retries(self, messages: list) -> Optional[str]: from aider.exceptions import LiteLLMExceptions litellm_ex = LiteLLMExceptions() @@ -1082,7 +1089,7 @@ def simple_send_with_retries(self, messages): return None -def register_models(model_settings_fnames): +def register_models(model_settings_fnames: list) -> list: files_loaded = [] for model_settings_fname in model_settings_fnames: if not os.path.exists(model_settings_fname): @@ -1109,7 +1116,7 @@ def register_models(model_settings_fnames): return files_loaded -def register_litellm_models(model_fnames): +def register_litellm_models(model_fnames: list) -> list: files_loaded = [] for model_fname in model_fnames: if not os.path.exists(model_fname): @@ -1133,7 +1140,7 @@ def register_litellm_models(model_fnames): return files_loaded -def validate_variables(vars): +def validate_variables(vars: list) -> dict: missing = [] for var in vars: if var not in os.environ: @@ -1143,7 +1150,7 @@ def validate_variables(vars): return dict(keys_in_environment=True, missing_keys=missing) -def sanity_check_models(io, main_model): +def sanity_check_models(io, main_model: Model) -> bool: problem_main = sanity_check_model(io, main_model) problem_weak = None @@ -1161,7 +1168,7 @@ def sanity_check_models(io, main_model): return problem_main or problem_weak or problem_editor -def sanity_check_model(io, model): +def sanity_check_model(io, model: Model) -> bool: show = False if model.missing_keys: @@ -1200,7 +1207,7 @@ def sanity_check_model(io, model): return show -def check_for_dependencies(io, model_name): +def check_for_dependencies(io, model_name: str) -> None: """ Check for model-specific dependencies and install them if needed. @@ -1224,7 +1231,7 @@ def check_for_dependencies(io, model_name): ) -def fuzzy_match_models(name): +def fuzzy_match_models(name: str) -> list: name = name.lower() chat_models = set() @@ -1269,7 +1276,7 @@ def fuzzy_match_models(name): return sorted(set(matching_models)) -def print_matching_models(io, search): +def print_matching_models(io, search: str) -> None: matches = fuzzy_match_models(search) if matches: io.tool_output(f'Models which match "{search}":') @@ -1279,7 +1286,7 @@ def print_matching_models(io, search): io.tool_output(f'No models match "{search}".') -def get_model_settings_as_yaml(): +def get_model_settings_as_yaml() -> str: from dataclasses import fields import yaml @@ -1314,7 +1321,7 @@ def get_model_settings_as_yaml(): return yaml_str.replace("\n- ", "\n\n- ") -def main(): +def main() -> None: if len(sys.argv) < 2: print("Usage: python models.py or python models.py --yaml") sys.exit(1) From 8737098dbb13eb03765ceff00c27efa522822656 Mon Sep 17 00:00:00 2001 From: Sarthak816 Date: Wed, 1 Jul 2026 22:15:46 +0530 Subject: [PATCH 3/7] fix: enable git pre-commit hooks by default (--git-commit-verify) Fixes #5376 - Aider was silently bypassing pre-commit hooks by applying --no-verify to all git commits. This is a security risk for projects relying on pre-commit hooks for SAST scanning, secret detection, and code formatting. Changed the default of --git-commit-verify from False to True in aider/args.py, so pre-commit hooks are now honored by default. Users who need to bypass hooks can explicitly opt in with --no-git-commit-verify. --- aider/args.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aider/args.py b/aider/args.py index 5b3fdf07faf..44256a48af5 100644 --- a/aider/args.py +++ b/aider/args.py @@ -491,8 +491,8 @@ def get_parser(default_config_files, git_root): group.add_argument( "--git-commit-verify", action=argparse.BooleanOptionalAction, - default=False, - help="Enable/disable git pre-commit hooks with --no-verify (default: False)", + default=True, + help="Enable/disable git pre-commit hooks with --no-verify (default: True)", ) group.add_argument( "--commit", From 01adfee73b0c46b5160d426b66d102015e22535c Mon Sep 17 00:00:00 2001 From: Sarthak816 Date: Wed, 1 Jul 2026 22:36:12 +0530 Subject: [PATCH 4/7] fix: avoid shell=True where possible to prevent RCE via shell injection Fixes #5375 - Remote Code Execution via unescaped cmd parameter with shell=True Changes: 1. aider/run_cmd.py: - Added _has_shell_operators() helper that detects shell metacharacters outside of quoted strings - Modified run_cmd_subprocess to prefer list-based subprocess execution (shell=False) for commands without shell operators - Falls back to shell=True only for commands containing pipes, redirects, chaining, or other shell features 2. aider/commands.py: - Modified cmd_git() to use shlex.split() + list-based subprocess.run with shell=False - Git commands don't need shell features, so shell=True was unnecessary --- aider/commands.py | 7 ++++--- aider/run_cmd.py | 31 ++++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index 4f6f16205f4..c4912c6c3f4 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -3,6 +3,7 @@ import glob import os import re +import shlex import subprocess import sys import tempfile @@ -975,16 +976,16 @@ def cmd_git(self, args: str) -> None: "Run a git command (output excluded from chat)" combined_output = None try: - args = "git " + args + cmd_parts = shlex.split("git " + args) env = dict(subprocess.os.environ) env["GIT_EDITOR"] = "true" result = subprocess.run( - args, + cmd_parts, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env, - shell=True, + shell=False, encoding=self.io.encoding, errors="replace", ) diff --git a/aider/run_cmd.py b/aider/run_cmd.py index f201b41dcc6..e2c27c0af74 100644 --- a/aider/run_cmd.py +++ b/aider/run_cmd.py @@ -1,5 +1,7 @@ import os import platform +import re +import shlex import subprocess import sys from io import BytesIO @@ -23,6 +25,20 @@ def run_cmd(command, verbose=False, error_print=None, cwd=None): return 1, error_message +def _has_shell_operators(command_str): + """Check if a command string contains shell operators that require shell=True. + + Detects pipes, redirects, command chaining, variable expansion, and + command substitution outside of quoted strings. + """ + # Remove content inside single and double quotes + stripped = re.sub(r"'[^']*'|\"[^\"]*\"", "", command_str) + # Check for shell operators + return bool( + re.search(r"[|;&$`()]|&&|\|\||>>?|< Date: Sat, 4 Jul 2026 21:58:46 +0530 Subject: [PATCH 5/7] fix: handle tags from Ollama reasoning models that stream into terminal Fixes #5396 - Ollama reasoning models (e.g. Qwen3.6) embed reasoning blocks in the regular content field rather than the reasoning_content field, causing raw XML in terminal output and corrupted formatting. Changes: 1. aider/coders/base_coder.py: - Added _normalize_think_tags() helper that converts raw tags to the internal REASONING_TAG format when reasoning_tag_name != 'think' - Applied in show_send_output_stream (streaming path) before accumulation and display - Applied in show_send_output (non-streaming path) before display - Applied in remove_reasoning_content as a safety net before stripping 2. aider/models.py: - Added generic settings for Ollama reasoning models (qwq, r1, reasoning, think, deepseek, qwen3) to set reasoning_tag='think' with appropriate defaults --- aider/coders/base_coder.py | 23 +++++++++++++++++++++++ aider/models.py | 11 +++++++++++ 2 files changed, 34 insertions(+) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 56613782b02..ac9134b0972 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -1879,6 +1879,11 @@ def show_send_output(self, completion): self.io.tool_error(show_content_err) raise Exception("No data found in LLM response!") + # Normalize tags from models that embed reasoning in content + self.partial_response_content = self._normalize_think_tags( + self.partial_response_content + ) + show_resp = self.render_incremental_response(True) if reasoning_content: @@ -1953,6 +1958,9 @@ def show_send_output_stream(self, completion): if received_content: self._stop_waiting_spinner() + + # Normalize tags from models that embed reasoning in content + text = self._normalize_think_tags(text) self.partial_response_content += text if self.show_pretty(): @@ -1983,8 +1991,23 @@ def live_incremental_response(self, final): def render_incremental_response(self, final): return self.get_multi_response_content_in_progress() + def _normalize_think_tags(self, text): + """ + Convert raw tags to the internal reasoning tag format. + Many Ollama/OpenAI-compatible models embed reasoning in the content + as tags rather than using the reasoning_content field. + """ + if self.reasoning_tag_name != "think": + text = re.sub(r"", f"<{REASONING_TAG}>", text) + text = re.sub(r"", f"", text) + return text + def remove_reasoning_content(self): """Remove reasoning content from the model's response.""" + # Normalize tags from models that embed reasoning in content + self.partial_response_content = self._normalize_think_tags( + self.partial_response_content + ) self.partial_response_content = remove_reasoning_content( self.partial_response_content, diff --git a/aider/models.py b/aider/models.py index 338901c3170..9b2b858d758 100644 --- a/aider/models.py +++ b/aider/models.py @@ -599,6 +599,17 @@ def apply_generic_model_settings(self, model: str) -> None: self.extra_params = {"top_p": 0.8, "top_k": 20, "min_p": 0.0} return # <-- + # Ollama/local reasoning models that embed tags in content + if self.is_ollama() and any( + kw in model for kw in ["qwq", "r1", "reasoning", "think", "deepseek", "qwen3"] + ): + self.edit_format = "diff" + self.use_repo_map = True + self.reasoning_tag = "think" + self.examples_as_sys_msg = True + self.use_temperature = 0.6 + return # <-- + # use the defaults if self.edit_format == "diff": self.use_repo_map = True From 5f0d9358f630675f71eef0f7b24a08199b54f3c5 Mon Sep 17 00:00:00 2001 From: Sarthak816 Date: Sat, 4 Jul 2026 22:08:25 +0530 Subject: [PATCH 6/7] fix: catch scipy ImportError in nx.pagerank, fall back to pure Python Fixes #5393 - Uncaught ImportError when scipy's native _spropack binary fails to load on macOS (section __DATA/__thread_bss linker issue). networkx 3.x's pagerank() calls _pagerank_scipy() directly with no try/except fallback. When scipy's deep import chain fails (e.g. _svdp.py -> _propack._spropack.so), the ImportError propagates uncaught. Added except (ImportError, OSError) around nx.pagerank() that falls back to networkx's pure-Python _pagerank_python implementation. User gets warning messages in both the fallback and failure paths. --- aider/repomap.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/aider/repomap.py b/aider/repomap.py index 541bba6ef4a..a581cf4d9a3 100644 --- a/aider/repomap.py +++ b/aider/repomap.py @@ -529,6 +529,25 @@ def get_ranked_tags( ranked = nx.pagerank(G, weight="weight") except ZeroDivisionError: return [] + except (ImportError, OSError): + # Issue #5393: scipy binary incompatibility on some platforms (e.g. macOS) + # Fall back to networkx's pure-Python pagerank which doesn't require scipy + self.io.tool_warning( + "SciPy PageRank unavailable, falling back to pure Python implementation" + ) + try: + ranked = ( + nx.algorithms.link_analysis.pagerank_alg._pagerank_python( + G, weight="weight", **pers_args + ) + ) + except (ImportError, OSError): + self.io.tool_warning( + "Unable to compute repo map (scipy/numpy not available)" + ) + return [] + except ZeroDivisionError: + return [] # distribute the rank from each source node, across all of its out edges ranked_definitions = defaultdict(float) From 599aeb103db1889876a13bc26b4cccab743e4301 Mon Sep 17 00:00:00 2001 From: Sarthak816 Date: Sun, 5 Jul 2026 23:06:10 +0530 Subject: [PATCH 7/7] fix: catch NotImplementedError from Path.glob() with non-relative patterns Fixes #5404 - Uncaught NotImplementedError in pathlib.py line 949 when using /read-only with patterns containing '..' or other non-relative components (Python 3.11+). Changes in aider/commands.py: 1. cmd_read_only: Wrapped Path(self.coder.root).glob() in try/except NotImplementedError, falling back to glob.glob() with recursive=True 2. glob_filtered_to_repo: Added NotImplementedError to the existing except tuple --- aider/commands.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index c4912c6c3f4..d36ee08c500 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -780,7 +780,7 @@ def glob_filtered_to_repo(self, pattern: str) -> list: else: try: raw_matched_files = list(Path(self.coder.root).glob(pattern)) - except (IndexError, AttributeError): + except (IndexError, AttributeError, NotImplementedError): raw_matched_files = [] except ValueError as err: self.io.tool_error(f"Error matching {pattern}: {err}") @@ -1366,7 +1366,16 @@ def cmd_read_only(self, args: str) -> None: matches = [Path(p) for p in glob.glob(expanded_pattern)] else: # For relative paths and globs, use glob from the root directory - matches = list(Path(self.coder.root).glob(expanded_pattern)) + try: + matches = list(Path(self.coder.root).glob(expanded_pattern)) + except NotImplementedError: + # Python 3.11+ Path.glob() rejects non-relative patterns (e.g. ../foo) + matches = [ + Path(p) for p in glob.glob( + str(Path(self.coder.root) / expanded_pattern), + recursive=True, + ) + ] if not matches: self.io.tool_error(f"No matches found for: {pattern}")