qgis-plugin-analyzer 1.3.0__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.
- __init__.py +19 -0
- analyzer/__init__.py +19 -0
- analyzer/cli.py +311 -0
- analyzer/engine.py +586 -0
- analyzer/fixer.py +314 -0
- analyzer/models/__init__.py +5 -0
- analyzer/models/analysis_models.py +62 -0
- analyzer/reporters/__init__.py +10 -0
- analyzer/reporters/html_reporter.py +388 -0
- analyzer/reporters/markdown_reporter.py +212 -0
- analyzer/reporters/summary_reporter.py +222 -0
- analyzer/rules/__init__.py +10 -0
- analyzer/rules/modernization_rules.py +33 -0
- analyzer/rules/qgis_rules.py +74 -0
- analyzer/scanner.py +794 -0
- analyzer/semantic.py +213 -0
- analyzer/transformers.py +190 -0
- analyzer/utils/__init__.py +39 -0
- analyzer/utils/ast_utils.py +133 -0
- analyzer/utils/config_utils.py +145 -0
- analyzer/utils/logging_utils.py +46 -0
- analyzer/utils/path_utils.py +135 -0
- analyzer/utils/performance_utils.py +150 -0
- analyzer/validators.py +263 -0
- qgis_plugin_analyzer-1.3.0.dist-info/METADATA +239 -0
- qgis_plugin_analyzer-1.3.0.dist-info/RECORD +30 -0
- qgis_plugin_analyzer-1.3.0.dist-info/WHEEL +5 -0
- qgis_plugin_analyzer-1.3.0.dist-info/entry_points.txt +2 -0
- qgis_plugin_analyzer-1.3.0.dist-info/licenses/LICENSE +677 -0
- qgis_plugin_analyzer-1.3.0.dist-info/top_level.txt +2 -0
analyzer/semantic.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# /***************************************************************************
|
|
2
|
+
# QGIS Plugin Analyzer
|
|
3
|
+
#
|
|
4
|
+
# Semantic analysis module for cross-file dependency and resource validation.
|
|
5
|
+
# ***************************************************************************/
|
|
6
|
+
|
|
7
|
+
import pathlib
|
|
8
|
+
import xml.etree.ElementTree as ET
|
|
9
|
+
from typing import Any, Dict, List, Set
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DependencyGraph:
|
|
13
|
+
"""Builds and analyzes the module dependency graph to detect circular imports.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
adjacency_list: Maps module path to the set of imported module paths.
|
|
17
|
+
nodes: Maps module path to its extracted metadata.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
"""Initializes an empty dependency graph."""
|
|
22
|
+
# Maps module path -> set of imported module paths
|
|
23
|
+
self.adjacency_list: Dict[str, Set[str]] = {}
|
|
24
|
+
# Maps module path -> metadata (imports, functions, etc.)
|
|
25
|
+
self.nodes: Dict[str, Dict[str, Any]] = {}
|
|
26
|
+
|
|
27
|
+
def add_node(self, module_path: str, data: Dict[str, Any]) -> None:
|
|
28
|
+
"""Adds a module node to the graph.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
module_path: Relative path to the module file.
|
|
32
|
+
data: Metadata dictionary for the module.
|
|
33
|
+
"""
|
|
34
|
+
self.nodes[module_path] = data
|
|
35
|
+
self.adjacency_list[module_path] = set()
|
|
36
|
+
|
|
37
|
+
def build_edges(self, project_path: pathlib.Path) -> None:
|
|
38
|
+
"""Resolves module imports and builds edges between nodes.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
project_path: Root path of the project.
|
|
42
|
+
"""
|
|
43
|
+
for module_path, data in self.nodes.items():
|
|
44
|
+
current_file = project_path / module_path
|
|
45
|
+
current_dir = current_file.parent
|
|
46
|
+
|
|
47
|
+
for imp in data.get("imports", []):
|
|
48
|
+
resolved_path = self._resolve_import(imp, current_dir, project_path)
|
|
49
|
+
if resolved_path and resolved_path in self.nodes:
|
|
50
|
+
self.adjacency_list[module_path].add(resolved_path)
|
|
51
|
+
|
|
52
|
+
def _resolve_import(
|
|
53
|
+
self, import_name: str, current_dir: pathlib.Path, project_path: pathlib.Path
|
|
54
|
+
) -> str:
|
|
55
|
+
"""Attempts to resolve a Python import string to a relative file path.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
import_name: The name of the imported module or package.
|
|
59
|
+
current_dir: Directory containing the importing file.
|
|
60
|
+
project_path: Root directory of the project.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The relative path to the resolved module, or an empty string if not found.
|
|
64
|
+
"""
|
|
65
|
+
# Handle relative imports (e.g., .utils)
|
|
66
|
+
if import_name.startswith("."):
|
|
67
|
+
# This is a simplification. Ideally AST gives better level info.
|
|
68
|
+
# Assuming same package level for now if single dot
|
|
69
|
+
parts = import_name.lstrip(".").split(".")
|
|
70
|
+
target = current_dir.joinpath(*parts).with_suffix(".py")
|
|
71
|
+
try:
|
|
72
|
+
rel = str(target.relative_to(project_path))
|
|
73
|
+
return rel
|
|
74
|
+
except ValueError:
|
|
75
|
+
pass
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
# Handle absolute imports within project
|
|
79
|
+
parts = import_name.split(".")
|
|
80
|
+
target = project_path.joinpath(*parts).with_suffix(".py")
|
|
81
|
+
try:
|
|
82
|
+
rel = str(target.relative_to(project_path))
|
|
83
|
+
return rel
|
|
84
|
+
except ValueError:
|
|
85
|
+
# Maybe it is a package (__init__.py)
|
|
86
|
+
target_pkg = project_path.joinpath(*parts) / "__init__.py"
|
|
87
|
+
try:
|
|
88
|
+
rel = str(target_pkg.relative_to(project_path))
|
|
89
|
+
return rel
|
|
90
|
+
except ValueError:
|
|
91
|
+
pass
|
|
92
|
+
return ""
|
|
93
|
+
|
|
94
|
+
def detect_cycles(self) -> List[List[str]]:
|
|
95
|
+
"""Detects circular import cycles using Depth First Search (DFS).
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
A list of dependency cycles, where each cycle is a list of module paths.
|
|
99
|
+
"""
|
|
100
|
+
visited = set()
|
|
101
|
+
recursion_stack = set()
|
|
102
|
+
cycles = []
|
|
103
|
+
|
|
104
|
+
def dfs(node, path):
|
|
105
|
+
visited.add(node)
|
|
106
|
+
recursion_stack.add(node)
|
|
107
|
+
path.append(node)
|
|
108
|
+
|
|
109
|
+
for neighbor in self.adjacency_list.get(node, []):
|
|
110
|
+
if neighbor not in visited:
|
|
111
|
+
dfs(neighbor, path)
|
|
112
|
+
elif neighbor in recursion_stack:
|
|
113
|
+
# Cycle found
|
|
114
|
+
cycle_start_index = path.index(neighbor)
|
|
115
|
+
cycles.append(path[cycle_start_index:] + [neighbor])
|
|
116
|
+
|
|
117
|
+
recursion_stack.remove(node)
|
|
118
|
+
path.pop()
|
|
119
|
+
|
|
120
|
+
for node in self.nodes:
|
|
121
|
+
if node not in visited:
|
|
122
|
+
dfs(node, [])
|
|
123
|
+
|
|
124
|
+
return cycles
|
|
125
|
+
|
|
126
|
+
def get_coupling_metrics(self) -> Dict[str, Dict[str, int]]:
|
|
127
|
+
"""Calculates Fan-In and Fan-Out metrics for each module in the graph.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
A dictionary mapping module paths to their coupling metrics.
|
|
131
|
+
"""
|
|
132
|
+
metrics = {node: {"fan_in": 0, "fan_out": 0} for node in self.nodes}
|
|
133
|
+
|
|
134
|
+
for source, targets in self.adjacency_list.items():
|
|
135
|
+
metrics[source]["fan_out"] = len(targets)
|
|
136
|
+
for target in targets:
|
|
137
|
+
if target in metrics: # Should always be true if graph valid
|
|
138
|
+
metrics[target]["fan_in"] += 1
|
|
139
|
+
|
|
140
|
+
return metrics
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ResourceValidator:
|
|
144
|
+
"""Validates Qt resource (QRC) usage against available definitions.
|
|
145
|
+
|
|
146
|
+
Attributes:
|
|
147
|
+
project_path: Path to the root of the project.
|
|
148
|
+
available_resources: A set of detected resource strings (e.g., ':/plugins/...').
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self, project_path: pathlib.Path) -> None:
|
|
152
|
+
"""Initializes the resource validator.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
project_path: Root path of the project.
|
|
156
|
+
"""
|
|
157
|
+
self.project_path = project_path
|
|
158
|
+
self.available_resources: Set[str] = set()
|
|
159
|
+
|
|
160
|
+
def scan_project_resources(self, ignore_matcher: Any = None) -> None:
|
|
161
|
+
"""Scans the project for .qrc files and extracts available resource paths.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
ignore_matcher: Optional object to determine if a path should be ignored.
|
|
165
|
+
"""
|
|
166
|
+
# Strategy: Parse .qrc files primarily as they are the source of truth
|
|
167
|
+
# Regex to find <file>path/to/icon.png</file> inside <qresource prefix="/plugins/myplugin">
|
|
168
|
+
|
|
169
|
+
for qrc_file in self.project_path.rglob("*.qrc"):
|
|
170
|
+
# Skip if matches ignore pattern
|
|
171
|
+
if ignore_matcher and ignore_matcher.is_ignored(qrc_file):
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
# Use standard xml.etree.ElementTree for robust parsing
|
|
176
|
+
# Note: ElementTree is safe against XXE by default in Python 3.x
|
|
177
|
+
# as it does not resolve entities unless a custom parser is provided.
|
|
178
|
+
try:
|
|
179
|
+
tree = ET.parse(qrc_file)
|
|
180
|
+
root = tree.getroot()
|
|
181
|
+
for qresource in root.findall("qresource"):
|
|
182
|
+
prefix = qresource.get("prefix", "/")
|
|
183
|
+
if not prefix.startswith("/"):
|
|
184
|
+
prefix = "/" + prefix
|
|
185
|
+
|
|
186
|
+
for file_elem in qresource.findall("file"):
|
|
187
|
+
if file_elem.text:
|
|
188
|
+
clean_path = file_elem.text.strip()
|
|
189
|
+
# Construct full resource path: :/prefix/path
|
|
190
|
+
res_path = f":{prefix}/{clean_path}".replace("//", "/")
|
|
191
|
+
self.available_resources.add(res_path)
|
|
192
|
+
except ET.ParseError:
|
|
193
|
+
pass # Fallback or log warning
|
|
194
|
+
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
def validate_usage(self, resource_matches: List[str]) -> List[str]:
|
|
199
|
+
"""Identifies resource paths used in code that are missing from definition files.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
resource_matches: List of resource strings extracted from the code.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
A list of unique, missing resource strings.
|
|
206
|
+
"""
|
|
207
|
+
missing = []
|
|
208
|
+
for res in resource_matches:
|
|
209
|
+
# Simple exact match check.
|
|
210
|
+
# Note: Alias handling is complex without compilation, ignoring for now.
|
|
211
|
+
if res not in self.available_resources:
|
|
212
|
+
missing.append(res)
|
|
213
|
+
return sorted(list(set(missing)))
|
analyzer/transformers.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# /***************************************************************************
|
|
2
|
+
# QGIS Plugin Analyzer
|
|
3
|
+
#
|
|
4
|
+
# AST-based code transformers for auto-fixing common issues.
|
|
5
|
+
# ***************************************************************************/
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import pathlib
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GDALImportTransformer(ast.NodeTransformer):
|
|
13
|
+
"""AST transformer that replaces direct GDAL imports with the OSGeo version.
|
|
14
|
+
|
|
15
|
+
Transforms 'import gdal' into 'from osgeo import gdal'.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
"""Initializes the transformer state."""
|
|
20
|
+
self.changes_made = False
|
|
21
|
+
|
|
22
|
+
def visit_Import(self, node: ast.Import) -> Optional[ast.AST]:
|
|
23
|
+
for alias in node.names:
|
|
24
|
+
if alias.name == "gdal":
|
|
25
|
+
self.changes_made = True
|
|
26
|
+
# Create 'from osgeo import gdal'
|
|
27
|
+
return ast.ImportFrom(
|
|
28
|
+
module="osgeo",
|
|
29
|
+
names=[ast.alias(name="gdal", asname=alias.asname)],
|
|
30
|
+
level=0,
|
|
31
|
+
)
|
|
32
|
+
return node
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LegacyImportTransformer(ast.NodeTransformer):
|
|
36
|
+
"""AST transformer that modernizes PyQt4/PyQt5 imports to qgis.PyQt.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
changes_made: Boolean flag indicating if any changes were applied.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
"""Initializes the transformer state."""
|
|
44
|
+
self.changes_made = False
|
|
45
|
+
|
|
46
|
+
def visit_Import(self, node: ast.Import) -> ast.Import:
|
|
47
|
+
for alias in node.names:
|
|
48
|
+
if alias.name.startswith(("PyQt4", "PyQt5")):
|
|
49
|
+
self.changes_made = True
|
|
50
|
+
# Replace PyQt5.QtCore -> qgis.PyQt.QtCore
|
|
51
|
+
new_name = alias.name.replace("PyQt5", "qgis.PyQt").replace("PyQt4", "qgis.PyQt")
|
|
52
|
+
alias.name = new_name
|
|
53
|
+
return node
|
|
54
|
+
|
|
55
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> ast.ImportFrom:
|
|
56
|
+
if node.module and node.module.startswith(("PyQt4", "PyQt5")):
|
|
57
|
+
self.changes_made = True
|
|
58
|
+
node.module = node.module.replace("PyQt5", "qgis.PyQt").replace("PyQt4", "qgis.PyQt")
|
|
59
|
+
return node
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PrintToLogTransformer(ast.NodeTransformer):
|
|
63
|
+
"""AST transformer that replaces print() calls with QgsMessageLog.logMessage().
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
changes_made: Boolean flag indicating if any changes were applied.
|
|
67
|
+
needs_import: Boolean flag indicating if a new import is required.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
"""Initializes the transformer state."""
|
|
72
|
+
self.changes_made = False
|
|
73
|
+
self.needs_import = False
|
|
74
|
+
|
|
75
|
+
def visit_Expr(self, node: ast.Expr) -> ast.Expr:
|
|
76
|
+
# Check if it's a print() call
|
|
77
|
+
if isinstance(node.value, ast.Call):
|
|
78
|
+
if isinstance(node.value.func, ast.Name) and node.value.func.id == "print":
|
|
79
|
+
self.changes_made = True
|
|
80
|
+
self.needs_import = True
|
|
81
|
+
|
|
82
|
+
# Get the message argument
|
|
83
|
+
if node.value.args:
|
|
84
|
+
message = node.value.args[0]
|
|
85
|
+
else:
|
|
86
|
+
message = ast.Constant(value="")
|
|
87
|
+
|
|
88
|
+
# Create QgsMessageLog.logMessage(message, "Plugin", Qgis.Info)
|
|
89
|
+
new_call = ast.Expr(
|
|
90
|
+
value=ast.Call(
|
|
91
|
+
func=ast.Attribute(
|
|
92
|
+
value=ast.Name(id="QgsMessageLog", ctx=ast.Load()),
|
|
93
|
+
attr="logMessage",
|
|
94
|
+
ctx=ast.Load(),
|
|
95
|
+
),
|
|
96
|
+
args=[
|
|
97
|
+
message,
|
|
98
|
+
ast.Constant(value="Plugin"),
|
|
99
|
+
ast.Attribute(
|
|
100
|
+
value=ast.Name(id="Qgis", ctx=ast.Load()),
|
|
101
|
+
attr="Info",
|
|
102
|
+
ctx=ast.Load(),
|
|
103
|
+
),
|
|
104
|
+
],
|
|
105
|
+
keywords=[],
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
return new_call
|
|
109
|
+
return node
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class I18nTransformer(ast.NodeTransformer):
|
|
113
|
+
"""AST transformer that wraps UI strings in self.tr() for internationalization.
|
|
114
|
+
|
|
115
|
+
Attributes:
|
|
116
|
+
changes_made: Boolean flag indicating if any changes were applied.
|
|
117
|
+
i18n_methods: Set of method names that accept strings for UI display.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self) -> None:
|
|
121
|
+
"""Initializes the transformer state."""
|
|
122
|
+
self.changes_made = False
|
|
123
|
+
self.i18n_methods = {
|
|
124
|
+
"setText",
|
|
125
|
+
"setWindowTitle",
|
|
126
|
+
"setTitle",
|
|
127
|
+
"setToolTip",
|
|
128
|
+
"setPlaceholderText",
|
|
129
|
+
"setTabText",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
def visit_Call(self, node: ast.Call) -> ast.Call:
|
|
133
|
+
# Check if it's a UI method call with a string literal
|
|
134
|
+
if isinstance(node.func, ast.Attribute):
|
|
135
|
+
if node.func.attr in self.i18n_methods:
|
|
136
|
+
# Check if first argument is a string literal
|
|
137
|
+
if node.args and isinstance(node.args[0], ast.Constant):
|
|
138
|
+
if isinstance(node.args[0].value, str):
|
|
139
|
+
val = node.args[0].value
|
|
140
|
+
# Skip empty strings or placeholders
|
|
141
|
+
if val.strip() and not val.startswith("%"):
|
|
142
|
+
self.changes_made = True
|
|
143
|
+
# Wrap in self.tr()
|
|
144
|
+
node.args[0] = ast.Call(
|
|
145
|
+
func=ast.Attribute(
|
|
146
|
+
value=ast.Name(id="self", ctx=ast.Load()),
|
|
147
|
+
attr="tr",
|
|
148
|
+
ctx=ast.Load(),
|
|
149
|
+
),
|
|
150
|
+
args=[ast.Constant(value=val)],
|
|
151
|
+
keywords=[],
|
|
152
|
+
)
|
|
153
|
+
self.generic_visit(node)
|
|
154
|
+
return node
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def apply_transformation(file_path: pathlib.Path, transformer: ast.NodeTransformer) -> bool:
|
|
158
|
+
"""Applies an AST transformation to a file and writes back the modified code.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
file_path: Path to the Python file to transform.
|
|
162
|
+
transformer: The AST node transformer to apply.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
True if the file was modified, False otherwise.
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
content = file_path.read_text(encoding="utf-8")
|
|
169
|
+
tree = ast.parse(content)
|
|
170
|
+
|
|
171
|
+
# Apply transformation
|
|
172
|
+
new_tree = transformer.visit(tree)
|
|
173
|
+
ast.fix_missing_locations(new_tree)
|
|
174
|
+
|
|
175
|
+
if hasattr(transformer, "changes_made") and transformer.changes_made:
|
|
176
|
+
# Unparse back to code
|
|
177
|
+
new_code = ast.unparse(new_tree)
|
|
178
|
+
|
|
179
|
+
# Add necessary imports if needed
|
|
180
|
+
if hasattr(transformer, "needs_import") and transformer.needs_import:
|
|
181
|
+
if "from qgis.core import QgsMessageLog, Qgis" not in new_code:
|
|
182
|
+
new_code = "from qgis.core import QgsMessageLog, Qgis\n\n" + new_code
|
|
183
|
+
|
|
184
|
+
file_path.write_text(new_code, encoding="utf-8")
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
return False
|
|
188
|
+
except Exception as e:
|
|
189
|
+
print(f"Error transforming {file_path}: {e}")
|
|
190
|
+
return False
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Utilities package for the QGIS Plugin Analyzer."""
|
|
2
|
+
|
|
3
|
+
from .ast_utils import (
|
|
4
|
+
calculate_complexity,
|
|
5
|
+
calculate_module_complexity,
|
|
6
|
+
check_main_guard,
|
|
7
|
+
extract_classes_from_ast,
|
|
8
|
+
extract_functions_from_ast,
|
|
9
|
+
extract_imports_from_ast,
|
|
10
|
+
)
|
|
11
|
+
from .config_utils import _minimal_toml_load, load_profile_config
|
|
12
|
+
from .logging_utils import logger, setup_logger
|
|
13
|
+
from .path_utils import (
|
|
14
|
+
DEFAULT_EXCLUDE,
|
|
15
|
+
IgnoreMatcher,
|
|
16
|
+
load_ignore_patterns,
|
|
17
|
+
safe_path_resolve,
|
|
18
|
+
)
|
|
19
|
+
from .performance_utils import LRUCache, ProgressTracker, timeout_manager
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"calculate_complexity",
|
|
23
|
+
"extract_functions_from_ast",
|
|
24
|
+
"extract_classes_from_ast",
|
|
25
|
+
"extract_imports_from_ast",
|
|
26
|
+
"calculate_module_complexity",
|
|
27
|
+
"check_main_guard",
|
|
28
|
+
"setup_logger",
|
|
29
|
+
"logger",
|
|
30
|
+
"safe_path_resolve",
|
|
31
|
+
"IgnoreMatcher",
|
|
32
|
+
"load_ignore_patterns",
|
|
33
|
+
"DEFAULT_EXCLUDE",
|
|
34
|
+
"load_profile_config",
|
|
35
|
+
"_minimal_toml_load",
|
|
36
|
+
"LRUCache",
|
|
37
|
+
"ProgressTracker",
|
|
38
|
+
"timeout_manager",
|
|
39
|
+
]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""AST utilities for Python code analysis.
|
|
2
|
+
|
|
3
|
+
This module provides helper functions for extracting information and calculating
|
|
4
|
+
metrics from Python Abstract Syntax Trees (AST).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
from typing import Any, Dict, List
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def calculate_complexity(node: ast.AST) -> int:
|
|
12
|
+
"""Calculates Cyclomatic Complexity for a node.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
node: The AST node to analyze.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
The cyclomatic complexity score.
|
|
19
|
+
"""
|
|
20
|
+
complexity = 1
|
|
21
|
+
for child in ast.walk(node):
|
|
22
|
+
if isinstance(
|
|
23
|
+
child,
|
|
24
|
+
(
|
|
25
|
+
ast.If,
|
|
26
|
+
ast.For,
|
|
27
|
+
ast.While,
|
|
28
|
+
ast.And,
|
|
29
|
+
ast.Or,
|
|
30
|
+
ast.ExceptHandler,
|
|
31
|
+
ast.With,
|
|
32
|
+
ast.AsyncWith,
|
|
33
|
+
),
|
|
34
|
+
):
|
|
35
|
+
complexity += 1
|
|
36
|
+
return complexity
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_functions_from_ast(tree: ast.AST) -> List[Dict[str, Any]]:
|
|
40
|
+
"""Extracts function information from AST.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
tree: The AST tree root.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A list of dictionaries containing function metadata (name, args, line, complexity, etc.).
|
|
47
|
+
"""
|
|
48
|
+
functions = []
|
|
49
|
+
for node in ast.walk(tree):
|
|
50
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
51
|
+
func_complexity = calculate_complexity(node)
|
|
52
|
+
functions.append(
|
|
53
|
+
{
|
|
54
|
+
"name": node.name,
|
|
55
|
+
"args": [arg.arg for arg in node.args.args],
|
|
56
|
+
"line": node.lineno,
|
|
57
|
+
"end_line": getattr(node, "end_lineno", node.lineno),
|
|
58
|
+
"complexity": func_complexity,
|
|
59
|
+
"docstring": ast.get_docstring(node) is not None,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
return functions
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def extract_classes_from_ast(tree: ast.AST) -> List[str]:
|
|
66
|
+
"""Extracts class information from AST.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
tree: The AST tree root.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
A list of class signatures (e.g., "ClassName(BaseClass)").
|
|
73
|
+
"""
|
|
74
|
+
classes = []
|
|
75
|
+
for node in ast.walk(tree):
|
|
76
|
+
if isinstance(node, ast.ClassDef):
|
|
77
|
+
bases = [ast.unparse(b) for b in node.bases]
|
|
78
|
+
classes.append(f"{node.name}({', '.join(bases)})" if bases else node.name)
|
|
79
|
+
return classes
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def extract_imports_from_ast(tree: ast.AST) -> List[str]:
|
|
83
|
+
"""Extracts import information from AST.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
tree: The AST tree root.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
A sorted list of imported module names.
|
|
90
|
+
"""
|
|
91
|
+
imports: list[str] = []
|
|
92
|
+
for node in ast.walk(tree):
|
|
93
|
+
if isinstance(node, ast.Import):
|
|
94
|
+
imports.extend(n.name for n in node.names)
|
|
95
|
+
elif isinstance(node, ast.ImportFrom):
|
|
96
|
+
if node.module:
|
|
97
|
+
imports.append(node.module)
|
|
98
|
+
return sorted(set(imports))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def calculate_module_complexity(tree: ast.AST) -> int:
|
|
102
|
+
"""Calculates module-level complexity based on decision points.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
tree: The AST tree root.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
The module-level complexity score.
|
|
109
|
+
"""
|
|
110
|
+
complexity = 1
|
|
111
|
+
for node in ast.walk(tree):
|
|
112
|
+
if isinstance(node, (ast.If, ast.For, ast.While, ast.And, ast.Or, ast.ExceptHandler)):
|
|
113
|
+
complexity += 1
|
|
114
|
+
return complexity
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def check_main_guard(tree: ast.AST) -> bool:
|
|
118
|
+
"""Checks if module has __name__ == '__main__' guard.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
tree: The AST tree root.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if the main guard is found, False otherwise.
|
|
125
|
+
"""
|
|
126
|
+
for node in ast.walk(tree):
|
|
127
|
+
if isinstance(node, ast.If):
|
|
128
|
+
if isinstance(node.test, ast.Compare) and isinstance(node.test.left, ast.Name):
|
|
129
|
+
if node.test.left.id == "__name__":
|
|
130
|
+
for cmp in node.test.comparators:
|
|
131
|
+
if isinstance(cmp, ast.Constant) and cmp.value == "__main__":
|
|
132
|
+
return True
|
|
133
|
+
return False
|