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.
Files changed (49) hide show
  1. {pyrefactor-1.0.8/src/pyrefactor.egg-info → pyrefactor-1.0.9}/PKG-INFO +1 -1
  2. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/pyproject.toml +1 -1
  3. pyrefactor-1.0.9/src/pyrefactor/__init__.py +20 -0
  4. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/__main__.py +3 -2
  5. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/ast_visitor.py +0 -1
  6. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/config.py +14 -2
  7. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/comparisons.py +45 -90
  8. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/complexity.py +0 -8
  9. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/context_manager.py +3 -37
  10. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/control_flow.py +12 -36
  11. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/dict_operations.py +66 -60
  12. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/loops.py +26 -54
  13. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/performance.py +0 -27
  14. {pyrefactor-1.0.8 → pyrefactor-1.0.9/src/pyrefactor.egg-info}/PKG-INFO +1 -1
  15. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_analyzer.py +82 -4
  16. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_config.py +60 -0
  17. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_config_discovery.py +16 -0
  18. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_dict_operations_detector.py +15 -0
  19. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_duplication_detector.py +7 -8
  20. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_loops_detector.py +2 -5
  21. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_performance_detector.py +3 -6
  22. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_version.py +17 -0
  23. pyrefactor-1.0.8/src/pyrefactor/__init__.py +0 -5
  24. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/LICENSE.md +0 -0
  25. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/README.md +0 -0
  26. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/setup.cfg +0 -0
  27. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/_version.py +0 -0
  28. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/analyzer.py +0 -0
  29. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/__init__.py +0 -0
  30. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/boolean_logic.py +0 -0
  31. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/detectors/duplication.py +0 -0
  32. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/models.py +0 -0
  33. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/py.typed +0 -0
  34. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor/reporter.py +0 -0
  35. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor.egg-info/SOURCES.txt +0 -0
  36. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor.egg-info/dependency_links.txt +0 -0
  37. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor.egg-info/entry_points.txt +0 -0
  38. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor.egg-info/requires.txt +0 -0
  39. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/src/pyrefactor.egg-info/top_level.txt +0 -0
  40. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_ast_visitor.py +0 -0
  41. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_boolean_logic_detector.py +0 -0
  42. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_cli.py +0 -0
  43. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_comparisons_detector.py +0 -0
  44. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_complexity_detector.py +0 -0
  45. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_context_manager_detector.py +0 -0
  46. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_control_flow_detector.py +0 -0
  47. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_integration.py +0 -0
  48. {pyrefactor-1.0.8 → pyrefactor-1.0.9}/tests/test_models.py +0 -0
  49. {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.8
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.8"
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 Exception as e:
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 Exception as e:
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.exists():
417
- return cls.from_toml_file(pyproject)
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 Issue, Severity
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
- val = (
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.add_issue(
94
- self._create_issue(
95
- node,
96
- severity=Severity.LOW,
97
- rule_id="R011",
98
- message="Multiple equality comparisons can be simplified using 'in' operator",
99
- suggestion=f"Use '{var_name} in ({values_str})' instead of multiple '==' comparisons. "
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) if hasattr(ast, "unparse") else "a"
156
- mid_str = ast.unparse(right1) if hasattr(ast, "unparse") else "b"
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.add_issue(
171
- self._create_issue(
172
- node,
173
- severity=Severity.LOW,
174
- rule_id="R012",
175
- message="Comparison can be chained for better readability",
176
- suggestion=f"Use '{left1_str} {op1} {mid_str} {op2} {right2_str}' "
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.add_issue(
210
- self._create_issue(
211
- node,
212
- severity=Severity.MEDIUM,
213
- rule_id="R014",
214
- message="Comparison with None should use 'is' or 'is not'",
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) if hasattr(ast, "unparse") else "expr"
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.add_issue(
229
- self._create_issue(
230
- node,
231
- severity=Severity.INFO,
232
- rule_id="R015",
233
- message=f"Redundant comparison with {singleton_val}",
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]) if hasattr(ast, "unparse") else "obj"
316
- type_name = (
317
- ast.unparse(node.comparators[0])
318
- if hasattr(ast, "unparse") and node.comparators
319
- else "Type"
320
- )
321
-
322
- self.add_issue(
323
- self._create_issue(
324
- node,
325
- severity=Severity.MEDIUM,
326
- rule_id="R016",
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, node_col_offset, node_lineno
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
- # Build parent map once for the entire tree
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
- issue = self._create_issue(
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 Issue, Severity
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.add_issue(
148
- self._create_issue(
149
- node,
150
- severity=Severity.MEDIUM,
151
- rule_id=rule_id,
152
- message=f"Unnecessary '{clause_type}' after '{terminator}' statement",
153
- suggestion=f"Remove '{clause_type}' and unindent its body since the "
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 Issue, Severity
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.add_issue(
62
- self._create_issue(
63
- node,
64
- severity=Severity.LOW,
65
- rule_id="R006",
66
- message="Consider using dict.get() instead of if/else for key lookup",
67
- suggestion=f"Use '{var_name} = {dict_name}.get({key_name}, {default_val})' "
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.add_issue(
220
- self._create_issue(
221
- node,
222
- severity=Severity.INFO,
223
- rule_id="R009",
224
- message="Unnecessary .keys() call when iterating dictionary",
225
- suggestion=f"Use 'for {target_name} in {dict_name}:' "
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.add_issue(
246
- self._create_issue(
247
- node,
248
- severity=Severity.MEDIUM,
249
- rule_id="R007",
250
- message="Consider using .items() to access both keys and values",
251
- suggestion=f"Use 'for {key_name}, value in {iter_name}.items():' "
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.add_issue(
324
- self._create_issue(
325
- node,
326
- severity=Severity.LOW,
327
- rule_id="R010",
328
- message="Consider using dictionary comprehension instead of dict()",
329
- suggestion="Use '{k: v for ...}' instead of 'dict([(k, v) for ...])' "
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]: