Lowers the barrier for sister sites to build their own plugins. Generated output satisfies the framework contract out of the box. CLI command (shopdb/plugins/cli.py): - `flask plugin new <name> --description "..."` generates a plugin skeleton under plugins/<name>/. Validates the name against CONTRIBUTING.md rules (lowercase letters/digits only, no underscores or hyphens, not in the reserved list) and refuses to overwrite existing plugins unless --overwrite is passed. - Output prints the next steps (install, migrate, test). Scaffolder (shopdb/plugins/scaffolder.py): - validate_name: enforces the naming rules - pascal_case: lowercase-to-PascalCase for class names - scaffold_plugin: copies templates with string.Template substitution. Three placeholders: $name, $Name, $description. Files with `model.py` in the path get renamed to <name>.py. Templates (shopdb/plugins/templates/): - manifest.json.tmpl: name, version 0.1.0, description, core_version range >=0.1.0,<1.0.0 (broad enough to survive minor framework bumps) - plugin.py.tmpl: <Name>Plugin class extending BasePlugin with all required hooks implemented (meta from manifest, get_blueprint returning the bp, get_models returning the example model). Includes on_install hook that seeds the AssetType row. - models/__init__.py.tmpl + models/model.py.tmpl: Asset extension table keyed by assetid with one example field. TODO comment marks it as a placeholder. - api/__init__.py.tmpl + api/routes.py.tmpl: Blueprint with list and detail endpoints using the framework's pagination + response helpers. - schemas/__init__.py.tmpl: marshmallow schema stub. - tests/__init__.py.tmpl + tests/test_plugin.py.tmpl: smoke tests asserting plugin loads, get_blueprint returns Blueprint, get_models returns at least one model. - README.md.tmpl: one-pager for plugin authors with common edits and next-step references. Canary tests (tests/test_plugin_scaffold.py): - 14 tests asserting the scaffold output passes contract checks. - Validates name rules (lowercase, reserved, hyphens, digits, etc.) - Verifies all expected files generated, manifest fields present. - Loads the generated plugin via PluginLoader (spec_from_file_location bypasses the real `plugins` package shadowing). - Asserts subclasses BasePlugin, get_blueprint returns Blueprint, get_models returns model with __tablename__. - Module-scoped fixture; cleans up sys.modules + SQLAlchemy metadata on teardown to avoid cross-test contamination. Quickstart docs (docs/PLUGIN-QUICKSTART.md): - 30-minute walkthrough: scaffold -> edit model -> add routes -> install -> verify -> add hooks. Cross-links to PLUGIN-HOOKS.md and the ADRs. Includes common-errors table. Test count: 87 -> 101 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
190 lines
6.0 KiB
Python
190 lines
6.0 KiB
Python
"""Canary tests for the plugin scaffolder.
|
|
|
|
The scaffold output must satisfy the framework contract the moment it
|
|
is generated. If a contract test would fail on a freshly scaffolded
|
|
plugin, the scaffold templates are broken, not the plugin author's
|
|
mistake.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from flask import Blueprint
|
|
|
|
from shopdb.plugins.scaffolder import (
|
|
scaffold_plugin,
|
|
validate_name,
|
|
pascal_case,
|
|
ScaffoldError,
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope='module')
|
|
def scaffolded(tmp_path_factory):
|
|
"""Scaffold a plugin named 'widgets' under a temporary plugins dir.
|
|
|
|
Module-scoped so the generated plugin module imports exactly once
|
|
per test module. SQLAlchemy registers the model once, no metadata
|
|
collision. Returns a tuple (target_dir, plugins_dir) where
|
|
plugins_dir is suitable as PluginLoader's plugins_dir argument.
|
|
|
|
The scaffold output is loaded via PluginLoader's spec-based import
|
|
so the canary does not collide with the real `plugins/` package
|
|
that already exists at <repo>/plugins/.
|
|
"""
|
|
tmp_path = tmp_path_factory.mktemp('scaffold')
|
|
plugins_dir = tmp_path / 'plugins'
|
|
plugins_dir.mkdir()
|
|
|
|
target = scaffold_plugin(
|
|
name='widgets',
|
|
description='Test widgets plugin',
|
|
plugins_dir=plugins_dir,
|
|
)
|
|
|
|
import plugins as real_plugins
|
|
real_plugins.__path__.insert(0, str(plugins_dir))
|
|
|
|
try:
|
|
yield target, plugins_dir
|
|
finally:
|
|
try:
|
|
real_plugins.__path__.remove(str(plugins_dir))
|
|
except ValueError:
|
|
pass
|
|
for mod_name in list(sys.modules):
|
|
if mod_name.startswith('plugins.widgets'):
|
|
del sys.modules[mod_name]
|
|
from shopdb.extensions import db as _db
|
|
if 'widgets' in _db.metadata.tables:
|
|
_db.metadata.remove(_db.metadata.tables['widgets'])
|
|
|
|
|
|
def _load_widgets_plugin_class(plugins_dir):
|
|
"""Load the scaffolded widgets plugin class via PluginLoader."""
|
|
from shopdb.plugins.loader import PluginLoader
|
|
from shopdb.plugins.registry import PluginRegistry
|
|
|
|
registry = PluginRegistry(plugins_dir.parent / 'plugins.json')
|
|
loader = PluginLoader(plugins_dir, registry)
|
|
return loader.load_plugin_class('widgets')
|
|
|
|
|
|
def test_validate_name_accepts_simple_lowercase():
|
|
validate_name('cameras')
|
|
validate_name('barcode')
|
|
validate_name('vlan2')
|
|
|
|
|
|
def test_validate_name_rejects_underscore():
|
|
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
|
|
validate_name('my_plugin')
|
|
|
|
|
|
def test_validate_name_rejects_hyphen():
|
|
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
|
|
validate_name('my-plugin')
|
|
|
|
|
|
def test_validate_name_rejects_uppercase():
|
|
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
|
|
validate_name('MyPlugin')
|
|
|
|
|
|
def test_validate_name_rejects_leading_digit():
|
|
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
|
|
validate_name('1plugin')
|
|
|
|
|
|
def test_validate_name_rejects_reserved():
|
|
with pytest.raises(ScaffoldError, match='reserved'):
|
|
validate_name('plugins')
|
|
|
|
|
|
def test_pascal_case():
|
|
assert pascal_case('cameras') == 'Cameras'
|
|
assert pascal_case('vlan') == 'Vlan'
|
|
|
|
|
|
def test_scaffold_creates_expected_files(scaffolded):
|
|
"""Scaffold output has every file the contract expects."""
|
|
target, _ = scaffolded
|
|
expected = [
|
|
'manifest.json',
|
|
'plugin.py',
|
|
'__init__.py',
|
|
'models/__init__.py',
|
|
'models/widgets.py',
|
|
'api/__init__.py',
|
|
'api/routes.py',
|
|
'schemas/__init__.py',
|
|
'tests/__init__.py',
|
|
'tests/test_plugin.py',
|
|
'README.md',
|
|
]
|
|
for relpath in expected:
|
|
assert (target / relpath).exists(), f'Missing scaffold output: {relpath}'
|
|
|
|
|
|
def test_scaffold_manifest_has_required_fields(scaffolded):
|
|
"""manifest.json contains the fields the loader requires."""
|
|
target, _ = scaffolded
|
|
manifest = json.loads((target / 'manifest.json').read_text())
|
|
assert manifest['name'] == 'widgets'
|
|
assert manifest['version']
|
|
assert manifest['description'] == 'Test widgets plugin'
|
|
assert manifest['core_version']
|
|
assert manifest['api_prefix'] == '/api/widgets'
|
|
|
|
|
|
def test_scaffold_plugin_class_is_importable(scaffolded):
|
|
"""The generated plugin.py loads via PluginLoader and subclasses BasePlugin."""
|
|
from shopdb.plugins.base import BasePlugin
|
|
|
|
_, plugins_dir = scaffolded
|
|
plugin_class = _load_widgets_plugin_class(plugins_dir)
|
|
|
|
assert issubclass(plugin_class, BasePlugin)
|
|
instance = plugin_class()
|
|
assert instance.meta.name == 'widgets'
|
|
|
|
|
|
def test_scaffold_get_blueprint_returns_blueprint(scaffolded):
|
|
"""The generated plugin's get_blueprint returns a Flask Blueprint."""
|
|
_, plugins_dir = scaffolded
|
|
plugin_class = _load_widgets_plugin_class(plugins_dir)
|
|
bp = plugin_class().get_blueprint()
|
|
assert isinstance(bp, Blueprint)
|
|
|
|
|
|
def test_scaffold_get_models_returns_model_class(scaffolded):
|
|
"""The generated plugin's get_models returns at least one model with a tablename."""
|
|
_, plugins_dir = scaffolded
|
|
plugin_class = _load_widgets_plugin_class(plugins_dir)
|
|
models = plugin_class().get_models()
|
|
assert len(models) == 1
|
|
assert models[0].__tablename__ == 'widgets'
|
|
|
|
|
|
def test_scaffold_refuses_to_overwrite_by_default(tmp_path):
|
|
"""A second scaffold of the same name without overwrite raises."""
|
|
plugins_dir = tmp_path / 'plugins'
|
|
plugins_dir.mkdir()
|
|
scaffold_plugin('widgets', 'first', plugins_dir)
|
|
|
|
with pytest.raises(ScaffoldError, match='already exists'):
|
|
scaffold_plugin('widgets', 'second', plugins_dir)
|
|
|
|
|
|
def test_scaffold_overwrites_when_explicitly_requested(tmp_path):
|
|
"""overwrite=True allows a re-scaffold."""
|
|
plugins_dir = tmp_path / 'plugins'
|
|
plugins_dir.mkdir()
|
|
scaffold_plugin('widgets', 'first', plugins_dir)
|
|
scaffold_plugin('widgets', 'second', plugins_dir, overwrite=True)
|
|
|
|
manifest = json.loads((plugins_dir / 'widgets' / 'manifest.json').read_text())
|
|
assert manifest['description'] == 'second'
|