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/scanner.py
ADDED
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
# /***************************************************************************
|
|
2
|
+
# QGIS Plugin Analyzer
|
|
3
|
+
# A QGIS tool
|
|
4
|
+
# Static code analysis and standards audit for QGIS plugins.
|
|
5
|
+
# -------------------
|
|
6
|
+
# begin : 2025-12-28
|
|
7
|
+
# git sha : $Format:%H$
|
|
8
|
+
# copyright : (C) 2025 by Juan M Bernales
|
|
9
|
+
# email : juanbernales@gmail.com
|
|
10
|
+
# ***************************************************************************/
|
|
11
|
+
#
|
|
12
|
+
# /***************************************************************************
|
|
13
|
+
# * *
|
|
14
|
+
# * This program is free software; you can redistribute it and/or modify *
|
|
15
|
+
# * it under the terms of the GNU General Public License as published by *
|
|
16
|
+
# * the Free Software Foundation; either version 2 of the License, or *
|
|
17
|
+
# * (at your option) any later version. *
|
|
18
|
+
# * *
|
|
19
|
+
# ***************************************************************************/
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
import pathlib
|
|
23
|
+
import re
|
|
24
|
+
from typing import Any, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
from .rules.qgis_rules import I18N_METHODS, get_qgis_audit_rules
|
|
27
|
+
from .utils.ast_utils import (
|
|
28
|
+
calculate_complexity,
|
|
29
|
+
calculate_module_complexity,
|
|
30
|
+
check_main_guard,
|
|
31
|
+
extract_classes_from_ast,
|
|
32
|
+
extract_functions_from_ast,
|
|
33
|
+
extract_imports_from_ast,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class QGISASTVisitor(ast.NodeVisitor):
|
|
38
|
+
"""AST visitor to detect QGIS-specific issues."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, rel_path: str, rules_config: Optional[Dict[str, Any]] = None) -> None:
|
|
41
|
+
"""Initializes the AST visitor for a specific file.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
rel_path: Relative path to the file being analyzed.
|
|
45
|
+
rules_config: Optional configuration for audit rules and severities.
|
|
46
|
+
"""
|
|
47
|
+
self.rel_path = rel_path
|
|
48
|
+
self.issues: List[Dict[str, Any]] = []
|
|
49
|
+
self.rules_config = rules_config or {}
|
|
50
|
+
self.class_methods_stack: List[Any] = [] # Actually Set[str] but allows flexibility
|
|
51
|
+
|
|
52
|
+
# New metrics for research-based scoring
|
|
53
|
+
self.docstring_styles: List[str] = [] # List of detected styles (Google, NumPy)
|
|
54
|
+
self.type_hint_stats = {
|
|
55
|
+
"total_parameters": 0,
|
|
56
|
+
"annotated_parameters": 0,
|
|
57
|
+
"has_return_hint": 0,
|
|
58
|
+
"total_functions": 0,
|
|
59
|
+
}
|
|
60
|
+
self.docstring_stats = {"total_public_items": 0, "has_docstring": 0}
|
|
61
|
+
self.i18n_methods = I18N_METHODS
|
|
62
|
+
|
|
63
|
+
def _check_docstring_style(self, doc: Optional[str]) -> None:
|
|
64
|
+
"""Identifies Google or NumPy docstring styles within a string.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
doc: The docstring content to analyze.
|
|
68
|
+
"""
|
|
69
|
+
if not doc:
|
|
70
|
+
return
|
|
71
|
+
# Google: Args: or Returns: or Raises: as headers
|
|
72
|
+
if re.search(r"\n\s*(Args|Returns|Raises|Yields):\s*\n", doc):
|
|
73
|
+
self.docstring_styles.append("Google")
|
|
74
|
+
# NumPy: Underlined headers
|
|
75
|
+
elif re.search(r"\n(Parameters|Returns|Raises|Yields)\n\s*-{3,}", doc):
|
|
76
|
+
self.docstring_styles.append("NumPy")
|
|
77
|
+
|
|
78
|
+
def visit_Module(self, node: ast.Module) -> None:
|
|
79
|
+
"""Analyzes a module-level AST node for docstrings and other metrics.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
node: The module node to analyze.
|
|
83
|
+
"""
|
|
84
|
+
doc = ast.get_docstring(node)
|
|
85
|
+
self.docstring_stats["total_public_items"] += 1
|
|
86
|
+
if doc:
|
|
87
|
+
self.docstring_stats["has_docstring"] += 1
|
|
88
|
+
self._check_docstring_style(doc)
|
|
89
|
+
elif self._should_report("MISSING_DOCSTRING"):
|
|
90
|
+
self.issues.append(
|
|
91
|
+
{
|
|
92
|
+
"file": self.rel_path,
|
|
93
|
+
"line": 1,
|
|
94
|
+
"type": "MISSING_DOCSTRING",
|
|
95
|
+
"severity": self._get_severity("MISSING_DOCSTRING"),
|
|
96
|
+
"message": "Module is missing a docstring (PEP 257).",
|
|
97
|
+
"code": "Module: " + self.rel_path,
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
self.generic_visit(node)
|
|
101
|
+
|
|
102
|
+
def _should_report(self, rule_id: str) -> bool:
|
|
103
|
+
"""Check if rule should be reported based on config."""
|
|
104
|
+
severity = self.rules_config.get(rule_id, "warning")
|
|
105
|
+
return bool(severity != "ignore")
|
|
106
|
+
|
|
107
|
+
def _get_severity(self, rule_id: str) -> str:
|
|
108
|
+
"""Get configured severity for rule (maps to 'high', 'medium', 'low')."""
|
|
109
|
+
config_severity = self.rules_config.get(rule_id, "warning")
|
|
110
|
+
# Map config severity to internal severity
|
|
111
|
+
severity_map = {
|
|
112
|
+
"error": "high",
|
|
113
|
+
"warning": "medium",
|
|
114
|
+
"info": "low",
|
|
115
|
+
}
|
|
116
|
+
return severity_map.get(config_severity, "medium")
|
|
117
|
+
|
|
118
|
+
def _check_obsolete_api(self, node: ast.Call) -> None:
|
|
119
|
+
"""Detects usage of obsolete QGIS APIs.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
node: The function call node to analyze.
|
|
123
|
+
"""
|
|
124
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == "writeAsVectorFormat":
|
|
125
|
+
if self._should_report("OBSOLETE_API"):
|
|
126
|
+
self.issues.append(
|
|
127
|
+
{
|
|
128
|
+
"file": self.rel_path,
|
|
129
|
+
"line": node.lineno,
|
|
130
|
+
"type": "OBSOLETE_API",
|
|
131
|
+
"severity": self._get_severity("OBSOLETE_API"),
|
|
132
|
+
"message": "Obsolete writeAsVectorFormat() usage. Use writeAsVectorFormatV3().",
|
|
133
|
+
"code": ast.unparse(node),
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _check_missing_i18n(self, node: ast.Call) -> None:
|
|
138
|
+
"""Detects untranslated UI strings in common PyQGIS methods.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
node: The function call node to analyze.
|
|
142
|
+
"""
|
|
143
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr in self.i18n_methods:
|
|
144
|
+
if (
|
|
145
|
+
node.args
|
|
146
|
+
and isinstance(node.args[0], ast.Constant)
|
|
147
|
+
and isinstance(node.args[0].value, str)
|
|
148
|
+
):
|
|
149
|
+
val = node.args[0].value
|
|
150
|
+
if val.strip() and not val.startswith("%"):
|
|
151
|
+
if self._should_report("MISSING_I18N"):
|
|
152
|
+
self.issues.append(
|
|
153
|
+
{
|
|
154
|
+
"file": self.rel_path,
|
|
155
|
+
"line": node.lineno,
|
|
156
|
+
"type": "MISSING_I18N",
|
|
157
|
+
"severity": self._get_severity("MISSING_I18N"),
|
|
158
|
+
"message": f"Untranslated UI text string in '{node.func.attr}': '{val}'. Use self.tr().",
|
|
159
|
+
"code": ast.unparse(node),
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def _check_missing_slot(self, node: ast.Call) -> None:
|
|
164
|
+
"""Heuristically detects potentially missing signal slots in signal connections.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
node: The function call node to analyze.
|
|
168
|
+
"""
|
|
169
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == "connect":
|
|
170
|
+
if node.args:
|
|
171
|
+
arg = node.args[0]
|
|
172
|
+
if (
|
|
173
|
+
isinstance(arg, ast.Attribute)
|
|
174
|
+
and isinstance(arg.value, ast.Name)
|
|
175
|
+
and arg.value.id == "self"
|
|
176
|
+
):
|
|
177
|
+
slot = arg.attr
|
|
178
|
+
if self.class_methods_stack:
|
|
179
|
+
current_methods = self.class_methods_stack[-1]
|
|
180
|
+
if slot not in current_methods:
|
|
181
|
+
if self._should_report("POTENTIAL_MISSING_SLOT"):
|
|
182
|
+
self.issues.append(
|
|
183
|
+
{
|
|
184
|
+
"file": self.rel_path,
|
|
185
|
+
"line": node.lineno,
|
|
186
|
+
"type": "POTENTIAL_MISSING_SLOT",
|
|
187
|
+
"severity": self._get_severity("POTENTIAL_MISSING_SLOT"),
|
|
188
|
+
"message": f"Connected slot 'self.{slot}' not found in class definitions. Verify it is defined or inherited.",
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _check_unsafe_subprocess(self, node: ast.Call) -> None:
|
|
193
|
+
"""Detects potentially unsafe subprocess usage.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
node: The function call node to analyze.
|
|
197
|
+
"""
|
|
198
|
+
# Targets: subprocess.run, call, Popen, check_call, check_output
|
|
199
|
+
is_subprocess = False
|
|
200
|
+
if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name):
|
|
201
|
+
if node.func.value.id == "subprocess" and node.func.attr in {
|
|
202
|
+
"run",
|
|
203
|
+
"call",
|
|
204
|
+
"Popen",
|
|
205
|
+
"check_call",
|
|
206
|
+
"check_output",
|
|
207
|
+
}:
|
|
208
|
+
is_subprocess = True
|
|
209
|
+
|
|
210
|
+
if not is_subprocess:
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
# 1. Check for shell=True
|
|
214
|
+
shell_true = False
|
|
215
|
+
for kw in node.keywords:
|
|
216
|
+
if kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
217
|
+
shell_true = True
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
if shell_true:
|
|
221
|
+
if self._should_report("UNSAFE_SUBPROCESS"):
|
|
222
|
+
self.issues.append(
|
|
223
|
+
{
|
|
224
|
+
"file": self.rel_path,
|
|
225
|
+
"line": node.lineno,
|
|
226
|
+
"type": "UNSAFE_SUBPROCESS",
|
|
227
|
+
"severity": self._get_severity("UNSAFE_SUBPROCESS"),
|
|
228
|
+
"message": "Subprocess called with 'shell=True'. This is a security risk if input is unsanitized.",
|
|
229
|
+
"code": ast.unparse(node),
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
# 2. Check for unquoted variable interpolation in the command string (heuristic)
|
|
235
|
+
# If the first argument is a string (not a list) and contains % or {} or f-string
|
|
236
|
+
if node.args:
|
|
237
|
+
cmd_arg = node.args[0]
|
|
238
|
+
if isinstance(cmd_arg, (ast.JoinedStr, ast.BinOp)):
|
|
239
|
+
if self._should_report("UNSAFE_SUBPROCESS"):
|
|
240
|
+
self.issues.append(
|
|
241
|
+
{
|
|
242
|
+
"file": self.rel_path,
|
|
243
|
+
"line": node.lineno,
|
|
244
|
+
"type": "UNSAFE_SUBPROCESS",
|
|
245
|
+
"severity": self._get_severity("UNSAFE_SUBPROCESS"),
|
|
246
|
+
"message": "Possible unquoted variable injection in subprocess command. Use a list of arguments instead.",
|
|
247
|
+
"code": ast.unparse(node),
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _check_blocking_network(self, node: ast.Call) -> None:
|
|
252
|
+
"""Detects synchronous network calls in UI-related files.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
node: The function call node to analyze.
|
|
256
|
+
"""
|
|
257
|
+
is_network = False
|
|
258
|
+
# requests.get/post...
|
|
259
|
+
if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name):
|
|
260
|
+
if node.func.value.id == "requests" and node.func.attr in {
|
|
261
|
+
"get",
|
|
262
|
+
"post",
|
|
263
|
+
"put",
|
|
264
|
+
"delete",
|
|
265
|
+
"patch",
|
|
266
|
+
}:
|
|
267
|
+
is_network = True
|
|
268
|
+
|
|
269
|
+
# urllib.request.urlopen (can be deep)
|
|
270
|
+
# Note: urllib.request.urlopen(...) or urlopen(...) if from urllib.request import urlopen
|
|
271
|
+
# current AST logic check: urllib.request.urlopen
|
|
272
|
+
if not is_network:
|
|
273
|
+
attr_chain = []
|
|
274
|
+
curr = node.func
|
|
275
|
+
while isinstance(curr, ast.Attribute):
|
|
276
|
+
attr_chain.append(curr.attr)
|
|
277
|
+
curr = curr.value
|
|
278
|
+
if isinstance(curr, ast.Name):
|
|
279
|
+
attr_chain.append(curr.id)
|
|
280
|
+
|
|
281
|
+
# Chain is reversed: ['urlopen', 'request', 'urllib']
|
|
282
|
+
if attr_chain == ["urlopen", "request", "urllib"]:
|
|
283
|
+
is_network = True
|
|
284
|
+
elif attr_chain == ["urlopen"] and isinstance(node.func, ast.Name) and node.func.id == "urlopen":
|
|
285
|
+
# This would need tracking imports, but let's stick to full path for now as per plan
|
|
286
|
+
# Or check if it's just 'urlopen'
|
|
287
|
+
is_network = True
|
|
288
|
+
|
|
289
|
+
if not is_network:
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
# Check if it's a UI/GUI file
|
|
293
|
+
is_ui_file = any(kw in self.rel_path.lower() for kw in ["gui", "ui", "dialog", "widget"])
|
|
294
|
+
|
|
295
|
+
if is_ui_file:
|
|
296
|
+
if self._should_report("BLOCKING_NETWORK_CALL"):
|
|
297
|
+
self.issues.append(
|
|
298
|
+
{
|
|
299
|
+
"file": self.rel_path,
|
|
300
|
+
"line": node.lineno,
|
|
301
|
+
"type": "BLOCKING_NETWORK_CALL",
|
|
302
|
+
"severity": self._get_severity("BLOCKING_NETWORK_CALL"),
|
|
303
|
+
"message": "Synchronous network call detected in UI file. This will freeze QGIS. Use QgsTask or QNetworkAccessManager.",
|
|
304
|
+
"code": ast.unparse(node),
|
|
305
|
+
}
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
309
|
+
"""Analyzes function call nodes for multiple QGIS-specific rules.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
node: The call node to analyze.
|
|
313
|
+
"""
|
|
314
|
+
self._check_obsolete_api(node)
|
|
315
|
+
self._check_missing_i18n(node)
|
|
316
|
+
self._check_missing_slot(node)
|
|
317
|
+
self._check_unsafe_subprocess(node)
|
|
318
|
+
self._check_blocking_network(node)
|
|
319
|
+
self.generic_visit(node)
|
|
320
|
+
|
|
321
|
+
def visit_For(self, node: ast.For) -> None:
|
|
322
|
+
"""Analyzes loop nodes for performance (spatial indexing) and Pythonic patterns.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
node: The loop node to analyze.
|
|
326
|
+
"""
|
|
327
|
+
# Detect SPATIAL_INDEX (Looping features without filter)
|
|
328
|
+
# Check if iterating over .getFeatures()
|
|
329
|
+
if isinstance(node.iter, ast.Call) and isinstance(node.iter.func, ast.Attribute):
|
|
330
|
+
if node.iter.func.attr == "getFeatures":
|
|
331
|
+
# If getFeatures() has no arguments or is passed QgsFeatureRequest() with no filter,
|
|
332
|
+
# it's potentially heavy.
|
|
333
|
+
warn = False
|
|
334
|
+
if not node.iter.args:
|
|
335
|
+
warn = True
|
|
336
|
+
elif len(node.iter.args) == 1:
|
|
337
|
+
arg = node.iter.args[0]
|
|
338
|
+
# Check if it's a blank QgsFeatureRequest()
|
|
339
|
+
if (
|
|
340
|
+
isinstance(arg, ast.Call)
|
|
341
|
+
and isinstance(arg.func, ast.Name)
|
|
342
|
+
and arg.func.id == "QgsFeatureRequest"
|
|
343
|
+
):
|
|
344
|
+
if not arg.args and not arg.keywords:
|
|
345
|
+
warn = True
|
|
346
|
+
|
|
347
|
+
if warn and self._should_report("SPATIAL_INDEX"):
|
|
348
|
+
self.issues.append(
|
|
349
|
+
{
|
|
350
|
+
"file": self.rel_path,
|
|
351
|
+
"line": node.lineno,
|
|
352
|
+
"type": "SPATIAL_INDEX",
|
|
353
|
+
"severity": self._get_severity("SPATIAL_INDEX"),
|
|
354
|
+
"message": "Iteration over features with getFeatures() and no filter. Use a spatial index and QgsFeatureRequest for large layers.",
|
|
355
|
+
"code": ast.unparse(node.iter),
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Non-Pythonic Loop Detection (check for manual counters like i += 1)
|
|
360
|
+
for body_node in ast.walk(node):
|
|
361
|
+
if isinstance(body_node, ast.AugAssign) and isinstance(body_node.op, ast.Add):
|
|
362
|
+
if isinstance(body_node.target, ast.Name):
|
|
363
|
+
if isinstance(body_node.value, ast.Constant) and body_node.value.value == 1:
|
|
364
|
+
if self._should_report("NON_PYTHONIC_LOOP"):
|
|
365
|
+
self.issues.append(
|
|
366
|
+
{
|
|
367
|
+
"file": self.rel_path,
|
|
368
|
+
"line": body_node.lineno,
|
|
369
|
+
"type": "NON_PYTHONIC_LOOP",
|
|
370
|
+
"severity": self._get_severity("NON_PYTHONIC_LOOP"),
|
|
371
|
+
"message": f"Manual counter '{body_node.target.id} += 1' detected inside loop. Use enumerate() instead.",
|
|
372
|
+
"code": ast.unparse(body_node),
|
|
373
|
+
}
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
self.generic_visit(node)
|
|
377
|
+
|
|
378
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
379
|
+
"""Analyzes class definitions for mandatory methods and documentation.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
node: The class definition node to analyze.
|
|
383
|
+
"""
|
|
384
|
+
# Track methods defined in the current class context
|
|
385
|
+
methods = {
|
|
386
|
+
item.name
|
|
387
|
+
for item in node.body
|
|
388
|
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
389
|
+
}
|
|
390
|
+
self.class_methods_stack.append(methods)
|
|
391
|
+
|
|
392
|
+
# 3. Detect MANDATORY_CLEANUP
|
|
393
|
+
# Simple check: if a class has initGui, it MUST have unload
|
|
394
|
+
has_init_gui = any(
|
|
395
|
+
isinstance(m, ast.FunctionDef) and m.name == "initGui" for m in node.body
|
|
396
|
+
)
|
|
397
|
+
has_unload = any(isinstance(m, ast.FunctionDef) and m.name == "unload" for m in node.body)
|
|
398
|
+
|
|
399
|
+
if has_init_gui and not has_unload:
|
|
400
|
+
if self._should_report("MANDATORY_CLEANUP"):
|
|
401
|
+
self.issues.append(
|
|
402
|
+
{
|
|
403
|
+
"file": self.rel_path,
|
|
404
|
+
"line": node.lineno,
|
|
405
|
+
"type": "MANDATORY_CLEANUP",
|
|
406
|
+
"severity": self._get_severity("MANDATORY_CLEANUP"),
|
|
407
|
+
"message": f"Class '{node.name}' implements 'initGui()' but is missing 'unload()'. Mandatory for cleanup.",
|
|
408
|
+
"code": f"class {node.name}...",
|
|
409
|
+
}
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# Research recommendation: Missing Docstring for Classes
|
|
413
|
+
if not node.name.startswith("_"):
|
|
414
|
+
doc = ast.get_docstring(node)
|
|
415
|
+
self.docstring_stats["total_public_items"] += 1
|
|
416
|
+
if doc:
|
|
417
|
+
self.docstring_stats["has_docstring"] += 1
|
|
418
|
+
self._check_docstring_style(doc)
|
|
419
|
+
elif self._should_report("MISSING_DOCSTRING"):
|
|
420
|
+
self.issues.append(
|
|
421
|
+
{
|
|
422
|
+
"file": self.rel_path,
|
|
423
|
+
"line": node.lineno,
|
|
424
|
+
"type": "MISSING_DOCSTRING",
|
|
425
|
+
"severity": self._get_severity("MISSING_DOCSTRING"),
|
|
426
|
+
"message": f"Public class '{node.name}' is missing a docstring.",
|
|
427
|
+
"code": f"class {node.name}...",
|
|
428
|
+
}
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
self.generic_visit(node)
|
|
432
|
+
self.class_methods_stack.pop()
|
|
433
|
+
|
|
434
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
435
|
+
"""Analyzes function definitions for best practices and research-based metrics.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
node: The function definition node to analyze.
|
|
439
|
+
"""
|
|
440
|
+
# 4. Detect IFACE_AS_ARGUMENT (QGS105)
|
|
441
|
+
# Avoid passing QgisInterface as an argument
|
|
442
|
+
for arg in node.args.args:
|
|
443
|
+
if arg.annotation and isinstance(arg.annotation, ast.Name):
|
|
444
|
+
if arg.annotation.id == "QgisInterface":
|
|
445
|
+
if self._should_report("IFACE_AS_ARGUMENT"):
|
|
446
|
+
self.issues.append(
|
|
447
|
+
{
|
|
448
|
+
"file": self.rel_path,
|
|
449
|
+
"line": node.lineno,
|
|
450
|
+
"type": "IFACE_AS_ARGUMENT",
|
|
451
|
+
"severity": self._get_severity("IFACE_AS_ARGUMENT"),
|
|
452
|
+
"message": f"Function '{node.name}' receives 'QgisInterface' as an argument. Use the global 'iface' or Singleton pattern.",
|
|
453
|
+
"code": ast.unparse(node).split("\n")[0],
|
|
454
|
+
}
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# 5. Detect HIGH_COMPLEXITY
|
|
458
|
+
complexity = calculate_complexity(node)
|
|
459
|
+
if complexity > 15:
|
|
460
|
+
if self._should_report("HIGH_COMPLEXITY"):
|
|
461
|
+
self.issues.append(
|
|
462
|
+
{
|
|
463
|
+
"file": self.rel_path,
|
|
464
|
+
"line": node.lineno,
|
|
465
|
+
"type": "HIGH_COMPLEXITY",
|
|
466
|
+
"severity": self._get_severity("HIGH_COMPLEXITY"),
|
|
467
|
+
"message": f"Function '{node.name}' is too complex (CC={complexity} > 15). Consider extracting methods to improve maintainability.",
|
|
468
|
+
"code": f"def {node.name}...",
|
|
469
|
+
}
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# Research recommendation: Missing Docstring and Type Hints
|
|
473
|
+
if not node.name.startswith("_") and node.name != "__init__":
|
|
474
|
+
doc = ast.get_docstring(node)
|
|
475
|
+
self.docstring_stats["total_public_items"] += 1
|
|
476
|
+
if doc:
|
|
477
|
+
self.docstring_stats["has_docstring"] += 1
|
|
478
|
+
self._check_docstring_style(doc)
|
|
479
|
+
elif self._should_report("MISSING_DOCSTRING"):
|
|
480
|
+
self.issues.append(
|
|
481
|
+
{
|
|
482
|
+
"file": self.rel_path,
|
|
483
|
+
"line": node.lineno,
|
|
484
|
+
"type": "MISSING_DOCSTRING",
|
|
485
|
+
"severity": self._get_severity("MISSING_DOCSTRING"),
|
|
486
|
+
"message": f"Public function '{node.name}' is missing a docstring.",
|
|
487
|
+
"code": f"def {node.name}...",
|
|
488
|
+
}
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# Type Hint Stats (PEP 484)
|
|
492
|
+
if node.name != "__init__":
|
|
493
|
+
self.type_hint_stats["total_functions"] += 1
|
|
494
|
+
params = [a for a in node.args.args if a.arg != "self" and a.arg != "cls"]
|
|
495
|
+
self.type_hint_stats["total_parameters"] += len(params)
|
|
496
|
+
annotated = [a for a in params if a.annotation]
|
|
497
|
+
self.type_hint_stats["annotated_parameters"] += len(annotated)
|
|
498
|
+
if node.returns:
|
|
499
|
+
self.type_hint_stats["has_return_hint"] += 1
|
|
500
|
+
|
|
501
|
+
# Rule: MISSING_TYPE_HINTS (if zero hints in a function with params)
|
|
502
|
+
if params and not annotated and not node.returns:
|
|
503
|
+
if self._should_report("MISSING_TYPE_HINTS"):
|
|
504
|
+
self.issues.append(
|
|
505
|
+
{
|
|
506
|
+
"file": self.rel_path,
|
|
507
|
+
"line": node.lineno,
|
|
508
|
+
"type": "MISSING_TYPE_HINTS",
|
|
509
|
+
"severity": self._get_severity("MISSING_TYPE_HINTS"),
|
|
510
|
+
"message": f"Function '{node.name}' has no type annotations.",
|
|
511
|
+
"code": f"def {node.name}...",
|
|
512
|
+
}
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
self.generic_visit(node)
|
|
516
|
+
|
|
517
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
518
|
+
"""Analyzes import nodes for protected members, legacy PyQt, and GDAL usage.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
node: The import node to analyze.
|
|
522
|
+
"""
|
|
523
|
+
for alias in node.names:
|
|
524
|
+
# 5. Detect QGIS_PROTECTED_MEMBER (QGS101/102)
|
|
525
|
+
if alias.name.startswith("qgis._") and not alias.name.startswith("qgis._3d"):
|
|
526
|
+
if self._should_report("QGIS_PROTECTED_MEMBER"):
|
|
527
|
+
self.issues.append(
|
|
528
|
+
{
|
|
529
|
+
"file": self.rel_path,
|
|
530
|
+
"line": node.lineno,
|
|
531
|
+
"type": "QGIS_PROTECTED_MEMBER",
|
|
532
|
+
"severity": self._get_severity("QGIS_PROTECTED_MEMBER"),
|
|
533
|
+
"message": f"Protected member import detected: '{alias.name}'. Protected members are unstable.",
|
|
534
|
+
"code": ast.unparse(node),
|
|
535
|
+
}
|
|
536
|
+
)
|
|
537
|
+
# 6. Detect GDAL_DIRECT_IMPORT (QGS106)
|
|
538
|
+
if alias.name == "gdal":
|
|
539
|
+
if self._should_report("GDAL_DIRECT_IMPORT"):
|
|
540
|
+
self.issues.append(
|
|
541
|
+
{
|
|
542
|
+
"file": self.rel_path,
|
|
543
|
+
"line": node.lineno,
|
|
544
|
+
"type": "GDAL_DIRECT_IMPORT",
|
|
545
|
+
"severity": self._get_severity("GDAL_DIRECT_IMPORT"),
|
|
546
|
+
"message": "Direct 'gdal' import detected. Use 'from osgeo import gdal'.",
|
|
547
|
+
"code": ast.unparse(node),
|
|
548
|
+
}
|
|
549
|
+
)
|
|
550
|
+
# QGIS_LEGACY_IMPORT (already existing)
|
|
551
|
+
if alias.name.startswith(("PyQt4", "PyQt5")):
|
|
552
|
+
if self._should_report("QGIS_LEGACY_IMPORT"):
|
|
553
|
+
self.issues.append(
|
|
554
|
+
{
|
|
555
|
+
"file": self.rel_path,
|
|
556
|
+
"line": node.lineno,
|
|
557
|
+
"type": "QGIS_LEGACY_IMPORT",
|
|
558
|
+
"severity": self._get_severity("QGIS_LEGACY_IMPORT"),
|
|
559
|
+
"message": f"Legacy import detected: '{alias.name}'. Use 'qgis.PyQt' for compatibility.",
|
|
560
|
+
"code": ast.unparse(node),
|
|
561
|
+
}
|
|
562
|
+
)
|
|
563
|
+
self.generic_visit(node)
|
|
564
|
+
|
|
565
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
566
|
+
"""Analyzes 'from import' nodes for protected members, legacy PyQt, and GDAL.
|
|
567
|
+
|
|
568
|
+
Also detects heavy dependencies in UI-related files.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
node: The import-from node to analyze.
|
|
572
|
+
"""
|
|
573
|
+
if node.module:
|
|
574
|
+
# Detect QGIS_PROTECTED_MEMBER
|
|
575
|
+
if node.module.startswith("qgis._") and not node.module.startswith("qgis._3d"):
|
|
576
|
+
if self._should_report("QGIS_PROTECTED_MEMBER"):
|
|
577
|
+
self.issues.append(
|
|
578
|
+
{
|
|
579
|
+
"file": self.rel_path,
|
|
580
|
+
"line": node.lineno,
|
|
581
|
+
"type": "QGIS_PROTECTED_MEMBER",
|
|
582
|
+
"severity": self._get_severity("QGIS_PROTECTED_MEMBER"),
|
|
583
|
+
"message": f"Protected member import detected: 'from {node.module} import ...'. Protected members are unstable.",
|
|
584
|
+
"code": ast.unparse(node),
|
|
585
|
+
}
|
|
586
|
+
)
|
|
587
|
+
# Detect GDAL_DIRECT_IMPORT
|
|
588
|
+
if node.module == "gdal":
|
|
589
|
+
if self._should_report("GDAL_DIRECT_IMPORT"):
|
|
590
|
+
self.issues.append(
|
|
591
|
+
{
|
|
592
|
+
"file": self.rel_path,
|
|
593
|
+
"line": node.lineno,
|
|
594
|
+
"type": "GDAL_DIRECT_IMPORT",
|
|
595
|
+
"severity": self._get_severity("GDAL_DIRECT_IMPORT"),
|
|
596
|
+
"message": "Direct 'gdal' import detected. Use 'from osgeo import gdal'.",
|
|
597
|
+
"code": ast.unparse(node),
|
|
598
|
+
}
|
|
599
|
+
)
|
|
600
|
+
# QGIS_LEGACY_IMPORT
|
|
601
|
+
if node.module.startswith(("PyQt4", "PyQt5")):
|
|
602
|
+
if self._should_report("QGIS_LEGACY_IMPORT"):
|
|
603
|
+
self.issues.append(
|
|
604
|
+
{
|
|
605
|
+
"file": self.rel_path,
|
|
606
|
+
"line": node.lineno,
|
|
607
|
+
"type": "QGIS_LEGACY_IMPORT",
|
|
608
|
+
"severity": self._get_severity("QGIS_LEGACY_IMPORT"),
|
|
609
|
+
"message": f"Legacy import detected: 'from {node.module} import ...'. Use 'qgis.PyQt' for compatibility.",
|
|
610
|
+
"code": ast.unparse(node),
|
|
611
|
+
}
|
|
612
|
+
)
|
|
613
|
+
# 7. Detect HEAVY_LOGIC_UI (QGS107)
|
|
614
|
+
heavy_libs = {"pandas", "numpy", "scipy", "sklearn", "matplotlib"}
|
|
615
|
+
is_ui_file = "gui" in self.rel_path.lower() or "ui" in self.rel_path.lower()
|
|
616
|
+
if is_ui_file and (
|
|
617
|
+
node.module in heavy_libs or node.module.split(".")[0] in heavy_libs
|
|
618
|
+
):
|
|
619
|
+
if self._should_report("HEAVY_LOGIC_UI"):
|
|
620
|
+
self.issues.append(
|
|
621
|
+
{
|
|
622
|
+
"file": self.rel_path,
|
|
623
|
+
"line": node.lineno,
|
|
624
|
+
"type": "HEAVY_LOGIC_UI",
|
|
625
|
+
"severity": self._get_severity("HEAVY_LOGIC_UI"),
|
|
626
|
+
"message": f"Heavy dependency '{node.module}' detected in UI file. Move logic to core.",
|
|
627
|
+
"code": ast.unparse(node),
|
|
628
|
+
}
|
|
629
|
+
)
|
|
630
|
+
self.generic_visit(node)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# The helper functions previously here have been moved to src/analyzer/utils/ast_utils.py
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def analyze_module_worker(
|
|
637
|
+
py_file: pathlib.Path,
|
|
638
|
+
project_path: pathlib.Path,
|
|
639
|
+
cached_data: Optional[Dict[str, Any]] = None,
|
|
640
|
+
rules_config: Optional[Dict[str, Any]] = None,
|
|
641
|
+
) -> Optional[Dict[str, Any]]:
|
|
642
|
+
"""Worker function for module analysis, intended for parallel execution.
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
py_file: Path to the Python file to analyze.
|
|
646
|
+
project_path: Root path of the project.
|
|
647
|
+
cached_data: Optional previously cached analysis results.
|
|
648
|
+
rules_config: Optional rule configuration overrides.
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
A dictionary containing the analysis results, or None if the file
|
|
652
|
+
could not be processed.
|
|
653
|
+
"""
|
|
654
|
+
try:
|
|
655
|
+
rel_path = str(py_file.relative_to(project_path))
|
|
656
|
+
|
|
657
|
+
# Fast read
|
|
658
|
+
with open(py_file, encoding="utf-8-sig", errors="replace") as f:
|
|
659
|
+
content = f.read()
|
|
660
|
+
|
|
661
|
+
if not content:
|
|
662
|
+
return None
|
|
663
|
+
|
|
664
|
+
# Parse AST
|
|
665
|
+
try:
|
|
666
|
+
tree = ast.parse(content)
|
|
667
|
+
except SyntaxError:
|
|
668
|
+
return {
|
|
669
|
+
"path": rel_path,
|
|
670
|
+
"lines": content.count("\n") + 1,
|
|
671
|
+
"syntax_error": True,
|
|
672
|
+
"file_size_kb": py_file.stat().st_size / 1024,
|
|
673
|
+
"complexity": 1,
|
|
674
|
+
"functions": [],
|
|
675
|
+
"classes": [],
|
|
676
|
+
"imports": [],
|
|
677
|
+
"has_main": False,
|
|
678
|
+
"docstrings": {"module": False},
|
|
679
|
+
"ast_issues": [],
|
|
680
|
+
"research_metrics": {
|
|
681
|
+
"docstring_styles": [],
|
|
682
|
+
"type_hint_stats": {
|
|
683
|
+
"total_parameters": 0,
|
|
684
|
+
"annotated_parameters": 0,
|
|
685
|
+
"has_return_hint": 0,
|
|
686
|
+
"total_functions": 0,
|
|
687
|
+
},
|
|
688
|
+
"docstring_stats": {"total_public_items": 0, "has_docstring": 0},
|
|
689
|
+
},
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
# Extract information using helper functions
|
|
693
|
+
functions = extract_functions_from_ast(tree)
|
|
694
|
+
classes = extract_classes_from_ast(tree)
|
|
695
|
+
imports = extract_imports_from_ast(tree)
|
|
696
|
+
module_complexity = calculate_module_complexity(tree)
|
|
697
|
+
has_main = check_main_guard(tree)
|
|
698
|
+
|
|
699
|
+
# Custom AST Audit
|
|
700
|
+
visitor = QGISASTVisitor(rel_path, rules_config=rules_config)
|
|
701
|
+
visitor.visit(tree)
|
|
702
|
+
|
|
703
|
+
return {
|
|
704
|
+
"path": rel_path,
|
|
705
|
+
"lines": content.count("\n") + 1,
|
|
706
|
+
"functions": functions,
|
|
707
|
+
"classes": classes,
|
|
708
|
+
"imports": imports,
|
|
709
|
+
"complexity": module_complexity,
|
|
710
|
+
"has_main": has_main,
|
|
711
|
+
"docstrings": {
|
|
712
|
+
"module": ast.get_docstring(tree) is not None,
|
|
713
|
+
},
|
|
714
|
+
"file_size_kb": py_file.stat().st_size / 1024,
|
|
715
|
+
"syntax_error": False,
|
|
716
|
+
"ast_issues": visitor.issues,
|
|
717
|
+
"resource_usages": getattr(visitor, "resource_usages", []),
|
|
718
|
+
"research_metrics": {
|
|
719
|
+
"docstring_styles": list(set(visitor.docstring_styles)),
|
|
720
|
+
"type_hint_stats": visitor.type_hint_stats,
|
|
721
|
+
"docstring_stats": visitor.docstring_stats,
|
|
722
|
+
},
|
|
723
|
+
"content": content,
|
|
724
|
+
}
|
|
725
|
+
except Exception:
|
|
726
|
+
return None
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def audit_qgis_standards(
|
|
730
|
+
modules_data: List[Dict[str, Any]],
|
|
731
|
+
project_path: pathlib.Path,
|
|
732
|
+
rules_config: Optional[Dict[str, Any]] = None,
|
|
733
|
+
) -> Dict[str, Any]:
|
|
734
|
+
"""Executes a comprehensive QGIS standards audit using regex and AST results.
|
|
735
|
+
|
|
736
|
+
Args:
|
|
737
|
+
modules_data: List of already analyzed module data.
|
|
738
|
+
project_path: Root path of the project.
|
|
739
|
+
rules_config: Optional rule configuration overrides.
|
|
740
|
+
|
|
741
|
+
Returns:
|
|
742
|
+
A dictionary consolidating all detected issues and the total issue count.
|
|
743
|
+
"""
|
|
744
|
+
rules = get_qgis_audit_rules()
|
|
745
|
+
results: Dict[str, Any] = {"issues": [], "issues_count": 0}
|
|
746
|
+
|
|
747
|
+
for module in modules_data:
|
|
748
|
+
# Add issues found via AST
|
|
749
|
+
if "ast_issues" in module:
|
|
750
|
+
results["issues"].extend(module["ast_issues"])
|
|
751
|
+
|
|
752
|
+
# Use cached content if available
|
|
753
|
+
path = module.get("path")
|
|
754
|
+
content = module.get("content")
|
|
755
|
+
|
|
756
|
+
if content is None and path:
|
|
757
|
+
full_path = project_path / path
|
|
758
|
+
if full_path.exists():
|
|
759
|
+
try:
|
|
760
|
+
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
761
|
+
except Exception:
|
|
762
|
+
continue
|
|
763
|
+
|
|
764
|
+
if content is None:
|
|
765
|
+
continue
|
|
766
|
+
|
|
767
|
+
for rule in rules:
|
|
768
|
+
rule_id = rule["id"]
|
|
769
|
+
severity_val = rules_config.get(rule_id, "warning") if rules_config else "warning"
|
|
770
|
+
if severity_val == "ignore":
|
|
771
|
+
continue
|
|
772
|
+
|
|
773
|
+
# Map config severity to internal severity
|
|
774
|
+
severity_map = {"error": "high", "warning": "medium", "info": "low"}
|
|
775
|
+
internal_severity = severity_map.get(severity_val, rule["severity"])
|
|
776
|
+
|
|
777
|
+
for match in rule["pattern"].finditer(content):
|
|
778
|
+
line_no = content.count("\n", 0, match.start()) + 1
|
|
779
|
+
results["issues"].append(
|
|
780
|
+
{
|
|
781
|
+
"file": path,
|
|
782
|
+
"line": line_no,
|
|
783
|
+
"type": rule["id"],
|
|
784
|
+
"severity": internal_severity,
|
|
785
|
+
"message": rule["message"],
|
|
786
|
+
"code": content[match.start() : match.end() + 20].strip(),
|
|
787
|
+
}
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
results["issues_count"] = len(results["issues"])
|
|
791
|
+
return results
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
# End of scanner.py
|