Coverage for src / tracekit / plugins / discovery.py: 90%
166 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Plugin discovery and scanning.
3This module provides plugin discovery from filesystem directories
4and Python entry points.
7Example:
8 >>> from tracekit.plugins.discovery import discover_plugins
9 >>> plugins = discover_plugins()
10 >>> for plugin in plugins:
11 ... print(f"Found: {plugin.name} v{plugin.version}")
12"""
14from __future__ import annotations
16import importlib
17import importlib.metadata
18import importlib.util
19import os
20import sys
21from dataclasses import dataclass
22from pathlib import Path
23from typing import TYPE_CHECKING
25from tracekit.plugins.base import PluginBase, PluginMetadata
27if TYPE_CHECKING:
28 from collections.abc import Iterator
30# Try to import yaml for plugin.yaml parsing
31try:
32 import yaml
34 YAML_AVAILABLE = True
35except ImportError:
36 YAML_AVAILABLE = False
39# TraceKit API version for compatibility checking
40TRACEKIT_API_VERSION = "1.0.0"
43@dataclass
44class DiscoveredPlugin:
45 """Information about a discovered plugin.
47 Attributes:
48 metadata: Plugin metadata.
49 path: Path to plugin directory or module.
50 entry_point: Entry point name (if from entry points).
51 compatible: Whether plugin is compatible with current API.
52 load_error: Error message if plugin failed to load.
53 """
55 metadata: PluginMetadata
56 path: Path | None = None
57 entry_point: str | None = None
58 compatible: bool = True
59 load_error: str | None = None
62def get_plugin_paths() -> list[Path]:
63 """Get list of plugin search directories.
65 Returns paths in priority order:
66 1. Project plugins: ./plugins/
67 2. User plugins: ~/.tracekit/plugins/
68 3. System plugins: /usr/lib/tracekit/plugins/
70 Returns:
71 List of plugin directory paths.
72 """
73 paths: list[Path] = []
75 # Project plugins (current directory)
76 project_plugins = Path.cwd() / "plugins"
77 if project_plugins.exists():
78 paths.append(project_plugins)
80 # User plugins
81 user_plugins = Path.home() / ".tracekit" / "plugins"
82 paths.append(user_plugins) # Include even if doesn't exist
84 # XDG config home
85 xdg_config = os.environ.get("XDG_CONFIG_HOME")
86 if xdg_config:
87 xdg_plugins = Path(xdg_config) / "tracekit" / "plugins"
88 paths.append(xdg_plugins)
90 # System plugins (Linux)
91 system_plugins = Path("/usr/lib/tracekit/plugins")
92 if system_plugins.exists(): 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true
93 paths.append(system_plugins)
95 # Local lib (Linux)
96 local_plugins = Path("/usr/local/lib/tracekit/plugins")
97 if local_plugins.exists(): 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true
98 paths.append(local_plugins)
100 return paths
103def discover_plugins(
104 *,
105 compatible_only: bool = False,
106 include_disabled: bool = False,
107) -> list[DiscoveredPlugin]:
108 """Discover all available plugins.
110 Scans plugin directories and Python entry points for plugins.
112 Args:
113 compatible_only: If True, only return compatible plugins.
114 include_disabled: If True, include disabled plugins.
116 Returns:
117 List of discovered plugins.
119 Example:
120 >>> plugins = discover_plugins()
121 >>> print(f"Found {len(plugins)} plugins")
122 """
123 plugins: list[DiscoveredPlugin] = []
124 seen_names: set[str] = set()
126 # Scan plugin directories
127 for plugin_dir in get_plugin_paths():
128 if plugin_dir.exists() and plugin_dir.is_dir(): 128 ↛ 127line 128 didn't jump to line 127 because the condition on line 128 was always true
129 for plugin in scan_directory(plugin_dir):
130 if plugin.metadata.name not in seen_names: 130 ↛ 129line 130 didn't jump to line 129 because the condition on line 130 was always true
131 plugins.append(plugin)
132 seen_names.add(plugin.metadata.name)
134 # Scan entry points
135 for plugin in scan_entry_points():
136 if plugin.metadata.name not in seen_names:
137 plugins.append(plugin)
138 seen_names.add(plugin.metadata.name)
140 # Filter by compatibility
141 if compatible_only:
142 plugins = [p for p in plugins if p.compatible]
144 # Filter disabled
145 if not include_disabled:
146 plugins = [p for p in plugins if p.metadata.enabled]
148 return plugins
151def scan_directory(directory: Path) -> Iterator[DiscoveredPlugin]:
152 """Scan a directory for plugins.
154 Each subdirectory with a plugin.yaml or Python package
155 is considered a potential plugin.
157 Args:
158 directory: Directory to scan.
160 Yields:
161 DiscoveredPlugin for each found plugin.
162 """
163 if not directory.exists():
164 return
166 for item in directory.iterdir():
167 if item.is_dir():
168 # Check for plugin.yaml
169 plugin_yaml = item / "plugin.yaml"
170 plugin_yml = item / "plugin.yml"
172 if plugin_yaml.exists():
173 plugin = _load_plugin_from_yaml(plugin_yaml)
174 if plugin: 174 ↛ 182line 174 didn't jump to line 182 because the condition on line 174 was always true
175 yield plugin
176 elif plugin_yml.exists():
177 plugin = _load_plugin_from_yaml(plugin_yml)
178 if plugin: 178 ↛ 182line 178 didn't jump to line 182 because the condition on line 178 was always true
179 yield plugin
181 # Check for Python package with __init__.py
182 init_py = item / "__init__.py"
183 if init_py.exists():
184 plugin = _load_plugin_from_module(item)
185 if plugin:
186 yield plugin
189def scan_entry_points() -> Iterator[DiscoveredPlugin]:
190 """Scan Python entry points for plugins.
192 Looks for entry points in the "tracekit.plugins" group.
194 Yields:
195 DiscoveredPlugin for each found entry point.
196 """
197 try:
198 # Python 3.10+ has entry_points with group filtering
199 if hasattr(importlib.metadata, "entry_points"): 199 ↛ exitline 199 didn't return from function 'scan_entry_points' because the condition on line 199 was always true
200 eps = importlib.metadata.entry_points()
202 # Handle different API versions
203 if hasattr(eps, "select"):
204 # Python 3.10+
205 plugins_eps = eps.select(group="tracekit.plugins")
206 elif hasattr(eps, "get"): 206 ↛ 211line 206 didn't jump to line 211 because the condition on line 206 was always true
207 # Python 3.9
208 plugins_eps = eps.get("tracekit.plugins", [])
209 else:
210 # Python 3.8 style (dict)
211 plugins_eps = eps.get("tracekit.plugins", []) # type: ignore[attr-defined]
213 for ep in plugins_eps:
214 try:
215 plugin_class = ep.load()
217 if isinstance(plugin_class, type) and issubclass(plugin_class, PluginBase):
218 instance = plugin_class()
219 metadata = instance.metadata
221 compatible = metadata.is_compatible_with(TRACEKIT_API_VERSION)
223 yield DiscoveredPlugin(
224 metadata=metadata,
225 entry_point=ep.name,
226 compatible=compatible,
227 )
228 except Exception as e:
229 # Create placeholder for failed load
230 yield DiscoveredPlugin(
231 metadata=PluginMetadata(
232 name=ep.name,
233 version="0.0.0",
234 description="Failed to load",
235 ),
236 entry_point=ep.name,
237 compatible=False,
238 load_error=str(e),
239 )
241 except Exception:
242 # Entry points not available or error
243 pass
246def _load_plugin_from_yaml(yaml_path: Path) -> DiscoveredPlugin | None:
247 """Load plugin metadata from YAML file.
249 Args:
250 yaml_path: Path to plugin.yaml file.
252 Returns:
253 DiscoveredPlugin or None if load fails.
254 """
255 if not YAML_AVAILABLE:
256 return None
258 try:
259 with open(yaml_path, encoding="utf-8") as f:
260 data = yaml.safe_load(f)
262 if not isinstance(data, dict):
263 return None
265 # Extract metadata
266 metadata = PluginMetadata(
267 name=data.get("name", yaml_path.parent.name),
268 version=data.get("version", "0.0.0"),
269 api_version=data.get("api_version", "1.0.0"),
270 author=data.get("author", ""),
271 description=data.get("description", ""),
272 homepage=data.get("homepage", ""),
273 license=data.get("license", ""),
274 path=yaml_path.parent,
275 enabled=data.get("enabled", True),
276 )
278 # Parse dependencies
279 if "dependencies" in data:
280 deps = data["dependencies"]
281 if isinstance(deps, list): 281 ↛ 290line 281 didn't jump to line 290 because the condition on line 281 was always true
282 for dep in deps:
283 if isinstance(dep, dict): 283 ↛ 282line 283 didn't jump to line 282 because the condition on line 283 was always true
284 if "plugin" in dep:
285 metadata.dependencies[dep["plugin"]] = dep.get("version", "*")
286 elif "package" in dep: 286 ↛ 282line 286 didn't jump to line 282 because the condition on line 286 was always true
287 metadata.dependencies[dep["package"]] = dep.get("version", "*")
289 # Parse provides
290 if "provides" in data:
291 provides = data["provides"]
292 if isinstance(provides, list): 292 ↛ 300line 292 didn't jump to line 300 because the condition on line 292 was always true
293 for item in provides:
294 if isinstance(item, dict): 294 ↛ 293line 294 didn't jump to line 293 because the condition on line 294 was always true
295 for key, value in item.items():
296 if key not in metadata.provides:
297 metadata.provides[key] = []
298 metadata.provides[key].append(value)
300 compatible = metadata.is_compatible_with(TRACEKIT_API_VERSION)
302 return DiscoveredPlugin(
303 metadata=metadata,
304 path=yaml_path.parent,
305 compatible=compatible,
306 )
308 except Exception as e:
309 return DiscoveredPlugin(
310 metadata=PluginMetadata(
311 name=yaml_path.parent.name,
312 version="0.0.0",
313 path=yaml_path.parent,
314 ),
315 path=yaml_path.parent,
316 compatible=False,
317 load_error=str(e),
318 )
321def _load_plugin_from_module(module_path: Path) -> DiscoveredPlugin | None:
322 """Load plugin from Python module.
324 Args:
325 module_path: Path to Python package directory.
327 Returns:
328 DiscoveredPlugin or None if load fails.
329 """
330 try:
331 # Add parent to path temporarily
332 parent = str(module_path.parent)
333 if parent not in sys.path: 333 ↛ 337line 333 didn't jump to line 337 because the condition on line 333 was always true
334 sys.path.insert(0, parent)
335 added_path = True
336 else:
337 added_path = False
339 try:
340 # Import the module
341 module_name = module_path.name
342 spec = importlib.util.spec_from_file_location(module_name, module_path / "__init__.py")
344 if spec is None or spec.loader is None: 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true
345 return None
347 module = importlib.util.module_from_spec(spec)
348 spec.loader.exec_module(module)
350 # Look for Plugin class
351 plugin_class = None
353 # Check for explicit Plugin class
354 if hasattr(module, "Plugin"):
355 plugin_class = module.Plugin
356 elif hasattr(module, "plugin"): 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true
357 plugin_class = module.plugin
359 # Check for any PluginBase subclass
360 if plugin_class is None:
361 for attr_name in dir(module):
362 attr = getattr(module, attr_name)
363 if ( 363 ↛ 368line 363 didn't jump to line 368 because the condition on line 363 was never true
364 isinstance(attr, type)
365 and issubclass(attr, PluginBase)
366 and attr is not PluginBase
367 ):
368 plugin_class = attr
369 break
371 if plugin_class is None:
372 return None
374 # Create instance and get metadata
375 instance = plugin_class()
376 metadata = instance.metadata
377 metadata.path = module_path
379 compatible = metadata.is_compatible_with(TRACEKIT_API_VERSION)
381 return DiscoveredPlugin(
382 metadata=metadata,
383 path=module_path,
384 compatible=compatible,
385 )
387 finally:
388 if added_path:
389 sys.path.remove(parent)
391 except Exception as e:
392 return DiscoveredPlugin(
393 metadata=PluginMetadata(
394 name=module_path.name,
395 version="0.0.0",
396 path=module_path,
397 ),
398 path=module_path,
399 compatible=False,
400 load_error=str(e),
401 )
404__all__ = [
405 "TRACEKIT_API_VERSION",
406 "DiscoveredPlugin",
407 "discover_plugins",
408 "get_plugin_paths",
409 "scan_directory",
410 "scan_entry_points",
411]