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

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