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.
Files changed (37) hide show
  1. analyzer/cli/__init__.py +14 -0
  2. analyzer/cli/app.py +147 -0
  3. analyzer/cli/base.py +93 -0
  4. analyzer/cli/commands/__init__.py +19 -0
  5. analyzer/cli/commands/analyze.py +47 -0
  6. analyzer/cli/commands/fix.py +58 -0
  7. analyzer/cli/commands/init.py +41 -0
  8. analyzer/cli/commands/list_rules.py +41 -0
  9. analyzer/cli/commands/security.py +46 -0
  10. analyzer/cli/commands/summary.py +52 -0
  11. analyzer/cli/commands/version.py +41 -0
  12. analyzer/cli.py +4 -184
  13. analyzer/commands.py +7 -7
  14. analyzer/engine.py +421 -238
  15. analyzer/fixer.py +206 -130
  16. analyzer/reporters/markdown_reporter.py +48 -15
  17. analyzer/reporters/summary_reporter.py +193 -80
  18. analyzer/scanner.py +218 -138
  19. analyzer/transformers.py +29 -8
  20. analyzer/utils/__init__.py +2 -0
  21. analyzer/utils/path_utils.py +53 -1
  22. analyzer/validators.py +90 -55
  23. analyzer/visitors/__init__.py +19 -0
  24. analyzer/visitors/base.py +75 -0
  25. analyzer/visitors/composite_visitor.py +73 -0
  26. analyzer/visitors/imports_visitor.py +85 -0
  27. analyzer/visitors/metrics_visitor.py +158 -0
  28. analyzer/visitors/security_visitor.py +52 -0
  29. analyzer/visitors/standards_visitor.py +284 -0
  30. {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/METADATA +16 -7
  31. qgis_plugin_analyzer-1.6.0.dist-info/RECORD +52 -0
  32. analyzer/visitors.py +0 -455
  33. qgis_plugin_analyzer-1.5.0.dist-info/RECORD +0 -35
  34. {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/WHEEL +0 -0
  35. {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/entry_points.txt +0 -0
  36. {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/licenses/LICENSE +0 -0
  37. {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[Dict[str, Any]]:
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
- if project_path.is_file():
57
- rel_path = py_file.name
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
- try:
70
- tree = ast.parse(content)
71
- except SyntaxError:
72
- return {
73
- "path": rel_path,
74
- "lines": content.count("\n") + 1,
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
- functions = extract_functions_from_ast(tree)
98
- classes = extract_classes_from_ast(tree)
99
- imports = extract_imports_from_ast(tree)
100
- module_complexity = calculate_module_complexity(tree)
101
- has_main = check_main_guard(tree)
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
- # Custom AST Audit
130
+ # Run Audits
104
131
  visitor = QGISASTVisitor(rel_path, rules_config=rules_config)
105
132
  visitor.visit(tree)
106
133
 
107
- # Security AST Audit
108
- security_visitor = QGISSecurityVisitor(rel_path)
109
- security_visitor.visit(tree)
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
- # Secrets Scanning (Regex + Entropy)
112
- secret_scanner = SecretScanner()
113
- secret_findings = secret_scanner.scan_text(content)
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
- return {
130
- "path": rel_path,
131
- "lines": content.count("\n") + 1,
132
- "functions": functions,
133
- "classes": classes,
134
- "imports": imports,
135
- "complexity": module_complexity,
136
- "has_main": has_main,
137
- "docstrings": {
138
- "module": ast.get_docstring(tree) is not None,
139
- },
140
- "file_size_kb": py_file.stat().st_size / 1024,
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[Dict[str, Any]],
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 found via AST
177
- if "ast_issues" in module:
178
- results["issues"].extend(module["ast_issues"])
179
-
180
- # Use cached content if available
181
- path = module.get("path")
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
- for rule in rules:
196
- rule_id = rule["id"]
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 apply_transformation(file_path: pathlib.Path, transformer: ast.NodeTransformer) -> bool:
158
- """Applies an AST transformation to a file and writes back the modified code.
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
- file_path: Path to the Python file to transform.
163
+ content: The Python source code.
162
164
  transformer: The AST node transformer to apply.
163
165
 
164
166
  Returns:
165
- True if the file was modified, False otherwise.
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
 
@@ -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",
@@ -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
+ }