Phase 3 (part 1): manifest-first loader, shopdb.api namespace, auto-register
Hardens the plugin framework so sister-site adoption is safe. Loader rewrite (shopdb/plugins/loader.py): - Reads manifest.json directly. Dependency sort and version checks no longer instantiate plugin classes (avoids __init__ side effects). - Fail-loud policy: in dev/test (DEBUG or TESTING true), plugin errors re-raise. In production, errors log with full context and the plugin is excluded from registration. Framework keeps booting. - Contract-version range check via packaging.SpecifierSet. Plugin's manifest.core_version must include the framework's __contract_version__ or load fails per the policy above. - Manifest validation: required fields (name, version, description), name matches directory, JSON parseable. Exceptions (shopdb/exceptions.py): - PluginNotFoundError, PluginContractError, PluginVersionError, PluginDependencyError. Specific types replace generic Exception swallowing. Auto-register core blueprints (shopdb/__init__.py): - CORE_BLUEPRINT_NAMES tuple drives registration. Adding a core resource is one entry, not three lines (import + register call). - Replaces 27 hand-coded register_blueprint calls. - Asserts each blueprint is exported by shopdb.core.api at boot. Public API namespace (shopdb/api/__init__.py): - audit_log: thin wrapper over AuditLog.log() with stable signature. - resolve_asset_position: implements ADR-001 position resolution (asset > related > location). Asset.mapx/mapy and AssetRelationship.inheritsposition columns are part of the locked contract surface but not yet in models; helper degrades gracefully to location-only fallback until the migration lands. BasePlugin helpers (shopdb/plugins/base.py): - get_setting(key, default), set_setting(key, value, ...). Settings namespaced as plugin.<pluginname>.<key> so two plugins can use the same key without colliding. Manifest version compatibility (plugins/*/manifest.json): - Bumped core_version from ">=1.0.0" to ">=0.1.0,<1.0.0" so all bundled plugins satisfy the new range check. Contract version bump (shopdb/__init__.py): - 0.1.0 -> 0.2.0. Additive surface change (Setting helpers, shopdb.api namespace) per ADR-002 minor-bump rules. Tests (tests/test_plugin_loader.py, tests/test_api_namespace.py): - 13 loader tests: manifest validation failures, version range checks, plugin.py import errors, strict-vs-isolate behavior under TESTING vs production-like config, manifest-first dependency sort. - 8 api-namespace tests: audit_log roundtrip, resolve position fallback chain, plugin.get_setting/set_setting roundtrip with per-plugin namespacing. Test count: 66 -> 87 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,129 +1,232 @@
|
||||
"""Plugin discovery and loading."""
|
||||
"""Plugin discovery and loading.
|
||||
|
||||
The loader reads each plugin's manifest.json before instantiating the
|
||||
plugin class. Dependency sorting and contract-version compatibility
|
||||
checks operate on manifests, not plugin instances. The plugin class is
|
||||
imported and instantiated only after the manifest passes validation.
|
||||
|
||||
Failure policy (per the enforcing-plugin-contract skill):
|
||||
- In dev/test (app.config.DEBUG or TESTING true): re-raise the original
|
||||
exception so failures are loud.
|
||||
- In production: log the failure with full context and exclude the
|
||||
plugin from registration. The framework keeps booting.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Type, Optional
|
||||
|
||||
from flask import Flask
|
||||
import logging
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
from packaging.version import Version, InvalidVersion
|
||||
|
||||
from .base import BasePlugin
|
||||
from .registry import PluginRegistry
|
||||
from ..exceptions import (
|
||||
PluginError,
|
||||
PluginNotFoundError,
|
||||
PluginContractError,
|
||||
PluginVersionError,
|
||||
PluginDependencyError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginLoader:
|
||||
"""
|
||||
Discovers and loads plugins from the plugins directory.
|
||||
"""
|
||||
"""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]] = {}
|
||||
self._manifests: Dict[str, dict] = {}
|
||||
|
||||
def _is_strict_mode(self, app: Optional[Flask]) -> bool:
|
||||
"""Return True if loader should re-raise instead of isolating failures."""
|
||||
if app is None:
|
||||
return True
|
||||
return bool(app.config.get('DEBUG') or app.config.get('TESTING'))
|
||||
|
||||
def _handle_failure(self, app: Optional[Flask], exc: Exception, name: str) -> None:
|
||||
"""In strict mode re-raise; in production log with context and continue."""
|
||||
if self._is_strict_mode(app):
|
||||
raise exc
|
||||
logger.exception(f'Plugin {name} failed to load: {exc}')
|
||||
|
||||
def discover_plugins(self) -> List[str]:
|
||||
"""
|
||||
Discover available plugins in plugins directory.
|
||||
Returns list of plugin names.
|
||||
"""
|
||||
"""Discover available plugins in plugins directory."""
|
||||
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():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
if (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]]:
|
||||
def load_manifest(self, name: str) -> dict:
|
||||
"""Load the plugin's manifest.json.
|
||||
|
||||
Raises PluginNotFoundError if the manifest does not exist or is
|
||||
unparseable. The result is cached.
|
||||
"""
|
||||
Load plugin class without instantiating.
|
||||
Used for inspection before installation.
|
||||
if name in self._manifests:
|
||||
return self._manifests[name]
|
||||
|
||||
manifest_path = self.plugins_dir / name / 'manifest.json'
|
||||
if not manifest_path.exists():
|
||||
raise PluginNotFoundError(
|
||||
f'Plugin {name} has no manifest.json at {manifest_path}',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
try:
|
||||
with open(manifest_path) as f:
|
||||
manifest = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} has invalid manifest.json: {e}',
|
||||
plugin_name=name,
|
||||
) from e
|
||||
|
||||
for required in ('name', 'version', 'description'):
|
||||
if not manifest.get(required):
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} manifest.json missing required field "{required}"',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
if manifest['name'] != name:
|
||||
raise PluginContractError(
|
||||
f'Plugin directory "{name}" does not match manifest name "{manifest["name"]}"',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
self._manifests[name] = manifest
|
||||
return manifest
|
||||
|
||||
def check_contract_version(self, name: str, contract_version: str) -> None:
|
||||
"""Verify the plugin's core_version range includes contract_version.
|
||||
|
||||
Raises PluginVersionError on mismatch.
|
||||
"""
|
||||
manifest = self.load_manifest(name)
|
||||
core_version_spec = manifest.get('core_version', '')
|
||||
|
||||
if not core_version_spec:
|
||||
return
|
||||
|
||||
try:
|
||||
specifier = SpecifierSet(core_version_spec)
|
||||
framework_version = Version(contract_version)
|
||||
except (InvalidSpecifier, InvalidVersion) as e:
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} has invalid version specifier "{core_version_spec}": {e}',
|
||||
plugin_name=name,
|
||||
) from e
|
||||
|
||||
if framework_version not in specifier:
|
||||
raise PluginVersionError(
|
||||
f'Plugin {name} requires core_version {core_version_spec} '
|
||||
f'but framework is at {contract_version}',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
def load_plugin_class(self, name: str) -> Type[BasePlugin]:
|
||||
"""Import the plugin's plugin.py and return the BasePlugin subclass.
|
||||
|
||||
Raises PluginNotFoundError if plugin.py is missing.
|
||||
Raises PluginContractError if the module has no BasePlugin subclass
|
||||
or import fails.
|
||||
"""
|
||||
if name in self._plugin_classes:
|
||||
return self._plugin_classes[name]
|
||||
|
||||
plugin_dir = self.plugins_dir / name
|
||||
plugin_module_path = plugin_dir / 'plugin.py'
|
||||
|
||||
plugin_module_path = self.plugins_dir / name / 'plugin.py'
|
||||
if not plugin_module_path.exists():
|
||||
logger.error(f"Plugin {name} not found: {plugin_module_path}")
|
||||
return None
|
||||
raise PluginNotFoundError(
|
||||
f'Plugin {name} plugin.py not found at {plugin_module_path}',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
try:
|
||||
# Import the plugin module
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"plugins.{name}.plugin",
|
||||
plugin_module_path
|
||||
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
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} import failed: {e}',
|
||||
plugin_name=name,
|
||||
) from e
|
||||
|
||||
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
|
||||
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} module has no BasePlugin subclass',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
def load_plugin(self, name: str, app: Flask, db) -> Optional[BasePlugin]:
|
||||
"""
|
||||
Load and instantiate a plugin.
|
||||
"""Load and instantiate a plugin.
|
||||
|
||||
Returns the plugin instance on success. In strict mode (dev/test),
|
||||
any failure raises. In production, returns None and logs the
|
||||
failure with full context.
|
||||
"""
|
||||
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()
|
||||
from shopdb import __contract_version__
|
||||
|
||||
# Check dependencies
|
||||
for dep in plugin.meta.dependencies:
|
||||
manifest = self.load_manifest(name)
|
||||
self.check_contract_version(name, __contract_version__)
|
||||
|
||||
for dep in manifest.get('dependencies', []):
|
||||
if not self.registry.is_enabled(dep):
|
||||
logger.error(
|
||||
f"Plugin {name} requires {dep} which is not enabled"
|
||||
raise PluginDependencyError(
|
||||
f'Plugin {name} requires {dep} which is not enabled',
|
||||
plugin_name=name,
|
||||
)
|
||||
return None
|
||||
|
||||
# Initialize plugin
|
||||
plugin_class = self.load_plugin_class(name)
|
||||
plugin = plugin_class()
|
||||
plugin.init_app(app, db)
|
||||
|
||||
self._loaded_plugins[name] = plugin
|
||||
return plugin
|
||||
|
||||
except PluginError as e:
|
||||
self._handle_failure(app, e, name)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error instantiating plugin {name}: {e}")
|
||||
wrapped = PluginError(
|
||||
f'Unexpected failure loading plugin {name}: {e}',
|
||||
plugin_name=name,
|
||||
)
|
||||
self._handle_failure(app, wrapped, name)
|
||||
return None
|
||||
|
||||
def load_enabled_plugins(self, app: Flask, db) -> Dict[str, BasePlugin]:
|
||||
"""
|
||||
Load all enabled plugins.
|
||||
Returns dict of name -> plugin instance.
|
||||
"""
|
||||
"""Load all enabled plugins in dependency order."""
|
||||
loaded = {}
|
||||
|
||||
# Sort by dependencies (simple topological sort)
|
||||
enabled = self.registry.get_enabled_plugins()
|
||||
sorted_plugins = self._sort_by_dependencies(enabled)
|
||||
|
||||
@@ -131,14 +234,16 @@ class PluginLoader:
|
||||
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}")
|
||||
logger.info(f'Loaded plugin: {name} v{plugin.meta.version}')
|
||||
|
||||
return loaded
|
||||
|
||||
def _sort_by_dependencies(self, plugin_names: List[str]) -> List[str]:
|
||||
"""Sort plugins so dependencies come first."""
|
||||
"""Sort plugins so dependencies come first.
|
||||
|
||||
Reads dependencies from manifest.json directly; does not
|
||||
instantiate plugin classes during sort.
|
||||
"""
|
||||
sorted_list = []
|
||||
visited = set()
|
||||
|
||||
@@ -147,16 +252,13 @@ class PluginLoader:
|
||||
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
|
||||
try:
|
||||
manifest = self.load_manifest(name)
|
||||
for dep in manifest.get('dependencies', []):
|
||||
if dep in plugin_names:
|
||||
visit(dep)
|
||||
except PluginError:
|
||||
pass
|
||||
|
||||
sorted_list.append(name)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user