clonebox 1.1.13__py3-none-any.whl → 1.1.15__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.
@@ -0,0 +1,523 @@
1
+ """
2
+ Plugin manager for CloneBox.
3
+ Handles plugin discovery, loading, and lifecycle.
4
+ """
5
+ import importlib
6
+ import importlib.util
7
+ import sys
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Optional, Dict, Any, List, Type, Set
11
+ import threading
12
+ import yaml
13
+
14
+ from clonebox.plugins.base import Plugin, PluginHook, PluginContext, PluginMetadata
15
+
16
+
17
+ @dataclass
18
+ class LoadedPlugin:
19
+ """Information about a loaded plugin."""
20
+ plugin: Plugin
21
+ metadata: PluginMetadata
22
+ config: Dict[str, Any]
23
+ enabled: bool = True
24
+ load_order: int = 0
25
+
26
+
27
+ class PluginManager:
28
+ """
29
+ Manages CloneBox plugins.
30
+
31
+ Plugins can be loaded from:
32
+ - Built-in plugins (clonebox.plugins.builtin.*)
33
+ - User plugins (~/.clonebox.d/plugins/)
34
+ - Project plugins (.clonebox.d/plugins/)
35
+ - Python packages (clonebox_plugin_*)
36
+
37
+ Usage:
38
+ manager = PluginManager()
39
+ manager.discover()
40
+ manager.load_all()
41
+
42
+ # Trigger a hook
43
+ ctx = PluginContext(hook=PluginHook.POST_VM_CREATE, vm_name="my-vm")
44
+ manager.trigger(PluginHook.POST_VM_CREATE, ctx)
45
+ """
46
+
47
+ DEFAULT_PLUGIN_DIRS = [
48
+ Path.home() / ".clonebox.d" / "plugins",
49
+ Path(".clonebox.d") / "plugins",
50
+ ]
51
+
52
+ def __init__(
53
+ self,
54
+ plugin_dirs: Optional[List[Path]] = None,
55
+ config_path: Optional[Path] = None,
56
+ ):
57
+ self._plugins: Dict[str, LoadedPlugin] = {}
58
+ self._hooks: Dict[PluginHook, List[str]] = {hook: [] for hook in PluginHook}
59
+ self._lock = threading.Lock()
60
+ self._load_order = 0
61
+
62
+ # Plugin directories
63
+ self.plugin_dirs = plugin_dirs or self.DEFAULT_PLUGIN_DIRS
64
+
65
+ # Plugin configuration
66
+ self.config_path = config_path or Path.home() / ".clonebox.d" / "plugins.yaml"
67
+ self._plugin_config = self._load_config()
68
+
69
+ def _load_config(self) -> Dict[str, Any]:
70
+ """Load plugin configuration."""
71
+ if self.config_path.exists():
72
+ try:
73
+ with open(self.config_path) as f:
74
+ return yaml.safe_load(f) or {}
75
+ except Exception:
76
+ return {}
77
+ return {}
78
+
79
+ def _save_config(self) -> None:
80
+ """Save plugin configuration."""
81
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
82
+ with open(self.config_path, "w") as f:
83
+ yaml.dump(self._plugin_config, f, default_flow_style=False)
84
+
85
+ def discover(self) -> List[str]:
86
+ """
87
+ Discover available plugins.
88
+ Returns list of discovered plugin names.
89
+ """
90
+ discovered: List[str] = []
91
+
92
+ # Discover from plugin directories
93
+ for plugin_dir in self.plugin_dirs:
94
+ if plugin_dir.exists() and plugin_dir.is_dir():
95
+ for item in plugin_dir.iterdir():
96
+ if item.is_file() and item.suffix == ".py" and not item.name.startswith("_"):
97
+ name = item.stem
98
+ discovered.append(f"file:{name}")
99
+ elif item.is_dir() and (item / "__init__.py").exists():
100
+ discovered.append(f"file:{item.name}")
101
+
102
+ # Discover installed packages (clonebox_plugin_*)
103
+ try:
104
+ import pkg_resources
105
+ for ep in pkg_resources.iter_entry_points("clonebox.plugins"):
106
+ discovered.append(f"pkg:{ep.name}")
107
+ except ImportError:
108
+ pass
109
+
110
+ return discovered
111
+
112
+ def load(self, plugin_name: str, config: Optional[Dict[str, Any]] = None) -> bool:
113
+ """
114
+ Load a specific plugin.
115
+
116
+ Args:
117
+ plugin_name: Plugin name (can be prefixed with file: or pkg:)
118
+ config: Plugin-specific configuration
119
+
120
+ Returns:
121
+ True if loaded successfully
122
+ """
123
+ with self._lock:
124
+ if plugin_name in self._plugins:
125
+ return True # Already loaded
126
+
127
+ try:
128
+ plugin_class = self._import_plugin(plugin_name)
129
+ if plugin_class is None:
130
+ return False
131
+
132
+ plugin = plugin_class()
133
+ metadata = plugin.metadata
134
+
135
+ # Get config
136
+ plugin_config = config or self._plugin_config.get(metadata.name, {})
137
+
138
+ # Check dependencies
139
+ for dep in metadata.dependencies:
140
+ if dep not in self._plugins:
141
+ # Try to load dependency
142
+ if not self.load(dep):
143
+ raise RuntimeError(f"Missing dependency: {dep}")
144
+
145
+ # Initialize plugin
146
+ plugin.initialize(plugin_config)
147
+
148
+ # Register plugin
149
+ self._load_order += 1
150
+ loaded = LoadedPlugin(
151
+ plugin=plugin,
152
+ metadata=metadata,
153
+ config=plugin_config,
154
+ enabled=True,
155
+ load_order=self._load_order,
156
+ )
157
+ self._plugins[metadata.name] = loaded
158
+
159
+ # Register hooks
160
+ for hook in metadata.hooks:
161
+ self._hooks[hook].append(metadata.name)
162
+
163
+ return True
164
+
165
+ except Exception as e:
166
+ import sys
167
+ print(f"Failed to load plugin {plugin_name}: {e}", file=sys.stderr)
168
+ return False
169
+
170
+ def _import_plugin(self, plugin_name: str) -> Optional[Type[Plugin]]:
171
+ """Import a plugin class."""
172
+ if plugin_name.startswith("file:"):
173
+ return self._import_file_plugin(plugin_name[5:])
174
+ elif plugin_name.startswith("pkg:"):
175
+ return self._import_package_plugin(plugin_name[4:])
176
+ else:
177
+ # Try both
178
+ plugin_class = self._import_package_plugin(plugin_name)
179
+ if plugin_class:
180
+ return plugin_class
181
+ return self._import_file_plugin(plugin_name)
182
+
183
+ def _import_file_plugin(self, name: str) -> Optional[Type[Plugin]]:
184
+ """Import plugin from file."""
185
+ for plugin_dir in self.plugin_dirs:
186
+ # Try as single file
187
+ plugin_file = plugin_dir / f"{name}.py"
188
+ if plugin_file.exists():
189
+ return self._load_module_from_file(name, plugin_file)
190
+
191
+ # Try as package
192
+ plugin_pkg = plugin_dir / name / "__init__.py"
193
+ if plugin_pkg.exists():
194
+ return self._load_module_from_file(name, plugin_pkg)
195
+
196
+ return None
197
+
198
+ def _load_module_from_file(self, name: str, path: Path) -> Optional[Type[Plugin]]:
199
+ """Load a module from file and find Plugin class."""
200
+ try:
201
+ spec = importlib.util.spec_from_file_location(f"clonebox_plugin_{name}", path)
202
+ if spec is None or spec.loader is None:
203
+ return None
204
+
205
+ module = importlib.util.module_from_spec(spec)
206
+ sys.modules[spec.name] = module
207
+ spec.loader.exec_module(module)
208
+
209
+ # Find Plugin subclass
210
+ for attr_name in dir(module):
211
+ attr = getattr(module, attr_name)
212
+ if (
213
+ isinstance(attr, type)
214
+ and issubclass(attr, Plugin)
215
+ and attr is not Plugin
216
+ ):
217
+ return attr
218
+
219
+ return None
220
+
221
+ except Exception:
222
+ return None
223
+
224
+ def _import_package_plugin(self, name: str) -> Optional[Type[Plugin]]:
225
+ """Import plugin from installed package."""
226
+ try:
227
+ import pkg_resources
228
+ for ep in pkg_resources.iter_entry_points("clonebox.plugins"):
229
+ if ep.name == name:
230
+ plugin_class = ep.load()
231
+ if issubclass(plugin_class, Plugin):
232
+ return plugin_class
233
+ except ImportError:
234
+ pass
235
+
236
+ # Try direct import
237
+ try:
238
+ module = importlib.import_module(f"clonebox_plugin_{name}")
239
+ for attr_name in dir(module):
240
+ attr = getattr(module, attr_name)
241
+ if (
242
+ isinstance(attr, type)
243
+ and issubclass(attr, Plugin)
244
+ and attr is not Plugin
245
+ ):
246
+ return attr
247
+ except ImportError:
248
+ pass
249
+
250
+ return None
251
+
252
+ def load_all(self, only_enabled: bool = True) -> int:
253
+ """
254
+ Load all discovered plugins.
255
+
256
+ Args:
257
+ only_enabled: Only load plugins marked as enabled in config
258
+
259
+ Returns:
260
+ Number of plugins loaded
261
+ """
262
+ discovered = self.discover()
263
+ loaded_count = 0
264
+
265
+ # Get enabled plugins from config
266
+ enabled = set(self._plugin_config.get("enabled", []))
267
+ disabled = set(self._plugin_config.get("disabled", []))
268
+
269
+ for plugin_name in discovered:
270
+ # Extract base name
271
+ base_name = plugin_name.split(":", 1)[-1]
272
+
273
+ # Check if enabled/disabled
274
+ if only_enabled:
275
+ if base_name in disabled:
276
+ continue
277
+ if enabled and base_name not in enabled:
278
+ continue
279
+
280
+ if self.load(plugin_name):
281
+ loaded_count += 1
282
+
283
+ return loaded_count
284
+
285
+ def unload(self, plugin_name: str) -> bool:
286
+ """Unload a plugin."""
287
+ with self._lock:
288
+ if plugin_name not in self._plugins:
289
+ return False
290
+
291
+ loaded = self._plugins[plugin_name]
292
+
293
+ # Check for dependents
294
+ for name, other in self._plugins.items():
295
+ if plugin_name in other.metadata.dependencies:
296
+ raise RuntimeError(f"Cannot unload: {name} depends on {plugin_name}")
297
+
298
+ # Shutdown plugin
299
+ try:
300
+ loaded.plugin.shutdown()
301
+ except Exception:
302
+ pass
303
+
304
+ # Remove from hooks
305
+ for hook in loaded.metadata.hooks:
306
+ if plugin_name in self._hooks[hook]:
307
+ self._hooks[hook].remove(plugin_name)
308
+
309
+ # Remove plugin
310
+ del self._plugins[plugin_name]
311
+
312
+ return True
313
+
314
+ def unload_all(self) -> None:
315
+ """Unload all plugins in reverse load order."""
316
+ # Sort by load order descending
317
+ plugins = sorted(
318
+ self._plugins.values(),
319
+ key=lambda p: p.load_order,
320
+ reverse=True,
321
+ )
322
+
323
+ for loaded in plugins:
324
+ try:
325
+ self.unload(loaded.metadata.name)
326
+ except Exception:
327
+ pass
328
+
329
+ def trigger(self, hook: PluginHook, ctx: PluginContext) -> PluginContext:
330
+ """
331
+ Trigger a hook on all registered plugins.
332
+
333
+ Args:
334
+ hook: The hook to trigger
335
+ ctx: Context to pass to plugins
336
+
337
+ Returns:
338
+ The context (possibly modified by plugins)
339
+ """
340
+ ctx.hook = hook
341
+
342
+ for plugin_name in self._hooks.get(hook, []):
343
+ if not ctx.should_continue:
344
+ break
345
+
346
+ loaded = self._plugins.get(plugin_name)
347
+ if loaded and loaded.enabled:
348
+ try:
349
+ loaded.plugin.handle_hook(hook, ctx)
350
+ except Exception as e:
351
+ ctx.add_warning(f"Plugin {plugin_name} error: {e}")
352
+
353
+ return ctx
354
+
355
+ def enable(self, plugin_name: str) -> bool:
356
+ """Enable a plugin."""
357
+ with self._lock:
358
+ if plugin_name in self._plugins:
359
+ self._plugins[plugin_name].enabled = True
360
+
361
+ # Update config
362
+ disabled = set(self._plugin_config.get("disabled", []))
363
+ disabled.discard(plugin_name)
364
+ self._plugin_config["disabled"] = list(disabled)
365
+
366
+ enabled = set(self._plugin_config.get("enabled", []))
367
+ enabled.add(plugin_name)
368
+ self._plugin_config["enabled"] = list(enabled)
369
+
370
+ self._save_config()
371
+ return True
372
+
373
+ def disable(self, plugin_name: str) -> bool:
374
+ """Disable a plugin."""
375
+ with self._lock:
376
+ if plugin_name in self._plugins:
377
+ self._plugins[plugin_name].enabled = False
378
+
379
+ # Update config
380
+ enabled = set(self._plugin_config.get("enabled", []))
381
+ enabled.discard(plugin_name)
382
+ self._plugin_config["enabled"] = list(enabled)
383
+
384
+ disabled = set(self._plugin_config.get("disabled", []))
385
+ disabled.add(plugin_name)
386
+ self._plugin_config["disabled"] = list(disabled)
387
+
388
+ self._save_config()
389
+ return True
390
+
391
+ def install(self, source: str) -> bool:
392
+ """
393
+ Install a plugin from a source.
394
+
395
+ Sources:
396
+ - PyPI package name: "clonebox-plugin-kubernetes"
397
+ - Git URL: "git+https://github.com/user/plugin.git"
398
+ - Local path: "/path/to/plugin"
399
+
400
+ Returns True if installation succeeded.
401
+ """
402
+ import subprocess
403
+
404
+ # Handle local path
405
+ if Path(source).exists():
406
+ target_dir = self.plugin_dirs[0] # User plugins dir
407
+ target_dir.mkdir(parents=True, exist_ok=True)
408
+ source_path = Path(source)
409
+
410
+ if source_path.is_file() and source_path.suffix == ".py":
411
+ # Single file plugin
412
+ import shutil
413
+ shutil.copy(source_path, target_dir / source_path.name)
414
+ return True
415
+ elif source_path.is_dir():
416
+ # Directory plugin
417
+ import shutil
418
+ target = target_dir / source_path.name
419
+ if target.exists():
420
+ shutil.rmtree(target)
421
+ shutil.copytree(source_path, target)
422
+ return True
423
+
424
+ # Handle pip installable (PyPI or git)
425
+ try:
426
+ result = subprocess.run(
427
+ [sys.executable, "-m", "pip", "install", "--user", source],
428
+ capture_output=True,
429
+ text=True,
430
+ )
431
+ return result.returncode == 0
432
+ except Exception:
433
+ return False
434
+
435
+ def uninstall(self, name: str) -> bool:
436
+ """
437
+ Uninstall a plugin.
438
+
439
+ Returns True if uninstallation succeeded.
440
+ """
441
+ import subprocess
442
+
443
+ # Check if it's a local plugin
444
+ for plugin_dir in self.plugin_dirs:
445
+ plugin_path = plugin_dir / f"{name}.py"
446
+ plugin_pkg = plugin_dir / name
447
+
448
+ if plugin_path.exists():
449
+ plugin_path.unlink()
450
+ return True
451
+ if plugin_pkg.exists() and plugin_pkg.is_dir():
452
+ import shutil
453
+ shutil.rmtree(plugin_pkg)
454
+ return True
455
+
456
+ # Try pip uninstall
457
+ try:
458
+ result = subprocess.run(
459
+ [sys.executable, "-m", "pip", "uninstall", "-y", f"clonebox-plugin-{name}"],
460
+ capture_output=True,
461
+ text=True,
462
+ )
463
+ if result.returncode == 0:
464
+ return True
465
+
466
+ # Try with original name
467
+ result = subprocess.run(
468
+ [sys.executable, "-m", "pip", "uninstall", "-y", name],
469
+ capture_output=True,
470
+ text=True,
471
+ )
472
+ return result.returncode == 0
473
+ except Exception:
474
+ return False
475
+
476
+ def list_plugins(self) -> List[Dict[str, Any]]:
477
+ """List all loaded plugins."""
478
+ return [
479
+ {
480
+ **loaded.metadata.to_dict(),
481
+ "enabled": loaded.enabled,
482
+ "load_order": loaded.load_order,
483
+ }
484
+ for loaded in sorted(self._plugins.values(), key=lambda p: p.load_order)
485
+ ]
486
+
487
+ def get_plugin(self, name: str) -> Optional[Plugin]:
488
+ """Get a loaded plugin by name."""
489
+ loaded = self._plugins.get(name)
490
+ return loaded.plugin if loaded else None
491
+
492
+ def has_plugin(self, name: str) -> bool:
493
+ """Check if a plugin is loaded."""
494
+ return name in self._plugins
495
+
496
+
497
+ # Global plugin manager
498
+ _plugin_manager: Optional[PluginManager] = None
499
+
500
+
501
+ def get_plugin_manager() -> PluginManager:
502
+ """Get the global plugin manager."""
503
+ global _plugin_manager
504
+ if _plugin_manager is None:
505
+ _plugin_manager = PluginManager()
506
+ return _plugin_manager
507
+
508
+
509
+ def set_plugin_manager(manager: PluginManager) -> None:
510
+ """Set the global plugin manager (useful for testing)."""
511
+ global _plugin_manager
512
+ _plugin_manager = manager
513
+
514
+
515
+ def trigger_hook(hook: PluginHook, **kwargs: Any) -> PluginContext:
516
+ """
517
+ Convenience function to trigger a hook.
518
+
519
+ Usage:
520
+ ctx = trigger_hook(PluginHook.POST_VM_CREATE, vm_name="my-vm")
521
+ """
522
+ ctx = PluginContext(hook=hook, **kwargs)
523
+ return get_plugin_manager().trigger(hook, ctx)