diff --git a/.aidev b/.aidev new file mode 100644 index 0000000..c0479d0 --- /dev/null +++ b/.aidev @@ -0,0 +1,16 @@ +ignore *.yml +ignore Dockerfile +ignore LICENSE + +slice dev + only docstring_auditor/*.py + only tests/*.py + +slice main + only docstring_auditor/*.py + +slice test + only tests/*.py + +slice docs + only README.md diff --git a/.gitignore b/.gitignore index a3aca88..026878c 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,5 @@ cython_debug/ package-lock.json package.json +***.draft diff --git a/docstring_auditor/main.py b/docstring_auditor/main.py index b974e2c..c13d0e2 100644 --- a/docstring_auditor/main.py +++ b/docstring_auditor/main.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 import os +import sys import click import ast import json -from typing import List, Optional, Dict +from typing import List, Optional, Dict, Tuple import openai @@ -23,7 +24,7 @@ def extract_functions(file_path: str) -> List[Optional[str]]: Returns ------- - List[str] + List[Optional[str]] A list of strings, where each string contains the entire code for a function, including the definition, docstring, and code. @@ -65,7 +66,7 @@ def ask_for_critique(function: str) -> Dict[str, str]: Returns ------- - response_dict : dict + response_dict : Dict[str, str] A dictionary containing the analysis of the docstring, including function name, errors, warnings, and the corrected docstring if needed. """ @@ -77,6 +78,7 @@ def ask_for_critique(function: str) -> Dict[str, str]: "You have extensive knowledge of all coding languages and packages." "You will review the documentation for python functions that I provide." "The documentation you are helping to write is written for someone with very little coding experience." + "Do not provide errors or warnings about imports." "Please provide verbose descriptions and ensure no assumptions are made in the documentation." ) @@ -117,51 +119,61 @@ def ask_for_critique(function: str) -> Dict[str, str]: return response_dict -def report_concerns(response_dict: Dict[str, str]) -> bool: +def report_concerns(response_dict: Dict[str, str]) -> Tuple[int, int]: """ Inform the user of any concerns with the docstring. Parameters ---------- - response_dict : dict + response_dict : Dict[str, str] A dictionary containing the function name, error, warning, and solution. Returns ------- - bool - Returns True if there are any concerns (errors or warnings), otherwise returns False. + Tuple[int, int] + Returns a tuple containing the count of errors and warnings found in the docstring. """ function_name = response_dict["function"] error = response_dict["error"] warning = response_dict["warning"] solution = response_dict["solution"] + error_count = 0 + warning_count = 0 + if not error and not warning: click.secho( f"No concerns found with the docstring for the function: {function_name}\n", fg="green", ) - return False else: if error: click.secho( f"An error was found in the function: {function_name}\n", fg="red" ) click.secho(f"{error}\n", fg="red") + error_count += 1 if warning: click.secho( f"A warning was found in the function: {function_name}\n", fg="yellow" ) click.secho(f"{warning}\n", fg="yellow") + warning_count += 1 if solution: click.secho(f"A proposed solution to these concerns is:\n\n{solution}\n\n") - return True + + return error_count, warning_count -def process_file(file_path: str): +def process_file(file_path: str) -> Tuple[int, int]: """ Process a single Python file and analyze its functions' docstrings. + This function processes the given Python file, extracts the functions within it, + and analyzes their docstrings for errors and warnings. + It then returns the total number of errors and warnings found in the + docstrings of the functions in the given file. + Parameters ---------- file_path : str @@ -169,21 +181,30 @@ def process_file(file_path: str): Returns ------- - None - The function does not return any value. It prints the critiques and suggestions for the docstrings in the given file. + Tuple[int, int] + A tuple containing the total number of errors and warnings found in the docstrings of the functions in the given file. """ functions = extract_functions(file_path) + error_count = 0 + warning_count = 0 + for idx, function in enumerate(functions): print( f"Processing function {idx + 1} of {len(functions)} in file {file_path}..." ) assert isinstance(function, str) critique = ask_for_critique(function) - report_concerns(critique) + errors, warnings = report_concerns(critique) + error_count += errors + warning_count += warnings + + return error_count, warning_count -def process_directory(directory_path: str, ignore_dirs: Optional[List[str]] = None): +def process_directory( + directory_path: str, ignore_dirs: Optional[List[str]] = None +) -> Tuple[int, int]: """ Recursively process all .py files in a directory and its subdirectories, ignoring specified directories. @@ -197,19 +218,26 @@ def process_directory(directory_path: str, ignore_dirs: Optional[List[str]] = No Returns ------- - None - The function does not return any value. It prints the critiques and suggestions for the docstrings in the .py files. + Tuple[int, int] + A tuple containing the total number of errors and warnings found in the docstrings of the .py files. """ if ignore_dirs is None: ignore_dirs = ["tests"] + error_count = 0 + warning_count = 0 + for root, dirs, files in os.walk(directory_path): dirs[:] = [d for d in dirs if d not in ignore_dirs] for file in files: if file.endswith(".py"): file_path = os.path.join(root, file) - process_file(file_path) + errors, warnings = process_file(file_path) + error_count += errors + warning_count += warnings + + return error_count, warning_count @click.command(name="DocstringAuditor") @@ -221,7 +249,13 @@ def process_directory(directory_path: str, ignore_dirs: Optional[List[str]] = No default=["tests"], help="A list of directory names to ignore while processing .py files. Separate multiple directories with a space.", ) -def docstring_auditor(path: str, ignore_dirs: List[str]): +@click.option( + "--error-on-warnings", + is_flag=True, + default=False, + help="If true, warnings will be treated as errors and included in the exit code count.", +) +def docstring_auditor(path: str, ignore_dirs: List[str], error_on_warnings: bool): """ Analyze Python functions' docstrings in a given file or directory and provide critiques and suggestions for improvement. @@ -233,9 +267,10 @@ def docstring_auditor(path: str, ignore_dirs: List[str]): ---------- path : str The path to the .py file or directory to analyze the functions' docstrings. - ignore_dirs : List[str] A list of directory names to ignore while processing .py files. + error_on_warnings : bool + If true, warnings will be treated as errors and included in the exit code count. Returns ------- @@ -243,13 +278,23 @@ def docstring_auditor(path: str, ignore_dirs: List[str]): The function does not return any value. It prints the critiques and suggestions for the docstrings in the given file or directory. """ if os.path.isfile(path): - process_file(path) + error_count, warning_count = process_file(path) elif os.path.isdir(path): - process_directory(path, ignore_dirs) + error_count, warning_count = process_directory(path, ignore_dirs) else: - click.secho( - "Invalid path. Please provide a valid file or directory path.", fg="red" + error_text = "Invalid path. Please provide a valid file or directory path." + click.secho(error_text, fg="red") + sys.exit(error_text) + + if error_count > 0 or (error_on_warnings and warning_count > 0): + error_text = ( + f"Auditor identified {error_count} errors and {warning_count} warnings." ) + click.secho(error_text, fg="red") + sys.exit(error_count + (warning_count if error_on_warnings else 0)) + else: + click.secho("No errors found.", fg="green") + sys.exit(0) if __name__ == "__main__": diff --git a/tests/test_exit_codes.py b/tests/test_exit_codes.py new file mode 100644 index 0000000..0a1aae9 --- /dev/null +++ b/tests/test_exit_codes.py @@ -0,0 +1,36 @@ +import pytest +import tempfile +from unittest.mock import patch +from docstring_auditor.main import docstring_auditor +from click.testing import CliRunner + + +# Test cases for sys exit values +test_data = [ + # (description, error_count, warning_count, error_on_warnings, expected_exit) + ("No errors or warnings", 0, 0, False, 0), + ("One error, no warnings", 1, 0, False, 1), + ("No errors, one warning (no error on warnings)", 0, 1, False, 0), + ("No errors, one warning (with error on warnings)", 0, 1, True, 1), + ("One error, one warning (no error on warnings)", 1, 1, False, 1), + ("One error, one warning (with error on warnings)", 1, 1, True, 2), +] + + +@pytest.mark.parametrize( + "desc, error_count, warning_count, error_on_warnings, expected_exit", test_data +) +def test_docstring_auditor_exit_values( + desc, error_count, warning_count, error_on_warnings, expected_exit +): + with tempfile.TemporaryDirectory() as tempdir: + with patch( + "docstring_auditor.main.process_directory", + return_value=(error_count, warning_count), + ): + runner = CliRunner() + args = [tempdir, "--ignore-dirs", "tests"] + if error_on_warnings: + args.append("--error-on-warnings") + result = runner.invoke(docstring_auditor, args=args) + assert result.exit_code == expected_exit, f"{desc} failed" diff --git a/tests/test_reporter.py b/tests/test_reporter.py index 031b4a7..9afe1f5 100644 --- a/tests/test_reporter.py +++ b/tests/test_reporter.py @@ -18,7 +18,7 @@ def mock_secho(*args, **kwargs): "solution": "", } result = report_concerns(response_dict) - assert result is False + assert result == (0, 0) assert len(captured_output) == 1 assert "No concerns found" in captured_output[0] @@ -38,7 +38,7 @@ def mock_secho(*args, **kwargs): "solution": "Updated docstring.", } result = report_concerns(response_dict) - assert result is True + assert result == (1, 0) assert len(captured_output) == 3 assert "An error was found" in captured_output[0] assert "An error occurred." in captured_output[1] @@ -60,7 +60,7 @@ def mock_secho(*args, **kwargs): "solution": "", } result = report_concerns(response_dict) - assert result is True + assert result == (0, 1) assert len(captured_output) == 2 assert "A warning was found" in captured_output[0] assert "A warning occurred." in captured_output[1] @@ -82,7 +82,7 @@ def mock_secho(*args, **kwargs): } result = report_concerns(response_dict) print(captured_output) - assert result is True + assert result == (1, 1) assert len(captured_output) == 5 assert "An error was found" in captured_output[0] assert "An error occurred." in captured_output[1]