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
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
1"""ACB Events system integration for FastBlocks.
3This module bridges FastBlocks components with ACB's event-driven architecture,
4enabling reactive updates, cache invalidation, and admin action tracking.
6Author: lesleslie <les@wedgwoodwebworks.com>
7Created: 2025-10-01
8"""
9# type: ignore # ACB events API stub - graceful degradation
11import operator
12import typing as t
13from contextlib import suppress
14from dataclasses import dataclass
15from uuid import UUID
17from acb.adapters import AdapterStatus
18from acb.depends import Inject, depends
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 )
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]
43 create_event = None # type: ignore[assignment]
44 event_handler = None # type: ignore[assignment]
47# FastBlocks Event Types
48class FastBlocksEventType:
49 """Event types emitted by FastBlocks components."""
51 # Cache events
52 CACHE_INVALIDATED = "fastblocks.cache.invalidated"
53 CACHE_CLEARED = "fastblocks.cache.cleared"
55 # Template events
56 TEMPLATE_RENDERED = "fastblocks.template.rendered"
57 TEMPLATE_ERROR = "fastblocks.template.error"
59 # HTMX events (server-sent updates)
60 HTMX_REFRESH = "fastblocks.htmx.refresh"
61 HTMX_REDIRECT = "fastblocks.htmx.redirect"
62 HTMX_TRIGGER = "fastblocks.htmx.trigger"
64 # Admin events
65 ADMIN_ACTION = "fastblocks.admin.action"
66 ADMIN_LOGIN = "fastblocks.admin.login"
67 ADMIN_LOGOUT = "fastblocks.admin.logout"
69 # Route events
70 ROUTE_REGISTERED = "fastblocks.route.registered"
71 ROUTE_ACCESSED = "fastblocks.route.accessed"
74@dataclass
75class CacheInvalidationPayload:
76 """Payload for cache invalidation events."""
78 cache_key: str
79 reason: str
80 invalidated_by: str | None = None
81 affected_templates: list[str] | None = None
84@dataclass
85class TemplateRenderPayload:
86 """Payload for template render events."""
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
96@dataclass
97class HtmxUpdatePayload:
98 """Payload for HTMX update events."""
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
107@dataclass
108class AdminActionPayload:
109 """Payload for admin action events."""
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
119class CacheInvalidationHandler(EventHandler): # type: ignore[misc]
120 """Handler for cache invalidation events."""
122 @depends.inject # type: ignore[misc]
123 def __init__(self, cache: Inject[t.Any] = depends()) -> None:
124 super().__init__()
125 self.cache = cache
127 async def handle(self, event: Event) -> t.Any:
128 """Handle cache invalidation event."""
129 if not ACB_EVENTS_AVAILABLE:
130 return None
132 try:
133 payload = CacheInvalidationPayload(**event.payload)
135 # Invalidate the cache key
136 if self.cache:
137 await self.cache.delete(payload.cache_key)
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)
145 return EventHandlerResult(
146 success=True,
147 message=f"Invalidated cache key: {payload.cache_key}",
148 )
150 except Exception as e:
151 return EventHandlerResult(
152 success=False,
153 error=str(e),
154 message=f"Failed to invalidate cache: {e}",
155 )
158class TemplateRenderHandler(EventHandler): # type: ignore[misc]
159 """Handler for template render events - collects performance metrics."""
161 def __init__(self) -> None:
162 super().__init__()
163 self.metrics: dict[str, list[TemplateRenderPayload]] = {}
165 async def handle(self, event: Event) -> t.Any:
166 """Handle template render event."""
167 if not ACB_EVENTS_AVAILABLE:
168 return None
170 try:
171 payload = TemplateRenderPayload(**event.payload)
173 # Store metrics for performance analysis
174 if payload.template_name not in self.metrics:
175 self.metrics[payload.template_name] = []
177 self.metrics[payload.template_name].append(payload)
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:]
185 return EventHandlerResult(
186 success=True,
187 message=f"Recorded render metrics for {payload.template_name}",
188 )
190 except Exception as e:
191 return EventHandlerResult(
192 success=False,
193 error=str(e),
194 message=f"Failed to record metrics: {e}",
195 )
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 {}
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)
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 }
216class HtmxUpdateHandler(EventHandler): # type: ignore[misc]
217 """Handler for HTMX update events - broadcasts to connected clients."""
219 def __init__(self) -> None:
220 super().__init__()
221 self.active_connections: set[t.Any] = set() # WebSocket connections
223 async def handle(self, event: Event) -> t.Any:
224 """Handle HTMX update event."""
225 if not ACB_EVENTS_AVAILABLE:
226 return None
228 try:
229 payload = HtmxUpdatePayload(**event.payload)
231 # Build HTMX headers for server-sent event
232 headers = {}
234 if payload.update_type == "refresh" and payload.target:
235 headers["HX-Trigger"] = "refresh"
236 headers["HX-Refresh"] = "true"
238 elif payload.update_type == "redirect" and payload.target:
239 headers["HX-Redirect"] = payload.target
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
246 headers["HX-Trigger-Data"] = json.dumps(payload.trigger_data)
248 # Broadcast to all connected clients
249 # Note: Actual WebSocket broadcast would happen in route handlers
250 # This handler just prepares the event data
252 return EventHandlerResult(
253 success=True,
254 message=f"Prepared HTMX {payload.update_type} event",
255 data={"headers": headers},
256 )
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 )
266class AdminActionHandler(EventHandler): # type: ignore[misc]
267 """Handler for admin action events - audit logging."""
269 def __init__(self) -> None:
270 super().__init__()
271 self.audit_log: list[tuple[float, AdminActionPayload]] = []
273 async def handle(self, event: Event) -> t.Any:
274 """Handle admin action event."""
275 if not ACB_EVENTS_AVAILABLE:
276 return None
278 try:
279 import time
281 payload = AdminActionPayload(**event.payload)
283 # Store in audit log
284 self.audit_log.append((time.time(), payload))
286 # Keep only last 1000 actions
287 if len(self.audit_log) > 1000:
288 self.audit_log = self.audit_log[-1000:]
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
295 return EventHandlerResult(
296 success=True,
297 message=f"Logged admin action: {payload.action_type}",
298 )
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 )
307 def get_recent_actions(self, limit: int = 50) -> list[dict[str, t.Any]]:
308 """Get recent admin actions."""
309 import time
311 now = time.time()
312 recent = sorted(self.audit_log, key=operator.itemgetter(0), reverse=True)[
313 :limit
314 ]
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 ]
329class FastBlocksEventPublisher:
330 """Simplified event publisher for FastBlocks components."""
332 _instance: t.ClassVar["FastBlocksEventPublisher | None"] = None
333 _publisher: t.Any = None # EventPublisher | None when ACB available
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
341 @depends.inject # type: ignore[misc]
342 def __init__(self, config: Inject[t.Any] = depends()) -> None:
343 if not ACB_EVENTS_AVAILABLE:
344 return
346 self.config = config
347 self.source = "fastblocks"
349 # Initialize publisher lazily
350 if self._publisher is None and ACB_EVENTS_AVAILABLE:
351 with suppress(Exception):
352 self._publisher = EventPublisher()
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
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 )
378 await self._publisher.publish(event)
379 return True
381 except Exception:
382 return False
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
397 try:
398 event_type = (
399 FastBlocksEventType.TEMPLATE_ERROR
400 if error
401 else FastBlocksEventType.TEMPLATE_RENDERED
402 )
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 )
418 await self._publisher.publish(event)
419 return True
421 except Exception:
422 return False
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
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 )
450 await self._publisher.publish(event)
451 return True
453 except Exception:
454 return False
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
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 )
484 await self._publisher.publish(event)
485 return True
487 except Exception:
488 return False
491async def register_fastblocks_event_handlers() -> bool:
492 """Register all FastBlocks event handlers with ACB Events system.
494 Returns:
495 True if registration successful, False if ACB Events unavailable
496 """
497 if not ACB_EVENTS_AVAILABLE:
498 return False
500 try:
501 publisher = FastBlocksEventPublisher()
503 if publisher._publisher is None:
504 return False
506 # Register event handlers
507 cache_handler = CacheInvalidationHandler()
508 template_handler = TemplateRenderHandler()
509 htmx_handler = HtmxUpdateHandler()
510 admin_handler = AdminActionHandler()
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 )
520 await publisher._publisher.subscribe(
521 EventSubscription(
522 event_type=FastBlocksEventType.TEMPLATE_RENDERED,
523 handler=template_handler,
524 )
525 )
527 await publisher._publisher.subscribe(
528 EventSubscription(
529 event_type=FastBlocksEventType.TEMPLATE_ERROR,
530 handler=template_handler,
531 )
532 )
534 await publisher._publisher.subscribe(
535 EventSubscription(
536 event_type=FastBlocksEventType.HTMX_REFRESH,
537 handler=htmx_handler,
538 )
539 )
541 await publisher._publisher.subscribe(
542 EventSubscription(
543 event_type=FastBlocksEventType.ADMIN_ACTION,
544 handler=admin_handler,
545 )
546 )
548 # Store handlers in depends for retrieval
549 depends.set(template_handler, name="template_metrics")
550 depends.set(admin_handler, name="admin_audit")
552 return True
554 except Exception:
555 # Graceful degradation if registration fails
556 return False
559def get_event_publisher() -> FastBlocksEventPublisher | None:
560 """Get the FastBlocks event publisher instance.
562 Returns:
563 Event publisher instance or None if ACB Events unavailable
564 """
565 if not ACB_EVENTS_AVAILABLE:
566 return None
568 return FastBlocksEventPublisher()
571# Module metadata for ACB discovery
572MODULE_ID = UUID("01937d88-0000-7000-8000-000000000002")
573MODULE_STATUS = AdapterStatus.STABLE