pyrefactor 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyrefactor/__init__.py +3 -0
- pyrefactor/__main__.py +231 -0
- pyrefactor/analyzer.py +185 -0
- pyrefactor/ast_visitor.py +197 -0
- pyrefactor/config.py +224 -0
- pyrefactor/detectors/__init__.py +23 -0
- pyrefactor/detectors/boolean_logic.py +231 -0
- pyrefactor/detectors/comparisons.py +353 -0
- pyrefactor/detectors/complexity.py +248 -0
- pyrefactor/detectors/context_manager.py +188 -0
- pyrefactor/detectors/control_flow.py +156 -0
- pyrefactor/detectors/dict_operations.py +346 -0
- pyrefactor/detectors/duplication.py +358 -0
- pyrefactor/detectors/loops.py +267 -0
- pyrefactor/detectors/performance.py +267 -0
- pyrefactor/models.py +98 -0
- pyrefactor/py.typed +0 -0
- pyrefactor/reporter.py +208 -0
- pyrefactor-1.0.1.dist-info/METADATA +353 -0
- pyrefactor-1.0.1.dist-info/RECORD +24 -0
- pyrefactor-1.0.1.dist-info/WHEEL +5 -0
- pyrefactor-1.0.1.dist-info/entry_points.txt +2 -0
- pyrefactor-1.0.1.dist-info/licenses/LICENSE.md +70 -0
- pyrefactor-1.0.1.dist-info/top_level.txt +1 -0
pyrefactor/__init__.py
ADDED
pyrefactor/__main__.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""CLI entry point for PyRefactor."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .analyzer import Analyzer
|
|
12
|
+
from .config import Config
|
|
13
|
+
from .models import AnalysisResult, Severity
|
|
14
|
+
from .reporter import ConsoleReporter
|
|
15
|
+
|
|
16
|
+
# Configure logging
|
|
17
|
+
logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Args:
|
|
23
|
+
"""Type-safe argument namespace."""
|
|
24
|
+
|
|
25
|
+
paths: list[Path]
|
|
26
|
+
config: Optional[Path]
|
|
27
|
+
group_by: str
|
|
28
|
+
min_severity: str
|
|
29
|
+
jobs: int
|
|
30
|
+
verbose: bool
|
|
31
|
+
version: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Class-level constant for severity mapping to avoid recreation
|
|
35
|
+
SEVERITY_MAP: dict[str, Severity] = {
|
|
36
|
+
"info": Severity.INFO,
|
|
37
|
+
"low": Severity.LOW,
|
|
38
|
+
"medium": Severity.MEDIUM,
|
|
39
|
+
"high": Severity.HIGH,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _add_parser_arguments(parser: argparse.ArgumentParser) -> None:
|
|
44
|
+
"""Add all arguments to the parser."""
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"paths", nargs="*", type=Path, help="Python files or directories to analyze"
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"-c",
|
|
50
|
+
"--config",
|
|
51
|
+
type=Path,
|
|
52
|
+
help="Path to configuration file (default: pyproject.toml)",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"-g",
|
|
56
|
+
"--group-by",
|
|
57
|
+
choices=["file", "severity"],
|
|
58
|
+
default="file",
|
|
59
|
+
help="Group output by file or severity (default: file)",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--min-severity",
|
|
63
|
+
choices=["info", "low", "medium", "high"],
|
|
64
|
+
default="info",
|
|
65
|
+
help="Minimum severity level to report (default: info)",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"-j", "--jobs", type=int, default=4, help="Number of parallel jobs (default: 4)"
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"-v", "--verbose", action="store_true", help="Enable verbose logging"
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument("--version", action="store_true", help="Show version and exit")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _create_argument_parser() -> argparse.ArgumentParser:
|
|
77
|
+
"""Create and configure the argument parser."""
|
|
78
|
+
parser = argparse.ArgumentParser(
|
|
79
|
+
description="PyRefactor - A Python refactoring and optimization linter",
|
|
80
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
81
|
+
epilog="""
|
|
82
|
+
Examples:
|
|
83
|
+
pyrefactor myfile.py Analyze a single file
|
|
84
|
+
pyrefactor src/ Analyze all Python files in directory
|
|
85
|
+
pyrefactor file1.py file2.py Analyze multiple files
|
|
86
|
+
pyrefactor --config custom.toml . Analyze with custom config
|
|
87
|
+
|
|
88
|
+
Exit Codes:
|
|
89
|
+
0 - No issues or only INFO/LOW severity issues
|
|
90
|
+
1 - MEDIUM or HIGH severity issues found
|
|
91
|
+
2 - Error during analysis
|
|
92
|
+
""",
|
|
93
|
+
)
|
|
94
|
+
_add_parser_arguments(parser)
|
|
95
|
+
return parser
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_arguments() -> Args:
|
|
99
|
+
"""Parse command line arguments."""
|
|
100
|
+
parser = _create_argument_parser()
|
|
101
|
+
namespace = parser.parse_args()
|
|
102
|
+
# Convert to our typed class
|
|
103
|
+
# Note: argparse returns Any type for namespace attributes
|
|
104
|
+
return Args(
|
|
105
|
+
paths=namespace.paths, # type: ignore[misc]
|
|
106
|
+
config=namespace.config, # type: ignore[misc]
|
|
107
|
+
group_by=namespace.group_by, # type: ignore[misc]
|
|
108
|
+
min_severity=namespace.min_severity, # type: ignore[misc]
|
|
109
|
+
jobs=namespace.jobs, # type: ignore[misc]
|
|
110
|
+
verbose=namespace.verbose, # type: ignore[misc]
|
|
111
|
+
version=namespace.version, # type: ignore[misc]
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _handle_version(args: Args) -> Optional[int]:
|
|
116
|
+
"""Handle version flag. Returns exit code if version flag is set, None otherwise."""
|
|
117
|
+
if args.version:
|
|
118
|
+
print(f"PyRefactor version {__version__}")
|
|
119
|
+
return 0
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _configure_logging(args: Args) -> None:
|
|
124
|
+
"""Configure logging based on command line arguments."""
|
|
125
|
+
if args.verbose:
|
|
126
|
+
logging.getLogger().setLevel(logging.INFO)
|
|
127
|
+
logger.setLevel(logging.INFO)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _load_config(args: Args) -> Optional[Config]:
|
|
131
|
+
"""Load configuration. Returns Config or None on error."""
|
|
132
|
+
try:
|
|
133
|
+
config = Config.load(args.config)
|
|
134
|
+
logger.info("Loaded configuration: %s", config)
|
|
135
|
+
return config
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.error("Error loading configuration: %s", e)
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _validate_paths(args: Args) -> Optional[list[Path]]:
|
|
142
|
+
"""Validate paths from arguments. Returns list of paths or None on error."""
|
|
143
|
+
paths: list[Path] = []
|
|
144
|
+
for path in args.paths:
|
|
145
|
+
if not path.exists():
|
|
146
|
+
logger.error("Path does not exist: %s", path)
|
|
147
|
+
return None
|
|
148
|
+
paths.append(path)
|
|
149
|
+
return paths
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _analyze_files_safely(
|
|
153
|
+
analyzer: Analyzer, paths: list[Path]
|
|
154
|
+
) -> Optional[AnalysisResult]:
|
|
155
|
+
"""Analyze files and handle errors. Returns result or None on error."""
|
|
156
|
+
try:
|
|
157
|
+
logger.info("Analyzing %d path(s)...", len(paths))
|
|
158
|
+
return analyzer.analyze_files(paths)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.error("Error during analysis: %s", e)
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _get_min_severity(severity_str: str) -> Severity:
|
|
165
|
+
"""Get Severity enum from string."""
|
|
166
|
+
return SEVERITY_MAP[severity_str]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _filter_by_severity(result: AnalysisResult, min_severity: Severity) -> None:
|
|
170
|
+
"""Filter issues by minimum severity in-place."""
|
|
171
|
+
for file_analysis in result.file_analyses:
|
|
172
|
+
file_analysis.issues = [
|
|
173
|
+
issue for issue in file_analysis.issues if issue.severity >= min_severity
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _has_critical_issues(result: AnalysisResult) -> bool:
|
|
178
|
+
"""Check if result has HIGH or MEDIUM severity issues."""
|
|
179
|
+
return any(
|
|
180
|
+
issue.severity in (Severity.HIGH, Severity.MEDIUM)
|
|
181
|
+
for issue in result.get_all_issues()
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def main() -> int:
|
|
186
|
+
"""Main entry point."""
|
|
187
|
+
args = parse_arguments()
|
|
188
|
+
|
|
189
|
+
# Handle version
|
|
190
|
+
version_exit = _handle_version(args)
|
|
191
|
+
if version_exit is not None:
|
|
192
|
+
return version_exit
|
|
193
|
+
|
|
194
|
+
# Check if paths were provided
|
|
195
|
+
if not args.paths:
|
|
196
|
+
logger.error("No paths provided")
|
|
197
|
+
return 2
|
|
198
|
+
|
|
199
|
+
# Configure logging
|
|
200
|
+
_configure_logging(args)
|
|
201
|
+
|
|
202
|
+
# Load configuration
|
|
203
|
+
config = _load_config(args)
|
|
204
|
+
if config is None:
|
|
205
|
+
return 2
|
|
206
|
+
|
|
207
|
+
# Validate paths
|
|
208
|
+
paths = _validate_paths(args)
|
|
209
|
+
if paths is None:
|
|
210
|
+
return 2
|
|
211
|
+
|
|
212
|
+
# Create analyzer and analyze files
|
|
213
|
+
analyzer = Analyzer(config)
|
|
214
|
+
result = _analyze_files_safely(analyzer, paths)
|
|
215
|
+
if result is None:
|
|
216
|
+
return 2
|
|
217
|
+
|
|
218
|
+
# Filter by minimum severity
|
|
219
|
+
min_severity = _get_min_severity(args.min_severity)
|
|
220
|
+
_filter_by_severity(result, min_severity)
|
|
221
|
+
|
|
222
|
+
# Report results
|
|
223
|
+
reporter = ConsoleReporter()
|
|
224
|
+
reporter.report(result, group_by=args.group_by)
|
|
225
|
+
|
|
226
|
+
# Determine exit code
|
|
227
|
+
return 1 if _has_critical_issues(result) else 0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
sys.exit(main())
|
pyrefactor/analyzer.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Main analyzer orchestrator for PyRefactor."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import concurrent.futures
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .ast_visitor import BaseDetector
|
|
9
|
+
from .config import Config
|
|
10
|
+
from .detectors import (
|
|
11
|
+
BooleanLogicDetector,
|
|
12
|
+
ComparisonsDetector,
|
|
13
|
+
ComplexityDetector,
|
|
14
|
+
ContextManagerDetector,
|
|
15
|
+
ControlFlowDetector,
|
|
16
|
+
DictOperationsDetector,
|
|
17
|
+
DuplicationDetector,
|
|
18
|
+
LoopsDetector,
|
|
19
|
+
PerformanceDetector,
|
|
20
|
+
)
|
|
21
|
+
from .models import AnalysisResult, FileAnalysis
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Analyzer:
|
|
27
|
+
"""Main analyzer that orchestrates all detectors."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: Config) -> None:
|
|
30
|
+
"""Initialize analyzer with configuration."""
|
|
31
|
+
self.config = config
|
|
32
|
+
|
|
33
|
+
def _create_detectors(
|
|
34
|
+
self, file_path: str, source_lines: list[str]
|
|
35
|
+
) -> list[BaseDetector]:
|
|
36
|
+
"""Create all enabled detectors for a file.
|
|
37
|
+
|
|
38
|
+
Factory method to consolidate detector initialization and reduce duplication.
|
|
39
|
+
"""
|
|
40
|
+
detectors: list[BaseDetector] = []
|
|
41
|
+
|
|
42
|
+
# Complexity detector (always enabled)
|
|
43
|
+
detectors.append(ComplexityDetector(self.config, file_path, source_lines))
|
|
44
|
+
|
|
45
|
+
# Conditionally enabled detectors
|
|
46
|
+
detector_configs = [
|
|
47
|
+
(self.config.performance.enabled, PerformanceDetector),
|
|
48
|
+
(self.config.boolean_logic.enabled, BooleanLogicDetector),
|
|
49
|
+
(self.config.loops.enabled, LoopsDetector),
|
|
50
|
+
(self.config.duplication.enabled, DuplicationDetector),
|
|
51
|
+
(self.config.context_manager.enabled, ContextManagerDetector),
|
|
52
|
+
(self.config.control_flow.enabled, ControlFlowDetector),
|
|
53
|
+
(self.config.dict_operations.enabled, DictOperationsDetector),
|
|
54
|
+
(self.config.comparisons.enabled, ComparisonsDetector),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
for enabled, detector_class in detector_configs:
|
|
58
|
+
if enabled:
|
|
59
|
+
detectors.append(detector_class(self.config, file_path, source_lines))
|
|
60
|
+
|
|
61
|
+
return detectors
|
|
62
|
+
|
|
63
|
+
def analyze_file(self, file_path: Path) -> FileAnalysis:
|
|
64
|
+
"""Analyze a single Python file."""
|
|
65
|
+
analysis = FileAnalysis(file_path=str(file_path))
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
# Read the file
|
|
69
|
+
source_code = file_path.read_text(encoding="utf-8")
|
|
70
|
+
source_lines = source_code.splitlines()
|
|
71
|
+
analysis.lines_of_code = len(source_lines)
|
|
72
|
+
|
|
73
|
+
# Parse the AST
|
|
74
|
+
try:
|
|
75
|
+
tree = ast.parse(source_code, filename=str(file_path))
|
|
76
|
+
except SyntaxError as e:
|
|
77
|
+
analysis.parse_error = f"Syntax error: {e}"
|
|
78
|
+
return analysis
|
|
79
|
+
|
|
80
|
+
# Create and run all enabled detectors
|
|
81
|
+
detectors = self._create_detectors(str(file_path), source_lines)
|
|
82
|
+
self._run_detectors(detectors, tree, analysis, file_path)
|
|
83
|
+
|
|
84
|
+
except Exception as e:
|
|
85
|
+
analysis.parse_error = f"Error analyzing file: {e}"
|
|
86
|
+
logger.error("Error analyzing %s: %s", file_path, e)
|
|
87
|
+
|
|
88
|
+
return analysis
|
|
89
|
+
|
|
90
|
+
def _run_detectors(
|
|
91
|
+
self,
|
|
92
|
+
detectors: list[BaseDetector],
|
|
93
|
+
tree: ast.Module,
|
|
94
|
+
analysis: FileAnalysis,
|
|
95
|
+
file_path: Path,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Run all detectors and collect issues."""
|
|
98
|
+
for detector in detectors:
|
|
99
|
+
try:
|
|
100
|
+
issues = detector.analyze(tree)
|
|
101
|
+
for issue in issues:
|
|
102
|
+
analysis.add_issue(issue)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.error(
|
|
105
|
+
"Error running %s on %s: %s",
|
|
106
|
+
detector.get_detector_name(),
|
|
107
|
+
file_path,
|
|
108
|
+
e,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def analyze_directory(
|
|
112
|
+
self, directory: Path, max_workers: int = 4
|
|
113
|
+
) -> AnalysisResult:
|
|
114
|
+
"""Analyze all Python files in a directory."""
|
|
115
|
+
result = AnalysisResult()
|
|
116
|
+
|
|
117
|
+
# Find all Python files
|
|
118
|
+
python_files = list(directory.rglob("*.py"))
|
|
119
|
+
|
|
120
|
+
# Filter excluded patterns
|
|
121
|
+
python_files = self._filter_excluded_files(python_files)
|
|
122
|
+
|
|
123
|
+
if not python_files:
|
|
124
|
+
logger.warning("No Python files found in %s", directory)
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
# Analyze files in parallel
|
|
128
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
129
|
+
future_to_file = {
|
|
130
|
+
executor.submit(self.analyze_file, file_path): file_path
|
|
131
|
+
for file_path in python_files
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for future in concurrent.futures.as_completed(future_to_file):
|
|
135
|
+
file_path = future_to_file[future]
|
|
136
|
+
try:
|
|
137
|
+
analysis = future.result()
|
|
138
|
+
result.add_file_analysis(analysis)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.error("Error analyzing %s: %s", file_path, e)
|
|
141
|
+
result.add_file_analysis(
|
|
142
|
+
FileAnalysis(
|
|
143
|
+
file_path=str(file_path),
|
|
144
|
+
parse_error=f"Analysis failed: {e}",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
def analyze_files(self, file_paths: list[Path]) -> AnalysisResult:
|
|
151
|
+
"""Analyze a list of Python files."""
|
|
152
|
+
result = AnalysisResult()
|
|
153
|
+
|
|
154
|
+
for file_path in file_paths:
|
|
155
|
+
self._process_path(file_path, result)
|
|
156
|
+
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
def _process_path(self, file_path: Path, result: AnalysisResult) -> None:
|
|
160
|
+
"""Process a single file or directory path.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
file_path: Path to file or directory
|
|
164
|
+
result: AnalysisResult to add analyses to
|
|
165
|
+
"""
|
|
166
|
+
if file_path.is_file():
|
|
167
|
+
analysis = self.analyze_file(file_path)
|
|
168
|
+
result.add_file_analysis(analysis)
|
|
169
|
+
elif file_path.is_dir():
|
|
170
|
+
dir_result = self.analyze_directory(file_path)
|
|
171
|
+
for analysis in dir_result.file_analyses:
|
|
172
|
+
result.add_file_analysis(analysis)
|
|
173
|
+
|
|
174
|
+
def _filter_excluded_files(self, files: list[Path]) -> list[Path]:
|
|
175
|
+
"""Filter out files matching exclusion patterns."""
|
|
176
|
+
if not self.config.exclude_patterns:
|
|
177
|
+
return files
|
|
178
|
+
|
|
179
|
+
return [
|
|
180
|
+
file_path
|
|
181
|
+
for file_path in files
|
|
182
|
+
if not any(
|
|
183
|
+
pattern in str(file_path) for pattern in self.config.exclude_patterns
|
|
184
|
+
)
|
|
185
|
+
]
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Base AST visitor framework for PyRefactor detectors."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Union
|
|
6
|
+
|
|
7
|
+
from .config import Config
|
|
8
|
+
from .models import Issue
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseDetector(ast.NodeVisitor, ABC):
|
|
12
|
+
"""Base class for all detectors."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, config: Config, file_path: str, source_lines: list[str]) -> None:
|
|
15
|
+
"""Initialize detector with configuration and source context."""
|
|
16
|
+
self.config = config
|
|
17
|
+
self.file_path = file_path
|
|
18
|
+
self.source_lines = source_lines
|
|
19
|
+
self.issues: list[Issue] = []
|
|
20
|
+
self.current_function: Union[ast.FunctionDef, ast.AsyncFunctionDef, None] = None
|
|
21
|
+
self.nesting_level = 0
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def get_detector_name(self) -> str:
|
|
25
|
+
"""Return the name of this detector."""
|
|
26
|
+
|
|
27
|
+
def add_issue(self, issue: Issue) -> None:
|
|
28
|
+
"""Add an issue to the detector's list."""
|
|
29
|
+
self.issues.append(issue)
|
|
30
|
+
|
|
31
|
+
def get_source_line(self, line: int) -> str:
|
|
32
|
+
"""Get a specific source line (1-indexed)."""
|
|
33
|
+
if 1 <= line <= len(self.source_lines):
|
|
34
|
+
return self.source_lines[line - 1]
|
|
35
|
+
return ""
|
|
36
|
+
|
|
37
|
+
def get_source_snippet(self, start_line: int, end_line: int) -> str:
|
|
38
|
+
"""Get a snippet of source code."""
|
|
39
|
+
if start_line < 1 or end_line > len(self.source_lines):
|
|
40
|
+
return ""
|
|
41
|
+
return "\n".join(self.source_lines[start_line - 1 : end_line])
|
|
42
|
+
|
|
43
|
+
def is_suppressed(self, node: ast.AST) -> bool:
|
|
44
|
+
"""Check if a node has a suppression comment."""
|
|
45
|
+
if not hasattr(node, "lineno"):
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
lineno: int = node.lineno
|
|
49
|
+
line = self.get_source_line(lineno)
|
|
50
|
+
# Check for suppression comments
|
|
51
|
+
if "# pyrefactor: ignore" in line or "# noqa" in line:
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
# Check previous line for suppression
|
|
55
|
+
if lineno > 1:
|
|
56
|
+
prev_line = self.get_source_line(lineno - 1)
|
|
57
|
+
if "# pyrefactor: ignore" in prev_line or "# noqa" in prev_line:
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
def analyze(self, tree: ast.AST) -> list[Issue]:
|
|
63
|
+
"""Run the detector on an AST and return issues found."""
|
|
64
|
+
self.visit(tree)
|
|
65
|
+
return self.issues
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def calculate_cyclomatic_complexity(
|
|
69
|
+
node: Union[ast.FunctionDef, ast.AsyncFunctionDef],
|
|
70
|
+
) -> int:
|
|
71
|
+
"""Calculate cyclomatic complexity of a function."""
|
|
72
|
+
|
|
73
|
+
class ComplexityVisitor(ast.NodeVisitor):
|
|
74
|
+
"""Visitor to count decision points."""
|
|
75
|
+
|
|
76
|
+
def __init__(self) -> None:
|
|
77
|
+
self.complexity = 1 # Base complexity
|
|
78
|
+
|
|
79
|
+
def visit_If(self, node: ast.If) -> None:
|
|
80
|
+
"""Count if statements."""
|
|
81
|
+
self._increment_and_visit(node)
|
|
82
|
+
|
|
83
|
+
def visit_For(self, node: ast.For) -> None:
|
|
84
|
+
"""Count for loops."""
|
|
85
|
+
self._increment_and_visit(node)
|
|
86
|
+
|
|
87
|
+
def visit_While(self, node: ast.While) -> None:
|
|
88
|
+
"""Count while loops."""
|
|
89
|
+
self._increment_and_visit(node)
|
|
90
|
+
|
|
91
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
92
|
+
"""Count except handlers."""
|
|
93
|
+
self._increment_and_visit(node)
|
|
94
|
+
|
|
95
|
+
def visit_With(self, node: ast.With) -> None:
|
|
96
|
+
"""Count with statements."""
|
|
97
|
+
self._increment_and_visit(node)
|
|
98
|
+
|
|
99
|
+
def visit_Assert(self, node: ast.Assert) -> None:
|
|
100
|
+
"""Count assertions."""
|
|
101
|
+
self._increment_and_visit(node)
|
|
102
|
+
|
|
103
|
+
def visit_BoolOp(self, node: ast.BoolOp) -> None:
|
|
104
|
+
"""Count boolean operations (and/or)."""
|
|
105
|
+
self.complexity += len(node.values) - 1
|
|
106
|
+
self.generic_visit(node)
|
|
107
|
+
|
|
108
|
+
def _increment_and_visit(self, node: ast.AST) -> None:
|
|
109
|
+
"""Increment complexity and continue visiting."""
|
|
110
|
+
self.complexity += 1
|
|
111
|
+
self.generic_visit(node)
|
|
112
|
+
|
|
113
|
+
visitor = ComplexityVisitor()
|
|
114
|
+
visitor.visit(node)
|
|
115
|
+
return visitor.complexity
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def count_nesting_depth(node: ast.AST) -> int:
|
|
119
|
+
"""Calculate maximum nesting depth in a node."""
|
|
120
|
+
|
|
121
|
+
class NestingVisitor(ast.NodeVisitor):
|
|
122
|
+
"""Visitor to track nesting depth."""
|
|
123
|
+
|
|
124
|
+
def __init__(self) -> None:
|
|
125
|
+
self.current_depth = 0
|
|
126
|
+
self.max_depth = 0
|
|
127
|
+
|
|
128
|
+
def visit_If(self, node: ast.If) -> None:
|
|
129
|
+
"""Track if nesting."""
|
|
130
|
+
self._visit_nested(node)
|
|
131
|
+
|
|
132
|
+
def visit_For(self, node: ast.For) -> None:
|
|
133
|
+
"""Track for loop nesting."""
|
|
134
|
+
self._visit_nested(node)
|
|
135
|
+
|
|
136
|
+
def visit_While(self, node: ast.While) -> None:
|
|
137
|
+
"""Track while loop nesting."""
|
|
138
|
+
self._visit_nested(node)
|
|
139
|
+
|
|
140
|
+
def visit_With(self, node: ast.With) -> None:
|
|
141
|
+
"""Track with statement nesting."""
|
|
142
|
+
self._visit_nested(node)
|
|
143
|
+
|
|
144
|
+
def visit_Try(self, node: ast.Try) -> None:
|
|
145
|
+
"""Track try block nesting."""
|
|
146
|
+
self._visit_nested(node)
|
|
147
|
+
|
|
148
|
+
def _visit_nested(self, node: ast.AST) -> None:
|
|
149
|
+
"""Visit a nested structure."""
|
|
150
|
+
self.current_depth += 1
|
|
151
|
+
self.max_depth = max(self.max_depth, self.current_depth)
|
|
152
|
+
self.generic_visit(node)
|
|
153
|
+
self.current_depth -= 1
|
|
154
|
+
|
|
155
|
+
visitor = NestingVisitor()
|
|
156
|
+
visitor.visit(node)
|
|
157
|
+
return visitor.max_depth
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def count_branches(node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> int:
|
|
161
|
+
"""Count the number of branches in a function."""
|
|
162
|
+
|
|
163
|
+
class BranchVisitor(ast.NodeVisitor):
|
|
164
|
+
"""Visitor to count branches."""
|
|
165
|
+
|
|
166
|
+
def __init__(self) -> None:
|
|
167
|
+
self.branches = 0
|
|
168
|
+
|
|
169
|
+
def visit_If(self, node: ast.If) -> None:
|
|
170
|
+
"""Count if/elif branches."""
|
|
171
|
+
self.branches += 1
|
|
172
|
+
if node.orelse:
|
|
173
|
+
# Check if else contains another if (elif)
|
|
174
|
+
if len(node.orelse) == 1 and isinstance(node.orelse[0], ast.If):
|
|
175
|
+
pass # Will be counted when we visit that node
|
|
176
|
+
else:
|
|
177
|
+
self.branches += 1 # else branch
|
|
178
|
+
self.generic_visit(node)
|
|
179
|
+
|
|
180
|
+
def visit_For(self, node: ast.For) -> None:
|
|
181
|
+
"""Count for loops as branches."""
|
|
182
|
+
self.branches += 1
|
|
183
|
+
self.generic_visit(node)
|
|
184
|
+
|
|
185
|
+
def visit_While(self, node: ast.While) -> None:
|
|
186
|
+
"""Count while loops as branches."""
|
|
187
|
+
self.branches += 1
|
|
188
|
+
self.generic_visit(node)
|
|
189
|
+
|
|
190
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
191
|
+
"""Count exception handlers."""
|
|
192
|
+
self.branches += 1
|
|
193
|
+
self.generic_visit(node)
|
|
194
|
+
|
|
195
|
+
visitor = BranchVisitor()
|
|
196
|
+
visitor.visit(node)
|
|
197
|
+
return visitor.branches
|