pyrefactor 1.0.7__tar.gz → 1.0.8__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 (48) hide show
  1. {pyrefactor-1.0.7/src/pyrefactor.egg-info → pyrefactor-1.0.8}/PKG-INFO +14 -7
  2. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/README.md +13 -6
  3. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/pyproject.toml +2 -2
  4. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/__main__.py +10 -4
  5. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/_version.py +8 -1
  6. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/analyzer.py +2 -1
  7. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/config.py +75 -29
  8. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/boolean_logic.py +21 -16
  9. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/duplication.py +3 -1
  10. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/performance.py +145 -10
  11. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/reporter.py +1 -1
  12. {pyrefactor-1.0.7 → pyrefactor-1.0.8/src/pyrefactor.egg-info}/PKG-INFO +14 -7
  13. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_analyzer.py +19 -5
  14. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_cli.py +79 -7
  15. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_complexity_detector.py +0 -126
  16. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_config.py +87 -20
  17. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_config_discovery.py +12 -14
  18. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_integration.py +18 -20
  19. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_performance_detector.py +123 -0
  20. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_version.py +39 -2
  21. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/LICENSE.md +0 -0
  22. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/setup.cfg +0 -0
  23. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/__init__.py +0 -0
  24. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/ast_visitor.py +0 -0
  25. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/__init__.py +0 -0
  26. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/comparisons.py +0 -0
  27. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/complexity.py +0 -0
  28. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/context_manager.py +0 -0
  29. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/control_flow.py +0 -0
  30. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/dict_operations.py +0 -0
  31. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/loops.py +0 -0
  32. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/models.py +0 -0
  33. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/py.typed +0 -0
  34. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor.egg-info/SOURCES.txt +0 -0
  35. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor.egg-info/dependency_links.txt +0 -0
  36. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor.egg-info/entry_points.txt +0 -0
  37. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor.egg-info/requires.txt +0 -0
  38. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor.egg-info/top_level.txt +0 -0
  39. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_ast_visitor.py +0 -0
  40. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_boolean_logic_detector.py +0 -0
  41. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_comparisons_detector.py +0 -0
  42. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_context_manager_detector.py +0 -0
  43. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_control_flow_detector.py +0 -0
  44. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_dict_operations_detector.py +0 -0
  45. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_duplication_detector.py +0 -0
  46. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_loops_detector.py +0 -0
  47. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_models.py +0 -0
  48. {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_reporter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyrefactor
3
- Version: 1.0.7
3
+ Version: 1.0.8
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
@@ -70,7 +70,7 @@ A Python refactoring and optimization linter that uses AST analysis to identify
70
70
  ## Detectors
71
71
 
72
72
  - **Complexity**: High cyclomatic complexity functions
73
- - **Performance**: String concatenation in loops, uncached calls, inefficient operations
73
+ - **Performance**: String concatenation in loops (thresholded), repeated uncached calls in loops, inefficient operations
74
74
  - **Boolean Logic**: Overcomplicated boolean expressions
75
75
  - **Loops**: Nested loops, invariant code, comprehension opportunities
76
76
  - **Duplication**: Duplicate code blocks
@@ -145,7 +145,13 @@ Configure via TOML file (e.g., `pyproject.toml`):
145
145
  exclude_patterns = ["__pycache__", ".venv", "build", "dist"]
146
146
 
147
147
  [tool.pyrefactor.complexity]
148
- max_complexity = 10
148
+ enabled = true
149
+ max_cyclomatic_complexity = 10
150
+ max_branches = 10
151
+ max_nesting_depth = 3
152
+ max_function_lines = 50
153
+ max_arguments = 5
154
+ max_local_variables = 15
149
155
 
150
156
  [tool.pyrefactor.performance]
151
157
  enabled = true
@@ -154,16 +160,15 @@ min_duplicate_calls = 3
154
160
 
155
161
  [tool.pyrefactor.boolean_logic]
156
162
  enabled = true
157
- min_depth = 3
163
+ max_boolean_operators = 3
158
164
 
159
165
  [tool.pyrefactor.loops]
160
166
  enabled = true
161
- max_nesting = 3
162
167
 
163
168
  [tool.pyrefactor.duplication]
164
169
  enabled = true
165
- min_lines = 5
166
- similarity_threshold = 0.8
170
+ min_duplicate_lines = 5
171
+ similarity_threshold = 0.85
167
172
 
168
173
  [tool.pyrefactor.context_manager]
169
174
  enabled = true
@@ -180,6 +185,8 @@ enabled = true
180
185
 
181
186
  Configuration is searched in: `--config` → `pyproject.toml` → `pyrefactor.ini` → defaults
182
187
 
188
+ **Note:** The PyPI package version (`pyproject.toml`) may differ from GitHub release build numbers used for standalone executables.
189
+
183
190
  ## CI/CD Integration
184
191
 
185
192
  ### Pre-commit Hook
@@ -15,7 +15,7 @@ A Python refactoring and optimization linter that uses AST analysis to identify
15
15
  ## Detectors
16
16
 
17
17
  - **Complexity**: High cyclomatic complexity functions
18
- - **Performance**: String concatenation in loops, uncached calls, inefficient operations
18
+ - **Performance**: String concatenation in loops (thresholded), repeated uncached calls in loops, inefficient operations
19
19
  - **Boolean Logic**: Overcomplicated boolean expressions
20
20
  - **Loops**: Nested loops, invariant code, comprehension opportunities
21
21
  - **Duplication**: Duplicate code blocks
@@ -90,7 +90,13 @@ Configure via TOML file (e.g., `pyproject.toml`):
90
90
  exclude_patterns = ["__pycache__", ".venv", "build", "dist"]
91
91
 
92
92
  [tool.pyrefactor.complexity]
93
- max_complexity = 10
93
+ enabled = true
94
+ max_cyclomatic_complexity = 10
95
+ max_branches = 10
96
+ max_nesting_depth = 3
97
+ max_function_lines = 50
98
+ max_arguments = 5
99
+ max_local_variables = 15
94
100
 
95
101
  [tool.pyrefactor.performance]
96
102
  enabled = true
@@ -99,16 +105,15 @@ min_duplicate_calls = 3
99
105
 
100
106
  [tool.pyrefactor.boolean_logic]
101
107
  enabled = true
102
- min_depth = 3
108
+ max_boolean_operators = 3
103
109
 
104
110
  [tool.pyrefactor.loops]
105
111
  enabled = true
106
- max_nesting = 3
107
112
 
108
113
  [tool.pyrefactor.duplication]
109
114
  enabled = true
110
- min_lines = 5
111
- similarity_threshold = 0.8
115
+ min_duplicate_lines = 5
116
+ similarity_threshold = 0.85
112
117
 
113
118
  [tool.pyrefactor.context_manager]
114
119
  enabled = true
@@ -125,6 +130,8 @@ enabled = true
125
130
 
126
131
  Configuration is searched in: `--config` → `pyproject.toml` → `pyrefactor.ini` → defaults
127
132
 
133
+ **Note:** The PyPI package version (`pyproject.toml`) may differ from GitHub release build numbers used for standalone executables.
134
+
128
135
  ## CI/CD Integration
129
136
 
130
137
  ### Pre-commit Hook
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyrefactor"
7
- version = "1.0.7"
7
+ version = "1.0.8"
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"}]
11
11
  readme = "README.md"
12
- license = {text = "Commercial Restricted License (CRL)"}
12
+ license = { text = "Commercial Restricted License (CRL)" }
13
13
  requires-python = ">=3.12"
14
14
  keywords = [
15
15
  "refactoring",
@@ -138,7 +138,10 @@ def _load_config(args: Args) -> Optional[Config]:
138
138
  logger.info("Loaded configuration: %s", config)
139
139
  return config
140
140
  except Exception as e:
141
- logger.error("Error loading configuration: %s", e)
141
+ if args.verbose:
142
+ logger.error("Error loading configuration: %s", e, exc_info=True)
143
+ else:
144
+ logger.error("Error loading configuration: %s", e)
142
145
  return None
143
146
 
144
147
 
@@ -154,14 +157,17 @@ def _validate_paths(args: Args) -> Optional[list[Path]]:
154
157
 
155
158
 
156
159
  def _analyze_files_safely(
157
- analyzer: Analyzer, paths: list[Path], max_workers: int
160
+ analyzer: Analyzer, paths: list[Path], max_workers: int, *, verbose: bool = False
158
161
  ) -> Optional[AnalysisResult]:
159
162
  """Analyze files and handle errors. Returns result or None on error."""
160
163
  try:
161
164
  logger.info("Analyzing %d path(s)...", len(paths))
162
165
  return analyzer.analyze_files(paths, max_workers=max_workers)
163
166
  except Exception as e:
164
- logger.error("Error during analysis: %s", e)
167
+ if verbose:
168
+ logger.error("Error during analysis: %s", e, exc_info=True)
169
+ else:
170
+ logger.error("Error during analysis: %s", e)
165
171
  return None
166
172
 
167
173
 
@@ -216,7 +222,7 @@ def main() -> int:
216
222
  # Create analyzer and analyze files
217
223
  max_workers = max(1, args.jobs)
218
224
  analyzer = Analyzer(config)
219
- result = _analyze_files_safely(analyzer, paths, max_workers)
225
+ result = _analyze_files_safely(analyzer, paths, max_workers, verbose=args.verbose)
220
226
  if result is None:
221
227
  return 2
222
228
 
@@ -1,5 +1,6 @@
1
1
  """Package version resolution."""
2
2
 
3
+ import sys
3
4
  from functools import lru_cache
4
5
  from importlib.metadata import PackageNotFoundError, version
5
6
  from pathlib import Path
@@ -8,7 +9,13 @@ _PACKAGE_NAME = "pyrefactor"
8
9
 
9
10
 
10
11
  def _pyproject_path() -> Path:
11
- """Return the repository pyproject.toml path."""
12
+ """Return the pyproject.toml path for version fallback."""
13
+ if getattr(sys, "frozen", False):
14
+ meipass = getattr(sys, "_MEIPASS", None)
15
+ if meipass:
16
+ bundled = Path(meipass) / "pyproject.toml"
17
+ if bundled.is_file():
18
+ return bundled
12
19
  return Path(__file__).resolve().parent.parent.parent / "pyproject.toml"
13
20
 
14
21
 
@@ -40,7 +40,8 @@ class Analyzer:
40
40
  """Create all enabled detectors for a file."""
41
41
  detectors: list[BaseDetector] = []
42
42
 
43
- detectors.append(ComplexityDetector(self.config, file_path, source_lines))
43
+ if self.config.complexity.enabled:
44
+ detectors.append(ComplexityDetector(self.config, file_path, source_lines))
44
45
 
45
46
  detector_configs = [
46
47
  (self.config.performance.enabled, PerformanceDetector),
@@ -11,6 +11,7 @@ from typing import Any, Mapping, Optional, Union
11
11
  class ComplexityConfig:
12
12
  """Configuration for complexity detector."""
13
13
 
14
+ enabled: bool = True
14
15
  max_branches: int = 10
15
16
  max_nesting_depth: int = 3
16
17
  max_function_lines: int = 50
@@ -24,6 +25,8 @@ class PerformanceConfig:
24
25
  """Configuration for performance detector."""
25
26
 
26
27
  enabled: bool = True
28
+ min_concatenations: int = 3
29
+ min_duplicate_calls: int = 3
27
30
 
28
31
 
29
32
  @dataclass
@@ -94,10 +97,14 @@ class Config:
94
97
  exclude_patterns: list[str] = field(default_factory=list)
95
98
 
96
99
  @staticmethod
97
- def _parse_complexity_config(config: configparser.ConfigParser) -> dict[str, int]:
100
+ def _parse_complexity_config(
101
+ config: configparser.ConfigParser,
102
+ ) -> dict[str, Union[int, bool]]:
98
103
  """Extract complexity configuration from config parser."""
99
- complexity_dict: dict[str, int] = {}
104
+ complexity_dict: dict[str, Union[int, bool]] = {}
100
105
  if config.has_section("complexity"):
106
+ if config.has_option("complexity", "enabled"):
107
+ complexity_dict["enabled"] = config.getboolean("complexity", "enabled")
101
108
  for key in [
102
109
  "max_branches",
103
110
  "max_nesting_depth",
@@ -110,6 +117,27 @@ class Config:
110
117
  complexity_dict[key] = config.getint("complexity", key)
111
118
  return complexity_dict
112
119
 
120
+ @staticmethod
121
+ def _parse_performance_config(
122
+ config: configparser.ConfigParser,
123
+ ) -> dict[str, Union[int, bool]]:
124
+ """Extract performance configuration from config parser."""
125
+ performance_dict: dict[str, Union[int, bool]] = {}
126
+ if config.has_section("performance"):
127
+ if config.has_option("performance", "enabled"):
128
+ performance_dict["enabled"] = config.getboolean(
129
+ "performance", "enabled"
130
+ )
131
+ if config.has_option("performance", "min_concatenations"):
132
+ performance_dict["min_concatenations"] = config.getint(
133
+ "performance", "min_concatenations"
134
+ )
135
+ if config.has_option("performance", "min_duplicate_calls"):
136
+ performance_dict["min_duplicate_calls"] = config.getint(
137
+ "performance", "min_duplicate_calls"
138
+ )
139
+ return performance_dict
140
+
113
141
  @staticmethod
114
142
  def _parse_duplication_config(
115
143
  config: configparser.ConfigParser,
@@ -207,6 +235,7 @@ class Config:
207
235
  raise ValueError("Invalid [tool.pyrefactor] section in configuration")
208
236
 
209
237
  complexity_fields = {
238
+ "enabled": bool,
210
239
  "max_branches": int,
211
240
  "max_nesting_depth": int,
212
241
  "max_function_lines": int,
@@ -214,6 +243,11 @@ class Config:
214
243
  "max_local_variables": int,
215
244
  "max_cyclomatic_complexity": int,
216
245
  }
246
+ performance_fields = {
247
+ "enabled": bool,
248
+ "min_concatenations": int,
249
+ "min_duplicate_calls": int,
250
+ }
217
251
  duplication_fields = {
218
252
  "enabled": bool,
219
253
  "min_duplicate_lines": int,
@@ -232,9 +266,7 @@ class Config:
232
266
  exclude_patterns = [str(pattern) for pattern in raw_exclude]
233
267
  elif isinstance(raw_exclude, str):
234
268
  exclude_patterns = [
235
- pattern.strip()
236
- for pattern in raw_exclude.split(",")
237
- if pattern.strip()
269
+ pattern.strip() for pattern in raw_exclude.split(",") if pattern.strip()
238
270
  ]
239
271
 
240
272
  return cls(
@@ -246,15 +278,21 @@ class Config:
246
278
  ),
247
279
  performance=PerformanceConfig(
248
280
  **cls._coerce_section(
249
- pyrefactor.get("performance", {})
250
- if isinstance(pyrefactor.get("performance"), dict)
251
- else {},
252
- enabled_only,
281
+ (
282
+ pyrefactor.get("performance", {})
283
+ if isinstance(pyrefactor.get("performance"), dict)
284
+ else {}
285
+ ),
286
+ performance_fields,
253
287
  )
254
288
  ),
255
289
  duplication=DuplicationConfig(
256
290
  **cls._coerce_section(
257
- duplication_section if isinstance(duplication_section, dict) else {},
291
+ (
292
+ duplication_section
293
+ if isinstance(duplication_section, dict)
294
+ else {}
295
+ ),
258
296
  duplication_fields,
259
297
  )
260
298
  ),
@@ -266,41 +304,51 @@ class Config:
266
304
  ),
267
305
  loops=LoopsConfig(
268
306
  **cls._coerce_section(
269
- pyrefactor.get("loops", {})
270
- if isinstance(pyrefactor.get("loops"), dict)
271
- else {},
307
+ (
308
+ pyrefactor.get("loops", {})
309
+ if isinstance(pyrefactor.get("loops"), dict)
310
+ else {}
311
+ ),
272
312
  enabled_only,
273
313
  )
274
314
  ),
275
315
  context_manager=ContextManagerConfig(
276
316
  **cls._coerce_section(
277
- pyrefactor.get("context_manager", {})
278
- if isinstance(pyrefactor.get("context_manager"), dict)
279
- else {},
317
+ (
318
+ pyrefactor.get("context_manager", {})
319
+ if isinstance(pyrefactor.get("context_manager"), dict)
320
+ else {}
321
+ ),
280
322
  enabled_only,
281
323
  )
282
324
  ),
283
325
  control_flow=ControlFlowConfig(
284
326
  **cls._coerce_section(
285
- pyrefactor.get("control_flow", {})
286
- if isinstance(pyrefactor.get("control_flow"), dict)
287
- else {},
327
+ (
328
+ pyrefactor.get("control_flow", {})
329
+ if isinstance(pyrefactor.get("control_flow"), dict)
330
+ else {}
331
+ ),
288
332
  enabled_only,
289
333
  )
290
334
  ),
291
335
  dict_operations=DictOperationsConfig(
292
336
  **cls._coerce_section(
293
- pyrefactor.get("dict_operations", {})
294
- if isinstance(pyrefactor.get("dict_operations"), dict)
295
- else {},
337
+ (
338
+ pyrefactor.get("dict_operations", {})
339
+ if isinstance(pyrefactor.get("dict_operations"), dict)
340
+ else {}
341
+ ),
296
342
  enabled_only,
297
343
  )
298
344
  ),
299
345
  comparisons=ComparisonsConfig(
300
346
  **cls._coerce_section(
301
- pyrefactor.get("comparisons", {})
302
- if isinstance(pyrefactor.get("comparisons"), dict)
303
- else {},
347
+ (
348
+ pyrefactor.get("comparisons", {})
349
+ if isinstance(pyrefactor.get("comparisons"), dict)
350
+ else {}
351
+ ),
304
352
  enabled_only,
305
353
  )
306
354
  ),
@@ -328,10 +376,8 @@ class Config:
328
376
  parser.read(config_path, encoding="utf-8")
329
377
 
330
378
  return cls(
331
- complexity=ComplexityConfig(**cls._parse_complexity_config(parser)),
332
- performance=PerformanceConfig(
333
- **cls._parse_enabled_flag(parser, "performance")
334
- ),
379
+ complexity=ComplexityConfig(**cls._parse_complexity_config(parser)), # type: ignore[arg-type]
380
+ performance=PerformanceConfig(**cls._parse_performance_config(parser)), # type: ignore[arg-type]
335
381
  duplication=DuplicationConfig(**cls._parse_duplication_config(parser)), # type: ignore[arg-type]
336
382
  boolean_logic=BooleanLogicConfig(**cls._parse_boolean_logic_config(parser)), # type: ignore[arg-type]
337
383
  loops=LoopsConfig(**cls._parse_enabled_flag(parser, "loops")),
@@ -45,24 +45,29 @@ class BooleanLogicDetector(BaseDetector):
45
45
  self.generic_visit(node)
46
46
  return
47
47
 
48
+ self._check_boolean_singleton_comparison(node)
49
+ self.generic_visit(node)
50
+
51
+ def _check_boolean_singleton_comparison(self, node: ast.Compare) -> None:
52
+ """Report use of ``is`` when comparing to boolean constants."""
48
53
  for comparator in node.comparators:
49
- if isinstance(comparator, ast.Constant) and isinstance(
50
- comparator.value, bool
54
+ if not (
55
+ isinstance(comparator, ast.Constant)
56
+ and isinstance(comparator.value, bool)
51
57
  ):
52
- for op in node.ops:
53
- if isinstance(op, ast.Is):
54
- self.report_issue(
55
- node,
56
- severity=Severity.MEDIUM,
57
- rule_id="B004",
58
- message="Using 'is' for boolean comparison",
59
- suggestion=(
60
- "Use '==' for value comparison or use the boolean directly"
61
- ),
62
- )
63
- break
64
-
65
- self.generic_visit(node)
58
+ continue
59
+ if not any(isinstance(op, ast.Is) for op in node.ops):
60
+ continue
61
+ self.report_issue(
62
+ node,
63
+ severity=Severity.MEDIUM,
64
+ rule_id="B004",
65
+ message="Using 'is' for boolean comparison",
66
+ suggestion=(
67
+ "Use '==' for value comparison or use the boolean directly"
68
+ ),
69
+ )
70
+ return
66
71
 
67
72
  def visit_FunctionDef(
68
73
  self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]
@@ -185,7 +185,9 @@ class DuplicationDetector(BaseDetector):
185
185
  continue
186
186
 
187
187
  # Hash the normalized code
188
- code_hash = hashlib.md5(normalized.encode()).hexdigest()
188
+ code_hash = hashlib.md5(
189
+ normalized.encode(), usedforsecurity=False
190
+ ).hexdigest()
189
191
 
190
192
  if code_hash not in self.code_blocks:
191
193
  self.code_blocks[code_hash] = []
@@ -1,6 +1,8 @@
1
1
  """Performance anti-pattern detector for PyRefactor."""
2
2
 
3
3
  import ast
4
+ from collections import Counter
5
+ from collections.abc import Callable
4
6
  from typing import Optional
5
7
 
6
8
  from ..ast_visitor import BaseDetector, build_parent_map
@@ -8,6 +10,87 @@ from ..config import Config
8
10
  from ..models import Issue, Severity
9
11
 
10
12
 
13
+ class _LoopBodyCallCounter(ast.NodeVisitor):
14
+ """Count identical call expressions in a loop body."""
15
+
16
+ def __init__(self, suppressed: set[ast.AST]) -> None:
17
+ self.suppressed = suppressed
18
+ self.call_counts: Counter[str] = Counter()
19
+ self.call_nodes: dict[str, ast.Call] = {}
20
+ self._nested_function_depth = 0
21
+
22
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
23
+ """Skip counting calls inside nested function definitions."""
24
+ self._nested_function_depth += 1
25
+ self.generic_visit(node)
26
+ self._nested_function_depth -= 1
27
+
28
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
29
+ """Skip counting calls inside nested async function definitions."""
30
+ self._nested_function_depth += 1
31
+ self.generic_visit(node)
32
+ self._nested_function_depth -= 1
33
+
34
+ def visit_Lambda(self, node: ast.Lambda) -> None:
35
+ """Skip counting calls inside lambda expressions."""
36
+ self._nested_function_depth += 1
37
+ self.generic_visit(node)
38
+ self._nested_function_depth -= 1
39
+
40
+ def visit_Call(self, node: ast.Call) -> None:
41
+ """Record call expressions at the loop body scope."""
42
+ if self._nested_function_depth == 0 and node not in self.suppressed:
43
+ signature = ast.dump(node, annotate_fields=False)
44
+ self.call_counts[signature] += 1
45
+ self.call_nodes.setdefault(signature, node)
46
+ self.generic_visit(node)
47
+
48
+
49
+ class _LoopBodyConcatCounter(ast.NodeVisitor):
50
+ """Count string += operations in a loop body."""
51
+
52
+ def __init__(
53
+ self,
54
+ suppressed: set[ast.AST],
55
+ matches_string: Callable[[ast.AST, str], bool],
56
+ ) -> None:
57
+ self.suppressed = suppressed
58
+ self.matches_string = matches_string
59
+ self.count = 0
60
+ self.last_node: Optional[ast.AugAssign] = None
61
+ self._nested_function_depth = 0
62
+
63
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
64
+ """Skip counting concatenations inside nested function definitions."""
65
+ self._nested_function_depth += 1
66
+ self.generic_visit(node)
67
+ self._nested_function_depth -= 1
68
+
69
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
70
+ """Skip counting concatenations inside nested async functions."""
71
+ self._nested_function_depth += 1
72
+ self.generic_visit(node)
73
+ self._nested_function_depth -= 1
74
+
75
+ def visit_Lambda(self, node: ast.Lambda) -> None:
76
+ """Skip counting concatenations inside lambda expressions."""
77
+ self._nested_function_depth += 1
78
+ self.generic_visit(node)
79
+ self._nested_function_depth -= 1
80
+
81
+ def visit_AugAssign(self, node: ast.AugAssign) -> None:
82
+ """Count string += operations at the loop body scope."""
83
+ if (
84
+ self._nested_function_depth == 0
85
+ and node not in self.suppressed
86
+ and isinstance(node.op, ast.Add)
87
+ and self.matches_string(node.target, "string")
88
+ ):
89
+ self.count += 1
90
+ self.last_node = node
91
+ self.generic_visit(node)
92
+
93
+
11
94
  class PerformanceDetector(BaseDetector):
12
95
  """Detects performance anti-patterns in code."""
13
96
 
@@ -23,6 +106,7 @@ class PerformanceDetector(BaseDetector):
23
106
  self.in_loop = False
24
107
  self.loop_stack: list[ast.AST] = []
25
108
  self.parent_map: dict[ast.AST, ast.AST] = {}
109
+ self.suppressed_nodes: set[ast.AST] = set()
26
110
 
27
111
  def get_detector_name(self) -> str:
28
112
  """Return the name of this detector."""
@@ -31,14 +115,24 @@ class PerformanceDetector(BaseDetector):
31
115
  def analyze(self, tree: ast.AST) -> list[Issue]:
32
116
  """Run the detector on an AST and return issues found."""
33
117
  self.parent_map = build_parent_map(tree)
118
+ self.suppressed_nodes = self._collect_suppressed_nodes(tree)
34
119
  self.visit(tree)
35
120
  return self.issues
36
121
 
122
+ def _collect_suppressed_nodes(self, tree: ast.AST) -> set[ast.AST]:
123
+ """Collect AST nodes that have suppression comments."""
124
+ suppressed: set[ast.AST] = set()
125
+ for node in ast.walk(tree):
126
+ if self.is_suppressed(node):
127
+ suppressed.add(node)
128
+ return suppressed
129
+
37
130
  def _visit_loop(self, node: ast.For | ast.While) -> None:
38
- """Consolidated method to track loop entry and exit."""
131
+ """Track loop entry, analyze body, then exit."""
39
132
  self.loop_stack.append(node)
40
133
  self.in_loop = True
41
134
  self.generic_visit(node)
135
+ self._check_loop_performance(node)
42
136
  self.loop_stack.pop()
43
137
  self.in_loop = bool(self.loop_stack)
44
138
 
@@ -50,6 +144,55 @@ class PerformanceDetector(BaseDetector):
50
144
  """Track when we're inside a while loop."""
51
145
  self._visit_loop(node)
52
146
 
147
+ def _check_loop_performance(self, loop_node: ast.For | ast.While) -> None:
148
+ """Check loop body for concatenation and duplicate call patterns."""
149
+ self._check_string_concatenations(loop_node)
150
+ self._check_duplicate_calls(loop_node)
151
+
152
+ def _check_string_concatenations(self, loop_node: ast.For | ast.While) -> None:
153
+ """Report P001 when string += count meets threshold."""
154
+ counter = _LoopBodyConcatCounter(
155
+ self.suppressed_nodes,
156
+ self._matches_type_hint,
157
+ )
158
+ counter.visit(loop_node)
159
+ min_concat = self.config.performance.min_concatenations
160
+ if counter.count >= min_concat and counter.last_node is not None:
161
+ self.report_issue(
162
+ counter.last_node,
163
+ severity=Severity.MEDIUM,
164
+ rule_id="P001",
165
+ message=(
166
+ f"String concatenation in loop using += "
167
+ f"({counter.count} times) is inefficient"
168
+ ),
169
+ suggestion=(
170
+ "Use str.join() with a list or io.StringIO for better performance"
171
+ ),
172
+ )
173
+
174
+ def _check_duplicate_calls(self, loop_node: ast.For | ast.While) -> None:
175
+ """Report P007 when identical calls repeat within a loop body."""
176
+ counter = _LoopBodyCallCounter(self.suppressed_nodes)
177
+ counter.visit(loop_node)
178
+ min_calls = self.config.performance.min_duplicate_calls
179
+ for signature, count in counter.call_counts.items():
180
+ if count >= min_calls:
181
+ call_node = counter.call_nodes[signature]
182
+ self.report_issue(
183
+ call_node,
184
+ severity=Severity.MEDIUM,
185
+ rule_id="P007",
186
+ message=(
187
+ f"Repeated identical call in loop ({count} times); "
188
+ "consider caching the result"
189
+ ),
190
+ suggestion=(
191
+ "Assign the call result to a variable before the loop "
192
+ "or cache it on first use inside the loop"
193
+ ),
194
+ )
195
+
53
196
  def visit_AugAssign(self, node: ast.AugAssign) -> None:
54
197
  """Check for inefficient augmented assignments in loops."""
55
198
  if not self.in_loop or self.is_suppressed(node):
@@ -60,15 +203,7 @@ class PerformanceDetector(BaseDetector):
60
203
  self.generic_visit(node)
61
204
  return
62
205
 
63
- if self._matches_type_hint(node.target, "string"):
64
- self.report_issue(
65
- node,
66
- severity=Severity.MEDIUM,
67
- rule_id="P001",
68
- message="String concatenation in loop using += is inefficient",
69
- suggestion="Use str.join() with a list or io.StringIO for better performance",
70
- )
71
- elif self._matches_type_hint(node.target, "list"):
206
+ if self._matches_type_hint(node.target, "list"):
72
207
  self.report_issue(
73
208
  node,
74
209
  severity=Severity.LOW,
@@ -46,7 +46,7 @@ class ConsoleReporter:
46
46
  try:
47
47
  # Reconfigure stdout to use UTF-8 encoding
48
48
  if hasattr(sys.stdout, "reconfigure"):
49
- # Python 3.7+ TextIOWrapper.reconfigure method
49
+ # TextIOWrapper.reconfigure (Python 3.7+; PyRefactor requires 3.12+)
50
50
  sys.stdout.reconfigure(encoding="utf-8")
51
51
  self.output = sys.stdout
52
52
  self.use_unicode = True