Architecture

Adding Dry-Run and Confirmation to Destructive Commands

Make destructive Python CLI commands safe: a plan-show-confirm-apply pattern, --dry-run that cannot drift, --yes for automation, typed confirmation and tests.

Updated

Every team has the story: someone ran the cleanup command with the wrong flag, or in the terminal that was pointed at production, and it did exactly what it was told. Commands that delete, overwrite, deploy, migrate or bulk-update deserve a different design from commands that read, because their mistakes are expensive and often irreversible. The good news is that one small pattern covers nearly all of them: plan the changes without making any, show the plan, confirm with the person running it, and apply exactly the plan that was shown. --dry-run stops after showing, --yes skips the question for automation, and a missing terminal never silently counts as "yes". This guide implements that pattern in a Typer command, adds stronger confirmation for high-risk targets, and tests every path. It belongs to the designing CLI interfaces and conventions topic.

Prerequisites

  • A Typer or Click CLI with at least one command that changes or deletes something.
  • The ability to compute what a command would do without doing it. If your current code interleaves deciding and acting, the first step below is to separate them.

The pattern

Plan, show, confirm, apply A destructive command first computes a plan of changes, shows it, asks for confirmation unless forced, and only then applies it. Plan, show, confirm, apply Plan compute changes Show what would happen Confirm unless --yes Apply the same plan no side effects --dry-run stops here yes Dry run and apply share one planning function, so the preview cannot drift from reality.

The most important design decision is that the dry run and the real run share one planning function. A dry run implemented as a separate code path — if dry_run: print("would delete ...") sprinkled through the command — drifts from reality the first time someone changes one branch and not the other, and a preview that lies is worse than none. When both modes compute the same plan object and only the real run passes it to apply, the preview is correct by construction.

The recipe

The example prunes old build artefacts. The domain logic returns a plan; the command decides whether to show it, ask about it, or apply it.

# src/mytool/prune.py
from __future__ import annotations

import re
import shutil
import time
from dataclasses import dataclass, field
from pathlib import Path

UNITS = {"m": 60, "h": 3600, "d": 86400}


def parse_age(text: str) -> float:
    m = re.fullmatch(r"(\d+)([mhd])", text.strip())
    if not m:
        raise ValueError(f"invalid duration {text!r}; use e.g. 30d, 12h, 90m")
    return int(m[1]) * UNITS[m[2]]


@dataclass(frozen=True)
class Deletion:
    path: Path
    age_days: float
    size: int


@dataclass
class Plan:
    deletions: list[Deletion] = field(default_factory=list)

    @property
    def total_size(self) -> int:
        return sum(d.size for d in self.deletions)

    def __bool__(self) -> bool:
        return bool(self.deletions)


def _size(path: Path) -> int:
    return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())


def plan_prune(root: Path, older_than: float, now: float | None = None) -> Plan:
    """Decide what to delete. Pure with respect to the filesystem: reads only."""
    now = now if now is not None else time.time()
    plan = Plan()
    for build in sorted(p for p in root.iterdir() if p.is_dir()):
        age = now - build.stat().st_mtime
        if age > older_than:
            plan.deletions.append(Deletion(build, age / 86400, _size(build)))
    return plan


def apply_prune(plan: Plan) -> int:
    """Carry out exactly the plan that was shown."""
    for d in plan.deletions:
        shutil.rmtree(d.path)
    return len(plan.deletions)
# src/mytool/cli.py
import sys
from pathlib import Path
from typing import Annotated

import typer

from mytool.prune import Plan, apply_prune, parse_age, plan_prune

app = typer.Typer()


def human(n: int) -> str:
    for unit in ("B", "KB", "MB", "GB"):
        if n < 1024:
            return f"{n:.1f} {unit}" if unit != "B" else f"{n} B"
        n /= 1024
    return f"{n:.1f} TB"


def show(plan: Plan, verb: str) -> None:
    typer.echo(f"{verb} {len(plan.deletions)} build(s) ({human(plan.total_size)}):", err=True)
    for d in plan.deletions:
        typer.echo(f"  {d.path.name:<12} {d.age_days:5.0f} days old  {human(d.size):>9}", err=True)


def stdin_is_tty() -> bool:
    """A seam for tests: CliRunner replaces stdin with a non-TTY stream."""
    return sys.stdin.isatty()


def confirm(message: str, yes: bool) -> None:
    if yes:
        return
    if not stdin_is_tty():
        typer.echo("error: refusing to delete without --yes (no terminal to confirm on)", err=True)
        raise typer.Exit(2)
    if not typer.confirm(message, default=False, err=True):
        typer.echo("aborted; nothing was deleted", err=True)
        raise typer.Exit(1)


@app.callback()
def main() -> None:
    """Build maintenance."""


@app.command()
def prune(
    root: Annotated[Path, typer.Argument(exists=True, file_okay=False)],
    older_than: Annotated[str, typer.Option(help="Age threshold, e.g. 30d.")] = "30d",
    dry_run: Annotated[bool, typer.Option("--dry-run", "-n", help="Show what would be deleted.")] = False,
    yes: Annotated[bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")] = False,
) -> None:
    """Delete build directories older than a threshold."""
    try:
        threshold = parse_age(older_than)
    except ValueError as exc:
        raise typer.BadParameter(str(exc), param_hint="--older-than")
    plan = plan_prune(root, threshold)
    if not plan:
        typer.echo("nothing to delete", err=True)
        return
    show(plan, "would delete" if dry_run else "will delete")
    if dry_run:
        return
    confirm(f"Delete {len(plan.deletions)} build(s) ({human(plan.total_size)})?", yes)
    deleted = apply_prune(plan)
    typer.echo(f"deleted {deleted} build(s)", err=True)


if __name__ == "__main__":
    app()

The decisions that matter

The default answer is "no". typer.confirm(..., default=False) means an accidental Enter aborts. The prompt shows [y/N], with the capital letter marking the default.

No terminal means no implicit yes. A script or CI job has no one to answer the prompt. Some tools treat that as consent; that is how scheduled jobs delete things nobody intended. Here, a missing --yes without a terminal is a usage error (exit 2) with a message saying exactly what to add.

Should this command ask first? A decision for confirmation prompts: when the command is destructive and a terminal is attached, ask; when it is not attached, require an explicit yes flag. Should this command ask first? Destructive, and is a person at a terminal? Yes, and interactive Prompt default answer: no Yes, but no terminal Require --yes fail otherwise Not destructive Just do it no prompt Never let a missing terminal turn "ask first" into "do it anyway".

The prompt restates the scale. "Delete 3 build(s) (1.2 GB)?" is a last chance to notice that the number is not what you expected — 3,000 instead of 3 — which is the most common way destructive commands go wrong.

The listing goes to stderr. The plan is narration, not the command's result, so it does not pollute stdout. A --json flag could emit the plan as data on stdout for tools that want to review it programmatically.

"Nothing to do" is success. Exit 0 with a short message when the plan is empty; scripts should not have to treat a clean state as an error.

Stronger confirmation for high-stakes targets

A y is muscle memory. For production environments, whole-database operations or anything tagged as protected, ask the user to type the name of the thing they are about to affect — the pattern GitHub uses for deleting repositories:

def confirm_by_name(target: str, yes: bool, allow_env: str = "MYTOOL_ALLOW_PROTECTED") -> None:
    import os
    if yes and os.environ.get(allow_env) == "1":
        return                                   # automation must opt in twice
    if not sys.stdin.isatty():
        typer.echo(f"error: {target!r} is protected; set {allow_env}=1 and pass --yes", err=True)
        raise typer.Exit(2)
    typed = typer.prompt(f"Type {target!r} to confirm", err=True)
    if typed != target:
        typer.echo("names did not match; aborted", err=True)
        raise typer.Exit(1)

Requiring both --yes and an environment variable for protected targets in automation makes "delete production from a script" a deliberate two-step configuration, not something a copied command line can do by accident. The profile-based version of this guard is in supporting multiple profiles and accounts.

UX considerations

A dry run users can trust Terminal output of a destructive cleanup command in dry-run mode listing what it would delete, followed by a confirmed run. A dry run users can trust bash $ mytool builds prune --older-than 30d --dry-run would delete 3 builds (1.2 GB): #4102 web 41 days old #4098 billing 44 days old $ mytool builds prune --older-than 30d Delete 3 builds (1.2 GB)? [y/N]: y The prompt restates the scale of the action; the default answer is the safe one.
  • Make dry runs look like the real thing. Same listing, same totals, with "would" instead of "will". Users should be able to trust a dry run's output as a preview.
  • Offer -n as the short form. -n for dry-run is a long-standing convention (make -n, rsync -n, git clean -n).
  • Say what happened after the fact. "deleted 3 build(s)" confirms the action and its scope.
  • Consider undo instead of confirmation. Where possible — moving to a trash directory, soft-deleting in an API — a reversible action with a short retention window is friendlier than any prompt. Confirmation is for what cannot be undone.
  • Never prompt in library code. Only the command layer knows whether a person is present. Core functions return plans; commands decide how to confirm.

Testing the behaviour

Test the plan logic directly with a fixed clock, and test every command path — dry run, confirmed, declined, forced, and non-interactive without --yes:

# tests/test_prune.py
import os
import time

from typer.testing import CliRunner

from mytool import cli
from mytool.prune import plan_prune

runner = CliRunner()
DAY = 86400


def make_builds(root, ages_days):
    now = time.time()
    for i, age in enumerate(ages_days):
        d = root / f"build-{i}"
        d.mkdir()
        (d / "out.bin").write_bytes(b"x" * 100)
        os.utime(d, (now - age * DAY, now - age * DAY))


def test_plan_selects_only_old_builds(tmp_path):
    make_builds(tmp_path, [1, 40, 60])
    plan = plan_prune(tmp_path, 30 * DAY)
    assert [d.path.name for d in plan.deletions] == ["build-1", "build-2"]


def test_dry_run_deletes_nothing(tmp_path):
    make_builds(tmp_path, [40])
    result = runner.invoke(cli.app, ["prune", str(tmp_path), "--dry-run"])
    assert result.exit_code == 0 and "would delete 1" in result.output
    assert (tmp_path / "build-0").exists()


def test_declining_aborts(tmp_path, monkeypatch):
    make_builds(tmp_path, [40])
    monkeypatch.setattr(cli, "stdin_is_tty", lambda: True)          # pretend a person is there
    result = runner.invoke(cli.app, ["prune", str(tmp_path)], input="n\n")
    assert result.exit_code == 1 and (tmp_path / "build-0").exists()


def test_non_interactive_requires_yes(tmp_path):
    make_builds(tmp_path, [40])
    result = runner.invoke(cli.app, ["prune", str(tmp_path)])       # CliRunner stdin is not a TTY
    assert result.exit_code == 2 and "--yes" in result.output
    assert (tmp_path / "build-0").exists()


def test_yes_deletes(tmp_path):
    make_builds(tmp_path, [40, 50])
    result = runner.invoke(cli.app, ["prune", str(tmp_path), "--yes"])
    assert result.exit_code == 0
    assert list(tmp_path.iterdir()) == []

The non-interactive test is the one that protects scheduled jobs: it proves the command refuses to act rather than treating an absent terminal as consent. More on driving prompts in tests is in testing interactive prompts and stdin.

Conclusion

Destructive commands need a shape, not just a warning: plan without side effects, show the plan, confirm with a default of "no", and apply exactly what was shown. --dry-run reuses the plan so previews cannot lie, --yes serves automation, a missing terminal is an error rather than consent, and protected targets ask for the name to be typed. Test each path — especially the non-interactive one — and the next "someone ran it against production" story ends with "and it asked first".

Frequently asked questions

Should --dry-run be the default?

For extremely dangerous commands, some tools default to a dry run and require --apply or --execute. It is a reasonable choice when the command is rarely run and its effects are severe, such as a data migration. For routine cleanup, a confirmation prompt with a dry-run option is less friction.

What if the plan changes between showing and applying?

For local files it rarely matters within the seconds of a prompt. For remote resources, apply should verify each item still matches the plan — using ETags or version numbers — and skip or abort on mismatch rather than deleting something different from what was shown.

How does this interact with --json output?

Emit the plan as JSON on stdout for --dry-run --json, so tools can review it programmatically, and keep confirmation prompts and progress on stderr. Scripts combining --json with --yes get a machine-readable report of what was done.

Should confirmation be skippable with an environment variable?

For ordinary destructive commands, --yes on the command line is clearer and appears in logs. Environment variables that silently skip prompts are easy to leave set; reserve them for the second factor on protected targets, as above.