Skip to content
50 changes: 46 additions & 4 deletions refactron/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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}'")
2 changes: 1 addition & 1 deletion refactron/cli/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions refactron/cli/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
{
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
21 changes: 21 additions & 0 deletions refactron/core/logging_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
182 changes: 137 additions & 45 deletions refactron/rag/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def parse_file(self, file_path: Path) -> ParsedFile:
"""Parse a Python file.
Expand All @@ -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
Expand Down
Loading
Loading