From c00194b6d9b8e27a6e094ccb4381be459d3b9b7e Mon Sep 17 00:00:00 2001 From: MrTango Date: Tue, 26 May 2026 17:58:17 +0300 Subject: [PATCH] Add declarative apply spec and direct dev commands - apply : scaffold addon + features from one validated YAML, with --check dry-run and fail-fast validation (templates, answers, choices) - test/serve/debug run uv run/runwsgi/pytest directly (no invoke); add check - feed config plone_version (minor) and github_user into copier user_defaults - document the spec format, language template and service_for in the skill --- plonecli/cli.py | 143 ++++++++- plonecli/skills/plonecli/SKILL.md | 25 +- plonecli/skills/plonecli/reference/spec.md | 71 +++++ .../skills/plonecli/reference/templates.md | 17 +- plonecli/spec.py | 295 ++++++++++++++++++ plonecli/templates.py | 25 +- tests/test_all_templates_data.py | 1 + tests/test_plonecli.py | 82 ++++- tests/test_spec.py | 200 ++++++++++++ tests/test_spec_e2e_integration.py | 133 ++++++++ tests/test_templates.py | 20 ++ 11 files changed, 984 insertions(+), 28 deletions(-) create mode 100644 plonecli/skills/plonecli/reference/spec.md create mode 100644 plonecli/spec.py create mode 100644 tests/test_spec.py create mode 100644 tests/test_spec_e2e_integration.py diff --git a/plonecli/cli.py b/plonecli/cli.py index 8442b31..c63b3d5 100644 --- a/plonecli/cli.py +++ b/plonecli/cli.py @@ -147,8 +147,8 @@ class ClickFilteredAliasedGroup(ClickAliasedGroup): def list_commands(self, ctx): existing_cmds = super().list_commands(ctx) project = find_project_root() - global_cmds = ["completion", "create", "config", "update", "skill"] - global_only_cmds = ["create"] + global_cmds = ["completion", "create", "apply", "config", "update", "skill"] + global_only_cmds = ["create", "apply"] if not project: cmds = [cmd for cmd in existing_cmds if cmd in global_cmds] else: @@ -368,6 +368,87 @@ def add(context, template, data, data_file, defaults, no_git): echo(f" Committed: {committed}", fg="green") +@cli.command("apply") +@click.argument("spec_file", type=click.Path(exists=True, dir_okay=False)) +@click.option( + "--check", + is_flag=True, + help="Validate the spec and print the plan without generating anything.", +) +@click.option( + "--no-git", + "no_git", + is_flag=True, + help="Do not initialise git or auto-commit generated output.", +) +@click.pass_context +def apply(context, spec_file, check, no_git): + """Scaffold a complete addon from a declarative spec file""" + from pathlib import Path + + from plonecli.spec import SpecError, describe_plan, load_spec, validate_spec + + config = context.obj["config"] + ensure_templates(config) + + try: + spec = load_spec(spec_file) + except SpecError as exc: + raise click.ClickException( + "Invalid spec:\n " + "\n ".join(exc.errors) + ) from exc + + errors = validate_spec(spec, config) + if errors: + raise click.ClickException( + "Spec validation failed:\n " + "\n ".join(errors) + ) + + echo(describe_plan(spec, config), fg="green") + if check: + echo("\nSpec is valid (dry-run, nothing generated).", fg="green") + return + + reg = TemplateRegistry(config) + resolved = reg.resolve_template_name(spec.template) + + if not confirm_clean_git(spec.name, defaults=True): + echo("Aborted.", fg="yellow") + return + + git_commit = config.auto_commit and not no_git + echo(f"\nCreating {resolved} project: {spec.name}", fg="green", reverse=True) + steps = reg.get_composite_steps(resolved) + if steps: + for index, step in enumerate(steps): + echo(f"\n Applying template: {step}", fg="green") + run_create( + step, spec.name, config, data=spec.data, defaults=True, + git_commit=git_commit, overwrite=index > 0, + ) + else: + run_create( + resolved, spec.name, config, data=spec.data, defaults=True, + git_commit=git_commit, + ) + + project = find_project_root(Path(spec.name).resolve()) + if project is None: + raise click.ClickException( + f"Generated project not detected at {spec.name!r}" + ) + + for feat in spec.features: + sub = reg.resolve_template_name(feat.template) + echo(f"\n Adding {sub}", fg="green") + run_add( + sub, project, config, data=feat.data, defaults=True, + git_commit=git_commit, + ) + + echo("\nDone.", fg="green") + + @cli.command() @click.pass_context def setup(context): @@ -389,14 +470,32 @@ def setup(context): run_create("zope-setup", str(project.root_folder), config, overwrite=True) +def _find_zope_ini(root_folder): + """Locate the instance zope.ini, preferring var/instance/etc/zope.ini.""" + from pathlib import Path + + root = Path(root_folder) + default = root / "var" / "instance" / "etc" / "zope.ini" + if default.exists(): + return default + matches = sorted(root.glob("*/*/etc/zope.ini")) + return matches[0] if matches else None + + @cli.command("serve") @click.pass_context def run_serve(context): - """Start the Plone instance (delegates to invoke start)""" + """Start the Plone instance""" project = context.obj.get("project") if project is None: raise NotInPackageError(context.command.name) - params = ["uv", "run", "invoke", "start"] + zope_ini = _find_zope_ini(project.root_folder) + if zope_ini is None: + raise click.UsageError( + "No instance found. Run 'plonecli setup' (zope-setup) first." + ) + rel_ini = zope_ini.relative_to(project.root_folder) + params = ["uv", "run", "runwsgi", str(rel_ini)] echo(f"\nRUN: {' '.join(params)}", fg="green", reverse=True) echo("\nINFO: Open this in a Web Browser: http://localhost:8080") echo("INFO: You can stop it by pressing CTRL + c\n") @@ -407,25 +506,51 @@ def run_serve(context): @click.option("-v", "--verbose", is_flag=True, help="Verbose test output") @click.pass_context def run_test(context, verbose): - """Run the tests in your package (delegates to invoke test)""" + """Run the tests in your package""" project = context.obj.get("project") if project is None: raise NotInPackageError(context.command.name) - params = ["uv", "run", "invoke", "test"] + params = ["uv", "run", "--extra", "test", "pytest"] if verbose: - params.append("--verbose") + params.append("-v") echo(f"\nRUN: {' '.join(params)}", fg="green", reverse=True) subprocess.call(params, cwd=str(project.root_folder)) +@cli.command("check") +@click.pass_context +def run_check(context): + """Run ruff and the test suite""" + project = context.obj.get("project") + if project is None: + raise NotInPackageError(context.command.name) + cwd = str(project.root_folder) + ruff = ["uv", "run", "ruff", "check", "."] + echo(f"\nRUN: {' '.join(ruff)}", fg="green", reverse=True) + rc = subprocess.call(ruff, cwd=cwd) + if rc != 0: + raise SystemExit(rc) + pytest = ["uv", "run", "--extra", "test", "pytest"] + echo(f"\nRUN: {' '.join(pytest)}", fg="green", reverse=True) + rc = subprocess.call(pytest, cwd=cwd) + if rc != 0: + raise SystemExit(rc) + + @cli.command("debug") @click.pass_context def run_debug(context): - """Start the Plone instance in debug mode (delegates to invoke debug)""" + """Start the Plone instance in debug mode""" project = context.obj.get("project") if project is None: raise NotInPackageError(context.command.name) - params = ["uv", "run", "invoke", "debug"] + zope_ini = _find_zope_ini(project.root_folder) + if zope_ini is None: + raise click.UsageError( + "No instance found. Run 'plonecli setup' (zope-setup) first." + ) + rel_ini = zope_ini.relative_to(project.root_folder) + params = ["uv", "run", "runwsgi", "-d", str(rel_ini)] echo(f"\nRUN: {' '.join(params)}", fg="green", reverse=True) echo("INFO: You can stop it by pressing CTRL + c\n") subprocess.call(params, cwd=str(project.root_folder)) diff --git a/plonecli/skills/plonecli/SKILL.md b/plonecli/skills/plonecli/SKILL.md index cd71953..3ed29ce 100644 --- a/plonecli/skills/plonecli/SKILL.md +++ b/plonecli/skills/plonecli/SKILL.md @@ -1,6 +1,6 @@ --- name: plonecli -description: Scaffold and develop Plone packages with plonecli (copier-template based). Use this BEFORE hand-writing any Plone add-on code — including when a plan or task step decides to create/implement a Plone feature such as a behavior, content type, view, viewlet, portlet, vocabulary, indexer, subscriber, control panel, form, REST API service, theme, or upgrade step. Scaffold these with plonecli subtemplates instead of writing files by hand. Also for any plonecli command (create, add, setup, serve, test, debug, update, config); creating a backend add-on or Zope project; scaffolding a GenericSetup upgrade step after profile XML changes so they reach installed sites; adapting an old/legacy package (mr.bob, buildout, setup.py) to plonecli's structure. Triggers on "plonecli ...", "create a Plone addon", "add/create/implement a behavior (or content type / restapi service / ...)", "add a field / add fields to a content type or behavior", "add upgrade_step", "migrate installed Plone sites", "zope-setup". The -d KEY=VALUE answers each template accepts are in reference/templates.md; the per-field question flow and the Plone field/widget catalogue are in reference/fields.md. +description: Scaffold and develop Plone packages with plonecli (copier-template based). Use this BEFORE hand-writing any Plone add-on code — including when a plan or task step decides to create/implement a Plone feature such as a behavior, content type, view, viewlet, portlet, vocabulary, indexer, subscriber, control panel, form, REST API service, theme, language/translation, or upgrade step. Scaffold these with plonecli subtemplates instead of writing files by hand. To scaffold a whole addon plus several features from one declarative YAML file, use `plonecli apply spec.yaml`. Also for any plonecli command (apply, create, add, setup, serve, test, check, debug, update, config); creating a backend add-on or Zope project; scaffolding a GenericSetup upgrade step after profile XML changes so they reach installed sites; adapting an old/legacy package (mr.bob, buildout, setup.py) to plonecli's structure. Triggers on "plonecli ...", "create a Plone addon", "add/create/implement a behavior (or content type / restapi service / language / ...)", "add a field / add fields to a content type or behavior", "add upgrade_step", "migrate installed Plone sites", "zope-setup". The declarative spec format is in reference/spec.md; the -d KEY=VALUE answers each template accepts are in reference/templates.md; the per-field question flow and the Plone field/widget catalogue are in reference/fields.md. --- # plonecli @@ -19,21 +19,24 @@ On first run, plonecli clones the copier-templates to `~/.copier-templates/plone | Command | Scope | What it does | |---|---|---| +| `apply ` | anywhere | Scaffold a whole addon + features from one declarative spec, non-interactively. `--check` validates only. See [reference/spec.md](reference/spec.md). | | `create