diff --git a/CHANGES.md b/CHANGES.md index 6b3cc5d..9684d26 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,7 +3,10 @@ ## 7.0.0b6 (unreleased) -- Nothing changed yet. +- Add `--defaults`, `-d/--data KEY=VALUE` and `--data-file` options to + `plonecli create` and `plonecli add` for non-interactive use (Claude Code / + CI), driving copier without prompts. Update the bundled skill to use them. + [MrTango] ## 7.0.0b5 (2026-05-22) diff --git a/plonecli/cli.py b/plonecli/cli.py index 230a745..0be0acb 100644 --- a/plonecli/cli.py +++ b/plonecli/cli.py @@ -26,6 +26,63 @@ def echo(msg, fg="green", reverse=False): click.echo(click.style(msg, fg=fg, reverse=reverse)) +def _parse_data(pairs): + """Parse ``KEY=VALUE`` strings into a dict of copier answers. + + Raises ``click.BadParameter`` if a pair is missing the ``=`` separator. + """ + data = {} + for pair in pairs: + key, sep, value = pair.partition("=") + if not sep or not key: + raise click.BadParameter( + f"expected KEY=VALUE, got {pair!r}", param_hint="-d/--data" + ) + data[key] = value + return data + + +def _load_data_file(path): + """Load copier answers from a YAML or JSON file into a dict. + + YAML is a superset of JSON, so a single ``yaml.safe_load`` covers both. + Raises ``click.BadParameter`` if the file is not a mapping. + """ + import yaml + + with open(path, encoding="utf-8") as fh: + loaded = yaml.safe_load(fh) or {} + if not isinstance(loaded, dict): + raise click.BadParameter( + f"{path}: expected a mapping of answers, got {type(loaded).__name__}", + param_hint="--data-file", + ) + return loaded + + +def _collect_data(data_file, data): + """Merge ``--data-file`` answers with inline ``-d`` ones (``-d`` wins).""" + answers = _load_data_file(data_file) if data_file else {} + answers.update(_parse_data(data)) + return answers + + +class InterspersedCommand(click.Command): + """A command that accepts options after its positional arguments. + + The top-level ``cli`` group is chained, which makes Click disable + interspersed args for every subcommand, so e.g. ``plonecli add upgrade_step + --defaults`` would otherwise leak ``--defaults`` back to the parent parser. + Re-enabling it on this command's own parser keeps chaining intact while + letting flags follow the action argument. + """ + + def make_parser(self, ctx): + parser = super().make_parser(ctx) + parser.allow_interspersed_args = True + return parser + + def _get_registry(): """Create a TemplateRegistry with current context.""" config = load_config() @@ -101,7 +158,7 @@ def cli(context, list_templates, versions): pass -class CreateCommand(click.Command): +class CreateCommand(InterspersedCommand): """A Click command that lists available templates in help output.""" def format_help(self, ctx, formatter): @@ -133,8 +190,28 @@ def format_help(self, ctx, formatter): @cli.command(cls=CreateCommand) @click.argument("template", type=click.STRING, shell_complete=get_templates) @click.argument("name") +@click.option( + "-d", + "--data", + "data", + multiple=True, + metavar="KEY=VALUE", + help="Pre-fill a template answer (repeatable). Skips its prompt.", +) +@click.option( + "--data-file", + "data_file", + type=click.Path(exists=True, dir_okay=False), + help="Load answers from a YAML/JSON file. Overridden by -d.", +) +@click.option( + "--defaults", + is_flag=True, + help="Use template defaults for unanswered questions instead of prompting " + "(non-interactive).", +) @click.pass_context -def create(context, template, name): +def create(context, template, name, data, data_file, defaults): """Create a new Plone package""" config = context.obj["config"] reg = TemplateRegistry(config) @@ -147,22 +224,43 @@ def create(context, template, name): possibilities=reg.get_main_templates(), ) + answers = _collect_data(data_file, data) steps = reg.get_composite_steps(resolved) if steps: echo(f"\nCreating {resolved} project: {name}", fg="green", reverse=True) for step in steps: echo(f"\n Applying template: {step}", fg="green") - run_create(step, name, config) + run_create(step, name, config, data=answers, defaults=defaults) else: echo(f"\nCreating {resolved} project: {name}", fg="green", reverse=True) - run_create(resolved, name, config) + run_create(resolved, name, config, data=answers, defaults=defaults) context.obj["target_dir"] = name -@cli.command() +@cli.command(cls=InterspersedCommand) @click.argument("template", type=click.STRING, shell_complete=get_templates) +@click.option( + "-d", + "--data", + "data", + multiple=True, + metavar="KEY=VALUE", + help="Pre-fill a template answer (repeatable). Skips its prompt.", +) +@click.option( + "--data-file", + "data_file", + type=click.Path(exists=True, dir_okay=False), + help="Load answers from a YAML/JSON file. Overridden by -d.", +) +@click.option( + "--defaults", + is_flag=True, + help="Use template defaults for unanswered questions instead of prompting " + "(non-interactive).", +) @click.pass_context -def add(context, template): +def add(context, template, data, data_file, defaults): """Add features to your existing Plone package""" project = context.obj.get("project") if project is None: @@ -179,8 +277,9 @@ def add(context, template): possibilities=reg.get_subtemplates(), ) + answers = _collect_data(data_file, data) echo(f"\nAdding {resolved} to {project.root_folder.name}", fg="green", reverse=True) - run_add(resolved, project, config) + run_add(resolved, project, config, data=answers, defaults=defaults) @cli.command() @@ -315,22 +414,6 @@ def update(context): echo(f"\nTemplates: {get_templates_info(config)}", fg="green") -class InterspersedCommand(click.Command): - """A command that accepts options after its positional arguments. - - The top-level ``cli`` group is chained, which makes Click disable - interspersed args for every subcommand, so ``plonecli skill install - --force`` leaks ``--force`` back to the parent parser. Re-enabling it on - this command's own parser keeps chaining intact while letting flags follow - the action argument. - """ - - def make_parser(self, ctx): - parser = super().make_parser(ctx) - parser.allow_interspersed_args = True - return parser - - @cli.command("skill", cls=InterspersedCommand) @click.argument("action", type=click.Choice(["install", "update", "status"])) @click.option( diff --git a/plonecli/skills/plonecli/SKILL.md b/plonecli/skills/plonecli/SKILL.md index ed3ac5a..ecbc187 100644 --- a/plonecli/skills/plonecli/SKILL.md +++ b/plonecli/skills/plonecli/SKILL.md @@ -43,10 +43,11 @@ On first run, plonecli clones the copier-templates to `~/.copier-templates/plone ## Critical rules +- **`create`/`add` are interactive by default — always run them non-interactively here.** copier opens prompts you cannot answer in Claude Code / CI (they hang or fail). Pass `--defaults` (use template defaults for unasked questions) plus `-d/--data KEY=VALUE` (repeatable) for each answer the user specified — or `--data-file PATH` for a YAML/JSON file of answers (`-d` overrides matching keys). Required answers without a default (`content_type_name`, `behavior_name`, `service_name`, `upgrade_step_title`) **must** be given via `-d`. Example: `plonecli add upgrade_step --defaults -d upgrade_step_title="Reimport viewlets"`. **Never give up on a prompt and hand-write the files the subtemplate would generate** — drive plonecli non-interactively instead. Don't invoke `copier` directly. See [reference/add.md](reference/add.md). - **Never start the dev server yourself.** Do not run `plonecli serve` / `plonecli debug` / `invoke start`. Assume the instance is already running; if it is not, ask the user to start it. (`plonecli test` is fine to run.) - **Use native `uv`.** Run things as `uv run `; never `uv pip` or `pip` unless explicitly told. - **Tests must pass — never skip them.** After scaffolding or adding a feature, run `plonecli test` and report real results. -- **Profile XML changes need an upgrade step — scaffold it automatically.** Whenever you edit GenericSetup profile XML under `profiles/default/` (e.g. `catalog.xml`, `types/*.xml`, `types.xml`, `workflows.xml`, `registry.xml`, `rolemap.xml`) in a way that must propagate to already-installed sites, run `plonecli add upgrade_step` as part of the same change — don't leave it to the user to remember. It bumps `profiles/default/metadata.xml` and registers a GS upgrade handler; then fill that handler so existing sites actually get the change (reapply the relevant import step or migrate data). Never hand-edit `metadata.xml`'s version to "do an upgrade" — that bumps the number without a registered step. Details and what does/doesn't need a step: [reference/add.md](reference/add.md). +- **Profile XML changes need an upgrade step — scaffold it automatically.** Whenever you edit GenericSetup profile XML under `profiles/default/` (e.g. `catalog.xml`, `types/*.xml`, `types.xml`, `workflows.xml`, `registry.xml`, `rolemap.xml`) in a way that must propagate to already-installed sites, run `plonecli add upgrade_step --defaults -d upgrade_step_title=""` as part of the same change — don't leave it to the user to remember. It bumps `profiles/default/metadata.xml` and registers a GS upgrade handler; then fill that handler so existing sites actually get the change (reapply the relevant import step or migrate data). Never hand-edit `metadata.xml`'s version to "do an upgrade" — that bumps the number without a registered step. Details and what does/doesn't need a step: [reference/add.md](reference/add.md). - **Don't recreate to change settings.** Re-running `create` over an existing project is wrong; use the reconfigure flow ([reference/maintain.md](reference/maintain.md)). - After `create`/`add`/reconfigure, generated files change — review `git status`/diff and preserve intentional local edits. @@ -57,10 +58,10 @@ On first run, plonecli clones the copier-templates to `~/.copier-templates/plone plonecli create backend_addon collective.todo cd collective.todo -# add features -plonecli add content_type -plonecli add behavior -plonecli add restapi_service +# add features (non-interactive: --defaults + -d for each answer) +plonecli add content_type --defaults -d content_type_name="Talk" +plonecli add behavior --defaults -d behavior_name="IFeatured" +plonecli add restapi_service --defaults -d service_name="@todos" # wrap it in a runnable Plone instance (adds the zope-setup / invoke harness) plonecli setup diff --git a/plonecli/skills/plonecli/reference/add.md b/plonecli/skills/plonecli/reference/add.md index 59a1188..8e8097a 100644 --- a/plonecli/skills/plonecli/reference/add.md +++ b/plonecli/skills/plonecli/reference/add.md @@ -9,6 +9,23 @@ plonecli add behavior plonecli add restapi_service ``` +## Non-interactive use (required in Claude Code / CI) + +By default `add` (and `create`) drop into copier's **interactive prompts**, which cannot be driven from a non-tty environment — they will hang or fail. **Never work around this by hand-rolling the files copier would generate.** Instead drive plonecli non-interactively: + +- `--defaults` — answer every question from the template's defaults (no prompts). +- `-d/--data KEY=VALUE` — pre-fill a specific answer (repeatable); overrides the default and skips that prompt. +- `--data-file PATH` — load answers from a YAML/JSON file (handy for many answers); inline `-d` overrides matching keys. + +```shell +# fully non-interactive: defaults for everything, override the few you care about +plonecli add content_type --defaults -d content_type_name="Talk" -d content_type_description="A conference talk" +plonecli add behavior --defaults -d behavior_name="IFeatured" +plonecli add restapi_service --defaults -d service_name="@todos" +``` + +Pass `-d` for every answer the user has specified; `--defaults` covers the rest. Required answers that have no default (e.g. `content_type_name`, `behavior_name`, `service_name`, `upgrade_step_title`) **must** be supplied with `-d` or copier still has to prompt. Don't invent values the user hasn't given — ask first, then pass them via `-d`. The per-template question/answer keys are listed below and shown by the prompts themselves. + ## Subtemplates are gated by project type plonecli detects the project type from `pyproject.toml` (e.g. `backend_addon`, `project`). Only subtemplates whose `parent` matches that type are offered. So: @@ -26,7 +43,7 @@ Always confirm what is actually available here with `plonecli -l` (run inside th | `behavior` | A behavior (reusable schema/marker applied to content types). | Wire the behavior onto a content type; add tests. | | `restapi_service` | A `plone.restapi` service (endpoint, adapter, registration). | Add tests exercising the endpoint. | -copier will prompt interactively for the specifics (names, fields, options) — answer per the user's requirements. Do not invent answers; if the user hasn't specified e.g. field names, ask. +copier asks for the specifics (names, fields, options). In Claude Code / CI you cannot answer prompts, so run non-interactively with `--defaults` and pass the user's choices via `-d KEY=VALUE` (see "Non-interactive use" above). Do not invent answers; if the user hasn't specified e.g. field names, ask, then pass them via `-d`. ## After adding @@ -40,10 +57,11 @@ When you change GenericSetup profile XML under `profiles/default/` in a way that ```shell cd collective.todo -plonecli add upgrade_step +# non-interactive: title is required, the rest default from the addon +plonecli add upgrade_step --defaults -d upgrade_step_title="Reimport viewlets" ``` -It prompts for (defaults injected from the addon): +Questions (defaults injected from the addon — pass `-d` to override any): | Question | Default | Meaning | |---|---|---| @@ -52,6 +70,8 @@ It prompts for (defaults injected from the addon): | `source_version` | current `metadata.xml` version | Version being upgraded **from**. | | `destination_version` | `source + 1` | Version being upgraded **to**. | +`upgrade_step_title` has no default, so it **must** be passed with `-d` — otherwise copier still prompts. Do not skip the command and hand-write the upgrade step files yourself; run it non-interactively as above. + What it does (so you don't do it by hand): - Bumps `profiles/default/metadata.xml` to `destination_version`. diff --git a/plonecli/skills/plonecli/reference/create.md b/plonecli/skills/plonecli/reference/create.md index a192ada..385c534 100644 --- a/plonecli/skills/plonecli/reference/create.md +++ b/plonecli/skills/plonecli/reference/create.md @@ -6,6 +6,8 @@ plonecli create addon collective.todo # backend add-on + zope-setup ( plonecli create zope-setup my-project # Zope project setup ``` +Like `add`, `create` is interactive by default. In Claude Code / CI run it non-interactively with `--defaults` (use template defaults) plus `-d/--data KEY=VALUE` for any answer you want to set, e.g. `plonecli create backend_addon collective.todo --defaults -d package_description="Todo manager"`. Do not call `copier` directly. + ## Project templates Discover them live with `plonecli -l` (this is authoritative — the registry scans `copier.yml` files in the templates clone, so available templates depend on the configured template repo/branch). diff --git a/plonecli/templates.py b/plonecli/templates.py index 54bb998..312f173 100644 --- a/plonecli/templates.py +++ b/plonecli/templates.py @@ -134,6 +134,7 @@ def run_create( target_name: str, config: PlonecliConfig, data: dict | None = None, + defaults: bool = False, ) -> None: """Run copier to create a new project from a main template. @@ -142,6 +143,8 @@ def run_create( target_name: Output directory name. config: Global plonecli configuration. data: Optional answers to pre-fill (skips interactive prompts for these). + defaults: Use template defaults for unanswered questions instead of + prompting (non-interactive mode). """ ensure_templates_cloned(config) src = str(get_template_path(template_name, config)) @@ -151,6 +154,7 @@ def run_create( dst_path=target_name, data=data or {}, user_defaults=_build_user_defaults(config), + defaults=defaults, unsafe=True, ) @@ -160,6 +164,7 @@ def run_add( project: ProjectContext, config: PlonecliConfig, data: dict | None = None, + defaults: bool = False, ) -> None: """Run copier to add a subtemplate to an existing project. @@ -168,6 +173,8 @@ def run_add( project: Detected project context. config: Global plonecli configuration. data: Optional extra answers. + defaults: Use template defaults for unanswered questions instead of + prompting (non-interactive mode). """ ensure_templates_cloned(config) src = str(get_template_path(template_name, config)) @@ -186,5 +193,6 @@ def run_add( dst_path=str(project.root_folder), data=template_data, user_defaults=_build_user_defaults(config), + defaults=defaults, unsafe=True, ) diff --git a/tests/test_all_templates_data.py b/tests/test_all_templates_data.py new file mode 100644 index 0000000..fe73c67 --- /dev/null +++ b/tests/test_all_templates_data.py @@ -0,0 +1,240 @@ +"""Verify every template's settings can be driven non-interactively via data. + +This covers the contract behind ``plonecli create/add --data KEY=VALUE``: for +every copier template shipped by the templates repo we must be able to answer +*every* question through ``data`` so copier never has to open an interactive +prompt (which can't be driven from a non-tty environment such as Claude Code or +CI). + +Two layers: + +* :func:`test_data_covers_all_questions` (always runs) parses each template's + ``copier.yml`` and asserts the answer builder produces a value for every + user-facing question. It guards against template drift — add a new question + and this fails until it is answerable. +* :func:`test_template_generates_non_interactively` (``integration``, opt-in) + actually generates every template with those answers using ``defaults=False`` + and no tty, proving copier never falls back to prompting. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest +import yaml + +from plonecli.config import PlonecliConfig +from plonecli.project import find_project_root +from plonecli.templates import run_add, run_create + + +DEV_TEMPLATES_DIR = Path("/home/node/develop/plone/src/copier-templates") +FALLBACK_TEMPLATES_DIR = Path("/home/node/.copier-templates/plone-copier-templates") + +# Required-but-defaultless questions, plus anything whose default is a Jinja +# expression we cannot render here, get a concrete, validator-passing value. +SPECIAL_VALUES = { + "package_name": "collective.datatest", + "plone_version": "6.1", + "behavior_name": "Featured", + "content_type_name": "Article", + "service_name": "myservice", + "upgrade_step_title": "Reimport viewlets", +} + + +def _find_templates_dir() -> Path | None: + if DEV_TEMPLATES_DIR.exists(): + return DEV_TEMPLATES_DIR + if FALLBACK_TEMPLATES_DIR.exists(): + return FALLBACK_TEMPLATES_DIR + return None + + +def _templates_dir() -> Path: + templates_dir = _find_templates_dir() + if templates_dir is None: + pytest.skip("No copier-templates checkout available") + return templates_dir + + +def _all_templates(): + """Yield (name, copier.yml-dict) for every real (non-composite) template. + + Evaluated at collection time, so it must not call ``pytest.skip`` (that is + only valid inside a test). When no templates checkout is available it yields + nothing, leaving the parametrized tests with an empty parameter set. + """ + templates_dir = _find_templates_dir() + if templates_dir is None: + return + for cfg in sorted(templates_dir.glob("*/copier.yml")): + data = yaml.safe_load(cfg.read_text()) + meta = data.get("_plonecli", {}) + # Composite templates (e.g. ``addon``) have no questions of their own; + # they are just an ordered list of other templates. + if meta.get("type") == "composite": + continue + yield cfg.parent.name, data + + +def _user_facing_questions(template_data: dict) -> dict: + """Return ``{name: spec}`` for questions copier may actually ask. + + Skips copier internals (``_*``) and computed values (``when: false``), which + are never prompted and so need no answer. + """ + questions = {} + for name, spec in template_data.items(): + if name.startswith("_") or not isinstance(spec, dict): + continue + if spec.get("when") is False: + continue + questions[name] = spec + return questions + + +def _is_jinja(value) -> bool: + return isinstance(value, str) and ("{{" in value or "{%" in value) + + +def _answer_for(name: str, spec: dict): + """Resolve a concrete, type-correct answer for a single question.""" + if name in SPECIAL_VALUES: + return SPECIAL_VALUES[name] + + qtype = spec.get("type", "str") + choices = spec.get("choices") + default = spec.get("default") + + if qtype == "bool": + return bool(default) if isinstance(default, bool) else True + + # Literal choice lists: prefer a static default, else the first option. + if isinstance(choices, list) and choices: + if isinstance(default, str) and not _is_jinja(default) and default in choices: + return default + return choices[0] + + if qtype == "int": + return default if isinstance(default, int) else 8080 + + # Plain string: a static default is always safe; otherwise synthesise a + # non-empty value (enough to satisfy "is required" validators). + if isinstance(default, str) and not _is_jinja(default) and default: + return default + return "Testing" + + +def _has_dynamic_choices(spec: dict) -> bool: + """Choices computed at render time (a Jinja string), e.g. ``plone_version``. + + Their valid set is only known once copier runs the template's extensions + (which may hit the network), so we cannot pick a value blindly — we leave + these to the template's own default in non-interactive mode. + """ + return isinstance(spec.get("choices"), str) + + +def _build_answers(template_data: dict) -> dict: + return { + name: _answer_for(name, spec) + for name, spec in _user_facing_questions(template_data).items() + } + + +def _copier_data(template_data: dict) -> dict: + """Answers safe to feed copier directly (excludes dynamic-choice fields).""" + return { + name: _answer_for(name, spec) + for name, spec in _user_facing_questions(template_data).items() + if not _has_dynamic_choices(spec) + } + + +@pytest.mark.parametrize( + "name,template_data", list(_all_templates()), ids=lambda v: v if isinstance(v, str) else "" +) +def test_data_covers_all_questions(name, template_data): + """Every user-facing question of every template gets a concrete answer.""" + questions = _user_facing_questions(template_data) + answers = _build_answers(template_data) + + missing = set(questions) - set(answers) + assert not missing, f"{name}: no data value resolved for {sorted(missing)}" + + for qname, value in answers.items(): + assert value is not None, f"{name}: {qname} resolved to None" + qtype = questions[qname].get("type", "str") + if qtype == "bool": + assert isinstance(value, bool), f"{name}: {qname} must be bool" + elif qtype == "int": + assert isinstance(value, int), f"{name}: {qname} must be int" + else: + assert isinstance(value, str) and value, f"{name}: {qname} must be non-empty str" + + +def _generate_main(name, template_data, config, tmp_path) -> Path: + project_dir = tmp_path / "collective.datatest" + run_create( + name, + str(project_dir), + config, + data=_copier_data(template_data), + defaults=True, + ) + return project_dir + + +@pytest.fixture(scope="module") +def _parents(tmp_path_factory): + """Generate one backend_addon and one zope-setup parent to add subs into.""" + config = PlonecliConfig(templates_dir=str(_templates_dir())) + parents = {} + for main in ("backend_addon", "zope-setup"): + cfg = _templates_dir() / main / "copier.yml" + if not cfg.exists(): + continue + data = yaml.safe_load(cfg.read_text()) + base = tmp_path_factory.mktemp(f"parent-{main}") + parents[main] = _generate_main(main, data, config, base) + return parents + + +@pytest.mark.integration +@pytest.mark.parametrize( + "name,template_data", list(_all_templates()), ids=lambda v: v if isinstance(v, str) else "" +) +def test_template_generates_non_interactively(name, template_data, _parents, tmp_path): + """Each template generates non-interactively — mirroring ``--defaults``. + + ``defaults=True`` matches ``plonecli create/add --defaults``: copier never + prompts, taking our ``data`` for every setting we override and the + template's own default for the rest (e.g. the network-fetched + ``plone_version`` list). Success proves the template can be driven without a + tty and that all our supplied values pass copier's validators and choices. + """ + if shutil.which("uv") is None: + pytest.skip("uv is required to run template post-copy hooks") + + config = PlonecliConfig(templates_dir=str(_templates_dir())) + meta = template_data.get("_plonecli", {}) + + if meta.get("type") == "main": + _generate_main(name, template_data, config, tmp_path) + return + + # Subtemplate: copy the relevant parent and add into the copy. + parent_name = meta.get("parent") + parent_dir = _parents.get(parent_name) + if parent_dir is None: + pytest.skip(f"no generated parent for {parent_name!r}") + + work = tmp_path / parent_dir.name + shutil.copytree(parent_dir, work) + project = find_project_root(work) + assert project is not None, f"{name}: parent project not detected in {work}" + + run_add(name, project, config, data=_copier_data(template_data), defaults=True) diff --git a/tests/test_plonecli.py b/tests/test_plonecli.py index 225f25a..ac4dd13 100644 --- a/tests/test_plonecli.py +++ b/tests/test_plonecli.py @@ -178,6 +178,163 @@ def test_add_command(mock_ensure, mock_run_add, mock_config, mock_project, runne mock_run_add.assert_called_once() +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_add") +@patch("plonecli.cli.ensure_templates_cloned") +def test_add_non_interactive( + mock_ensure, mock_run_add, mock_config, mock_project, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + _make_template(tmp_path, "upgrade_step", {"type": "sub", "parent": "backend_addon"}) + + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = MagicMock( + root_folder=tmp_path, + project_type="backend_addon", + package_name="test.addon", + package_folder="test/addon", + settings={}, + ) + + result = runner.invoke( + cli, + [ + "add", + "upgrade_step", + "--defaults", + "-d", + "upgrade_step_title=Reimport viewlets", + "--data", + "destination_version=1002", + ], + ) + assert result.exit_code == 0 + mock_run_add.assert_called_once() + kwargs = mock_run_add.call_args.kwargs + assert kwargs["defaults"] is True + assert kwargs["data"] == { + "upgrade_step_title": "Reimport viewlets", + "destination_version": "1002", + } + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_add") +@patch("plonecli.cli.ensure_templates_cloned") +def test_add_data_file_merges_with_inline_data( + mock_ensure, mock_run_add, mock_config, mock_project, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + _make_template(tmp_path, "upgrade_step", {"type": "sub", "parent": "backend_addon"}) + + data_file = tmp_path / "answers.yml" + data_file.write_text( + "upgrade_step_title: From file\nupgrade_step_description: From file\n" + ) + + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = MagicMock( + root_folder=tmp_path, + project_type="backend_addon", + package_name="test.addon", + package_folder="test/addon", + settings={}, + ) + + result = runner.invoke( + cli, + [ + "add", + "upgrade_step", + "--data-file", + str(data_file), + # inline -d overrides the same key from the file + "-d", + "upgrade_step_title=Inline wins", + ], + ) + assert result.exit_code == 0 + kwargs = mock_run_add.call_args.kwargs + assert kwargs["data"] == { + "upgrade_step_title": "Inline wins", + "upgrade_step_description": "From file", + } + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_add") +@patch("plonecli.cli.ensure_templates_cloned") +def test_add_data_file_missing_fails( + mock_ensure, mock_run_add, mock_config, mock_project, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + _make_template(tmp_path, "upgrade_step", {"type": "sub", "parent": "backend_addon"}) + + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = MagicMock( + root_folder=tmp_path, + project_type="backend_addon", + package_name="test.addon", + package_folder="test/addon", + settings={}, + ) + + result = runner.invoke( + cli, ["add", "upgrade_step", "--data-file", str(tmp_path / "nope.yml")] + ) + assert result.exit_code != 0 + mock_run_add.assert_not_called() + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_add") +@patch("plonecli.cli.ensure_templates_cloned") +def test_add_data_without_separator_fails( + mock_ensure, mock_run_add, mock_config, mock_project, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + _make_template(tmp_path, "upgrade_step", {"type": "sub", "parent": "backend_addon"}) + + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = MagicMock( + root_folder=tmp_path, + project_type="backend_addon", + package_name="test.addon", + package_folder="test/addon", + settings={}, + ) + + result = runner.invoke(cli, ["add", "upgrade_step", "-d", "no_separator"]) + assert result.exit_code != 0 + mock_run_add.assert_not_called() + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_create") +@patch("plonecli.cli.ensure_templates_cloned") +def test_create_non_interactive( + mock_ensure, mock_run_create, mock_config, mock_project, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + result = runner.invoke( + cli, + ["create", "backend_addon", "my.addon", "--defaults", "-d", "description=Demo"], + ) + + assert result.exit_code == 0 + mock_run_create.assert_called_once() + kwargs = mock_run_create.call_args.kwargs + assert kwargs["defaults"] is True + assert kwargs["data"] == {"description": "Demo"} + + @patch("plonecli.cli.find_project_root", return_value=None) @patch("plonecli.cli.load_config") def test_add_outside_project(mock_config, mock_project, runner, tmp_path): diff --git a/uv.lock b/uv.lock index 4be30e2..9da4423 100644 --- a/uv.lock +++ b/uv.lock @@ -579,7 +579,7 @@ wheels = [ [[package]] name = "plonecli" -version = "7.0.0b3.dev0" +version = "7.0.0b6.dev0" source = { editable = "." } dependencies = [ { name = "click" },