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.
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,5 @@
1
+ """Models package for the QGIS Plugin Analyzer."""
2
+
3
+ from .analysis_models import ModuleAnalysis, ProjectContext
4
+
5
+ __all__ = ["ModuleAnalysis", "ProjectContext"]
@@ -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
+ ]