Coverage for astrocyte/_hooks.py: 100%
33 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 05:24 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 05:24 +0000
1"""Hook manager — registration and dispatch for event hooks."""
3from __future__ import annotations
5import asyncio
6import logging
7import threading
8import uuid
9from collections.abc import Awaitable, Callable
10from datetime import datetime, timezone
12from astrocyte.policy.observability import StructuredLogger
13from astrocyte.types import HookEvent
15# Hook handler type — FFI-safe: takes a HookEvent, returns an awaitable or None.
16HookHandler = Callable[[HookEvent], Awaitable[None] | None]
19class HookManager:
20 """Thread-safe event hook registration and dispatch."""
22 def __init__(self, logger: StructuredLogger) -> None:
23 self._hooks: dict[str, list[HookHandler]] = {}
24 self._lock = threading.Lock()
25 self._logger = logger
27 def register(self, event_type: str, handler: HookHandler) -> None:
28 """Register an event hook handler."""
29 with self._lock:
30 if event_type not in self._hooks:
31 self._hooks[event_type] = []
32 self._hooks[event_type].append(handler)
34 async def fire(
35 self,
36 event_type: str,
37 bank_id: str | None = None,
38 data: dict[str, str | int | float | bool | None] | None = None,
39 ) -> None:
40 """Fire all registered hooks for an event type. Non-blocking, failures logged."""
41 with self._lock:
42 handlers = list(self._hooks.get(event_type, []))
43 if not handlers:
44 return
45 event = HookEvent(
46 event_id=uuid.uuid4().hex,
47 type=event_type,
48 timestamp=datetime.now(timezone.utc),
49 bank_id=bank_id,
50 data=data,
51 )
52 for handler in handlers:
53 try:
54 result = handler(event)
55 if asyncio.iscoroutine(result) or asyncio.isfuture(result):
56 await result
57 except Exception:
58 self._logger.log(
59 "astrocyte.hook.error",
60 bank_id=bank_id,
61 data={"event_type": event_type},
62 level=logging.WARNING,
63 )