Initial commit: Shop Database Flask Application

Flask backend with Vue 3 frontend for shop floor machine management.
Includes database schema export for MySQL shopdb_flask database.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-01-13 16:07:34 -05:00
commit 1196de6e88
188 changed files with 19921 additions and 0 deletions

276
shopdb/plugins/__init__.py Normal file
View File

@@ -0,0 +1,276 @@
"""Plugin manager - main entry point for plugin system."""
from pathlib import Path
from typing import Dict, List, Optional
from flask import Flask
import logging
from .base import BasePlugin, PluginMeta
from .registry import PluginRegistry, PluginState
from .loader import PluginLoader
from .migrations import PluginMigrationManager
logger = logging.getLogger(__name__)
__all__ = [
'PluginManager',
'BasePlugin',
'PluginMeta',
'PluginRegistry',
'PluginState',
'plugin_manager'
]
class PluginManager:
"""
Central manager for all plugin operations.
Usage:
plugin_manager = PluginManager()
plugin_manager.init_app(app, db)
# In CLI:
plugin_manager.install_plugin('printers')
"""
def __init__(self):
self.registry: Optional[PluginRegistry] = None
self.loader: Optional[PluginLoader] = None
self.migration_manager: Optional[PluginMigrationManager] = None
self._app: Optional[Flask] = None
self._db = None
def init_app(self, app: Flask, db) -> None:
"""Initialize plugin manager with Flask app."""
self._app = app
self._db = db
# Setup paths
instance_path = Path(app.instance_path)
plugins_dir = Path(app.root_path).parent / 'plugins'
# Initialize components
self.registry = PluginRegistry(instance_path / 'plugins.json')
self.loader = PluginLoader(plugins_dir, self.registry)
self.migration_manager = PluginMigrationManager(
plugins_dir,
app.config.get('SQLALCHEMY_DATABASE_URI')
)
# Load enabled plugins
self._load_enabled_plugins()
# Store on app for access
app.extensions['plugin_manager'] = self
def _load_enabled_plugins(self) -> None:
"""Load and register all enabled plugins."""
plugins = self.loader.load_enabled_plugins(self._app, self._db)
for name, plugin in plugins.items():
self._register_plugin_components(plugin)
def _register_plugin_components(self, plugin: BasePlugin) -> None:
"""Register plugin's blueprint, models, CLI commands, etc."""
# Register blueprint
blueprint = plugin.get_blueprint()
if blueprint:
self._app.register_blueprint(
blueprint,
url_prefix=plugin.meta.api_prefix
)
logger.debug(f"Registered blueprint: {plugin.meta.api_prefix}")
# Register CLI commands
for cmd in plugin.get_cli_commands():
self._app.cli.add_command(cmd)
def discover_available(self) -> List[Dict]:
"""
Get list of all available plugins (installed or not).
Returns list of plugin info dicts.
"""
available = []
for name in self.loader.discover_plugins():
plugin_class = self.loader.load_plugin_class(name)
if plugin_class:
try:
temp = plugin_class()
meta = temp.meta
state = self.registry.get(name)
available.append({
'name': meta.name,
'version': meta.version,
'description': meta.description,
'author': meta.author,
'dependencies': meta.dependencies,
'installed': state is not None,
'enabled': state.enabled if state else False,
'installed_at': state.installed_at if state else None
})
except Exception as e:
logger.warning(f"Error inspecting plugin {name}: {e}")
return available
def install_plugin(self, name: str, run_migrations: bool = True) -> bool:
"""
Install a plugin.
Steps:
1. Verify plugin exists
2. Check dependencies
3. Run database migrations
4. Register in registry
5. Call plugin's on_install hook
"""
# Check if already installed
if self.registry.is_installed(name):
logger.warning(f"Plugin {name} is already installed")
return False
# Load plugin class
plugin_class = self.loader.load_plugin_class(name)
if not plugin_class:
logger.error(f"Plugin {name} not found")
return False
temp_plugin = plugin_class()
meta = temp_plugin.meta
# Check dependencies
for dep in meta.dependencies:
if not self.registry.is_installed(dep):
logger.error(
f"Plugin {name} requires {dep} to be installed first"
)
return False
# Run migrations
if run_migrations:
success = self.migration_manager.run_plugin_migrations(name)
if not success:
logger.error(f"Failed to run migrations for {name}")
return False
# Register plugin
self.registry.register(name, meta.version)
# Load the plugin
plugin = self.loader.load_plugin(name, self._app, self._db)
if plugin:
self._register_plugin_components(plugin)
plugin.on_install(self._app)
logger.info(f"Installed plugin: {name} v{meta.version}")
return True
def uninstall_plugin(self, name: str, remove_data: bool = False) -> bool:
"""
Uninstall a plugin.
Args:
name: Plugin name
remove_data: If True, run downgrade migrations to remove tables
"""
if not self.registry.is_installed(name):
logger.warning(f"Plugin {name} is not installed")
return False
# Check if other plugins depend on this one
for other_name in self.registry.get_enabled_plugins():
if other_name == name:
continue
other_plugin = self.loader.get_loaded_plugin(other_name)
if other_plugin and name in other_plugin.meta.dependencies:
logger.error(
f"Cannot uninstall {name}: {other_name} depends on it"
)
return False
# Get plugin instance
plugin = self.loader.get_loaded_plugin(name)
# Call on_uninstall hook
if plugin:
plugin.on_uninstall(self._app)
# Optionally remove data
if remove_data:
self.migration_manager.downgrade_plugin(name)
# Unregister
self.registry.unregister(name)
logger.info(f"Uninstalled plugin: {name}")
return True
def enable_plugin(self, name: str) -> bool:
"""Enable a disabled plugin."""
if not self.registry.is_installed(name):
logger.error(f"Plugin {name} is not installed")
return False
if self.registry.is_enabled(name):
logger.info(f"Plugin {name} is already enabled")
return True
# Check dependencies are enabled
plugin_class = self.loader.load_plugin_class(name)
if plugin_class:
temp = plugin_class()
for dep in temp.meta.dependencies:
if not self.registry.is_enabled(dep):
logger.error(f"Cannot enable {name}: {dep} is not enabled")
return False
self.registry.enable(name)
# Load the plugin
plugin = self.loader.load_plugin(name, self._app, self._db)
if plugin:
self._register_plugin_components(plugin)
plugin.on_enable(self._app)
logger.info(f"Enabled plugin: {name}")
return True
def disable_plugin(self, name: str) -> bool:
"""Disable an enabled plugin."""
if not self.registry.is_enabled(name):
logger.info(f"Plugin {name} is already disabled")
return True
# Check if other plugins depend on this one
for other_name in self.registry.get_enabled_plugins():
if other_name == name:
continue
other_plugin = self.loader.get_loaded_plugin(other_name)
if other_plugin and name in other_plugin.meta.dependencies:
logger.error(
f"Cannot disable {name}: {other_name} depends on it"
)
return False
plugin = self.loader.get_loaded_plugin(name)
if plugin:
plugin.on_disable(self._app)
self.registry.disable(name)
logger.info(f"Disabled plugin: {name}")
return True
def get_plugin(self, name: str) -> Optional[BasePlugin]:
"""Get a loaded plugin instance."""
return self.loader.get_loaded_plugin(name)
def get_all_plugins(self) -> Dict[str, BasePlugin]:
"""Get all loaded plugins."""
return self.loader.get_all_loaded()
# Global plugin manager instance
plugin_manager = PluginManager()

122
shopdb/plugins/base.py Normal file
View File

@@ -0,0 +1,122 @@
"""Base plugin class that all plugins must inherit from."""
from abc import ABC, abstractmethod
from typing import List, Dict, Optional, Type
from dataclasses import dataclass, field
from flask import Flask, Blueprint
@dataclass
class PluginMeta:
"""Plugin metadata container."""
name: str
version: str
description: str
author: str = ""
dependencies: List[str] = field(default_factory=list)
core_version: str = ">=1.0.0"
api_prefix: str = None
def __post_init__(self):
if self.api_prefix is None:
self.api_prefix = f"/api/{self.name.replace('_', '-')}"
class BasePlugin(ABC):
"""
Base class for all ShopDB plugins.
Plugins must implement:
- meta: PluginMeta instance
- get_blueprint(): Return Flask Blueprint for API routes
- get_models(): Return list of SQLAlchemy model classes
Optionally implement:
- init_app(app, db): Custom initialization
- get_cli_commands(): Return Click commands
- get_services(): Return service classes
- on_install(): Called when plugin is installed
- on_uninstall(): Called when plugin is uninstalled
- on_enable(): Called when plugin is enabled
- on_disable(): Called when plugin is disabled
"""
@property
@abstractmethod
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
pass
@abstractmethod
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
pass
@abstractmethod
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
pass
def init_app(self, app: Flask, db) -> None:
"""
Initialize plugin with Flask app.
Override for custom initialization.
"""
pass
def get_cli_commands(self) -> List:
"""Return list of Click command groups/commands."""
return []
def get_services(self) -> Dict[str, Type]:
"""Return dict of service name -> service class."""
return {}
def get_event_handlers(self) -> Dict[str, callable]:
"""Return dict of event name -> handler function."""
return {}
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed via CLI."""
pass
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled via CLI."""
pass
def on_enable(self, app: Flask) -> None:
"""Called when plugin is enabled."""
pass
def on_disable(self, app: Flask) -> None:
"""Called when plugin is disabled."""
pass
def get_dashboard_widgets(self) -> List[Dict]:
"""
Return dashboard widget definitions.
Each widget: {
'name': str,
'component': str, # Frontend component name
'endpoint': str, # API endpoint for data
'size': str, # 'small', 'medium', 'large'
'position': int # Order on dashboard
}
"""
return []
def get_navigation_items(self) -> List[Dict]:
"""
Return navigation menu items.
Each item: {
'name': str,
'icon': str,
'route': str,
'position': int,
'children': []
}
"""
return []

203
shopdb/plugins/cli.py Normal file
View File

@@ -0,0 +1,203 @@
"""Flask CLI commands for plugin management."""
import click
from flask import current_app
from flask.cli import with_appcontext
@click.group('plugin')
def plugin_cli():
"""Plugin management commands."""
pass
@plugin_cli.command('list')
@with_appcontext
def list_plugins():
"""List all available plugins."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
return
plugins = pm.discover_available()
if not plugins:
click.echo("No plugins found in plugins directory.")
return
# Format output
click.echo("")
click.echo(click.style("Available Plugins:", fg='cyan', bold=True))
click.echo("-" * 60)
for p in plugins:
if p['enabled']:
status = click.style("[Enabled]", fg='green')
elif p['installed']:
status = click.style("[Disabled]", fg='yellow')
else:
status = click.style("[Available]", fg='white')
click.echo(f" {p['name']:20} v{p['version']:10} {status}")
if p['description']:
click.echo(f" {p['description'][:55]}...")
if p['dependencies']:
deps = ', '.join(p['dependencies'])
click.echo(f" Dependencies: {deps}")
click.echo("")
@plugin_cli.command('install')
@click.argument('name')
@click.option('--skip-migrations', is_flag=True, help='Skip database migrations')
@with_appcontext
def install_plugin(name: str, skip_migrations: bool):
"""
Install a plugin.
Usage: flask plugin install printers
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
click.echo(f"Installing plugin: {name}")
if pm.install_plugin(name, run_migrations=not skip_migrations):
click.echo(click.style(f"Successfully installed {name}", fg='green'))
else:
click.echo(click.style(f"Failed to install {name}", fg='red'))
raise SystemExit(1)
@plugin_cli.command('uninstall')
@click.argument('name')
@click.option('--remove-data', is_flag=True, help='Remove plugin database tables')
@click.confirmation_option(prompt='Are you sure you want to uninstall this plugin?')
@with_appcontext
def uninstall_plugin(name: str, remove_data: bool):
"""
Uninstall a plugin.
Usage: flask plugin uninstall printers
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
click.echo(f"Uninstalling plugin: {name}")
if pm.uninstall_plugin(name, remove_data=remove_data):
click.echo(click.style(f"Successfully uninstalled {name}", fg='green'))
else:
click.echo(click.style(f"Failed to uninstall {name}", fg='red'))
raise SystemExit(1)
@plugin_cli.command('enable')
@click.argument('name')
@with_appcontext
def enable_plugin(name: str):
"""Enable a disabled plugin."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
if pm.enable_plugin(name):
click.echo(click.style(f"Enabled {name}", fg='green'))
else:
click.echo(click.style(f"Failed to enable {name}", fg='red'))
raise SystemExit(1)
@plugin_cli.command('disable')
@click.argument('name')
@with_appcontext
def disable_plugin(name: str):
"""Disable an enabled plugin."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
if pm.disable_plugin(name):
click.echo(click.style(f"Disabled {name}", fg='green'))
else:
click.echo(click.style(f"Failed to disable {name}", fg='red'))
raise SystemExit(1)
@plugin_cli.command('info')
@click.argument('name')
@with_appcontext
def plugin_info(name: str):
"""Show detailed information about a plugin."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
plugin_class = pm.loader.load_plugin_class(name)
if not plugin_class:
click.echo(click.style(f"Plugin {name} not found", fg='red'))
raise SystemExit(1)
try:
temp = plugin_class()
meta = temp.meta
except Exception as e:
click.echo(click.style(f"Error loading plugin: {e}", fg='red'))
raise SystemExit(1)
state = pm.registry.get(name)
click.echo("")
click.echo("=" * 50)
click.echo(click.style(f"Plugin: {meta.name}", fg='cyan', bold=True))
click.echo("=" * 50)
click.echo(f"Version: {meta.version}")
click.echo(f"Description: {meta.description}")
click.echo(f"Author: {meta.author or 'Unknown'}")
click.echo(f"API Prefix: {meta.api_prefix}")
click.echo(f"Dependencies: {', '.join(meta.dependencies) or 'None'}")
click.echo(f"Core Version: {meta.core_version}")
click.echo("")
if state:
status = click.style('Enabled', fg='green') if state.enabled else click.style('Disabled', fg='yellow')
click.echo(f"Status: {status}")
click.echo(f"Installed: {state.installed_at}")
click.echo(f"Migrations: {len(state.migrations_applied)} applied")
else:
click.echo(f"Status: {click.style('Not installed', fg='white')}")
click.echo("")
@plugin_cli.command('migrate')
@click.argument('name')
@click.option('--revision', default='head', help='Target revision')
@with_appcontext
def migrate_plugin(name: str, revision: str):
"""Run migrations for a specific plugin."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
if not pm.registry.is_installed(name):
click.echo(click.style(f"Plugin {name} is not installed", fg='red'))
raise SystemExit(1)
click.echo(f"Running migrations for {name}...")
if pm.migration_manager.run_plugin_migrations(name, revision):
click.echo(click.style("Migrations completed", fg='green'))
else:
click.echo(click.style("Migration failed", fg='red'))
raise SystemExit(1)

174
shopdb/plugins/loader.py Normal file
View File

@@ -0,0 +1,174 @@
"""Plugin discovery and loading."""
import importlib
import importlib.util
from pathlib import Path
from typing import Dict, List, Type, Optional
from flask import Flask
import logging
from .base import BasePlugin
from .registry import PluginRegistry
logger = logging.getLogger(__name__)
class PluginLoader:
"""
Discovers and loads plugins from the plugins directory.
"""
def __init__(self, plugins_dir: Path, registry: PluginRegistry):
self.plugins_dir = plugins_dir
self.registry = registry
self._loaded_plugins: Dict[str, BasePlugin] = {}
self._plugin_classes: Dict[str, Type[BasePlugin]] = {}
def discover_plugins(self) -> List[str]:
"""
Discover available plugins in plugins directory.
Returns list of plugin names.
"""
available = []
if not self.plugins_dir.exists():
return available
for item in self.plugins_dir.iterdir():
if item.is_dir() and (item / 'plugin.py').exists():
available.append(item.name)
elif item.is_dir() and (item / '__init__.py').exists():
# Check for plugin.py in package
if (item / 'plugin.py').exists():
available.append(item.name)
return available
def load_plugin_class(self, name: str) -> Optional[Type[BasePlugin]]:
"""
Load plugin class without instantiating.
Used for inspection before installation.
"""
if name in self._plugin_classes:
return self._plugin_classes[name]
plugin_dir = self.plugins_dir / name
plugin_module_path = plugin_dir / 'plugin.py'
if not plugin_module_path.exists():
logger.error(f"Plugin {name} not found: {plugin_module_path}")
return None
try:
# Import the plugin module
spec = importlib.util.spec_from_file_location(
f"plugins.{name}.plugin",
plugin_module_path
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Find the plugin class
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (isinstance(attr, type) and
issubclass(attr, BasePlugin) and
attr is not BasePlugin):
self._plugin_classes[name] = attr
return attr
logger.error(f"No BasePlugin subclass found in {name}")
return None
except Exception as e:
logger.error(f"Error loading plugin {name}: {e}")
return None
def load_plugin(self, name: str, app: Flask, db) -> Optional[BasePlugin]:
"""
Load and instantiate a plugin.
"""
if name in self._loaded_plugins:
return self._loaded_plugins[name]
plugin_class = self.load_plugin_class(name)
if not plugin_class:
return None
try:
# Instantiate plugin
plugin = plugin_class()
# Check dependencies
for dep in plugin.meta.dependencies:
if not self.registry.is_enabled(dep):
logger.error(
f"Plugin {name} requires {dep} which is not enabled"
)
return None
# Initialize plugin
plugin.init_app(app, db)
self._loaded_plugins[name] = plugin
return plugin
except Exception as e:
logger.error(f"Error instantiating plugin {name}: {e}")
return None
def load_enabled_plugins(self, app: Flask, db) -> Dict[str, BasePlugin]:
"""
Load all enabled plugins.
Returns dict of name -> plugin instance.
"""
loaded = {}
# Sort by dependencies (simple topological sort)
enabled = self.registry.get_enabled_plugins()
sorted_plugins = self._sort_by_dependencies(enabled)
for name in sorted_plugins:
plugin = self.load_plugin(name, app, db)
if plugin:
loaded[name] = plugin
logger.info(f"Loaded plugin: {name} v{plugin.meta.version}")
else:
logger.warning(f"Failed to load plugin: {name}")
return loaded
def _sort_by_dependencies(self, plugin_names: List[str]) -> List[str]:
"""Sort plugins so dependencies come first."""
sorted_list = []
visited = set()
def visit(name):
if name in visited:
return
visited.add(name)
plugin_class = self.load_plugin_class(name)
if plugin_class:
# Create temporary instance to get meta
try:
temp = plugin_class()
for dep in temp.meta.dependencies:
if dep in plugin_names:
visit(dep)
except Exception:
pass
sorted_list.append(name)
for name in plugin_names:
visit(name)
return sorted_list
def get_loaded_plugin(self, name: str) -> Optional[BasePlugin]:
"""Get an already loaded plugin."""
return self._loaded_plugins.get(name)
def get_all_loaded(self) -> Dict[str, BasePlugin]:
"""Get all loaded plugins."""
return self._loaded_plugins.copy()

View File

@@ -0,0 +1,173 @@
"""Plugin migration management using Alembic."""
from pathlib import Path
from typing import Optional
import logging
import subprocess
import sys
logger = logging.getLogger(__name__)
class PluginMigrationManager:
"""
Manages database migrations for plugins.
Each plugin has its own migrations directory.
"""
def __init__(self, plugins_dir: Path, database_url: str):
self.plugins_dir = plugins_dir
self.database_url = database_url
def get_migrations_dir(self, plugin_name: str) -> Optional[Path]:
"""Get migrations directory for a plugin."""
migrations_dir = self.plugins_dir / plugin_name / 'migrations'
if migrations_dir.exists():
return migrations_dir
return None
def run_plugin_migrations(
self,
plugin_name: str,
revision: str = 'head'
) -> bool:
"""
Run migrations for a plugin.
Uses flask db upgrade with the plugin's migrations directory.
"""
migrations_dir = self.get_migrations_dir(plugin_name)
if not migrations_dir:
logger.info(f"No migrations directory for plugin {plugin_name}")
return True # No migrations to run
try:
# Use alembic directly with plugin's migrations
from alembic.config import Config
from alembic import command
config = Config()
config.set_main_option('script_location', str(migrations_dir))
config.set_main_option('sqlalchemy.url', self.database_url)
# Use plugin-specific version table
config.set_main_option(
'version_table',
f'alembic_version_{plugin_name}'
)
command.upgrade(config, revision)
logger.info(f"Migrations completed for {plugin_name}")
return True
except ImportError:
# Fallback to subprocess if alembic not available in context
logger.warning("Using subprocess for migrations")
return self._run_migrations_subprocess(plugin_name, revision)
except Exception as e:
logger.error(f"Migration failed for {plugin_name}: {e}")
return False
def _run_migrations_subprocess(
self,
plugin_name: str,
revision: str = 'head'
) -> bool:
"""Run migrations via subprocess as fallback."""
migrations_dir = self.get_migrations_dir(plugin_name)
if not migrations_dir:
return True
try:
result = subprocess.run(
[
sys.executable, '-m', 'alembic',
'-c', str(migrations_dir / 'alembic.ini'),
'upgrade', revision
],
capture_output=True,
text=True,
env={
**dict(__import__('os').environ),
'DATABASE_URL': self.database_url
}
)
if result.returncode != 0:
logger.error(f"Migration error: {result.stderr}")
return False
return True
except Exception as e:
logger.error(f"Migration subprocess failed: {e}")
return False
def downgrade_plugin(
self,
plugin_name: str,
revision: str = 'base'
) -> bool:
"""
Downgrade/rollback plugin migrations.
"""
migrations_dir = self.get_migrations_dir(plugin_name)
if not migrations_dir:
return True
try:
from alembic.config import Config
from alembic import command
config = Config()
config.set_main_option('script_location', str(migrations_dir))
config.set_main_option('sqlalchemy.url', self.database_url)
config.set_main_option(
'version_table',
f'alembic_version_{plugin_name}'
)
command.downgrade(config, revision)
logger.info(f"Downgrade completed for {plugin_name}")
return True
except Exception as e:
logger.error(f"Downgrade failed for {plugin_name}: {e}")
return False
def get_current_revision(self, plugin_name: str) -> Optional[str]:
"""Get current migration revision for a plugin."""
migrations_dir = self.get_migrations_dir(plugin_name)
if not migrations_dir:
return None
try:
from alembic.config import Config
from alembic.script import ScriptDirectory
config = Config()
config.set_main_option('script_location', str(migrations_dir))
script = ScriptDirectory.from_config(config)
return script.get_current_head()
except Exception:
return None
def has_pending_migrations(self, plugin_name: str) -> bool:
"""Check if plugin has pending migrations."""
# Simplified check - would need DB connection for full check
migrations_dir = self.get_migrations_dir(plugin_name)
if not migrations_dir:
return False
versions_dir = migrations_dir / 'versions'
if not versions_dir.exists():
return False
# Check if there are any migration files
migration_files = list(versions_dir.glob('*.py'))
return len(migration_files) > 0

121
shopdb/plugins/registry.py Normal file
View File

@@ -0,0 +1,121 @@
"""Plugin registry for tracking installed and enabled plugins."""
import json
from pathlib import Path
from typing import Dict, List, Optional
from dataclasses import dataclass, field, asdict
from datetime import datetime
@dataclass
class PluginState:
"""Persistent state for a plugin."""
name: str
version: str
installed_at: str
enabled: bool = True
migrations_applied: List[str] = field(default_factory=list)
config: Dict = field(default_factory=dict)
class PluginRegistry:
"""
Manages plugin state persistence.
Stores state in JSON file in instance folder.
"""
def __init__(self, state_file: Path):
self.state_file = state_file
self._plugins: Dict[str, PluginState] = {}
self._load()
def _load(self) -> None:
"""Load registry from file."""
if self.state_file.exists():
try:
with open(self.state_file, 'r') as f:
data = json.load(f)
for name, state_data in data.get('plugins', {}).items():
self._plugins[name] = PluginState(**state_data)
except (json.JSONDecodeError, TypeError):
# Corrupted file, start fresh
self._plugins = {}
def _save(self) -> None:
"""Save registry to file."""
self.state_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.state_file, 'w') as f:
json.dump({
'plugins': {
name: asdict(state)
for name, state in self._plugins.items()
}
}, f, indent=2)
def register(self, name: str, version: str) -> PluginState:
"""Register a newly installed plugin."""
state = PluginState(
name=name,
version=version,
installed_at=datetime.utcnow().isoformat(),
enabled=True
)
self._plugins[name] = state
self._save()
return state
def unregister(self, name: str) -> None:
"""Remove plugin from registry."""
if name in self._plugins:
del self._plugins[name]
self._save()
def get(self, name: str) -> Optional[PluginState]:
"""Get plugin state."""
return self._plugins.get(name)
def is_installed(self, name: str) -> bool:
"""Check if plugin is installed."""
return name in self._plugins
def is_enabled(self, name: str) -> bool:
"""Check if plugin is enabled."""
state = self._plugins.get(name)
return state.enabled if state else False
def enable(self, name: str) -> None:
"""Enable a plugin."""
if name in self._plugins:
self._plugins[name].enabled = True
self._save()
def disable(self, name: str) -> None:
"""Disable a plugin."""
if name in self._plugins:
self._plugins[name].enabled = False
self._save()
def get_enabled_plugins(self) -> List[str]:
"""Get list of enabled plugin names."""
return [
name for name, state in self._plugins.items()
if state.enabled
]
def add_migration(self, name: str, revision: str) -> None:
"""Record that a migration was applied."""
if name in self._plugins:
if revision not in self._plugins[name].migrations_applied:
self._plugins[name].migrations_applied.append(revision)
self._save()
def get_all(self) -> Dict[str, PluginState]:
"""Get all registered plugins."""
return self._plugins.copy()
def update_config(self, name: str, config: Dict) -> None:
"""Update plugin configuration."""
if name in self._plugins:
self._plugins[name].config.update(config)
self._save()