"""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., /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