From 1396f38f65480247037cd4381a812b952e531f7e Mon Sep 17 00:00:00 2001 From: Harshad Hegde Date: Tue, 27 Feb 2024 01:41:11 -0600 Subject: [PATCH 1/2] Added code for bug finding + fixing --- README.md | 4 +- src/codergpt/bug_finder/__init__.py | 5 ++ src/codergpt/bug_finder/bug_finder.py | 100 ++++++++++++++++++++++++++ src/codergpt/cli.py | 47 ++++++++++++ src/codergpt/commenter/commenter.py | 34 +++++---- src/codergpt/constants.py | 2 +- src/codergpt/main.py | 50 +++++++++++-- src/codergpt/utils.py | 55 ++++++++++++++ tests/input/buggy_code.py | 26 +++++++ tests/test_bug_finder.py | 81 +++++++++++++++++++++ tests/test_commenter.py | 15 ++-- tox.ini | 4 +- 12 files changed, 388 insertions(+), 35 deletions(-) create mode 100644 src/codergpt/bug_finder/__init__.py create mode 100644 src/codergpt/bug_finder/bug_finder.py create mode 100644 src/codergpt/utils.py create mode 100644 tests/input/buggy_code.py create mode 100644 tests/test_bug_finder.py diff --git a/README.md b/README.md index 82a0dbb..c49a487 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ CoderGPT is a versatile command-line interface (CLI) designed to enhance coding # Model Providers Implemented - [x] OpenAI [`gpt-3.5-turbo`, `gpt-4`, `gpt-4-turbo-preview`(default)] - [x] Google [`gemini-pro`] - - [x] Anthropic [`claude-2`] + - [x] Anthropic [`claude-2.1`] ## Prerequisites @@ -46,7 +46,7 @@ codergpt [OPTIONS] COMMAND [ARGS]... - Available models: - OpenAI: [`gpt-3.5-turbo`, `gpt-4`, `gpt-4-turbo-preview`(default)] - Google: [`gemini-pro`] - - Anthropic[`claude-2`] + - Anthropic[`claude-2.1`] #### Commands diff --git a/src/codergpt/bug_finder/__init__.py b/src/codergpt/bug_finder/__init__.py new file mode 100644 index 0000000..d6ec53a --- /dev/null +++ b/src/codergpt/bug_finder/__init__.py @@ -0,0 +1,5 @@ +"""Bug-finder module for the package.""" + +from .bug_finder import BugFinder + +__all__ = ["BugFinder"] diff --git a/src/codergpt/bug_finder/bug_finder.py b/src/codergpt/bug_finder/bug_finder.py new file mode 100644 index 0000000..7ab0c8a --- /dev/null +++ b/src/codergpt/bug_finder/bug_finder.py @@ -0,0 +1,100 @@ +"""Bug-finder class for the package.""" + +from pathlib import Path +from typing import Any, Dict, Optional, Union + +from langchain_core.runnables.base import RunnableSerializable + +from codergpt.utils import extract_code_from_response + + +class BugFinder: + """Bug-finder class for the package.""" + + def __init__(self, chain: RunnableSerializable[Dict, Any]): + """Initialize the BugFinder class.""" + self.chain = chain + + def find_bugs( + self, code: str, function: Optional[str] = None, classname: Optional[str] = None, language: Optional[str] = None + ): + """ + Find bugs in the given code. + + :param code: The code to find bugs in. + :param function: The name of the function to find bugs in. Default is None. + :param classname: The name of the class to find bugs in. Default is None. + :param language: The language of the code. Default is None. + """ + if function: + response = self.chain.invoke( + { + "input": f"Find and list all the bugs in the function {function}" + f" in the following {language} code: \n\n```\n{code}\n```" + } + ) + # Pretty print the response + print(f"Bugs found in '{function}':\n{response.content}") + elif classname: + response = self.chain.invoke( + { + "input": f"Find and list all the bugs in the class {classname}" + f" in the following {language} code: \n\n```\n{code}\n```" + } + ) + # Pretty print the response + print(f"Bugs found in '{classname}':\n{response.content}") + else: + # Find bugs in full code + response = self.chain.invoke( + {"input": f"Find and list all the bugs in the following {language} code: \n\n```\n{code}\n```"} + ) + # Pretty print the response + print(f"Bugs found in the code:\n{response.content}") + + def fix_bugs( + self, + filename: Union[str, Path], + code: str, + function: Optional[str] = None, + classname: Optional[str] = None, + language: Optional[str] = None, + outfile: Optional[str] = None, + ) -> None: + """ + Fix bugs in the given code. + + :param code: The code to fix bugs in. + :param function: The name of the function to fix bugs in. Default is None. + :param classname: The name of the class to fix bugs + :param outfile:Path for output file with bug-fix code. Default is None. + """ + if function: + response = self.chain.invoke( + { + "input": f"List all the bug fixes if any and rewrite the function {function}" + f" in the following {language} code: \n\n```\n{code}\n```" + } + ) + # Pretty print the response + print(f"Fixed code for '{function}':\n{response.content}") + return response.content + elif classname: + response = self.chain.invoke( + { + "input": f"List all the bug fixes if any and rewrite the class {classname}" + f" in the following {language} code: \n\n```\n{code}\n```" + } + ) + # Pretty print the response + print(f"Fixed code for '{classname}':\n{response.content}") + return response.content + else: + # Fix bugs in full code + response = self.chain.invoke( + { + "input": f"List all the bug fixes if any and rewrite the following {language}" + f" code: \n\n```\n{code}\n```" + } + ) + return extract_code_from_response(language, response.content, filename, outfile) diff --git a/src/codergpt/cli.py b/src/codergpt/cli.py index b3f81aa..82af68a 100644 --- a/src/codergpt/cli.py +++ b/src/codergpt/cli.py @@ -206,5 +206,52 @@ def write_documentation(path: Union[str, Path], outfile: Union[str, Path] = None raise ValueError("The path provided is not a file.") +@main.command("find-bugs") +@path_argument +@function_option +@class_option +def find_bugs_in_code(path: Union[str, Path], function: str, classname: str): + """ + Write tests for the code file. + + :param path: The path to the code file. + :param function: The name of the function to test. Default is None. + :param classname: The name of the class to test. Default is None. + """ + # Ensure path is a string or Path object for consistency + if isinstance(path, str): + path = Path(path) + + # Check if path is a file + if path.is_file(): + coder.bug_finder(path=path, function=function, classname=classname) + else: + raise ValueError("The path provided is not a file.") + + +@main.command("fix-bugs") +@path_argument +@function_option +@class_option +@output_option +def fix_bugs_in_code(path: Union[str, Path], function: str, classname: str, outfile: Union[str, Path] = None): + """ + Write tests for the code file. + + :param path: The path to the code file. + :param function: The name of the function to test. Default is None. + :param classname: The name of the class to test. Default is None. + """ + # Ensure path is a string or Path object for consistency + if isinstance(path, str): + path = Path(path) + + # Check if path is a file + if path.is_file(): + coder.bug_fixer(path=path, function=function, classname=classname, outfile=outfile) + else: + raise ValueError("The path provided is not a file.") + + if __name__ == "__main__": main() diff --git a/src/codergpt/commenter/commenter.py b/src/codergpt/commenter/commenter.py index aea2e09..59a13c9 100644 --- a/src/codergpt/commenter/commenter.py +++ b/src/codergpt/commenter/commenter.py @@ -1,11 +1,13 @@ """Commenter Module.""" import os +from pathlib import Path from typing import Any, Dict, Optional from langchain_core.runnables.base import RunnableSerializable from codergpt.constants import TEMPLATES +from codergpt.utils import extract_code_from_response, get_language_from_extension class CodeCommenter: @@ -29,13 +31,17 @@ def comment(self, code: str, filename: str, overwrite: bool = False, language: O :param language: Coding language of the file, defaults to None """ comment_template = None - if language and language in TEMPLATES.keys(): - # Check if "comment" key exists in the language template - if "comment" in TEMPLATES[language]: - # Get the path to the comment template - comment_template_path = TEMPLATES[language]["comment"] - with open(comment_template_path, "r") as comment_template_file: - comment_template = comment_template_file.read() + if language: + if language in TEMPLATES.keys(): + # Check if "comment" key exists in the language template + if "comment" in TEMPLATES[language]: + # Get the path to the comment template + comment_template_path = TEMPLATES[language]["comment"] + with open(comment_template_path, "r") as comment_template_file: + comment_template = comment_template_file.read() + else: + # Get the language from the file extension + language = get_language_from_extension(filename) if comment_template: invoke_params = { @@ -54,15 +60,15 @@ def comment(self, code: str, filename: str, overwrite: bool = False, language: O response = self.chain.invoke(invoke_params) - # Extract the commented code from the response if necessary - commented_code = response.content - new_filename = filename if not overwrite: # Create a new filename with the _updated suffix base, ext = os.path.splitext(filename) - new_filename = f"{base}_updated{ext}" + new_filename = f"{Path(filename).parent / base}_updated{ext}" - # Write the commented code to the new file - with open(new_filename, "w") as updated_file: - updated_file.write(commented_code) + if language: + return extract_code_from_response(language, response.content, filename, new_filename) + else: + # Write the commented code to the new file + with open(new_filename, "w") as updated_file: + updated_file.write(response.content) diff --git a/src/codergpt/constants.py b/src/codergpt/constants.py index 9e51786..b6c58bf 100644 --- a/src/codergpt/constants.py +++ b/src/codergpt/constants.py @@ -14,7 +14,7 @@ GPT_3_5_TURBO = "gpt-3.5-turbo" GPT_4 = "gpt-4" GPT_4_TURBO = "gpt-4-turbo-preview" -CLAUDE = "claude-2" +CLAUDE = "claude-2.1" GEMINI = "gemini-pro" ALL_MODELS = [ diff --git a/src/codergpt/main.py b/src/codergpt/main.py index 8eb26d1..ca1a697 100644 --- a/src/codergpt/main.py +++ b/src/codergpt/main.py @@ -4,19 +4,20 @@ from pathlib import Path from typing import Optional, Union -import yaml from langchain_anthropic import ChatAnthropicMessages from langchain_core.prompts import ChatPromptTemplate from langchain_google_genai import ChatGoogleGenerativeAI from langchain_openai import ChatOpenAI from tabulate import tabulate +from codergpt.bug_finder.bug_finder import BugFinder from codergpt.commenter.commenter import CodeCommenter -from codergpt.constants import CLAUDE, EXTENSION_MAP_FILE, GEMINI, GPT_4_TURBO, INSPECTION_HEADERS +from codergpt.constants import CLAUDE, GEMINI, GPT_4_TURBO, INSPECTION_HEADERS from codergpt.documenter.documenter import CodeDocumenter from codergpt.explainer.explainer import CodeExplainer from codergpt.optimizer.optimizer import CodeOptimizer from codergpt.test_writer.test_writer import CodeTester +from codergpt.utils import get_language_from_extension class CoderGPT: @@ -52,9 +53,6 @@ def inspect_package(self, path: Union[str, Path]): """ print("Inspecting the code.") - with open(EXTENSION_MAP_FILE, "r") as file: - extension_to_language = yaml.safe_load(file) - path = Path(path) file_language_list = [] @@ -62,13 +60,13 @@ def inspect_package(self, path: Union[str, Path]): if path.is_dir(): for file in path.rglob("*.*"): - language = extension_to_language["language-map"].get(file.suffix) + language = get_language_from_extension(filename=file) if language is not None: file_language_list.append((str(file), language)) file_language_dict[str(file)] = language elif path.is_file(): - language = extension_to_language["language-map"].get(path.suffix) + language = get_language_from_extension(filename=path) if language is not None: file_language_list.append((str(path), language)) file_language_dict[str(path)] = language @@ -166,6 +164,44 @@ def documenter(self, path: Union[str, Path], outfile: str = None): code, language = self.get_code(filename=path) code_documenter.document(filename=filename, code=code, language=language, outfile=outfile) + def bug_finder(self, path: Union[str, Path], function: Optional[str] = None, classname: Optional[str] = None): + """ + Find bugs in the code file. + + :param path: The path to the code file. + :param function: The name of the function to find bugs in. Default is None. + :param classname: The name of the class to find bugs in. Default is None. + """ + if isinstance(path, str): + path = Path(path) + bug_finder = BugFinder(self.chain) + code, language = self.get_code(filename=path, function_name=function, class_name=classname) + bug_finder.find_bugs(code=code, function=function, classname=classname, language=language) + + def bug_fixer( + self, + path: Union[str, Path], + function: Optional[str] = None, + classname: Optional[str] = None, + outfile: Optional[str] = None, + ): + """ + Fix bugs in the code file. + + :param path: The path to the code file. + :param function: The name of the function to fix bugs in. Default is None. + :param classname: The name of the class to fix bugs in. Default is None. + :param outfile: The path to the output file. Default is None. + """ + if isinstance(path, str): + path = Path(path) + bug_finder = BugFinder(self.chain) + code, language = self.get_code(filename=path, function_name=function, class_name=classname) + filename = path.stem + bug_finder.fix_bugs( + filename=filename, code=code, function=function, classname=classname, language=language, outfile=outfile + ) + if __name__ == "__main__": coder = CoderGPT() diff --git a/src/codergpt/utils.py b/src/codergpt/utils.py new file mode 100644 index 0000000..260d00d --- /dev/null +++ b/src/codergpt/utils.py @@ -0,0 +1,55 @@ +"""Utility functions for the codergpt package.""" + +import os +import re +from pathlib import Path +from typing import Optional, Union + +import yaml + +from codergpt.constants import EXTENSION_MAP_FILE + + +def extract_code_from_response( + language: str, response: str, filename: Union[str, Path], outfile: Optional[str] = None +) -> str: + """ + Generate code files based on LLM responses. + + :param language: Code language. + :param response: LLM response. + :param filename: Source code file. + :param outfile: Destination filepath, defaults to None + """ + base, ext = os.path.splitext(filename) + file_parent = Path(filename).parent + + if not language: + get_language_from_extension(filename) + + code_pattern_block = rf"```{language.lower()}(.*?)(?<=\n)```" + matches = re.findall(code_pattern_block, response, re.DOTALL) + + if matches: + code_to_save = matches[0].strip() + if not outfile: + outfile = f"{file_parent/base}_updated{ext}" + with open(outfile, "w") as file: + file.write(code_to_save) + print(f"Fixed code saved in file: {outfile}") + + print(response) + return response + + +def get_language_from_extension(filename: Union[str, Path]) -> Optional[str]: + """ + Get the language of a file from its extension. + + :param filename: The filename to get the language for. + :return: The language of the file, if found. + """ + with open(EXTENSION_MAP_FILE, "r") as file: + extension_to_language = yaml.safe_load(file) + language = extension_to_language["language-map"].get(Path(filename).suffix) + return language diff --git a/tests/input/buggy_code.py b/tests/input/buggy_code.py new file mode 100644 index 0000000..72095f5 --- /dev/null +++ b/tests/input/buggy_code.py @@ -0,0 +1,26 @@ +# This code snippet is intentionally buggy for demonstration purposes. + + +def calculate_sum(lst): + # Intentional bug: sum is a built-in function, should not be used as variable name + sum = 0 + for i in lst: + # Intentional bug: 'i' is a string, cannot be added to an integer + sum += i + return sum + + +def divide(x, y): + # Intentional bug: Division by zero is not handled + return x / y + + +# Intentional bug: misspelled 'True' as 'Ture' +while Ture: + print("This loop will run forever because of a typo") + +# Intentional bug: 'calculate_sum' expects a list, not separate arguments +result = calculate_sum(1, 2, 3, 4) + +# Intentional bug: 'divide' function may raise ZeroDivisionError +print(divide(10, 0)) diff --git a/tests/test_bug_finder.py b/tests/test_bug_finder.py new file mode 100644 index 0000000..0369f06 --- /dev/null +++ b/tests/test_bug_finder.py @@ -0,0 +1,81 @@ +"""BugFinder test cases.""" + +import os +import unittest +from unittest.mock import patch + +from codergpt.bug_finder import BugFinder +from codergpt.constants import TEST_DIR +from langchain_core.prompts import ChatPromptTemplate +from langchain_openai import ChatOpenAI + + +class TestBugFinder(unittest.TestCase): + """Test cases for the BugFinder class.""" + + @patch("langchain_core.runnables.base.RunnableSerializable") + def setUp(self, mock_runnable): + """Set up method for the test cases.""" + self.bug_finder = BugFinder(mock_runnable) + self.mock_runnable = mock_runnable + + def test_find_bugs_function(self): + """Test case for the find_bugs method when the input is a function.""" + code = "def test():\n pass" + function = "test" + language = "python" + self.bug_finder.find_bugs(code, function=function, language=language) + self.mock_runnable.invoke.assert_called_once() + + def test_find_bugs_class(self): + """Test case for the find_bugs method when the input is a class.""" + code = "class Test:\n pass" + classname = "Test" + language = "python" + self.bug_finder.find_bugs(code, classname=classname, language=language) + self.mock_runnable.invoke.assert_called_once() + + def test_find_bugs_code(self): + """Test case for the find_bugs method when the input is a code snippet.""" + code = "print('Hello, World!')" + language = "python" + self.bug_finder.find_bugs(code, language=language) + self.mock_runnable.invoke.assert_called_once() + + def test_fix_bugs_function(self): + """Test case for the fix_bugs method when the input is a function.""" + code = "def test():\n pass" + function = "test" + language = "python" + self.bug_finder.fix_bugs("test.py", code, function=function, language=language) + self.mock_runnable.invoke.assert_called_once() + + def test_fix_bugs_class(self): + """Test case for the fix_bugs method when the input is a class.""" + code = "class Test:\n pass" + classname = "Test" + language = "python" + self.bug_finder.fix_bugs("test.py", code, classname=classname, language=language) + self.mock_runnable.invoke.assert_called_once() + + def test_fix_bugs_code(self): + """Test case for the fix_bugs method when the input is a code snippet.""" + code = "print('Hello, World!')" + language = "Python" + file = TEST_DIR / "input/buggy_code.py" + llm = ChatOpenAI(openai_api_key=os.environ.get("OPENAI_API_KEY"), temperature=0.3, model="gpt-3.5-turbo") + prompt = ChatPromptTemplate.from_messages( + [("system", "You are world class software developer."), ("user", "{input}")] + ) + chain = prompt | llm + actual_bugfinder = BugFinder( + chain=chain, + ) + actual_bugfinder.fix_bugs(file, code, language=language, outfile=TEST_DIR / "output/fixed_code.py") + self.assertTrue((TEST_DIR / "output/fixed_code.py").exists()) + # cleanup + os.remove(TEST_DIR / "output/fixed_code.py") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_commenter.py b/tests/test_commenter.py index e1be3b2..d69211a 100644 --- a/tests/test_commenter.py +++ b/tests/test_commenter.py @@ -28,33 +28,30 @@ def test_comment_with_overwrite(self): self.mock_chain.invoke.return_value.content = expected_commented_code # Act - self.commenter.comment(code=code, filename=filename, overwrite=True, language="python") + result = self.commenter.comment(code=code, filename=filename, overwrite=True, language="python") # Assert self.mock_chain.invoke.assert_called_once() - with open(filename, "r") as f: - content = f.read() - self.assertEqual(content, expected_commented_code) + + self.assertEqual(result, expected_commented_code) def test_comment_without_overwrite(self): """Test the comment method without overwrite set.""" # Setup code = "print('Goodbye, World!')" filename = TEST_INPUT_DIR / "test.py" - updated_filename = TEST_INPUT_DIR / "test_updated.py" expected_commented_code = "# This prints a farewell message to the console\nprint('Goodbye, World!')" # Configure the mock to return the expected commented code self.mock_chain.invoke.return_value.content = expected_commented_code # Act - self.commenter.comment(code=code, filename=filename) + result = self.commenter.comment(code=code, filename=filename) # Assert self.mock_chain.invoke.assert_called_once() - with open(updated_filename, "r") as f: - content = f.read() - self.assertEqual(content, expected_commented_code) + + self.assertEqual(result, expected_commented_code) def tearDown(self): """Clean up created files after each test case.""" diff --git a/tox.ini b/tox.ini index 5a3b0d5..e15402f 100644 --- a/tox.ini +++ b/tox.ini @@ -68,7 +68,7 @@ skip_install = true deps = codespell tomli # required for getting config from pyproject.toml -commands = codespell src/ tests/ -S tests/input/,tests/output/ +commands = codespell src/ tests/ --skip="tests/input/*,tests/output/*" [testenv:codespell-write] description = Run spell checker and write corrections. @@ -76,7 +76,7 @@ skip_install = true deps = codespell tomli -commands = codespell src/ tests/ --write-changes -S tests/input/,tests/output/ +commands = codespell src/ tests/ --write-changes --skip="tests/input/*,tests/output/*" [testenv:docstr-coverage] skip_install = true From b72dac505d3cc62fb69308a7fadc8655dbd286c7 Mon Sep 17 00:00:00 2001 From: Harshad Hegde Date: Tue, 27 Feb 2024 01:45:53 -0600 Subject: [PATCH 2/2] Added gh actions skip --- tests/test_bug_finder.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_bug_finder.py b/tests/test_bug_finder.py index 0369f06..4689505 100644 --- a/tests/test_bug_finder.py +++ b/tests/test_bug_finder.py @@ -19,6 +19,12 @@ def setUp(self, mock_runnable): self.bug_finder = BugFinder(mock_runnable) self.mock_runnable = mock_runnable + def skip_if_github_actions(reason): + """Skip a test if running on GitHub Actions.""" + if os.getenv("GITHUB_ACTIONS") == "true": + return unittest.skip(reason) + return lambda func: func + def test_find_bugs_function(self): """Test case for the find_bugs method when the input is a function.""" code = "def test():\n pass" @@ -58,6 +64,7 @@ def test_fix_bugs_class(self): self.bug_finder.fix_bugs("test.py", code, classname=classname, language=language) self.mock_runnable.invoke.assert_called_once() + @skip_if_github_actions("This test case is skipped because it requires an OpenAI API key.") def test_fix_bugs_code(self): """Test case for the fix_bugs method when the input is a code snippet.""" code = "print('Hello, World!')"