T03: plugin system framework + lifecycle + migrations + event bus + DI

- Plugin registry with discover/install/activate/deactivate/uninstall lifecycle
- PluginManifest Pydantic v2 schema (name, version, dependencies, routes, events, migrations)
- BasePlugin abstract class with lifecycle hooks (on_install/activate/deactivate/uninstall)
- Migration runner with tenant_id validator (rejects tables without tenant_id)
- Event bus integration: register/unregister listeners on activate/deactivate
- Service container DI: plugins receive db, cache, event_bus, storage, notifications
- Idempotent operations (activate active=200, deactivate inactive=200)
- UI registry for frontend component registration
- 47 new tests (14 ACs + 33 unit tests), 103 total tests pass
- Migration 0003: plugins + plugin_migrations tables
- Coverage: 85.92% for plugin modules
This commit is contained in:
leocrm-bot
2026-06-29 01:18:46 +02:00
parent dd16940bb2
commit 9678344f0e
21 changed files with 2414 additions and 3 deletions
+8
View File
@@ -0,0 +1,8 @@
"""Plugin system framework for LeoCRM."""
from app.plugins.manifest import PluginManifest
from app.plugins.base import BasePlugin
from app.plugins.registry import get_registry
from app.plugins.migration_runner import MigrationRunner
__all__ = ["PluginManifest", "BasePlugin", "get_registry", "MigrationRunner"]
+125
View File
@@ -0,0 +1,125 @@
"""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_<event_name>`` 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}>"
+5
View File
@@ -0,0 +1,5 @@
"""Built-in plugins directory.
Each module in this package that exports a BasePlugin subclass will be
discovered automatically by the plugin registry on application startup.
"""
@@ -0,0 +1,7 @@
-- Bad migration: creates table WITHOUT tenant_id (should be rejected by validator)
CREATE TABLE IF NOT EXISTS plugin_bad_table (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(200) NOT NULL,
content TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1,10 @@
-- Test plugin migration: creates plugin_test_data table with tenant_id
CREATE TABLE IF NOT EXISTS plugin_test_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
title VARCHAR(200) NOT NULL,
content TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_plugin_test_data_tenant ON plugin_test_data(tenant_id);
+52
View File
@@ -0,0 +1,52 @@
"""Sample test plugin for LeoCRM plugin system testing."""
from __future__ import annotations
from typing import Any
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest
class TestSamplePlugin(BasePlugin):
"""A sample plugin for testing the plugin lifecycle."""
manifest = PluginManifest(
name="test_sample",
version="1.0.0",
display_name="Test Sample Plugin",
description="A sample plugin for testing install/activate/deactivate/uninstall lifecycle.",
dependencies=[],
routes=[],
events=["company.created", "contact.created"],
migrations=["0001_test_plugin.sql"],
permissions=[],
)
def __init__(self) -> None:
super().__init__()
self.install_called = False
self.activate_called = False
self.deactivate_called = False
self.uninstall_called = False
self.event_log: list[dict[str, Any]] = []
async def on_install(self, db, service_container) -> None:
self.install_called = True
async def on_activate(self, db, service_container, event_bus) -> None:
self.activate_called = True
await super().on_activate(db, service_container, event_bus)
async def on_deactivate(self, db, service_container, event_bus) -> None:
self.deactivate_called = True
await super().on_deactivate(db, service_container, event_bus)
async def on_uninstall(self, db, service_container) -> None:
self.uninstall_called = True
async def on_company_created(self, payload: dict[str, Any]) -> None:
self.event_log.append({"event": "company.created", "payload": payload})
async def on_contact_created(self, payload: dict[str, Any]) -> None:
self.event_log.append({"event": "contact.created", "payload": payload})
+70
View File
@@ -0,0 +1,70 @@
"""Plugin manifest schema (Pydantic v2)."""
from __future__ import annotations
from pydantic import BaseModel, Field, field_validator
class PluginRouteDef(BaseModel):
"""Route definition within a plugin manifest."""
path: str = Field(..., description="URL path prefix, e.g. /api/v1/plugin-mail")
module: str = Field(..., description="Dotted path to the module containing the APIRouter")
router_attr: str = Field(default="router", description="Attribute name of the APIRouter in the module")
class PluginManifest(BaseModel):
"""Manifest describing a plugin's metadata, dependencies, and capabilities."""
name: str = Field(..., min_length=1, max_length=80, description="Unique plugin identifier (snake_case)")
version: str = Field(..., min_length=1, max_length=40, description="Semantic version string")
display_name: str = Field(..., min_length=1, max_length=120)
description: str = Field(default="", max_length=500)
dependencies: list[str] = Field(default_factory=list, description="Other plugin names this plugin depends on")
routes: list[PluginRouteDef] = Field(default_factory=list, description="Route definitions to register on activation")
events: list[str] = Field(default_factory=list, description="Event names this plugin listens to")
migrations: list[str] = Field(default_factory=list, description="Migration file names (ordered, e.g. 0001_initial.sql)")
permissions: list[str] = Field(default_factory=list, description="Required permissions for this plugin")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
if not v.replace("_", "").isalnum():
raise ValueError("Plugin name must be alphanumeric with underscores only")
return v.lower()
model_config = {"extra": "forbid"}
class ManifestSchemaResponse(BaseModel):
"""Response model describing the manifest schema for API consumers."""
fields: dict[str, dict[str, str]]
example: PluginManifest
# Pre-built schema documentation for GET /api/v1/plugins/manifest endpoint
MANIFEST_SCHEMA_DOC = ManifestSchemaResponse(
fields={
"name": {"type": "str", "required": "true", "description": "Unique plugin identifier (snake_case, max 80 chars)"},
"version": {"type": "str", "required": "true", "description": "Semantic version string"},
"display_name": {"type": "str", "required": "true", "description": "Human-readable plugin name"},
"description": {"type": "str", "required": "false", "description": "Plugin description (max 500 chars)"},
"dependencies": {"type": "list[str]", "required": "false", "description": "Other plugin names required"},
"routes": {"type": "list[PluginRouteDef]", "required": "false", "description": "Route definitions to register"},
"events": {"type": "list[str]", "required": "false", "description": "Event names to listen to"},
"migrations": {"type": "list[str]", "required": "false", "description": "Migration file names (ordered)"},
"permissions": {"type": "list[str]", "required": "false", "description": "Required permissions"},
},
example=PluginManifest(
name="example_plugin",
version="1.0.0",
display_name="Example Plugin",
description="An example plugin demonstrating the manifest schema.",
dependencies=[],
routes=[],
events=["company.created", "contact.created"],
migrations=["0001_initial.sql"],
permissions=["companies.read"],
),
)
+282
View File
@@ -0,0 +1,282 @@
"""Plugin DB migration runner with tenant_id column validation."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from sqlalchemy import text, inspect
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.models.plugin import PluginMigration
class MigrationValidationError(Exception):
"""Raised when a plugin migration creates a table without tenant_id column."""
class MigrationRunner:
"""Runs plugin SQL migrations and validates that all created tables have tenant_id."""
def __init__(self, engine: AsyncEngine, migrations_dir: str | None = None) -> None:
self._engine = engine
self._migrations_dir = Path(migrations_dir or os.path.join(os.path.dirname(__file__), "migrations"))
def _resolve_migration_path(self, filename: str) -> Path:
"""Resolve a migration filename to an absolute path."""
# Try migrations_dir first, then app/plugins/migrations/
candidate = self._migrations_dir / filename
if candidate.exists():
return candidate
# Fallback: builtins directory
builtins_dir = Path(__file__).parent / "builtins" / "migrations"
candidate2 = builtins_dir / filename
if candidate2.exists():
return candidate2
raise FileNotFoundError(f"Migration file not found: {filename}")
async def run_migration(
self,
db: AsyncSession,
plugin_name: str,
migration_filename: str,
tenant_id: Any | None = None,
) -> PluginMigration:
"""Run a single SQL migration file for a plugin.
Reads the SQL file, executes it, validates created tables have tenant_id,
and records the migration in plugin_migrations table.
Args:
db: Async database session.
plugin_name: Name of the plugin being migrated.
migration_filename: Filename of the migration SQL file.
tenant_id: Optional tenant UUID for tenant-scoped migrations.
Returns:
PluginMigration record.
Raises:
FileNotFoundError: If migration file doesn't exist.
MigrationValidationError: If a created table lacks tenant_id column.
"""
migration_path = self._resolve_migration_path(migration_filename)
sql_content = migration_path.read_text(encoding="utf-8")
# Capture existing table names before migration (static analysis of SQL)
existing_tables = await self._get_table_names_via_session(db)
# Execute the migration SQL
# Split on semicolons but handle potential function definitions
statements = self._split_sql(sql_content)
for stmt in statements:
stmt = stmt.strip()
if stmt:
await db.execute(text(stmt))
await db.flush()
# Capture table names after migration (using same session connection)
new_tables = await self._get_table_names_via_session(db)
created_tables = new_tables - existing_tables
# Validate: all newly created tables must have tenant_id column
tables_without_tenant = []
for table_name in created_tables:
if not await self._table_has_column_via_session(db, table_name, "tenant_id"):
tables_without_tenant.append(table_name)
if tables_without_tenant:
# Rollback the migration
await db.rollback()
raise MigrationValidationError(
f"Plugin '{plugin_name}' migration '{migration_filename}' created tables without tenant_id column: "
f"{', '.join(tables_without_tenant)}. All plugin tables must have tenant_id for multi-tenant isolation."
)
# Record the migration in plugin_migrations table
migration_record = PluginMigration(
plugin_name=plugin_name,
migration_file=migration_filename,
status="applied",
)
db.add(migration_record)
await db.flush()
return migration_record
async def run_all_migrations(
self,
db: AsyncSession,
plugin_name: str,
migration_files: list[str],
tenant_id: Any | None = None,
) -> list[PluginMigration]:
"""Run all migration files for a plugin in order.
Skips already-applied migrations (idempotent).
"""
# Check which migrations are already applied
from sqlalchemy import select
result = await db.execute(
select(PluginMigration).where(
PluginMigration.plugin_name == plugin_name,
PluginMigration.status == "applied",
)
)
applied_files = {row.migration_file for row in result.scalars().all()}
records: list[PluginMigration] = []
for filename in migration_files:
if filename in applied_files:
continue # Already applied — idempotent skip
record = await self.run_migration(db, plugin_name, filename, tenant_id)
records.append(record)
return records
async def drop_plugin_tables(self, db: AsyncSession, plugin_name: str) -> list[str]:
"""Drop all tables that were created by a plugin's migrations.
Args:
db: Async database session.
plugin_name: Name of the plugin to remove tables for.
Returns:
List of dropped table names.
"""
from sqlalchemy import select
# Get all migration records for this plugin
result = await db.execute(
select(PluginMigration).where(PluginMigration.plugin_name == plugin_name)
)
migration_records = result.scalars().all()
dropped_tables: list[str] = []
for record in migration_records:
migration_path = self._resolve_migration_path(record.migration_file)
sql_content = migration_path.read_text(encoding="utf-8")
# Parse table names from CREATE TABLE statements
table_names = self._extract_table_names(sql_content)
for table_name in table_names:
try:
await db.execute(text(f'DROP TABLE IF EXISTS "{table_name}" CASCADE'))
dropped_tables.append(table_name)
except Exception:
pass # Table may already be gone
await db.flush()
# Remove migration records
for record in migration_records:
await db.delete(record)
await db.flush()
return dropped_tables
async def _get_table_names_via_session(self, db: AsyncSession) -> set[str]:
"""Get current table names using the session's own connection.
This ensures we see uncommitted DDL changes within the same transaction.
"""
result = await db.execute(
text("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
)
return {row[0] for row in result.fetchall()}
async def _table_has_column_via_session(
self, db: AsyncSession, table_name: str, column_name: str
) -> bool:
"""Check if a table has a specific column using the session's connection."""
result = await db.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = :table_name AND column_name = :column_name"
),
{"table_name": table_name, "column_name": column_name},
)
return result.fetchone() is not None
async def _get_table_names(self) -> set[str]:
"""Get current table names from the database (separate connection)."""
def _get_names(sync_conn):
insp = inspect(sync_conn)
return set(insp.get_table_names())
async with self._engine.connect() as conn:
return await conn.run_sync(_get_names)
async def _table_has_column(self, table_name: str, column_name: str) -> bool:
"""Check if a table has a specific column (separate connection)."""
def _has_col(sync_conn):
insp = inspect(sync_conn)
if table_name not in insp.get_table_names():
return False
columns = [col["name"] for col in insp.get_columns(table_name)]
return column_name in columns
async with self._engine.connect() as conn:
return await conn.run_sync(_has_col)
@staticmethod
def _split_sql(sql: str) -> list[str]:
"""Split SQL into individual statements, respecting basic semicolon boundaries."""
statements: list[str] = []
current: list[str] = []
in_dollar_quote = False
dollar_tag = ""
for line in sql.splitlines():
stripped = line.strip()
# Handle dollar-quoted blocks (PostgreSQL function bodies)
if "$$" in stripped and not in_dollar_quote:
parts = stripped.split("$$", 1)
if len(parts) > 1:
dollar_tag = parts[0].strip()
if dollar_tag:
in_dollar_quote = True
elif stripped.count("$$") >= 2:
# Single-line $$ block
current.append(line)
if stripped.rstrip().endswith(";"):
statements.append("\n".join(current))
current = []
continue
else:
in_dollar_quote = True
current.append(line)
continue
if in_dollar_quote:
current.append(line)
if "$$" in stripped:
in_dollar_quote = False
# After closing $$, check if the line ends with ; to flush statement
after_dollar = stripped.split("$$", 1)[-1] if "$$" in stripped else ""
if after_dollar.rstrip().endswith(";"):
statements.append("\n".join(current))
current = []
continue
current.append(line)
if stripped.endswith(";"):
statements.append("\n".join(current))
current = []
if current:
remaining = "\n".join(current).strip()
if remaining:
statements.append(remaining)
return statements
@staticmethod
def _extract_table_names(sql: str) -> list[str]:
"""Extract table names from CREATE TABLE statements in SQL."""
import re
# Match CREATE TABLE [IF NOT EXISTS] "table_name" or CREATE TABLE table_name
pattern = r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["\']?(\w+)["\']?'
return re.findall(pattern, sql, re.IGNORECASE)
+385
View File
@@ -0,0 +1,385 @@
"""Plugin registry — manages discovered, installed, and active plugins."""
from __future__ import annotations
import importlib
import logging
from typing import Any
from fastapi import FastAPI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine
from app.core.event_bus import EventBus, get_event_bus
from app.core.service_container import ServiceContainer, get_container
from app.models.plugin import Plugin as PluginModel
from app.plugins.base import BasePlugin
from app.plugins.migration_runner import MigrationRunner
logger = logging.getLogger(__name__)
class PluginRegistry:
"""Registry for managing plugin lifecycle.
Maintains in-memory state for discovered plugins and their runtime instances,
backed by database records for persistent status tracking.
"""
def __init__(self) -> None:
# name -> BasePlugin instance (discovered/loaded)
self._plugins: dict[str, BasePlugin] = {}
# name -> PluginModel DB record (installed status)
self._db_status: dict[str, PluginModel] = {}
# name -> list of mounted APIRouter references (for unregistration)
self._mounted_routers: dict[str, list[Any]] = {}
self._engine: AsyncEngine | None = None
self._event_bus: EventBus = get_event_bus()
self._container: ServiceContainer = get_container()
self._migration_runner: MigrationRunner | None = None
self._app: FastAPI | None = None
self._initialized = False
def initialize(self, engine: AsyncEngine, app: FastAPI | None = None) -> None:
"""Initialize the registry with the DB engine and optional FastAPI app."""
self._engine = engine
self._app = app
self._migration_runner = MigrationRunner(engine)
self._initialized = True
@property
def migration_runner(self) -> MigrationRunner:
if self._migration_runner is None:
raise RuntimeError("Registry not initialized — call initialize() first")
return self._migration_runner
@property
def engine(self) -> AsyncEngine:
if self._engine is None:
raise RuntimeError("Registry not initialized — call initialize() first")
return self._engine
# ─── Discovery ───
def discover_builtins(self) -> list[str]:
"""Discover and load built-in plugins from app.plugins.builtins.
Scans the builtins package for modules that export a BasePlugin subclass.
Returns list of discovered plugin names.
"""
discovered: list[str] = []
try:
builtins_pkg = importlib.import_module("app.plugins.builtins")
except ImportError:
return discovered
pkg_path = getattr(builtins_pkg, "__path__", None)
if pkg_path is None:
return discovered
import pkgutil
for importer, modname, ispkg in pkgutil.iter_modules(pkg_path):
if modname.startswith("_"):
continue
try:
full_name = f"app.plugins.builtins.{modname}"
module = importlib.import_module(full_name)
# Look for BasePlugin subclass in module
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, BasePlugin)
and attr is not BasePlugin
):
instance = attr()
if instance.name not in self._plugins:
self._plugins[instance.name] = instance
discovered.append(instance.name)
logger.info(f"Discovered plugin: {instance.name} v{instance.version}")
except Exception as exc:
logger.warning(f"Failed to load builtin plugin module {modname}: {exc}")
return discovered
def register_plugin(self, plugin: BasePlugin) -> None:
"""Manually register a plugin instance."""
self._plugins[plugin.name] = plugin
logger.info(f"Registered plugin: {plugin.name} v{plugin.version}")
def get_plugin(self, name: str) -> BasePlugin | None:
"""Get a registered plugin instance by name."""
return self._plugins.get(name)
def list_discovered(self) -> list[str]:
"""List all discovered plugin names."""
return list(self._plugins.keys())
# ─── DB Status Sync ───
async def sync_db_status(self, db: AsyncSession) -> None:
"""Load plugin status records from the database."""
result = await db.execute(select(PluginModel))
self._db_status = {row.name: row for row in result.scalars().all()}
def get_db_status(self, name: str) -> PluginModel | None:
"""Get the DB status record for a plugin."""
return self._db_status.get(name)
# ─── Install / Activate / Deactivate / Uninstall ───
async def install(self, db: AsyncSession, name: str) -> PluginModel:
"""Install a plugin: run migrations and create DB record.
Idempotent: if already installed, returns existing record.
"""
plugin = self.get_plugin(name)
if plugin is None:
raise ValueError(f"Plugin '{name}' not found in registry")
# Check if already installed in DB
existing = await self._get_plugin_record(db, name)
if existing is not None:
return existing
# Run migrations
if plugin.manifest.migrations:
await self.migration_runner.run_all_migrations(
db, name, plugin.manifest.migrations
)
# Call on_install hook
await plugin.on_install(db, self._container)
# Create DB record
record = PluginModel(
name=name,
display_name=plugin.manifest.display_name,
version=plugin.manifest.version,
status="installed",
installed=True,
active=False,
)
db.add(record)
await db.flush()
self._db_status[name] = record
return record
async def activate(self, db: AsyncSession, name: str) -> PluginModel:
"""Activate a plugin: register routes, event listeners, set status=active.
Idempotent: if already active, returns existing record without error.
"""
plugin = self.get_plugin(name)
if plugin is None:
raise ValueError(f"Plugin '{name}' not found in registry")
record = await self._get_plugin_record(db, name)
if record is None:
raise ValueError(f"Plugin '{name}' is not installed — install first")
# Idempotent: already active
if record.active and record.status == "active":
return record
# Call on_activate hook (registers event listeners)
await plugin.on_activate(db, self._container, self._event_bus)
# Register routes on FastAPI app if available
if self._app is not None:
routers = plugin.get_routes()
mounted = []
for router in routers:
self._app.include_router(router)
mounted.append(router)
self._mounted_routers[name] = mounted
# Update DB record
record.status = "active"
record.active = True
await db.flush()
self._db_status[name] = record
return record
async def deactivate(self, db: AsyncSession, name: str) -> PluginModel:
"""Deactivate a plugin: unregister event listeners, set status=inactive.
Idempotent: if already inactive, returns existing record without error.
"""
plugin = self.get_plugin(name)
if plugin is None:
raise ValueError(f"Plugin '{name}' not found in registry")
record = await self._get_plugin_record(db, name)
if record is None:
raise ValueError(f"Plugin '{name}' is not installed")
# Idempotent: already inactive
if not record.active and record.status == "inactive":
return record
# Call on_deactivate hook (unregisters event listeners)
await plugin.on_deactivate(db, self._container, self._event_bus)
# Unregister routes from FastAPI app if available
if self._app is not None and name in self._mounted_routers:
mounted = self._mounted_routers.pop(name, [])
# Collect all route paths from mounted routers for removal
paths_to_remove: set[str] = set()
for router in mounted:
for route in router.routes:
if hasattr(route, "path"):
paths_to_remove.add(route.path)
# Remove matching routes from app
self._app.router.routes = [
r for r in self._app.router.routes
if not (hasattr(r, "path") and r.path in paths_to_remove)
]
# Update DB record
record.status = "inactive"
record.active = False
await db.flush()
self._db_status[name] = record
return record
async def uninstall(
self, db: AsyncSession, name: str, remove_data: bool = False
) -> PluginModel:
"""Uninstall a plugin: deactivate, optionally drop tables, remove DB record.
Args:
db: Async database session.
name: Plugin name to uninstall.
remove_data: If True, drop all plugin-created tables.
Returns:
The plugin record before deletion (for response).
Raises:
ValueError if plugin not installed.
"""
plugin = self.get_plugin(name)
if plugin is None:
raise ValueError(f"Plugin '{name}' not found in registry")
record = await self._get_plugin_record(db, name)
if record is None:
raise ValueError(f"Plugin '{name}' is not installed")
# Deactivate first if active
if record.active:
await self.deactivate(db, name)
# Refetch record after deactivate
record = await self._get_plugin_record(db, name)
if record is None:
raise ValueError(f"Plugin '{name}' disappeared during uninstall")
# Call on_uninstall hook
await plugin.on_uninstall(db, self._container)
# Optionally drop plugin tables
dropped_tables: list[str] = []
if remove_data:
dropped_tables = await self.migration_runner.drop_plugin_tables(db, name)
# Remove DB record
await db.delete(record)
await db.flush()
self._db_status.pop(name, None)
# Return a detached copy for response
record_dropped_tables = dropped_tables
record.status = "uninstalled"
record.dropped_tables = record_dropped_tables
return record
async def list_plugins(self, db: AsyncSession) -> list[dict[str, Any]]:
"""List all plugins with their current status.
Merges discovered (in-memory) plugins with installed (DB) records.
"""
result = await db.execute(select(PluginModel))
db_records = {row.name: row for row in result.scalars().all()}
plugins_list: list[dict[str, Any]] = []
for name, plugin in self._plugins.items():
record = db_records.get(name)
if record is not None:
plugins_list.append({
"name": name,
"display_name": record.display_name,
"version": record.version,
"status": record.status,
"installed": record.installed,
"active": record.active,
"description": plugin.manifest.description,
"dependencies": plugin.manifest.dependencies,
"events": plugin.manifest.events,
"migrations": plugin.manifest.migrations,
"permissions": plugin.manifest.permissions,
})
else:
plugins_list.append({
"name": name,
"display_name": plugin.manifest.display_name,
"version": plugin.version,
"status": "discovered",
"installed": False,
"active": False,
"description": plugin.manifest.description,
"dependencies": plugin.manifest.dependencies,
"events": plugin.manifest.events,
"migrations": plugin.manifest.migrations,
"permissions": plugin.manifest.permissions,
})
# Also include DB-only records (plugins that were installed but no longer discovered)
for name, record in db_records.items():
if name not in self._plugins:
plugins_list.append({
"name": name,
"display_name": record.display_name,
"version": record.version,
"status": record.status,
"installed": record.installed,
"active": record.active,
"description": "",
"dependencies": [],
"events": [],
"migrations": [],
"permissions": [],
})
return plugins_list
# ─── Internal Helpers ───
async def _get_plugin_record(self, db: AsyncSession, name: str) -> PluginModel | None:
"""Fetch a plugin record from DB by name."""
result = await db.execute(
select(PluginModel).where(PluginModel.name == name)
)
return result.scalar_one_or_none()
# Global registry instance
_registry: PluginRegistry | None = None
def get_registry() -> PluginRegistry:
"""Get the global plugin registry."""
global _registry
if _registry is None:
_registry = PluginRegistry()
return _registry
def reset_registry_for_testing() -> PluginRegistry:
"""Create a fresh registry for testing."""
global _registry
_registry = PluginRegistry()
return _registry