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>
122 lines
3.5 KiB
Python
122 lines
3.5 KiB
Python
"""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
|