Coverage for src / tracekit / extensibility / plugins.py: 28%
95 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 architecture for third-party extensions.
3This module implements entry point discovery for third-party plugins,
4allowing custom decoders, measurements, and file formats to be loaded
5dynamically.
6"""
8from __future__ import annotations
10import importlib.metadata
11import logging
12from dataclasses import dataclass
13from typing import Any
15logger = logging.getLogger(__name__)
18class PluginError(Exception):
19 """Exception raised when plugin loading fails.
21 This exception is used to isolate plugin failures so they don't
22 crash the main application.
24 Example:
25 >>> try:
26 ... plugin = tk.load_plugin('tracekit.decoders', 'flexray')
27 ... except tk.PluginError as e:
28 ... print(f"Plugin failed: {e}")
30 References:
31 API-007: Plugin Architecture
32 """
34 pass
37@dataclass
38class PluginMetadata:
39 """Metadata about a loaded plugin.
41 Attributes:
42 name: Plugin name.
43 entry_point: Entry point group.
44 version: Plugin version (if available).
45 module: Module name.
46 callable: The loaded plugin object.
47 dependencies: Plugin dependencies (if available).
49 Example:
50 >>> plugin = load_plugin('tracekit.decoders', 'can')
51 >>> print(f"Loaded {plugin.name} v{plugin.version}")
53 References:
54 API-007: Plugin Architecture
55 """
57 name: str
58 entry_point: str
59 version: str | None = None
60 module: str | None = None
61 callable: Any | None = None
62 dependencies: list[str] | None = None
64 def __repr__(self) -> str:
65 """String representation of plugin metadata.
67 Returns:
68 String representation showing plugin name, version, and module.
69 """
70 parts = [f"name='{self.name}'"]
71 if self.version:
72 parts.append(f"version='{self.version}'")
73 if self.module:
74 parts.append(f"module='{self.module}'")
75 return f"PluginMetadata({', '.join(parts)})"
78class PluginManager:
79 """Manager for discovering and loading third-party plugins.
81 Discovers plugins via setuptools entry points and provides lazy loading
82 with error isolation. Supports multiple entry point groups for different
83 plugin types.
85 Entry point groups:
86 - tracekit.decoders: Protocol decoders
87 - tracekit.measurements: Custom measurements
88 - tracekit.loaders: File format loaders
89 - tracekit.exporters: Export format handlers
91 Example:
92 >>> import tracekit as tk
93 >>> # Plugins auto-discovered from installed packages
94 >>> # Use plugin decoder
95 >>> can_frames = tk.decode(trace, protocol='can', baudrate=500000)
96 >>> # List available plugins
97 >>> plugins = tk.list_plugins()
98 >>> print(f"Installed plugins: {plugins}")
100 Advanced Example:
101 >>> # Manually load plugin with error handling
102 >>> try:
103 ... plugin = tk.load_plugin('tracekit.decoders', 'flexray')
104 ... print(f"Plugin loaded: {plugin.name} v{plugin.version}")
105 ... except tk.PluginError as e:
106 ... print(f"Plugin failed to load: {e}")
108 Plugin Package Example:
109 In your package's pyproject.toml:
110 ```toml
111 [project.entry-points."tracekit.decoders"]
112 flexray = "my_package.flexray:FlexRayDecoder"
113 ```
115 References:
116 API-007: Plugin Architecture
117 importlib.metadata entry points
118 https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/
119 """
121 # Standard entry point groups
122 ENTRY_POINT_GROUPS = [ # noqa: RUF012
123 "tracekit.decoders",
124 "tracekit.measurements",
125 "tracekit.loaders",
126 "tracekit.exporters",
127 ]
129 def __init__(self) -> None:
130 """Initialize plugin manager."""
131 self._loaded_plugins: dict[tuple[str, str], PluginMetadata] = {}
132 self._failed_plugins: dict[tuple[str, str], Exception] = {}
134 def discover_plugins(self, group: str | None = None) -> dict[str, list[str]]:
135 """Discover available plugins via entry points.
137 Args:
138 group: Specific entry point group to search. If None, searches
139 all standard groups.
141 Returns:
142 Dictionary mapping group names to lists of plugin names.
144 Example:
145 >>> manager = PluginManager()
146 >>> plugins = manager.discover_plugins()
147 >>> print(plugins)
148 {'tracekit.decoders': ['uart', 'spi', 'can'], ...}
150 References:
151 importlib.metadata.entry_points
152 """
153 discovered: dict[str, list[str]] = {}
154 groups = [group] if group else self.ENTRY_POINT_GROUPS
156 for group_name in groups:
157 try:
158 # Get entry points for this group
159 # Python 3.10+ API
160 eps = importlib.metadata.entry_points(group=group_name)
161 discovered[group_name] = [ep.name for ep in eps]
162 except (AttributeError, TypeError):
163 # Fallback for Python 3.9 and earlier
164 try:
165 eps = importlib.metadata.entry_points().get(group_name, []) # type: ignore[attr-defined]
166 discovered[group_name] = [ep.name for ep in eps]
167 except Exception as e:
168 logger.warning(f"Failed to discover plugins for group '{group_name}': {e}")
169 discovered[group_name] = []
171 return discovered
173 def load_plugin(
174 self,
175 group: str,
176 name: str,
177 reload: bool = False,
178 ) -> PluginMetadata:
179 """Load a plugin by group and name.
181 Loads the plugin lazily on first use. Subsequent calls return cached
182 instance unless reload=True.
184 Args:
185 group: Entry point group.
186 name: Plugin name.
187 reload: Force reload even if already loaded. Default False.
189 Returns:
190 PluginMetadata with loaded plugin information.
192 Raises:
193 PluginError: If plugin fails to load.
195 Example:
196 >>> manager = PluginManager()
197 >>> plugin = manager.load_plugin('tracekit.decoders', 'can')
198 >>> decoder = plugin.callable
200 References:
201 API-007: Plugin Architecture
202 """
203 plugin_key = (group, name)
205 # Check if already loaded
206 if not reload and plugin_key in self._loaded_plugins:
207 return self._loaded_plugins[plugin_key]
209 # Check if previously failed
210 if not reload and plugin_key in self._failed_plugins:
211 raise PluginError(
212 f"Plugin '{name}' in group '{group}' previously failed to load: "
213 f"{self._failed_plugins[plugin_key]}"
214 )
216 try:
217 # Find entry point
218 entry_point = self._find_entry_point(group, name)
220 if entry_point is None:
221 raise PluginError(f"Plugin '{name}' not found in group '{group}'")
223 # Load the plugin
224 logger.info(f"Loading plugin '{name}' from group '{group}'")
225 plugin_obj = entry_point.load()
227 # Get version if available
228 version = None
229 if hasattr(entry_point, "dist") and entry_point.dist:
230 version = entry_point.dist.version
232 # Create metadata
233 metadata = PluginMetadata(
234 name=name,
235 entry_point=group,
236 version=version,
237 module=entry_point.value,
238 callable=plugin_obj,
239 )
241 # Cache loaded plugin
242 self._loaded_plugins[plugin_key] = metadata
244 logger.info(f"Successfully loaded plugin '{name}' v{version}")
245 return metadata
247 except Exception as e:
248 # Cache failure
249 self._failed_plugins[plugin_key] = e
250 logger.error(f"Failed to load plugin '{name}': {e}")
251 raise PluginError(f"Failed to load plugin '{name}' from group '{group}': {e}") from e
253 def get_plugin(self, group: str, name: str) -> Any:
254 """Get loaded plugin callable.
256 Convenience method that loads plugin if needed and returns the
257 callable object.
259 Args:
260 group: Entry point group.
261 name: Plugin name.
263 Returns:
264 The loaded plugin object.
266 Example:
267 >>> manager = PluginManager()
268 >>> decoder = manager.get_plugin('tracekit.decoders', 'can')
269 >>> frames = decoder.decode(trace)
270 """
271 metadata = self.load_plugin(group, name)
272 return metadata.callable
274 def is_loaded(self, group: str, name: str) -> bool:
275 """Check if plugin is already loaded.
277 Args:
278 group: Entry point group.
279 name: Plugin name.
281 Returns:
282 True if plugin is loaded.
284 Example:
285 >>> if manager.is_loaded('tracekit.decoders', 'can'):
286 ... print("CAN decoder already loaded")
287 """
288 return (group, name) in self._loaded_plugins
290 def list_loaded_plugins(self) -> list[PluginMetadata]:
291 """List all loaded plugins.
293 Returns:
294 List of PluginMetadata for loaded plugins.
296 Example:
297 >>> loaded = manager.list_loaded_plugins()
298 >>> for plugin in loaded:
299 ... print(f"{plugin.name} v{plugin.version}")
300 """
301 return list(self._loaded_plugins.values())
303 def unload_plugin(self, group: str, name: str) -> None:
304 """Unload a plugin from cache.
306 Args:
307 group: Entry point group.
308 name: Plugin name.
310 Example:
311 >>> manager.unload_plugin('tracekit.decoders', 'can')
312 """
313 plugin_key = (group, name)
314 if plugin_key in self._loaded_plugins:
315 del self._loaded_plugins[plugin_key]
316 if plugin_key in self._failed_plugins:
317 del self._failed_plugins[plugin_key]
319 def _find_entry_point(self, group: str, name: str) -> Any | None:
320 """Find entry point by group and name.
322 Args:
323 group: Entry point group.
324 name: Entry point name.
326 Returns:
327 Entry point object or None if not found.
328 """
329 try:
330 # Python 3.10+ API
331 eps = importlib.metadata.entry_points(group=group)
332 for ep in eps:
333 if ep.name == name:
334 return ep
335 except (AttributeError, TypeError):
336 # Fallback for Python 3.9 and earlier
337 try:
338 eps = importlib.metadata.entry_points().get(group, []) # type: ignore[attr-defined]
339 for ep in eps:
340 if ep.name == name:
341 return ep
342 except Exception:
343 pass
345 return None
348# Global plugin manager instance
349_manager = PluginManager()
352def load_plugin(group: str, name: str) -> PluginMetadata:
353 """Load a plugin from the global plugin manager.
355 Convenience function for loading plugins without accessing the manager
356 directly.
358 Args:
359 group: Entry point group.
360 name: Plugin name.
362 Returns:
363 PluginMetadata with loaded plugin.
365 Example:
366 >>> import tracekit as tk
367 >>> plugin = tk.load_plugin('tracekit.decoders', 'flexray')
368 >>> print(f"Loaded {plugin.name} v{plugin.version}")
370 References:
371 API-007: Plugin Architecture
372 """
373 return _manager.load_plugin(group, name)
376def list_plugins(group: str | None = None) -> dict[str, list[str]]:
377 """List available plugins.
379 Args:
380 group: Specific group to list. If None, lists all groups.
382 Returns:
383 Dictionary mapping group names to plugin names.
385 Example:
386 >>> import tracekit as tk
387 >>> plugins = tk.list_plugins()
388 >>> print(f"Available decoders: {plugins['tracekit.decoders']}")
390 References:
391 API-007: Plugin Architecture
392 """
393 return _manager.discover_plugins(group)
396def get_plugin_manager() -> PluginManager:
397 """Get the global plugin manager instance.
399 Returns:
400 Global PluginManager instance.
402 Example:
403 >>> manager = tk.get_plugin_manager()
404 >>> loaded = manager.list_loaded_plugins()
405 """
406 return _manager
409__all__ = [
410 "PluginError",
411 "PluginManager",
412 "PluginMetadata",
413 "get_plugin_manager",
414 "list_plugins",
415 "load_plugin",
416]