- 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>
237 lines
7.6 KiB
Python
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
|