diff --git a/CHANGES.md b/CHANGES.md index 6f06706..e386502 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,7 +3,54 @@ ## 7.0.0b14 (unreleased) -- Nothing changed yet. +- Save `~/.plonecli/config.toml` with a real TOML writer, so quotes, backslashes + and newlines in any value round-trip instead of producing a file plonecli can + no longer read. An unreadable config now fails with a message naming the path. + [MrTango] + +- Compare versions with PEP 440 in the update check, so users on a beta are told + about newer betas and a final release is never "updated" to an older one. + [MrTango] + +- Abort instead of proceeding when a template runs on a git repository with + uncommitted changes in non-interactive mode (`--defaults` or no terminal). New + `--allow-dirty` flag on `create`, `add` and `setup` opts back in. + [MrTango] + +- Give `setup` the `--data`, `--data-file` and `--defaults` options of + `create`/`add`, so a backend addon can be bootstrapped from a script or CI. + [MrTango] + +- Restore test filtering: `plonecli test -t NAME` runs a single test and + `-s TARGET` restricts the run to one package. Both pass through to the + generated `invoke test` task; projects with an older `tasks.py` get a message + naming the fix instead of an unknown-flag error from invoke. + [MrTango] + +- `plonecli test` now exits with the test run's exit code, so a failing test run + fails the command. + [MrTango] + +- Report a failed git auto-commit as a coloured error on stderr naming the + uncommitted directory, instead of a bare stdout print. + [MrTango] + +- Explain what is missing when `serve`, `test` or `debug` cannot run the invoke + tasks - no `uv` on PATH, no generated `tasks.py`, or a project that does not + declare `invoke` (dev group) or `pytest` (`test` extra) - instead of failing + inside a subprocess. + [MrTango] + +- Fix `plonecli completion --install`, which failed with + "No such option: --install" because the chained top-level group disables + interspersed arguments. + [MrTango] + +- Add CLI-level tests for `config`, `update`, `setup` and `completion`, unit + tests for the Plone-version fetching module, and update-banner tests. Document + the full command set, the non-interactive options and the test filters in the + README. + [MrTango] ## 7.0.0b13 (2026-07-24) diff --git a/README.md b/README.md index 043b681..99ba5c9 100644 --- a/README.md +++ b/README.md @@ -132,21 +132,49 @@ This creates `~/.plonecli/config.toml` with your settings. plonecli --help Commands: - add Add features to your existing Plone package - config Configure plonecli global settings - create Create a new Plone package - debug Start the Plone instance in debug mode - serve Start the Plone instance - setup Run zope-setup inside an existing backend_addon - test Run the tests in your package - update Update copier-templates and check for plonecli updates + add Add features to your existing Plone package + completion Show or install shell completion + config Configure plonecli global settings + create Create a new Plone package + debug Start the Plone instance in debug mode + serve Start the Plone instance + setup Run zope-setup inside an existing backend_addon + skill Install/update the bundled Agent Skills for AI coding agents + test Run the tests in your package + update Update copier-templates and check for plonecli updates Options: -l, --list-templates List available templates - -V, --versions Show version information + -V, --versions Show plonecli and copier-templates versions -h, --help Show this message and exit. ``` +The list is context-aware: outside a Plone project only the global commands +(`completion`, `config`, `create`, `skill`, `update`) are shown; inside one, +`create` is replaced by the project commands. + +`create`, `add` and `setup` share the non-interactive options, so a package can +be bootstrapped from a script or CI: + +```shell +plonecli create addon collective.todo --defaults -d description="Todo lists" +plonecli add content_type --defaults --data-file answers.yml +plonecli setup --defaults -d plone_version=6.1.1 +``` + +| Option | What it does | +|---------------------|--------------------------------------------------------------------| +| `-d KEY=VALUE` | Pre-fill a template answer (repeatable), skipping its prompt | +| `--data-file FILE` | Load answers from a YAML/JSON file (`-d` wins on conflicts) | +| `--defaults` | Use template defaults for unanswered questions instead of prompting | +| `--allow-dirty` | Run even if the git repository has uncommitted changes | +| `--no-git` | Skip the auto-commit (`create`, `add`) | + +On a repository with uncommitted changes, an interactive run asks whether to +continue, and a non-interactive one (`--defaults`, or no terminal) aborts so +generated files never silently mix into your work in progress. Pass +`--allow-dirty` when that mixing is intended. + ### Creating a Plone Add-on @@ -205,6 +233,20 @@ With verbose output: plonecli test --verbose ``` +Run a single test, or restrict the run to one package: + +```shell +plonecli test -t test_behavior_installed +plonecli test -s src/collective/todo +``` + +Both are passed to the project's `invoke test` task: `-t/--test` becomes pytest's +`-k`, and `-s/--package` becomes the pytest target path. `plonecli test` exits +with the test run's exit code, so it can gate a script or a CI job. + +Projects generated before the task gained these parameters need their `tasks.py` +refreshed with `plonecli update && plonecli setup`. + ### Debug Mode @@ -314,6 +356,10 @@ local_path = "~/.copier-templates/plone-copier-templates" The default Plone version is fetched from `https://dist.plone.org/release/` and cached for 24 hours. +Run `plonecli config` to (re)write the file interactively. If it ever becomes +unreadable, plonecli says so and names the path — delete it and run +`plonecli config` again to start fresh. + ### Environment Variables You can override template configuration using environment variables. These take precedence over the config file: diff --git a/evals/skill/README.md b/evals/skill/README.md index 2508df1..8cae1fc 100644 --- a/evals/skill/README.md +++ b/evals/skill/README.md @@ -61,6 +61,7 @@ means a skill leaked into the baseline. | `restapi-implicit` | The skill *triggers* when the prompt never says "plonecli" | | `fields-manual` | Fields are hand-edited into the schema (`plone-schema-fields` skill) | | `upgrade-step` | Profile-XML edits for installed sites get `plonecli add upgrade_step` | +| `uninstall-mirror` | Recreatable settings in `profiles/default` are mirrored for removal in `profiles/uninstall` | | `no-serve` | The agent never starts the dev server itself | | `legacy-adapt` | Legacy packages get minimal adaptation, not re-scaffolding | | `reconfigure` | Settings changes use `invoke reconfigure`, not `create` | diff --git a/evals/skill/run_evals.py b/evals/skill/run_evals.py index 458a8cb..1f083e7 100644 --- a/evals/skill/run_evals.py +++ b/evals/skill/run_evals.py @@ -259,6 +259,31 @@ class Case: "needs plonecli add upgrade_step." ), ), + Case( + id="uninstall-mirror", + prompt=( + "Add a boolean catalog index is_featured to the GenericSetup " + "profile of the collective.demo add-on in this directory, and make " + "sure uninstalling the add-on cleans the index up again." + ), + fixture="addon", + checks=[ + file_has( + "collective.demo/src/collective/demo/profiles/default/catalog.xml", + r"is_featured", + "default catalog.xml gains the index", + ), + file_has( + "collective.demo/src/collective/demo/profiles/uninstall/catalog.xml", + r'is_featured(?s).*remove="True"|remove="True"(?s).*is_featured', + "uninstall catalog.xml removes the index (remove=\"True\")", + ), + ], + notes=( + "Uninstall rule: a recreatable setting added to profiles/default " + "must be mirrored for removal in profiles/uninstall." + ), + ), Case( id="no-serve", prompt=( diff --git a/plonecli/cli.py b/plonecli/cli.py index b274f0d..3ef604f 100644 --- a/plonecli/cli.py +++ b/plonecli/cli.py @@ -3,14 +3,20 @@ from __future__ import annotations import importlib.metadata +import re +import shutil import subprocess import sys +import tomllib +from pathlib import Path import click from click_aliases import ClickAliasedGroup from plonecli.config import load_config, save_config from plonecli.exceptions import NoSuchValue, NotInPackageError +from plonecli.git import dirty_files +from plonecli.output import echo from plonecli.project import find_project_root from plonecli.registry import TemplateRegistry from plonecli.templates import ( @@ -22,10 +28,6 @@ ) -def echo(msg, fg="green", reverse=False): - click.echo(click.style(msg, fg=fg, reverse=reverse)) - - def ensure_templates(config): """Clone the copier-templates on first use. @@ -34,8 +36,6 @@ def ensure_templates(config): a freshly installed plonecli works without a manual ``plonecli update``. Idempotent: a no-op once the clone is present. """ - from pathlib import Path - templates_dir = Path(config.templates_dir) if not (templates_dir / ".git").exists(): echo("\nFetching copier-templates (first run)...", fg="green") @@ -47,15 +47,13 @@ def _is_interactive(): return sys.stdin.isatty() -def confirm_clean_git(target_dir, defaults: bool) -> bool: +def confirm_clean_git(target_dir, defaults: bool, allow_dirty: bool = False) -> bool: """Warn and ask to continue if the target repo has uncommitted changes. - Returns True to proceed, False if the user cancels. The prompt defaults to - *cancel* so an accidental Enter is safe. In non-interactive mode (``--defaults`` - or no tty) the warning is printed but the run proceeds without prompting. + Returns True to proceed, False if the user cancels; the prompt defaults to + *cancel*. In non-interactive mode (``--defaults`` or no tty) there is nobody + to ask, so a dirty tree aborts. ``allow_dirty`` skips the check. """ - from plonecli.git import dirty_files - modified, untracked = dirty_files(target_dir) if not modified and not untracked: return True @@ -67,8 +65,14 @@ def confirm_clean_git(target_dir, defaults: bool) -> bool: echo(f" untracked: {f}", fg="yellow") echo("", fg="yellow") - if defaults or not _is_interactive(): + if allow_dirty: return True + if defaults or not _is_interactive(): + raise click.ClickException( + "Refusing to run on a git repository with uncommitted changes in " + "non-interactive mode.\n" + "Commit or stash them, or pass --allow-dirty to proceed anyway." + ) return click.confirm("Continue anyway?", default=False) @@ -113,6 +117,45 @@ def _collect_data(data_file, data): return answers +def template_run_options(command): + """Add the answer, non-interactive and dirty-tree options. + + Shared by ``create``, ``add`` and ``setup``; only ``--no-git`` differs. + """ + for option in reversed( + [ + 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.option( + "--allow-dirty", + "allow_dirty", + is_flag=True, + help="Run even if the git repository has uncommitted changes.", + ), + ] + ): + command = option(command) + return command + + class InterspersedCommand(click.Command): """A command that accepts options after its positional arguments. @@ -162,8 +205,20 @@ def list_commands(self, ctx): context_settings={"help_option_names": ["-h", "--help"]}, invoke_without_command=True, ) -@click.option("-l", "--list-templates", "list_templates", is_flag=True) -@click.option("-V", "--versions", "versions", is_flag=True) +@click.option( + "-l", + "--list-templates", + "list_templates", + is_flag=True, + help="List available templates.", +) +@click.option( + "-V", + "--versions", + "versions", + is_flag=True, + help="Show plonecli and copier-templates versions.", +) @click.pass_context def cli(context, list_templates, versions): """Plone Command Line Interface (CLI)""" @@ -239,26 +294,7 @@ 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).", -) +@template_run_options @click.option( "--no-git", "no_git", @@ -266,7 +302,7 @@ def format_help(self, ctx, formatter): help="Do not initialise git or auto-commit the generated package.", ) @click.pass_context -def create(context, template, name, data, data_file, defaults, no_git): +def create(context, template, name, data, data_file, defaults, no_git, allow_dirty): """Create a new Plone package""" config = context.obj["config"] ensure_templates(config) @@ -280,7 +316,7 @@ def create(context, template, name, data, data_file, defaults, no_git): possibilities=reg.get_main_templates(), ) - if not confirm_clean_git(name, defaults): + if not confirm_clean_git(name, defaults, allow_dirty): echo("Aborted.", fg="yellow") return @@ -319,26 +355,7 @@ def create(context, template, name, data, data_file, defaults, no_git): @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).", -) +@template_run_options @click.option( "--no-git", "no_git", @@ -346,7 +363,7 @@ def create(context, template, name, data, data_file, defaults, no_git): help="Do not auto-commit the changes made by this subtemplate.", ) @click.pass_context -def add(context, template, data, data_file, defaults, no_git): +def add(context, template, data, data_file, defaults, no_git, allow_dirty): """Add features to your existing Plone package""" project = context.obj.get("project") if project is None: @@ -364,7 +381,7 @@ def add(context, template, data, data_file, defaults, no_git): possibilities=reg.get_subtemplates(), ) - if not confirm_clean_git(project.root_folder, defaults): + if not confirm_clean_git(project.root_folder, defaults, allow_dirty): echo("Aborted.", fg="yellow") return @@ -384,8 +401,9 @@ def add(context, template, data, data_file, defaults, no_git): @cli.command() +@template_run_options @click.pass_context -def setup(context): +def setup(context, data, data_file, defaults, allow_dirty): """Run zope-setup inside an existing backend_addon""" project = context.obj.get("project") if project is None: @@ -395,13 +413,120 @@ def setup(context): "The 'setup' command can only be run inside a backend_addon project." ) - if not confirm_clean_git(project.root_folder, defaults=False): + if not confirm_clean_git(project.root_folder, defaults, allow_dirty): echo("Aborted.", fg="yellow") return config = context.obj["config"] + answers = _collect_data(data_file, data) echo("\nRunning zope-setup...", fg="green", reverse=True) - run_create("zope-setup", str(project.root_folder), config, overwrite=True) + run_create( + "zope-setup", + str(project.root_folder), + config, + data=answers, + defaults=defaults, + overwrite=True, + ) + + +TASKS_FILE = "tasks.py" + +# Where the zope-setup template declares each tool the tasks need, used to name +# the fix when a project is missing one. +_TASK_TOOLS = { + "invoke": "the 'dev' dependency group", + "pytest": "the 'test' extra", +} + + +def _declared_distributions(pyproject: Path) -> set[str] | None: + """Normalised names of every distribution the project declares. + + Covers ``dependencies``, every optional-dependency extra and every + dependency group. Returns None if pyproject.toml cannot be read, leaving + callers no basis to complain. + """ + try: + with open(pyproject, "rb") as f: + doc = tomllib.load(f) + except (tomllib.TOMLDecodeError, OSError): + return None + + requirements = list(doc.get("project", {}).get("dependencies", [])) + for group in doc.get("project", {}).get("optional-dependencies", {}).values(): + requirements += group + for group in doc.get("dependency-groups", {}).values(): + requirements += [r for r in group if isinstance(r, str)] + + return {_distribution_name(r) for r in requirements} + + +def _distribution_name(requirement: str) -> str: + """The bare distribution name of a PEP 508 requirement, normalised.""" + name = re.split(r"[\s\[<>=!~;@]", requirement.strip(), maxsplit=1)[0] + return name.lower().replace("_", "-") + + +def _require_task_runner(project, tools=("invoke",)) -> None: + """Fail with guidance if the project's invoke tasks cannot run. + + Covers the three ways ``uv run invoke `` dies with a raw error: no + ``uv`` on PATH, no generated ``tasks.py``, or a ``tools`` entry the project + never declared. + """ + if shutil.which("uv") is None: + raise click.ClickException( + "uv was not found on PATH, but plonecli runs the project tasks with " + "'uv run invoke'.\n" + "Install uv: https://docs.astral.sh/uv/getting-started/installation/" + ) + + if not (project.root_folder / TASKS_FILE).exists(): + raise click.ClickException( + f"No {TASKS_FILE} in {project.root_folder}, so this project has no " + "invoke tasks to run.\n" + f"{TASKS_FILE} comes from the zope-setup template. Add it with: " + "plonecli setup (inside a backend addon)" + ) + + declared = _declared_distributions(project.root_folder / "pyproject.toml") + if declared is None: + return + missing = [tool for tool in tools if tool not in declared] + if missing: + needed = ", ".join(f"{tool} in {_TASK_TOOLS[tool]}" for tool in missing) + raise click.ClickException( + f"{project.root_folder / 'pyproject.toml'} does not declare " + f"{', '.join(missing)}, so the invoke tasks cannot run.\n" + f"The zope-setup template declares {needed}; add it there, or " + "re-apply the template with: plonecli setup" + ) + + +_TEST_TASK_SIGNATURE = re.compile(r"^def test\((?P[^)]*)\)", re.MULTILINE) + + +def _unsupported_test_options(project, wanted: list[str]) -> list[str]: + """Which of ``wanted`` the project's generated ``test`` task does not accept. + + An unreadable or unrecognised ``tasks.py`` yields an empty list: let invoke + have the final word rather than guess. + """ + try: + source = (project.root_folder / TASKS_FILE).read_text(encoding="utf-8") + except OSError: + return [] + + match = _TEST_TASK_SIGNATURE.search(source) + if not match: + return [] + + accepted = { + param.split("=")[0].split(":")[0].strip() + for param in match.group("params").split(",") + } + return [name for name in wanted if name not in accepted] @cli.command("serve") @@ -411,6 +536,7 @@ def run_serve(context): project = context.obj.get("project") if project is None: raise NotInPackageError(context.command.name) + _require_task_runner(project) params = ["uv", "run", "invoke", "start"] echo(f"\nRUN: {' '.join(params)}", fg="green", reverse=True) echo("\nINFO: Open this in a Web Browser: http://localhost:8080") @@ -418,19 +544,49 @@ def run_serve(context): subprocess.call(params, cwd=str(project.root_folder)) -@cli.command("test") +@cli.command("test", cls=InterspersedCommand) @click.option("-v", "--verbose", is_flag=True, help="Verbose test output") +@click.option( + "-t", + "--test", + "test", + metavar="NAME", + help="Run only tests matching NAME (pytest -k).", +) +@click.option( + "-s", + "--package", + "package", + metavar="TARGET", + help="Restrict the run to one pytest target path, e.g. src/collective/todo.", +) @click.pass_context -def run_test(context, verbose): +def run_test(context, verbose, test, package): """Run the tests in your package (delegates to invoke test)""" project = context.obj.get("project") if project is None: raise NotInPackageError(context.command.name) + _require_task_runner(project, tools=("invoke", "pytest")) + params = ["uv", "run", "invoke", "test"] + filters = {"test": test, "package": package} + requested = [name for name, value in filters.items() if value] + unsupported = _unsupported_test_options(project, requested) + if unsupported: + flags = ", ".join(f"--{name}" for name in unsupported) + raise click.ClickException( + f"The invoke 'test' task in {project.root_folder / TASKS_FILE} does not " + f"accept {flags}.\n" + "Refresh the generated tasks.py: plonecli update && plonecli setup" + ) + for name in requested: + params += [f"--{name}", filters[name]] if verbose: params.append("--verbose") + echo(f"\nRUN: {' '.join(params)}", fg="green", reverse=True) - subprocess.call(params, cwd=str(project.root_folder)) + # Propagate the result so a failing test run fails the command too. + context.exit(subprocess.call(params, cwd=str(project.root_folder))) @cli.command("debug") @@ -440,6 +596,7 @@ def run_debug(context): project = context.obj.get("project") if project is None: raise NotInPackageError(context.command.name) + _require_task_runner(project) params = ["uv", "run", "invoke", "debug"] echo(f"\nRUN: {' '.join(params)}", fg="green", reverse=True) echo("INFO: You can stop it by pressing CTRL + c\n") @@ -580,7 +737,7 @@ def skill(context, action, scope, copy_only, force): echo(f" copied {act.target}") -@cli.command() +@cli.command(cls=InterspersedCommand) @click.argument( "shell", required=False, diff --git a/plonecli/config.py b/plonecli/config.py index 7ef45cc..74839d6 100644 --- a/plonecli/config.py +++ b/plonecli/config.py @@ -8,6 +8,10 @@ from dataclasses import dataclass from pathlib import Path +import tomli_w + +from plonecli.exceptions import ConfigError + CONFIG_DIR = Path.home() / ".plonecli" CONFIG_FILE = CONFIG_DIR / "config.toml" TEMPLATES_DIR = Path.home() / ".copier-templates" / "plone-copier-templates" @@ -44,8 +48,15 @@ def load_config() -> PlonecliConfig: """ config = PlonecliConfig() if CONFIG_FILE.exists(): - with open(CONFIG_FILE, "rb") as f: - data = tomllib.load(f) + try: + with open(CONFIG_FILE, "rb") as f: + data = tomllib.load(f) + except (tomllib.TOMLDecodeError, OSError) as exc: + raise ConfigError( + f"Could not read the plonecli config at {CONFIG_FILE}: {exc}\n" + f"Fix the file, or delete it and run 'plonecli config' to recreate " + f"it: rm {CONFIG_FILE}" + ) from exc author = data.get("author", {}) defaults = data.get("defaults", {}) @@ -93,27 +104,29 @@ def _portable_path(path_str: str) -> str: def save_config(config: PlonecliConfig) -> None: - """Save config to ~/.plonecli/config.toml.""" - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - - content = f"""\ -[author] -name = "{config.author_name}" -email = "{config.author_email}" -github_user = "{config.github_user}" + """Save config to ~/.plonecli/config.toml. -[defaults] -plone_version = "{config.plone_version}" - -[templates] -repo_url = "{config.repo_url}" -branch = "{config.repo_branch}" -local_path = "{_portable_path(config.templates_dir)}" + Serialised with a real TOML writer so that quotes, backslashes and newlines + in any value round-trip instead of producing an unparseable file. + """ + CONFIG_DIR.mkdir(parents=True, exist_ok=True) -[git] -auto_commit = {str(config.auto_commit).lower()} -""" - CONFIG_FILE.write_text(content) + document = { + "author": { + "name": config.author_name, + "email": config.author_email, + "github_user": config.github_user, + }, + "defaults": {"plone_version": config.plone_version}, + "templates": { + "repo_url": config.repo_url, + "branch": config.repo_branch, + "local_path": _portable_path(config.templates_dir), + }, + "git": {"auto_commit": config.auto_commit}, + } + with open(CONFIG_FILE, "wb") as f: + tomli_w.dump(document, f) def migrate_from_mrbob() -> PlonecliConfig | None: diff --git a/plonecli/exceptions.py b/plonecli/exceptions.py index 88f890d..06d6417 100644 --- a/plonecli/exceptions.py +++ b/plonecli/exceptions.py @@ -2,7 +2,11 @@ from __future__ import annotations -from click.exceptions import BadOptionUsage, NoSuchOption +from click.exceptions import BadOptionUsage, ClickException, NoSuchOption + + +class ConfigError(ClickException): + """Raised when the global config file cannot be read.""" class NotInPackageError(BadOptionUsage): diff --git a/plonecli/git.py b/plonecli/git.py index f5d7f32..73ee67e 100644 --- a/plonecli/git.py +++ b/plonecli/git.py @@ -13,6 +13,7 @@ from pathlib import Path from plonecli.config import PlonecliConfig +from plonecli.output import error def is_git_repo(path: Path) -> bool: @@ -151,5 +152,9 @@ def commit_template_changes( ) return message except (subprocess.CalledProcessError, FileNotFoundError) as exc: - print(f"Warning: skipped git auto-commit ({exc}).") + error( + f"ERROR: git auto-commit failed ({exc}).\n" + f"The generated files in {target} are uncommitted - commit them " + f"yourself." + ) return None diff --git a/plonecli/output.py b/plonecli/output.py new file mode 100644 index 0000000..ac12158 --- /dev/null +++ b/plonecli/output.py @@ -0,0 +1,15 @@ +"""Coloured terminal output, shared by the commands and by ``plonecli.git``.""" + +from __future__ import annotations + +import click + + +def echo(msg: str, fg: str = "green", reverse: bool = False) -> None: + """Write a styled line to stdout.""" + click.echo(click.style(msg, fg=fg, reverse=reverse)) + + +def error(msg: str) -> None: + """Write a styled error line to stderr.""" + click.echo(click.style(msg, fg="red"), err=True) diff --git a/plonecli/skills/plone-schema-fields/SKILL.md b/plonecli/skills/plone-schema-fields/SKILL.md index dbde8fc..fc0eca8 100644 --- a/plonecli/skills/plone-schema-fields/SKILL.md +++ b/plonecli/skills/plone-schema-fields/SKILL.md @@ -285,5 +285,7 @@ Common widgets (import from `plone.app.z3cform.widgets`; see - An upgrade step **is** needed only if the same change also edits profile XML — e.g. you add a catalog index/metadata for the field (`catalog.xml`) or change `types/*.xml`. Then follow the upgrade-step rule in the `plonecli` skill - (`plonecli add upgrade_step`). + (`plonecli add upgrade_step`), and mirror the index's removal into + `profiles/uninstall/catalog.xml` (`remove="True"`) so uninstalling leaves no + orphaned index — see that skill's uninstall-profile guidance. - Review `git status`/diff and preserve intentional local edits. diff --git a/plonecli/skills/plonecli/SKILL.md b/plonecli/skills/plonecli/SKILL.md index c0baa9a..07fa710 100644 --- a/plonecli/skills/plonecli/SKILL.md +++ b/plonecli/skills/plonecli/SKILL.md @@ -50,6 +50,7 @@ On first run, plonecli clones the copier-templates to `~/.copier-templates/plone - **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 --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). +- **Uninstall must mirror the install profile for recreatable settings.** Whenever you add a catalog index, metadata column, or `plone.registry` record — anything a reinstall recreates — to `profiles/default/` (`catalog.xml`, `registry.xml`), add its removal to `profiles/uninstall/` (`remove="True"`) in the *same* change, so uninstalling leaves no orphaned index or setting behind. Only mirror configuration the addon owns and can rebuild; never strip user content or data on uninstall. Details: [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)). - **Old/legacy package: adapt the structure minimally, never hand-roll old-style files.** If `plonecli add` won't wire features into an old package (mr.bob/`bobtemplates.plone`, buildout, `setup.py` — typically missing `[tool.plone.backend_addon.settings]` or a `src//configure.zcml`), don't fall back to writing the subtemplate's files by hand, and don't re-run the `backend_addon` template over it (it overwrites `__init__.py` and other real code). Inspect what's there, then make only the minimal edits the subtemplate hooks need to function — chiefly the `[tool.plone.backend_addon.settings]` block (so plonecli detects the addon and can register subtemplates) and a stub `src//configure.zcml` if absent (hooks append ``s before `` and silently skip it when the file is missing). Preserve existing code; recommend but don't force broader modernization. Then run `plonecli add` normally. **A migrated package should also end up with the same complete, working `tasks.py` a freshly generated one has — but `tasks.py` comes from the `zope-setup` layer, so never hand-write it: if there's no compatible zope-setup yet, run `plonecli setup` to get it (that lays down the package-fitting `tasks.py`); if a zope-setup exists with a stale `tasks.py`, regenerate via `uv run invoke reconfigure --target=zope-setup`.** Details: [reference/migrate.md](reference/migrate.md). - `create` and `add` auto-commit by default (`create` also `git init`s the package); review with `git log`/`git show`. Any uncommitted local edits get swept into that commit, so commit/stash your work first, or pass `--no-git` to skip the commit. On a dirty repo, `create`/`add`/`setup` warn and prompt to continue (default: cancel); `--defaults` (or no tty) skips the prompt and proceeds after the warning. `reconfigure` does not commit — review its changes with `git status`/diff and preserve intentional local edits. diff --git a/plonecli/skills/plonecli/reference/add.md b/plonecli/skills/plonecli/reference/add.md index 43a96c3..6d52590 100644 --- a/plonecli/skills/plonecli/reference/add.md +++ b/plonecli/skills/plonecli/reference/add.md @@ -105,6 +105,27 @@ Usually don't: If unsure whether a given profile edit needs migrating to existing sites, add the upgrade step — it's cheap and safe; a missing one silently leaves installed sites stale. +## Uninstall profile — mirror recreatable settings + +`profiles/default/` and `profiles/uninstall/` are a pair. Whatever the default profile creates that a reinstall would recreate — catalog indexes, metadata columns, `plone.registry` records — the uninstall profile must remove, so uninstalling leaves a clean site. plonecli scaffolds `profiles/uninstall/` with `browserlayer.xml` (removes the addon's layer) and `metadata.xml`; you extend it as you add recreatable settings to `profiles/default/`. + +Mirror the removal in the **same change** that adds the setting: + +| Added to `profiles/default/` | Add to `profiles/uninstall/` | +|---|---| +| Index / metadata column in `catalog.xml` | `catalog.xml` with `` / `` | +| Record in `registry.xml` | `registry.xml` with `` (or `` for a whole set) | + +```xml + + + + + +``` + +Only mirror configuration the addon **owns and can rebuild** — indexes, metadata columns, registry records, roles/permissions. Never remove user-created content or data on uninstall: reinstalling does not recreate it, and its loss is unrecoverable. For cleanup that XML can't express (e.g. deleting objects the addon created), use the `uninstall(context)` handler in `setuphandlers.py` instead of the profile. + ## zope_instance Inside a `zope-setup` project, `plonecli add zope_instance` adds an additional named Zope instance. Each instance has its own `.copier-answers.zope-instance-.yml` and can later be reconfigured by name ([maintain.md](maintain.md)). diff --git a/plonecli/updater.py b/plonecli/updater.py index 2f1c972..793d9c3 100644 --- a/plonecli/updater.py +++ b/plonecli/updater.py @@ -8,6 +8,8 @@ from urllib.error import URLError from urllib.request import urlopen +from packaging.version import InvalidVersion, Version + from plonecli.config import CONFIG_DIR PYPI_URL = "https://pypi.org/pypi/plonecli/json" @@ -55,15 +57,16 @@ def _write_cache(latest_version: str) -> None: UPDATE_CACHE_FILE.write_text(json.dumps(data)) -def _version_tuple(version: str) -> tuple[int, ...]: - """Parse version string to comparable tuple, ignoring pre-release suffixes.""" - import re +def _is_newer(latest: str, current: str) -> bool: + """Whether ``latest`` is a newer release than ``current`` (PEP 440). - # Extract just the numeric release segment (e.g. "3.0.0" from "3.0.0a1") - match = re.match(r"(\d+(?:\.\d+)*)", version) - if not match: - return (0,) - return tuple(int(x) for x in match.group(1).split(".")) + Pre-releases take part in the comparison. An unparseable version on either + side means "no update". + """ + try: + return Version(latest) > Version(current) + except InvalidVersion: + return False def check_for_updates(force: bool = False) -> str | None: @@ -80,17 +83,14 @@ def check_for_updates(force: bool = False) -> str | None: cache = _read_cache() if cache: latest = cache.get("latest_version") - if latest: - current = _get_current_version() - if _version_tuple(latest) > _version_tuple(current): - return latest + if latest and _is_newer(latest, _get_current_version()): + return latest return None latest = _fetch_latest_version() if latest: _write_cache(latest) - current = _get_current_version() - if _version_tuple(latest) > _version_tuple(current): + if _is_newer(latest, _get_current_version()): return latest return None diff --git a/pyproject.toml b/pyproject.toml index 928569b..7af221b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,9 @@ dependencies = [ "click-aliases", "copier>=9.0.0", "copier-templates-extensions", + "packaging", "pyyaml", + "tomli-w>=1.0", ] [project.optional-dependencies] diff --git a/tests/conftest.py b/tests/conftest.py index f809fab..5afefa1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +1,9 @@ """Pytest configuration for plonecli tests.""" + +import pytest +from click.testing import CliRunner + + +@pytest.fixture +def runner(): + return CliRunner() diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..082e380 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,14 @@ +"""Shared test helpers.""" + +from unittest.mock import MagicMock + + +def project_at(path, project_type="backend_addon"): + """A stand-in ProjectContext rooted at ``path``.""" + return MagicMock( + root_folder=path, + project_type=project_type, + package_name="test.addon", + package_folder="test/addon", + settings={}, + ) diff --git a/tests/test_completion_command.py b/tests/test_completion_command.py new file mode 100644 index 0000000..652c8b8 --- /dev/null +++ b/tests/test_completion_command.py @@ -0,0 +1,138 @@ +"""CLI-level tests for the ``plonecli completion`` command. + +This command appends to the user's shell rc files, so shell detection, the +script output path and append idempotency are all covered against a temp home. +""" + +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from plonecli.cli import cli + + +@pytest.fixture(autouse=True) +def isolated_cli(tmp_path, monkeypatch): + """A temp home, no project, and no network-backed update check.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setattr( + "plonecli.cli.load_config", + lambda: MagicMock(templates_dir=str(tmp_path / "templates")), + ) + monkeypatch.setattr("plonecli.cli.find_project_root", lambda: None) + monkeypatch.setattr("plonecli.updater.check_for_updates", lambda *a, **k: None) + return home + + +def _completed(stdout=""): + return subprocess.CompletedProcess(args=["plonecli"], returncode=0, stdout=stdout) + + +@pytest.mark.parametrize("shell", ["bash", "zsh", "fish"]) +def test_prints_the_completion_script(runner, shell): + with patch( + "subprocess.run", return_value=_completed("# generated completion\n") + ) as mock_run: + result = runner.invoke(cli, ["completion", shell]) + + assert result.exit_code == 0, result.output + assert "# generated completion" in result.output + env = mock_run.call_args.kwargs["env"] + assert env["_PLONECLI_COMPLETE"] == f"{shell}_source" + + +def test_falls_back_to_the_eval_line(runner): + """If the generator produces nothing, print the activation line instead.""" + with patch("subprocess.run", return_value=_completed("")): + result = runner.invoke(cli, ["completion", "bash"]) + + assert result.exit_code == 0, result.output + assert "_PLONECLI_COMPLETE=bash_source plonecli" in result.output + + +@pytest.mark.parametrize("shell", ["bash", "zsh", "fish"]) +def test_detects_the_login_shell(runner, monkeypatch, shell): + monkeypatch.setenv("SHELL", f"/usr/bin/{shell}") + + with patch("subprocess.run", return_value=_completed("")) as mock_run: + result = runner.invoke(cli, ["completion"]) + + assert result.exit_code == 0, result.output + assert mock_run.call_args.kwargs["env"]["_PLONECLI_COMPLETE"] == f"{shell}_source" + + +@pytest.mark.parametrize("shell_env", ["", "/bin/tcsh"]) +def test_undetectable_shell_asks_for_one(runner, monkeypatch, shell_env): + monkeypatch.setenv("SHELL", shell_env) + + result = runner.invoke(cli, ["completion"]) + + assert result.exit_code != 0 + assert "bash|zsh|fish" in result.output + + +def test_rejects_an_unsupported_shell(runner): + result = runner.invoke(cli, ["completion", "tcsh"]) + + assert result.exit_code != 0 + + +@pytest.mark.parametrize( + ("shell", "rc_relpath"), + [("bash", ".bashrc"), ("zsh", ".zshrc")], +) +def test_install_appends_the_eval_line(runner, isolated_cli, shell, rc_relpath): + """Also a regression for the flag-after-argument form. + + The top-level group is chained, which disables interspersed args, so + ``plonecli completion bash --install`` used to fail with "No such option: + --install". + """ + rc_file = isolated_cli / rc_relpath + rc_file.write_text("# existing user config\n") + + result = runner.invoke(cli, ["completion", shell, "--install"]) + + assert result.exit_code == 0, result.output + content = rc_file.read_text() + assert "# existing user config" in content + assert f'eval "$(_PLONECLI_COMPLETE={shell}_source plonecli)"' in content + assert str(rc_file) in result.output + + +def test_install_creates_the_fish_completions_file(runner, isolated_cli): + result = runner.invoke(cli, ["completion", "fish", "--install"]) + + assert result.exit_code == 0, result.output + rc_file = isolated_cli / ".config/fish/completions/plonecli.fish" + assert "env _PLONECLI_COMPLETE=fish_source plonecli | source" in rc_file.read_text() + + +def test_install_creates_a_missing_rc_file(runner, isolated_cli): + result = runner.invoke(cli, ["completion", "bash", "--install"]) + + assert result.exit_code == 0, result.output + assert "_PLONECLI_COMPLETE" in (isolated_cli / ".bashrc").read_text() + + +@pytest.mark.parametrize("shell", ["bash", "zsh", "fish"]) +def test_install_is_idempotent(runner, isolated_cli, shell): + """A second --install must not append a duplicate line to the rc file.""" + first = runner.invoke(cli, ["completion", shell, "--install"]) + assert first.exit_code == 0, first.output + rc_files = { + "bash": isolated_cli / ".bashrc", + "zsh": isolated_cli / ".zshrc", + "fish": isolated_cli / ".config/fish/completions/plonecli.fish", + } + after_first = rc_files[shell].read_text() + + second = runner.invoke(cli, ["completion", shell, "--install"]) + + assert second.exit_code == 0, second.output + assert "already configured" in second.output + assert rc_files[shell].read_text() == after_first + assert after_first.count("_PLONECLI_COMPLETE") == 1 diff --git a/tests/test_config.py b/tests/test_config.py index 5909d99..0980ade 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,11 +1,14 @@ """Tests for plonecli.config module.""" +import pytest + from plonecli.config import ( PlonecliConfig, load_config, migrate_from_mrbob, save_config, ) +from plonecli.exceptions import ConfigError def test_default_config(): @@ -155,6 +158,47 @@ def test_save_and_reload(tmp_path, monkeypatch): assert loaded.plone_version == original.plone_version +@pytest.mark.parametrize( + "author_name", + [ + 'Ann "The Hammer" O\'Neill', + r"Back\slash Bob", + "Line\nBreak", + "Tab\there", + "Unicode ✓ Ünïcödé", + '"""triple quoted"""', + ], +) +def test_save_and_reload_hostile_characters(author_name, tmp_path, monkeypatch): + """Any string survives save/load. + + Regression: the config was serialised with f-string interpolation, so a + quote or backslash in a value produced a config.toml that ``tomllib`` + refused to parse, breaking every later plonecli invocation. + """ + config_dir = tmp_path / ".plonecli" + config_file = config_dir / "config.toml" + monkeypatch.setattr("plonecli.config.CONFIG_DIR", config_dir) + monkeypatch.setattr("plonecli.config.CONFIG_FILE", config_file) + + save_config(PlonecliConfig(author_name=author_name)) + + assert load_config().author_name == author_name + + +def test_load_config_broken_file_names_the_path(tmp_path, monkeypatch): + config_file = tmp_path / "config.toml" + config_file.write_text('[author]\nname = "unterminated\n') + monkeypatch.setattr("plonecli.config.CONFIG_FILE", config_file) + + with pytest.raises(ConfigError) as excinfo: + load_config() + + message = str(excinfo.value) + assert str(config_file) in message + assert "delete" in message.lower() + + def test_migrate_from_mrbob(tmp_path, monkeypatch): mrbob_file = tmp_path / ".mrbob" mrbob_file.write_text("""\ diff --git a/tests/test_config_command.py b/tests/test_config_command.py new file mode 100644 index 0000000..9828997 --- /dev/null +++ b/tests/test_config_command.py @@ -0,0 +1,195 @@ +"""CLI-level tests for the ``plonecli config`` command.""" + +from unittest.mock import patch + +import pytest + +from plonecli.cli import cli +from plonecli.config import PlonecliConfig, load_config + + +@pytest.fixture +def isolated_config(tmp_path, monkeypatch): + """Point the config module at a temp home and return the config file path.""" + home = tmp_path / "home" + home.mkdir() + config_dir = home / ".plonecli" + config_file = config_dir / "config.toml" + monkeypatch.setattr("plonecli.config.CONFIG_DIR", config_dir) + monkeypatch.setattr("plonecli.config.CONFIG_FILE", config_file) + monkeypatch.setattr("plonecli.config.Path.home", lambda: home) + monkeypatch.setenv("HOME", str(home)) + for var in ( + "PLONECLI_TEMPLATES_REPO_URL", + "PLONECLI_TEMPLATES_BRANCH", + "PLONECLI_TEMPLATES_DIR", + ): + monkeypatch.delenv(var, raising=False) + return config_file + + +# In prompt order, so the values can be fed to the command as one input stream. +ANSWERS = { + "author_name": "Jane Doe", + "author_email": "jane@example.com", + "github_user": "janedoe", + "plone_version": "6.1.1", + "repo_url": "https://github.com/plone/copier-templates", + "repo_branch": "main", +} + + +def _input(**overrides): + """The scripted prompt answers, with named ones replaced.""" + return "\n".join((ANSWERS | overrides).values()) + "\n" + + +def _accept_defaults(**overrides): + """Press Enter at every prompt, except where an answer is given.""" + return "\n".join((dict.fromkeys(ANSWERS, "") | overrides).values()) + "\n" + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.plone_versions.get_latest_stable_version", return_value="6.1.1") +def test_config_prompts_and_saves(mock_latest, mock_project, runner, isolated_config): + result = runner.invoke(cli, ["config"], input=_input()) + + assert result.exit_code == 0, result.output + assert str(isolated_config) in result.output + saved = load_config() + assert saved.author_name == "Jane Doe" + assert saved.author_email == "jane@example.com" + assert saved.github_user == "janedoe" + assert saved.plone_version == "6.1.1" + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.plone_versions.get_latest_stable_version", return_value="6.1.1") +def test_config_suggests_latest_plone_version( + mock_latest, mock_project, runner, isolated_config +): + """An empty answer takes the suggested latest stable release.""" + result = runner.invoke(cli, ["config"], input=_input(plone_version="")) + + assert result.exit_code == 0, result.output + assert load_config().plone_version == "6.1.1" + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.plone_versions.get_latest_stable_version", return_value="6.1.1") +def test_config_round_trips_hostile_author_name( + mock_latest, mock_project, runner, isolated_config +): + """A quote in the author name must not corrupt the saved config. + + Regression: the saved file became unparseable, so every later plonecli + invocation - including a second ``plonecli config`` - failed. + """ + hostile = 'Ann "The Hammer" O\'Neill' + + result = runner.invoke(cli, ["config"], input=_input(author_name=hostile)) + + assert result.exit_code == 0, result.output + assert load_config().author_name == hostile + + # And the config command can be run again on top of it. + again = runner.invoke(cli, ["config"], input=_input()) + assert again.exit_code == 0, again.output + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.plone_versions.get_latest_stable_version", return_value="6.1.1") +def test_config_offers_mrbob_migration( + mock_latest, mock_project, runner, isolated_config +): + home = isolated_config.parent.parent + (home / ".mrbob").write_text( + "[variables]\n" + "author.name = Bob User\n" + "author.email = bob@example.com\n" + "author.github.user = bobuser\n" + "\n[defaults]\n" + "plone.version = 6.0.11\n" + ) + + # Accept the import, then accept every prefilled default. + result = runner.invoke(cli, ["config"], input="y\n" + _accept_defaults()) + + assert result.exit_code == 0, result.output + assert "~/.mrbob" in result.output + saved = load_config() + assert saved.author_name == "Bob User" + assert saved.author_email == "bob@example.com" + assert saved.github_user == "bobuser" + assert saved.plone_version == "6.0.11" + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.plone_versions.get_latest_stable_version", return_value="6.1.1") +def test_config_declining_mrbob_migration_keeps_defaults( + mock_latest, mock_project, runner, isolated_config +): + home = isolated_config.parent.parent + (home / ".mrbob").write_text("[variables]\nauthor.name = Bob User\n") + + result = runner.invoke(cli, ["config"], input="n\n" + _accept_defaults()) + + assert result.exit_code == 0, result.output + assert load_config().author_name == PlonecliConfig().author_name + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.plone_versions.get_latest_stable_version", return_value="6.1.1") +def test_config_no_mrbob_offer_once_configured( + mock_latest, mock_project, runner, isolated_config +): + """The migration offer is a first-run thing only.""" + home = isolated_config.parent.parent + (home / ".mrbob").write_text("[variables]\nauthor.name = Bob User\n") + first = runner.invoke(cli, ["config"], input="n\n" + _input()) + assert first.exit_code == 0, first.output + + result = runner.invoke(cli, ["config"], input=_accept_defaults()) + + assert result.exit_code == 0, result.output + assert "~/.mrbob" not in result.output + assert load_config().author_name == "Jane Doe" + + +@pytest.mark.parametrize("args", [["-V"], ["config"]]) +@patch("plonecli.cli.find_project_root", return_value=None) +def test_broken_config_reports_the_path_and_the_recovery( + mock_project, runner, isolated_config, args +): + """An unreadable config must explain itself, not raise a parse error. + + It is loaded up front for every command, so ``plonecli config`` cannot + rewrite it either - hence the ``rm`` in the message. + """ + isolated_config.parent.mkdir(parents=True, exist_ok=True) + isolated_config.write_text('[author]\nname = "unterminated\n') + + result = runner.invoke(cli, args) + + assert result.exit_code != 0 + assert str(isolated_config) in result.output + assert "rm " in result.output + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.plone_versions.get_latest_stable_version", return_value="6.1.1") +def test_config_keeps_existing_values_as_defaults( + mock_latest, mock_project, runner, isolated_config +): + runner.invoke(cli, ["config"], input=_input()) + + # Change only the email; empty answers keep each stored value. + result = runner.invoke( + cli, ["config"], input=_accept_defaults(author_email="new@example.com") + ) + + assert result.exit_code == 0, result.output + saved = load_config() + assert saved.author_email == "new@example.com" + assert saved.author_name == "Jane Doe" + assert saved.github_user == "janedoe" diff --git a/tests/test_git.py b/tests/test_git.py index ba1a2ba..4239388 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -1,6 +1,7 @@ """Tests for plonecli.git auto-commit support.""" import subprocess +from unittest.mock import patch from plonecli.config import PlonecliConfig from plonecli.git import commit_template_changes, dirty_files, is_git_repo @@ -100,6 +101,30 @@ def test_commit_returns_none_for_missing_dir(tmp_path): ) +def test_commit_failure_is_reported_as_an_error(tmp_path, capsys): + """A failed auto-commit must be visible, not a bare stdout print. + + The generated files are still on disk but uncommitted, so the user has to + notice; the warning goes to stderr through the styled error channel. + """ + (tmp_path / "a.py").write_text("a\n") + config = PlonecliConfig() + + def boom(*args, **kwargs): + raise FileNotFoundError("git") + + with patch("plonecli.git.subprocess.run", side_effect=boom): + msg = commit_template_changes( + tmp_path, "backend_addon", config, is_subtemplate=False + ) + + assert msg is None + captured = capsys.readouterr() + assert "auto-commit" in captured.err + assert "uncommitted" in captured.err + assert captured.out == "" + + def test_is_git_repo_false_for_plain_dir(tmp_path): assert is_git_repo(tmp_path) is False diff --git a/tests/test_plone_versions.py b/tests/test_plone_versions.py new file mode 100644 index 0000000..8765434 --- /dev/null +++ b/tests/test_plone_versions.py @@ -0,0 +1,134 @@ +"""Tests for plonecli.plone_versions: parsing, caching and offline fallback.""" + +import json +from datetime import UTC, datetime, timedelta +from io import BytesIO +from unittest.mock import patch +from urllib.error import URLError + +import pytest + +from plonecli.plone_versions import ( + FALLBACK_VERSION, + _read_cache, + _write_cache, + fetch_stable_versions, + get_latest_stable_version, + get_version_choices, +) + +LISTING = """ +../ +6.0.13/ +6.1.0/ +6.1.1/ +6.1.2rc1/ +6.2.0a1/ +6.2.0b2/ +5.2.14/ +6.1.0.dev0/ +""" + + +@pytest.fixture +def cache_file(tmp_path, monkeypatch): + path = tmp_path / ".plone_versions_cache.json" + monkeypatch.setattr("plonecli.plone_versions.VERSIONS_CACHE_FILE", path) + monkeypatch.setattr("plonecli.plone_versions.CONFIG_DIR", tmp_path) + return path + + +def _urlopen_returning(html): + return lambda *args, **kwargs: BytesIO(html.encode("utf-8")) + + +def _offline(*args, **kwargs): + raise URLError("offline") + + +def test_fetch_parses_and_sorts_descending(): + with patch("plonecli.plone_versions.urlopen", _urlopen_returning(LISTING)): + versions = fetch_stable_versions() + + assert versions == ["6.1.1", "6.1.0", "6.0.13", "5.2.14"] + + +def test_fetch_filters_out_pre_releases(): + with patch("plonecli.plone_versions.urlopen", _urlopen_returning(LISTING)): + versions = fetch_stable_versions() + + assert not [v for v in versions if any(c.isalpha() for c in v)] + + +def test_fetch_returns_empty_for_a_listing_without_versions(): + with patch( + "plonecli.plone_versions.urlopen", _urlopen_returning("nothing") + ): + assert fetch_stable_versions() == [] + + +def test_latest_version_is_fetched_and_cached(cache_file): + with patch("plonecli.plone_versions.urlopen", _urlopen_returning(LISTING)): + assert get_latest_stable_version(force=True) == "6.1.1" + + cached = json.loads(cache_file.read_text()) + assert cached["latest"] == "6.1.1" + assert cached["versions"][0] == "6.1.1" + + +def test_fresh_cache_is_used_without_fetching(cache_file): + _write_cache(["9.9.9", "9.9.8"], "9.9.9") + + def explode(*args, **kwargs): + raise AssertionError("network must not be touched with a fresh cache") + + with patch("plonecli.plone_versions.urlopen", explode): + assert get_latest_stable_version() == "9.9.9" + assert get_version_choices() == ["9.9.9", "9.9.8"] + + +def test_expired_cache_triggers_a_refetch(cache_file): + old = (datetime.now(UTC) - timedelta(hours=25)).isoformat() + cache_file.write_text( + json.dumps({"last_check": old, "versions": ["9.9.9"], "latest": "9.9.9"}) + ) + assert _read_cache() is None + + with patch("plonecli.plone_versions.urlopen", _urlopen_returning(LISTING)): + assert get_latest_stable_version() == "6.1.1" + + +def test_corrupt_cache_is_ignored(cache_file): + cache_file.write_text("{not json") + + assert _read_cache() is None + + with patch("plonecli.plone_versions.urlopen", _urlopen_returning(LISTING)): + assert get_latest_stable_version() == "6.1.1" + + +def test_offline_falls_back_to_the_stale_cache(cache_file): + """A stale cache beats the hardcoded fallback when the network is down.""" + old = (datetime.now(UTC) - timedelta(days=30)).isoformat() + cache_file.write_text( + json.dumps({"last_check": old, "versions": ["6.0.9"], "latest": "6.0.9"}) + ) + + with patch("plonecli.plone_versions.urlopen", _offline): + assert get_latest_stable_version() == "6.0.9" + + +def test_offline_without_a_cache_uses_the_fallback(cache_file): + with patch("plonecli.plone_versions.urlopen", _offline): + assert get_latest_stable_version() == FALLBACK_VERSION + assert get_version_choices() == [FALLBACK_VERSION] + + +def test_version_choices_are_capped_at_five(cache_file): + listing = "".join(f'6.1.{n}/' for n in range(10)) + + with patch("plonecli.plone_versions.urlopen", _urlopen_returning(listing)): + choices = get_version_choices(force=True) + + assert len(choices) == 5 + assert choices[0] == "6.1.9" diff --git a/tests/test_plonecli.py b/tests/test_plonecli.py index 442a496..7be445f 100644 --- a/tests/test_plonecli.py +++ b/tests/test_plonecli.py @@ -4,14 +4,9 @@ from unittest.mock import MagicMock, patch import pytest -from click.testing import CliRunner from plonecli.cli import cli - - -@pytest.fixture -def runner(): - return CliRunner() +from tests.helpers import project_at @patch("plonecli.cli.find_project_root", return_value=None) @@ -470,16 +465,6 @@ def _dirty_repo(path): (path / "wip.txt").write_text("work in progress\n") -def _project_at(path): - return MagicMock( - root_folder=path, - project_type="backend_addon", - package_name="test.addon", - package_folder="test/addon", - settings={}, - ) - - @patch("plonecli.cli._is_interactive", return_value=True) @patch("plonecli.cli.find_project_root") @patch("plonecli.cli.load_config") @@ -492,7 +477,7 @@ def test_add_aborts_on_dirty_repo( _make_template(tmp_path, "behavior", {"type": "sub", "parent": "backend_addon"}) _dirty_repo(tmp_path) mock_config.return_value = MagicMock(templates_dir=str(tmp_path), auto_commit=True) - mock_project.return_value = _project_at(tmp_path) + mock_project.return_value = project_at(tmp_path) result = runner.invoke(cli, ["add", "behavior"], input="n\n") @@ -514,7 +499,7 @@ def test_add_proceeds_when_confirmed_on_dirty_repo( _make_template(tmp_path, "behavior", {"type": "sub", "parent": "backend_addon"}) _dirty_repo(tmp_path) mock_config.return_value = MagicMock(templates_dir=str(tmp_path), auto_commit=True) - mock_project.return_value = _project_at(tmp_path) + mock_project.return_value = project_at(tmp_path) result = runner.invoke(cli, ["add", "behavior"], input="y\n") @@ -527,25 +512,141 @@ def test_add_proceeds_when_confirmed_on_dirty_repo( @patch("plonecli.cli.load_config") @patch("plonecli.cli.run_add") @patch("plonecli.cli.ensure_templates_cloned") -def test_add_dirty_repo_bypassed_by_defaults( +def test_add_dirty_repo_fails_in_non_interactive_mode( mock_ensure, mock_run_add, mock_config, mock_project, mock_tty, runner, tmp_path ): + """``--defaults`` must not silently mix generated files into dirty work.""" _make_template(tmp_path, "backend_addon", {"type": "main"}) _make_template(tmp_path, "upgrade_step", {"type": "sub", "parent": "backend_addon"}) _dirty_repo(tmp_path) mock_config.return_value = MagicMock(templates_dir=str(tmp_path), auto_commit=True) - mock_project.return_value = _project_at(tmp_path) + mock_project.return_value = project_at(tmp_path) result = runner.invoke( cli, ["add", "upgrade_step", "--defaults", "-d", "upgrade_step_title=X"] ) - assert result.exit_code == 0 - # Warning still shown, but no prompt and the run proceeds. + assert result.exit_code != 0 + assert "uncommitted changes" in result.output + assert "--allow-dirty" in result.output + mock_run_add.assert_not_called() + + +@patch("plonecli.cli._is_interactive", return_value=False) +@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_dirty_repo_fails_without_a_tty( + mock_ensure, mock_run_add, mock_config, mock_project, mock_tty, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + _make_template(tmp_path, "behavior", {"type": "sub", "parent": "backend_addon"}) + _dirty_repo(tmp_path) + mock_config.return_value = MagicMock(templates_dir=str(tmp_path), auto_commit=True) + mock_project.return_value = project_at(tmp_path) + + result = runner.invoke(cli, ["add", "behavior"]) + + assert result.exit_code != 0 + mock_run_add.assert_not_called() + + +@patch("plonecli.cli._is_interactive", return_value=False) +@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_allow_dirty_proceeds_non_interactively( + mock_ensure, mock_run_add, mock_config, mock_project, mock_tty, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + _make_template(tmp_path, "upgrade_step", {"type": "sub", "parent": "backend_addon"}) + _dirty_repo(tmp_path) + mock_config.return_value = MagicMock(templates_dir=str(tmp_path), auto_commit=True) + mock_project.return_value = project_at(tmp_path) + + result = runner.invoke( + cli, + [ + "add", + "upgrade_step", + "--defaults", + "--allow-dirty", + "-d", + "upgrade_step_title=X", + ], + ) + + assert result.exit_code == 0, result.output assert "uncommitted changes" in result.output mock_run_add.assert_called_once() +@patch("plonecli.cli._is_interactive", return_value=True) +@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_allow_dirty_skips_the_prompt( + mock_ensure, mock_run_add, mock_config, mock_project, mock_tty, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + _make_template(tmp_path, "behavior", {"type": "sub", "parent": "backend_addon"}) + _dirty_repo(tmp_path) + mock_config.return_value = MagicMock(templates_dir=str(tmp_path), auto_commit=True) + mock_project.return_value = project_at(tmp_path) + + # No input supplied: a prompt would abort the run. + result = runner.invoke(cli, ["add", "behavior", "--allow-dirty"]) + + assert result.exit_code == 0, result.output + mock_run_add.assert_called_once() + + +@patch("plonecli.cli._is_interactive", return_value=False) +@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_dirty_target_fails_in_non_interactive_mode( + mock_ensure, mock_run_create, mock_config, mock_project, mock_tty, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + target = tmp_path / "my.addon" + target.mkdir() + _dirty_repo(target) + mock_config.return_value = MagicMock(templates_dir=str(tmp_path), auto_commit=True) + + result = runner.invoke(cli, ["create", "backend_addon", str(target), "--defaults"]) + + assert result.exit_code != 0 + assert "--allow-dirty" in result.output + mock_run_create.assert_not_called() + + +@patch("plonecli.cli._is_interactive", return_value=False) +@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_allow_dirty_proceeds( + mock_ensure, mock_run_create, mock_config, mock_project, mock_tty, runner, tmp_path +): + _make_template(tmp_path, "backend_addon", {"type": "main"}) + target = tmp_path / "my.addon" + target.mkdir() + _dirty_repo(target) + mock_config.return_value = MagicMock(templates_dir=str(tmp_path), auto_commit=True) + + result = runner.invoke( + cli, ["create", "backend_addon", str(target), "--defaults", "--allow-dirty"] + ) + + assert result.exit_code == 0, result.output + mock_run_create.assert_called_once() + + @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): @@ -554,19 +655,37 @@ def test_add_outside_project(mock_config, mock_project, runner, tmp_path): assert result.exit_code != 0 +def _tasks_project( + path, + test_params="c, verbose=False, test=None, package=None", + pyproject=( + "[project]\nname = 'x'\n\n" + "[project.optional-dependencies]\ntest = ['pytest']\n\n" + "[dependency-groups]\ndev = ['invoke']\n" + ), +): + """A project whose generated tasks.py exposes the given ``test`` signature.""" + (path / "tasks.py").write_text( + f'"""Invoke tasks."""\n\n\n@task\ndef test({test_params}):\n pass\n' + ) + if pyproject is not None: + (path / "pyproject.toml").write_text(pyproject) + return MagicMock( + root_folder=path, + project_type="backend_addon", + settings={}, + ) + + @patch("plonecli.cli.find_project_root") @patch("plonecli.cli.load_config") @patch("plonecli.cli.subprocess.call", return_value=0) def test_serve_command(mock_call, mock_config, mock_project, runner, tmp_path): mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) - mock_project.return_value = MagicMock( - root_folder=tmp_path, - project_type="zope-setup", - settings={}, - ) + mock_project.return_value = _tasks_project(tmp_path) result = runner.invoke(cli, ["serve"]) - assert result.exit_code == 0 + assert result.exit_code == 0, result.output mock_call.assert_called_once() call_args = mock_call.call_args[0][0] assert call_args == ["uv", "run", "invoke", "start"] @@ -577,14 +696,10 @@ def test_serve_command(mock_call, mock_config, mock_project, runner, tmp_path): @patch("plonecli.cli.subprocess.call", return_value=0) def test_test_command(mock_call, mock_config, mock_project, runner, tmp_path): mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) - mock_project.return_value = MagicMock( - root_folder=tmp_path, - project_type="backend_addon", - settings={}, - ) + mock_project.return_value = _tasks_project(tmp_path) result = runner.invoke(cli, ["test"]) - assert result.exit_code == 0 + assert result.exit_code == 0, result.output call_args = mock_call.call_args[0][0] assert call_args == ["uv", "run", "invoke", "test"] @@ -594,30 +709,282 @@ def test_test_command(mock_call, mock_config, mock_project, runner, tmp_path): @patch("plonecli.cli.subprocess.call", return_value=0) def test_test_command_verbose(mock_call, mock_config, mock_project, runner, tmp_path): mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) - mock_project.return_value = MagicMock( - root_folder=tmp_path, - project_type="backend_addon", - settings={}, - ) + mock_project.return_value = _tasks_project(tmp_path) result = runner.invoke(cli, ["test", "--verbose"]) - assert result.exit_code == 0 + assert result.exit_code == 0, result.output + call_args = mock_call.call_args[0][0] + assert "--verbose" in call_args + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.subprocess.call", return_value=0) +def test_test_command_single_test( + mock_call, mock_config, mock_project, runner, tmp_path +): + """``-t`` keeps the edit-test loop fast by running one named test.""" + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = _tasks_project(tmp_path) + + result = runner.invoke(cli, ["test", "-t", "test_behavior_installed"]) + + assert result.exit_code == 0, result.output + assert mock_call.call_args[0][0] == [ + "uv", + "run", + "invoke", + "test", + "--test", + "test_behavior_installed", + ] + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.subprocess.call", return_value=0) +def test_test_command_package_filter( + mock_call, mock_config, mock_project, runner, tmp_path +): + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = _tasks_project(tmp_path) + + result = runner.invoke(cli, ["test", "-s", "src/collective/todo"]) + + assert result.exit_code == 0, result.output + assert mock_call.call_args[0][0] == [ + "uv", + "run", + "invoke", + "test", + "--package", + "src/collective/todo", + ] + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.subprocess.call", return_value=0) +def test_test_filters_combine_with_verbose( + mock_call, mock_config, mock_project, runner, tmp_path +): + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = _tasks_project(tmp_path) + + result = runner.invoke( + cli, ["test", "-v", "-t", "test_x", "-s", "src/collective/todo"] + ) + + assert result.exit_code == 0, result.output call_args = mock_call.call_args[0][0] assert "--verbose" in call_args + assert "--test" in call_args + assert "--package" in call_args + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.subprocess.call", return_value=0) +def test_test_filter_on_old_tasks_file_explains_the_fix( + mock_call, mock_config, mock_project, runner, tmp_path +): + """A project generated before the task gained the filters must not just + hand an unknown flag to invoke.""" + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = _tasks_project(tmp_path, test_params="c, verbose=False") + + result = runner.invoke(cli, ["test", "-t", "test_x"]) + + assert result.exit_code != 0 + assert "--test" in result.output + assert "tasks.py" in result.output + mock_call.assert_not_called() + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.subprocess.call", return_value=0) +def test_test_without_filters_runs_on_old_tasks_file( + mock_call, mock_config, mock_project, runner, tmp_path +): + """The signature check only gates the new options.""" + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = _tasks_project(tmp_path, test_params="c, verbose=False") + + result = runner.invoke(cli, ["test"]) + + assert result.exit_code == 0, result.output + mock_call.assert_called_once() @patch("plonecli.cli.find_project_root") @patch("plonecli.cli.load_config") @patch("plonecli.cli.subprocess.call", return_value=0) def test_debug_command(mock_call, mock_config, mock_project, runner, tmp_path): + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = _tasks_project(tmp_path) + + result = runner.invoke(cli, ["debug"]) + assert result.exit_code == 0, result.output + call_args = mock_call.call_args[0][0] + assert call_args == ["uv", "run", "invoke", "debug"] + + +@pytest.mark.parametrize("command", ["serve", "test", "debug"]) +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.subprocess.call", return_value=0) +def test_task_commands_explain_missing_tasks_file( + mock_call, mock_config, mock_project, runner, tmp_path, command +): + """Without tasks.py there is no invoke tooling; say so instead of failing + inside a subprocess.""" mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) mock_project.return_value = MagicMock( root_folder=tmp_path, - project_type="zope-setup", + project_type="backend_addon", settings={}, ) - result = runner.invoke(cli, ["debug"]) - assert result.exit_code == 0 - call_args = mock_call.call_args[0][0] - assert call_args == ["uv", "run", "invoke", "debug"] + result = runner.invoke(cli, [command]) + + assert result.exit_code != 0 + assert "tasks.py" in result.output + assert "plonecli setup" in result.output + mock_call.assert_not_called() + + +@pytest.mark.parametrize("command", ["serve", "test", "debug"]) +@patch("plonecli.cli.shutil.which", return_value=None) +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.subprocess.call", return_value=0) +def test_task_commands_explain_missing_uv( + mock_call, mock_config, mock_project, mock_which, runner, tmp_path, command +): + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = _tasks_project(tmp_path) + + result = runner.invoke(cli, [command]) + + assert result.exit_code != 0 + assert "uv" in result.output + mock_call.assert_not_called() + + +@pytest.mark.parametrize("command", ["serve", "test", "debug"]) +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.subprocess.call", return_value=0) +def test_task_commands_explain_undeclared_invoke( + mock_call, mock_config, mock_project, runner, tmp_path, command +): + """``uv run invoke`` dies with "Failed to spawn: invoke" if the project does + not declare invoke, so name the dependency group instead.""" + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = _tasks_project( + tmp_path, + pyproject="[project]\nname = 'x'\n\n" + "[project.optional-dependencies]\ntest = ['pytest']\n", + ) + + result = runner.invoke(cli, [command]) + + assert result.exit_code != 0 + assert "invoke" in result.output + assert "dev" in result.output + mock_call.assert_not_called() + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.subprocess.call", return_value=0) +def test_test_command_explains_undeclared_pytest( + mock_call, mock_config, mock_project, runner, tmp_path +): + """The generated task runs ``uv run --extra test pytest``, so name that extra.""" + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = _tasks_project( + tmp_path, + pyproject="[project]\nname = 'x'\n\n[dependency-groups]\ndev = ['invoke']\n", + ) + + result = runner.invoke(cli, ["test"]) + + assert result.exit_code != 0 + assert "pytest" in result.output + assert "test" in result.output + mock_call.assert_not_called() + + +@pytest.mark.parametrize("command", ["serve", "debug"]) +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.subprocess.call", return_value=0) +def test_serve_and_debug_do_not_need_pytest( + mock_call, mock_config, mock_project, runner, tmp_path, command +): + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = _tasks_project( + tmp_path, + pyproject="[project]\nname = 'x'\n\n[dependency-groups]\ndev = ['invoke']\n", + ) + + result = runner.invoke(cli, [command]) + + assert result.exit_code == 0, result.output + mock_call.assert_called_once() + + +@pytest.mark.parametrize("command", ["serve", "test", "debug"]) +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.subprocess.call", return_value=0) +def test_unreadable_pyproject_does_not_block_the_run( + mock_call, mock_config, mock_project, runner, tmp_path, command +): + """With no basis to judge the dependencies, defer to uv rather than guess.""" + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = _tasks_project(tmp_path, pyproject="[project\nbroken") + + result = runner.invoke(cli, [command]) + + assert result.exit_code == 0, result.output + mock_call.assert_called_once() + + +@pytest.mark.parametrize( + "requirement", + ["invoke", "invoke>=2.0", "Invoke", "invoke[extra]>=2 ; python_version>'3.10'"], +) +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.subprocess.call", return_value=0) +def test_invoke_recognised_in_any_requirement_form( + mock_call, mock_config, mock_project, runner, tmp_path, requirement +): + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = _tasks_project( + tmp_path, + pyproject=f'[project]\nname = "x"\ndependencies = ["{requirement}"]\n', + ) + + result = runner.invoke(cli, ["serve"]) + + assert result.exit_code == 0, result.output + mock_call.assert_called_once() + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.subprocess.call", return_value=1) +def test_test_command_propagates_a_failing_exit_code( + mock_call, mock_config, mock_project, runner, tmp_path +): + """Failing tests must fail the CLI, so scripts and CI notice.""" + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = _tasks_project(tmp_path) + + result = runner.invoke(cli, ["test"]) + + assert result.exit_code == 1 diff --git a/tests/test_setup_command.py b/tests/test_setup_command.py new file mode 100644 index 0000000..a43153a --- /dev/null +++ b/tests/test_setup_command.py @@ -0,0 +1,157 @@ +"""CLI-level tests for the ``plonecli setup`` command.""" + +from unittest.mock import MagicMock, patch + +from plonecli.cli import cli +from tests.helpers import project_at + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.cli.load_config") +def test_setup_outside_project_fails(mock_config, mock_project, runner, tmp_path): + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + + result = runner.invoke(cli, ["setup"]) + + assert result.exit_code != 0 + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_create") +def test_setup_rejects_non_backend_addon( + mock_run_create, mock_config, mock_project, runner, tmp_path +): + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = project_at(tmp_path, project_type="zope-setup") + + result = runner.invoke(cli, ["setup"]) + + assert result.exit_code != 0 + assert "backend_addon" in result.output + mock_run_create.assert_not_called() + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_create") +def test_setup_runs_zope_setup_with_overwrite( + mock_run_create, mock_config, mock_project, runner, tmp_path +): + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = project_at(tmp_path) + + result = runner.invoke(cli, ["setup"]) + + assert result.exit_code == 0, result.output + mock_run_create.assert_called_once() + args = mock_run_create.call_args[0] + assert args[0] == "zope-setup" + assert args[1] == str(tmp_path) + assert mock_run_create.call_args.kwargs["overwrite"] is True + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_create") +def test_setup_non_interactive( + mock_run_create, mock_config, mock_project, runner, tmp_path +): + """A backend addon can be bootstrapped end to end from a script.""" + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = project_at(tmp_path) + + result = runner.invoke( + cli, + ["setup", "--defaults", "-d", "plone_version=6.1.1", "-d", "db_storage=zeo"], + ) + + assert result.exit_code == 0, result.output + kwargs = mock_run_create.call_args.kwargs + assert kwargs["defaults"] is True + assert kwargs["data"] == {"plone_version": "6.1.1", "db_storage": "zeo"} + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_create") +def test_setup_data_file_merges_with_inline_data( + mock_run_create, mock_config, mock_project, runner, tmp_path +): + data_file = tmp_path / "answers.yml" + data_file.write_text("plone_version: 6.0.13\ndb_storage: relstorage\n") + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = project_at(tmp_path) + + result = runner.invoke( + cli, + ["setup", "--data-file", str(data_file), "-d", "plone_version=6.1.1"], + ) + + assert result.exit_code == 0, result.output + assert mock_run_create.call_args.kwargs["data"] == { + "plone_version": "6.1.1", + "db_storage": "relstorage", + } + + +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_create") +def test_setup_data_without_separator_fails( + mock_run_create, mock_config, mock_project, runner, tmp_path +): + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = project_at(tmp_path) + + result = runner.invoke(cli, ["setup", "-d", "no_separator"]) + + assert result.exit_code != 0 + mock_run_create.assert_not_called() + + +@patch("plonecli.cli._is_interactive", return_value=False) +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_create") +@patch("plonecli.cli.dirty_files", return_value=(["src/foo.py"], [])) +def test_setup_dirty_repo_fails_in_non_interactive_mode( + mock_dirty, + mock_run_create, + mock_config, + mock_project, + mock_tty, + runner, + tmp_path, +): + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = project_at(tmp_path) + + result = runner.invoke(cli, ["setup", "--defaults"]) + + assert result.exit_code != 0 + assert "--allow-dirty" in result.output + mock_run_create.assert_not_called() + + +@patch("plonecli.cli._is_interactive", return_value=False) +@patch("plonecli.cli.find_project_root") +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.run_create") +@patch("plonecli.cli.dirty_files", return_value=(["src/foo.py"], [])) +def test_setup_allow_dirty_proceeds( + mock_dirty, + mock_run_create, + mock_config, + mock_project, + mock_tty, + runner, + tmp_path, +): + mock_config.return_value = MagicMock(templates_dir=str(tmp_path)) + mock_project.return_value = project_at(tmp_path) + + result = runner.invoke(cli, ["setup", "--defaults", "--allow-dirty"]) + + assert result.exit_code == 0, result.output + mock_run_create.assert_called_once() diff --git a/tests/test_skill.py b/tests/test_skill.py index 0cb44d7..c76fbad 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -3,17 +3,11 @@ from unittest.mock import MagicMock, patch import pytest -from click.testing import CliRunner from plonecli import skill_installer from plonecli.cli import cli -@pytest.fixture -def runner(): - return CliRunner() - - def test_bundled_skills_present(): names = skill_installer.bundled_skill_names() assert "plonecli" in names diff --git a/tests/test_theme_barceloneta_integration.py b/tests/test_theme_barceloneta_integration.py index 4ce1932..859ecde 100644 --- a/tests/test_theme_barceloneta_integration.py +++ b/tests/test_theme_barceloneta_integration.py @@ -52,8 +52,9 @@ def test_theme_barceloneta_generates_and_tests_pass(tmp_path: Path) -> None: project_dir = tmp_path / package_name theme_name = "My Test Theme" - # 1. Generate the backend_addon — pre-fill every answer so copier runs - # non-interactively. + # 1. Generate the backend_addon. ``defaults=True`` takes the template default + # for anything not pre-filled here, so a newly added template question + # cannot turn this into an interactive (and therefore failing) run. run_create( "backend_addon", str(project_dir), @@ -67,6 +68,7 @@ def test_theme_barceloneta_generates_and_tests_pass(tmp_path: Path) -> None: "author_name": "Plone Developer", "author_email": "dev@plone.org", }, + defaults=True, ) assert (project_dir / "pyproject.toml").exists() @@ -81,6 +83,7 @@ def test_theme_barceloneta_generates_and_tests_pass(tmp_path: Path) -> None: "theme_name": theme_name, "theme_description": "Integration test theme", }, + defaults=True, ) # The template ships a theme test keyed on theme_id. diff --git a/tests/test_update_command.py b/tests/test_update_command.py new file mode 100644 index 0000000..60003eb --- /dev/null +++ b/tests/test_update_command.py @@ -0,0 +1,215 @@ +"""CLI-level tests for ``plonecli update`` and the update banner.""" + +import json +from io import BytesIO +from unittest.mock import MagicMock, patch + +import pytest + +from plonecli.cli import cli + + +@pytest.fixture +def config(tmp_path): + return MagicMock(templates_dir=str(tmp_path)) + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.get_templates_info", return_value="abc1234 2026-07-26") +@patch("plonecli.cli.update_templates_clone", return_value="Templates updated: a → b") +@patch("plonecli.cli.ensure_templates_cloned") +@patch("plonecli.updater.check_for_updates", return_value=None) +def test_update_reports_template_update( + mock_check, + mock_ensure, + mock_update, + mock_info, + mock_config, + mock_project, + runner, + config, +): + mock_config.return_value = config + + result = runner.invoke(cli, ["update"]) + + assert result.exit_code == 0, result.output + assert "Templates updated: a → b" in result.output + assert "up to date" in result.output + assert "abc1234" in result.output + # ``update`` forces a fresh PyPI check rather than reusing the 24h cache. + assert any(call.kwargs.get("force") for call in mock_check.call_args_list) + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.get_templates_info", return_value="abc1234 2026-07-26") +@patch("plonecli.cli.update_templates_clone") +@patch("plonecli.cli.ensure_templates_cloned") +@patch("plonecli.updater.check_for_updates", return_value=None) +def test_update_reports_template_failure_without_aborting( + mock_check, + mock_ensure, + mock_update, + mock_info, + mock_config, + mock_project, + runner, + config, +): + """A failed template fetch must not hide the plonecli version check.""" + mock_config.return_value = config + mock_update.side_effect = RuntimeError("network unreachable") + + result = runner.invoke(cli, ["update"]) + + assert result.exit_code == 0, result.output + assert "Failed to update templates: network unreachable" in result.output + assert "up to date" in result.output + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.get_templates_info", return_value="abc1234 2026-07-26") +@patch( + "plonecli.cli.update_templates_clone", return_value="Templates already up to date." +) +@patch("plonecli.cli.ensure_templates_cloned") +@patch("plonecli.updater.check_for_updates", return_value="7.0.0b14") +@patch("plonecli.cli.importlib.metadata.version", return_value="7.0.0b13") +def test_update_reports_new_plonecli_version( + mock_version, + mock_check, + mock_ensure, + mock_update, + mock_info, + mock_config, + mock_project, + runner, + config, +): + mock_config.return_value = config + + result = runner.invoke(cli, ["update"]) + + assert result.exit_code == 0, result.output + assert "New version available: 7.0.0b14" in result.output + assert "current: 7.0.0b13" in result.output + assert "uv tool upgrade plonecli" in result.output + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.cli.load_config") +@patch("plonecli.cli.get_templates_info", return_value="abc1234 2026-07-26") +@patch( + "plonecli.cli.update_templates_clone", return_value="Templates already up to date." +) +@patch("plonecli.cli.ensure_templates_cloned") +@patch("plonecli.updater.check_for_updates") +def test_update_reports_version_check_failure( + mock_check, + mock_ensure, + mock_update, + mock_info, + mock_config, + mock_project, + runner, + config, +): + mock_config.return_value = config + mock_check.side_effect = OSError("no route to host") + + result = runner.invoke(cli, ["update"]) + + assert result.exit_code == 0, result.output + assert "Could not check for updates: no route to host" in result.output + # The templates info is still reported. + assert "abc1234" in result.output + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.cli.load_config") +@patch("plonecli.updater.check_for_updates", return_value="7.0.0b14") +def test_banner_shown_when_a_newer_version_exists( + mock_check, mock_config, mock_project, runner, config +): + """Regression: the notifier compared release segments only, so it never + fired while plonecli shipped betas.""" + mock_config.return_value = config + + result = runner.invoke(cli, []) + + assert "A new version of plonecli is available: 7.0.0b14" in result.output + assert "uv tool upgrade plonecli" in result.output + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.cli.load_config") +@patch("plonecli.updater.check_for_updates", return_value=None) +def test_banner_silent_when_current( + mock_check, mock_config, mock_project, runner, config +): + mock_config.return_value = config + + result = runner.invoke(cli, []) + + assert "new version of plonecli" not in result.output + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.cli.load_config") +@patch("plonecli.updater._get_current_version", return_value="7.0.0b13") +def test_banner_renders_from_a_pypi_response( + mock_current, mock_config, mock_project, runner, config, tmp_path, monkeypatch +): + """The whole notifier chain, from the PyPI payload to the banner. + + Regression: the version comparison stripped pre-release suffixes, so this + path silently produced nothing for every beta release plonecli shipped. + """ + mock_config.return_value = config + monkeypatch.setattr( + "plonecli.updater.UPDATE_CACHE_FILE", tmp_path / ".update_cache.json" + ) + monkeypatch.setattr("plonecli.updater.CONFIG_DIR", tmp_path) + payload = json.dumps({"info": {"version": "7.0.0b14"}}).encode("utf-8") + monkeypatch.setattr("plonecli.updater.urlopen", lambda *a, **k: BytesIO(payload)) + + result = runner.invoke(cli, []) + + assert "A new version of plonecli is available: 7.0.0b14" in result.output + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.cli.load_config") +@patch("plonecli.updater._get_current_version", return_value="7.0.0b14") +def test_no_banner_when_pypi_reports_the_installed_version( + mock_current, mock_config, mock_project, runner, config, tmp_path, monkeypatch +): + mock_config.return_value = config + monkeypatch.setattr( + "plonecli.updater.UPDATE_CACHE_FILE", tmp_path / ".update_cache.json" + ) + monkeypatch.setattr("plonecli.updater.CONFIG_DIR", tmp_path) + payload = json.dumps({"info": {"version": "7.0.0b14"}}).encode("utf-8") + monkeypatch.setattr("plonecli.updater.urlopen", lambda *a, **k: BytesIO(payload)) + + result = runner.invoke(cli, []) + + assert "new version of plonecli" not in result.output + + +@patch("plonecli.cli.find_project_root", return_value=None) +@patch("plonecli.cli.load_config") +@patch("plonecli.updater.check_for_updates") +def test_banner_never_breaks_the_cli( + mock_check, mock_config, mock_project, runner, config +): + """An update check failure must stay invisible on a normal invocation.""" + mock_config.return_value = config + mock_check.side_effect = OSError("offline") + + result = runner.invoke(cli, ["--help"]) + + assert result.exit_code == 0, result.output diff --git a/tests/test_updater.py b/tests/test_updater.py index ef9a835..23c3c75 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -4,20 +4,58 @@ from datetime import UTC, datetime, timedelta from unittest.mock import patch +import pytest + from plonecli.updater import ( + _is_newer, _read_cache, - _version_tuple, _write_cache, check_for_updates, ) -def test_version_tuple(): - assert _version_tuple("3.0.0") == (3, 0, 0) - assert _version_tuple("3.0.0a1") == (3, 0, 0) - assert _version_tuple("2.6") == (2, 6) - assert _version_tuple("3.0.0") > _version_tuple("2.6.0") - assert _version_tuple("3.1.0") > _version_tuple("3.0.0") +@pytest.mark.parametrize( + ("latest", "current"), + [ + ("3.1.0", "3.0.0"), + ("3.0.0", "2.6.0"), + # Pre-releases must be comparable, not truncated to their release + # segment: while plonecli itself ships betas, a newer beta is the only + # update a user can get. + ("7.0.0b14", "7.0.0b13"), + ("7.0.0", "7.0.0b13"), + ("7.0.0b1", "7.0.0a3"), + ("7.0.1", "7.0.0"), + ("7.0.0", "7.0.0.dev0"), + ], +) +def test_is_newer_true(latest, current): + assert _is_newer(latest, current) is True + + +@pytest.mark.parametrize( + ("latest", "current"), + [ + ("3.0.0", "3.0.0"), + ("3.0.0", "3.1.0"), + ("7.0.0b13", "7.0.0b14"), + # A final release must never be "updated" to an older pre-release. + ("7.0.0b13", "7.0.0"), + ("7.0.0a1", "7.0.0b1"), + ("7.0.0.dev0", "7.0.0"), + ], +) +def test_is_newer_false(latest, current): + assert _is_newer(latest, current) is False + + +@pytest.mark.parametrize( + ("latest", "current"), + [("not-a-version", "3.0.0"), ("3.0.0", "not-a-version")], +) +def test_is_newer_unparseable_never_prompts(latest, current): + """An unparseable version is not an update; better silent than wrong.""" + assert _is_newer(latest, current) is False def test_write_and_read_cache(tmp_path, monkeypatch): @@ -63,6 +101,18 @@ def test_check_for_updates_new_available( assert result == "3.1.0" +@patch("plonecli.updater._fetch_latest_version", return_value="7.0.0b14") +@patch("plonecli.updater._get_current_version", return_value="7.0.0b13") +def test_check_for_updates_newer_beta_available( + mock_current, mock_fetch, tmp_path, monkeypatch +): + """Beta users must be told about a newer beta.""" + monkeypatch.setattr("plonecli.updater.UPDATE_CACHE_FILE", tmp_path / "cache.json") + monkeypatch.setattr("plonecli.updater.CONFIG_DIR", tmp_path) + + assert check_for_updates(force=True) == "7.0.0b14" + + @patch("plonecli.updater._fetch_latest_version", return_value="3.0.0") @patch("plonecli.updater._get_current_version", return_value="3.0.0") def test_check_for_updates_up_to_date(mock_current, mock_fetch, tmp_path, monkeypatch): diff --git a/uv.lock b/uv.lock index c573f88..ecdfc0e 100644 --- a/uv.lock +++ b/uv.lock @@ -579,14 +579,16 @@ wheels = [ [[package]] name = "plonecli" -version = "7.0.0b13.dev0" +version = "7.0.0b14.dev0" source = { editable = "." } dependencies = [ { name = "click" }, { name = "click-aliases" }, { name = "copier" }, { name = "copier-templates-extensions" }, + { name = "packaging" }, { name = "pyyaml" }, + { name = "tomli-w" }, ] [package.optional-dependencies] @@ -611,11 +613,13 @@ requires-dist = [ { name = "copier", specifier = ">=9.0.0" }, { name = "copier-templates-extensions" }, { name = "myst-parser", marker = "extra == 'docs'" }, + { name = "packaging" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, { name = "pyyaml" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "sphinx", marker = "extra == 'docs'" }, + { name = "tomli-w", specifier = ">=1.0" }, { name = "tox", marker = "extra == 'dev'" }, ] provides-extras = ["test", "docs", "dev"]