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:
cproudlock
2026-05-08 16:15:28 -04:00
parent 5fefb53bca
commit 6f085a175d
14 changed files with 757 additions and 130 deletions

View File

@@ -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)