diff --git a/README.md b/README.md index 0ec5235..94f5e6e 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ So far, the following projects have been integrated to this repo: |[AI chatbot](Artificial-intelligence_bot) |[umar abdullahi](https://github.com/umarbrowser) | |[AI for guess the number](https://github.com/hastagAB/Awesome-Python-Scripts/tree/master/AI_for_Guess_the_number) | [Omar Sameh](https://github.com/ShadowHunter15) | |[Address locator](Location_Of_Adress) | [Chris]() | +|[AWE-SMART CODE REVIEWER](Location_Of_Adress) | [Chris]() | |[Asymmetric Encryption](asymmetric_cryptography) |[victor matheus](https://github.com/victormatheusc) | |[Attachment Unique Mail](Attachment_Unique_Mail) |[Arnav Dandekar](https://github.com/4rnv) | |[Automated calendar](automated_calendar) | [J.A. HernΓ‘ndez](https://github.com/jesusalberto18) | @@ -121,6 +122,7 @@ So far, the following projects have been integrated to this repo: |[Remove-Duplicate-Files](Remove-Duplicate-Files)|[Aayushi Varma](https://github.com/aayuv17)| |[Rock-Paper-Scissor Game](https://github.com/hastagAB/Awesome-Python-Scripts/tree/master/Rock-Paper-Scissor)|[Punit Sakre](https://github.com/punitsakre23)| |[send_whatsapp_message](send_whatsapp_message)|[Mukesh Prasad](https://github.com/mukeshprasad)| +|[smart_code_reviewer](https://github.com/TheBinaryAVA/Awesome-Python-Scripts/tree/master/smart_code_reviewer)|[TheBinaryAVA](https://github.com/TheBinaryAVA)| |[Send messages to sqs in parallel](send_sqs_messages_in_parallel)|[Jinam Shah](https://github.com/jinamshah)| |[Server Ping](Ping_Server)|[prince]()| |[Signature photo to PNG converter](signature2png)|[Rodolfo Ferro](https://github.com/RodolfoFerro)| diff --git a/smart_code_reviewer/README.md b/smart_code_reviewer/README.md new file mode 100644 index 0000000..02186a3 --- /dev/null +++ b/smart_code_reviewer/README.md @@ -0,0 +1,258 @@ +# Smart Code Reviewer πŸ” +BY AVANTHIKA + +A zero-dependency Python static analysis tool that reviews your Python source +files and reports on **code quality**, **time-complexity patterns**, and +**best-practice violations** β€” all from the command line. + +Built entirely with the Python standard library (`ast`, `argparse`, `json`). + +--- + +## Features + +| Category | Check | +|---|---| +| **Complexity** | Nested loops (possible O(nΒ²)) | +| **Best Practices** | Missing module / class / function docstrings | +| **Best Practices** | `while` loops without obvious termination | +| **Best Practices** | Functions with too many arguments (>5) | +| **Style** | Long functions (>50 lines) | +| **Style** | Non-snake_case function names | + +Additional capabilities: + +- βœ… Analyse **multiple files** in one command +- βœ… **JSON output** mode (`--json`) for CI/CD pipelines +- βœ… **Adjustable thresholds** (`--max-lines`, `--max-args`) +- βœ… Colour output with `NO_COLOR` support +- βœ… Meaningful exit codes for scripting + +--- + +## Requirements + +- Python **3.8+** +- No external packages + +--- + +## Installation + +```bash +# Clone / download the project +git clone https://github.com/yourname/smart-code-reviewer.git +cd smart-code-reviewer + +# (Optional) make the CLI executable +chmod +x reviewer.py +``` + +No `pip install` step required. + +--- + +## Usage + +### Basic review + +```bash +python reviewer.py myfile.py +``` + +### Review multiple files + +```bash +python reviewer.py src/main.py src/utils.py src/models.py +``` + +### Glob expansion (shell feature) + +```bash +python reviewer.py src/*.py +``` + +### JSON output (great for CI) + +```bash +python reviewer.py myfile.py --json +``` + +### Custom thresholds + +```bash +# Flag functions longer than 30 lines, or with more than 4 args +python reviewer.py myfile.py --max-lines 30 --max-args 4 +``` + +### Disable colour (e.g. when piping output) + +```bash +python reviewer.py myfile.py --no-color +# or use the standard env var: +NO_COLOR=1 python reviewer.py myfile.py +``` + +### Full help + +```bash +python reviewer.py --help +``` + +--- + +## Example output + +``` +================================================================ + Smart Code Reviewer examples/sample_bad.py +================================================================ + Lines: 72 Functions: 4 Classes: 1 + Found 8 issues: 2 warnings, 3 warnings, 3 infos + + ── Complexity ────────────────────────────────────────────── + ⚠ WARNING line 18 [process_data] + Nested for-loop detected. This may indicate O(nΒ²) or worse time complexity. + + ── Best Practices ────────────────────────────────────────── + ⚠ WARNING line 12 [DataLoader] + Class is missing a docstring. + + ⚠ WARNING line 25 [process_data] + Public function is missing a docstring. + + ⚠ WARNING line 40 [connect] + Function has 6 arguments (threshold: 5). Consider using a config object or dataclass. + + β„Ή INFO line 34 + while-loop found. Ensure the termination condition is guaranteed to prevent infinite loops. + + ── Style ──────────────────────────────────────────────────── + ⚠ WARNING line 25 [process_data] + Function is 52 lines long (threshold: 50). + + β„Ή INFO line 60 [loadData] + Function name 'loadData' does not follow snake_case convention. + +================================================================ +``` + +### JSON output + +```json +[ + { + "filepath": "examples/sample_bad.py", + "summary": { + "total_lines": 72, + "total_functions": 4, + "total_classes": 1, + "total_issues": 7, + "errors": 0, + "warnings": 5, + "infos": 2 + }, + "findings": [ + { + "category": "Complexity", + "severity": "warning", + "line": 18, + "symbol": "process_data", + "message": "Nested for-loop detected. This may indicate O(nΒ²) or worse time complexity." + } + ] + } +] +``` + +--- + +## Running the tests + +```bash +# From the project root +python -m unittest discover -s tests -v +``` + +Expected output: + +``` +test_acceptable_arg_count_not_flagged (test_analyzer.TestTooManyArguments) ... ok +test_camel_case_function_flagged (test_analyzer.TestNamingConvention) ... ok +test_class_without_docstring_flagged (test_analyzer.TestMissingDocstrings) ... ok +... +---------------------------------------------------------------------- +Ran 18 tests in 0.042s + +OK +``` + +--- + +## Project structure + +``` +smart-code-reviewer/ +β”œβ”€β”€ reviewer.py # CLI entry point (argparse, exit codes) +β”œβ”€β”€ analyzer.py # Core AST visitor + data model +β”œβ”€β”€ utils.py # Colour output, report formatting, JSON helpers +β”œβ”€β”€ requirements.txt # No external deps β€” standard library only +β”œβ”€β”€ README.md +└── tests/ + └── test_analyzer.py # 18 unit tests covering all checks +``` + +--- + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | No issues found | +| `1` | Warnings / info found | +| `2` | Errors found | +| `3` | File read / parse failure | +| `4` | Bad CLI arguments | + +Useful for CI pipelines: + +```bash +python reviewer.py src/main.py || echo "Review failed" +``` + +--- + +## Configuration reference + +| Flag | Default | Description | +|---|---|---| +| `--json` | off | Output results as JSON | +| `--no-color` | off | Disable ANSI colour | +| `--max-lines N` | 50 | Max lines per function | +| `--max-args N` | 5 | Max function arguments | + +--- + +## Extending the tool + +All analysis logic lives in `analyzer.py` as an `ast.NodeVisitor` subclass. +To add a new check: + +1. Add a `visit_` method to `CodeAnalyzerVisitor`. +2. Call `self._add(category, severity, line, message)` to record a finding. +3. Add a corresponding unit test in `tests/test_analyzer.py`. + +--- + +## Contributing + +Pull requests are welcome. Please: +- Follow PEP 8 +- Add tests for any new check +- Keep zero external dependencies + +--- + +## License + +MIT Β© 2026 diff --git a/smart_code_reviewer/analyzer.cpython-312.pyc b/smart_code_reviewer/analyzer.cpython-312.pyc new file mode 100644 index 0000000..aa96f7c Binary files /dev/null and b/smart_code_reviewer/analyzer.cpython-312.pyc differ diff --git a/smart_code_reviewer/analyzer.py b/smart_code_reviewer/analyzer.py new file mode 100644 index 0000000..0d0df2a --- /dev/null +++ b/smart_code_reviewer/analyzer.py @@ -0,0 +1,330 @@ +""" +analyzer.py - Core AST analysis engine for Smart Code Reviewer. + +Uses Python's built-in `ast` module to walk the syntax tree and +collect structured findings across three categories: + - Complexity : nested loops, while-loop termination risk + - Best Practices: missing docstrings, too many arguments + - Style : long functions, naming conventions +""" + +import ast +from dataclasses import dataclass, field +from typing import List, Optional + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class Finding: + """Represents a single issue found during analysis.""" + + category: str # "Complexity" | "Best Practices" | "Style" + severity: str # "error" | "warning" | "info" + line: Optional[int] # Source line number (1-based), None for file-level + message: str # Human-readable description + symbol: str = "" # Function / class name (if applicable) + + def __str__(self) -> str: + loc = f"line {self.line}" if self.line else "file-level" + sym = f" [{self.symbol}]" if self.symbol else "" + return f" [{self.severity.upper():7s}] {loc}{sym}: {self.message}" + + +@dataclass +class AnalysisResult: + """Aggregated results for a single file.""" + + filepath: str + findings: List[Finding] = field(default_factory=list) + total_functions: int = 0 + total_classes: int = 0 + total_lines: int = 0 + + # Convenience helpers + def by_category(self, category: str) -> List[Finding]: + return [f for f in self.findings if f.category == category] + + def error_count(self) -> int: + return sum(1 for f in self.findings if f.severity == "error") + + def warning_count(self) -> int: + return sum(1 for f in self.findings if f.severity == "warning") + + def info_count(self) -> int: + return sum(1 for f in self.findings if f.severity == "info") + + +# --------------------------------------------------------------------------- +# Configuration (thresholds) +# --------------------------------------------------------------------------- + +class ReviewConfig: + """Centralised thresholds so they're easy to tune.""" + + MAX_FUNCTION_LINES: int = 50 + MAX_FUNCTION_ARGS: int = 5 + CATEGORIES = ("Complexity", "Best Practices", "Style") + + +# --------------------------------------------------------------------------- +# Visitor +# --------------------------------------------------------------------------- + +class CodeAnalyzerVisitor(ast.NodeVisitor): + """ + Walks an AST and accumulates findings. + + Call `.visit(tree)` then read `.findings`, `.func_count`, `.class_count`. + """ + + def __init__(self, source_lines: List[str], config: ReviewConfig): + self._lines = source_lines + self._cfg = config + self.findings: List[Finding] = [] + self.func_count: int = 0 + self.class_count: int = 0 + + # Tracks nesting depth so we can flag nested loops. + self._loop_depth: int = 0 + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _add( + self, + category: str, + severity: str, + line: Optional[int], + message: str, + symbol: str = "", + ) -> None: + self.findings.append( + Finding( + category=category, + severity=severity, + line=line, + message=message, + symbol=symbol, + ) + ) + + def _function_line_count(self, node: ast.FunctionDef) -> int: + """Return the number of source lines spanned by a function node.""" + return node.end_lineno - node.lineno + 1 # type: ignore[attr-defined] + + def _has_docstring(self, node: ast.AST) -> bool: + """Return True if the first statement is a string literal (docstring).""" + body = getattr(node, "body", []) + if body and isinstance(body[0], ast.Expr): + val = body[0].value + return isinstance(val, ast.Constant) and isinstance(val.value, str) + return False + + # ------------------------------------------------------------------ + # Visitors + # ------------------------------------------------------------------ + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # noqa: N802 + self._check_function(node) + self.generic_visit(node) + + # async defs get the same treatment + visit_AsyncFunctionDef = visit_FunctionDef # type: ignore[assignment] + + def visit_ClassDef(self, node: ast.ClassDef) -> None: # noqa: N802 + self.class_count += 1 + if not self._has_docstring(node): + self._add( + "Best Practices", + "warning", + node.lineno, + "Class is missing a docstring.", + symbol=node.name, + ) + self.generic_visit(node) + + def visit_For(self, node: ast.For) -> None: # noqa: N802 + self._check_loop(node, loop_type="for") + + def visit_While(self, node: ast.While) -> None: # noqa: N802 + self._check_loop(node, loop_type="while") + + # ------------------------------------------------------------------ + # Check helpers + # ------------------------------------------------------------------ + + def _check_function(self, node: ast.FunctionDef) -> None: + self.func_count += 1 + name = node.name + + # --- Style: long function --- + line_count = self._function_line_count(node) + if line_count > self._cfg.MAX_FUNCTION_LINES: + self._add( + "Style", + "warning", + node.lineno, + f"Function is {line_count} lines long " + f"(threshold: {self._cfg.MAX_FUNCTION_LINES}).", + symbol=name, + ) + + # --- Best Practices: missing docstring --- + if not self._has_docstring(node) and not name.startswith("_"): + self._add( + "Best Practices", + "warning", + node.lineno, + "Public function is missing a docstring.", + symbol=name, + ) + + # --- Best Practices: too many arguments --- + n_args = len(node.args.args) + if n_args > self._cfg.MAX_FUNCTION_ARGS: + self._add( + "Best Practices", + "warning", + node.lineno, + f"Function has {n_args} arguments " + f"(threshold: {self._cfg.MAX_FUNCTION_ARGS}). " + "Consider using a config object or dataclass.", + symbol=name, + ) + + # --- Style: naming convention (should be snake_case) --- + if not _is_snake_case(name) and not name.startswith("__"): + self._add( + "Style", + "info", + node.lineno, + f"Function name '{name}' does not follow snake_case convention.", + symbol=name, + ) + + def _check_loop(self, node: ast.AST, loop_type: str) -> None: + if self._loop_depth > 0: + # We're already inside a loop β€” this is a nested loop. + self._add( + "Complexity", + "warning", + node.lineno, # type: ignore[attr-defined] + f"Nested {loop_type}-loop detected. " + "This may indicate O(nΒ²) or worse time complexity.", + ) + + if loop_type == "while": + self._add( + "Best Practices", + "info", + node.lineno, # type: ignore[attr-defined] + "while-loop found. Ensure the termination condition is " + "guaranteed to prevent infinite loops.", + ) + + # Recurse with incremented depth. + self._loop_depth += 1 + self.generic_visit(node) + self._loop_depth -= 1 + + +# --------------------------------------------------------------------------- +# Module-level checks (run on the whole tree, not per-node) +# --------------------------------------------------------------------------- + +def _check_module_docstring(tree: ast.Module) -> Optional[Finding]: + """Return a finding if the module lacks a module-level docstring.""" + body = tree.body + if body and isinstance(body[0], ast.Expr): + val = body[0].value + if isinstance(val, ast.Constant) and isinstance(val.value, str): + return None # has docstring + return Finding( + category="Best Practices", + severity="info", + line=None, + message="Module is missing a module-level docstring.", + ) + + +# --------------------------------------------------------------------------- +# Utility +# --------------------------------------------------------------------------- + +def _is_snake_case(name: str) -> bool: + """ + Return True when *name* looks like valid Python snake_case. + + Dunder methods like __init__ are excluded by the caller. + """ + return name == name.lower() and not name[0].isdigit() + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +class FileAnalyzer: + """ + High-level interface: parse a file and return an :class:`AnalysisResult`. + + Example:: + + result = FileAnalyzer("mymodule.py").analyze() + for finding in result.findings: + print(finding) + """ + + def __init__(self, filepath: str, config: Optional[ReviewConfig] = None): + self.filepath = filepath + self.config = config or ReviewConfig() + + def analyze(self) -> AnalysisResult: + """ + Read, parse, and analyse the target file. + + Raises: + FileNotFoundError: if the file path does not exist. + SyntaxError: if the file contains invalid Python syntax. + OSError: for other I/O related errors. + """ + source = self._read_source() + tree = self._parse(source) + source_lines = source.splitlines() + + result = AnalysisResult( + filepath=self.filepath, + total_lines=len(source_lines), + ) + + # Module-level docstring check + mod_finding = _check_module_docstring(tree) + if mod_finding: + result.findings.append(mod_finding) + + # Walk AST + visitor = CodeAnalyzerVisitor(source_lines, self.config) + visitor.visit(tree) + + result.findings.extend(visitor.findings) + result.total_functions = visitor.func_count + result.total_classes = visitor.class_count + + return result + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _read_source(self) -> str: + """Read the file and return its content as a string.""" + with open(self.filepath, "r", encoding="utf-8") as fh: + return fh.read() + + def _parse(self, source: str) -> ast.Module: + """Parse source into an AST, raising SyntaxError on failure.""" + return ast.parse(source, filename=self.filepath) diff --git a/smart_code_reviewer/requirements.txt b/smart_code_reviewer/requirements.txt new file mode 100644 index 0000000..b0d40e9 --- /dev/null +++ b/smart_code_reviewer/requirements.txt @@ -0,0 +1,14 @@ +# Smart Code Reviewer β€” Python dependencies +# +# This tool uses ONLY the Python standard library. +# No third-party packages are required. +# +# Minimum Python version: 3.8 +# (ast.FunctionDef.end_lineno was added in 3.8) +# +# To run tests: +# python -m unittest discover -s tests +# +# Optional (for development / linting): +# pycodestyle>=2.11 +# mypy>=1.9 diff --git a/smart_code_reviewer/reviewer.py b/smart_code_reviewer/reviewer.py new file mode 100644 index 0000000..ed73a67 --- /dev/null +++ b/smart_code_reviewer/reviewer.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +reviewer.py - CLI entry point for Smart Code Reviewer. + +Usage examples: + python reviewer.py myfile.py + python reviewer.py src/main.py src/utils.py + python reviewer.py *.py --json + python reviewer.py myfile.py --no-color +""" + +import argparse +import os +import sys + +# Allow running from any working directory (add package root to path). +sys.path.insert(0, os.path.dirname(__file__)) + +from analyzer import FileAnalyzer, ReviewConfig +from utils import print_json, print_multi_summary, print_report + + +# --------------------------------------------------------------------------- +# Exit codes (follow UNIX conventions) +# --------------------------------------------------------------------------- + +EXIT_OK = 0 # No issues found +EXIT_ISSUES = 1 # Issues found (warnings / infos) +EXIT_ERRORS = 2 # Hard errors found +EXIT_IO_ERROR = 3 # File read / parse failure +EXIT_USAGE_ERROR = 4 # Bad CLI arguments + + +# --------------------------------------------------------------------------- +# CLI definition +# --------------------------------------------------------------------------- + +def build_parser() -> argparse.ArgumentParser: + """Return the configured argument parser.""" + parser = argparse.ArgumentParser( + prog="reviewer", + description=( + "Smart Code Reviewer β€” static analysis tool for Python files.\n" + "Analyses code quality, time complexity heuristics, and best-practice violations." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +examples: + python reviewer.py myfile.py + python reviewer.py src/a.py src/b.py + python reviewer.py myfile.py --json + python reviewer.py myfile.py --max-lines 30 --max-args 4 + """, + ) + + parser.add_argument( + "files", + metavar="FILE", + nargs="+", + help="One or more Python source files to analyse.", + ) + + parser.add_argument( + "--json", + action="store_true", + default=False, + help="Output results as JSON instead of the human-readable report.", + ) + + parser.add_argument( + "--no-color", + action="store_true", + default=False, + help="Disable ANSI colour output.", + ) + + parser.add_argument( + "--max-lines", + metavar="N", + type=int, + default=ReviewConfig.MAX_FUNCTION_LINES, + help=( + f"Maximum lines per function before a style warning is raised " + f"(default: {ReviewConfig.MAX_FUNCTION_LINES})." + ), + ) + + parser.add_argument( + "--max-args", + metavar="N", + type=int, + default=ReviewConfig.MAX_FUNCTION_ARGS, + help=( + f"Maximum function arguments before a best-practice warning is raised " + f"(default: {ReviewConfig.MAX_FUNCTION_ARGS})." + ), + ) + + return parser + + +# --------------------------------------------------------------------------- +# Core runner +# --------------------------------------------------------------------------- + +def run(args: argparse.Namespace) -> int: + """ + Execute the review for all specified files. + + Returns an exit code (see module-level constants). + """ + # Honour --no-color by patching the environment variable that utils.py + # respects without needing to pass state through function arguments. + if args.no_color: + os.environ["NO_COLOR"] = "1" + + # Build shared config from CLI flags. + config = ReviewConfig() + config.MAX_FUNCTION_LINES = args.max_lines + config.MAX_FUNCTION_ARGS = args.max_args + + results = [] + had_io_error = False + + for filepath in args.files: + # Validate extension (allow but warn on non-.py files). + if not filepath.endswith(".py"): + _warn(f"'{filepath}' does not appear to be a Python file. Skipping.") + continue + + try: + analyzer = FileAnalyzer(filepath, config=config) + result = analyzer.analyze() + results.append(result) + + except FileNotFoundError: + _error(f"File not found: {filepath}") + had_io_error = True + + except SyntaxError as exc: + _error( + f"Syntax error in '{filepath}' at line {exc.lineno}: {exc.msg}" + ) + had_io_error = True + + except OSError as exc: + _error(f"Cannot read '{filepath}': {exc.strerror}") + had_io_error = True + + if not results: + _error("No files were successfully analysed.") + return EXIT_IO_ERROR if had_io_error else EXIT_USAGE_ERROR + + # ---- Output ---- + if args.json: + print_json(results) + else: + for result in results: + print_report(result) + if len(results) > 1: + print_multi_summary(results) + + # ---- Determine exit code ---- + if had_io_error: + return EXIT_IO_ERROR + + total_errors = sum(r.error_count() for r in results) + if total_errors: + return EXIT_ERRORS + + total_issues = sum(len(r.findings) for r in results) + if total_issues: + return EXIT_ISSUES + + return EXIT_OK + + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + +def _warn(message: str) -> None: + print(f"[WARNING] {message}", file=sys.stderr) + + +def _error(message: str) -> None: + print(f"[ERROR] {message}", file=sys.stderr) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + sys.exit(run(args)) + + +if __name__ == "__main__": + main() diff --git a/smart_code_reviewer/test_analyzer.cpython-312.pyc b/smart_code_reviewer/test_analyzer.cpython-312.pyc new file mode 100644 index 0000000..6eaa4c5 Binary files /dev/null and b/smart_code_reviewer/test_analyzer.cpython-312.pyc differ diff --git a/smart_code_reviewer/test_analyzer.py b/smart_code_reviewer/test_analyzer.py new file mode 100644 index 0000000..7450df0 --- /dev/null +++ b/smart_code_reviewer/test_analyzer.py @@ -0,0 +1,313 @@ +""" +tests/test_analyzer.py - Unit tests for Smart Code Reviewer. + +Run with: + python -m unittest discover -s tests + # or directly: + python tests/test_analyzer.py +""" + +import os +import sys +import textwrap +import unittest + +# Ensure the package root is importable regardless of where tests are run. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from analyzer import FileAnalyzer, ReviewConfig + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + +def _analyze_source(source: str, **config_kwargs) -> list: + """ + Parse *source* from a string and return the list of findings. + + Writes a temporary file because :class:`FileAnalyzer` works on paths. + Uses a fixed temporary file name that is cleaned up after each test. + """ + import tempfile + + config = ReviewConfig() + for key, value in config_kwargs.items(): + setattr(config, key.upper(), value) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False, encoding="utf-8" + ) as tmp: + tmp.write(textwrap.dedent(source)) + tmp_path = tmp.name + + try: + result = FileAnalyzer(tmp_path, config=config).analyze() + return result.findings + finally: + os.unlink(tmp_path) + + +def _categories(findings) -> list: + return [f.category for f in findings] + + +def _messages(findings) -> list: + return [f.message for f in findings] + + +def _severities(findings) -> list: + return [f.severity for f in findings] + + +# --------------------------------------------------------------------------- +# Test cases +# --------------------------------------------------------------------------- + +class TestMissingDocstrings(unittest.TestCase): + """Missing docstring checks (Best Practices).""" + + def test_function_without_docstring_flagged(self): + source = """\ + def add(a, b): + return a + b + """ + findings = _analyze_source(source) + self.assertTrue( + any("docstring" in m.lower() for m in _messages(findings)), + "Expected a missing-docstring finding.", + ) + + def test_function_with_docstring_not_flagged(self): + source = """\ + def add(a, b): + \"\"\"Return the sum of a and b.\"\"\" + return a + b + """ + findings = _analyze_source(source) + # Only check function-level docstring findings (exclude file-level) + bp_msgs = [ + f.message for f in findings + if f.category == "Best Practices" + and "docstring" in f.message.lower() + and f.line is not None # file-level findings have line=None + ] + self.assertEqual(bp_msgs, [], "Clean function should not be flagged.") + + def test_private_function_docstring_not_required(self): + source = """\ + def _helper(x): + return x * 2 + """ + findings = _analyze_source(source) + bp_msgs = [ + f.message for f in findings + if "docstring" in f.message.lower() + and f.line is not None # exclude file-level module finding + ] + self.assertEqual(bp_msgs, [], "Private functions should not require a docstring.") + + def test_class_without_docstring_flagged(self): + source = """\ + class MyClass: + pass + """ + findings = _analyze_source(source) + self.assertTrue( + any("docstring" in m.lower() for m in _messages(findings)) + ) + + +class TestLongFunctions(unittest.TestCase): + """Long function detection (Style).""" + + def test_long_function_flagged(self): + # 10 real lines + 1 def + 1 docstring = 12 lines, but we force threshold low. + body = "\n".join(f" x_{i} = {i}" for i in range(10)) + source = f'def long_func():\n """Docstring."""\n{body}\n' + findings = _analyze_source(source, max_function_lines=5) + style_msgs = [f for f in findings if f.category == "Style"] + self.assertTrue( + any("lines long" in m.message for m in style_msgs), + "Expected a 'lines long' style finding.", + ) + + def test_short_function_not_flagged(self): + source = """\ + def short(): + \"\"\"Docstring.\"\"\" + return 1 + """ + findings = _analyze_source(source, max_function_lines=50) + style_msgs = [f for f in findings if f.category == "Style" and "lines long" in f.message] + self.assertEqual(style_msgs, []) + + +class TestTooManyArguments(unittest.TestCase): + """Excessive argument count detection (Best Practices).""" + + def test_too_many_args_flagged(self): + source = """\ + def func(a, b, c, d, e, f): + \"\"\"Six args.\"\"\" + pass + """ + findings = _analyze_source(source, max_function_args=5) + self.assertTrue( + any("arguments" in m.lower() for m in _messages(findings)) + ) + + def test_acceptable_arg_count_not_flagged(self): + source = """\ + def func(a, b, c): + \"\"\"Three args β€” fine.\"\"\" + pass + """ + findings = _analyze_source(source, max_function_args=5) + self.assertFalse( + any("arguments" in m.lower() for m in _messages(findings)) + ) + + +class TestNestedLoops(unittest.TestCase): + """Nested loop / complexity detection.""" + + def test_nested_for_loops_flagged(self): + source = """\ + def process(data): + \"\"\"Nested loops.\"\"\" + for i in data: + for j in data: + pass + """ + findings = _analyze_source(source) + complexity_msgs = [f for f in findings if f.category == "Complexity"] + self.assertTrue( + any("nested" in m.message.lower() for m in complexity_msgs), + "Expected a nested-loop finding.", + ) + + def test_single_loop_not_flagged(self): + source = """\ + def process(data): + \"\"\"Single loop.\"\"\" + for i in data: + pass + """ + findings = _analyze_source(source) + complexity_msgs = [f for f in findings if f.category == "Complexity"] + self.assertEqual(complexity_msgs, []) + + def test_nested_while_loop_flagged(self): + source = """\ + def process(n): + \"\"\"Nested while.\"\"\" + i = 0 + while i < n: + j = 0 + while j < n: + j += 1 + i += 1 + """ + findings = _analyze_source(source) + complexity_msgs = [f for f in findings if f.category == "Complexity"] + self.assertTrue(len(complexity_msgs) >= 1) + + +class TestWhileLoopWarning(unittest.TestCase): + """While loop termination warnings.""" + + def test_while_loop_info_emitted(self): + source = """\ + def run(): + \"\"\"Has a while loop.\"\"\" + i = 0 + while i < 10: + i += 1 + """ + findings = _analyze_source(source) + bp_msgs = [f for f in findings if f.category == "Best Practices"] + self.assertTrue( + any("while" in m.message.lower() for m in bp_msgs) + ) + + +class TestNamingConvention(unittest.TestCase): + """snake_case convention checks (Style).""" + + def test_camel_case_function_flagged(self): + source = """\ + def myFunction(): + \"\"\"Not snake_case.\"\"\" + pass + """ + findings = _analyze_source(source) + style_msgs = [f for f in findings if f.category == "Style"] + self.assertTrue( + any("snake_case" in m.message for m in style_msgs) + ) + + def test_snake_case_function_not_flagged(self): + source = """\ + def my_function(): + \"\"\"Proper snake_case.\"\"\" + pass + """ + findings = _analyze_source(source) + style_msgs = [ + f for f in findings + if f.category == "Style" and "snake_case" in f.message + ] + self.assertEqual(style_msgs, []) + + +class TestErrorHandling(unittest.TestCase): + """Error handling β€” FileAnalyzer should raise standard exceptions.""" + + def test_file_not_found(self): + with self.assertRaises(FileNotFoundError): + FileAnalyzer("/nonexistent/path/to/file.py").analyze() + + def test_syntax_error(self): + import tempfile + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False, encoding="utf-8" + ) as tmp: + tmp.write("def broken(\n") + tmp_path = tmp.name + + try: + with self.assertRaises(SyntaxError): + FileAnalyzer(tmp_path).analyze() + finally: + os.unlink(tmp_path) + + +class TestModuleDocstring(unittest.TestCase): + """Module-level docstring check.""" + + def test_module_without_docstring_flagged(self): + source = """\ + x = 1 + """ + findings = _analyze_source(source) + file_level = [f for f in findings if f.line is None] + self.assertTrue(len(file_level) >= 1) + + def test_module_with_docstring_not_flagged(self): + source = """\ + \"\"\"My module docstring.\"\"\" + x = 1 + """ + findings = _analyze_source(source) + file_level = [f for f in findings if f.line is None] + self.assertEqual(file_level, []) + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/smart_code_reviewer/utils.cpython-312.pyc b/smart_code_reviewer/utils.cpython-312.pyc new file mode 100644 index 0000000..86a868c Binary files /dev/null and b/smart_code_reviewer/utils.cpython-312.pyc differ diff --git a/smart_code_reviewer/utils.py b/smart_code_reviewer/utils.py new file mode 100644 index 0000000..bb671e8 --- /dev/null +++ b/smart_code_reviewer/utils.py @@ -0,0 +1,252 @@ +""" +utils.py - Helper utilities for Smart Code Reviewer. + +Contains: + - ANSI colour helpers for terminal output + - JSON serialisation helpers + - Summary statistics builder +""" + +import json +import os +import sys +from typing import Dict, List + +from analyzer import AnalysisResult, Finding + + +# --------------------------------------------------------------------------- +# Terminal colour support +# --------------------------------------------------------------------------- + +# Respect NO_COLOR env var (https://no-color.org/) and non-TTY streams. +_COLOUR_ENABLED: bool = ( + sys.stdout.isatty() + and os.environ.get("NO_COLOR") is None + and os.environ.get("TERM") != "dumb" +) + + +class Colour: + """ANSI escape codes used throughout the reporter.""" + + RESET = "\033[0m" + BOLD = "\033[1m" + DIM = "\033[2m" + + RED = "\033[91m" + YELLOW = "\033[93m" + CYAN = "\033[96m" + GREEN = "\033[92m" + MAGENTA = "\033[95m" + WHITE = "\033[97m" + GREY = "\033[90m" + + @staticmethod + def apply(code: str, text: str) -> str: + """Wrap *text* in *code* if colour output is enabled.""" + if not _COLOUR_ENABLED: + return text + return f"{code}{text}{Colour.RESET}" + + # Convenience shortcuts + @staticmethod + def bold(text: str) -> str: + return Colour.apply(Colour.BOLD, text) + + @staticmethod + def red(text: str) -> str: + return Colour.apply(Colour.RED, text) + + @staticmethod + def yellow(text: str) -> str: + return Colour.apply(Colour.YELLOW, text) + + @staticmethod + def cyan(text: str) -> str: + return Colour.apply(Colour.CYAN, text) + + @staticmethod + def green(text: str) -> str: + return Colour.apply(Colour.GREEN, text) + + @staticmethod + def grey(text: str) -> str: + return Colour.apply(Colour.GREY, text) + + @staticmethod + def magenta(text: str) -> str: + return Colour.apply(Colour.MAGENTA, text) + + +# --------------------------------------------------------------------------- +# Severity helpers +# --------------------------------------------------------------------------- + +_SEVERITY_COLOUR: Dict[str, str] = { + "error": Colour.RED, + "warning": Colour.YELLOW, + "info": Colour.CYAN, +} + +_SEVERITY_ICON: Dict[str, str] = { + "error": "βœ–", + "warning": "⚠", + "info": "β„Ή", +} + + +def format_severity(severity: str) -> str: + """Return a coloured, icon-prefixed severity label.""" + icon = _SEVERITY_ICON.get(severity, "Β·") + colour = _SEVERITY_COLOUR.get(severity, "") + label = f"{icon} {severity.upper()}" + return Colour.apply(colour, label) + + +# --------------------------------------------------------------------------- +# Report formatting +# --------------------------------------------------------------------------- + +CATEGORIES = ("Complexity", "Best Practices", "Style") + +_CATEGORY_COLOUR: Dict[str, str] = { + "Complexity": Colour.MAGENTA, + "Best Practices": Colour.CYAN, + "Style": Colour.YELLOW, +} + + +def format_finding(finding: Finding) -> str: + """Format a single :class:`Finding` as a human-readable string.""" + sev_label = format_severity(finding.severity) + loc = ( + Colour.grey(f"line {finding.line}") + if finding.line + else Colour.grey("file-level") + ) + sym = ( + f" {Colour.bold(finding.symbol)}" if finding.symbol else "" + ) + return f" {sev_label:<22} {loc}{sym}\n {finding.message}" + + +def print_report(result: AnalysisResult) -> None: + """Print a full human-readable report for one :class:`AnalysisResult`.""" + # ---- Header ---- + print() + print(Colour.bold("=" * 64)) + print( + Colour.bold(" Smart Code Reviewer") + + " " + + Colour.grey(result.filepath) + ) + print(Colour.bold("=" * 64)) + + # ---- Stats line ---- + stats = ( + f" {Colour.grey('Lines:')} {result.total_lines}" + f" {Colour.grey('Functions:')} {result.total_functions}" + f" {Colour.grey('Classes:')} {result.total_classes}" + ) + print(stats) + + total = len(result.findings) + if total == 0: + print() + print(Colour.green(" βœ” No issues found. Great work!")) + print() + return + + # Summary counts + errors = result.error_count() + warnings = result.warning_count() + infos = result.info_count() + summary_parts = [] + if errors: + summary_parts.append(Colour.red(f"{errors} error{'s' if errors > 1 else ''}")) + if warnings: + summary_parts.append(Colour.yellow(f"{warnings} warning{'s' if warnings > 1 else ''}")) + if infos: + summary_parts.append(Colour.cyan(f"{infos} info")) + + print(f" Found {total} issue{'s' if total > 1 else ''}: {', '.join(summary_parts)}") + + # ---- Category sections ---- + for category in CATEGORIES: + findings = result.by_category(category) + if not findings: + continue + + cat_colour = _CATEGORY_COLOUR.get(category, "") + print() + print(Colour.apply(cat_colour, Colour.bold(f" ── {category} "))) + print(Colour.apply(cat_colour, " " + "─" * 58)) + for finding in findings: + print(format_finding(finding)) + print() + + # ---- Footer ---- + print(Colour.bold("=" * 64)) + print() + + +def print_multi_summary(results: List[AnalysisResult]) -> None: + """Print a brief aggregate summary when multiple files are reviewed.""" + if len(results) <= 1: + return + + total_issues = sum(len(r.findings) for r in results) + total_errors = sum(r.error_count() for r in results) + total_warns = sum(r.warning_count() for r in results) + + print() + print(Colour.bold("═" * 64)) + print(Colour.bold(" Aggregate Summary")) + print(Colour.bold("═" * 64)) + print(f" Files analysed : {len(results)}") + print(f" Total issues : {total_issues}") + print( + f" Errors : {Colour.red(str(total_errors))}" + f" Warnings : {Colour.yellow(str(total_warns))}" + ) + clean = sum(1 for r in results if len(r.findings) == 0) + print(f" Clean files : {Colour.green(str(clean))}") + print(Colour.bold("═" * 64)) + print() + + +# --------------------------------------------------------------------------- +# JSON serialisation +# --------------------------------------------------------------------------- + +def result_to_dict(result: AnalysisResult) -> dict: + """Convert an :class:`AnalysisResult` to a plain dict (JSON-serialisable).""" + return { + "filepath": result.filepath, + "summary": { + "total_lines": result.total_lines, + "total_functions": result.total_functions, + "total_classes": result.total_classes, + "total_issues": len(result.findings), + "errors": result.error_count(), + "warnings": result.warning_count(), + "infos": result.info_count(), + }, + "findings": [ + { + "category": f.category, + "severity": f.severity, + "line": f.line, + "symbol": f.symbol, + "message": f.message, + } + for f in result.findings + ], + } + + +def print_json(results: List[AnalysisResult]) -> None: + """Serialise results to JSON and write to stdout.""" + payload = [result_to_dict(r) for r in results] + print(json.dumps(payload, indent=2))