Files
shopdb-flask/shopdb/plugins/scaffolder.py
cproudlock 529b9f2fed
All checks were successful
CI / backend (push) Successful in 24s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Ship plugin framework shore-up: frontend scaffold, sister-site adoption kit
- flask plugin new now scaffolds the frontend too: List/Detail/Form
  views on the global styles, a gated route module (ADR-009), and an
  api-client snippet emitted into the plugin dir. Views are written
  before the route file so a partially generated plugin cannot 500 the
  dev server.
- docs/PLUGIN-EXTERNAL-REPO.md + scripts/test-external-plugin.sh: how a
  sister site develops a plugin in its own repo and runs the framework
  contract tests in CI against a pinned framework ref (script verified
  to fail on a broken core_version pin).
- docs/CONTRACT-STABILITY.md: settled vs churning contract surface and
  the provisional 1.0 criteria.
- CLAUDE.md active-state refresh (contract 0.6.0, 11 plugins, 340
  tests, measuringtools done).

Known limitation documented: Path.rglob does not descend symlinks, so
the import-surface contract test skips symlinked external plugins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:30:03 -04:00

237 lines
7.6 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 _render_template(
template_path: Path,
out_path: Path,
substitutions: dict,
overwrite: bool,
) -> bool:
"""Render one template to out_path.
Skips silently when out_path already exists and overwrite is False, so a
scaffold never clobbers a file the author has already edited. Returns True
when the file was written, False when it was skipped.
"""
if out_path.exists() and not overwrite:
return False
out_path.parent.mkdir(parents=True, exist_ok=True)
body = template_path.read_text()
out_path.write_text(Template(body).safe_substitute(substitutions))
return True
def _scaffold_frontend(
name: str,
substitutions: dict,
plugin_target: Path,
frontend_dir: Path,
template_root: Path,
overwrite: bool,
) -> None:
"""Render the frontend starting points for a scaffolded plugin.
Writes the paste-in api-client snippet into the plugin directory, then the
Vue views and the router route file into the real frontend tree. The
snippet is emitted regardless of whether the frontend tree exists, because
it is a plugin-directory artifact useful even for external-repo plugins.
Views and the route file are skipped when frontend_dir is missing, which is
the normal case for a plugin developed in its own repository.
Ordering matters: every view is written before the route file. A route file
that lazy-imports a view that is not on disk crashes the Vite dev server,
so the views must land first.
"""
fe_templates = template_root / 'frontend'
if not fe_templates.exists():
return
plugin_name = substitutions['Name']
# snippet lands in the plugin dir; author pastes it into api/index.js
snippet_template = fe_templates / 'frontend-api-snippet.js.tmpl'
if snippet_template.exists():
_render_template(
snippet_template,
plugin_target / 'frontend-api-snippet.js',
substitutions,
overwrite,
)
# views and route need the real frontend tree; external repos skip these
if not frontend_dir.exists():
return
views_dir = frontend_dir / 'views' / name
# views first: route file lazy-imports them, missing views 500 vite
view_templates = {
'List.vue.tmpl': f'{plugin_name}List.vue',
'Detail.vue.tmpl': f'{plugin_name}Detail.vue',
'Form.vue.tmpl': f'{plugin_name}Form.vue',
}
for template_name, out_name in view_templates.items():
template_path = fe_templates / 'views' / template_name
if template_path.exists():
_render_template(
template_path,
views_dir / out_name,
substitutions,
overwrite,
)
# route file last: all referenced views now exist on disk
route_template = fe_templates / 'routes.js.tmpl'
if route_template.exists():
_render_template(
route_template,
frontend_dir / 'router' / 'routes' / f'{name}.js',
substitutions,
overwrite,
)
def scaffold_plugin(
name: str,
description: str,
plugins_dir: Path,
template_root: Optional[Path] = None,
overwrite: bool = False,
frontend: bool = True,
frontend_dir: Optional[Path] = None,
) -> 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 and any
existing generated frontend files
frontend: If True, also render the Vue frontend starting points (list,
detail, form views, a route file, and a paste-in api-client snippet)
frontend_dir: Frontend src directory to render views/routes into
(default: <plugins_dir>/../frontend/src). Views and the route file
are skipped when this directory does not exist, which is the normal
case for a plugin developed in its own repository.
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)
# frontend templates render into the frontend tree, not the plugin dir
if rel.parts and rel.parts[0] == 'frontend':
continue
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)
if frontend:
if frontend_dir is None:
frontend_dir = plugins_dir.parent / 'frontend' / 'src'
_scaffold_frontend(
name=name,
substitutions=substitutions,
plugin_target=target,
frontend_dir=Path(frontend_dir),
template_root=template_root,
overwrite=overwrite,
)
return target