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.
- {pyrefactor-1.0.7/src/pyrefactor.egg-info → pyrefactor-1.0.8}/PKG-INFO +14 -7
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/README.md +13 -6
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/pyproject.toml +2 -2
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/__main__.py +10 -4
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/_version.py +8 -1
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/analyzer.py +2 -1
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/config.py +75 -29
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/boolean_logic.py +21 -16
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/duplication.py +3 -1
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/performance.py +145 -10
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/reporter.py +1 -1
- {pyrefactor-1.0.7 → pyrefactor-1.0.8/src/pyrefactor.egg-info}/PKG-INFO +14 -7
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_analyzer.py +19 -5
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_cli.py +79 -7
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_complexity_detector.py +0 -126
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_config.py +87 -20
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_config_discovery.py +12 -14
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_integration.py +18 -20
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_performance_detector.py +123 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_version.py +39 -2
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/LICENSE.md +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/setup.cfg +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/__init__.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/ast_visitor.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/__init__.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/comparisons.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/complexity.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/context_manager.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/control_flow.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/dict_operations.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/detectors/loops.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/models.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor/py.typed +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor.egg-info/SOURCES.txt +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor.egg-info/dependency_links.txt +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor.egg-info/entry_points.txt +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor.egg-info/requires.txt +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/src/pyrefactor.egg-info/top_level.txt +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_ast_visitor.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_boolean_logic_detector.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_comparisons_detector.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_context_manager_detector.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_control_flow_detector.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_dict_operations_detector.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_duplication_detector.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_loops_detector.py +0 -0
- {pyrefactor-1.0.7 → pyrefactor-1.0.8}/tests/test_models.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
similarity_threshold = 0.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
similarity_threshold = 0.
|
|
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
|
+
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
50
|
-
comparator
|
|
54
|
+
if not (
|
|
55
|
+
isinstance(comparator, ast.Constant)
|
|
56
|
+
and isinstance(comparator.value, bool)
|
|
51
57
|
):
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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(
|
|
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
|
-
"""
|
|
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, "
|
|
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
|
|
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
|