diff --git a/refactron/__init__.py b/refactron/__init__.py index a116b3f..2f9d3b9 100644 --- a/refactron/__init__.py +++ b/refactron/__init__.py @@ -4,10 +4,7 @@ A powerful Python library for code refactoring, optimization, and technical debt elimination. """ -from refactron.core.analysis_result import AnalysisResult -from refactron.core.exceptions import AnalysisError, ConfigError, RefactoringError, RefactronError -from refactron.core.refactor_result import RefactorResult -from refactron.core.refactron import Refactron +from typing import TYPE_CHECKING, Any __version__ = "1.0.15" __author__ = "Om Sherikar" @@ -21,3 +18,48 @@ "RefactoringError", "ConfigError", ] + +if TYPE_CHECKING: + from refactron.core.analysis_result import AnalysisResult + from refactron.core.exceptions import ( + AnalysisError, + ConfigError, + RefactoringError, + RefactronError, + ) + from refactron.core.refactor_result import RefactorResult + from refactron.core.refactron import Refactron + + +def __getattr__(name: str) -> Any: + """ + Lazily load heavy public symbols. + This keeps lightweight CLI paths (e.g. `--version`) fast and side-effect free. + """ + if name == "Refactron": + from refactron.core.refactron import Refactron + + return Refactron + if name == "AnalysisResult": + from refactron.core.analysis_result import AnalysisResult + + return AnalysisResult + if name == "RefactorResult": + from refactron.core.refactor_result import RefactorResult + + return RefactorResult + if name in {"RefactronError", "AnalysisError", "RefactoringError", "ConfigError"}: + from refactron.core.exceptions import ( + AnalysisError, + ConfigError, + RefactoringError, + RefactronError, + ) + + return { + "RefactronError": RefactronError, + "AnalysisError": AnalysisError, + "RefactoringError": RefactoringError, + "ConfigError": ConfigError, + }[name] + raise AttributeError(f"module 'refactron' has no attribute '{name}'") diff --git a/refactron/cli/analysis.py b/refactron/cli/analysis.py index 17a3a5c..dd29556 100644 --- a/refactron/cli/analysis.py +++ b/refactron/cli/analysis.py @@ -263,7 +263,7 @@ def report( output_path = Path(output) output_path.parent.mkdir(parents=True, exist_ok=True) - with open(output_path, "w") as f: + with open(output_path, "w", encoding="utf-8") as f: f.write(report_content) file_size = output_path.stat().st_size diff --git a/refactron/cli/ui.py b/refactron/cli/ui.py index b148198..a27f902 100644 --- a/refactron/cli/ui.py +++ b/refactron/cli/ui.py @@ -7,7 +7,7 @@ import random import sys import time -from typing import Any, Optional +from typing import TYPE_CHECKING, Any, Optional import click from rich import box @@ -20,10 +20,13 @@ from rich.text import Text from rich.theme import Theme -from refactron import Refactron, __version__ +from refactron import __version__ from refactron.core.analysis_result import AnalysisResult from refactron.core.refactor_result import RefactorResult +if TYPE_CHECKING: + from refactron import Refactron + # Custom theme for a premium, modern look THEME = Theme( { @@ -250,7 +253,7 @@ def _print_refactor_messages(summary: dict, preview: bool) -> None: console.print("\n[success]Refactoring completed! Don't forget to test your code.[/success]") -def _collect_feedback_interactive(refactron: Refactron, result: RefactorResult) -> None: +def _collect_feedback_interactive(refactron: "Refactron", result: RefactorResult) -> None: """ Collect feedback from user interactively for each refactoring operation. @@ -301,7 +304,7 @@ def _collect_feedback_interactive(refactron: Refactron, result: RefactorResult) console.print("\n[success]Thank you for your feedback![/success]") -def _record_applied_operations(refactron: Refactron, result: RefactorResult) -> None: +def _record_applied_operations(refactron: "Refactron", result: RefactorResult) -> None: """ Automatically record all operations as accepted when --apply is used. diff --git a/refactron/core/logging_config.py b/refactron/core/logging_config.py index 02d0af6..453fb03 100644 --- a/refactron/core/logging_config.py +++ b/refactron/core/logging_config.py @@ -139,6 +139,27 @@ def get_logger(self) -> logging.Logger: """ return self.logger + def close(self) -> None: + """Close and remove all handlers. + + On Windows, file handlers hold an exclusive lock on the log file. + Calling this method before deleting the log directory (e.g. in + tests that use ``tempfile.TemporaryDirectory``) prevents the + ``PermissionError: [WinError 32]`` raised by ``shutil.rmtree``. + """ + for handler in list(self.logger.handlers): + try: + handler.close() + except Exception: + pass + self.logger.removeHandler(handler) + + def __enter__(self) -> "StructuredLogger": + return self + + def __exit__(self, *_: object) -> None: + self.close() + def log_with_context( self, level: str, message: str, extra_data: Optional[Dict[str, Any]] = None ) -> None: diff --git a/refactron/rag/parser.py b/refactron/rag/parser.py index df982f8..13d0fb4 100644 --- a/refactron/rag/parser.py +++ b/refactron/rag/parser.py @@ -2,6 +2,8 @@ from __future__ import annotations +import os +import platform from dataclasses import dataclass from pathlib import Path from typing import List, Optional, Tuple @@ -59,54 +61,133 @@ def __init__(self) -> None: "Install with: pip install tree-sitter tree-sitter-python" ) - # Initialize Python language - handle different tree-sitter API versions + language = CodeParser._build_language() + self.parser = CodeParser._build_parser(language) + + @staticmethod + def _tree_sitter_minor_version() -> int: + """Return the minor version of the installed tree-sitter package.""" + import tree_sitter + + # Prefer __version__ attr; fall back to importlib.metadata. + version = getattr(tree_sitter, "__version__", None) + if version is None: + try: + from importlib.metadata import version as _pkg_version + + version = _pkg_version("tree-sitter") + except Exception: + version = "0.20.0" + parts = str(version).split(".") + if len(parts) < 2: + return 20 + try: + return int(parts[1]) + except ValueError: + return 20 + + @staticmethod + def _build_language() -> "Language": + """Build a tree-sitter ``Language`` for Python. + + Exhaustively probes every constructor signature known across + tree-sitter 0.20 – 0.23 so the parser works regardless of which + exact version is installed in CI or a user's environment. + """ lang = tspython.language() - # In some versions, tspython.language() already returns a Language object + # ── already a Language object (some 0.22+ binding builds) ────────── if isinstance(lang, Language): - PY_LANGUAGE = lang - else: - # Try newer API first (single argument) - try: - PY_LANGUAGE = Language(lang) - except TypeError: - # Try older API (needs name) + return lang + + # ── probe 1: Language(ptr_or_capsule, "python") (0.20 / 0.21) ──── + try: + return Language(lang, "python") + except Exception: + pass + + # ── probe 2: Language(ptr_or_capsule) (0.22+) ────────────────────── + try: + return Language(lang) + except Exception: + pass + + # ── probe 3: scan tspython package dir for a compiled grammar .so ── + # (needed on 0.20.x when Language() requires a .so path) + pkg_dir = os.path.dirname(tspython.__file__) + system = platform.system() + ext = ".dll" if system == "Windows" else (".dylib" if system == "Darwin" else ".so") + + for fname in sorted(os.listdir(pkg_dir)): + if fname.endswith(ext): + lib_path = os.path.join(pkg_dir, fname) try: - PY_LANGUAGE = Language(lang, "python") - except TypeError: - # Try using the path to the compiled library (for very old or CI bindings) - try: - import os - import platform - - pkg_dir = os.path.dirname(tspython.__file__) - - # Find the correct shared library extension - system = platform.system() - if system == "Windows": - ext = ".dll" - elif system == "Darwin": - ext = ".dylib" - else: - ext = ".so" - - # Look for common names of the compiled language file - lib_path = None - for fname in os.listdir(pkg_dir): - if fname.endswith(ext): - lib_path = os.path.join(pkg_dir, fname) - break - - if lib_path: - PY_LANGUAGE = Language(lib_path, "python") - else: - # Last resort: try as keyword or whatever lang is - PY_LANGUAGE = Language(lang, name="python") - except Exception: - # Absolute last resort - PY_LANGUAGE = Language(lang, name="python") - - self.parser = Parser(PY_LANGUAGE) + return Language(lib_path, "python") + except Exception: + continue + + raise RuntimeError( + "Could not construct tree-sitter Language for Python " + f"(tree-sitter minor version: {CodeParser._tree_sitter_minor_version()}). " + "Try: pip install --upgrade 'tree-sitter>=0.21.3,<0.22' " + "'tree-sitter-python>=0.21.0,<0.22'" + ) + + @staticmethod + def _parser_works(parser: "Parser") -> bool: + """Return True if the parser can successfully parse trivial Python code. + + tree-sitter 0.21.x raises ``ValueError`` when no language is set; + 0.22+ returns ``None``. Both are handled here. + """ + try: + result = parser.parse(b"x = 1") + return result is not None + except Exception: + return False + + @staticmethod + def _build_parser(language: "Language") -> "Parser": + """Construct a tree-sitter Parser for the given Language. + + Tries every constructor / configuration pattern known across + tree-sitter 0.20 – 0.23: + - 0.22+ ``Parser(language)`` + - 0.20/21 ``Parser()`` then ``parser.set_language(language)`` + - fallback ``parser.language = language`` (some patched builds) + """ + # ── probe 1: new-style constructor (0.22+) ─────────────────────────── + try: + p = Parser(language) + if CodeParser._parser_works(p): + return p + except Exception: + pass + + # ── probe 2: no-arg constructor + set_language() (0.20 / 0.21) ────── + try: + p = Parser() + p.set_language(language) + if CodeParser._parser_works(p): + return p + except Exception: + pass + + # ── probe 3: attribute assignment fallback ─────────────────────────── + try: + p = Parser() + p.language = language # type: ignore[attr-defined] + if CodeParser._parser_works(p): + return p + except Exception: + pass + + raise RuntimeError( + f"tree-sitter Parser could not be initialised " + f"(tree-sitter minor version: {CodeParser._tree_sitter_minor_version()}). " + "Try: pip install --upgrade 'tree-sitter>=0.21.3,<0.22' " + "'tree-sitter-python>=0.21.0,<0.22'" + ) def parse_file(self, file_path: Path) -> ParsedFile: """Parse a Python file. @@ -120,7 +201,18 @@ def parse_file(self, file_path: Path) -> ParsedFile: with open(file_path, "rb") as f: source_code = f.read() - tree = self.parser.parse(source_code) + # tree-sitter 0.22+ returns None on failure; 0.21.x raises ValueError. + # We normalise both to a single ValueError with an informative message. + try: + tree = self.parser.parse(source_code) + except (ValueError, RuntimeError) as exc: + raise ValueError(f"Parsing failed for file {file_path}: {exc}") from exc + + if tree is None: + raise ValueError( + f"Parsing failed for file {file_path}. " + "The file may contain syntax errors or use unsupported Python features." + ) root = tree.root_node # Extract module docstring diff --git a/tests/test_backup.py b/tests/test_backup.py index 125ba5d..c911f64 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -1,6 +1,8 @@ """Tests for the backup and rollback system.""" +import os import shutil +import stat import subprocess import tempfile from pathlib import Path @@ -11,6 +13,21 @@ from refactron.core.credentials import RefactronCredentials +def _rmtree_force(path: Path) -> None: + """Remove a directory tree, clearing read-only bits first (needed on Windows). + + Git marks object files read-only; plain ``shutil.rmtree`` raises + ``PermissionError: [WinError 5]`` when it tries to delete them. + The ``onexc`` / ``onerror`` callback clears the offending bit and retries. + """ + + def _handle_readonly(func, fpath, exc_info): # type: ignore[no-untyped-def] + os.chmod(fpath, stat.S_IWRITE) + func(fpath) + + shutil.rmtree(path, onerror=_handle_readonly) + + @pytest.fixture(autouse=True) def mock_auth(monkeypatch): """Mock authentication for all CLI tests.""" @@ -329,7 +346,7 @@ def git_repo(self): ) yield temp if temp.exists(): - shutil.rmtree(temp) + _rmtree_force(temp) def test_is_git_repo_true(self, git_repo): """Test is_git_repo returns True for actual repo.""" @@ -516,12 +533,19 @@ def test_rollback_help(self): assert result.exit_code == 0 assert "Rollback refactoring changes" in result.output - def test_rollback_list_empty(self): + def test_rollback_list_empty(self, monkeypatch): """Test rollback --list with no sessions.""" + from unittest.mock import MagicMock + from click.testing import CliRunner + import refactron.cli.refactor as refactor_mod from refactron.cli import main + mock_system = MagicMock() + mock_system.list_sessions.return_value = [] + monkeypatch.setattr(refactor_mod, "BackupRollbackSystem", lambda *a, **kw: mock_system) + runner = CliRunner() with runner.isolated_filesystem(): result = runner.invoke(main, ["rollback", "--list"]) @@ -529,12 +553,19 @@ def test_rollback_list_empty(self): assert result.exit_code == 0 assert "No backup sessions found" in result.output - def test_rollback_no_sessions(self): + def test_rollback_no_sessions(self, monkeypatch): """Test rollback with no sessions.""" + from unittest.mock import MagicMock + from click.testing import CliRunner + import refactron.cli.refactor as refactor_mod from refactron.cli import main + mock_system = MagicMock() + mock_system.list_sessions.return_value = [] + monkeypatch.setattr(refactor_mod, "BackupRollbackSystem", lambda *a, **kw: mock_system) + runner = CliRunner() with runner.isolated_filesystem(): result = runner.invoke(main, ["rollback"]) @@ -542,12 +573,20 @@ def test_rollback_no_sessions(self): assert result.exit_code == 0 assert "No backup sessions found" in result.output - def test_rollback_nonexistent_session(self): + def test_rollback_nonexistent_session(self, monkeypatch): """Test rollback with nonexistent session.""" + from unittest.mock import MagicMock + from click.testing import CliRunner + import refactron.cli.refactor as refactor_mod from refactron.cli import main + mock_system = MagicMock() + mock_system.list_sessions.return_value = [] + mock_system.backup_manager.get_session.return_value = None + monkeypatch.setattr(refactor_mod, "BackupRollbackSystem", lambda *a, **kw: mock_system) + runner = CliRunner() with runner.isolated_filesystem(): result = runner.invoke(main, ["rollback", "--session", "nonexistent"]) diff --git a/tests/test_cicd.py b/tests/test_cicd.py index da124dd..597a57b 100644 --- a/tests/test_cicd.py +++ b/tests/test_cicd.py @@ -302,7 +302,7 @@ def test_save_workflow(self) -> None: generator.save_workflow(workflow_content, output_path) assert output_path.exists() - assert "Refactron Code Analysis" in output_path.read_text() + assert "Refactron Code Analysis" in output_path.read_text(encoding="utf-8") class TestGitLabCIGenerator: @@ -337,7 +337,7 @@ def test_save_pipeline(self) -> None: generator.save_pipeline(pipeline_content, output_path) assert output_path.exists() - assert "analyze:" in output_path.read_text() + assert "analyze:" in output_path.read_text(encoding="utf-8") class TestPreCommitGenerator: @@ -370,10 +370,12 @@ def test_save_config(self) -> None: generator.save_config(config_content, output_path) assert output_path.exists() - assert "refactron" in output_path.read_text() + assert "refactron" in output_path.read_text(encoding="utf-8") def test_save_hook(self) -> None: """Test saving pre-commit hook script.""" + import sys + generator = PreCommitGenerator() hook_content = generator.generate_simple_hook() @@ -382,7 +384,9 @@ def test_save_hook(self) -> None: generator.save_hook(hook_content, output_path) assert output_path.exists() - assert output_path.stat().st_mode & 0o111 # Executable + # chmod(0o755) has no effect on Windows; skip the bit-check there. + if sys.platform != "win32": + assert output_path.stat().st_mode & 0o111 # Executable class TestPRIntegration: diff --git a/tests/test_logging.py b/tests/test_logging.py index 5703128..3bd56ae 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -90,35 +90,33 @@ def test_initialization_defaults(self): """Test logger initialization with defaults.""" with TemporaryDirectory() as tmpdir: log_file = Path(tmpdir) / "test.log" - logger = StructuredLogger( + with StructuredLogger( name="test", level="INFO", log_file=log_file, enable_console=False, enable_file=True, - ) - - assert logger.name == "test" - assert logger.level == logging.INFO - assert logger.log_file == log_file + ) as logger: + assert logger.name == "test" + assert logger.level == logging.INFO + assert logger.log_file == log_file def test_json_logging(self): """Test JSON format logging to file.""" with TemporaryDirectory() as tmpdir: log_file = Path(tmpdir) / "test.log" - logger = StructuredLogger( + with StructuredLogger( name="test", level="INFO", log_file=log_file, log_format="json", enable_console=False, enable_file=True, - ) - - logger.get_logger().info("Test message") + ) as logger: + logger.get_logger().info("Test message") - # Read and verify log file - with open(log_file, "r") as f: + # Read and verify log file (handler closed above) + with open(log_file, "r", encoding="utf-8") as f: log_line = f.read().strip() log_data = json.loads(log_line) @@ -129,19 +127,18 @@ def test_text_logging(self): """Test text format logging to file.""" with TemporaryDirectory() as tmpdir: log_file = Path(tmpdir) / "test.log" - logger = StructuredLogger( + with StructuredLogger( name="test", level="INFO", log_file=log_file, log_format="text", enable_console=False, enable_file=True, - ) + ) as logger: + logger.get_logger().info("Test text message") - logger.get_logger().info("Test text message") - - # Read and verify log file - with open(log_file, "r") as f: + # Read and verify log file (handler closed above) + with open(log_file, "r", encoding="utf-8") as f: log_line = f.read().strip() assert "Test text message" in log_line @@ -154,7 +151,7 @@ def test_log_rotation(self): max_bytes = 1024 backup_count = 3 - logger = StructuredLogger( + with StructuredLogger( name="test", level="INFO", log_file=log_file, @@ -162,18 +159,17 @@ def test_log_rotation(self): backup_count=backup_count, enable_console=False, enable_file=True, - ) - - # Verify handler is configured with rotation - file_handler = None - for handler in logger.get_logger().handlers: - if hasattr(handler, "maxBytes"): - file_handler = handler - break - - assert file_handler is not None - assert file_handler.maxBytes == max_bytes - assert file_handler.backupCount == backup_count + ) as logger: + # Verify handler is configured with rotation + file_handler = None + for handler in logger.get_logger().handlers: + if hasattr(handler, "maxBytes"): + file_handler = handler + break + + assert file_handler is not None + assert file_handler.maxBytes == max_bytes + assert file_handler.backupCount == backup_count def test_log_levels(self): """Test different log levels.""" @@ -181,21 +177,20 @@ def test_log_levels(self): log_file = Path(tmpdir) / "test.log" # Test with WARNING level - logger = StructuredLogger( + with StructuredLogger( name="test", level="WARNING", log_file=log_file, log_format="json", enable_console=False, enable_file=True, - ) - - logger.get_logger().info("Info message") - logger.get_logger().warning("Warning message") - logger.get_logger().error("Error message") + ) as logger: + logger.get_logger().info("Info message") + logger.get_logger().warning("Warning message") + logger.get_logger().error("Error message") - # Read log file - with open(log_file, "r") as f: + # Read log file (handler closed above) + with open(log_file, "r", encoding="utf-8") as f: lines = f.readlines() # Only WARNING and ERROR should be logged @@ -242,14 +237,13 @@ def test_setup_logging(): """Test setup_logging convenience function.""" with TemporaryDirectory() as tmpdir: log_file = Path(tmpdir) / "test.log" - logger = setup_logging( + with setup_logging( level="DEBUG", log_file=log_file, log_format="json", enable_console=False, enable_file=True, - ) - - assert isinstance(logger, StructuredLogger) - assert logger.level == logging.DEBUG - assert logger.log_file == log_file + ) as logger: + assert isinstance(logger, StructuredLogger) + assert logger.level == logging.DEBUG + assert logger.log_file == log_file diff --git a/tests/test_patterns_feedback.py b/tests/test_patterns_feedback.py index d72f926..d7674ff 100644 --- a/tests/test_patterns_feedback.py +++ b/tests/test_patterns_feedback.py @@ -358,11 +358,19 @@ def test_detect_project_root_finds_setup_py(self): root = refactron.detect_project_root(test_file) assert root.resolve() == project_dir.resolve() - def test_detect_project_root_fallback(self): - """Test that project root detection falls back to file parent.""" + def test_detect_project_root_fallback(self, monkeypatch): + """Test that project root detection falls back to file parent. + + The real filesystem walk can find a pyproject.toml / .git in an + ancestor directory (e.g. the developer's home or the repo root). + Patch ``Path.exists`` so that no marker is ever found, forcing the + fallback path to activate regardless of where tmp lives. + """ config = RefactronConfig() refactron = Refactron(config) + monkeypatch.setattr(Path, "exists", lambda self: False) + with tempfile.TemporaryDirectory() as tmpdir: test_file = Path(tmpdir) / "test.py" diff --git a/tests/test_rag_chunker.py b/tests/test_rag_chunker.py index 91aef78..7397a85 100644 --- a/tests/test_rag_chunker.py +++ b/tests/test_rag_chunker.py @@ -6,7 +6,23 @@ import pytest from refactron.rag.chunker import CodeChunk, CodeChunker -from refactron.rag.parser import CodeParser +from refactron.rag.parser import TREE_SITTER_AVAILABLE, CodeParser + + +def _tree_sitter_usable() -> bool: + if not TREE_SITTER_AVAILABLE: + return False + try: + CodeParser() + return True + except RuntimeError: + return False + + +pytestmark = pytest.mark.skipif( + not _tree_sitter_usable(), + reason="tree-sitter is not available or cannot be initialised in this environment", +) class TestCodeChunker: diff --git a/tests/test_rag_parser.py b/tests/test_rag_parser.py index 02e68b2..42ef9cb 100644 --- a/tests/test_rag_parser.py +++ b/tests/test_rag_parser.py @@ -5,7 +5,29 @@ import pytest -from refactron.rag.parser import CodeParser, ParsedClass, ParsedFile, ParsedFunction +from refactron.rag.parser import ( + TREE_SITTER_AVAILABLE, + CodeParser, + ParsedClass, + ParsedFile, + ParsedFunction, +) + + +def _tree_sitter_usable() -> bool: + if not TREE_SITTER_AVAILABLE: + return False + try: + CodeParser() + return True + except RuntimeError: + return False + + +pytestmark = pytest.mark.skipif( + not _tree_sitter_usable(), + reason="tree-sitter is not available or cannot be initialised in this environment", +) class TestCodeParser: