Coverage for src / tracekit / plugins / registry.py: 95%

115 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 23:04 +0000

1"""Plugin registry and management. 

2 

3This module provides the central plugin registry for loading, 

4registering, and accessing plugins. 

5 

6 

7Example: 

8 >>> from tracekit.plugins.registry import register_plugin, get_plugin 

9 >>> register_plugin(MyDecoder) 

10 >>> decoder = get_plugin("my_decoder") 

11""" 

12 

13from __future__ import annotations 

14 

15import logging 

16from typing import TYPE_CHECKING, Any 

17 

18from tracekit.plugins.discovery import ( 

19 TRACEKIT_API_VERSION, 

20 DiscoveredPlugin, 

21 discover_plugins, 

22) 

23 

24if TYPE_CHECKING: 

25 from tracekit.plugins.base import PluginBase, PluginCapability, PluginMetadata 

26 

27logger = logging.getLogger(__name__) 

28 

29 

30class PluginConflictError(Exception): 

31 """Plugin registration conflict. 

32 

33 Raised when registering a plugin with a name that already exists. 

34 

35 Attributes: 

36 existing: Metadata of existing plugin. 

37 new: Metadata of new plugin. 

38 """ 

39 

40 def __init__( 

41 self, 

42 message: str, 

43 existing: PluginMetadata, 

44 new: PluginMetadata, 

45 ) -> None: 

46 super().__init__(message) 

47 self.existing = existing 

48 self.new = new 

49 

50 

51class PluginVersionError(Exception): 

52 """Plugin version incompatibility. 

53 

54 Raised when a plugin is not compatible with the current API. 

55 

56 Attributes: 

57 plugin_api_version: Plugin's required API version. 

58 tracekit_api_version: Current TraceKit API version. 

59 """ 

60 

61 def __init__( 

62 self, 

63 message: str, 

64 plugin_api_version: str, 

65 tracekit_api_version: str, 

66 ) -> None: 

67 super().__init__(message) 

68 self.plugin_api_version = plugin_api_version 

69 self.tracekit_api_version = tracekit_api_version 

70 

71 

72class PluginDependencyError(Exception): 

73 """Plugin dependency not satisfied. 

74 

75 Attributes: 

76 plugin: Plugin name that has unmet dependency. 

77 dependency: Missing dependency name. 

78 required_version: Required dependency version. 

79 """ 

80 

81 def __init__( 

82 self, 

83 message: str, 

84 plugin: str, 

85 dependency: str, 

86 required_version: str, 

87 ) -> None: 

88 super().__init__(message) 

89 self.plugin = plugin 

90 self.dependency = dependency 

91 self.required_version = required_version 

92 

93 

94class PluginRegistry: 

95 """Central registry for plugins. 

96 

97 Manages plugin registration, loading, and lookup. 

98 

99 Example: 

100 >>> registry = PluginRegistry() 

101 >>> registry.register(MyDecoder) 

102 >>> plugin = registry.get("my_decoder") 

103 """ 

104 

105 def __init__(self) -> None: 

106 """Initialize empty registry.""" 

107 self._plugins: dict[str, PluginBase] = {} 

108 self._metadata: dict[str, PluginMetadata] = {} 

109 self._by_capability: dict[PluginCapability, list[str]] = {} 

110 self._discovered: list[DiscoveredPlugin] = [] 

111 

112 def register( 

113 self, 

114 plugin: type[PluginBase] | PluginBase, 

115 *, 

116 check_compatibility: bool = True, 

117 check_conflicts: bool = True, 

118 config: dict[str, Any] | None = None, 

119 ) -> None: 

120 """Register a plugin with the registry. 

121 

122 Args: 

123 plugin: Plugin class or instance to register. 

124 check_compatibility: Verify API compatibility. 

125 check_conflicts: Check for duplicate names. 

126 config: Optional plugin configuration. 

127 

128 Raises: 

129 PluginConflictError: If plugin name already registered. 

130 PluginVersionError: If plugin is not compatible. 

131 """ 

132 # Get or create instance 

133 instance = plugin() if isinstance(plugin, type) else plugin 

134 

135 metadata = instance.metadata 

136 

137 # Check compatibility 

138 if check_compatibility and not metadata.is_compatible_with(TRACEKIT_API_VERSION): 

139 raise PluginVersionError( 

140 f"Plugin '{metadata.name}' requires API v{metadata.api_version}, " 

141 f"but TraceKit API is v{TRACEKIT_API_VERSION}", 

142 plugin_api_version=metadata.api_version, 

143 tracekit_api_version=TRACEKIT_API_VERSION, 

144 ) 

145 

146 # Check conflicts (PLUG-002: conflict detection for duplicate plugins) 

147 if check_conflicts and metadata.name in self._plugins: 

148 existing = self._metadata[metadata.name] 

149 

150 # Provide detailed conflict information 

151 conflict_msg = ( 

152 f"Plugin '{metadata.name}' already registered:\n" 

153 f" Existing: v{existing.version} at {existing.path}\n" 

154 f" New: v{metadata.version}" 

155 ) 

156 

157 # Check if same version 

158 if existing.version == metadata.version: 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true

159 conflict_msg += " (same version)" 

160 

161 raise PluginConflictError( 

162 conflict_msg, 

163 existing=existing, 

164 new=metadata, 

165 ) 

166 

167 # Register 

168 self._plugins[metadata.name] = instance 

169 self._metadata[metadata.name] = metadata 

170 

171 # Index by capability 

172 for cap in metadata.capabilities: 

173 if cap not in self._by_capability: 

174 self._by_capability[cap] = [] 

175 self._by_capability[cap].append(metadata.name) 

176 

177 # Configure and load 

178 if config: 

179 instance.on_configure(config) 

180 

181 instance.on_load() 

182 

183 logger.info(f"Registered plugin: {metadata.name} v{metadata.version}") 

184 

185 def unregister(self, name: str) -> None: 

186 """Unregister a plugin. 

187 

188 Args: 

189 name: Plugin name to unregister. 

190 """ 

191 if name not in self._plugins: 

192 return 

193 

194 instance = self._plugins[name] 

195 metadata = self._metadata[name] 

196 

197 # Call unload hook 

198 instance.on_unload() 

199 

200 # Remove from capability index 

201 for cap in metadata.capabilities: 

202 if cap in self._by_capability and name in self._by_capability[cap]: 202 ↛ 201line 202 didn't jump to line 201 because the condition on line 202 was always true

203 self._by_capability[cap].remove(name) 

204 

205 # Remove from registry 

206 del self._plugins[name] 

207 del self._metadata[name] 

208 

209 logger.info(f"Unregistered plugin: {name}") 

210 

211 def get(self, name: str) -> PluginBase | None: 

212 """Get plugin by name. 

213 

214 Args: 

215 name: Plugin name. 

216 

217 Returns: 

218 Plugin instance or None. 

219 """ 

220 return self._plugins.get(name) 

221 

222 def get_metadata(self, name: str) -> PluginMetadata | None: 

223 """Get plugin metadata by name. 

224 

225 Args: 

226 name: Plugin name. 

227 

228 Returns: 

229 Plugin metadata or None. 

230 """ 

231 return self._metadata.get(name) 

232 

233 def list_plugins( 

234 self, 

235 *, 

236 capability: PluginCapability | None = None, 

237 ) -> list[PluginMetadata]: 

238 """List registered plugins. 

239 

240 Args: 

241 capability: Filter by capability. 

242 

243 Returns: 

244 List of plugin metadata. 

245 """ 

246 if capability is not None: 

247 names = self._by_capability.get(capability, []) 

248 return [self._metadata[name] for name in names] 

249 

250 return list(self._metadata.values()) 

251 

252 def has_plugin(self, name: str) -> bool: 

253 """Check if plugin is registered. 

254 

255 Args: 

256 name: Plugin name. 

257 

258 Returns: 

259 True if registered. 

260 """ 

261 return name in self._plugins 

262 

263 def is_compatible(self, name: str) -> bool: 

264 """Check if plugin is compatible with current API. 

265 

266 Args: 

267 name: Plugin name. 

268 

269 Returns: 

270 True if compatible. 

271 """ 

272 metadata = self._metadata.get(name) 

273 if metadata is None: 

274 return False 

275 return metadata.is_compatible_with(TRACEKIT_API_VERSION) 

276 

277 def discover_and_load( 

278 self, 

279 *, 

280 compatible_only: bool = True, 

281 config: dict[str, dict[str, Any]] | None = None, 

282 ) -> list[PluginMetadata]: 

283 """Discover and load all available plugins. 

284 

285 Args: 

286 compatible_only: Only load compatible plugins. 

287 config: Configuration dict keyed by plugin name. 

288 

289 Returns: 

290 List of loaded plugin metadata. 

291 """ 

292 self._discovered = discover_plugins(compatible_only=compatible_only) 

293 loaded: list[PluginMetadata] = [] 

294 

295 for discovered in self._discovered: 

296 if discovered.load_error: 

297 logger.warning( 

298 f"Skipping plugin {discovered.metadata.name}: {discovered.load_error}" 

299 ) 

300 continue 

301 

302 if not discovered.compatible and compatible_only: 

303 logger.debug(f"Skipping incompatible plugin: {discovered.metadata.name}") 

304 continue 

305 

306 try: 

307 if config and discovered.metadata.name in config: 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true

308 config[discovered.metadata.name] 

309 

310 # For now, just store the metadata 

311 # Full loading requires importing the plugin module 

312 self._metadata[discovered.metadata.name] = discovered.metadata 

313 loaded.append(discovered.metadata) 

314 

315 except Exception as e: 

316 logger.error(f"Failed to load plugin {discovered.metadata.name}: {e}") 

317 

318 return loaded 

319 

320 def get_providers(self, item_type: str, item_name: str) -> list[str]: 

321 """Find plugins that provide a specific capability. 

322 

323 Args: 

324 item_type: Type of item (e.g., "protocols", "algorithms"). 

325 item_name: Name of item. 

326 

327 Returns: 

328 List of plugin names that provide the item. 

329 """ 

330 providers: list[str] = [] 

331 

332 for name, metadata in self._metadata.items(): 

333 if item_type in metadata.provides: 333 ↛ 332line 333 didn't jump to line 332 because the condition on line 333 was always true

334 if item_name in metadata.provides[item_type]: 

335 providers.append(name) 

336 

337 return providers 

338 

339 

340# Global registry instance 

341_global_registry: PluginRegistry | None = None 

342 

343 

344def get_plugin_registry() -> PluginRegistry: 

345 """Get the global plugin registry. 

346 

347 Returns: 

348 Global PluginRegistry instance. 

349 """ 

350 global _global_registry 

351 

352 if _global_registry is None: 

353 _global_registry = PluginRegistry() 

354 

355 return _global_registry 

356 

357 

358def register_plugin( 

359 plugin: type[PluginBase] | PluginBase, 

360 *, 

361 config: dict[str, Any] | None = None, 

362) -> None: 

363 """Register plugin with global registry. 

364 

365 Args: 

366 plugin: Plugin class or instance. 

367 config: Plugin configuration. 

368 """ 

369 get_plugin_registry().register(plugin, config=config) 

370 

371 

372def get_plugin(name: str) -> PluginBase | None: 

373 """Get plugin from global registry. 

374 

375 Args: 

376 name: Plugin name. 

377 

378 Returns: 

379 Plugin instance or None. 

380 """ 

381 return get_plugin_registry().get(name) 

382 

383 

384def list_plugins( 

385 *, 

386 capability: PluginCapability | None = None, 

387) -> list[PluginMetadata]: 

388 """List plugins from global registry. 

389 

390 Args: 

391 capability: Filter by capability. 

392 

393 Returns: 

394 List of plugin metadata. 

395 """ 

396 return get_plugin_registry().list_plugins(capability=capability) 

397 

398 

399def is_compatible(name: str) -> bool: 

400 """Check plugin compatibility. 

401 

402 Args: 

403 name: Plugin name. 

404 

405 Returns: 

406 True if compatible. 

407 """ 

408 return get_plugin_registry().is_compatible(name) 

409 

410 

411__all__ = [ 

412 "PluginConflictError", 

413 "PluginDependencyError", 

414 "PluginRegistry", 

415 "PluginVersionError", 

416 "get_plugin", 

417 "get_plugin_registry", 

418 "is_compatible", 

419 "list_plugins", 

420 "register_plugin", 

421]