pyrefactor 1.0.8__tar.gz → 1.0.9__tar.gz
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-1.0.8/src/pyrefactor.egg-info → pyrefactor-1.0.9}/PKG-INFO +1 -1
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/pyproject.toml +1 -1
- pyrefactor-1.0.9/src/pyrefactor/__init__.py +20 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/__main__.py +3 -2
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/ast_visitor.py +0 -1
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/config.py +14 -2
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/comparisons.py +45 -90
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/complexity.py +0 -8
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/context_manager.py +3 -37
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/control_flow.py +12 -36
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/dict_operations.py +66 -60
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/loops.py +26 -54
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/performance.py +0 -27
- {pyrefactor-1.0.8 → pyrefactor-1.0.9/src/pyrefactor.egg-info}/PKG-INFO +1 -1
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_analyzer.py +82 -4
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_config.py +60 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_config_discovery.py +16 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_dict_operations_detector.py +15 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_duplication_detector.py +7 -8
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_loops_detector.py +2 -5
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_performance_detector.py +3 -6
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_version.py +17 -0
- pyrefactor-1.0.8/src/pyrefactor/__init__.py +0 -5
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/LICENSE.md +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/README.md +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/setup.cfg +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/_version.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/analyzer.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/__init__.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/boolean_logic.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/duplication.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/models.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/py.typed +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/reporter.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor.egg-info/SOURCES.txt +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor.egg-info/dependency_links.txt +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor.egg-info/entry_points.txt +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor.egg-info/requires.txt +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor.egg-info/top_level.txt +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_ast_visitor.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_boolean_logic_detector.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_cli.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_comparisons_detector.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_complexity_detector.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_context_manager_detector.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_control_flow_detector.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_integration.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_models.py +0 -0
- {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_reporter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyrefactor
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.9
|
|
4
4
|
Summary: A Python refactoring and optimization linter that analyzes code for performance issues, complexity problems, and opportunities for improvement
|
|
5
5
|
Author: tboy1337
|
|
6
6
|
Maintainer: tboy1337
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyrefactor"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.9"
|
|
8
8
|
description = "A Python refactoring and optimization linter that analyzes code for performance issues, complexity problems, and opportunities for improvement"
|
|
9
9
|
authors = [{name = "tboy1337"}]
|
|
10
10
|
maintainers = [{name = "tboy1337"}]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""PyRefactor - A Python refactoring and optimization linter."""
|
|
2
|
+
|
|
3
|
+
from pyrefactor._version import get_version
|
|
4
|
+
from pyrefactor.analyzer import Analyzer
|
|
5
|
+
from pyrefactor.config import Config
|
|
6
|
+
from pyrefactor.models import AnalysisResult, FileAnalysis, Issue, Severity
|
|
7
|
+
from pyrefactor.reporter import ConsoleReporter
|
|
8
|
+
|
|
9
|
+
__version__ = get_version()
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Analyzer",
|
|
13
|
+
"AnalysisResult",
|
|
14
|
+
"Config",
|
|
15
|
+
"ConsoleReporter",
|
|
16
|
+
"FileAnalysis",
|
|
17
|
+
"Issue",
|
|
18
|
+
"Severity",
|
|
19
|
+
"__version__",
|
|
20
|
+
]
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import argparse
|
|
4
4
|
import logging
|
|
5
5
|
import sys
|
|
6
|
+
import tomllib
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Optional
|
|
@@ -137,7 +138,7 @@ def _load_config(args: Args) -> Optional[Config]:
|
|
|
137
138
|
config = Config.load(args.config)
|
|
138
139
|
logger.info("Loaded configuration: %s", config)
|
|
139
140
|
return config
|
|
140
|
-
except
|
|
141
|
+
except (ValueError, OSError, tomllib.TOMLDecodeError) as e:
|
|
141
142
|
if args.verbose:
|
|
142
143
|
logger.error("Error loading configuration: %s", e, exc_info=True)
|
|
143
144
|
else:
|
|
@@ -163,7 +164,7 @@ def _analyze_files_safely(
|
|
|
163
164
|
try:
|
|
164
165
|
logger.info("Analyzing %d path(s)...", len(paths))
|
|
165
166
|
return analyzer.analyze_files(paths, max_workers=max_workers)
|
|
166
|
-
except
|
|
167
|
+
except (OSError, RuntimeError) as e:
|
|
167
168
|
if verbose:
|
|
168
169
|
logger.error("Error during analysis: %s", e, exc_info=True)
|
|
169
170
|
else:
|
|
@@ -39,7 +39,6 @@ class BaseDetector(ast.NodeVisitor, ABC):
|
|
|
39
39
|
self.source_lines = source_lines
|
|
40
40
|
self.issues: list[Issue] = []
|
|
41
41
|
self.current_function: Union[ast.FunctionDef, ast.AsyncFunctionDef, None] = None
|
|
42
|
-
self.nesting_level = 0
|
|
43
42
|
|
|
44
43
|
@abstractmethod
|
|
45
44
|
def get_detector_name(self) -> str:
|
|
@@ -355,6 +355,15 @@ class Config:
|
|
|
355
355
|
exclude_patterns=exclude_patterns,
|
|
356
356
|
)
|
|
357
357
|
|
|
358
|
+
@staticmethod
|
|
359
|
+
def _has_pyrefactor_config(data: dict[str, Any]) -> bool:
|
|
360
|
+
"""Return True when parsed TOML contains a non-empty [tool.pyrefactor] table."""
|
|
361
|
+
tool_section = data.get("tool")
|
|
362
|
+
if not isinstance(tool_section, dict):
|
|
363
|
+
return False
|
|
364
|
+
pyrefactor = tool_section.get("pyrefactor")
|
|
365
|
+
return isinstance(pyrefactor, dict) and bool(pyrefactor)
|
|
366
|
+
|
|
358
367
|
@classmethod
|
|
359
368
|
def from_toml_file(cls, config_path: Path) -> "Config":
|
|
360
369
|
"""Load configuration from a TOML file."""
|
|
@@ -413,8 +422,11 @@ class Config:
|
|
|
413
422
|
return cls.from_file(config_path)
|
|
414
423
|
|
|
415
424
|
pyproject = Path("pyproject.toml")
|
|
416
|
-
if pyproject.
|
|
417
|
-
|
|
425
|
+
if pyproject.is_file():
|
|
426
|
+
with pyproject.open("rb") as config_file:
|
|
427
|
+
data = tomllib.load(config_file)
|
|
428
|
+
if cls._has_pyrefactor_config(data):
|
|
429
|
+
return cls.from_toml_data(data)
|
|
418
430
|
|
|
419
431
|
ini_file = Path("pyrefactor.ini")
|
|
420
432
|
if ini_file.exists():
|
|
@@ -4,7 +4,7 @@ import ast
|
|
|
4
4
|
from typing import Optional, Tuple, cast
|
|
5
5
|
|
|
6
6
|
from ..ast_visitor import BaseDetector
|
|
7
|
-
from ..models import
|
|
7
|
+
from ..models import Severity
|
|
8
8
|
|
|
9
9
|
# Singleton values that should be compared with 'is' instead of '=='
|
|
10
10
|
SINGLETON_VALUES = frozenset({True, False, None})
|
|
@@ -17,26 +17,6 @@ class ComparisonsDetector(BaseDetector):
|
|
|
17
17
|
"""Return the name of this detector."""
|
|
18
18
|
return "comparisons"
|
|
19
19
|
|
|
20
|
-
def _create_issue(
|
|
21
|
-
self,
|
|
22
|
-
node: ast.AST,
|
|
23
|
-
*,
|
|
24
|
-
severity: Severity,
|
|
25
|
-
rule_id: str,
|
|
26
|
-
message: str,
|
|
27
|
-
suggestion: str,
|
|
28
|
-
) -> Issue:
|
|
29
|
-
"""Create an Issue object for comparison issues."""
|
|
30
|
-
return Issue(
|
|
31
|
-
file=self.file_path,
|
|
32
|
-
line=cast(int, getattr(node, "lineno", 0)),
|
|
33
|
-
column=cast(int, getattr(node, "col_offset", 0)),
|
|
34
|
-
severity=severity,
|
|
35
|
-
rule_id=rule_id,
|
|
36
|
-
message=message,
|
|
37
|
-
suggestion=suggestion,
|
|
38
|
-
)
|
|
39
|
-
|
|
40
20
|
def visit_BoolOp(self, node: ast.BoolOp) -> None:
|
|
41
21
|
"""Check for patterns that could use 'in' operator or chained comparisons."""
|
|
42
22
|
if self.is_suppressed(node):
|
|
@@ -75,30 +55,21 @@ class ComparisonsDetector(BaseDetector):
|
|
|
75
55
|
|
|
76
56
|
# We have x == a or x == b pattern
|
|
77
57
|
if len(comparisons) >= 2:
|
|
78
|
-
var_name = (
|
|
79
|
-
ast.unparse(comparisons[0].left) if hasattr(ast, "unparse") else "x"
|
|
80
|
-
)
|
|
58
|
+
var_name = ast.unparse(comparisons[0].left)
|
|
81
59
|
values = []
|
|
82
60
|
for comp in comparisons:
|
|
83
61
|
if comp.comparators:
|
|
84
|
-
|
|
85
|
-
ast.unparse(comp.comparators[0])
|
|
86
|
-
if hasattr(ast, "unparse")
|
|
87
|
-
else "value"
|
|
88
|
-
)
|
|
89
|
-
values.append(val)
|
|
62
|
+
values.append(ast.unparse(comp.comparators[0]))
|
|
90
63
|
|
|
91
64
|
values_str = ", ".join(values)
|
|
92
65
|
|
|
93
|
-
self.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
f"Use a set if values are hashable for O(1) lookup.",
|
|
101
|
-
)
|
|
66
|
+
self.report_issue(
|
|
67
|
+
node,
|
|
68
|
+
severity=Severity.LOW,
|
|
69
|
+
rule_id="R011",
|
|
70
|
+
message="Multiple equality comparisons can be simplified using 'in' operator",
|
|
71
|
+
suggestion=f"Use '{var_name} in ({values_str})' instead of multiple '==' comparisons. "
|
|
72
|
+
f"Use a set if values are hashable for O(1) lookup.",
|
|
102
73
|
)
|
|
103
74
|
|
|
104
75
|
def _check_chained_comparison(self, node: ast.BoolOp) -> None:
|
|
@@ -152,13 +123,9 @@ class ComparisonsDetector(BaseDetector):
|
|
|
152
123
|
return None
|
|
153
124
|
|
|
154
125
|
# Extract string representations
|
|
155
|
-
left1_str = ast.unparse(comp1.left)
|
|
156
|
-
mid_str = ast.unparse(right1)
|
|
157
|
-
right2_str = (
|
|
158
|
-
ast.unparse(comp2.comparators[0])
|
|
159
|
-
if hasattr(ast, "unparse") and comp2.comparators
|
|
160
|
-
else "c"
|
|
161
|
-
)
|
|
126
|
+
left1_str = ast.unparse(comp1.left)
|
|
127
|
+
mid_str = ast.unparse(right1)
|
|
128
|
+
right2_str = ast.unparse(comp2.comparators[0]) if comp2.comparators else "c"
|
|
162
129
|
|
|
163
130
|
return (left1_str, op1, mid_str, op2, right2_str)
|
|
164
131
|
|
|
@@ -167,15 +134,13 @@ class ComparisonsDetector(BaseDetector):
|
|
|
167
134
|
) -> None:
|
|
168
135
|
"""Report a chainable comparison issue."""
|
|
169
136
|
left1_str, op1, mid_str, op2, right2_str = chain_info
|
|
170
|
-
self.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
f"instead of separate comparisons",
|
|
178
|
-
)
|
|
137
|
+
self.report_issue(
|
|
138
|
+
node,
|
|
139
|
+
severity=Severity.LOW,
|
|
140
|
+
rule_id="R012",
|
|
141
|
+
message="Comparison can be chained for better readability",
|
|
142
|
+
suggestion=f"Use '{left1_str} {op1} {mid_str} {op2} {right2_str}' "
|
|
143
|
+
f"instead of separate comparisons",
|
|
179
144
|
)
|
|
180
145
|
|
|
181
146
|
def visit_Compare(self, node: ast.Compare) -> None:
|
|
@@ -206,33 +171,29 @@ class ComparisonsDetector(BaseDetector):
|
|
|
206
171
|
"""Report inappropriate None comparison."""
|
|
207
172
|
correct_op = "is not" if checking_for_absence else "is"
|
|
208
173
|
wrong_op = "!=" if checking_for_absence else "=="
|
|
209
|
-
self.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
suggestion=f"Use '{correct_op}' instead of '{wrong_op}' when comparing with None",
|
|
216
|
-
)
|
|
174
|
+
self.report_issue(
|
|
175
|
+
node,
|
|
176
|
+
severity=Severity.MEDIUM,
|
|
177
|
+
rule_id="R014",
|
|
178
|
+
message="Comparison with None should use 'is' or 'is not'",
|
|
179
|
+
suggestion=f"Use '{correct_op}' instead of '{wrong_op}' when comparing with None",
|
|
217
180
|
)
|
|
218
181
|
|
|
219
182
|
def _report_bool_comparison(
|
|
220
183
|
self, node: ast.Compare, op: ast.cmpop, singleton_val: bool, other: ast.AST
|
|
221
184
|
) -> None:
|
|
222
185
|
"""Report redundant True/False comparison."""
|
|
223
|
-
other_str = ast.unparse(other)
|
|
186
|
+
other_str = ast.unparse(other)
|
|
224
187
|
|
|
225
188
|
# Determine the suggested replacement
|
|
226
189
|
suggestion = self._get_bool_comparison_suggestion(singleton_val, op, other_str)
|
|
227
190
|
|
|
228
|
-
self.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
suggestion=suggestion,
|
|
235
|
-
)
|
|
191
|
+
self.report_issue(
|
|
192
|
+
node,
|
|
193
|
+
severity=Severity.INFO,
|
|
194
|
+
rule_id="R015",
|
|
195
|
+
message=f"Redundant comparison with {singleton_val}",
|
|
196
|
+
suggestion=suggestion,
|
|
236
197
|
)
|
|
237
198
|
|
|
238
199
|
def _get_bool_comparison_suggestion(
|
|
@@ -312,24 +273,18 @@ class ComparisonsDetector(BaseDetector):
|
|
|
312
273
|
return
|
|
313
274
|
|
|
314
275
|
# At this point, node.left is a Call with args
|
|
315
|
-
obj = ast.unparse(node.left.args[0])
|
|
316
|
-
type_name = (
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
message="Use isinstance() for type checking instead of type() comparison",
|
|
328
|
-
suggestion=(
|
|
329
|
-
f"Use 'isinstance({obj}, {type_name})' instead of "
|
|
330
|
-
f"'type({obj}) == {type_name}'"
|
|
331
|
-
),
|
|
332
|
-
)
|
|
276
|
+
obj = ast.unparse(node.left.args[0])
|
|
277
|
+
type_name = ast.unparse(node.comparators[0]) if node.comparators else "Type"
|
|
278
|
+
|
|
279
|
+
self.report_issue(
|
|
280
|
+
node,
|
|
281
|
+
severity=Severity.MEDIUM,
|
|
282
|
+
rule_id="R016",
|
|
283
|
+
message="Use isinstance() for type checking instead of type() comparison",
|
|
284
|
+
suggestion=(
|
|
285
|
+
f"Use 'isinstance({obj}, {type_name})' instead of "
|
|
286
|
+
f"'type({obj}) == {type_name}'"
|
|
287
|
+
),
|
|
333
288
|
)
|
|
334
289
|
|
|
335
290
|
def visit_Call(self, node: ast.Call) -> None:
|
|
@@ -91,11 +91,6 @@ class ComplexityDetector(BaseDetector):
|
|
|
91
91
|
if self.is_suppressed(node):
|
|
92
92
|
return
|
|
93
93
|
|
|
94
|
-
# Save current function context
|
|
95
|
-
old_function = self.current_function
|
|
96
|
-
self.current_function = node
|
|
97
|
-
|
|
98
|
-
# Group checks that don't require AST traversal
|
|
99
94
|
self._check_function_length(node)
|
|
100
95
|
self._check_arguments(node)
|
|
101
96
|
|
|
@@ -105,9 +100,6 @@ class ComplexityDetector(BaseDetector):
|
|
|
105
100
|
self._check_nesting_depth(node)
|
|
106
101
|
self._check_cyclomatic_complexity(node)
|
|
107
102
|
|
|
108
|
-
# Restore function context
|
|
109
|
-
self.current_function = old_function
|
|
110
|
-
|
|
111
103
|
def _check_function_length(
|
|
112
104
|
self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]
|
|
113
105
|
) -> None:
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import ast
|
|
4
4
|
from typing import Optional
|
|
5
5
|
|
|
6
|
-
from ..ast_visitor import BaseDetector,
|
|
6
|
+
from ..ast_visitor import BaseDetector, build_parent_map
|
|
7
7
|
from ..config import Config
|
|
8
8
|
from ..models import Issue, Severity
|
|
9
9
|
|
|
@@ -38,51 +38,19 @@ class ContextManagerDetector(BaseDetector):
|
|
|
38
38
|
|
|
39
39
|
def analyze(self, tree: ast.AST) -> list[Issue]:
|
|
40
40
|
"""Run the detector on an AST and return issues found."""
|
|
41
|
-
|
|
42
|
-
self._build_parent_map(tree)
|
|
41
|
+
self.parent_map = build_parent_map(tree)
|
|
43
42
|
self.visit(tree)
|
|
44
43
|
return self.issues
|
|
45
44
|
|
|
46
|
-
def _build_parent_map(self, tree: ast.AST) -> None:
|
|
47
|
-
"""Build a map of child -> parent for the entire tree."""
|
|
48
|
-
for parent in ast.walk(tree):
|
|
49
|
-
for child in ast.iter_child_nodes(parent):
|
|
50
|
-
self.parent_map[child] = parent
|
|
51
|
-
|
|
52
45
|
def get_detector_name(self) -> str:
|
|
53
46
|
"""Return the name of this detector."""
|
|
54
47
|
return "context_manager"
|
|
55
48
|
|
|
56
|
-
def _create_issue(
|
|
57
|
-
self,
|
|
58
|
-
node: ast.AST,
|
|
59
|
-
*,
|
|
60
|
-
severity: Severity,
|
|
61
|
-
rule_id: str,
|
|
62
|
-
message: str,
|
|
63
|
-
suggestion: str,
|
|
64
|
-
) -> Issue | None:
|
|
65
|
-
"""Create an Issue object for context manager issues."""
|
|
66
|
-
line = node_lineno(node)
|
|
67
|
-
if line is None:
|
|
68
|
-
return None
|
|
69
|
-
return Issue(
|
|
70
|
-
file=self.file_path,
|
|
71
|
-
line=line,
|
|
72
|
-
column=node_col_offset(node),
|
|
73
|
-
severity=severity,
|
|
74
|
-
rule_id=rule_id,
|
|
75
|
-
message=message,
|
|
76
|
-
suggestion=suggestion,
|
|
77
|
-
)
|
|
78
|
-
|
|
79
49
|
def _is_context_manager_call(self, node: ast.Call) -> bool:
|
|
80
50
|
"""Check if a call returns a context manager."""
|
|
81
|
-
# Check for direct function calls (e.g., open(), file())
|
|
82
51
|
if isinstance(node.func, ast.Name):
|
|
83
52
|
return node.func.id in CONTEXT_MANAGER_FUNCS
|
|
84
53
|
|
|
85
|
-
# Check for method calls (e.g., lock.acquire(), Path.open())
|
|
86
54
|
if isinstance(node.func, ast.Attribute):
|
|
87
55
|
return node.func.attr in CONTEXT_MANAGER_METHODS
|
|
88
56
|
|
|
@@ -154,7 +122,7 @@ class ContextManagerDetector(BaseDetector):
|
|
|
154
122
|
# Get the function name for a better error message
|
|
155
123
|
func_name = self._get_func_name(cm_call)
|
|
156
124
|
|
|
157
|
-
|
|
125
|
+
self.report_issue(
|
|
158
126
|
node,
|
|
159
127
|
severity=Severity.HIGH,
|
|
160
128
|
rule_id="R001",
|
|
@@ -165,8 +133,6 @@ class ContextManagerDetector(BaseDetector):
|
|
|
165
133
|
f"Use 'with {func_name}(...) as resource:' to ensure proper resource cleanup"
|
|
166
134
|
),
|
|
167
135
|
)
|
|
168
|
-
if issue is not None:
|
|
169
|
-
self.add_issue(issue)
|
|
170
136
|
|
|
171
137
|
def _find_context_manager_call(self, node: ast.AST) -> Optional[ast.Call]:
|
|
172
138
|
"""Find a context manager call in an expression tree."""
|
|
@@ -1,40 +1,14 @@
|
|
|
1
1
|
"""Control flow simplification detector for PyRefactor."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
|
-
from typing import cast
|
|
5
4
|
|
|
6
5
|
from ..ast_visitor import BaseDetector
|
|
7
|
-
from ..models import
|
|
6
|
+
from ..models import Severity
|
|
8
7
|
|
|
9
8
|
|
|
10
9
|
class ControlFlowDetector(BaseDetector):
|
|
11
10
|
"""Detects unnecessary else/elif clauses after return/raise/break/continue."""
|
|
12
11
|
|
|
13
|
-
def get_detector_name(self) -> str:
|
|
14
|
-
"""Return the name of this detector."""
|
|
15
|
-
return "control_flow"
|
|
16
|
-
|
|
17
|
-
def _create_issue(
|
|
18
|
-
self,
|
|
19
|
-
node: ast.AST,
|
|
20
|
-
*,
|
|
21
|
-
severity: Severity,
|
|
22
|
-
rule_id: str,
|
|
23
|
-
message: str,
|
|
24
|
-
suggestion: str,
|
|
25
|
-
) -> Issue:
|
|
26
|
-
"""Create an Issue object for control flow issues."""
|
|
27
|
-
return Issue(
|
|
28
|
-
file=self.file_path,
|
|
29
|
-
line=cast(int, getattr(node, "lineno", 0)),
|
|
30
|
-
column=cast(int, getattr(node, "col_offset", 0)),
|
|
31
|
-
severity=severity,
|
|
32
|
-
rule_id=rule_id,
|
|
33
|
-
message=message,
|
|
34
|
-
suggestion=suggestion,
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
# Map terminator types to rule IDs
|
|
38
12
|
_TERMINATOR_RULES = {
|
|
39
13
|
"return": "R002",
|
|
40
14
|
"raise": "R003",
|
|
@@ -42,6 +16,10 @@ class ControlFlowDetector(BaseDetector):
|
|
|
42
16
|
"continue": "R005",
|
|
43
17
|
}
|
|
44
18
|
|
|
19
|
+
def get_detector_name(self) -> str:
|
|
20
|
+
"""Return the name of this detector."""
|
|
21
|
+
return "control_flow"
|
|
22
|
+
|
|
45
23
|
def visit_If(self, node: ast.If) -> None:
|
|
46
24
|
"""Check for unnecessary else clauses."""
|
|
47
25
|
if self.is_suppressed(node):
|
|
@@ -144,13 +122,11 @@ class ControlFlowDetector(BaseDetector):
|
|
|
144
122
|
else:
|
|
145
123
|
clause_type = "else"
|
|
146
124
|
|
|
147
|
-
self.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
f"preceding code always executes '{terminator}'",
|
|
155
|
-
)
|
|
125
|
+
self.report_issue(
|
|
126
|
+
node,
|
|
127
|
+
severity=Severity.MEDIUM,
|
|
128
|
+
rule_id=rule_id,
|
|
129
|
+
message=f"Unnecessary '{clause_type}' after '{terminator}' statement",
|
|
130
|
+
suggestion=f"Remove '{clause_type}' and unindent its body since the "
|
|
131
|
+
f"preceding code always executes '{terminator}'",
|
|
156
132
|
)
|
|
@@ -4,7 +4,7 @@ import ast
|
|
|
4
4
|
from typing import Optional, Tuple, cast
|
|
5
5
|
|
|
6
6
|
from ..ast_visitor import BaseDetector
|
|
7
|
-
from ..models import
|
|
7
|
+
from ..models import Severity
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class DictOperationsDetector(BaseDetector):
|
|
@@ -14,26 +14,6 @@ class DictOperationsDetector(BaseDetector):
|
|
|
14
14
|
"""Return the name of this detector."""
|
|
15
15
|
return "dict_operations"
|
|
16
16
|
|
|
17
|
-
def _create_issue(
|
|
18
|
-
self,
|
|
19
|
-
node: ast.AST,
|
|
20
|
-
*,
|
|
21
|
-
severity: Severity,
|
|
22
|
-
rule_id: str,
|
|
23
|
-
message: str,
|
|
24
|
-
suggestion: str,
|
|
25
|
-
) -> Issue:
|
|
26
|
-
"""Create an Issue object for dictionary operation issues."""
|
|
27
|
-
return Issue(
|
|
28
|
-
file=self.file_path,
|
|
29
|
-
line=cast(int, getattr(node, "lineno", 0)),
|
|
30
|
-
column=cast(int, getattr(node, "col_offset", 0)),
|
|
31
|
-
severity=severity,
|
|
32
|
-
rule_id=rule_id,
|
|
33
|
-
message=message,
|
|
34
|
-
suggestion=suggestion,
|
|
35
|
-
)
|
|
36
|
-
|
|
37
17
|
def visit_If(self, node: ast.If) -> None:
|
|
38
18
|
"""Check for dict.get() opportunities."""
|
|
39
19
|
if self.is_suppressed(node):
|
|
@@ -58,15 +38,13 @@ class DictOperationsDetector(BaseDetector):
|
|
|
58
38
|
|
|
59
39
|
var_name, key_name, dict_name, default_val = components
|
|
60
40
|
|
|
61
|
-
self.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
f"instead of if/else block",
|
|
69
|
-
)
|
|
41
|
+
self.report_issue(
|
|
42
|
+
node,
|
|
43
|
+
severity=Severity.LOW,
|
|
44
|
+
rule_id="R006",
|
|
45
|
+
message="Consider using dict.get() instead of if/else for key lookup",
|
|
46
|
+
suggestion=f"Use '{var_name} = {dict_name}.get({key_name}, {default_val})' "
|
|
47
|
+
f"instead of if/else block",
|
|
70
48
|
)
|
|
71
49
|
|
|
72
50
|
def _is_valid_dict_get_structure(self, node: ast.If) -> bool:
|
|
@@ -114,9 +92,7 @@ class DictOperationsDetector(BaseDetector):
|
|
|
114
92
|
return None
|
|
115
93
|
|
|
116
94
|
var_name = cast(ast.Name, if_assign.targets[0]).id
|
|
117
|
-
default_val = (
|
|
118
|
-
ast.unparse(else_assign.value) if hasattr(ast, "unparse") else "..."
|
|
119
|
-
)
|
|
95
|
+
default_val = ast.unparse(else_assign.value)
|
|
120
96
|
|
|
121
97
|
return (var_name, key_name.id, dict_name.id, default_val)
|
|
122
98
|
|
|
@@ -185,6 +161,42 @@ class DictOperationsDetector(BaseDetector):
|
|
|
185
161
|
|
|
186
162
|
return True
|
|
187
163
|
|
|
164
|
+
def visit_Compare(self, node: ast.Compare) -> None:
|
|
165
|
+
"""Check for unnecessary .keys() in membership tests."""
|
|
166
|
+
if self.is_suppressed(node):
|
|
167
|
+
self.generic_visit(node)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
self._check_unnecessary_keys_membership(node)
|
|
171
|
+
self.generic_visit(node)
|
|
172
|
+
|
|
173
|
+
def _check_unnecessary_keys_membership(self, node: ast.Compare) -> None:
|
|
174
|
+
"""Check for pattern: key in dict.keys()."""
|
|
175
|
+
if len(node.ops) != 1 or not isinstance(node.ops[0], ast.In):
|
|
176
|
+
return
|
|
177
|
+
if not node.comparators:
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
comparator = node.comparators[0]
|
|
181
|
+
if not isinstance(comparator, ast.Call):
|
|
182
|
+
return
|
|
183
|
+
if not isinstance(comparator.func, ast.Attribute):
|
|
184
|
+
return
|
|
185
|
+
if comparator.func.attr != "keys":
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
dict_name = self._get_name(comparator.func.value)
|
|
189
|
+
if not dict_name:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
self.report_issue(
|
|
193
|
+
node,
|
|
194
|
+
severity=Severity.INFO,
|
|
195
|
+
rule_id="R009",
|
|
196
|
+
message="Unnecessary .keys() call in membership test",
|
|
197
|
+
suggestion=f"Use 'key in {dict_name}' instead of 'key in {dict_name}.keys()'",
|
|
198
|
+
)
|
|
199
|
+
|
|
188
200
|
def visit_For(self, node: ast.For) -> None:
|
|
189
201
|
"""Check for dictionary iteration improvements."""
|
|
190
202
|
if self.is_suppressed(node):
|
|
@@ -216,15 +228,13 @@ class DictOperationsDetector(BaseDetector):
|
|
|
216
228
|
return
|
|
217
229
|
|
|
218
230
|
target_name = self._get_target_name(node.target)
|
|
219
|
-
self.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
f"instead of 'for {target_name} in {dict_name}.keys():'",
|
|
227
|
-
)
|
|
231
|
+
self.report_issue(
|
|
232
|
+
node,
|
|
233
|
+
severity=Severity.INFO,
|
|
234
|
+
rule_id="R009",
|
|
235
|
+
message="Unnecessary .keys() call when iterating dictionary",
|
|
236
|
+
suggestion=f"Use 'for {target_name} in {dict_name}:' "
|
|
237
|
+
f"instead of 'for {target_name} in {dict_name}.keys():'",
|
|
228
238
|
)
|
|
229
239
|
|
|
230
240
|
def _check_dict_items_opportunity(self, node: ast.For) -> None:
|
|
@@ -242,15 +252,13 @@ class DictOperationsDetector(BaseDetector):
|
|
|
242
252
|
|
|
243
253
|
# Check if body contains dict[key] accesses
|
|
244
254
|
if self._has_dict_key_access(node.body, iter_name, key_name):
|
|
245
|
-
self.
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
f"to avoid repeated dict lookups",
|
|
253
|
-
)
|
|
255
|
+
self.report_issue(
|
|
256
|
+
node,
|
|
257
|
+
severity=Severity.MEDIUM,
|
|
258
|
+
rule_id="R007",
|
|
259
|
+
message="Consider using .items() to access both keys and values",
|
|
260
|
+
suggestion=f"Use 'for {key_name}, value in {iter_name}.items():' "
|
|
261
|
+
f"to avoid repeated dict lookups",
|
|
254
262
|
)
|
|
255
263
|
|
|
256
264
|
def _has_dict_key_access(
|
|
@@ -320,15 +328,13 @@ class DictOperationsDetector(BaseDetector):
|
|
|
320
328
|
if len(arg.elt.elts) != 2:
|
|
321
329
|
return
|
|
322
330
|
|
|
323
|
-
self.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
"for better readability and performance",
|
|
331
|
-
)
|
|
331
|
+
self.report_issue(
|
|
332
|
+
node,
|
|
333
|
+
severity=Severity.LOW,
|
|
334
|
+
rule_id="R010",
|
|
335
|
+
message="Consider using dictionary comprehension instead of dict()",
|
|
336
|
+
suggestion="Use '{k: v for ...}' instead of 'dict([(k, v) for ...])' "
|
|
337
|
+
"for better readability and performance",
|
|
332
338
|
)
|
|
333
339
|
|
|
334
340
|
def _get_name(self, node: ast.AST) -> Optional[str]:
|