qgis-plugin-analyzer 1.5.0__py3-none-any.whl → 1.6.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.
- analyzer/cli/__init__.py +14 -0
- analyzer/cli/app.py +147 -0
- analyzer/cli/base.py +93 -0
- analyzer/cli/commands/__init__.py +19 -0
- analyzer/cli/commands/analyze.py +47 -0
- analyzer/cli/commands/fix.py +58 -0
- analyzer/cli/commands/init.py +41 -0
- analyzer/cli/commands/list_rules.py +41 -0
- analyzer/cli/commands/security.py +46 -0
- analyzer/cli/commands/summary.py +52 -0
- analyzer/cli/commands/version.py +41 -0
- analyzer/cli.py +4 -184
- analyzer/commands.py +7 -7
- analyzer/engine.py +421 -238
- analyzer/fixer.py +206 -130
- analyzer/reporters/markdown_reporter.py +48 -15
- analyzer/reporters/summary_reporter.py +193 -80
- analyzer/scanner.py +218 -138
- analyzer/transformers.py +29 -8
- analyzer/utils/__init__.py +2 -0
- analyzer/utils/path_utils.py +53 -1
- analyzer/validators.py +90 -55
- analyzer/visitors/__init__.py +19 -0
- analyzer/visitors/base.py +75 -0
- analyzer/visitors/composite_visitor.py +73 -0
- analyzer/visitors/imports_visitor.py +85 -0
- analyzer/visitors/metrics_visitor.py +158 -0
- analyzer/visitors/security_visitor.py +52 -0
- analyzer/visitors/standards_visitor.py +284 -0
- {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/METADATA +16 -7
- qgis_plugin_analyzer-1.6.0.dist-info/RECORD +52 -0
- analyzer/visitors.py +0 -455
- qgis_plugin_analyzer-1.5.0.dist-info/RECORD +0 -35
- {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/WHEEL +0 -0
- {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/entry_points.txt +0 -0
- {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/licenses/LICENSE +0 -0
- {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/top_level.txt +0 -0
analyzer/scanner.py
CHANGED
|
@@ -18,9 +18,15 @@
|
|
|
18
18
|
# * *
|
|
19
19
|
# ***************************************************************************/
|
|
20
20
|
|
|
21
|
+
"""Module for scanning and auditing QGIS plugin Python files.
|
|
22
|
+
|
|
23
|
+
This module provides functionalities to analyze individual Python modules using AST,
|
|
24
|
+
check for security vulnerabilities, and audit against QGIS coding standards.
|
|
25
|
+
"""
|
|
26
|
+
|
|
21
27
|
import ast
|
|
22
28
|
import pathlib
|
|
23
|
-
from typing import Any, Dict, List, Optional
|
|
29
|
+
from typing import Any, Dict, List, Optional, TypedDict
|
|
24
30
|
|
|
25
31
|
from .rules.qgis_rules import get_qgis_audit_rules
|
|
26
32
|
from .secrets import SecretScanner
|
|
@@ -33,13 +39,53 @@ from .utils.ast_utils import (
|
|
|
33
39
|
)
|
|
34
40
|
from .visitors import QGISASTVisitor, QGISSecurityVisitor
|
|
35
41
|
|
|
42
|
+
# --- Types ---
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ResearchMetrics(TypedDict):
|
|
46
|
+
"""Structured research metrics for a module."""
|
|
47
|
+
|
|
48
|
+
docstring_styles: List[str]
|
|
49
|
+
type_hint_stats: Dict[str, Any]
|
|
50
|
+
docstring_stats: Dict[str, Any]
|
|
51
|
+
security_findings_count: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ModuleAnalysisResult(TypedDict, total=False):
|
|
55
|
+
"""Formal structure for module analysis results."""
|
|
56
|
+
|
|
57
|
+
path: str
|
|
58
|
+
lines: int
|
|
59
|
+
functions: List[Dict[str, Any]]
|
|
60
|
+
classes: List[str]
|
|
61
|
+
imports: List[str]
|
|
62
|
+
complexity: int
|
|
63
|
+
has_main: bool
|
|
64
|
+
docstrings: Dict[str, bool]
|
|
65
|
+
file_size_kb: float
|
|
66
|
+
syntax_error: bool
|
|
67
|
+
ast_issues: List[Dict[str, Any]]
|
|
68
|
+
security_issues: List[Dict[str, Any]]
|
|
69
|
+
resource_usages: List[str]
|
|
70
|
+
research_metrics: ResearchMetrics
|
|
71
|
+
content: Optional[str]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --- Constants ---
|
|
75
|
+
|
|
76
|
+
SEVERITY_MAP = {
|
|
77
|
+
"error": "high",
|
|
78
|
+
"warning": "medium",
|
|
79
|
+
"info": "low",
|
|
80
|
+
}
|
|
81
|
+
|
|
36
82
|
|
|
37
83
|
def analyze_module_worker(
|
|
38
84
|
py_file: pathlib.Path,
|
|
39
85
|
project_path: pathlib.Path,
|
|
40
86
|
cached_data: Optional[Dict[str, Any]] = None,
|
|
41
87
|
rules_config: Optional[Dict[str, Any]] = None,
|
|
42
|
-
) -> Optional[
|
|
88
|
+
) -> Optional[ModuleAnalysisResult]:
|
|
43
89
|
"""Worker function for module analysis, intended for parallel execution.
|
|
44
90
|
|
|
45
91
|
Args:
|
|
@@ -53,170 +99,204 @@ def analyze_module_worker(
|
|
|
53
99
|
could not be processed.
|
|
54
100
|
"""
|
|
55
101
|
try:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
else:
|
|
59
|
-
rel_path = str(py_file.relative_to(project_path))
|
|
60
|
-
|
|
61
|
-
# Fast read
|
|
62
|
-
with open(py_file, encoding="utf-8-sig", errors="replace") as f:
|
|
63
|
-
content = f.read()
|
|
64
|
-
|
|
102
|
+
rel_path = _get_relative_path(py_file, project_path)
|
|
103
|
+
content = _read_file_content(py_file)
|
|
65
104
|
if not content:
|
|
66
105
|
return None
|
|
67
106
|
|
|
68
|
-
# Parse AST
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
"syntax_error": True,
|
|
76
|
-
"file_size_kb": py_file.stat().st_size / 1024,
|
|
77
|
-
"complexity": 1,
|
|
78
|
-
"functions": [],
|
|
79
|
-
"classes": [],
|
|
80
|
-
"imports": [],
|
|
81
|
-
"has_main": False,
|
|
82
|
-
"docstrings": {"module": False},
|
|
83
|
-
"ast_issues": [],
|
|
84
|
-
"research_metrics": {
|
|
85
|
-
"docstring_styles": [],
|
|
86
|
-
"type_hint_stats": {
|
|
87
|
-
"total_parameters": 0,
|
|
88
|
-
"annotated_parameters": 0,
|
|
89
|
-
"has_return_hint": 0,
|
|
90
|
-
"total_functions": 0,
|
|
91
|
-
},
|
|
92
|
-
"docstring_stats": {"total_public_items": 0, "has_docstring": 0},
|
|
93
|
-
},
|
|
94
|
-
}
|
|
107
|
+
# Parse AST with error handling
|
|
108
|
+
tree_or_error = _parse_ast(content, rel_path, py_file)
|
|
109
|
+
if isinstance(tree_or_error, dict) and tree_or_error.get("syntax_error"):
|
|
110
|
+
# Ensure it fits the return type
|
|
111
|
+
return tree_or_error # type: ignore
|
|
112
|
+
|
|
113
|
+
tree = tree_or_error
|
|
95
114
|
|
|
96
115
|
# Extract information using helper functions
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
116
|
+
results: ModuleAnalysisResult = {
|
|
117
|
+
"path": rel_path,
|
|
118
|
+
"lines": content.count("\n") + 1,
|
|
119
|
+
"functions": extract_functions_from_ast(tree),
|
|
120
|
+
"classes": extract_classes_from_ast(tree),
|
|
121
|
+
"imports": extract_imports_from_ast(tree),
|
|
122
|
+
"complexity": calculate_module_complexity(tree),
|
|
123
|
+
"has_main": check_main_guard(tree),
|
|
124
|
+
"docstrings": {"module": ast.get_docstring(tree) is not None},
|
|
125
|
+
"file_size_kb": py_file.stat().st_size / 1024,
|
|
126
|
+
"syntax_error": False,
|
|
127
|
+
"content": content,
|
|
128
|
+
}
|
|
102
129
|
|
|
103
|
-
#
|
|
130
|
+
# Run Audits
|
|
104
131
|
visitor = QGISASTVisitor(rel_path, rules_config=rules_config)
|
|
105
132
|
visitor.visit(tree)
|
|
106
133
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
134
|
+
security_issues = _collect_security_issues(tree, content, rel_path)
|
|
135
|
+
results.update(
|
|
136
|
+
{
|
|
137
|
+
"ast_issues": visitor.issues,
|
|
138
|
+
"security_issues": security_issues,
|
|
139
|
+
"resource_usages": getattr(visitor, "resource_usages", []),
|
|
140
|
+
"research_metrics": {
|
|
141
|
+
"docstring_styles": list(set(visitor.docstring_styles)),
|
|
142
|
+
"type_hint_stats": visitor.type_hint_stats,
|
|
143
|
+
"docstring_stats": visitor.docstring_stats,
|
|
144
|
+
"security_findings_count": len(security_issues),
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
)
|
|
110
148
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
149
|
+
return results
|
|
150
|
+
except Exception:
|
|
151
|
+
return None
|
|
114
152
|
|
|
115
|
-
# Consolidate security issues
|
|
116
|
-
security_issues = security_visitor.findings
|
|
117
|
-
for sf in secret_findings:
|
|
118
|
-
security_issues.append(
|
|
119
|
-
{
|
|
120
|
-
"file": rel_path,
|
|
121
|
-
"line": sf.line,
|
|
122
|
-
"type": sf.type,
|
|
123
|
-
"severity": "high" if sf.confidence == "HIGH" else "medium",
|
|
124
|
-
"message": sf.message,
|
|
125
|
-
"confidence": sf.confidence.lower(),
|
|
126
|
-
}
|
|
127
|
-
)
|
|
128
153
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
"syntax_error": False,
|
|
142
|
-
"ast_issues": visitor.issues,
|
|
143
|
-
"security_issues": security_issues,
|
|
144
|
-
"resource_usages": getattr(visitor, "resource_usages", []),
|
|
145
|
-
"research_metrics": {
|
|
146
|
-
"docstring_styles": list(set(visitor.docstring_styles)),
|
|
147
|
-
"type_hint_stats": visitor.type_hint_stats,
|
|
148
|
-
"docstring_stats": visitor.docstring_stats,
|
|
149
|
-
"security_findings_count": len(security_issues),
|
|
150
|
-
},
|
|
151
|
-
"content": content,
|
|
152
|
-
}
|
|
154
|
+
def _get_relative_path(py_file: pathlib.Path, project_path: pathlib.Path) -> str:
|
|
155
|
+
"""Safely calculates the relative path of a file."""
|
|
156
|
+
if project_path.is_file():
|
|
157
|
+
return py_file.name
|
|
158
|
+
return str(py_file.relative_to(project_path))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _read_file_content(py_file: pathlib.Path) -> Optional[str]:
|
|
162
|
+
"""Reads file content handling common encoding issues."""
|
|
163
|
+
try:
|
|
164
|
+
with open(py_file, encoding="utf-8-sig", errors="replace") as f:
|
|
165
|
+
return f.read()
|
|
153
166
|
except Exception:
|
|
154
167
|
return None
|
|
155
168
|
|
|
156
169
|
|
|
170
|
+
def _parse_ast(content: str, rel_path: str, py_file: pathlib.Path) -> Any:
|
|
171
|
+
"""Parses AST or returns a structured error dictionary."""
|
|
172
|
+
try:
|
|
173
|
+
return ast.parse(content)
|
|
174
|
+
except SyntaxError:
|
|
175
|
+
return _create_empty_analysis_result(rel_path, py_file, content, syntax_error=True)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _create_empty_analysis_result(
|
|
179
|
+
rel_path: str, py_file: pathlib.Path, content: str, syntax_error: bool = False
|
|
180
|
+
) -> ModuleAnalysisResult:
|
|
181
|
+
"""Creates a basic results structure for errors or empty files."""
|
|
182
|
+
return {
|
|
183
|
+
"path": rel_path,
|
|
184
|
+
"lines": content.count("\n") + 1,
|
|
185
|
+
"syntax_error": syntax_error,
|
|
186
|
+
"file_size_kb": py_file.stat().st_size / 1024,
|
|
187
|
+
"complexity": 1,
|
|
188
|
+
"functions": [],
|
|
189
|
+
"classes": [],
|
|
190
|
+
"imports": [],
|
|
191
|
+
"has_main": False,
|
|
192
|
+
"docstrings": {"module": False},
|
|
193
|
+
"ast_issues": [],
|
|
194
|
+
"research_metrics": {
|
|
195
|
+
"docstring_styles": [],
|
|
196
|
+
"type_hint_stats": {
|
|
197
|
+
"total_parameters": 0,
|
|
198
|
+
"annotated_parameters": 0,
|
|
199
|
+
"has_return_hint": 0,
|
|
200
|
+
"total_functions": 0,
|
|
201
|
+
},
|
|
202
|
+
"docstring_stats": {"total_public_items": 0, "has_docstring": 0},
|
|
203
|
+
"security_findings_count": 0,
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _collect_security_issues(tree: ast.AST, content: str, rel_path: str) -> List[Dict[str, Any]]:
|
|
209
|
+
"""Consolidates issues from AST security visitor and secret scanner."""
|
|
210
|
+
security_visitor = QGISSecurityVisitor(rel_path)
|
|
211
|
+
security_visitor.visit(tree)
|
|
212
|
+
issues = security_visitor.findings
|
|
213
|
+
|
|
214
|
+
secret_scanner = SecretScanner()
|
|
215
|
+
for sf in secret_scanner.scan_text(content):
|
|
216
|
+
issues.append(
|
|
217
|
+
{
|
|
218
|
+
"file": rel_path,
|
|
219
|
+
"line": sf.line,
|
|
220
|
+
"type": sf.type,
|
|
221
|
+
"severity": "high" if sf.confidence == "HIGH" else "medium",
|
|
222
|
+
"message": sf.message,
|
|
223
|
+
"confidence": sf.confidence.lower(),
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
return issues
|
|
227
|
+
|
|
228
|
+
|
|
157
229
|
def audit_qgis_standards(
|
|
158
|
-
modules_data: List[
|
|
230
|
+
modules_data: List[ModuleAnalysisResult],
|
|
159
231
|
project_path: pathlib.Path,
|
|
160
232
|
rules_config: Optional[Dict[str, Any]] = None,
|
|
161
233
|
) -> Dict[str, Any]:
|
|
162
|
-
"""Executes a comprehensive QGIS standards audit using regex and AST results.
|
|
163
|
-
|
|
164
|
-
Args:
|
|
165
|
-
modules_data: List of already analyzed module data.
|
|
166
|
-
project_path: Root path of the project.
|
|
167
|
-
rules_config: Optional rule configuration overrides.
|
|
168
|
-
|
|
169
|
-
Returns:
|
|
170
|
-
A dictionary consolidating all detected issues and the total issue count.
|
|
171
|
-
"""
|
|
234
|
+
"""Executes a comprehensive QGIS standards audit using regex and AST results."""
|
|
172
235
|
rules = get_qgis_audit_rules()
|
|
173
236
|
results: Dict[str, Any] = {"issues": [], "issues_count": 0}
|
|
174
237
|
|
|
175
238
|
for module in modules_data:
|
|
176
|
-
# Add issues
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
content = module.get("content")
|
|
183
|
-
|
|
184
|
-
if content is None and path:
|
|
185
|
-
full_path = project_path / path
|
|
186
|
-
if full_path.exists():
|
|
187
|
-
try:
|
|
188
|
-
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
189
|
-
except Exception:
|
|
190
|
-
continue
|
|
191
|
-
|
|
192
|
-
if content is None:
|
|
193
|
-
continue
|
|
239
|
+
# Add AST issues
|
|
240
|
+
results["issues"].extend(module.get("ast_issues", []))
|
|
241
|
+
|
|
242
|
+
# Run Regex rules
|
|
243
|
+
path = module.get("path", "")
|
|
244
|
+
content = module.get("content") or _try_read_module_file(path, project_path)
|
|
194
245
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
severity_val = rules_config.get(rule_id, "warning") if rules_config else "warning"
|
|
198
|
-
if severity_val == "ignore":
|
|
199
|
-
continue
|
|
200
|
-
|
|
201
|
-
# Map config severity to internal severity
|
|
202
|
-
severity_map = {"error": "high", "warning": "medium", "info": "low"}
|
|
203
|
-
internal_severity = severity_map.get(severity_val, rule["severity"])
|
|
204
|
-
|
|
205
|
-
for match in rule["pattern"].finditer(content):
|
|
206
|
-
line_no = content.count("\n", 0, match.start()) + 1
|
|
207
|
-
results["issues"].append(
|
|
208
|
-
{
|
|
209
|
-
"file": path,
|
|
210
|
-
"line": line_no,
|
|
211
|
-
"type": rule["id"],
|
|
212
|
-
"severity": internal_severity,
|
|
213
|
-
"message": rule["message"],
|
|
214
|
-
"code": content[match.start() : match.end() + 20].strip(),
|
|
215
|
-
}
|
|
216
|
-
)
|
|
246
|
+
if content:
|
|
247
|
+
_run_regex_audit_on_module(content, path, rules, rules_config, results["issues"])
|
|
217
248
|
|
|
218
249
|
results["issues_count"] = len(results["issues"])
|
|
219
250
|
return results
|
|
220
251
|
|
|
221
252
|
|
|
253
|
+
def _run_regex_audit_on_module(
|
|
254
|
+
content: str,
|
|
255
|
+
path: str,
|
|
256
|
+
rules: List[Dict[str, Any]],
|
|
257
|
+
rules_config: Optional[Dict[str, Any]],
|
|
258
|
+
issues_out: List[Dict[str, Any]],
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Runs all regex rules on a module's content."""
|
|
261
|
+
for rule in rules:
|
|
262
|
+
internal_severity = _get_rule_severity(rule, rules_config)
|
|
263
|
+
if internal_severity == "ignore":
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
for match in rule["pattern"].finditer(content):
|
|
267
|
+
line_no = content.count("\n", 0, match.start()) + 1
|
|
268
|
+
issues_out.append(
|
|
269
|
+
{
|
|
270
|
+
"file": path,
|
|
271
|
+
"line": line_no,
|
|
272
|
+
"type": rule["id"],
|
|
273
|
+
"severity": internal_severity,
|
|
274
|
+
"message": rule["message"],
|
|
275
|
+
"code": content[match.start() : match.end() + 20].strip(),
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _try_read_module_file(path: Optional[str], project_path: pathlib.Path) -> Optional[str]:
|
|
281
|
+
"""Attempts to read a module file from path if content is missing."""
|
|
282
|
+
if not path:
|
|
283
|
+
return None
|
|
284
|
+
full_path = project_path / path
|
|
285
|
+
if full_path.exists():
|
|
286
|
+
return _read_file_content(full_path)
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _get_rule_severity(rule: Dict[str, Any], config: Optional[Dict[str, Any]]) -> str:
|
|
291
|
+
"""Calculates rule severity based on configuration."""
|
|
292
|
+
rule_id = rule["id"]
|
|
293
|
+
severity_val = config.get(rule_id, "warning") if config else "warning"
|
|
294
|
+
|
|
295
|
+
if severity_val == "ignore":
|
|
296
|
+
return "ignore"
|
|
297
|
+
|
|
298
|
+
severity = SEVERITY_MAP.get(severity_val, rule["severity"])
|
|
299
|
+
return str(severity)
|
|
300
|
+
|
|
301
|
+
|
|
222
302
|
# End of scanner.py
|
analyzer/transformers.py
CHANGED
|
@@ -154,26 +154,24 @@ class I18nTransformer(ast.NodeTransformer):
|
|
|
154
154
|
return node
|
|
155
155
|
|
|
156
156
|
|
|
157
|
-
def
|
|
158
|
-
|
|
157
|
+
def apply_transformation_to_content(
|
|
158
|
+
content: str, transformer: ast.NodeTransformer
|
|
159
|
+
) -> Optional[str]:
|
|
160
|
+
"""Applies an AST transformation to code content string.
|
|
159
161
|
|
|
160
162
|
Args:
|
|
161
|
-
|
|
163
|
+
content: The Python source code.
|
|
162
164
|
transformer: The AST node transformer to apply.
|
|
163
165
|
|
|
164
166
|
Returns:
|
|
165
|
-
|
|
167
|
+
The transformed code string if changes were made, None otherwise.
|
|
166
168
|
"""
|
|
167
169
|
try:
|
|
168
|
-
content = file_path.read_text(encoding="utf-8")
|
|
169
170
|
tree = ast.parse(content)
|
|
170
|
-
|
|
171
|
-
# Apply transformation
|
|
172
171
|
new_tree = transformer.visit(tree)
|
|
173
172
|
ast.fix_missing_locations(new_tree)
|
|
174
173
|
|
|
175
174
|
if hasattr(transformer, "changes_made") and transformer.changes_made:
|
|
176
|
-
# Unparse back to code
|
|
177
175
|
new_code = ast.unparse(new_tree)
|
|
178
176
|
|
|
179
177
|
# Add necessary imports if needed
|
|
@@ -181,6 +179,29 @@ def apply_transformation(file_path: pathlib.Path, transformer: ast.NodeTransform
|
|
|
181
179
|
if "from qgis.core import QgsMessageLog, Qgis" not in new_code:
|
|
182
180
|
new_code = "from qgis.core import QgsMessageLog, Qgis\n\n" + new_code
|
|
183
181
|
|
|
182
|
+
return new_code
|
|
183
|
+
|
|
184
|
+
return None
|
|
185
|
+
except Exception as e:
|
|
186
|
+
print(f"Error transforming content: {e}")
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def apply_transformation(file_path: pathlib.Path, transformer: ast.NodeTransformer) -> bool:
|
|
191
|
+
"""Applies an AST transformation to a file and writes back the modified code.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
file_path: Path to the Python file to transform.
|
|
195
|
+
transformer: The AST node transformer to apply.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
True if the file was modified, False otherwise.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
content = file_path.read_text(encoding="utf-8")
|
|
202
|
+
new_code = apply_transformation_to_content(content, transformer)
|
|
203
|
+
|
|
204
|
+
if new_code is not None:
|
|
184
205
|
file_path.write_text(new_code, encoding="utf-8")
|
|
185
206
|
return True
|
|
186
207
|
|
analyzer/utils/__init__.py
CHANGED
|
@@ -13,6 +13,7 @@ from .logging_utils import logger, setup_logger
|
|
|
13
13
|
from .path_utils import (
|
|
14
14
|
DEFAULT_EXCLUDE,
|
|
15
15
|
IgnoreMatcher,
|
|
16
|
+
discover_project_files,
|
|
16
17
|
load_ignore_patterns,
|
|
17
18
|
safe_path_resolve,
|
|
18
19
|
)
|
|
@@ -29,6 +30,7 @@ __all__ = [
|
|
|
29
30
|
"logger",
|
|
30
31
|
"safe_path_resolve",
|
|
31
32
|
"IgnoreMatcher",
|
|
33
|
+
"discover_project_files",
|
|
32
34
|
"load_ignore_patterns",
|
|
33
35
|
"DEFAULT_EXCLUDE",
|
|
34
36
|
"load_profile_config",
|
analyzer/utils/path_utils.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import fnmatch
|
|
4
4
|
import os
|
|
5
5
|
import pathlib
|
|
6
|
-
from typing import Dict, List
|
|
6
|
+
from typing import Any, Dict, List
|
|
7
7
|
|
|
8
8
|
# Default patterns to ignore if not specified
|
|
9
9
|
DEFAULT_EXCLUDE = {
|
|
@@ -21,6 +21,9 @@ DEFAULT_EXCLUDE = {
|
|
|
21
21
|
"analysis_results/",
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
# Prohibited binary extensions per QGIS repository policy
|
|
25
|
+
BINARY_EXTENSIONS = {".exe", ".dll", ".so", ".dylib", ".pyd", ".bin", ".a", ".lib"}
|
|
26
|
+
|
|
24
27
|
|
|
25
28
|
def safe_path_resolve(base_path: pathlib.Path, target_path_str: str) -> pathlib.Path:
|
|
26
29
|
"""Resolves a target path safely relative to a base path.
|
|
@@ -133,3 +136,52 @@ def load_ignore_patterns(ignore_file: pathlib.Path) -> List[str]:
|
|
|
133
136
|
return []
|
|
134
137
|
with open(ignore_file) as f:
|
|
135
138
|
return f.readlines()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def discover_project_files(project_path: pathlib.Path, matcher: IgnoreMatcher) -> Dict[str, Any]:
|
|
142
|
+
"""Scans the project directory once to discover all relevant files and metrics.
|
|
143
|
+
This replaces multiple redundant rglob calls, optimizing I/O performance.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
project_path: Root path of the project.
|
|
147
|
+
matcher: IgnoreMatcher instance for filtering.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
A dictionary with:
|
|
151
|
+
- python_files: List of Paths to .py files.
|
|
152
|
+
- binaries: List of relative paths to binary files.
|
|
153
|
+
- total_size_mb: Total size of non-ignored files in MB.
|
|
154
|
+
- has_metadata: Boolean indicating if metadata.txt exists.
|
|
155
|
+
"""
|
|
156
|
+
python_files = []
|
|
157
|
+
binaries = []
|
|
158
|
+
total_bytes = 0
|
|
159
|
+
has_metadata = False
|
|
160
|
+
|
|
161
|
+
for file_path in project_path.rglob("*"):
|
|
162
|
+
if not file_path.is_file():
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
if matcher.is_ignored(file_path):
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
# Basics
|
|
169
|
+
total_bytes += file_path.stat().st_size
|
|
170
|
+
|
|
171
|
+
# Check metadata
|
|
172
|
+
if file_path.name == "metadata.txt" and file_path.parent == project_path:
|
|
173
|
+
has_metadata = True
|
|
174
|
+
|
|
175
|
+
# Classify by extension
|
|
176
|
+
ext = file_path.suffix.lower()
|
|
177
|
+
if ext == ".py":
|
|
178
|
+
python_files.append(file_path)
|
|
179
|
+
elif ext in BINARY_EXTENSIONS:
|
|
180
|
+
binaries.append(str(file_path.relative_to(project_path)))
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
"python_files": python_files,
|
|
184
|
+
"binaries": binaries,
|
|
185
|
+
"total_size_mb": total_bytes / (1024 * 1024),
|
|
186
|
+
"has_metadata": has_metadata,
|
|
187
|
+
}
|