Coverage for core / platform / extensions.py: 74.1%
158 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Extension System — Platform-wide plugin architecture.
4Generalizes integrations/channels/plugins/plugin_system.py from
5channels-only to platform-wide. Extensions can:
6- Register services in the ServiceRegistry
7- Subscribe to events on the EventBus
8- Read/write PlatformConfig
9- Provide an AppManifest (auto-registered in AppRegistry)
10- Hot-reload without OS restart
12State machine (from plugin_system.py):
13 UNLOADED → LOADED → ENABLED ⇄ DISABLED → UNLOADED
14 ↓ ↓
15 ERROR ERROR
17Usage:
18 class MyExtension(Extension):
19 @property
20 def manifest(self):
21 return AppManifest(id='my_ext', name='My Extension', ...)
23 def on_load(self, registry, config):
24 self._svc = MyService()
25 registry.register('my_service', lambda: self._svc)
27 def on_enable(self):
28 self._svc.start()
30 def on_disable(self):
31 self._svc.stop()
32"""
34import importlib
35import importlib.util
36import logging
37import sys
38import threading
39from abc import ABC, abstractmethod
40from datetime import datetime
41from enum import Enum
42from typing import Any, Dict, List, Optional
44from core.platform.app_manifest import AppManifest
46logger = logging.getLogger('hevolve.platform')
49class ExtensionState(Enum):
50 """Extension lifecycle states."""
51 UNLOADED = 'unloaded'
52 LOADED = 'loaded'
53 ENABLED = 'enabled'
54 DISABLED = 'disabled'
55 ERROR = 'error'
58class Extension(ABC):
59 """Base class for HART OS extensions.
61 Subclass this to create a platform extension. Must provide
62 a manifest and lifecycle hooks.
63 """
65 def __init__(self):
66 self._state = ExtensionState.UNLOADED
67 self._loaded_at: Optional[datetime] = None
68 self._error: Optional[str] = None
70 @property
71 @abstractmethod
72 def manifest(self) -> AppManifest:
73 """Return the extension's AppManifest."""
74 ...
76 @property
77 def state(self) -> ExtensionState:
78 return self._state
80 @property
81 def error(self) -> Optional[str]:
82 return self._error
84 def on_load(self, registry: Any, config: Any) -> None:
85 """Called when extension is loaded. Register services here.
87 Args:
88 registry: The ServiceRegistry — register services here.
89 config: PlatformConfig access — read/write settings.
90 """
91 pass
93 def on_enable(self) -> None:
94 """Called when extension is enabled (activated)."""
95 pass
97 def on_disable(self) -> None:
98 """Called when extension is disabled (deactivated)."""
99 pass
101 def on_unload(self) -> None:
102 """Called when extension is unloaded (removed from memory)."""
103 pass
106class ExtensionRegistry:
107 """Manages the lifecycle of platform extensions.
109 Generalizes PluginManager from plugin_system.py to work at
110 the platform level with ServiceRegistry and EventBus integration.
111 """
113 def __init__(self, service_registry: Any = None,
114 platform_config: Any = None,
115 event_emitter: Any = None):
116 """Initialize the extension registry.
118 Args:
119 service_registry: ServiceRegistry for extensions to register services.
120 platform_config: PlatformConfig for extensions to read/write settings.
121 event_emitter: Optional callable(topic, data) for event bus integration.
122 """
123 self._extensions: Dict[str, Extension] = {}
124 self._modules: Dict[str, str] = {} # ext_id -> module_path
125 self._registry = service_registry
126 self._config = platform_config
127 self._emit = event_emitter
128 self._lock = threading.Lock()
130 def load(self, module_path: str) -> Extension:
131 """Load an extension from a Python module path.
133 The module must contain a class that subclasses Extension.
134 The first Extension subclass found is instantiated.
136 Args:
137 module_path: Dotted module path (e.g., 'extensions.my_ext').
139 Returns:
140 The loaded Extension instance.
142 Raises:
143 ImportError: If module not found.
144 TypeError: If no Extension subclass found.
145 ValueError: If extension ID already loaded.
146 """
147 # Sandbox analysis before import — block dangerous patterns
148 from core.platform.extension_sandbox import ExtensionSandbox
149 spec = importlib.util.find_spec(module_path)
150 if spec and spec.origin and spec.origin.endswith('.py'):
151 safe, violations = ExtensionSandbox.analyze_file(spec.origin)
152 if not safe:
153 # Emit security event + audit log (non-blocking)
154 try:
155 from core.platform.events import emit_event
156 emit_event('security.extension_blocked', {
157 'module': module_path,
158 'violations': violations,
159 })
160 except Exception:
161 pass
162 try:
163 from security.immutable_audit_log import get_audit_log
164 get_audit_log().log_event(
165 'security', 'extension_sandbox',
166 f"Blocked extension '{module_path}'",
167 detail={'violations': violations})
168 except Exception:
169 pass
170 raise ImportError(
171 f"Extension '{module_path}' blocked by sandbox: "
172 f"{'; '.join(violations)}")
174 try:
175 module = importlib.import_module(module_path)
176 except ImportError as e:
177 raise ImportError(f"Cannot load extension module '{module_path}': {e}")
179 # Find the Extension subclass
180 ext_class = None
181 for attr_name in dir(module):
182 attr = getattr(module, attr_name)
183 if (isinstance(attr, type) and issubclass(attr, Extension)
184 and attr is not Extension):
185 ext_class = attr
186 break
188 if ext_class is None:
189 raise TypeError(
190 f"No Extension subclass found in '{module_path}'")
192 ext = ext_class()
193 ext_id = ext.manifest.id
195 with self._lock:
196 if ext_id in self._extensions:
197 raise ValueError(f"Extension '{ext_id}' already loaded")
199 # Call on_load
200 try:
201 ext.on_load(self._registry, self._config)
202 ext._state = ExtensionState.LOADED
203 ext._loaded_at = datetime.now()
204 except Exception as e:
205 ext._state = ExtensionState.ERROR
206 ext._error = str(e)
207 logger.error("Extension '%s' on_load failed: %s", ext_id, e)
208 raise
210 with self._lock:
211 self._extensions[ext_id] = ext
212 self._modules[ext_id] = module_path
214 if self._emit:
215 self._emit('extension.loaded', {
216 'ext_id': ext_id, 'name': ext.manifest.name})
218 return ext
220 def load_from_directory(self, path: str) -> List[Extension]:
221 """Scan a directory for extension modules and load them.
223 Looks for Python files with Extension subclasses. Each file
224 is treated as a potential extension module.
226 Args:
227 path: Directory path to scan.
229 Returns:
230 List of loaded Extension instances.
231 """
232 import os
234 loaded = []
235 if not os.path.isdir(path):
236 logger.debug("Extensions directory not found: %s", path)
237 return loaded
239 for fname in sorted(os.listdir(path)):
240 if not fname.endswith('.py') or fname.startswith('_'):
241 continue
243 module_name = fname[:-3] # strip .py
244 # Build module path relative to working directory
245 rel_path = os.path.relpath(path).replace(os.sep, '.')
246 module_path = f"{rel_path}.{module_name}"
248 try:
249 ext = self.load(module_path)
250 loaded.append(ext)
251 except Exception as e:
252 logger.warning("Failed to load extension from %s: %s",
253 fname, e)
255 return loaded
257 def enable(self, ext_id: str) -> None:
258 """Enable a loaded extension.
260 Args:
261 ext_id: Extension ID.
263 Raises:
264 KeyError: If not loaded.
265 RuntimeError: If not in LOADED or DISABLED state.
266 """
267 ext = self._get_extension(ext_id)
268 if ext.state not in (ExtensionState.LOADED, ExtensionState.DISABLED):
269 raise RuntimeError(
270 f"Cannot enable '{ext_id}': state is {ext.state.value}")
272 try:
273 ext.on_enable()
274 ext._state = ExtensionState.ENABLED
275 ext._error = None
276 except Exception as e:
277 ext._state = ExtensionState.ERROR
278 ext._error = str(e)
279 logger.error("Extension '%s' on_enable failed: %s", ext_id, e)
280 raise
282 if self._emit:
283 self._emit('extension.enabled', {'ext_id': ext_id})
285 def disable(self, ext_id: str) -> None:
286 """Disable an enabled extension.
288 Args:
289 ext_id: Extension ID.
291 Raises:
292 KeyError: If not loaded.
293 RuntimeError: If not in ENABLED state.
294 """
295 ext = self._get_extension(ext_id)
296 if ext.state != ExtensionState.ENABLED:
297 raise RuntimeError(
298 f"Cannot disable '{ext_id}': state is {ext.state.value}")
300 try:
301 ext.on_disable()
302 ext._state = ExtensionState.DISABLED
303 except Exception as e:
304 ext._state = ExtensionState.ERROR
305 ext._error = str(e)
306 logger.error("Extension '%s' on_disable failed: %s", ext_id, e)
308 if self._emit:
309 self._emit('extension.disabled', {'ext_id': ext_id})
311 def unload(self, ext_id: str) -> None:
312 """Unload an extension completely.
314 Disables first if enabled, then calls on_unload().
316 Args:
317 ext_id: Extension ID.
319 Raises:
320 KeyError: If not loaded.
321 """
322 ext = self._get_extension(ext_id)
324 if ext.state == ExtensionState.ENABLED:
325 try:
326 ext.on_disable()
327 except Exception as e:
328 logger.warning("Extension '%s' on_disable error during unload: %s",
329 ext_id, e)
331 try:
332 ext.on_unload()
333 except Exception as e:
334 logger.warning("Extension '%s' on_unload error: %s", ext_id, e)
336 ext._state = ExtensionState.UNLOADED
338 with self._lock:
339 del self._extensions[ext_id]
340 self._modules.pop(ext_id, None)
342 if self._emit:
343 self._emit('extension.unloaded', {'ext_id': ext_id})
345 def reload(self, ext_id: str) -> Extension:
346 """Hot-reload an extension: unload, re-import, load.
348 Args:
349 ext_id: Extension ID.
351 Returns:
352 New Extension instance.
354 Raises:
355 KeyError: If not loaded or module path unknown.
356 """
357 with self._lock:
358 module_path = self._modules.get(ext_id)
359 if not module_path:
360 raise KeyError(f"No module path for '{ext_id}'")
362 self.unload(ext_id)
364 # Force re-import
365 if module_path in sys.modules:
366 del sys.modules[module_path]
368 return self.load(module_path)
370 def list_extensions(self) -> List[dict]:
371 """Return summary of all loaded extensions."""
372 result = []
373 for ext_id, ext in self._extensions.items():
374 result.append({
375 'id': ext_id,
376 'name': ext.manifest.name,
377 'version': ext.manifest.version,
378 'state': ext.state.value,
379 'error': ext.error,
380 'loaded_at': ext._loaded_at.isoformat() if ext._loaded_at else None,
381 })
382 return result
384 def get(self, ext_id: str) -> Optional[Extension]:
385 """Get an extension by ID."""
386 return self._extensions.get(ext_id)
388 def count(self) -> int:
389 """Return number of loaded extensions."""
390 return len(self._extensions)
392 def _get_extension(self, ext_id: str) -> Extension:
393 """Get extension or raise KeyError."""
394 if ext_id not in self._extensions:
395 raise KeyError(f"Extension '{ext_id}' not loaded")
396 return self._extensions[ext_id]
398 # ── Lifecycle (for ServiceRegistry) ───────────────────────
400 def health(self) -> dict:
401 """Health report."""
402 state_counts = {}
403 for ext in self._extensions.values():
404 s = ext.state.value
405 state_counts[s] = state_counts.get(s, 0) + 1
406 return {
407 'status': 'ok',
408 'total': len(self._extensions),
409 'states': state_counts,
410 }