Phase 4: plugin scaffolding (flask plugin new) with canary tests

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>
This commit is contained in:
cproudlock
2026-05-08 17:13:46 -04:00
parent 6f085a175d
commit 8eb9362452
15 changed files with 759 additions and 0 deletions

158
docs/PLUGIN-QUICKSTART.md Normal file
View File

@@ -0,0 +1,158 @@
# Plugin Quickstart
Build a working shopdb-flask plugin in 30 minutes. This walks through generating, customizing, installing, and testing a plugin from scratch.
For the full hook reference, see [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md).
For the architectural decisions behind the contract, see [migrations/adr/](../migrations/adr/).
## Step 1: Generate the skeleton
```bash
flask plugin new cameras --description "Tracks shop-floor surveillance cameras"
```
Output: `plugins/cameras/` with manifest, plugin class, example model, example routes, schemas stub, tests, and a README.
The generated plugin already passes the framework's contract tests. Verify before editing:
```bash
pytest plugins/cameras/tests/
```
## Step 2: Edit the model
Open `plugins/cameras/models/cameras.py`. Replace the `examplefield` placeholder with your domain fields:
```python
class Cameras(BaseModel):
__tablename__ = 'cameras'
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
primary_key=True,
)
streamurl = db.Column(db.String(255), nullable=False)
resolution = db.Column(db.String(20))
fps = db.Column(db.Integer)
poeport = db.Column(db.String(50))
asset = db.relationship('Asset', backref=db.backref('cameras', uselist=False))
def to_dict(self):
return {
'assetid': self.assetid,
'streamurl': self.streamurl,
'resolution': self.resolution,
'fps': self.fps,
'poeport': self.poeport,
}
```
Note the naming convention: lowercase concatenated, no underscores (`streamurl`, not `stream_url`). See [CONTRIBUTING.md](../CONTRIBUTING.md).
## Step 3: Add routes
Open `plugins/cameras/api/routes.py`. The scaffold provides list and detail endpoints. Add CRUD as needed:
```python
@cameras_bp.route('', methods=['POST'])
@jwt_required()
def create_camera():
data = request.get_json()
asset = Asset(assetnumber=data['assetnumber'], name=data['name'], ...)
db.session.add(asset)
db.session.flush()
camera = Cameras(
assetid=asset.assetid,
streamurl=data['streamurl'],
resolution=data.get('resolution'),
)
db.session.add(camera)
db.session.commit()
return success_response(camera.to_dict(), http_code=201)
```
For audit logging, use the public helper:
```python
from shopdb.api import audit_log
audit_log(action='created', entitytype='Camera', entityid=asset.assetid, entityname=asset.name)
```
## Step 4: Install the plugin
```bash
flask plugin install cameras
flask db migrate -m "Add cameras plugin tables"
flask db upgrade
```
`install` runs the plugin's `on_install` hook (which seeds the AssetType row), registers it in the plugin registry, and runs migrations.
## Step 5: Verify it works
```bash
flask plugin list
```
You should see `cameras [Enabled]`.
Run the plugin's tests:
```bash
pytest plugins/cameras/tests/
```
Hit the API:
```bash
curl http://localhost:5001/api/cameras
```
## Step 6: Add hooks (optional)
Override hooks on the plugin class as needed. See [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) for the full list. Common ones:
| Hook | Adds |
|------|------|
| `get_searchable_fields` | Plugin contributes to the global search endpoint |
| `get_navigation_items` | Plugin shows up in the sidebar nav |
| `get_dashboard_widgets` | Plugin's dashboard widget appears on the home page |
| `get_collector_schema` | Plugin accepts external pushes at `/api/collector/<name>` |
Each hook has a default that does nothing. Override only what your plugin needs.
## Step 7: Frontend (manual for now)
Backend scaffolding is automated. Frontend is manual until the frontend scaffolding skill ships. Convention:
- `frontend/src/views/cameras/CamerasList.vue`
- `frontend/src/views/cameras/CameraDetail.vue`
- `frontend/src/views/cameras/CameraForm.vue`
Copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) as a starting point. Update the API client in `frontend/src/api/index.js` to add cameras endpoints.
## Common errors
| Symptom | Cause | Fix |
|---------|-------|-----|
| `PluginNotFoundError: manifest.json` | Manifest deleted or moved | Restore `plugins/<name>/manifest.json` |
| `PluginContractError: missing required field` | manifest.json incomplete | Re-add `name`, `version`, `description` |
| `PluginVersionError: requires core_version X but framework is Y` | Framework upgraded past your range | Update `core_version` in manifest |
| `Table 'cameras' is already defined` | Two models declared the same `__tablename__` | Pick a unique table name |
| Index name collision | Two indexes share the same name (SQLite enforces global uniqueness) | Prefix index names with table: `idx_cameras_streamurl` |
## Next steps
- [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) for the full hook reference
- [CONTRIBUTING.md](../CONTRIBUTING.md) for naming conventions
- [migrations/adr/ADR-001-asset-as-platform-contract.md](../migrations/adr/ADR-001-asset-as-platform-contract.md) for what your plugin can rely on
- [migrations/adr/ADR-006-collector-contract.md](../migrations/adr/ADR-006-collector-contract.md) for accepting external collector input
## Distribution
If you are building a plugin for a specific GE Aerospace site (sister-site adoption), ship it as its own git repo. The site running shopdb-flask clones or symlinks your plugin into `<repo>/plugins/<name>/`. See [ADR-003](../migrations/adr/ADR-003-plugin-distribution.md).

View File

@@ -179,6 +179,46 @@ def plugin_info(name: str):
click.echo("") click.echo("")
@plugin_cli.command('new')
@click.argument('name')
@click.option('--description', default='', help='One-sentence plugin description')
@click.option('--overwrite', is_flag=True, help='Overwrite existing plugin directory')
@with_appcontext
def new_plugin(name: str, description: str, overwrite: bool):
"""Scaffold a new plugin from the bundled templates.
Usage: flask plugin new cameras --description "Tracks shop-floor cameras"
"""
from pathlib import Path
from .scaffolder import scaffold_plugin, ScaffoldError
plugins_dir = Path(current_app.root_path).parent / 'plugins'
if not description:
description = f'{name.capitalize()} plugin (TODO: replace this description)'
try:
target = scaffold_plugin(
name=name,
description=description,
plugins_dir=plugins_dir,
overwrite=overwrite,
)
except ScaffoldError as e:
click.echo(click.style(f'Scaffold failed: {e}', fg='red'))
raise SystemExit(1)
click.echo(click.style(f'Created plugin at {target}', fg='green'))
click.echo('')
click.echo('Next steps:')
click.echo(f' 1. Edit plugins/{name}/models/{name}.py with your domain fields')
click.echo(f' 2. Edit plugins/{name}/api/routes.py with your endpoints')
click.echo(f' 3. Run: flask plugin install {name}')
click.echo(f' 4. Run: flask db migrate -m "Add {name} plugin"')
click.echo(f' 5. Run: flask db upgrade')
click.echo(f' 6. Run: pytest plugins/{name}/tests/')
@plugin_cli.command('migrate') @plugin_cli.command('migrate')
@click.argument('name') @click.argument('name')
@click.option('--revision', default='head', help='Target revision') @click.option('--revision', default='head', help='Target revision')

View File

@@ -0,0 +1,121 @@
"""Plugin scaffolder.
Generates a new plugin skeleton from the templates under
shopdb/plugins/templates/. The generated plugin satisfies the framework
contract out of the box; tests/test_plugin_scaffold.py is the canary
that this stays true as the contract evolves.
"""
import re
from pathlib import Path
from string import Template
from typing import Optional
TEMPLATE_ROOT = Path(__file__).parent / 'templates'
VALID_NAME_PATTERN = re.compile(r'^[a-z][a-z0-9]*$')
RESERVED_NAMES = {
'plugin', 'plugins', 'shopdb', 'core', 'api', 'tests', 'templates',
'schemas', 'models', 'frontend', 'docs', 'migrations', 'scripts',
}
class ScaffoldError(Exception):
"""Raised when scaffolding cannot proceed."""
def validate_name(name: str) -> None:
"""Validate plugin name against CONTRIBUTING.md and reserved list.
Raises ScaffoldError on any violation.
"""
if not name:
raise ScaffoldError('Plugin name is required')
if not VALID_NAME_PATTERN.match(name):
raise ScaffoldError(
f'Plugin name "{name}" must be lowercase letters and digits only, '
f'starting with a letter (no hyphens, underscores, or special chars). '
f'See CONTRIBUTING.md for the naming convention.'
)
if name in RESERVED_NAMES:
raise ScaffoldError(
f'Plugin name "{name}" is reserved. Pick a different name.'
)
def pascal_case(name: str) -> str:
"""Convert lowercase plugin name to PascalCase class name.
'cameras' -> 'Cameras'. The convention assumes single-word lowercase
plugin names per the naming rules in CONTRIBUTING.md, so this is
just title-casing.
"""
return name[:1].upper() + name[1:]
def scaffold_plugin(
name: str,
description: str,
plugins_dir: Path,
template_root: Optional[Path] = None,
overwrite: bool = False,
) -> Path:
"""Generate a new plugin from templates.
Args:
name: Plugin name (lowercase, single word)
description: One-sentence description for manifest.json + README
plugins_dir: Target plugins directory (e.g., <repo>/plugins)
template_root: Override template source dir (default: bundled templates)
overwrite: If True, overwrite an existing plugin directory
Returns:
Path to the generated plugin directory.
Raises:
ScaffoldError on validation failure or when target exists and
overwrite is False.
"""
validate_name(name)
template_root = template_root or TEMPLATE_ROOT
if not template_root.exists():
raise ScaffoldError(f'Template root not found: {template_root}')
target = plugins_dir / name
if target.exists():
if not overwrite:
raise ScaffoldError(
f'Plugin directory already exists: {target}. '
f'Pass overwrite=True or remove it first.'
)
substitutions = {
'name': name,
'Name': pascal_case(name),
'description': description,
}
target.mkdir(parents=True, exist_ok=True)
for template_path in template_root.rglob('*.tmpl'):
rel = template_path.relative_to(template_root)
out_rel_str = str(rel.with_suffix(''))
if 'model.py' in out_rel_str:
out_rel_str = out_rel_str.replace('model.py', f'{name}.py')
out_path = target / out_rel_str
out_path.parent.mkdir(parents=True, exist_ok=True)
body = template_path.read_text()
rendered = Template(body).safe_substitute(substitutions)
out_path.write_text(rendered)
return target

View File

@@ -0,0 +1,43 @@
# $Name plugin
$description
This plugin was generated by `flask plugin new $name`. It satisfies the framework contract out of the box. Replace the example model and routes with your domain.
## What's here
- `plugin.py` - the `${Name}Plugin` class extending `BasePlugin`. Edit `init_app` for custom setup, `on_install` to seed reference data.
- `models/$name.py` - example Asset extension table. Replace `examplefield` with your domain fields.
- `api/routes.py` - example list and detail endpoints. Add CRUD as needed.
- `schemas/__init__.py` - marshmallow schema stub for request/response validation.
- `tests/test_plugin.py` - smoke tests asserting contract compliance.
- `manifest.json` - plugin metadata. Bump `version` on changes; keep `core_version` range broad.
## Common edits
| You want to... | Do this |
|---|---|
| Add a hook (search, navigation, dashboard widget) | Override the method in `${Name}Plugin`. See `docs/PLUGIN-HOOKS.md`. |
| Accept external collector data | Override `get_collector_schema()` to return a JSON Schema. See ADR-006. |
| Add another model | Create `models/<other>.py`, export it in `models/__init__.py`, return it in `get_models()`. |
| Add a CLI command | Override `get_cli_commands()` returning a list of Click commands. |
## Frontend
Vue components for this plugin live under `frontend/src/views/$name/` (per project convention). Backend scaffolding does not generate frontend yet; copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) as a starting point.
## Install and run
```bash
flask plugin install $name
flask db migrate -m "Add $name plugin tables"
flask db upgrade
pytest plugins/$name/tests/
```
## References
- `docs/PLUGIN-HOOKS.md` - canonical hook reference
- `docs/PLUGIN-QUICKSTART.md` - 30-minute walkthrough
- `migrations/adr/ADR-001-asset-as-platform-contract.md` - the platform contract
- `migrations/adr/ADR-002-plugin-versioning.md` - versioning rules

View File

@@ -0,0 +1,5 @@
"""$Name plugin package."""
from .plugin import ${Name}Plugin
__all__ = ['${Name}Plugin']

View File

@@ -0,0 +1,5 @@
"""$Name plugin API package."""
from .routes import ${name}_bp
__all__ = ['${name}_bp']

View File

@@ -0,0 +1,44 @@
"""$Name plugin API routes."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes,
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import $Name
${name}_bp = Blueprint('$name', __name__)
@${name}_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_${name}():
"""List $name assets, paginated."""
page, per_page = get_pagination_params(request)
query = ${Name}.query
items, total = paginate_query(query, page, per_page)
data = [item.to_dict() for item in items]
return paginated_response(data, page, per_page, total)
@${name}_bp.route('/<int:assetid>', methods=['GET'])
@jwt_required(optional=True)
def get_${name}(assetid: int):
"""Get a single $name by assetid."""
item = ${Name}.query.get(assetid)
if not item:
return error_response(
ErrorCodes.NOT_FOUND,
f'$Name with assetid {assetid} not found',
http_code=404,
)
return success_response(item.to_dict())

View File

@@ -0,0 +1,9 @@
{
"name": "$name",
"version": "0.1.0",
"description": "$description",
"author": "",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/$name"
}

View File

@@ -0,0 +1,5 @@
"""$Name plugin models."""
from .$name import $Name
__all__ = ['$Name']

View File

@@ -0,0 +1,33 @@
"""$Name model.
This is an Asset extension table keyed by assetid. The Asset row holds
the platform fields (assetnumber, name, vendorid, locationid, etc.);
this table holds the $name-specific fields. Replace the example fields
below with your domain model.
"""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
class $Name(BaseModel):
"""$Name domain entity, extending Asset by assetid."""
__tablename__ = '$name'
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
primary_key=True,
)
# TODO: replace these example fields with your domain fields.
examplefield = db.Column(db.String(255), nullable=True)
asset = db.relationship('Asset', backref=db.backref('$name', uselist=False))
def to_dict(self):
return {
'assetid': self.assetid,
'examplefield': self.examplefield,
}

View File

@@ -0,0 +1,71 @@
"""$Name plugin main class.
$description
"""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.core.models import AssetType
from shopdb.extensions import db
from .models import $Name
from .api import ${name}_bp
logger = logging.getLogger(__name__)
class ${Name}Plugin(BasePlugin):
"""$Name plugin.
$description
"""
def __init__(self):
manifest_path = Path(__file__).parent / 'manifest.json'
with open(manifest_path) 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'],
author=self._manifest.get('author', ''),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=0.1.0'),
api_prefix=self._manifest.get('api_prefix'),
)
def get_blueprint(self) -> Optional[Blueprint]:
return ${name}_bp
def get_models(self) -> List[Type]:
return [$Name]
def init_app(self, app: Flask, db_instance) -> None:
logger.info(f'$Name plugin initialized (v{self.meta.version})')
def on_install(self, app: Flask) -> None:
with app.app_context():
self._ensure_asset_type()
logger.info('$Name plugin installed')
def _ensure_asset_type(self) -> None:
existing = AssetType.query.filter_by(assettype='$name').first()
if not existing:
asset_type = AssetType(
assettype='$name',
pluginname='$name',
tablename='$name',
description=self.meta.description,
)
db.session.add(asset_type)
db.session.commit()
logger.debug('Created asset type: $name')

View File

@@ -0,0 +1,6 @@
"""$Name plugin schemas (marshmallow).
Add schema classes here when you need request/response validation
beyond the simple to_dict() output. The framework wires marshmallow
into the response helpers; see docs/PLUGIN-HOOKS.md for details.
"""

View File

@@ -0,0 +1,30 @@
"""$Name plugin smoke tests.
Asserts the plugin loads cleanly and satisfies the framework contract.
Replace and extend with domain tests as you build the plugin out.
"""
from plugins.$name.plugin import ${Name}Plugin
def test_${name}_plugin_meta_is_valid():
"""${Name}Plugin.meta returns a PluginMeta with the expected name."""
plugin = ${Name}Plugin()
assert plugin.meta.name == '$name'
assert plugin.meta.api_prefix == '/api/$name'
def test_${name}_plugin_get_blueprint_returns_blueprint():
"""get_blueprint returns a Flask Blueprint, not None."""
from flask import Blueprint
plugin = ${Name}Plugin()
assert isinstance(plugin.get_blueprint(), Blueprint)
def test_${name}_plugin_get_models_returns_a_model():
"""get_models returns a list with at least one SQLAlchemy model."""
plugin = ${Name}Plugin()
models = plugin.get_models()
assert len(models) >= 1
for model in models:
assert hasattr(model, '__tablename__')

View File

@@ -0,0 +1,189 @@
"""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'