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

1""" 

2Extension System — Platform-wide plugin architecture. 

3 

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 

11 

12State machine (from plugin_system.py): 

13 UNLOADED → LOADED → ENABLED ⇄ DISABLED → UNLOADED 

14 ↓ ↓ 

15 ERROR ERROR 

16 

17Usage: 

18 class MyExtension(Extension): 

19 @property 

20 def manifest(self): 

21 return AppManifest(id='my_ext', name='My Extension', ...) 

22 

23 def on_load(self, registry, config): 

24 self._svc = MyService() 

25 registry.register('my_service', lambda: self._svc) 

26 

27 def on_enable(self): 

28 self._svc.start() 

29 

30 def on_disable(self): 

31 self._svc.stop() 

32""" 

33 

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 

43 

44from core.platform.app_manifest import AppManifest 

45 

46logger = logging.getLogger('hevolve.platform') 

47 

48 

49class ExtensionState(Enum): 

50 """Extension lifecycle states.""" 

51 UNLOADED = 'unloaded' 

52 LOADED = 'loaded' 

53 ENABLED = 'enabled' 

54 DISABLED = 'disabled' 

55 ERROR = 'error' 

56 

57 

58class Extension(ABC): 

59 """Base class for HART OS extensions. 

60 

61 Subclass this to create a platform extension. Must provide 

62 a manifest and lifecycle hooks. 

63 """ 

64 

65 def __init__(self): 

66 self._state = ExtensionState.UNLOADED 

67 self._loaded_at: Optional[datetime] = None 

68 self._error: Optional[str] = None 

69 

70 @property 

71 @abstractmethod 

72 def manifest(self) -> AppManifest: 

73 """Return the extension's AppManifest.""" 

74 ... 

75 

76 @property 

77 def state(self) -> ExtensionState: 

78 return self._state 

79 

80 @property 

81 def error(self) -> Optional[str]: 

82 return self._error 

83 

84 def on_load(self, registry: Any, config: Any) -> None: 

85 """Called when extension is loaded. Register services here. 

86 

87 Args: 

88 registry: The ServiceRegistry — register services here. 

89 config: PlatformConfig access — read/write settings. 

90 """ 

91 pass 

92 

93 def on_enable(self) -> None: 

94 """Called when extension is enabled (activated).""" 

95 pass 

96 

97 def on_disable(self) -> None: 

98 """Called when extension is disabled (deactivated).""" 

99 pass 

100 

101 def on_unload(self) -> None: 

102 """Called when extension is unloaded (removed from memory).""" 

103 pass 

104 

105 

106class ExtensionRegistry: 

107 """Manages the lifecycle of platform extensions. 

108 

109 Generalizes PluginManager from plugin_system.py to work at 

110 the platform level with ServiceRegistry and EventBus integration. 

111 """ 

112 

113 def __init__(self, service_registry: Any = None, 

114 platform_config: Any = None, 

115 event_emitter: Any = None): 

116 """Initialize the extension registry. 

117 

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() 

129 

130 def load(self, module_path: str) -> Extension: 

131 """Load an extension from a Python module path. 

132 

133 The module must contain a class that subclasses Extension. 

134 The first Extension subclass found is instantiated. 

135 

136 Args: 

137 module_path: Dotted module path (e.g., 'extensions.my_ext'). 

138 

139 Returns: 

140 The loaded Extension instance. 

141 

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)}") 

173 

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}") 

178 

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 

187 

188 if ext_class is None: 

189 raise TypeError( 

190 f"No Extension subclass found in '{module_path}'") 

191 

192 ext = ext_class() 

193 ext_id = ext.manifest.id 

194 

195 with self._lock: 

196 if ext_id in self._extensions: 

197 raise ValueError(f"Extension '{ext_id}' already loaded") 

198 

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 

209 

210 with self._lock: 

211 self._extensions[ext_id] = ext 

212 self._modules[ext_id] = module_path 

213 

214 if self._emit: 

215 self._emit('extension.loaded', { 

216 'ext_id': ext_id, 'name': ext.manifest.name}) 

217 

218 return ext 

219 

220 def load_from_directory(self, path: str) -> List[Extension]: 

221 """Scan a directory for extension modules and load them. 

222 

223 Looks for Python files with Extension subclasses. Each file 

224 is treated as a potential extension module. 

225 

226 Args: 

227 path: Directory path to scan. 

228 

229 Returns: 

230 List of loaded Extension instances. 

231 """ 

232 import os 

233 

234 loaded = [] 

235 if not os.path.isdir(path): 

236 logger.debug("Extensions directory not found: %s", path) 

237 return loaded 

238 

239 for fname in sorted(os.listdir(path)): 

240 if not fname.endswith('.py') or fname.startswith('_'): 

241 continue 

242 

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}" 

247 

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) 

254 

255 return loaded 

256 

257 def enable(self, ext_id: str) -> None: 

258 """Enable a loaded extension. 

259 

260 Args: 

261 ext_id: Extension ID. 

262 

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}") 

271 

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 

281 

282 if self._emit: 

283 self._emit('extension.enabled', {'ext_id': ext_id}) 

284 

285 def disable(self, ext_id: str) -> None: 

286 """Disable an enabled extension. 

287 

288 Args: 

289 ext_id: Extension ID. 

290 

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}") 

299 

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) 

307 

308 if self._emit: 

309 self._emit('extension.disabled', {'ext_id': ext_id}) 

310 

311 def unload(self, ext_id: str) -> None: 

312 """Unload an extension completely. 

313 

314 Disables first if enabled, then calls on_unload(). 

315 

316 Args: 

317 ext_id: Extension ID. 

318 

319 Raises: 

320 KeyError: If not loaded. 

321 """ 

322 ext = self._get_extension(ext_id) 

323 

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) 

330 

331 try: 

332 ext.on_unload() 

333 except Exception as e: 

334 logger.warning("Extension '%s' on_unload error: %s", ext_id, e) 

335 

336 ext._state = ExtensionState.UNLOADED 

337 

338 with self._lock: 

339 del self._extensions[ext_id] 

340 self._modules.pop(ext_id, None) 

341 

342 if self._emit: 

343 self._emit('extension.unloaded', {'ext_id': ext_id}) 

344 

345 def reload(self, ext_id: str) -> Extension: 

346 """Hot-reload an extension: unload, re-import, load. 

347 

348 Args: 

349 ext_id: Extension ID. 

350 

351 Returns: 

352 New Extension instance. 

353 

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}'") 

361 

362 self.unload(ext_id) 

363 

364 # Force re-import 

365 if module_path in sys.modules: 

366 del sys.modules[module_path] 

367 

368 return self.load(module_path) 

369 

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 

383 

384 def get(self, ext_id: str) -> Optional[Extension]: 

385 """Get an extension by ID.""" 

386 return self._extensions.get(ext_id) 

387 

388 def count(self) -> int: 

389 """Return number of loaded extensions.""" 

390 return len(self._extensions) 

391 

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] 

397 

398 # ── Lifecycle (for ServiceRegistry) ─────────────────────── 

399 

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 }