Phase 2: define plugin contract surface and add compliance tests
Locks the public surface plugin authors at sister sites depend on.
Contract version (shopdb/__init__.py):
- __contract_version__ = '0.1.0'. Per ADR-002, plugins declare a
compatible range in manifest.json `core_version`. Pre-1.0 signals
the contract is still settling; sister sites should pin tightly.
BasePlugin hook changes (shopdb/plugins/base.py):
- Add get_collector_schema() per ADR-006. Returns JSON Schema (with
identityfield + fields) describing the payload of an external
collector pushing to /api/collector/<pluginname>. Defaults to None
(no auto-registered endpoint).
- Remove get_event_handlers(). Event bus deferred indefinitely per
ADR-001 (no real use case yet; add via new ADR if it appears).
Hook reference (docs/PLUGIN-HOOKS.md):
- Canonical reference for the contract: required hooks (meta,
get_blueprint, get_models), optional hooks (init_app,
get_cli_commands, get_services, get_dashboard_widgets,
get_navigation_items, get_searchable_fields, get_collector_schema),
lifecycle hooks (on_install, on_uninstall, on_enable, on_disable),
helpers exposed in shopdb.api (audit_log, Setting,
resolve_asset_position).
- Versioning rules + change-classification guidance.
Compliance tests (tests/test_plugin_contract.py):
- 8 distinct contract assertions parametrized over 6 bundled plugins
(computers, equipment, network, notifications, printers, usb).
- Asserts: subclasses BasePlugin; manifest has required fields; meta
returns valid PluginMeta; get_blueprint returns Blueprint or None;
get_models returns model classes; get_collector_schema returns
None or {identityfield, fields}; get_navigation_items and
get_searchable_fields return list.
- Plus 3 framework-level: __contract_version__ is valid semver,
get_event_handlers absent, get_collector_schema present.
Test count: 15 -> 66 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
293
docs/PLUGIN-HOOKS.md
Normal file
293
docs/PLUGIN-HOOKS.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Plugin Hooks Reference
|
||||
|
||||
This is the canonical reference for the shopdb-flask plugin contract. Plugin authors implement `BasePlugin` and override the hooks they care about. Hooks marked `required` must be implemented; hooks marked `optional` have sensible defaults and can be left alone.
|
||||
|
||||
The contract is locked in [ADR-001](../migrations/adr/ADR-001-asset-as-platform-contract.md) and versioned per [ADR-002](../migrations/adr/ADR-002-plugin-versioning.md).
|
||||
|
||||
## Contract version
|
||||
|
||||
The framework declares its contract version in `shopdb/__init__.py`:
|
||||
|
||||
```python
|
||||
__contract_version__ = '0.1.0'
|
||||
```
|
||||
|
||||
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "yourplugin",
|
||||
"version": "1.0.0",
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"dependencies": []
|
||||
}
|
||||
```
|
||||
|
||||
The plugin loader checks this at load time and refuses to load plugins outside the supported range.
|
||||
|
||||
## Plugin metadata
|
||||
|
||||
Each plugin ships a `manifest.json`. The dataclass `PluginMeta` is constructed from it.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "computers",
|
||||
"version": "1.0.0",
|
||||
"description": "Tracks shop-floor PCs and engineering workstations",
|
||||
"author": "shopdb-flask",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/computers"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `name` | Yes | Lowercase concatenated, no underscores or dashes |
|
||||
| `version` | Yes | Plugin's own semver |
|
||||
| `description` | Yes | One sentence |
|
||||
| `dependencies` | No | List of plugin names that must load first |
|
||||
| `core_version` | Yes | Range of framework `__contract_version__` this plugin supports |
|
||||
| `api_prefix` | No | Defaults to `/api/<name>` |
|
||||
|
||||
## Required hooks
|
||||
|
||||
### `meta` -> `PluginMeta`
|
||||
|
||||
Returns the plugin's metadata. Convention is to construct from `manifest.json`:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
import json
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
|
||||
class ComputersPlugin(BasePlugin):
|
||||
def __init__(self):
|
||||
manifestpath = Path(__file__).parent / 'manifest.json'
|
||||
with open(manifestpath) as f:
|
||||
self._manifest = json.load(f)
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
return PluginMeta(
|
||||
name=self._manifest['name'],
|
||||
version=self._manifest['version'],
|
||||
description=self._manifest['description'],
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=0.1.0'),
|
||||
api_prefix=self._manifest.get('api_prefix'),
|
||||
)
|
||||
```
|
||||
|
||||
### `get_blueprint() -> Optional[Blueprint]`
|
||||
|
||||
Returns a Flask Blueprint with the plugin's API routes, or `None` if the plugin has no HTTP routes. The loader registers the blueprint at the `api_prefix` from the manifest.
|
||||
|
||||
```python
|
||||
from flask import Blueprint
|
||||
from .api import computers_bp
|
||||
|
||||
class ComputersPlugin(BasePlugin):
|
||||
def get_blueprint(self):
|
||||
return computers_bp
|
||||
```
|
||||
|
||||
### `get_models() -> List[Type]`
|
||||
|
||||
Returns the SQLAlchemy model classes the plugin defines. Used by the migration runner and admin tooling.
|
||||
|
||||
```python
|
||||
from .models import Computer, ComputerSoftware
|
||||
|
||||
class ComputersPlugin(BasePlugin):
|
||||
def get_models(self):
|
||||
return [Computer, ComputerSoftware]
|
||||
```
|
||||
|
||||
## Optional hooks
|
||||
|
||||
### `init_app(app, db) -> None`
|
||||
|
||||
Custom initialization. Called by the loader after the blueprint is registered and models are known. Use for Marshmallow schema registration, Caching configuration, secondary blueprint registration, or anything else the plugin needs.
|
||||
|
||||
```python
|
||||
class PrintersPlugin(BasePlugin):
|
||||
def init_app(self, app, db):
|
||||
from .api import printers_legacy_bp
|
||||
app.register_blueprint(printers_legacy_bp, url_prefix='/api/printers/legacy')
|
||||
```
|
||||
|
||||
### `get_cli_commands() -> List`
|
||||
|
||||
Returns a list of Click commands or command groups to register on the Flask CLI.
|
||||
|
||||
```python
|
||||
import click
|
||||
|
||||
@click.group()
|
||||
def computers_cli():
|
||||
pass
|
||||
|
||||
@computers_cli.command()
|
||||
def reset_computers():
|
||||
"""Reset all computer status flags."""
|
||||
...
|
||||
|
||||
class ComputersPlugin(BasePlugin):
|
||||
def get_cli_commands(self):
|
||||
return [computers_cli]
|
||||
```
|
||||
|
||||
### `get_services() -> Dict[str, Type]`
|
||||
|
||||
Returns a dict of service-name to service-class. Other plugins can request services via the plugin manager.
|
||||
|
||||
```python
|
||||
from .services import ZabbixService
|
||||
|
||||
class PrintersPlugin(BasePlugin):
|
||||
def get_services(self):
|
||||
return {'zabbix': ZabbixService}
|
||||
```
|
||||
|
||||
### `get_dashboard_widgets() -> List[Dict]`
|
||||
|
||||
Returns dashboard widget definitions for the home page.
|
||||
|
||||
```python
|
||||
class NotificationsPlugin(BasePlugin):
|
||||
def get_dashboard_widgets(self):
|
||||
return [{
|
||||
'name': 'recent_notifications',
|
||||
'component': 'NotificationsWidget',
|
||||
'endpoint': '/api/notifications/recent',
|
||||
'size': 'medium',
|
||||
'position': 1,
|
||||
}]
|
||||
```
|
||||
|
||||
### `get_navigation_items() -> List[Dict]`
|
||||
|
||||
Returns navigation menu items.
|
||||
|
||||
```python
|
||||
class ComputersPlugin(BasePlugin):
|
||||
def get_navigation_items(self):
|
||||
return [{
|
||||
'name': 'Computers',
|
||||
'icon': 'desktop',
|
||||
'route': '/computers',
|
||||
'position': 10,
|
||||
}]
|
||||
```
|
||||
|
||||
### `get_searchable_fields() -> List[Dict]`
|
||||
|
||||
Declares fields the plugin contributes to global search.
|
||||
|
||||
```python
|
||||
from .models import Computer
|
||||
|
||||
class ComputersPlugin(BasePlugin):
|
||||
def get_searchable_fields(self):
|
||||
return [{
|
||||
'model': Computer,
|
||||
'search_fields': ['hostname', 'serialnumber', 'currentuser'],
|
||||
'result_type': 'computer',
|
||||
'url_template': '/computers/{id}',
|
||||
'title_field': 'hostname',
|
||||
'subtitle_field': 'currentuser',
|
||||
}]
|
||||
```
|
||||
|
||||
### `get_collector_schema() -> Optional[Dict]`
|
||||
|
||||
Declares the JSON Schema for an external collector pushing to `/api/collector/<pluginname>`. See [ADR-006](../migrations/adr/ADR-006-collector-contract.md) for the contract.
|
||||
|
||||
```python
|
||||
class ComputersPlugin(BasePlugin):
|
||||
def get_collector_schema(self):
|
||||
return {
|
||||
'identityfield': 'hostname',
|
||||
'fields': {
|
||||
'hostname': {'type': 'string', 'required': True},
|
||||
'macaddress': {'type': 'string'},
|
||||
'osname': {'type': 'string'},
|
||||
'osversion': {'type': 'string'},
|
||||
'currentuser': {'type': 'string'},
|
||||
'ipaddress': {'type': 'string'},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the hook returns `None` (the default), no collector endpoint is registered.
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
These run when the plugin's installation state changes. All optional.
|
||||
|
||||
| Hook | When | Use case |
|
||||
|------|------|----------|
|
||||
| `on_install(app)` | First time the plugin is installed via `flask plugin install` | Seed reference data, run plugin-specific migrations, register webhooks |
|
||||
| `on_uninstall(app)` | When the plugin is removed via `flask plugin uninstall` | Clean up reference data, deregister webhooks |
|
||||
| `on_enable(app)` | When the plugin is enabled at runtime | Subscribe to events, warm caches |
|
||||
| `on_disable(app)` | When the plugin is disabled at runtime | Unsubscribe, drain queues |
|
||||
|
||||
## Helpers exposed to plugins
|
||||
|
||||
The framework provides helper APIs in `shopdb.api` (the public namespace).
|
||||
|
||||
### Audit logging
|
||||
|
||||
```python
|
||||
from shopdb.api import audit_log
|
||||
|
||||
audit_log(
|
||||
action='created',
|
||||
entitytype='Computer',
|
||||
entityid=computer.assetid,
|
||||
entityname=computer.hostname,
|
||||
changes={'before': {}, 'after': computer.to_dict()},
|
||||
)
|
||||
```
|
||||
|
||||
### Plugin-scoped settings
|
||||
|
||||
```python
|
||||
class PrintersPlugin(BasePlugin):
|
||||
def init_app(self, app, db):
|
||||
zabbix_url = self.get_setting('zabbix_url')
|
||||
if not zabbix_url:
|
||||
self.set_setting('zabbix_url', 'http://zabbix.example.com')
|
||||
```
|
||||
|
||||
Settings persist to the core `Setting` model and survive restarts.
|
||||
|
||||
### Position resolution
|
||||
|
||||
```python
|
||||
from shopdb.api import resolve_asset_position
|
||||
|
||||
position = resolve_asset_position(asset)
|
||||
# Returns dict: {'mapx': 234, 'mapy': 567, 'positionsource': 'self' | 'related' | 'location' | None}
|
||||
```
|
||||
|
||||
See [ADR-001](../migrations/adr/ADR-001-asset-as-platform-contract.md) for the position resolution algorithm.
|
||||
|
||||
## Removed hooks
|
||||
|
||||
The following hooks existed in early drafts and have been removed for v1:
|
||||
|
||||
| Hook | Reason |
|
||||
|------|--------|
|
||||
| `get_event_handlers` | Event bus deferred indefinitely. No real use case yet. Add via new ADR if needed. |
|
||||
|
||||
## Versioning your changes
|
||||
|
||||
When you change anything documented here, you must:
|
||||
|
||||
1. Bump `__contract_version__` per [ADR-002](../migrations/adr/ADR-002-plugin-versioning.md): major for removals or signature changes, minor for additive optional hooks, patch for docs.
|
||||
2. Update [ADR-001](../migrations/adr/ADR-001-asset-as-platform-contract.md) if the contract surface itself changed (or supersede with a new ADR).
|
||||
3. Add or update the test in `tests/test_plugin_contract.py` that asserts the new behavior.
|
||||
|
||||
The skill `defining-asset-contract` walks through the full checklist.
|
||||
@@ -8,6 +8,12 @@ from .config import config
|
||||
from .extensions import db, migrate, jwt, cors, ma, init_extensions
|
||||
from .plugins import plugin_manager
|
||||
|
||||
# Platform contract version. See ADR-001 for the contract surface and
|
||||
# ADR-002 for the bump rules. Plugins declare a compatible range in
|
||||
# their manifest.json `core_version` field. Pre-1.0 (0.x) means the
|
||||
# contract is still settling; sister sites should pin tight ranges.
|
||||
__contract_version__ = '0.1.0'
|
||||
|
||||
|
||||
def create_app(config_name: str = None) -> Flask:
|
||||
"""
|
||||
|
||||
@@ -73,9 +73,21 @@ class BasePlugin(ABC):
|
||||
"""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 get_collector_schema(self) -> Optional[Dict]:
|
||||
"""Return JSON Schema describing the collector payload for this plugin.
|
||||
|
||||
Return None if the plugin does not accept collector input.
|
||||
|
||||
See ADR-006 for the contract. The schema must include:
|
||||
- 'identityfield': name of the field that uniquely identifies an
|
||||
asset across submissions (e.g., 'hostname' for PCs,
|
||||
'macaddress' for network devices). Used for idempotent upsert.
|
||||
- 'fields': JSON Schema definitions for the rest of the payload.
|
||||
|
||||
Plugins returning a non-None schema have an endpoint at
|
||||
/api/collector/<pluginname> auto-registered by the loader.
|
||||
"""
|
||||
return None
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed via CLI."""
|
||||
|
||||
145
tests/test_plugin_contract.py
Normal file
145
tests/test_plugin_contract.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Plugin contract compliance tests.
|
||||
|
||||
Asserts every bundled plugin satisfies the contract surface declared in
|
||||
ADR-001 and the BasePlugin ABC. Run on every change to BasePlugin or any
|
||||
plugin's plugin.py / manifest.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from flask import Blueprint
|
||||
|
||||
from shopdb import __contract_version__
|
||||
from shopdb.plugins import plugin_manager
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
|
||||
|
||||
BUNDLED_PLUGINS = ('computers', 'equipment', 'network', 'notifications', 'printers', 'usb')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_classes(app):
|
||||
"""Load each bundled plugin's class without instantiating in app context."""
|
||||
classes = {}
|
||||
with app.app_context():
|
||||
loader = plugin_manager.loader
|
||||
for name in BUNDLED_PLUGINS:
|
||||
cls = loader.load_plugin_class(name)
|
||||
assert cls is not None, f'Plugin class for {name} could not be loaded'
|
||||
classes[name] = cls
|
||||
return classes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_instances(app, plugin_classes):
|
||||
"""Instantiate each bundled plugin in app context."""
|
||||
return {name: cls() for name, cls in plugin_classes.items()}
|
||||
|
||||
|
||||
def test_contract_version_is_set():
|
||||
"""Framework declares a contract version."""
|
||||
assert __contract_version__
|
||||
parts = __contract_version__.split('.')
|
||||
assert len(parts) == 3, f'Expected semver X.Y.Z, got {__contract_version__}'
|
||||
for part in parts:
|
||||
assert part.isdigit(), f'Non-numeric semver part in {__contract_version__}'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_class_subclasses_baseplugin(plugin_classes, name):
|
||||
"""Each plugin class extends BasePlugin."""
|
||||
cls = plugin_classes[name]
|
||||
assert issubclass(cls, BasePlugin), f'{name} does not subclass BasePlugin'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_manifest_has_required_fields(name):
|
||||
"""Each plugin's manifest.json declares the required fields."""
|
||||
manifestpath = Path('plugins') / name / 'manifest.json'
|
||||
assert manifestpath.exists(), f'{name} is missing manifest.json'
|
||||
|
||||
manifest = json.loads(manifestpath.read_text())
|
||||
|
||||
assert manifest.get('name') == name
|
||||
assert manifest.get('version'), f'{name} manifest missing version'
|
||||
assert manifest.get('description'), f'{name} manifest missing description'
|
||||
assert manifest.get('core_version'), f'{name} manifest missing core_version'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_meta_returns_pluginmeta(plugin_instances, name):
|
||||
"""plugin.meta returns a PluginMeta instance with sane fields."""
|
||||
plugin = plugin_instances[name]
|
||||
meta = plugin.meta
|
||||
assert isinstance(meta, PluginMeta)
|
||||
assert meta.name == name
|
||||
assert meta.version
|
||||
assert meta.api_prefix and meta.api_prefix.startswith('/api/')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_blueprint_returns_blueprint_or_none(plugin_instances, name):
|
||||
"""get_blueprint returns a Flask Blueprint or None."""
|
||||
plugin = plugin_instances[name]
|
||||
bp = plugin.get_blueprint()
|
||||
assert bp is None or isinstance(bp, Blueprint), (
|
||||
f'{name}.get_blueprint returned {type(bp).__name__}'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_models_returns_list(plugin_instances, name):
|
||||
"""get_models returns a list of model classes."""
|
||||
plugin = plugin_instances[name]
|
||||
models = plugin.get_models()
|
||||
assert isinstance(models, list), f'{name}.get_models did not return a list'
|
||||
for model in models:
|
||||
assert hasattr(model, '__tablename__'), (
|
||||
f'{name}.get_models returned {model} without __tablename__'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_collector_schema_returns_dict_or_none(plugin_instances, name):
|
||||
"""get_collector_schema returns None or a dict with identityfield + fields."""
|
||||
plugin = plugin_instances[name]
|
||||
schema = plugin.get_collector_schema()
|
||||
if schema is None:
|
||||
return
|
||||
assert isinstance(schema, dict)
|
||||
assert 'identityfield' in schema, (
|
||||
f'{name}.get_collector_schema must declare identityfield'
|
||||
)
|
||||
assert 'fields' in schema, (
|
||||
f'{name}.get_collector_schema must declare fields'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_navigation_items_is_iterable(plugin_instances, name):
|
||||
"""get_navigation_items returns an iterable (default empty list)."""
|
||||
plugin = plugin_instances[name]
|
||||
items = plugin.get_navigation_items()
|
||||
assert isinstance(items, list)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_searchable_fields_is_iterable(plugin_instances, name):
|
||||
"""get_searchable_fields returns a list (default empty)."""
|
||||
plugin = plugin_instances[name]
|
||||
fields = plugin.get_searchable_fields()
|
||||
assert isinstance(fields, list)
|
||||
|
||||
|
||||
def test_baseplugin_does_not_have_event_handlers_hook():
|
||||
"""The removed get_event_handlers hook must not be on BasePlugin."""
|
||||
assert not hasattr(BasePlugin, 'get_event_handlers'), (
|
||||
'get_event_handlers was removed for v1. See ADR-001 contract surface.'
|
||||
)
|
||||
|
||||
|
||||
def test_baseplugin_has_collector_schema_hook():
|
||||
"""The collector schema hook is on the contract surface."""
|
||||
assert hasattr(BasePlugin, 'get_collector_schema')
|
||||
Reference in New Issue
Block a user