Coverage for fastblocks/_events_integration.py: 0%

219 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-09 00:47 -0700

1"""ACB Events system integration for FastBlocks. 

2 

3This module bridges FastBlocks components with ACB's event-driven architecture, 

4enabling reactive updates, cache invalidation, and admin action tracking. 

5 

6Author: lesleslie <les@wedgwoodwebworks.com> 

7Created: 2025-10-01 

8""" 

9# type: ignore # ACB events API stub - graceful degradation 

10 

11import operator 

12import typing as t 

13from contextlib import suppress 

14from dataclasses import dataclass 

15from uuid import UUID 

16 

17from acb.adapters import AdapterStatus 

18from acb.depends import Inject, depends 

19 

20# Optional ACB events imports (graceful degradation if not available) 

21try: 

22 from acb.events import ( 

23 Event, 

24 EventHandler, 

25 EventHandlerResult, 

26 EventPriority, 

27 EventPublisher, 

28 EventSubscription, 

29 create_event, 

30 event_handler, 

31 ) 

32 

33 ACB_EVENTS_AVAILABLE = True 

34except ImportError: 

35 ACB_EVENTS_AVAILABLE = False 

36 from typing import Any as Event # type: ignore[misc] 

37 from typing import Any as EventHandler # type: ignore[misc] 

38 from typing import Any as EventHandlerResult # type: ignore[misc] 

39 from typing import Any as EventPriority # type: ignore[misc] 

40 from typing import Any as EventPublisher # type: ignore[misc] 

41 from typing import Any as EventSubscription # type: ignore[misc] 

42 

43 create_event = None # type: ignore[assignment] 

44 event_handler = None # type: ignore[assignment] 

45 

46 

47# FastBlocks Event Types 

48class FastBlocksEventType: 

49 """Event types emitted by FastBlocks components.""" 

50 

51 # Cache events 

52 CACHE_INVALIDATED = "fastblocks.cache.invalidated" 

53 CACHE_CLEARED = "fastblocks.cache.cleared" 

54 

55 # Template events 

56 TEMPLATE_RENDERED = "fastblocks.template.rendered" 

57 TEMPLATE_ERROR = "fastblocks.template.error" 

58 

59 # HTMX events (server-sent updates) 

60 HTMX_REFRESH = "fastblocks.htmx.refresh" 

61 HTMX_REDIRECT = "fastblocks.htmx.redirect" 

62 HTMX_TRIGGER = "fastblocks.htmx.trigger" 

63 

64 # Admin events 

65 ADMIN_ACTION = "fastblocks.admin.action" 

66 ADMIN_LOGIN = "fastblocks.admin.login" 

67 ADMIN_LOGOUT = "fastblocks.admin.logout" 

68 

69 # Route events 

70 ROUTE_REGISTERED = "fastblocks.route.registered" 

71 ROUTE_ACCESSED = "fastblocks.route.accessed" 

72 

73 

74@dataclass 

75class CacheInvalidationPayload: 

76 """Payload for cache invalidation events.""" 

77 

78 cache_key: str 

79 reason: str 

80 invalidated_by: str | None = None 

81 affected_templates: list[str] | None = None 

82 

83 

84@dataclass 

85class TemplateRenderPayload: 

86 """Payload for template render events.""" 

87 

88 template_name: str 

89 render_time_ms: float 

90 cache_hit: bool 

91 context_size: int 

92 fragment_count: int 

93 error: str | None = None 

94 

95 

96@dataclass 

97class HtmxUpdatePayload: 

98 """Payload for HTMX update events.""" 

99 

100 update_type: str # "refresh", "redirect", "trigger" 

101 target: str | None = None # CSS selector or URL 

102 swap_method: str | None = None # "innerHTML", "outerHTML", etc. 

103 trigger_name: str | None = None 

104 trigger_data: dict[str, t.Any] | None = None 

105 

106 

107@dataclass 

108class AdminActionPayload: 

109 """Payload for admin action events.""" 

110 

111 action_type: str # "create", "update", "delete", "login", "logout" 

112 user_id: str 

113 resource_type: str | None = None 

114 resource_id: str | None = None 

115 changes: dict[str, t.Any] | None = None 

116 ip_address: str | None = None 

117 

118 

119class CacheInvalidationHandler(EventHandler): # type: ignore[misc] 

120 """Handler for cache invalidation events.""" 

121 

122 @depends.inject # type: ignore[misc] 

123 def __init__(self, cache: Inject[t.Any] = depends()) -> None: 

124 super().__init__() 

125 self.cache = cache 

126 

127 async def handle(self, event: Event) -> t.Any: 

128 """Handle cache invalidation event.""" 

129 if not ACB_EVENTS_AVAILABLE: 

130 return None 

131 

132 try: 

133 payload = CacheInvalidationPayload(**event.payload) 

134 

135 # Invalidate the cache key 

136 if self.cache: 

137 await self.cache.delete(payload.cache_key) 

138 

139 # Also invalidate related template caches if specified 

140 if payload.affected_templates: 

141 for template_name in payload.affected_templates: 

142 template_key = f"template:{template_name}" 

143 await self.cache.delete(template_key) 

144 

145 return EventHandlerResult( 

146 success=True, 

147 message=f"Invalidated cache key: {payload.cache_key}", 

148 ) 

149 

150 except Exception as e: 

151 return EventHandlerResult( 

152 success=False, 

153 error=str(e), 

154 message=f"Failed to invalidate cache: {e}", 

155 ) 

156 

157 

158class TemplateRenderHandler(EventHandler): # type: ignore[misc] 

159 """Handler for template render events - collects performance metrics.""" 

160 

161 def __init__(self) -> None: 

162 super().__init__() 

163 self.metrics: dict[str, list[TemplateRenderPayload]] = {} 

164 

165 async def handle(self, event: Event) -> t.Any: 

166 """Handle template render event.""" 

167 if not ACB_EVENTS_AVAILABLE: 

168 return None 

169 

170 try: 

171 payload = TemplateRenderPayload(**event.payload) 

172 

173 # Store metrics for performance analysis 

174 if payload.template_name not in self.metrics: 

175 self.metrics[payload.template_name] = [] 

176 

177 self.metrics[payload.template_name].append(payload) 

178 

179 # Keep only last 100 renders per template 

180 if len(self.metrics[payload.template_name]) > 100: 

181 self.metrics[payload.template_name] = self.metrics[ 

182 payload.template_name 

183 ][-100:] 

184 

185 return EventHandlerResult( 

186 success=True, 

187 message=f"Recorded render metrics for {payload.template_name}", 

188 ) 

189 

190 except Exception as e: 

191 return EventHandlerResult( 

192 success=False, 

193 error=str(e), 

194 message=f"Failed to record metrics: {e}", 

195 ) 

196 

197 def get_template_stats(self, template_name: str) -> dict[str, t.Any]: 

198 """Get performance statistics for a template.""" 

199 if template_name not in self.metrics: 

200 return {} 

201 

202 renders = self.metrics[template_name] 

203 render_times = [r.render_time_ms for r in renders] 

204 cache_hits = sum(1 for r in renders if r.cache_hit) 

205 

206 return { 

207 "total_renders": len(renders), 

208 "avg_render_time_ms": sum(render_times) / len(render_times), 

209 "min_render_time_ms": min(render_times), 

210 "max_render_time_ms": max(render_times), 

211 "cache_hit_ratio": cache_hits / len(renders), 

212 "recent_errors": [r.error for r in renders[-10:] if r.error], 

213 } 

214 

215 

216class HtmxUpdateHandler(EventHandler): # type: ignore[misc] 

217 """Handler for HTMX update events - broadcasts to connected clients.""" 

218 

219 def __init__(self) -> None: 

220 super().__init__() 

221 self.active_connections: set[t.Any] = set() # WebSocket connections 

222 

223 async def handle(self, event: Event) -> t.Any: 

224 """Handle HTMX update event.""" 

225 if not ACB_EVENTS_AVAILABLE: 

226 return None 

227 

228 try: 

229 payload = HtmxUpdatePayload(**event.payload) 

230 

231 # Build HTMX headers for server-sent event 

232 headers = {} 

233 

234 if payload.update_type == "refresh" and payload.target: 

235 headers["HX-Trigger"] = "refresh" 

236 headers["HX-Refresh"] = "true" 

237 

238 elif payload.update_type == "redirect" and payload.target: 

239 headers["HX-Redirect"] = payload.target 

240 

241 elif payload.update_type == "trigger" and payload.trigger_name: 

242 headers["HX-Trigger"] = payload.trigger_name 

243 if payload.trigger_data: 

244 import json 

245 

246 headers["HX-Trigger-Data"] = json.dumps(payload.trigger_data) 

247 

248 # Broadcast to all connected clients 

249 # Note: Actual WebSocket broadcast would happen in route handlers 

250 # This handler just prepares the event data 

251 

252 return EventHandlerResult( 

253 success=True, 

254 message=f"Prepared HTMX {payload.update_type} event", 

255 data={"headers": headers}, 

256 ) 

257 

258 except Exception as e: 

259 return EventHandlerResult( 

260 success=False, 

261 error=str(e), 

262 message=f"Failed to prepare HTMX event: {e}", 

263 ) 

264 

265 

266class AdminActionHandler(EventHandler): # type: ignore[misc] 

267 """Handler for admin action events - audit logging.""" 

268 

269 def __init__(self) -> None: 

270 super().__init__() 

271 self.audit_log: list[tuple[float, AdminActionPayload]] = [] 

272 

273 async def handle(self, event: Event) -> t.Any: 

274 """Handle admin action event.""" 

275 if not ACB_EVENTS_AVAILABLE: 

276 return None 

277 

278 try: 

279 import time 

280 

281 payload = AdminActionPayload(**event.payload) 

282 

283 # Store in audit log 

284 self.audit_log.append((time.time(), payload)) 

285 

286 # Keep only last 1000 actions 

287 if len(self.audit_log) > 1000: 

288 self.audit_log = self.audit_log[-1000:] 

289 

290 # In production, this would also: 

291 # - Write to database audit table 

292 # - Send to monitoring/alerting system 

293 # - Trigger security checks for sensitive actions 

294 

295 return EventHandlerResult( 

296 success=True, 

297 message=f"Logged admin action: {payload.action_type}", 

298 ) 

299 

300 except Exception as e: 

301 return EventHandlerResult( 

302 success=False, 

303 error=str(e), 

304 message=f"Failed to log admin action: {e}", 

305 ) 

306 

307 def get_recent_actions(self, limit: int = 50) -> list[dict[str, t.Any]]: 

308 """Get recent admin actions.""" 

309 import time 

310 

311 now = time.time() 

312 recent = sorted(self.audit_log, key=operator.itemgetter(0), reverse=True)[ 

313 :limit 

314 ] 

315 

316 return [ 

317 { 

318 "timestamp": timestamp, 

319 "age_seconds": now - timestamp, 

320 "action_type": payload.action_type, 

321 "user_id": payload.user_id, 

322 "resource_type": payload.resource_type, 

323 "resource_id": payload.resource_id, 

324 } 

325 for timestamp, payload in recent 

326 ] 

327 

328 

329class FastBlocksEventPublisher: 

330 """Simplified event publisher for FastBlocks components.""" 

331 

332 _instance: t.ClassVar["FastBlocksEventPublisher | None"] = None 

333 _publisher: t.Any = None # EventPublisher | None when ACB available 

334 

335 def __new__(cls) -> "FastBlocksEventPublisher": 

336 """Singleton pattern for event publisher.""" 

337 if cls._instance is None: 

338 cls._instance = super().__new__(cls) # type: ignore[misc] 

339 return cls._instance 

340 

341 @depends.inject # type: ignore[misc] 

342 def __init__(self, config: Inject[t.Any] = depends()) -> None: 

343 if not ACB_EVENTS_AVAILABLE: 

344 return 

345 

346 self.config = config 

347 self.source = "fastblocks" 

348 

349 # Initialize publisher lazily 

350 if self._publisher is None and ACB_EVENTS_AVAILABLE: 

351 with suppress(Exception): 

352 self._publisher = EventPublisher() 

353 

354 async def publish_cache_invalidation( 

355 self, 

356 cache_key: str, 

357 reason: str, 

358 invalidated_by: str | None = None, 

359 affected_templates: list[str] | None = None, 

360 ) -> bool: 

361 """Publish cache invalidation event.""" 

362 if not ACB_EVENTS_AVAILABLE or self._publisher is None: 

363 return False 

364 

365 try: 

366 event = create_event( 

367 event_type=FastBlocksEventType.CACHE_INVALIDATED, 

368 source=self.source, 

369 payload={ 

370 "cache_key": cache_key, 

371 "reason": reason, 

372 "invalidated_by": invalidated_by, 

373 "affected_templates": affected_templates, 

374 }, 

375 priority=EventPriority.HIGH, 

376 ) 

377 

378 await self._publisher.publish(event) 

379 return True 

380 

381 except Exception: 

382 return False 

383 

384 async def publish_template_render( 

385 self, 

386 template_name: str, 

387 render_time_ms: float, 

388 cache_hit: bool, 

389 context_size: int, 

390 fragment_count: int, 

391 error: str | None = None, 

392 ) -> bool: 

393 """Publish template render event.""" 

394 if not ACB_EVENTS_AVAILABLE or self._publisher is None: 

395 return False 

396 

397 try: 

398 event_type = ( 

399 FastBlocksEventType.TEMPLATE_ERROR 

400 if error 

401 else FastBlocksEventType.TEMPLATE_RENDERED 

402 ) 

403 

404 event = create_event( 

405 event_type=event_type, 

406 source=self.source, 

407 payload={ 

408 "template_name": template_name, 

409 "render_time_ms": render_time_ms, 

410 "cache_hit": cache_hit, 

411 "context_size": context_size, 

412 "fragment_count": fragment_count, 

413 "error": error, 

414 }, 

415 priority=EventPriority.NORMAL, 

416 ) 

417 

418 await self._publisher.publish(event) 

419 return True 

420 

421 except Exception: 

422 return False 

423 

424 async def publish_htmx_update( 

425 self, 

426 update_type: str, 

427 target: str | None = None, 

428 swap_method: str | None = None, 

429 trigger_name: str | None = None, 

430 trigger_data: dict[str, t.Any] | None = None, 

431 ) -> bool: 

432 """Publish HTMX update event.""" 

433 if not ACB_EVENTS_AVAILABLE or self._publisher is None: 

434 return False 

435 

436 try: 

437 event = create_event( 

438 event_type=FastBlocksEventType.HTMX_REFRESH, 

439 source=self.source, 

440 payload={ 

441 "update_type": update_type, 

442 "target": target, 

443 "swap_method": swap_method, 

444 "trigger_name": trigger_name, 

445 "trigger_data": trigger_data, 

446 }, 

447 priority=EventPriority.HIGH, 

448 ) 

449 

450 await self._publisher.publish(event) 

451 return True 

452 

453 except Exception: 

454 return False 

455 

456 async def publish_admin_action( 

457 self, 

458 action_type: str, 

459 user_id: str, 

460 resource_type: str | None = None, 

461 resource_id: str | None = None, 

462 changes: dict[str, t.Any] | None = None, 

463 ip_address: str | None = None, 

464 ) -> bool: 

465 """Publish admin action event.""" 

466 if not ACB_EVENTS_AVAILABLE or self._publisher is None: 

467 return False 

468 

469 try: 

470 event = create_event( 

471 event_type=FastBlocksEventType.ADMIN_ACTION, 

472 source=self.source, 

473 payload={ 

474 "action_type": action_type, 

475 "user_id": user_id, 

476 "resource_type": resource_type, 

477 "resource_id": resource_id, 

478 "changes": changes, 

479 "ip_address": ip_address, 

480 }, 

481 priority=EventPriority.CRITICAL, 

482 ) 

483 

484 await self._publisher.publish(event) 

485 return True 

486 

487 except Exception: 

488 return False 

489 

490 

491async def register_fastblocks_event_handlers() -> bool: 

492 """Register all FastBlocks event handlers with ACB Events system. 

493 

494 Returns: 

495 True if registration successful, False if ACB Events unavailable 

496 """ 

497 if not ACB_EVENTS_AVAILABLE: 

498 return False 

499 

500 try: 

501 publisher = FastBlocksEventPublisher() 

502 

503 if publisher._publisher is None: 

504 return False 

505 

506 # Register event handlers 

507 cache_handler = CacheInvalidationHandler() 

508 template_handler = TemplateRenderHandler() 

509 htmx_handler = HtmxUpdateHandler() 

510 admin_handler = AdminActionHandler() 

511 

512 # Subscribe handlers to their event types 

513 await publisher._publisher.subscribe( 

514 EventSubscription( 

515 event_type=FastBlocksEventType.CACHE_INVALIDATED, 

516 handler=cache_handler, 

517 ) 

518 ) 

519 

520 await publisher._publisher.subscribe( 

521 EventSubscription( 

522 event_type=FastBlocksEventType.TEMPLATE_RENDERED, 

523 handler=template_handler, 

524 ) 

525 ) 

526 

527 await publisher._publisher.subscribe( 

528 EventSubscription( 

529 event_type=FastBlocksEventType.TEMPLATE_ERROR, 

530 handler=template_handler, 

531 ) 

532 ) 

533 

534 await publisher._publisher.subscribe( 

535 EventSubscription( 

536 event_type=FastBlocksEventType.HTMX_REFRESH, 

537 handler=htmx_handler, 

538 ) 

539 ) 

540 

541 await publisher._publisher.subscribe( 

542 EventSubscription( 

543 event_type=FastBlocksEventType.ADMIN_ACTION, 

544 handler=admin_handler, 

545 ) 

546 ) 

547 

548 # Store handlers in depends for retrieval 

549 depends.set(template_handler, name="template_metrics") 

550 depends.set(admin_handler, name="admin_audit") 

551 

552 return True 

553 

554 except Exception: 

555 # Graceful degradation if registration fails 

556 return False 

557 

558 

559def get_event_publisher() -> FastBlocksEventPublisher | None: 

560 """Get the FastBlocks event publisher instance. 

561 

562 Returns: 

563 Event publisher instance or None if ACB Events unavailable 

564 """ 

565 if not ACB_EVENTS_AVAILABLE: 

566 return None 

567 

568 return FastBlocksEventPublisher() 

569 

570 

571# Module metadata for ACB discovery 

572MODULE_ID = UUID("01937d88-0000-7000-8000-000000000002") 

573MODULE_STATUS = AdapterStatus.STABLE