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.
@@ -0,0 +1,267 @@
1
+ """Performance anti-pattern detector for PyRefactor."""
2
+
3
+ import ast
4
+ from typing import Optional, Union, cast
5
+
6
+ from ..ast_visitor import BaseDetector
7
+ from ..config import Config
8
+ from ..models import Issue, Severity
9
+
10
+
11
+ class PerformanceDetector(BaseDetector):
12
+ """Detects performance anti-patterns in code."""
13
+
14
+ # Type hint patterns for heuristic type detection
15
+ TYPE_HINTS: dict[str, list[str]] = {
16
+ "string": ["str", "text", "message", "name"],
17
+ "list": ["list", "items", "results", "array", "collection"],
18
+ "dict": ["dict", "map", "cache", "mapping"],
19
+ }
20
+
21
+ def __init__(self, config: Config, file_path: str, source_lines: list[str]) -> None:
22
+ """Initialize performance detector."""
23
+ super().__init__(config, file_path, source_lines)
24
+ self.in_loop = False
25
+ self.loop_stack: list[ast.AST] = []
26
+
27
+ def get_detector_name(self) -> str:
28
+ """Return the name of this detector."""
29
+ return "performance"
30
+
31
+ def _create_issue(
32
+ self,
33
+ node: ast.AST,
34
+ *,
35
+ severity: Severity,
36
+ rule_id: str,
37
+ message: str,
38
+ suggestion: str,
39
+ ) -> Issue:
40
+ """Create an Issue object for performance-related issues."""
41
+ return Issue(
42
+ file=self.file_path,
43
+ line=cast(int, getattr(node, "lineno", 0)),
44
+ column=cast(int, getattr(node, "col_offset", 0)),
45
+ severity=severity,
46
+ rule_id=rule_id,
47
+ message=message,
48
+ suggestion=suggestion,
49
+ )
50
+
51
+ def _visit_loop(self, node: Union[ast.For, ast.While]) -> None:
52
+ """Consolidated method to track loop entry and exit."""
53
+ self.loop_stack.append(node)
54
+ self.in_loop = True
55
+ self.generic_visit(node)
56
+ self.loop_stack.pop()
57
+ self.in_loop = bool(self.loop_stack)
58
+
59
+ def visit_For(self, node: ast.For) -> None:
60
+ """Track when we're inside a for loop."""
61
+ self._visit_loop(node)
62
+
63
+ def visit_While(self, node: ast.While) -> None:
64
+ """Track when we're inside a while loop."""
65
+ self._visit_loop(node)
66
+
67
+ def visit_AugAssign(self, node: ast.AugAssign) -> None:
68
+ """Check for inefficient augmented assignments in loops."""
69
+ if not self.in_loop or self.is_suppressed(node):
70
+ self.generic_visit(node)
71
+ return
72
+
73
+ # Check for string concatenation with +=
74
+ if not isinstance(node.op, ast.Add):
75
+ self.generic_visit(node)
76
+ return
77
+
78
+ if self._matches_type_hint(node.target, "string"):
79
+ self.add_issue(
80
+ self._create_issue(
81
+ node,
82
+ severity=Severity.MEDIUM,
83
+ rule_id="P001",
84
+ message="String concatenation in loop using += is inefficient",
85
+ suggestion="Use str.join() with a list or io.StringIO for better performance",
86
+ )
87
+ )
88
+ # Check for list concatenation with +=
89
+ elif self._matches_type_hint(node.target, "list"):
90
+ self.add_issue(
91
+ self._create_issue(
92
+ node,
93
+ severity=Severity.LOW,
94
+ rule_id="P002",
95
+ message="List concatenation in loop using += may be inefficient",
96
+ suggestion="Use list.extend() or list comprehension for better performance",
97
+ )
98
+ )
99
+ self.generic_visit(node)
100
+
101
+ def visit_Call(self, node: ast.Call) -> None:
102
+ """Check for inefficient function calls."""
103
+ if self.is_suppressed(node):
104
+ self.generic_visit(node)
105
+ return
106
+
107
+ self._check_dict_keys_usage(node)
108
+ self._check_redundant_list_conversion(node)
109
+ self._check_len_usage(node)
110
+
111
+ self.generic_visit(node)
112
+
113
+ def _check_dict_keys_usage(self, node: ast.Call) -> None:
114
+ """Check for unnecessary dict.keys() in membership tests."""
115
+ if not isinstance(node.func, ast.Attribute):
116
+ return
117
+
118
+ if node.func.attr != "keys":
119
+ return
120
+
121
+ if not self._matches_type_hint(node.func.value, "dict"):
122
+ return
123
+
124
+ parent: Optional[ast.AST] = getattr(node, "_parent", None)
125
+ if not isinstance(parent, ast.Compare):
126
+ return
127
+
128
+ if not parent.ops or not isinstance(parent.ops[0], ast.In):
129
+ return
130
+
131
+ self.add_issue( # pyrefactor: ignore
132
+ self._create_issue(
133
+ node,
134
+ severity=Severity.INFO,
135
+ rule_id="P003",
136
+ message="Unnecessary dict.keys() call in membership test",
137
+ suggestion="Use 'key in dict' instead of 'key in dict.keys()'",
138
+ )
139
+ )
140
+
141
+ def _check_redundant_list_conversion(self, node: ast.Call) -> None:
142
+ """Check for redundant list() conversions of list comprehensions."""
143
+ if not isinstance(node.func, ast.Name):
144
+ return
145
+
146
+ if node.func.id != "list":
147
+ return
148
+
149
+ if not node.args or not isinstance(node.args[0], ast.ListComp):
150
+ return
151
+ # pyrefactor: ignore
152
+ self.add_issue(
153
+ self._create_issue(
154
+ node,
155
+ severity=Severity.INFO,
156
+ rule_id="P004",
157
+ message="Redundant list() conversion of list comprehension",
158
+ suggestion="List comprehensions already return lists; remove list() wrapper",
159
+ )
160
+ )
161
+
162
+ def _check_len_usage(self, node: ast.Call) -> None: # pyrefactor: ignore
163
+ """Check for len() calls and their usage patterns."""
164
+ if not isinstance(node.func, ast.Name):
165
+ return
166
+
167
+ if node.func.id != "len":
168
+ return
169
+
170
+ self._check_len_comparison(node)
171
+
172
+ def visit_Compare(self, node: ast.Compare) -> None:
173
+ """Check for inefficient comparisons."""
174
+ # Store parent reference for nested checks
175
+ self._set_parent_refs(node)
176
+ self.generic_visit(node)
177
+
178
+ def _set_parent_refs(self, node: ast.AST) -> None:
179
+ """Set parent references on all children of a node for upward traversal."""
180
+ for child in ast.walk(node):
181
+ setattr(child, "_parent", node)
182
+
183
+ def _check_len_comparison(self, len_call: ast.Call) -> None:
184
+ """Check for inefficient len() comparison patterns.
185
+
186
+ Detects patterns like:
187
+ - len(x) > 0 (should use truthiness)
188
+ - len(x) == 0 (should use 'not x')
189
+ """
190
+ len_parent: Optional[ast.AST] = getattr(len_call, "_parent", None)
191
+ if not isinstance(len_parent, ast.Compare):
192
+ return
193
+
194
+ if not len_parent.ops or not len_parent.comparators:
195
+ return
196
+
197
+ # Check if comparing with 0
198
+ has_zero_comp = any(
199
+ isinstance(comp, ast.Constant) and comp.value == 0
200
+ for comp in len_parent.comparators
201
+ )
202
+ if not has_zero_comp:
203
+ return
204
+
205
+ # Check for > or >= operators
206
+ if any(isinstance(op, (ast.Gt, ast.GtE)) for op in len_parent.ops):
207
+ self._add_len_issue( # pyrefactor: ignore
208
+ len_call,
209
+ "P005",
210
+ "Use truthiness instead of len() > 0",
211
+ "Use 'if container:' instead of 'if len(container) > 0:'",
212
+ )
213
+ # Check for == or != operators
214
+ elif any(isinstance(op, (ast.Eq, ast.NotEq)) for op in len_parent.ops):
215
+ self._add_len_issue( # pyrefactor: ignore
216
+ len_call,
217
+ "P006",
218
+ "Use truthiness instead of len() == 0",
219
+ "Use 'if not container:' instead of 'if len(container) == 0:'",
220
+ )
221
+
222
+ def _add_len_issue(
223
+ self, len_call: ast.Call, rule_id: str, message: str, suggestion: str
224
+ ) -> None:
225
+ """Add an issue for len() usage patterns."""
226
+ self.add_issue(
227
+ self._create_issue(
228
+ len_call,
229
+ severity=Severity.INFO,
230
+ rule_id=rule_id,
231
+ message=message,
232
+ suggestion=suggestion,
233
+ )
234
+ )
235
+
236
+ def _matches_type_hint(
237
+ self, node: ast.AST, type_name: str
238
+ ) -> bool: # pyrefactor: ignore
239
+ """Check if a node likely matches a given type based on naming heuristics.
240
+
241
+ Args:
242
+ node: AST node to check
243
+ type_name: Type to check for ('string', 'list', or 'dict')
244
+
245
+ Returns:
246
+ True if the node name matches type hints, False otherwise
247
+ """
248
+ if not isinstance(node, ast.Name):
249
+ return False
250
+
251
+ name_lower = node.id.lower()
252
+ hints = self.TYPE_HINTS.get(type_name, [])
253
+
254
+ # Check if any hint appears in the variable name
255
+ return any(hint in name_lower for hint in hints) or (
256
+ type_name == "list" and name_lower.endswith("s")
257
+ )
258
+
259
+ def visit_ListComp(self, node: ast.ListComp) -> None:
260
+ """Check for list comprehension opportunities."""
261
+ self.generic_visit(node)
262
+
263
+ def visit_Assign(self, node: ast.Assign) -> None:
264
+ """Check for inefficient assignments."""
265
+ # Store parent references for nested analysis
266
+ self._set_parent_refs(node)
267
+ self.generic_visit(node)
pyrefactor/models.py ADDED
@@ -0,0 +1,98 @@
1
+ """Data models for PyRefactor."""
2
+
3
+ import functools
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from typing import Optional
7
+
8
+
9
+ @functools.total_ordering
10
+ class Severity(Enum):
11
+ """Severity levels for detected issues."""
12
+
13
+ INFO = "info"
14
+ LOW = "low"
15
+ MEDIUM = "medium"
16
+ HIGH = "high"
17
+
18
+ def __lt__(self, other: "Severity") -> bool:
19
+ """Compare severity levels."""
20
+ order = [Severity.INFO, Severity.LOW, Severity.MEDIUM, Severity.HIGH]
21
+ return order.index(self) < order.index(other)
22
+
23
+
24
+ @dataclass
25
+ class Issue:
26
+ """Represents a detected refactoring or optimization opportunity."""
27
+
28
+ file: str
29
+ line: int
30
+ column: int
31
+ severity: Severity
32
+ rule_id: str
33
+ message: str
34
+ suggestion: Optional[str] = None
35
+ code_snippet: Optional[str] = None
36
+ end_line: Optional[int] = None
37
+
38
+ def __post_init__(self) -> None:
39
+ """Validate issue data."""
40
+ if self.line < 1:
41
+ raise ValueError("Line number must be positive")
42
+ if self.column < 0:
43
+ raise ValueError("Column number must be non-negative")
44
+
45
+
46
+ @dataclass
47
+ class FileAnalysis:
48
+ """Results of analyzing a single file."""
49
+
50
+ file_path: str
51
+ issues: list[Issue] = field(default_factory=list)
52
+ parse_error: Optional[str] = None
53
+ lines_of_code: int = 0
54
+
55
+ def add_issue(self, issue: Issue) -> None:
56
+ """Add an issue to the analysis results."""
57
+ self.issues.append(issue)
58
+
59
+ def get_issues_by_severity(self, severity: Severity) -> list[Issue]:
60
+ """Get all issues matching a specific severity."""
61
+ return [issue for issue in self.issues if issue.severity == severity]
62
+
63
+ def has_errors(self) -> bool:
64
+ """Check if there are any high or medium severity issues."""
65
+ return any(
66
+ issue.severity in (Severity.HIGH, Severity.MEDIUM) for issue in self.issues
67
+ )
68
+
69
+
70
+ @dataclass
71
+ class AnalysisResult:
72
+ """Overall analysis results for multiple files."""
73
+
74
+ file_analyses: list[FileAnalysis] = field(default_factory=list)
75
+
76
+ def add_file_analysis(self, analysis: FileAnalysis) -> None:
77
+ """Add a file analysis to the results."""
78
+ self.file_analyses.append(analysis)
79
+
80
+ def get_all_issues(self) -> list[Issue]:
81
+ """Get all issues from all files."""
82
+ return [issue for analysis in self.file_analyses for issue in analysis.issues]
83
+
84
+ def get_issues_by_severity(self, severity: Severity) -> list[Issue]:
85
+ """Get all issues matching a specific severity."""
86
+ return [issue for issue in self.get_all_issues() if issue.severity == severity]
87
+
88
+ def total_issues(self) -> int:
89
+ """Get total count of issues."""
90
+ return len(self.get_all_issues())
91
+
92
+ def files_analyzed(self) -> int:
93
+ """Get count of files analyzed."""
94
+ return len(self.file_analyses)
95
+
96
+ def files_with_issues(self) -> int:
97
+ """Get count of files that have issues."""
98
+ return sum(1 for analysis in self.file_analyses if analysis.issues)
pyrefactor/py.typed ADDED
File without changes
pyrefactor/reporter.py ADDED
@@ -0,0 +1,208 @@
1
+ """Console reporter for PyRefactor."""
2
+
3
+ import io
4
+ import sys
5
+ from collections import defaultdict
6
+ from typing import TextIO
7
+
8
+ from colorama import Fore, Style, init
9
+
10
+ from .models import AnalysisResult, Issue, Severity
11
+
12
+ # Initialize colorama for cross-platform colored output
13
+ init(autoreset=True)
14
+
15
+
16
+ class ConsoleReporter:
17
+ """Reporter that outputs results to console."""
18
+
19
+ # Class-level constants for severity styling
20
+ SEVERITY_COLORS: dict[Severity, str] = {
21
+ Severity.HIGH: Fore.RED,
22
+ Severity.MEDIUM: Fore.YELLOW,
23
+ Severity.LOW: Fore.BLUE,
24
+ Severity.INFO: Fore.CYAN,
25
+ }
26
+
27
+ SEVERITY_ICONS: dict[Severity, str] = {
28
+ Severity.HIGH: "✗",
29
+ Severity.MEDIUM: "⚠",
30
+ Severity.LOW: "ℹ",
31
+ Severity.INFO: "→",
32
+ }
33
+
34
+ # ASCII fallback icons for terminals that don't support Unicode
35
+ ASCII_ICONS: dict[Severity, str] = {
36
+ Severity.HIGH: "X",
37
+ Severity.MEDIUM: "!",
38
+ Severity.LOW: "i",
39
+ Severity.INFO: ">",
40
+ }
41
+
42
+ def __init__(self, output: TextIO = sys.stdout) -> None:
43
+ """Initialize reporter with output stream."""
44
+ # Try to ensure UTF-8 encoding for Unicode symbols
45
+ if output is sys.stdout:
46
+ try:
47
+ # Reconfigure stdout to use UTF-8 encoding
48
+ if hasattr(sys.stdout, "reconfigure"):
49
+ # Python 3.7+ TextIOWrapper.reconfigure method
50
+ sys.stdout.reconfigure(encoding="utf-8")
51
+ self.output = sys.stdout
52
+ self.use_unicode = True
53
+ else:
54
+ # Wrap stdout with UTF-8 text wrapper
55
+ self.output = io.TextIOWrapper(
56
+ sys.stdout.buffer, # type: ignore[misc]
57
+ encoding="utf-8",
58
+ errors="replace",
59
+ )
60
+ self.use_unicode = True
61
+ except (AttributeError, OSError):
62
+ # Fall back to ASCII icons if UTF-8 is not available
63
+ self.output = output
64
+ self.use_unicode = False
65
+ else:
66
+ self.output = output
67
+ self.use_unicode = True
68
+
69
+ def report(self, result: AnalysisResult, group_by: str = "file") -> None:
70
+ """Generate and print report."""
71
+ if group_by == "file":
72
+ self._report_by_file(result)
73
+ elif group_by == "severity":
74
+ self._report_by_severity(result)
75
+ else:
76
+ self._report_by_file(result)
77
+
78
+ # Print summary
79
+ self._print_summary(result)
80
+
81
+ def _report_by_file(self, result: AnalysisResult) -> None:
82
+ """Report issues grouped by file."""
83
+ for analysis in result.file_analyses:
84
+ if analysis.parse_error:
85
+ self._print(f"\n{Fore.RED}✗ {analysis.file_path}{Style.RESET_ALL}")
86
+ self._print(f" Parse error: {analysis.parse_error}")
87
+ continue
88
+
89
+ if not analysis.issues:
90
+ continue
91
+
92
+ # Print file header
93
+ self._print(f"\n{Fore.CYAN}{analysis.file_path}{Style.RESET_ALL}")
94
+
95
+ # Sort issues by line number
96
+ sorted_issues = sorted(
97
+ analysis.issues, key=lambda issue: issue.line # type: ignore[misc]
98
+ )
99
+
100
+ # Print each issue
101
+ for issue in sorted_issues:
102
+ self._print_issue(issue)
103
+
104
+ def _report_by_severity(self, result: AnalysisResult) -> None:
105
+ """Report issues grouped by severity."""
106
+ issues_by_severity: dict[Severity, list[Issue]] = defaultdict(list)
107
+
108
+ for issue in result.get_all_issues():
109
+ issues_by_severity[issue.severity].append(issue)
110
+
111
+ # Print in order: HIGH, MEDIUM, LOW, INFO
112
+ for severity in [Severity.HIGH, Severity.MEDIUM, Severity.LOW, Severity.INFO]:
113
+ issues = issues_by_severity.get(severity, [])
114
+ if not issues:
115
+ continue
116
+
117
+ color = self._get_severity_color(severity)
118
+ self._print(
119
+ f"\n{color}{severity.value.upper()} Severity Issues{Style.RESET_ALL}"
120
+ )
121
+
122
+ # Sort by file and line
123
+ sorted_issues = sorted(
124
+ issues, key=lambda issue: (issue.file, issue.line) # type: ignore[misc]
125
+ )
126
+
127
+ for issue in sorted_issues:
128
+ self._print_issue(issue, include_file=True)
129
+
130
+ def _print_issue(self, issue: Issue, include_file: bool = False) -> None:
131
+ """Print a single issue."""
132
+ # Severity indicator
133
+ severity_color = self._get_severity_color(issue.severity)
134
+ severity_icon = self._get_severity_icon(issue.severity)
135
+
136
+ # Location
137
+ location = f"{issue.line}:{issue.column}"
138
+ if include_file:
139
+ location = f"{issue.file}:{location}"
140
+
141
+ # Print main issue line
142
+ self._print(
143
+ f" {severity_color}{severity_icon} [{issue.rule_id}] "
144
+ f"{location}{Style.RESET_ALL}"
145
+ )
146
+ self._print(f" {issue.message}")
147
+
148
+ # Print suggestion if available
149
+ if issue.suggestion:
150
+ self._print(f" {Fore.GREEN}→ {issue.suggestion}{Style.RESET_ALL}")
151
+
152
+ # Print code snippet if available
153
+ if issue.code_snippet:
154
+ self._print(
155
+ f" {Fore.LIGHTBLACK_EX}{issue.code_snippet}{Style.RESET_ALL}"
156
+ )
157
+
158
+ def _print_summary(self, result: AnalysisResult) -> None:
159
+ """Print summary statistics."""
160
+ self._print(f"\n{Fore.YELLOW}{'=' * 70}{Style.RESET_ALL}")
161
+ self._print(f"{Fore.YELLOW}Summary{Style.RESET_ALL}")
162
+ self._print(f"{Fore.YELLOW}{'=' * 70}{Style.RESET_ALL}")
163
+
164
+ total_issues = result.total_issues()
165
+ files_analyzed = result.files_analyzed()
166
+ files_with_issues = result.files_with_issues()
167
+
168
+ self._print(f"\nFiles analyzed: {files_analyzed}")
169
+ self._print(f"Files with issues: {files_with_issues}")
170
+ self._print(f"Total issues: {total_issues}")
171
+
172
+ if total_issues > 0:
173
+ self._print("\nIssues by severity:")
174
+ for severity in [
175
+ Severity.HIGH,
176
+ Severity.MEDIUM,
177
+ Severity.LOW,
178
+ Severity.INFO,
179
+ ]:
180
+ count = len(result.get_issues_by_severity(severity))
181
+ if count > 0:
182
+ color = self._get_severity_color(severity)
183
+ self._print(
184
+ f" {color}{severity.value.upper()}: {count}{Style.RESET_ALL}"
185
+ )
186
+
187
+ # Exit code indicator
188
+ if any(
189
+ issue.severity in (Severity.HIGH, Severity.MEDIUM)
190
+ for issue in result.get_all_issues()
191
+ ):
192
+ self._print(
193
+ f"\n{Fore.RED}⚠ High or medium severity issues found{Style.RESET_ALL}"
194
+ )
195
+
196
+ def _get_severity_color(self, severity: Severity) -> str:
197
+ """Get color for severity level."""
198
+ return self.SEVERITY_COLORS.get(severity, "")
199
+
200
+ def _get_severity_icon(self, severity: Severity) -> str:
201
+ """Get icon for severity level."""
202
+ if self.use_unicode:
203
+ return self.SEVERITY_ICONS.get(severity, "•")
204
+ return self.ASCII_ICONS.get(severity, "*")
205
+
206
+ def _print(self, message: str) -> None:
207
+ """Print a message to output."""
208
+ print(message, file=self.output)