clonebox 1.1.13__py3-none-any.whl → 1.1.14__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,438 @@
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 list_plugins(self) -> List[Dict[str, Any]]:
392
+ """List all loaded plugins."""
393
+ return [
394
+ {
395
+ **loaded.metadata.to_dict(),
396
+ "enabled": loaded.enabled,
397
+ "load_order": loaded.load_order,
398
+ }
399
+ for loaded in sorted(self._plugins.values(), key=lambda p: p.load_order)
400
+ ]
401
+
402
+ def get_plugin(self, name: str) -> Optional[Plugin]:
403
+ """Get a loaded plugin by name."""
404
+ loaded = self._plugins.get(name)
405
+ return loaded.plugin if loaded else None
406
+
407
+ def has_plugin(self, name: str) -> bool:
408
+ """Check if a plugin is loaded."""
409
+ return name in self._plugins
410
+
411
+
412
+ # Global plugin manager
413
+ _plugin_manager: Optional[PluginManager] = None
414
+
415
+
416
+ def get_plugin_manager() -> PluginManager:
417
+ """Get the global plugin manager."""
418
+ global _plugin_manager
419
+ if _plugin_manager is None:
420
+ _plugin_manager = PluginManager()
421
+ return _plugin_manager
422
+
423
+
424
+ def set_plugin_manager(manager: PluginManager) -> None:
425
+ """Set the global plugin manager (useful for testing)."""
426
+ global _plugin_manager
427
+ _plugin_manager = manager
428
+
429
+
430
+ def trigger_hook(hook: PluginHook, **kwargs: Any) -> PluginContext:
431
+ """
432
+ Convenience function to trigger a hook.
433
+
434
+ Usage:
435
+ ctx = trigger_hook(PluginHook.POST_VM_CREATE, vm_name="my-vm")
436
+ """
437
+ ctx = PluginContext(hook=hook, **kwargs)
438
+ return get_plugin_manager().trigger(hook, ctx)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 1.1.13
3
+ Version: 1.1.14
4
4
  Summary: Clone your workstation environment to an isolated VM with selective apps, paths and services
5
5
  Author: CloneBox Team
6
6
  License: Apache-2.0
@@ -1,7 +1,8 @@
1
1
  clonebox/__init__.py,sha256=CyfHVVq6KqBr4CNERBpXk_O6Q5B35q03YpdQbokVvvI,408
2
2
  clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
3
- clonebox/cli.py,sha256=CcqJgVH-hSGm4FXTuJCWniocKnAeQayhPPO17nxK578,139009
4
- clonebox/cloner.py,sha256=3ZL_F-GTMT38cJ0-mgz-ESG7Ioa-1tEYPjdoSBS4lP0,96633
3
+ clonebox/audit.py,sha256=67V2ap4QYVXH41b2GmNeRw0-0BilPPTgbDJ0eA5xv2A,14119
4
+ clonebox/cli.py,sha256=7QCjprKNRguPVHhxXzd2ej-t61W8kOcxlSMSNxYoXag,153643
5
+ clonebox/cloner.py,sha256=Hf4ugJ0lZ52etrnY2RoopwbMaQsPctZp5d1i3_qQKsU,97470
5
6
  clonebox/container.py,sha256=tiYK1ZB-DhdD6A2FuMA0h_sRNkUI7KfYcJ0tFOcdyeM,6105
6
7
  clonebox/dashboard.py,sha256=dMY6odvPq3j6FronhRRsX7aY3qdCwznB-aCWKEmHDNw,5768
7
8
  clonebox/detector.py,sha256=vS65cvFNPmUBCX1Y_TMTnSRljw6r1Ae9dlVtACs5XFc,23075
@@ -11,6 +12,7 @@ clonebox/importer.py,sha256=Q9Uk1IOA41mgGhU4ynW2k-h9GEoGxRKI3c9wWE4uxcA,7097
11
12
  clonebox/logging.py,sha256=jD_WgkHmt_h99EmX82QWK7D0OKWyxqcLwgT4UDQFp2g,3675
12
13
  clonebox/models.py,sha256=13B0lVAuaGnpY4h4iYUPigIvfxYx2pWaAEM1cYF_Hbo,8263
13
14
  clonebox/monitor.py,sha256=zlIarNf8w_i34XI8hZGxxrg5PVZK_Yxm6FQnkhLavRI,9181
15
+ clonebox/orchestrator.py,sha256=LvoDZIX47g2pdvMYfCW3NJ5sAiRYKKa45KGlujnJZuI,19871
14
16
  clonebox/p2p.py,sha256=6o0JnscKqF9-BftQhW5fF1W6YY1wXshY9LEklNcHGJc,5913
15
17
  clonebox/profiles.py,sha256=UP37fX_rhrG_O9ehNFJBUcULPmUtN1A8KsJ6cM44oK0,1986
16
18
  clonebox/resource_monitor.py,sha256=lDR9KyPbVtImeeOkOBPPVP-5yCgoL5hsVFPZ_UqsY0w,5286
@@ -29,14 +31,17 @@ clonebox/interfaces/disk.py,sha256=F7Xzj2dq5UTZ2KGCuThDM8bwTps6chFbquOUmfLREjI,9
29
31
  clonebox/interfaces/hypervisor.py,sha256=8ms4kZLA-5Ba1e_n68mCucwP_K9mufbmTBlo7XzURn4,1991
30
32
  clonebox/interfaces/network.py,sha256=YPIquxEB7sZHczbpuopcZpffTjWYI6cKmAu3wAEFllk,853
31
33
  clonebox/interfaces/process.py,sha256=njvAIZw_TCjw01KpyVQKIDoRvhTwl0FfVGbQ6mxTROk,1024
34
+ clonebox/plugins/__init__.py,sha256=3cxlz159nokZCOL2c017WqTwt5z00yyn-o-SemP1g6c,416
35
+ clonebox/plugins/base.py,sha256=A2H-2vrYUczNZCDioQ8cAtvaSob4YpXutx7FWMjksC4,10133
36
+ clonebox/plugins/manager.py,sha256=gotjHloAiiADLJjATOvdBxrasU3Rc8h2fW0MwVGIhCI,14105
32
37
  clonebox/snapshots/__init__.py,sha256=ndlrIavPAiA8z4Ep3-D_EPhOcjNKYFnP3rIpEKaGdb8,273
33
38
  clonebox/snapshots/manager.py,sha256=hGzM8V6ZJPXjTqj47c4Kr8idlE-c1Q3gPUvuw1HvS1A,11393
34
39
  clonebox/snapshots/models.py,sha256=sRnn3OZE8JG9FZJlRuA3ihO-JXoPCQ3nD3SQytflAao,6206
35
40
  clonebox/templates/profiles/ml-dev.yaml,sha256=w07MToGh31xtxpjbeXTBk9BkpAN8A3gv8HeA3ESKG9M,461
36
41
  clonebox/templates/profiles/web-stack.yaml,sha256=EBnnGMzML5vAjXmIUbCpbTCwmRaNJiuWd3EcL43DOK8,485
37
- clonebox-1.1.13.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
38
- clonebox-1.1.13.dist-info/METADATA,sha256=-lAcuKp05h-8MuuwAq5aj0FPwGwOJfntQfQ_NZrcSts,48916
39
- clonebox-1.1.13.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
40
- clonebox-1.1.13.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
41
- clonebox-1.1.13.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
42
- clonebox-1.1.13.dist-info/RECORD,,
42
+ clonebox-1.1.14.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
43
+ clonebox-1.1.14.dist-info/METADATA,sha256=MCTVRt4sr9hDpl7N0x_DwR0067cNXqL-Ti1huPFVU60,48916
44
+ clonebox-1.1.14.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
45
+ clonebox-1.1.14.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
46
+ clonebox-1.1.14.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
47
+ clonebox-1.1.14.dist-info/RECORD,,