"""Base plugin abstract class with lifecycle hooks.""" from __future__ import annotations from abc import ABC from typing import TYPE_CHECKING, Any from fastapi import APIRouter from app.plugins.manifest import PluginManifest if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession from app.core.event_bus import EventBus from app.core.service_container import ServiceContainer class BasePlugin(ABC): """Abstract base class for all LeoCRM plugins. Subclasses must define a ``manifest`` class attribute (PluginManifest) and implement the lifecycle hooks. """ manifest: PluginManifest def __init__(self) -> None: if not hasattr(self, "manifest") or self.manifest is None: raise ValueError(f"{self.__class__.__name__} must define a 'manifest' attribute") self._event_handlers: dict[str, Any] = {} self._routers: list[APIRouter] = [] # ─── Lifecycle Hooks ─── async def on_install(self, db: AsyncSession, service_container: ServiceContainer) -> None: """Called when the plugin is installed (after migrations are run). Override to perform seed data or initial setup. """ return None async def on_activate( self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus ) -> None: """Called when the plugin is activated. Override to register event listeners and prepare runtime state. Default implementation subscribes to events listed in the manifest. """ for event_name in self.manifest.events: handler = self._make_event_handler(event_name) self._event_handlers[event_name] = handler event_bus.subscribe(event_name, handler) async def on_deactivate( self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus ) -> None: """Called when the plugin is deactivated. Override to clean up runtime state. Default implementation unsubscribes all event listeners that were registered during activation. """ for event_name, handler in self._event_handlers.items(): event_bus.unsubscribe(event_name, handler) self._event_handlers.clear() async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None: """Called when the plugin is uninstalled (before data tables are dropped). Override to clean up any external resources. """ return None # ─── Route Registration ─── def get_routes(self) -> list[APIRouter]: """Return list of APIRouter instances to mount on the FastAPI app. Default implementation loads routers from manifest route definitions by importing the module and getting the specified attribute. """ if self._routers: return self._routers routers: list[APIRouter] = [] for route_def in self.manifest.routes: import importlib module = importlib.import_module(route_def.module) router: APIRouter = getattr(module, route_def.router_attr) routers.append(router) self._routers = routers return routers # ─── Event Handler Factory ─── def _make_event_handler(self, event_name: str) -> Any: """Create an event handler coroutine for the given event name. Looks for a method named ``on_`` with dots replaced by underscores. Falls back to ``on_event`` if it exists, otherwise uses a no-op handler. """ method_name = f"on_{event_name.replace('.', '_')}" specific = getattr(self, method_name, None) if specific is not None: return specific fallback = getattr(self, "on_event", None) if fallback is not None: return fallback # Default no-op handler async def _noop_handler(payload: dict[str, Any]) -> None: pass return _noop_handler # ─── Utility ─── @property def name(self) -> str: return self.manifest.name @property def version(self) -> str: return self.manifest.version def __repr__(self) -> str: return ( f"<{self.__class__.__name__} name={self.manifest.name} version={self.manifest.version}>" )