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/semantic.py ADDED
@@ -0,0 +1,213 @@
1
+ # /***************************************************************************
2
+ # QGIS Plugin Analyzer
3
+ #
4
+ # Semantic analysis module for cross-file dependency and resource validation.
5
+ # ***************************************************************************/
6
+
7
+ import pathlib
8
+ import xml.etree.ElementTree as ET
9
+ from typing import Any, Dict, List, Set
10
+
11
+
12
+ class DependencyGraph:
13
+ """Builds and analyzes the module dependency graph to detect circular imports.
14
+
15
+ Attributes:
16
+ adjacency_list: Maps module path to the set of imported module paths.
17
+ nodes: Maps module path to its extracted metadata.
18
+ """
19
+
20
+ def __init__(self) -> None:
21
+ """Initializes an empty dependency graph."""
22
+ # Maps module path -> set of imported module paths
23
+ self.adjacency_list: Dict[str, Set[str]] = {}
24
+ # Maps module path -> metadata (imports, functions, etc.)
25
+ self.nodes: Dict[str, Dict[str, Any]] = {}
26
+
27
+ def add_node(self, module_path: str, data: Dict[str, Any]) -> None:
28
+ """Adds a module node to the graph.
29
+
30
+ Args:
31
+ module_path: Relative path to the module file.
32
+ data: Metadata dictionary for the module.
33
+ """
34
+ self.nodes[module_path] = data
35
+ self.adjacency_list[module_path] = set()
36
+
37
+ def build_edges(self, project_path: pathlib.Path) -> None:
38
+ """Resolves module imports and builds edges between nodes.
39
+
40
+ Args:
41
+ project_path: Root path of the project.
42
+ """
43
+ for module_path, data in self.nodes.items():
44
+ current_file = project_path / module_path
45
+ current_dir = current_file.parent
46
+
47
+ for imp in data.get("imports", []):
48
+ resolved_path = self._resolve_import(imp, current_dir, project_path)
49
+ if resolved_path and resolved_path in self.nodes:
50
+ self.adjacency_list[module_path].add(resolved_path)
51
+
52
+ def _resolve_import(
53
+ self, import_name: str, current_dir: pathlib.Path, project_path: pathlib.Path
54
+ ) -> str:
55
+ """Attempts to resolve a Python import string to a relative file path.
56
+
57
+ Args:
58
+ import_name: The name of the imported module or package.
59
+ current_dir: Directory containing the importing file.
60
+ project_path: Root directory of the project.
61
+
62
+ Returns:
63
+ The relative path to the resolved module, or an empty string if not found.
64
+ """
65
+ # Handle relative imports (e.g., .utils)
66
+ if import_name.startswith("."):
67
+ # This is a simplification. Ideally AST gives better level info.
68
+ # Assuming same package level for now if single dot
69
+ parts = import_name.lstrip(".").split(".")
70
+ target = current_dir.joinpath(*parts).with_suffix(".py")
71
+ try:
72
+ rel = str(target.relative_to(project_path))
73
+ return rel
74
+ except ValueError:
75
+ pass
76
+ return ""
77
+
78
+ # Handle absolute imports within project
79
+ parts = import_name.split(".")
80
+ target = project_path.joinpath(*parts).with_suffix(".py")
81
+ try:
82
+ rel = str(target.relative_to(project_path))
83
+ return rel
84
+ except ValueError:
85
+ # Maybe it is a package (__init__.py)
86
+ target_pkg = project_path.joinpath(*parts) / "__init__.py"
87
+ try:
88
+ rel = str(target_pkg.relative_to(project_path))
89
+ return rel
90
+ except ValueError:
91
+ pass
92
+ return ""
93
+
94
+ def detect_cycles(self) -> List[List[str]]:
95
+ """Detects circular import cycles using Depth First Search (DFS).
96
+
97
+ Returns:
98
+ A list of dependency cycles, where each cycle is a list of module paths.
99
+ """
100
+ visited = set()
101
+ recursion_stack = set()
102
+ cycles = []
103
+
104
+ def dfs(node, path):
105
+ visited.add(node)
106
+ recursion_stack.add(node)
107
+ path.append(node)
108
+
109
+ for neighbor in self.adjacency_list.get(node, []):
110
+ if neighbor not in visited:
111
+ dfs(neighbor, path)
112
+ elif neighbor in recursion_stack:
113
+ # Cycle found
114
+ cycle_start_index = path.index(neighbor)
115
+ cycles.append(path[cycle_start_index:] + [neighbor])
116
+
117
+ recursion_stack.remove(node)
118
+ path.pop()
119
+
120
+ for node in self.nodes:
121
+ if node not in visited:
122
+ dfs(node, [])
123
+
124
+ return cycles
125
+
126
+ def get_coupling_metrics(self) -> Dict[str, Dict[str, int]]:
127
+ """Calculates Fan-In and Fan-Out metrics for each module in the graph.
128
+
129
+ Returns:
130
+ A dictionary mapping module paths to their coupling metrics.
131
+ """
132
+ metrics = {node: {"fan_in": 0, "fan_out": 0} for node in self.nodes}
133
+
134
+ for source, targets in self.adjacency_list.items():
135
+ metrics[source]["fan_out"] = len(targets)
136
+ for target in targets:
137
+ if target in metrics: # Should always be true if graph valid
138
+ metrics[target]["fan_in"] += 1
139
+
140
+ return metrics
141
+
142
+
143
+ class ResourceValidator:
144
+ """Validates Qt resource (QRC) usage against available definitions.
145
+
146
+ Attributes:
147
+ project_path: Path to the root of the project.
148
+ available_resources: A set of detected resource strings (e.g., ':/plugins/...').
149
+ """
150
+
151
+ def __init__(self, project_path: pathlib.Path) -> None:
152
+ """Initializes the resource validator.
153
+
154
+ Args:
155
+ project_path: Root path of the project.
156
+ """
157
+ self.project_path = project_path
158
+ self.available_resources: Set[str] = set()
159
+
160
+ def scan_project_resources(self, ignore_matcher: Any = None) -> None:
161
+ """Scans the project for .qrc files and extracts available resource paths.
162
+
163
+ Args:
164
+ ignore_matcher: Optional object to determine if a path should be ignored.
165
+ """
166
+ # Strategy: Parse .qrc files primarily as they are the source of truth
167
+ # Regex to find <file>path/to/icon.png</file> inside <qresource prefix="/plugins/myplugin">
168
+
169
+ for qrc_file in self.project_path.rglob("*.qrc"):
170
+ # Skip if matches ignore pattern
171
+ if ignore_matcher and ignore_matcher.is_ignored(qrc_file):
172
+ continue
173
+
174
+ try:
175
+ # Use standard xml.etree.ElementTree for robust parsing
176
+ # Note: ElementTree is safe against XXE by default in Python 3.x
177
+ # as it does not resolve entities unless a custom parser is provided.
178
+ try:
179
+ tree = ET.parse(qrc_file)
180
+ root = tree.getroot()
181
+ for qresource in root.findall("qresource"):
182
+ prefix = qresource.get("prefix", "/")
183
+ if not prefix.startswith("/"):
184
+ prefix = "/" + prefix
185
+
186
+ for file_elem in qresource.findall("file"):
187
+ if file_elem.text:
188
+ clean_path = file_elem.text.strip()
189
+ # Construct full resource path: :/prefix/path
190
+ res_path = f":{prefix}/{clean_path}".replace("//", "/")
191
+ self.available_resources.add(res_path)
192
+ except ET.ParseError:
193
+ pass # Fallback or log warning
194
+
195
+ except Exception:
196
+ pass
197
+
198
+ def validate_usage(self, resource_matches: List[str]) -> List[str]:
199
+ """Identifies resource paths used in code that are missing from definition files.
200
+
201
+ Args:
202
+ resource_matches: List of resource strings extracted from the code.
203
+
204
+ Returns:
205
+ A list of unique, missing resource strings.
206
+ """
207
+ missing = []
208
+ for res in resource_matches:
209
+ # Simple exact match check.
210
+ # Note: Alias handling is complex without compilation, ignoring for now.
211
+ if res not in self.available_resources:
212
+ missing.append(res)
213
+ return sorted(list(set(missing)))
@@ -0,0 +1,190 @@
1
+ # /***************************************************************************
2
+ # QGIS Plugin Analyzer
3
+ #
4
+ # AST-based code transformers for auto-fixing common issues.
5
+ # ***************************************************************************/
6
+
7
+ import ast
8
+ import pathlib
9
+ from typing import Optional
10
+
11
+
12
+ class GDALImportTransformer(ast.NodeTransformer):
13
+ """AST transformer that replaces direct GDAL imports with the OSGeo version.
14
+
15
+ Transforms 'import gdal' into 'from osgeo import gdal'.
16
+ """
17
+
18
+ def __init__(self) -> None:
19
+ """Initializes the transformer state."""
20
+ self.changes_made = False
21
+
22
+ def visit_Import(self, node: ast.Import) -> Optional[ast.AST]:
23
+ for alias in node.names:
24
+ if alias.name == "gdal":
25
+ self.changes_made = True
26
+ # Create 'from osgeo import gdal'
27
+ return ast.ImportFrom(
28
+ module="osgeo",
29
+ names=[ast.alias(name="gdal", asname=alias.asname)],
30
+ level=0,
31
+ )
32
+ return node
33
+
34
+
35
+ class LegacyImportTransformer(ast.NodeTransformer):
36
+ """AST transformer that modernizes PyQt4/PyQt5 imports to qgis.PyQt.
37
+
38
+ Attributes:
39
+ changes_made: Boolean flag indicating if any changes were applied.
40
+ """
41
+
42
+ def __init__(self) -> None:
43
+ """Initializes the transformer state."""
44
+ self.changes_made = False
45
+
46
+ def visit_Import(self, node: ast.Import) -> ast.Import:
47
+ for alias in node.names:
48
+ if alias.name.startswith(("PyQt4", "PyQt5")):
49
+ self.changes_made = True
50
+ # Replace PyQt5.QtCore -> qgis.PyQt.QtCore
51
+ new_name = alias.name.replace("PyQt5", "qgis.PyQt").replace("PyQt4", "qgis.PyQt")
52
+ alias.name = new_name
53
+ return node
54
+
55
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> ast.ImportFrom:
56
+ if node.module and node.module.startswith(("PyQt4", "PyQt5")):
57
+ self.changes_made = True
58
+ node.module = node.module.replace("PyQt5", "qgis.PyQt").replace("PyQt4", "qgis.PyQt")
59
+ return node
60
+
61
+
62
+ class PrintToLogTransformer(ast.NodeTransformer):
63
+ """AST transformer that replaces print() calls with QgsMessageLog.logMessage().
64
+
65
+ Attributes:
66
+ changes_made: Boolean flag indicating if any changes were applied.
67
+ needs_import: Boolean flag indicating if a new import is required.
68
+ """
69
+
70
+ def __init__(self) -> None:
71
+ """Initializes the transformer state."""
72
+ self.changes_made = False
73
+ self.needs_import = False
74
+
75
+ def visit_Expr(self, node: ast.Expr) -> ast.Expr:
76
+ # Check if it's a print() call
77
+ if isinstance(node.value, ast.Call):
78
+ if isinstance(node.value.func, ast.Name) and node.value.func.id == "print":
79
+ self.changes_made = True
80
+ self.needs_import = True
81
+
82
+ # Get the message argument
83
+ if node.value.args:
84
+ message = node.value.args[0]
85
+ else:
86
+ message = ast.Constant(value="")
87
+
88
+ # Create QgsMessageLog.logMessage(message, "Plugin", Qgis.Info)
89
+ new_call = ast.Expr(
90
+ value=ast.Call(
91
+ func=ast.Attribute(
92
+ value=ast.Name(id="QgsMessageLog", ctx=ast.Load()),
93
+ attr="logMessage",
94
+ ctx=ast.Load(),
95
+ ),
96
+ args=[
97
+ message,
98
+ ast.Constant(value="Plugin"),
99
+ ast.Attribute(
100
+ value=ast.Name(id="Qgis", ctx=ast.Load()),
101
+ attr="Info",
102
+ ctx=ast.Load(),
103
+ ),
104
+ ],
105
+ keywords=[],
106
+ )
107
+ )
108
+ return new_call
109
+ return node
110
+
111
+
112
+ class I18nTransformer(ast.NodeTransformer):
113
+ """AST transformer that wraps UI strings in self.tr() for internationalization.
114
+
115
+ Attributes:
116
+ changes_made: Boolean flag indicating if any changes were applied.
117
+ i18n_methods: Set of method names that accept strings for UI display.
118
+ """
119
+
120
+ def __init__(self) -> None:
121
+ """Initializes the transformer state."""
122
+ self.changes_made = False
123
+ self.i18n_methods = {
124
+ "setText",
125
+ "setWindowTitle",
126
+ "setTitle",
127
+ "setToolTip",
128
+ "setPlaceholderText",
129
+ "setTabText",
130
+ }
131
+
132
+ def visit_Call(self, node: ast.Call) -> ast.Call:
133
+ # Check if it's a UI method call with a string literal
134
+ if isinstance(node.func, ast.Attribute):
135
+ if node.func.attr in self.i18n_methods:
136
+ # Check if first argument is a string literal
137
+ if node.args and isinstance(node.args[0], ast.Constant):
138
+ if isinstance(node.args[0].value, str):
139
+ val = node.args[0].value
140
+ # Skip empty strings or placeholders
141
+ if val.strip() and not val.startswith("%"):
142
+ self.changes_made = True
143
+ # Wrap in self.tr()
144
+ node.args[0] = ast.Call(
145
+ func=ast.Attribute(
146
+ value=ast.Name(id="self", ctx=ast.Load()),
147
+ attr="tr",
148
+ ctx=ast.Load(),
149
+ ),
150
+ args=[ast.Constant(value=val)],
151
+ keywords=[],
152
+ )
153
+ self.generic_visit(node)
154
+ return node
155
+
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.
159
+
160
+ Args:
161
+ file_path: Path to the Python file to transform.
162
+ transformer: The AST node transformer to apply.
163
+
164
+ Returns:
165
+ True if the file was modified, False otherwise.
166
+ """
167
+ try:
168
+ content = file_path.read_text(encoding="utf-8")
169
+ tree = ast.parse(content)
170
+
171
+ # Apply transformation
172
+ new_tree = transformer.visit(tree)
173
+ ast.fix_missing_locations(new_tree)
174
+
175
+ if hasattr(transformer, "changes_made") and transformer.changes_made:
176
+ # Unparse back to code
177
+ new_code = ast.unparse(new_tree)
178
+
179
+ # Add necessary imports if needed
180
+ if hasattr(transformer, "needs_import") and transformer.needs_import:
181
+ if "from qgis.core import QgsMessageLog, Qgis" not in new_code:
182
+ new_code = "from qgis.core import QgsMessageLog, Qgis\n\n" + new_code
183
+
184
+ file_path.write_text(new_code, encoding="utf-8")
185
+ return True
186
+
187
+ return False
188
+ except Exception as e:
189
+ print(f"Error transforming {file_path}: {e}")
190
+ return False
@@ -0,0 +1,39 @@
1
+ """Utilities package for the QGIS Plugin Analyzer."""
2
+
3
+ from .ast_utils import (
4
+ calculate_complexity,
5
+ calculate_module_complexity,
6
+ check_main_guard,
7
+ extract_classes_from_ast,
8
+ extract_functions_from_ast,
9
+ extract_imports_from_ast,
10
+ )
11
+ from .config_utils import _minimal_toml_load, load_profile_config
12
+ from .logging_utils import logger, setup_logger
13
+ from .path_utils import (
14
+ DEFAULT_EXCLUDE,
15
+ IgnoreMatcher,
16
+ load_ignore_patterns,
17
+ safe_path_resolve,
18
+ )
19
+ from .performance_utils import LRUCache, ProgressTracker, timeout_manager
20
+
21
+ __all__ = [
22
+ "calculate_complexity",
23
+ "extract_functions_from_ast",
24
+ "extract_classes_from_ast",
25
+ "extract_imports_from_ast",
26
+ "calculate_module_complexity",
27
+ "check_main_guard",
28
+ "setup_logger",
29
+ "logger",
30
+ "safe_path_resolve",
31
+ "IgnoreMatcher",
32
+ "load_ignore_patterns",
33
+ "DEFAULT_EXCLUDE",
34
+ "load_profile_config",
35
+ "_minimal_toml_load",
36
+ "LRUCache",
37
+ "ProgressTracker",
38
+ "timeout_manager",
39
+ ]
@@ -0,0 +1,133 @@
1
+ """AST utilities for Python code analysis.
2
+
3
+ This module provides helper functions for extracting information and calculating
4
+ metrics from Python Abstract Syntax Trees (AST).
5
+ """
6
+
7
+ import ast
8
+ from typing import Any, Dict, List
9
+
10
+
11
+ def calculate_complexity(node: ast.AST) -> int:
12
+ """Calculates Cyclomatic Complexity for a node.
13
+
14
+ Args:
15
+ node: The AST node to analyze.
16
+
17
+ Returns:
18
+ The cyclomatic complexity score.
19
+ """
20
+ complexity = 1
21
+ for child in ast.walk(node):
22
+ if isinstance(
23
+ child,
24
+ (
25
+ ast.If,
26
+ ast.For,
27
+ ast.While,
28
+ ast.And,
29
+ ast.Or,
30
+ ast.ExceptHandler,
31
+ ast.With,
32
+ ast.AsyncWith,
33
+ ),
34
+ ):
35
+ complexity += 1
36
+ return complexity
37
+
38
+
39
+ def extract_functions_from_ast(tree: ast.AST) -> List[Dict[str, Any]]:
40
+ """Extracts function information from AST.
41
+
42
+ Args:
43
+ tree: The AST tree root.
44
+
45
+ Returns:
46
+ A list of dictionaries containing function metadata (name, args, line, complexity, etc.).
47
+ """
48
+ functions = []
49
+ for node in ast.walk(tree):
50
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
51
+ func_complexity = calculate_complexity(node)
52
+ functions.append(
53
+ {
54
+ "name": node.name,
55
+ "args": [arg.arg for arg in node.args.args],
56
+ "line": node.lineno,
57
+ "end_line": getattr(node, "end_lineno", node.lineno),
58
+ "complexity": func_complexity,
59
+ "docstring": ast.get_docstring(node) is not None,
60
+ }
61
+ )
62
+ return functions
63
+
64
+
65
+ def extract_classes_from_ast(tree: ast.AST) -> List[str]:
66
+ """Extracts class information from AST.
67
+
68
+ Args:
69
+ tree: The AST tree root.
70
+
71
+ Returns:
72
+ A list of class signatures (e.g., "ClassName(BaseClass)").
73
+ """
74
+ classes = []
75
+ for node in ast.walk(tree):
76
+ if isinstance(node, ast.ClassDef):
77
+ bases = [ast.unparse(b) for b in node.bases]
78
+ classes.append(f"{node.name}({', '.join(bases)})" if bases else node.name)
79
+ return classes
80
+
81
+
82
+ def extract_imports_from_ast(tree: ast.AST) -> List[str]:
83
+ """Extracts import information from AST.
84
+
85
+ Args:
86
+ tree: The AST tree root.
87
+
88
+ Returns:
89
+ A sorted list of imported module names.
90
+ """
91
+ imports: list[str] = []
92
+ for node in ast.walk(tree):
93
+ if isinstance(node, ast.Import):
94
+ imports.extend(n.name for n in node.names)
95
+ elif isinstance(node, ast.ImportFrom):
96
+ if node.module:
97
+ imports.append(node.module)
98
+ return sorted(set(imports))
99
+
100
+
101
+ def calculate_module_complexity(tree: ast.AST) -> int:
102
+ """Calculates module-level complexity based on decision points.
103
+
104
+ Args:
105
+ tree: The AST tree root.
106
+
107
+ Returns:
108
+ The module-level complexity score.
109
+ """
110
+ complexity = 1
111
+ for node in ast.walk(tree):
112
+ if isinstance(node, (ast.If, ast.For, ast.While, ast.And, ast.Or, ast.ExceptHandler)):
113
+ complexity += 1
114
+ return complexity
115
+
116
+
117
+ def check_main_guard(tree: ast.AST) -> bool:
118
+ """Checks if module has __name__ == '__main__' guard.
119
+
120
+ Args:
121
+ tree: The AST tree root.
122
+
123
+ Returns:
124
+ True if the main guard is found, False otherwise.
125
+ """
126
+ for node in ast.walk(tree):
127
+ if isinstance(node, ast.If):
128
+ if isinstance(node.test, ast.Compare) and isinstance(node.test.left, ast.Name):
129
+ if node.test.left.id == "__name__":
130
+ for cmp in node.test.comparators:
131
+ if isinstance(cmp, ast.Constant) and cmp.value == "__main__":
132
+ return True
133
+ return False