pyrefactor 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyrefactor/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """PyRefactor - A Python refactoring and optimization linter."""
2
+
3
+ __version__ = "1.0.1"
pyrefactor/__main__.py ADDED
@@ -0,0 +1,231 @@
1
+ """CLI entry point for PyRefactor."""
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from . import __version__
11
+ from .analyzer import Analyzer
12
+ from .config import Config
13
+ from .models import AnalysisResult, Severity
14
+ from .reporter import ConsoleReporter
15
+
16
+ # Configure logging
17
+ logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class Args:
23
+ """Type-safe argument namespace."""
24
+
25
+ paths: list[Path]
26
+ config: Optional[Path]
27
+ group_by: str
28
+ min_severity: str
29
+ jobs: int
30
+ verbose: bool
31
+ version: bool
32
+
33
+
34
+ # Class-level constant for severity mapping to avoid recreation
35
+ SEVERITY_MAP: dict[str, Severity] = {
36
+ "info": Severity.INFO,
37
+ "low": Severity.LOW,
38
+ "medium": Severity.MEDIUM,
39
+ "high": Severity.HIGH,
40
+ }
41
+
42
+
43
+ def _add_parser_arguments(parser: argparse.ArgumentParser) -> None:
44
+ """Add all arguments to the parser."""
45
+ parser.add_argument(
46
+ "paths", nargs="*", type=Path, help="Python files or directories to analyze"
47
+ )
48
+ parser.add_argument(
49
+ "-c",
50
+ "--config",
51
+ type=Path,
52
+ help="Path to configuration file (default: pyproject.toml)",
53
+ )
54
+ parser.add_argument(
55
+ "-g",
56
+ "--group-by",
57
+ choices=["file", "severity"],
58
+ default="file",
59
+ help="Group output by file or severity (default: file)",
60
+ )
61
+ parser.add_argument(
62
+ "--min-severity",
63
+ choices=["info", "low", "medium", "high"],
64
+ default="info",
65
+ help="Minimum severity level to report (default: info)",
66
+ )
67
+ parser.add_argument(
68
+ "-j", "--jobs", type=int, default=4, help="Number of parallel jobs (default: 4)"
69
+ )
70
+ parser.add_argument(
71
+ "-v", "--verbose", action="store_true", help="Enable verbose logging"
72
+ )
73
+ parser.add_argument("--version", action="store_true", help="Show version and exit")
74
+
75
+
76
+ def _create_argument_parser() -> argparse.ArgumentParser:
77
+ """Create and configure the argument parser."""
78
+ parser = argparse.ArgumentParser(
79
+ description="PyRefactor - A Python refactoring and optimization linter",
80
+ formatter_class=argparse.RawDescriptionHelpFormatter,
81
+ epilog="""
82
+ Examples:
83
+ pyrefactor myfile.py Analyze a single file
84
+ pyrefactor src/ Analyze all Python files in directory
85
+ pyrefactor file1.py file2.py Analyze multiple files
86
+ pyrefactor --config custom.toml . Analyze with custom config
87
+
88
+ Exit Codes:
89
+ 0 - No issues or only INFO/LOW severity issues
90
+ 1 - MEDIUM or HIGH severity issues found
91
+ 2 - Error during analysis
92
+ """,
93
+ )
94
+ _add_parser_arguments(parser)
95
+ return parser
96
+
97
+
98
+ def parse_arguments() -> Args:
99
+ """Parse command line arguments."""
100
+ parser = _create_argument_parser()
101
+ namespace = parser.parse_args()
102
+ # Convert to our typed class
103
+ # Note: argparse returns Any type for namespace attributes
104
+ return Args(
105
+ paths=namespace.paths, # type: ignore[misc]
106
+ config=namespace.config, # type: ignore[misc]
107
+ group_by=namespace.group_by, # type: ignore[misc]
108
+ min_severity=namespace.min_severity, # type: ignore[misc]
109
+ jobs=namespace.jobs, # type: ignore[misc]
110
+ verbose=namespace.verbose, # type: ignore[misc]
111
+ version=namespace.version, # type: ignore[misc]
112
+ )
113
+
114
+
115
+ def _handle_version(args: Args) -> Optional[int]:
116
+ """Handle version flag. Returns exit code if version flag is set, None otherwise."""
117
+ if args.version:
118
+ print(f"PyRefactor version {__version__}")
119
+ return 0
120
+ return None
121
+
122
+
123
+ def _configure_logging(args: Args) -> None:
124
+ """Configure logging based on command line arguments."""
125
+ if args.verbose:
126
+ logging.getLogger().setLevel(logging.INFO)
127
+ logger.setLevel(logging.INFO)
128
+
129
+
130
+ def _load_config(args: Args) -> Optional[Config]:
131
+ """Load configuration. Returns Config or None on error."""
132
+ try:
133
+ config = Config.load(args.config)
134
+ logger.info("Loaded configuration: %s", config)
135
+ return config
136
+ except Exception as e:
137
+ logger.error("Error loading configuration: %s", e)
138
+ return None
139
+
140
+
141
+ def _validate_paths(args: Args) -> Optional[list[Path]]:
142
+ """Validate paths from arguments. Returns list of paths or None on error."""
143
+ paths: list[Path] = []
144
+ for path in args.paths:
145
+ if not path.exists():
146
+ logger.error("Path does not exist: %s", path)
147
+ return None
148
+ paths.append(path)
149
+ return paths
150
+
151
+
152
+ def _analyze_files_safely(
153
+ analyzer: Analyzer, paths: list[Path]
154
+ ) -> Optional[AnalysisResult]:
155
+ """Analyze files and handle errors. Returns result or None on error."""
156
+ try:
157
+ logger.info("Analyzing %d path(s)...", len(paths))
158
+ return analyzer.analyze_files(paths)
159
+ except Exception as e:
160
+ logger.error("Error during analysis: %s", e)
161
+ return None
162
+
163
+
164
+ def _get_min_severity(severity_str: str) -> Severity:
165
+ """Get Severity enum from string."""
166
+ return SEVERITY_MAP[severity_str]
167
+
168
+
169
+ def _filter_by_severity(result: AnalysisResult, min_severity: Severity) -> None:
170
+ """Filter issues by minimum severity in-place."""
171
+ for file_analysis in result.file_analyses:
172
+ file_analysis.issues = [
173
+ issue for issue in file_analysis.issues if issue.severity >= min_severity
174
+ ]
175
+
176
+
177
+ def _has_critical_issues(result: AnalysisResult) -> bool:
178
+ """Check if result has HIGH or MEDIUM severity issues."""
179
+ return any(
180
+ issue.severity in (Severity.HIGH, Severity.MEDIUM)
181
+ for issue in result.get_all_issues()
182
+ )
183
+
184
+
185
+ def main() -> int:
186
+ """Main entry point."""
187
+ args = parse_arguments()
188
+
189
+ # Handle version
190
+ version_exit = _handle_version(args)
191
+ if version_exit is not None:
192
+ return version_exit
193
+
194
+ # Check if paths were provided
195
+ if not args.paths:
196
+ logger.error("No paths provided")
197
+ return 2
198
+
199
+ # Configure logging
200
+ _configure_logging(args)
201
+
202
+ # Load configuration
203
+ config = _load_config(args)
204
+ if config is None:
205
+ return 2
206
+
207
+ # Validate paths
208
+ paths = _validate_paths(args)
209
+ if paths is None:
210
+ return 2
211
+
212
+ # Create analyzer and analyze files
213
+ analyzer = Analyzer(config)
214
+ result = _analyze_files_safely(analyzer, paths)
215
+ if result is None:
216
+ return 2
217
+
218
+ # Filter by minimum severity
219
+ min_severity = _get_min_severity(args.min_severity)
220
+ _filter_by_severity(result, min_severity)
221
+
222
+ # Report results
223
+ reporter = ConsoleReporter()
224
+ reporter.report(result, group_by=args.group_by)
225
+
226
+ # Determine exit code
227
+ return 1 if _has_critical_issues(result) else 0
228
+
229
+
230
+ if __name__ == "__main__":
231
+ sys.exit(main())
pyrefactor/analyzer.py ADDED
@@ -0,0 +1,185 @@
1
+ """Main analyzer orchestrator for PyRefactor."""
2
+
3
+ import ast
4
+ import concurrent.futures
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from .ast_visitor import BaseDetector
9
+ from .config import Config
10
+ from .detectors import (
11
+ BooleanLogicDetector,
12
+ ComparisonsDetector,
13
+ ComplexityDetector,
14
+ ContextManagerDetector,
15
+ ControlFlowDetector,
16
+ DictOperationsDetector,
17
+ DuplicationDetector,
18
+ LoopsDetector,
19
+ PerformanceDetector,
20
+ )
21
+ from .models import AnalysisResult, FileAnalysis
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class Analyzer:
27
+ """Main analyzer that orchestrates all detectors."""
28
+
29
+ def __init__(self, config: Config) -> None:
30
+ """Initialize analyzer with configuration."""
31
+ self.config = config
32
+
33
+ def _create_detectors(
34
+ self, file_path: str, source_lines: list[str]
35
+ ) -> list[BaseDetector]:
36
+ """Create all enabled detectors for a file.
37
+
38
+ Factory method to consolidate detector initialization and reduce duplication.
39
+ """
40
+ detectors: list[BaseDetector] = []
41
+
42
+ # Complexity detector (always enabled)
43
+ detectors.append(ComplexityDetector(self.config, file_path, source_lines))
44
+
45
+ # Conditionally enabled detectors
46
+ detector_configs = [
47
+ (self.config.performance.enabled, PerformanceDetector),
48
+ (self.config.boolean_logic.enabled, BooleanLogicDetector),
49
+ (self.config.loops.enabled, LoopsDetector),
50
+ (self.config.duplication.enabled, DuplicationDetector),
51
+ (self.config.context_manager.enabled, ContextManagerDetector),
52
+ (self.config.control_flow.enabled, ControlFlowDetector),
53
+ (self.config.dict_operations.enabled, DictOperationsDetector),
54
+ (self.config.comparisons.enabled, ComparisonsDetector),
55
+ ]
56
+
57
+ for enabled, detector_class in detector_configs:
58
+ if enabled:
59
+ detectors.append(detector_class(self.config, file_path, source_lines))
60
+
61
+ return detectors
62
+
63
+ def analyze_file(self, file_path: Path) -> FileAnalysis:
64
+ """Analyze a single Python file."""
65
+ analysis = FileAnalysis(file_path=str(file_path))
66
+
67
+ try:
68
+ # Read the file
69
+ source_code = file_path.read_text(encoding="utf-8")
70
+ source_lines = source_code.splitlines()
71
+ analysis.lines_of_code = len(source_lines)
72
+
73
+ # Parse the AST
74
+ try:
75
+ tree = ast.parse(source_code, filename=str(file_path))
76
+ except SyntaxError as e:
77
+ analysis.parse_error = f"Syntax error: {e}"
78
+ return analysis
79
+
80
+ # Create and run all enabled detectors
81
+ detectors = self._create_detectors(str(file_path), source_lines)
82
+ self._run_detectors(detectors, tree, analysis, file_path)
83
+
84
+ except Exception as e:
85
+ analysis.parse_error = f"Error analyzing file: {e}"
86
+ logger.error("Error analyzing %s: %s", file_path, e)
87
+
88
+ return analysis
89
+
90
+ def _run_detectors(
91
+ self,
92
+ detectors: list[BaseDetector],
93
+ tree: ast.Module,
94
+ analysis: FileAnalysis,
95
+ file_path: Path,
96
+ ) -> None:
97
+ """Run all detectors and collect issues."""
98
+ for detector in detectors:
99
+ try:
100
+ issues = detector.analyze(tree)
101
+ for issue in issues:
102
+ analysis.add_issue(issue)
103
+ except Exception as e:
104
+ logger.error(
105
+ "Error running %s on %s: %s",
106
+ detector.get_detector_name(),
107
+ file_path,
108
+ e,
109
+ )
110
+
111
+ def analyze_directory(
112
+ self, directory: Path, max_workers: int = 4
113
+ ) -> AnalysisResult:
114
+ """Analyze all Python files in a directory."""
115
+ result = AnalysisResult()
116
+
117
+ # Find all Python files
118
+ python_files = list(directory.rglob("*.py"))
119
+
120
+ # Filter excluded patterns
121
+ python_files = self._filter_excluded_files(python_files)
122
+
123
+ if not python_files:
124
+ logger.warning("No Python files found in %s", directory)
125
+ return result
126
+
127
+ # Analyze files in parallel
128
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
129
+ future_to_file = {
130
+ executor.submit(self.analyze_file, file_path): file_path
131
+ for file_path in python_files
132
+ }
133
+
134
+ for future in concurrent.futures.as_completed(future_to_file):
135
+ file_path = future_to_file[future]
136
+ try:
137
+ analysis = future.result()
138
+ result.add_file_analysis(analysis)
139
+ except Exception as e:
140
+ logger.error("Error analyzing %s: %s", file_path, e)
141
+ result.add_file_analysis(
142
+ FileAnalysis(
143
+ file_path=str(file_path),
144
+ parse_error=f"Analysis failed: {e}",
145
+ )
146
+ )
147
+
148
+ return result
149
+
150
+ def analyze_files(self, file_paths: list[Path]) -> AnalysisResult:
151
+ """Analyze a list of Python files."""
152
+ result = AnalysisResult()
153
+
154
+ for file_path in file_paths:
155
+ self._process_path(file_path, result)
156
+
157
+ return result
158
+
159
+ def _process_path(self, file_path: Path, result: AnalysisResult) -> None:
160
+ """Process a single file or directory path.
161
+
162
+ Args:
163
+ file_path: Path to file or directory
164
+ result: AnalysisResult to add analyses to
165
+ """
166
+ if file_path.is_file():
167
+ analysis = self.analyze_file(file_path)
168
+ result.add_file_analysis(analysis)
169
+ elif file_path.is_dir():
170
+ dir_result = self.analyze_directory(file_path)
171
+ for analysis in dir_result.file_analyses:
172
+ result.add_file_analysis(analysis)
173
+
174
+ def _filter_excluded_files(self, files: list[Path]) -> list[Path]:
175
+ """Filter out files matching exclusion patterns."""
176
+ if not self.config.exclude_patterns:
177
+ return files
178
+
179
+ return [
180
+ file_path
181
+ for file_path in files
182
+ if not any(
183
+ pattern in str(file_path) for pattern in self.config.exclude_patterns
184
+ )
185
+ ]
@@ -0,0 +1,197 @@
1
+ """Base AST visitor framework for PyRefactor detectors."""
2
+
3
+ import ast
4
+ from abc import ABC, abstractmethod
5
+ from typing import Union
6
+
7
+ from .config import Config
8
+ from .models import Issue
9
+
10
+
11
+ class BaseDetector(ast.NodeVisitor, ABC):
12
+ """Base class for all detectors."""
13
+
14
+ def __init__(self, config: Config, file_path: str, source_lines: list[str]) -> None:
15
+ """Initialize detector with configuration and source context."""
16
+ self.config = config
17
+ self.file_path = file_path
18
+ self.source_lines = source_lines
19
+ self.issues: list[Issue] = []
20
+ self.current_function: Union[ast.FunctionDef, ast.AsyncFunctionDef, None] = None
21
+ self.nesting_level = 0
22
+
23
+ @abstractmethod
24
+ def get_detector_name(self) -> str:
25
+ """Return the name of this detector."""
26
+
27
+ def add_issue(self, issue: Issue) -> None:
28
+ """Add an issue to the detector's list."""
29
+ self.issues.append(issue)
30
+
31
+ def get_source_line(self, line: int) -> str:
32
+ """Get a specific source line (1-indexed)."""
33
+ if 1 <= line <= len(self.source_lines):
34
+ return self.source_lines[line - 1]
35
+ return ""
36
+
37
+ def get_source_snippet(self, start_line: int, end_line: int) -> str:
38
+ """Get a snippet of source code."""
39
+ if start_line < 1 or end_line > len(self.source_lines):
40
+ return ""
41
+ return "\n".join(self.source_lines[start_line - 1 : end_line])
42
+
43
+ def is_suppressed(self, node: ast.AST) -> bool:
44
+ """Check if a node has a suppression comment."""
45
+ if not hasattr(node, "lineno"):
46
+ return False
47
+
48
+ lineno: int = node.lineno
49
+ line = self.get_source_line(lineno)
50
+ # Check for suppression comments
51
+ if "# pyrefactor: ignore" in line or "# noqa" in line:
52
+ return True
53
+
54
+ # Check previous line for suppression
55
+ if lineno > 1:
56
+ prev_line = self.get_source_line(lineno - 1)
57
+ if "# pyrefactor: ignore" in prev_line or "# noqa" in prev_line:
58
+ return True
59
+
60
+ return False
61
+
62
+ def analyze(self, tree: ast.AST) -> list[Issue]:
63
+ """Run the detector on an AST and return issues found."""
64
+ self.visit(tree)
65
+ return self.issues
66
+
67
+
68
+ def calculate_cyclomatic_complexity(
69
+ node: Union[ast.FunctionDef, ast.AsyncFunctionDef],
70
+ ) -> int:
71
+ """Calculate cyclomatic complexity of a function."""
72
+
73
+ class ComplexityVisitor(ast.NodeVisitor):
74
+ """Visitor to count decision points."""
75
+
76
+ def __init__(self) -> None:
77
+ self.complexity = 1 # Base complexity
78
+
79
+ def visit_If(self, node: ast.If) -> None:
80
+ """Count if statements."""
81
+ self._increment_and_visit(node)
82
+
83
+ def visit_For(self, node: ast.For) -> None:
84
+ """Count for loops."""
85
+ self._increment_and_visit(node)
86
+
87
+ def visit_While(self, node: ast.While) -> None:
88
+ """Count while loops."""
89
+ self._increment_and_visit(node)
90
+
91
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
92
+ """Count except handlers."""
93
+ self._increment_and_visit(node)
94
+
95
+ def visit_With(self, node: ast.With) -> None:
96
+ """Count with statements."""
97
+ self._increment_and_visit(node)
98
+
99
+ def visit_Assert(self, node: ast.Assert) -> None:
100
+ """Count assertions."""
101
+ self._increment_and_visit(node)
102
+
103
+ def visit_BoolOp(self, node: ast.BoolOp) -> None:
104
+ """Count boolean operations (and/or)."""
105
+ self.complexity += len(node.values) - 1
106
+ self.generic_visit(node)
107
+
108
+ def _increment_and_visit(self, node: ast.AST) -> None:
109
+ """Increment complexity and continue visiting."""
110
+ self.complexity += 1
111
+ self.generic_visit(node)
112
+
113
+ visitor = ComplexityVisitor()
114
+ visitor.visit(node)
115
+ return visitor.complexity
116
+
117
+
118
+ def count_nesting_depth(node: ast.AST) -> int:
119
+ """Calculate maximum nesting depth in a node."""
120
+
121
+ class NestingVisitor(ast.NodeVisitor):
122
+ """Visitor to track nesting depth."""
123
+
124
+ def __init__(self) -> None:
125
+ self.current_depth = 0
126
+ self.max_depth = 0
127
+
128
+ def visit_If(self, node: ast.If) -> None:
129
+ """Track if nesting."""
130
+ self._visit_nested(node)
131
+
132
+ def visit_For(self, node: ast.For) -> None:
133
+ """Track for loop nesting."""
134
+ self._visit_nested(node)
135
+
136
+ def visit_While(self, node: ast.While) -> None:
137
+ """Track while loop nesting."""
138
+ self._visit_nested(node)
139
+
140
+ def visit_With(self, node: ast.With) -> None:
141
+ """Track with statement nesting."""
142
+ self._visit_nested(node)
143
+
144
+ def visit_Try(self, node: ast.Try) -> None:
145
+ """Track try block nesting."""
146
+ self._visit_nested(node)
147
+
148
+ def _visit_nested(self, node: ast.AST) -> None:
149
+ """Visit a nested structure."""
150
+ self.current_depth += 1
151
+ self.max_depth = max(self.max_depth, self.current_depth)
152
+ self.generic_visit(node)
153
+ self.current_depth -= 1
154
+
155
+ visitor = NestingVisitor()
156
+ visitor.visit(node)
157
+ return visitor.max_depth
158
+
159
+
160
+ def count_branches(node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> int:
161
+ """Count the number of branches in a function."""
162
+
163
+ class BranchVisitor(ast.NodeVisitor):
164
+ """Visitor to count branches."""
165
+
166
+ def __init__(self) -> None:
167
+ self.branches = 0
168
+
169
+ def visit_If(self, node: ast.If) -> None:
170
+ """Count if/elif branches."""
171
+ self.branches += 1
172
+ if node.orelse:
173
+ # Check if else contains another if (elif)
174
+ if len(node.orelse) == 1 and isinstance(node.orelse[0], ast.If):
175
+ pass # Will be counted when we visit that node
176
+ else:
177
+ self.branches += 1 # else branch
178
+ self.generic_visit(node)
179
+
180
+ def visit_For(self, node: ast.For) -> None:
181
+ """Count for loops as branches."""
182
+ self.branches += 1
183
+ self.generic_visit(node)
184
+
185
+ def visit_While(self, node: ast.While) -> None:
186
+ """Count while loops as branches."""
187
+ self.branches += 1
188
+ self.generic_visit(node)
189
+
190
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
191
+ """Count exception handlers."""
192
+ self.branches += 1
193
+ self.generic_visit(node)
194
+
195
+ visitor = BranchVisitor()
196
+ visitor.visit(node)
197
+ return visitor.branches