Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
236
shopdb/plugins/scaffolder.py
Normal file
236
shopdb/plugins/scaffolder.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user