qgis-plugin-analyzer 1.4.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 (41) hide show
  1. analyzer/__init__.py +2 -1
  2. analyzer/cli/__init__.py +14 -0
  3. analyzer/cli/app.py +147 -0
  4. analyzer/cli/base.py +93 -0
  5. analyzer/cli/commands/__init__.py +19 -0
  6. analyzer/cli/commands/analyze.py +47 -0
  7. analyzer/cli/commands/fix.py +58 -0
  8. analyzer/cli/commands/init.py +41 -0
  9. analyzer/cli/commands/list_rules.py +41 -0
  10. analyzer/cli/commands/security.py +46 -0
  11. analyzer/cli/commands/summary.py +52 -0
  12. analyzer/cli/commands/version.py +41 -0
  13. analyzer/cli.py +4 -281
  14. analyzer/commands.py +163 -0
  15. analyzer/engine.py +491 -245
  16. analyzer/fixer.py +206 -130
  17. analyzer/reporters/markdown_reporter.py +88 -14
  18. analyzer/reporters/summary_reporter.py +226 -49
  19. analyzer/rules/qgis_rules.py +3 -1
  20. analyzer/scanner.py +219 -711
  21. analyzer/secrets.py +84 -0
  22. analyzer/security_checker.py +85 -0
  23. analyzer/security_rules.py +127 -0
  24. analyzer/transformers.py +29 -8
  25. analyzer/utils/__init__.py +2 -0
  26. analyzer/utils/path_utils.py +53 -1
  27. analyzer/validators.py +90 -55
  28. analyzer/visitors/__init__.py +19 -0
  29. analyzer/visitors/base.py +75 -0
  30. analyzer/visitors/composite_visitor.py +73 -0
  31. analyzer/visitors/imports_visitor.py +85 -0
  32. analyzer/visitors/metrics_visitor.py +158 -0
  33. analyzer/visitors/security_visitor.py +52 -0
  34. analyzer/visitors/standards_visitor.py +284 -0
  35. {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/METADATA +32 -10
  36. qgis_plugin_analyzer-1.6.0.dist-info/RECORD +52 -0
  37. {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/WHEEL +1 -1
  38. qgis_plugin_analyzer-1.4.0.dist-info/RECORD +0 -30
  39. {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/entry_points.txt +0 -0
  40. {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/licenses/LICENSE +0 -0
  41. {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,75 @@
1
+ """Base visitor class with shared functionality for all AST visitors."""
2
+
3
+ import ast
4
+ from typing import Any, Dict, List, Optional
5
+
6
+
7
+ class BaseVisitor(ast.NodeVisitor):
8
+ """Base class for AST visitors with common reporting and configuration logic.
9
+
10
+ Attributes:
11
+ rel_path: Relative path to the file being analyzed.
12
+ issues: List of detected issues.
13
+ rules_config: Configuration for audit rules and severities.
14
+ """
15
+
16
+ def __init__(self, rel_path: str, rules_config: Optional[Dict[str, Any]] = None) -> None:
17
+ """Initializes the base visitor.
18
+
19
+ Args:
20
+ rel_path: Relative path to the file being analyzed.
21
+ rules_config: Optional configuration for audit rules and severities.
22
+ """
23
+ self.rel_path = rel_path
24
+ self.issues: List[Dict[str, Any]] = []
25
+ self.rules_config = rules_config or {}
26
+
27
+ def _should_report(self, rule_id: str) -> bool:
28
+ """Check if rule should be reported based on config.
29
+
30
+ Args:
31
+ rule_id: The rule identifier.
32
+
33
+ Returns:
34
+ True if the rule should be reported, False otherwise.
35
+ """
36
+ severity = self.rules_config.get(rule_id, "warning")
37
+ return bool(severity != "ignore")
38
+
39
+ def _get_severity(self, rule_id: str) -> str:
40
+ """Get configured severity for rule (maps to 'high', 'medium', 'low').
41
+
42
+ Args:
43
+ rule_id: The rule identifier.
44
+
45
+ Returns:
46
+ The severity level as a string.
47
+ """
48
+ config_severity = self.rules_config.get(rule_id, "warning")
49
+ severity_map = {
50
+ "error": "high",
51
+ "warning": "medium",
52
+ "info": "low",
53
+ }
54
+ return severity_map.get(config_severity, "medium")
55
+
56
+ def _report_issue(self, rule_id: str, line: int, message: str, code: str = "") -> None:
57
+ """Helper to report an issue if enabled.
58
+
59
+ Args:
60
+ rule_id: The rule identifier.
61
+ line: Line number where the issue was detected.
62
+ message: Description of the issue.
63
+ code: Optional code snippet related to the issue.
64
+ """
65
+ if self._should_report(rule_id):
66
+ self.issues.append(
67
+ {
68
+ "file": self.rel_path,
69
+ "line": line,
70
+ "type": rule_id,
71
+ "severity": self._get_severity(rule_id),
72
+ "message": message,
73
+ "code": code,
74
+ }
75
+ )
@@ -0,0 +1,73 @@
1
+ """Composite visitor that orchestrates all specialized visitors."""
2
+
3
+ import ast
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from .imports_visitor import ImportsVisitor
7
+ from .metrics_visitor import MetricsVisitor
8
+ from .standards_visitor import StandardsVisitor
9
+
10
+
11
+ class CompositeVisitor(ast.NodeVisitor):
12
+ """Orchestrator that combines all specialized visitors.
13
+
14
+ This class maintains compatibility with the original QGISASTVisitor API
15
+ while delegating work to specialized visitors.
16
+
17
+ Attributes:
18
+ rel_path: Relative path to the file being analyzed.
19
+ issues: Aggregated list of all issues from all visitors.
20
+ docstring_styles: Aggregated docstring styles from metrics visitor.
21
+ type_hint_stats: Type hint statistics from metrics visitor.
22
+ docstring_stats: Docstring statistics from metrics visitor.
23
+ """
24
+
25
+ def __init__(self, rel_path: str, rules_config: Optional[Dict[str, Any]] = None) -> None:
26
+ """Initializes the composite visitor.
27
+
28
+ Args:
29
+ rel_path: Relative path to the file being analyzed.
30
+ rules_config: Optional configuration for audit rules and severities.
31
+ """
32
+ self.rel_path = rel_path
33
+ self.rules_config = rules_config or {}
34
+
35
+ # Initialize specialized visitors
36
+ self._imports_visitor = ImportsVisitor(rel_path, rules_config)
37
+ self._metrics_visitor = MetricsVisitor(rel_path, rules_config)
38
+ self._standards_visitor = StandardsVisitor(rel_path, rules_config)
39
+
40
+ # Aggregated results
41
+ self.issues: List[Dict[str, Any]] = []
42
+
43
+ @property
44
+ def docstring_styles(self) -> List[str]:
45
+ """Returns docstring styles from metrics visitor."""
46
+ return self._metrics_visitor.docstring_styles
47
+
48
+ @property
49
+ def type_hint_stats(self) -> Dict[str, int]:
50
+ """Returns type hint statistics from metrics visitor."""
51
+ return self._metrics_visitor.type_hint_stats
52
+
53
+ @property
54
+ def docstring_stats(self) -> Dict[str, int]:
55
+ """Returns docstring statistics from metrics visitor."""
56
+ return self._metrics_visitor.docstring_stats
57
+
58
+ def visit(self, node: ast.AST) -> None:
59
+ """Visits a node with all specialized visitors.
60
+
61
+ Args:
62
+ node: The AST node to visit.
63
+ """
64
+ # Dispatch to all specialized visitors
65
+ self._imports_visitor.visit(node)
66
+ self._metrics_visitor.visit(node)
67
+ self._standards_visitor.visit(node)
68
+
69
+ # Aggregate issues
70
+ self.issues = []
71
+ self.issues.extend(self._imports_visitor.issues)
72
+ self.issues.extend(self._metrics_visitor.issues)
73
+ self.issues.extend(self._standards_visitor.issues)
@@ -0,0 +1,85 @@
1
+ """AST visitor for import validation and analysis."""
2
+
3
+ import ast
4
+ from typing import Any, cast
5
+
6
+ from .base import BaseVisitor
7
+
8
+
9
+ class ImportsVisitor(BaseVisitor):
10
+ """Visitor focused on import-related checks.
11
+
12
+ Detects issues like:
13
+ - Direct GDAL imports
14
+ - Legacy PyQt4/PyQt5 imports
15
+ - Protected member imports
16
+ - Heavy dependencies in UI files
17
+ """
18
+
19
+ def visit_Import(self, node: ast.Import) -> None:
20
+ """Analyzes import nodes.
21
+
22
+ Args:
23
+ node: The import AST node.
24
+ """
25
+ for alias in node.names:
26
+ self._check_import_name(alias.name, node, ast.unparse(node))
27
+ self.generic_visit(node)
28
+
29
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
30
+ """Analyzes 'from import' nodes.
31
+
32
+ Args:
33
+ node: The import-from AST node.
34
+ """
35
+ if node.module:
36
+ self._check_import_name(node.module, node, ast.unparse(node))
37
+
38
+ # HEAVY_LOGIC_UI check
39
+ heavy_libs = {"pandas", "numpy", "scipy", "sklearn", "matplotlib"}
40
+ is_ui_file = "gui" in self.rel_path.lower() or "ui" in self.rel_path.lower()
41
+ if is_ui_file and (
42
+ node.module in heavy_libs or node.module.split(".")[0] in heavy_libs
43
+ ):
44
+ self._report_issue(
45
+ "HEAVY_LOGIC_UI",
46
+ node.lineno,
47
+ f"Heavy dependency '{node.module}' detected in UI file. Move logic to core.",
48
+ ast.unparse(node),
49
+ )
50
+ self.generic_visit(node)
51
+
52
+ def _check_import_name(self, name: str, node: ast.AST, code_snippet: str) -> None:
53
+ """Checks a single import name for violations.
54
+
55
+ Args:
56
+ name: The import name to check.
57
+ node: The AST node containing the import.
58
+ code_snippet: String representation of the import statement.
59
+ """
60
+ # QGIS_PROTECTED_MEMBER
61
+ if name.startswith("qgis._") and not name.startswith("qgis._3d"):
62
+ self._report_issue(
63
+ "QGIS_PROTECTED_MEMBER",
64
+ cast(Any, node).lineno,
65
+ f"Protected member import detected: '{name}'. Protected members are unstable.",
66
+ code_snippet,
67
+ )
68
+
69
+ # GDAL_DIRECT_IMPORT
70
+ if name == "gdal":
71
+ self._report_issue(
72
+ "GDAL_DIRECT_IMPORT",
73
+ cast(Any, node).lineno,
74
+ "Direct 'gdal' import detected. Use 'from osgeo import gdal'.",
75
+ code_snippet,
76
+ )
77
+
78
+ # QGIS_LEGACY_IMPORT
79
+ if name.startswith(("PyQt4", "PyQt5")):
80
+ self._report_issue(
81
+ "QGIS_LEGACY_IMPORT",
82
+ cast(Any, node).lineno,
83
+ f"Legacy import detected: '{name}'. Use 'qgis.PyQt' for compatibility.",
84
+ code_snippet,
85
+ )
@@ -0,0 +1,158 @@
1
+ """AST visitor for docstring and metrics collection."""
2
+
3
+ import ast
4
+ import re
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from ..utils.ast_utils import calculate_complexity
8
+ from .base import BaseVisitor
9
+
10
+
11
+ class MetricsVisitor(BaseVisitor):
12
+ """Visitor focused on collecting research-based metrics.
13
+
14
+ Collects:
15
+ - Docstring coverage and styles
16
+ - Type hint coverage
17
+ - Complexity metrics
18
+ """
19
+
20
+ def __init__(self, rel_path: str, rules_config: Optional[Dict[str, Any]] = None) -> None:
21
+ """Initializes the metrics visitor.
22
+
23
+ Args:
24
+ rel_path: Relative path to the file being analyzed.
25
+ rules_config: Optional configuration for audit rules and severities.
26
+ """
27
+ super().__init__(rel_path, rules_config)
28
+ self.docstring_styles: List[str] = []
29
+ self.type_hint_stats = {
30
+ "total_parameters": 0,
31
+ "annotated_parameters": 0,
32
+ "has_return_hint": 0,
33
+ "total_functions": 0,
34
+ }
35
+ self.docstring_stats = {"total_public_items": 0, "has_docstring": 0}
36
+
37
+ def visit_Module(self, node: ast.Module) -> None:
38
+ """Analyzes a module-level AST node.
39
+
40
+ Args:
41
+ node: The module AST node.
42
+ """
43
+ doc = ast.get_docstring(node)
44
+ self.docstring_stats["total_public_items"] += 1
45
+ if doc:
46
+ self.docstring_stats["has_docstring"] += 1
47
+ self._check_docstring_style(doc)
48
+ else:
49
+ self._report_issue(
50
+ "MISSING_DOCSTRING",
51
+ 1,
52
+ "Module is missing a docstring (PEP 257).",
53
+ f"Module: {self.rel_path}",
54
+ )
55
+ self.generic_visit(node)
56
+
57
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
58
+ """Analyzes function definitions.
59
+
60
+ Args:
61
+ node: The function definition AST node.
62
+ """
63
+ # HIGH_COMPLEXITY
64
+ complexity = calculate_complexity(node)
65
+ if complexity > 15:
66
+ self._report_issue(
67
+ "HIGH_COMPLEXITY",
68
+ node.lineno,
69
+ f"Function '{node.name}' is too complex (CC={complexity} > 15). Consider extracting methods.",
70
+ f"def {node.name}...",
71
+ )
72
+
73
+ self._check_docstring_and_metrics(node)
74
+ self._check_type_hints(node)
75
+ self.generic_visit(node)
76
+
77
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
78
+ """Analyzes class definitions.
79
+
80
+ Args:
81
+ node: The class definition AST node.
82
+ """
83
+ # Missing Docstring
84
+ if not node.name.startswith("_"):
85
+ doc = ast.get_docstring(node)
86
+ self.docstring_stats["total_public_items"] += 1
87
+ if doc:
88
+ self.docstring_stats["has_docstring"] += 1
89
+ self._check_docstring_style(doc)
90
+ else:
91
+ self._report_issue(
92
+ "MISSING_DOCSTRING",
93
+ node.lineno,
94
+ f"Public class '{node.name}' is missing a docstring.",
95
+ f"class {node.name}...",
96
+ )
97
+
98
+ self.generic_visit(node)
99
+
100
+ def _check_docstring_style(self, doc: Optional[str]) -> None:
101
+ """Identifies Google or NumPy docstring styles within a string.
102
+
103
+ Args:
104
+ doc: The docstring to analyze.
105
+ """
106
+ if not doc:
107
+ return
108
+ # Google: Args: or Returns: or Raises: as headers
109
+ if re.search(r"\n\s*(Args|Returns|Raises|Yields):\s*\n", doc):
110
+ self.docstring_styles.append("Google")
111
+ # NumPy: Underlined headers
112
+ elif re.search(r"\n(Parameters|Returns|Raises|Yields)\n\s*-{3,}", doc):
113
+ self.docstring_styles.append("NumPy")
114
+
115
+ def _check_docstring_and_metrics(self, node: ast.FunctionDef) -> None:
116
+ """Checks docstrings and collects metrics.
117
+
118
+ Args:
119
+ node: The function definition AST node.
120
+ """
121
+ if not node.name.startswith("_") and node.name != "__init__":
122
+ doc = ast.get_docstring(node)
123
+ self.docstring_stats["total_public_items"] += 1
124
+ if doc:
125
+ self.docstring_stats["has_docstring"] += 1
126
+ self._check_docstring_style(doc)
127
+ else:
128
+ self._report_issue(
129
+ "MISSING_DOCSTRING",
130
+ node.lineno,
131
+ f"Public function '{node.name}' is missing a docstring.",
132
+ f"def {node.name}...",
133
+ )
134
+
135
+ def _check_type_hints(self, node: ast.FunctionDef) -> None:
136
+ """Checks for type hints.
137
+
138
+ Args:
139
+ node: The function definition AST node.
140
+ """
141
+ if node.name == "__init__":
142
+ return
143
+
144
+ self.type_hint_stats["total_functions"] += 1
145
+ params = [a for a in node.args.args if a.arg != "self" and a.arg != "cls"]
146
+ self.type_hint_stats["total_parameters"] += len(params)
147
+ annotated = [a for a in params if a.annotation]
148
+ self.type_hint_stats["annotated_parameters"] += len(annotated)
149
+ if node.returns:
150
+ self.type_hint_stats["has_return_hint"] += 1
151
+
152
+ if params and not annotated and not node.returns:
153
+ self._report_issue(
154
+ "MISSING_TYPE_HINTS",
155
+ node.lineno,
156
+ f"Function '{node.name}' has no type annotations.",
157
+ f"def {node.name}...",
158
+ )
@@ -0,0 +1,52 @@
1
+ """AST visitor for security vulnerability detection."""
2
+
3
+ import ast
4
+ from typing import Any, Dict, List
5
+
6
+ from ..security_checker import SecurityContext, SecurityRegistry
7
+
8
+
9
+ class SecurityVisitor(ast.NodeVisitor):
10
+ """AST visitor focused on security vulnerabilities (Bandit-inspired).
11
+
12
+ Attributes:
13
+ rel_path: Relative path to the file being analyzed.
14
+ findings: List of security findings detected.
15
+ """
16
+
17
+ def __init__(self, rel_path: str):
18
+ """Initializes the security visitor.
19
+
20
+ Args:
21
+ rel_path: Relative path to the file being analyzed.
22
+ """
23
+ self.rel_path = rel_path
24
+ self.findings: List[Dict[str, Any]] = []
25
+
26
+ def visit(self, node: ast.AST):
27
+ """Dispatches security checks for the current node.
28
+
29
+ Args:
30
+ node: The AST node to analyze.
31
+ """
32
+ checks = SecurityRegistry.get_checks_for_node(type(node))
33
+ context = SecurityContext(node, self.rel_path)
34
+
35
+ for check_func in checks:
36
+ finding = check_func(context)
37
+ if finding:
38
+ self.findings.append(
39
+ {
40
+ "file": self.rel_path,
41
+ "line": finding.line,
42
+ "type": finding.id,
43
+ "severity": finding.severity.lower(),
44
+ "message": finding.message,
45
+ "code": finding.code_snippet,
46
+ "confidence": finding.confidence.lower()
47
+ if hasattr(finding, "confidence")
48
+ else "medium",
49
+ }
50
+ )
51
+
52
+ super().generic_visit(node)