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/fixer.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# /***************************************************************************
|
|
2
|
+
# QGIS Plugin Analyzer
|
|
3
|
+
#
|
|
4
|
+
# Auto-fix engine for applying code corrections.
|
|
5
|
+
# ***************************************************************************/
|
|
6
|
+
|
|
7
|
+
import difflib
|
|
8
|
+
import pathlib
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import Any, Dict, List
|
|
13
|
+
|
|
14
|
+
from .transformers import (
|
|
15
|
+
GDALImportTransformer,
|
|
16
|
+
I18nTransformer,
|
|
17
|
+
LegacyImportTransformer,
|
|
18
|
+
PrintToLogTransformer,
|
|
19
|
+
apply_transformation,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_git_status(project_path: pathlib.Path) -> bool:
|
|
24
|
+
"""Checks if the Git working directory is clean.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
project_path: Root path of the Git project.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
True if there are no uncommitted changes, False otherwise.
|
|
31
|
+
Returns True if Git is not available.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
["git", "status", "--porcelain"],
|
|
36
|
+
cwd=project_path,
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
timeout=5,
|
|
40
|
+
)
|
|
41
|
+
return len(result.stdout.strip()) == 0
|
|
42
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
43
|
+
# Git not available or timeout
|
|
44
|
+
return True # Don't block if git is not available
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def show_diff(file_path: pathlib.Path, original_content: str, new_content: str) -> None:
|
|
48
|
+
"""Displays a colorized unified diff between original and new content.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
file_path: Path to the file being compared.
|
|
52
|
+
original_content: The original content of the file.
|
|
53
|
+
new_content: The modified content of the file.
|
|
54
|
+
"""
|
|
55
|
+
diff = difflib.unified_diff(
|
|
56
|
+
original_content.splitlines(keepends=True),
|
|
57
|
+
new_content.splitlines(keepends=True),
|
|
58
|
+
fromfile=f"a/{file_path.name}",
|
|
59
|
+
tofile=f"b/{file_path.name}",
|
|
60
|
+
lineterm="",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
print(" " + "ā" * 60)
|
|
64
|
+
for line in diff:
|
|
65
|
+
line = line.rstrip()
|
|
66
|
+
if line.startswith("+++") or line.startswith("---"):
|
|
67
|
+
print(f" {line}")
|
|
68
|
+
elif line.startswith("+"):
|
|
69
|
+
print(f" \033[32m{line}\033[0m") # Green
|
|
70
|
+
elif line.startswith("-"):
|
|
71
|
+
print(f" \033[31m{line}\033[0m") # Red
|
|
72
|
+
elif line.startswith("@@"):
|
|
73
|
+
print(f" \033[36m{line}\033[0m") # Cyan
|
|
74
|
+
else:
|
|
75
|
+
print(f" {line}")
|
|
76
|
+
print(" " + "ā" * 60)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FixStrategy(ABC):
|
|
80
|
+
"""Abstract base class for all auto-fix strategies."""
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
def can_fix(self, issue: Dict[str, Any]) -> bool:
|
|
84
|
+
"""Returns True if this strategy can fix the given issue."""
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
def apply_fix(self, file_path: pathlib.Path, issue: Dict[str, Any]) -> bool:
|
|
89
|
+
"""Applies the fix to the file. Returns True if successful."""
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def get_description(self, issue: Dict[str, Any]) -> str:
|
|
94
|
+
"""Returns a human-readable description of the fix."""
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class GDALImportFixer(FixStrategy):
|
|
99
|
+
"""Fixes direct GDAL imports."""
|
|
100
|
+
|
|
101
|
+
def can_fix(self, issue: Dict[str, Any]) -> bool:
|
|
102
|
+
return issue.get("type") == "GDAL_DIRECT_IMPORT"
|
|
103
|
+
|
|
104
|
+
def apply_fix(self, file_path: pathlib.Path, issue: Dict[str, Any]) -> bool:
|
|
105
|
+
transformer = GDALImportTransformer()
|
|
106
|
+
return apply_transformation(file_path, transformer)
|
|
107
|
+
|
|
108
|
+
def get_description(self, issue: Dict[str, Any]) -> str:
|
|
109
|
+
return "Replace 'import gdal' with 'from osgeo import gdal'"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class LegacyImportFixer(FixStrategy):
|
|
113
|
+
"""Fixes PyQt4/PyQt5 imports."""
|
|
114
|
+
|
|
115
|
+
def can_fix(self, issue: Dict[str, Any]) -> bool:
|
|
116
|
+
return issue.get("type") == "QGIS_LEGACY_IMPORT"
|
|
117
|
+
|
|
118
|
+
def apply_fix(self, file_path: pathlib.Path, issue: Dict[str, Any]) -> bool:
|
|
119
|
+
transformer = LegacyImportTransformer()
|
|
120
|
+
return apply_transformation(file_path, transformer)
|
|
121
|
+
|
|
122
|
+
def get_description(self, issue: Dict[str, Any]) -> str:
|
|
123
|
+
return "Replace PyQt4/PyQt5 imports with qgis.PyQt"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class PrintToLogFixer(FixStrategy):
|
|
127
|
+
"""Fixes print() statements to use QgsMessageLog."""
|
|
128
|
+
|
|
129
|
+
def can_fix(self, issue: Dict[str, Any]) -> bool:
|
|
130
|
+
# This would need a new rule type in scanner.py
|
|
131
|
+
return issue.get("type") == "PRINT_STATEMENT"
|
|
132
|
+
|
|
133
|
+
def apply_fix(self, file_path: pathlib.Path, issue: Dict[str, Any]) -> bool:
|
|
134
|
+
transformer = PrintToLogTransformer()
|
|
135
|
+
return apply_transformation(file_path, transformer)
|
|
136
|
+
|
|
137
|
+
def get_description(self, issue: Dict[str, Any]) -> str:
|
|
138
|
+
return "Replace print() with QgsMessageLog.logMessage()"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class I18nFixer(FixStrategy):
|
|
142
|
+
"""Wraps hardcoded UI strings in self.tr()."""
|
|
143
|
+
|
|
144
|
+
def can_fix(self, issue: Dict[str, Any]) -> bool:
|
|
145
|
+
return issue.get("type") == "MISSING_I18N"
|
|
146
|
+
|
|
147
|
+
def apply_fix(self, file_path: pathlib.Path, issue: Dict[str, Any]) -> bool:
|
|
148
|
+
transformer = I18nTransformer()
|
|
149
|
+
return apply_transformation(file_path, transformer)
|
|
150
|
+
|
|
151
|
+
def get_description(self, issue: Dict[str, Any]) -> str:
|
|
152
|
+
return "Wrap hardcoded string in self.tr() for internationalization"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class AutoFixer:
|
|
156
|
+
"""Orchestrates the identification and application of auto-fixes.
|
|
157
|
+
|
|
158
|
+
Attributes:
|
|
159
|
+
project_path: Root path of the project.
|
|
160
|
+
dry_run: If True, changes are proposed but not written.
|
|
161
|
+
strategies: List of available fix strategies.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, project_path: pathlib.Path, dry_run: bool = True) -> None:
|
|
165
|
+
"""Initializes the auto-fixer.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
project_path: Root path of the project.
|
|
169
|
+
dry_run: Whether to run in simulation mode.
|
|
170
|
+
"""
|
|
171
|
+
self.project_path = project_path
|
|
172
|
+
self.dry_run = dry_run
|
|
173
|
+
self.strategies: List[FixStrategy] = [
|
|
174
|
+
GDALImportFixer(),
|
|
175
|
+
LegacyImportFixer(),
|
|
176
|
+
PrintToLogFixer(),
|
|
177
|
+
I18nFixer(),
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
def get_fixable_issues(self, issues: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
181
|
+
"""Filters a list of issues to identify those that can be auto-fixed.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
issues: A list of issue dictionaries.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
A list of fixable issues, each enriched with a 'fixer' strategy.
|
|
188
|
+
"""
|
|
189
|
+
fixable = []
|
|
190
|
+
for issue in issues:
|
|
191
|
+
for strategy in self.strategies:
|
|
192
|
+
if strategy.can_fix(issue):
|
|
193
|
+
issue["fixer"] = strategy
|
|
194
|
+
fixable.append(issue)
|
|
195
|
+
break
|
|
196
|
+
return fixable
|
|
197
|
+
|
|
198
|
+
def _check_git_status_with_prompt(self, interactive: bool) -> bool:
|
|
199
|
+
"""Checks git status and prompts user if needed. Returns True to continue."""
|
|
200
|
+
if self.dry_run:
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
is_clean = check_git_status(self.project_path)
|
|
204
|
+
if not is_clean:
|
|
205
|
+
print("\nā ļø WARNING: Working directory has uncommitted changes.")
|
|
206
|
+
print(" It's recommended to commit or stash changes before applying fixes.")
|
|
207
|
+
if interactive:
|
|
208
|
+
response = input(" Continue anyway? [y/N]: ").lower()
|
|
209
|
+
if response != "y":
|
|
210
|
+
print("Aborted by user.")
|
|
211
|
+
return False
|
|
212
|
+
print()
|
|
213
|
+
return True
|
|
214
|
+
|
|
215
|
+
def _group_issues_by_file(
|
|
216
|
+
self, issues: List[Dict[str, Any]]
|
|
217
|
+
) -> Dict[str, List[Dict[str, Any]]]:
|
|
218
|
+
"""Groups issues by file path."""
|
|
219
|
+
by_file: Dict[str, List[Dict[str, Any]]] = {}
|
|
220
|
+
for issue in issues:
|
|
221
|
+
file_path = issue.get("file", "")
|
|
222
|
+
if file_path not in by_file:
|
|
223
|
+
by_file[file_path] = []
|
|
224
|
+
by_file[file_path].append(issue)
|
|
225
|
+
return by_file
|
|
226
|
+
|
|
227
|
+
def _apply_single_fix(
|
|
228
|
+
self,
|
|
229
|
+
file_path: pathlib.Path,
|
|
230
|
+
issue: Dict[str, Any],
|
|
231
|
+
original_content: str,
|
|
232
|
+
interactive: bool,
|
|
233
|
+
stats: Dict[str, int],
|
|
234
|
+
) -> bool:
|
|
235
|
+
"""Applies a single fix and updates stats. Returns True to continue, False to abort."""
|
|
236
|
+
fixer: FixStrategy = issue["fixer"]
|
|
237
|
+
description = fixer.get_description(issue)
|
|
238
|
+
|
|
239
|
+
print(f" Line {issue.get('line', '?')}: {description}")
|
|
240
|
+
|
|
241
|
+
if interactive and not self.dry_run:
|
|
242
|
+
# Show diff preview
|
|
243
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as tmp:
|
|
244
|
+
tmp.write(original_content)
|
|
245
|
+
tmp_path = pathlib.Path(tmp.name)
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
fixer.apply_fix(tmp_path, issue)
|
|
249
|
+
new_content = tmp_path.read_text(encoding="utf-8")
|
|
250
|
+
|
|
251
|
+
if new_content != original_content:
|
|
252
|
+
show_diff(file_path, original_content, new_content)
|
|
253
|
+
finally:
|
|
254
|
+
tmp_path.unlink()
|
|
255
|
+
|
|
256
|
+
response = input(" Apply fix? [y/n/q]: ").lower()
|
|
257
|
+
if response == "q":
|
|
258
|
+
print("Aborted by user.")
|
|
259
|
+
return False
|
|
260
|
+
if response != "y":
|
|
261
|
+
stats["skipped"] += 1
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
if not self.dry_run:
|
|
265
|
+
success = fixer.apply_fix(file_path, issue)
|
|
266
|
+
if success:
|
|
267
|
+
stats["applied"] += 1
|
|
268
|
+
print(" ā
Applied")
|
|
269
|
+
else:
|
|
270
|
+
stats["failed"] += 1
|
|
271
|
+
print(" ā Failed")
|
|
272
|
+
else:
|
|
273
|
+
stats["applied"] += 1 # Count as "would apply"
|
|
274
|
+
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
def apply_fixes(self, issues: List[Dict[str, Any]], interactive: bool = True) -> Dict[str, int]:
|
|
278
|
+
"""Applies fixes to identified issues, grouping by file.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
issues: List of issues to fix.
|
|
282
|
+
interactive: Whether to prompt for confirmation and show diffs.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
A dictionary containing processing statistics (applied, skipped, failed).
|
|
286
|
+
"""
|
|
287
|
+
stats = {"applied": 0, "skipped": 0, "failed": 0}
|
|
288
|
+
|
|
289
|
+
# Git status check
|
|
290
|
+
if not self._check_git_status_with_prompt(interactive):
|
|
291
|
+
return stats
|
|
292
|
+
|
|
293
|
+
# Group by file
|
|
294
|
+
by_file = self._group_issues_by_file(issues)
|
|
295
|
+
|
|
296
|
+
for file_rel, file_issues in by_file.items():
|
|
297
|
+
file_path = self.project_path / file_rel
|
|
298
|
+
print(f"\nš {file_rel}")
|
|
299
|
+
|
|
300
|
+
# Read original content for diff
|
|
301
|
+
try:
|
|
302
|
+
original_content = file_path.read_text(encoding="utf-8")
|
|
303
|
+
except Exception as e:
|
|
304
|
+
print(f" ā Error reading file: {e}")
|
|
305
|
+
stats["failed"] += len(file_issues)
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
for issue in file_issues:
|
|
309
|
+
if not self._apply_single_fix(
|
|
310
|
+
file_path, issue, original_content, interactive, stats
|
|
311
|
+
):
|
|
312
|
+
return stats
|
|
313
|
+
|
|
314
|
+
return stats
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Core data models for project and module analysis."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Dict, List
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ModuleAnalysis:
|
|
9
|
+
"""Analysis results for a single Python module.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
path: Relative path to the file.
|
|
13
|
+
lines: Total number of lines.
|
|
14
|
+
functions: List of function metadata dictionaries.
|
|
15
|
+
classes: List of class signatures.
|
|
16
|
+
imports: List of imported modules.
|
|
17
|
+
complexity: Cyclomatic complexity score.
|
|
18
|
+
docstrings: Dictionary containing docstring presence information.
|
|
19
|
+
has_main: True if the module has a __main__ guard.
|
|
20
|
+
file_size_kb: Size of the file in kilobytes.
|
|
21
|
+
syntax_error: True if the file has syntax errors.
|
|
22
|
+
metadata: Additional analysis metadata.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
path: str
|
|
26
|
+
lines: int
|
|
27
|
+
functions: List[Dict[str, Any]]
|
|
28
|
+
classes: List[str]
|
|
29
|
+
imports: List[str]
|
|
30
|
+
complexity: int
|
|
31
|
+
docstrings: Dict[str, Any]
|
|
32
|
+
has_main: bool
|
|
33
|
+
file_size_kb: float
|
|
34
|
+
syntax_error: bool = False
|
|
35
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ProjectContext:
|
|
40
|
+
"""Consolidated context for an entire project analysis.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
project_name: Name of the project under analysis.
|
|
44
|
+
structure: Dictionary describing project file structure.
|
|
45
|
+
entry_points: List of detected entry points.
|
|
46
|
+
tech_stack: Dictionary of detected technologies and their versions.
|
|
47
|
+
patterns: Dictionary of detected architectural patterns.
|
|
48
|
+
technical_debt: List of identified technical debt items.
|
|
49
|
+
optimization_opportunities: List of suggested optimizations.
|
|
50
|
+
security_issues: List of identified security vulnerabilities.
|
|
51
|
+
metrics: Consolidated project-level metrics and scores.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
project_name: str
|
|
55
|
+
structure: Dict[str, Any] = field(default_factory=dict)
|
|
56
|
+
entry_points: List[str] = field(default_factory=list)
|
|
57
|
+
tech_stack: Dict[str, List[str]] = field(default_factory=dict)
|
|
58
|
+
patterns: Dict[str, Any] = field(default_factory=dict)
|
|
59
|
+
technical_debt: List[Dict[str, Any]] = field(default_factory=list)
|
|
60
|
+
optimization_opportunities: List[Dict[str, Any]] = field(default_factory=list)
|
|
61
|
+
security_issues: List[Dict[str, Any]] = field(default_factory=list)
|
|
62
|
+
metrics: Dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Reporters package for the QGIS Plugin Analyzer."""
|
|
2
|
+
|
|
3
|
+
from .html_reporter import generate_html_report
|
|
4
|
+
from .markdown_reporter import generate_markdown_summary, save_json_context
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"generate_html_report",
|
|
8
|
+
"generate_markdown_summary",
|
|
9
|
+
"save_json_context",
|
|
10
|
+
]
|