← Back to list

Stop Writing Python CLIs Like It’s 2015

argparse still works. Typer makes your users — and future you — actually want to run the thing.

Daniel Valev in Towards Dev · 2026-05-19 12:01 · 0 claps · 7.7 min read
#python #devops #software-engineering #command-line #backend-development
Open on Medium ↗
Wiki topics: 🌐 · Web Development ☁️ · DevOps & Cloud 🥊 · Combat Sports

Stop Writing Python CLIs Like It’s 2015

argparse still works. Typer makes your users — and future you — actually want to run the thing.

The deploy script that bit me at 2 a.m.

A few years back, I inherited a deploy script that had been growing for about three years. It started as fifty lines of Python. By the time I touched it, it was six hundred. No help text worth reading. No subcommands — just an ever-expanding list of positional arguments that you had to know by heart. And when something went wrong, it exited with zero. Every time. Regardless of the outcome.

The CI pipeline upstream of it would happily mark the build green, the container would never get pushed, and you’d find out at two in the morning when the service fell over. That’s the thing about a bad CLI — the damage is slow and invisible. It doesn’t blow up in your face during code review. It quietly lies to you in production.

That script is what pushed me to actually sit down and think through how to build CLI tooling properly. Not just “it parses flags,” but proper subcommands, honest help output, and exit codes that tell the truth.

argparse is fine — you’re just not using it fully

I’m not going to bury argparse. It’s in the stdlib, it’s been stable since Python 3.2, and if you’re writing something that has zero external dependencies and needs to run on a locked-down server where you can’t pip-install anything, it’s absolutely the right call. The problem isn’t argparse — it’s that most people use it like a glorified sys.argv parser and never discover subparsers.

Here’s what a real subcommand structure looks like in argparse, the kind you’d actually want in a devops tool:

import argparse
import sys

def cmd_deploy(args):
    """Handle the deploy subcommand."""
    print(f"Deploying {args.service} to {args.env}")
    # ... real deploy logic
    # returning 0 = success; caller checks this
    return 0

def cmd_rollback(args):
    """Handle the rollback subcommand."""
    if not args.revision:
        print("ERROR: --revision is required for rollback", file=sys.stderr)
        return 2  # misuse of command, not a runtime error
    print(f"Rolling back {args.service} to revision {args.revision}")
    return 0

def build_parser():
    parser = argparse.ArgumentParser(
        description="Internal deploy tooling for the platform team.",
        epilog="Run 'deploy <command> --help' for per-command options.",
    )
    subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
    subparsers.required = True  # without this, no subcommand = no error

    # --- deploy subcommand ---
    p_deploy = subparsers.add_parser(
        "deploy",
        help="Push a new revision to an environment.",
        description="Deploys the latest image tag to the target environment.",
    )
    p_deploy.add_argument("service", help="Name of the service to deploy.")
    p_deploy.add_argument(
        "--env",
        choices=["staging", "production"],
        default="staging",
        help="Target environment (default: staging).",
    )
    p_deploy.set_defaults(func=cmd_deploy)

    # --- rollback subcommand ---
    p_rollback = subparsers.add_parser(
        "rollback",
        help="Revert a service to a previous revision.",
    )
    p_rollback.add_argument("service", help="Name of the service to roll back.")
    p_rollback.add_argument("--revision", help="Git SHA or tag to roll back to.")
    p_rollback.set_defaults(func=cmd_rollback)

    return parser

def main():
    parser = build_parser()
    args = parser.parse_args()
    exit_code = args.func(args)
    sys.exit(exit_code)

if __name__ == "__main__":
    main()

Notice set_defaults(func=…) — that’s the argparse pattern for dispatching to the right handler without a cascade of if/elif on args.command. Also, notice subparsers.required = True. Without that line, calling the script with no subcommand just silently does nothing on Python 3.3+. That default has burned a lot of people.

The epilog argument on ArgumentParser is underused. It shows up at the bottom of the top-level — help, and it’s the right place to drop usage examples or a link to internal docs. Most people skip it and then write a separate wiki page nobody reads.

Typer: the same ideas with half the ceremony

argparse’s subparser pattern works, but it’s verbose enough that people avoid structuring their tools properly because of the setup cost. Typer removes that excuse. It builds on top of Click, uses Python type hints as the source of truth, and generates help automatically from your function signatures and docstrings.

The equivalent of the above in Typer looks like this:

import typer
import sys
from typing import Annotated

app = typer.Typer(
    help="Internal deploy tooling for the platform team.",
    epilog="Run 'deploy COMMAND --help' for per-command options.",
)

@app.command()
def deploy(
    service: Annotated[str, typer.Argument(help="Name of the service to deploy.")],
    env: Annotated[str, typer.Option(help="Target environment.")] = "staging",
):
    """Push a new revision to an environment."""
    valid_envs = ("staging", "production")
    if env not in valid_envs:
        typer.echo(f"ERROR: --env must be one of {valid_envs}", err=True)
        raise typer.Exit(code=2)

    typer.echo(f"Deploying {service} to {env}")

@app.command()
def rollback(
    service: Annotated[str, typer.Argument(help="Name of the service to roll back.")],
    revision: Annotated[str, typer.Option(help="Git SHA or tag to roll back to.")] = "",
):
    """Revert a service to a previous revision."""
    if not revision:
        typer.echo("ERROR: --revision is required for rollback", err=True)
        raise typer.Exit(code=2)

    typer.echo(f"Rolling back {service} to revision {revision}")

if __name__ == "__main__":
    app()

That’s it. The function name becomes the subcommand name. The docstring becomes the help text for that subcommand. The type annotations tell Typer what to validate and how to display the argument in — help. You get a rich, formatted help output with colored panels out of the box — nothing extra to configure.

One thing worth knowing: Annotated[…, typer.Argument()] and Annotated[…, typer.Option()] became the preferred API in Typer 0.9+. The older style of typer.Argument() as a default value still works, but the Annotated form is what you should be writing in new code. It keeps the type information clean and plays better with type checkers.

Structuring larger CLIs with sub-applications

Single-file tooling eventually outgrows itself. When your CLI has a dozen commands across three domains — say, infra, services, and secrets — you don’t want them all in one flat list. You want something like:

deploy infra plan
deploy infra apply
deploy services push
deploy secrets rotate

Typer handles this with nested Typer apps:

import typer

# Top-level app
app = typer.Typer(help="Platform engineering CLI.")

# Sub-apps for each domain
infra_app = typer.Typer(help="Terraform and infrastructure commands.")
services_app = typer.Typer(help="Service lifecycle commands.")

# Register sub-apps as subcommand groups
app.add_typer(infra_app, name="infra")
app.add_typer(services_app, name="services")

@infra_app.command()
def plan(
    workspace: str = typer.Option("default", help="Terraform workspace to target."),
):
    """Run terraform plan against the current configuration."""
    typer.echo(f"Planning workspace: {workspace}")

@infra_app.command()
def apply(
    workspace: str = typer.Option("default", help="Terraform workspace to target."),
    auto_approve: bool = typer.Option(False, "--auto-approve", help="Skip confirmation prompt."),
):
    """Apply the planned changes."""
    if not auto_approve:
        typer.confirm("This will apply changes to infrastructure. Continue?", abort=True)
    typer.echo(f"Applying workspace: {workspace}")

@services_app.command()
def push(
    service: str = typer.Argument(..., help="Service name to push."),
    tag: str = typer.Option("latest", help="Image tag to deploy."),
):
    """Push a service image to the registry and deploy."""
    typer.echo(f"Pushing {service}:{tag}")

if __name__ == "__main__":
    app()

You’d get deploy infra — help showing plan and apply, and deploy services — help showing push, each with their own full help output. The typer.confirm() call on apply is a good pattern for destructive operations — it gives you a confirmation prompt that the user can bypass with — auto-approve in automation contexts.

In argparse, this same structure requires manually wiring nested subparsers on each subparser object. It works, but the code starts reading like XML configuration. The Typer version stays close to the shape of your actual logic.

Help text is a contract — write it like documentation

There’s a class of — help output I call the “skeleton help”: you run myservice — help and you get argument names with no descriptions, or worse, placeholder text that’s never been updated since the initial commit. It tells you the arguments exist but not what they mean, what the valid values are, or what happens if you get it wrong.

Your help text is the first thing a new team member reads. It is documentation that’s always in sync with the code because it IS the code. Treat it accordingly.

In Typer, every function docstring and every help= parameter ends up in the output. In argparse, the description, help=, and metavar= on each argument all feed the help output. A few things that genuinely improve the experience:

First, use metavar on argparse options to show the user what value format you expect. Instead of the default — env ENV, you can write metavar=”{staging|production}” to make the valid choices immediately obvious without having to hunt through the description.

Second, in Typer, use rich_markup_mode=”rich” on your Typer() constructor if you want inline formatting in your help text. You can then write help text like “Target [bold]environment[/bold]. Must be [italic]staging[/italic] or production.” The terminal renders it with color and emphasis. Useful for long commands where you want to visually separate the key detail from the prose.

Third, always write the help string in the imperative mood, same as git commit messages. “Push a new revision” not “This command pushes a new revision.” It scans faster when you’re running — help mid-incident.

Exit codes are a contract with your shell — stop breaking it

This is the one I see violated more than anything else. A CLI that exits with code zero on failure is actively dangerous. Any CI system, any shell script, any Makefile that calls your tool will assume success and keep going.

The conventions are not complicated:

Exit code 0 means success. The operation completed as intended.

Exit code 1 means a general runtime error — the operation was understood, but something went wrong during execution. A connection refused. A file not found. A timeout.

Exit code 2 means misuse of the command — wrong arguments, missing required option, or invalid combination. Both argparse and Typer exit with 2 automatically when they detect argument errors. Your code should do the same for semantic argument validation that the parser can’t catch.

Codes 3 through 125 are yours to define, and for non-trivial tools, you should define them. Document them. Put them in a module-level constant dict. Grep-ability across your scripts is worth more than you think.

import typer
import sys

# Define exit codes as named constants at module level.
# This makes `grep EXIT_CODES` across your codebase meaningful.
EXIT_CODES = {
    "SUCCESS": 0,
    "RUNTIME_ERROR": 1,
    "MISUSE": 2,
    "SERVICE_NOT_FOUND": 3,
    "DEPLOY_TIMEOUT": 4,
    "ROLLBACK_FAILED": 5,
}

def deploy_service(service: str, env: str) -> int:
    """Returns an exit code. Callers decide what to do with it."""
    try:
        result = _call_deploy_api(service, env)  # imaginary internal call
        if result.get("status") == "not_found":
            typer.echo(f"ERROR: service '{service}' not found in registry.", err=True)
            return EXIT_CODES["SERVICE_NOT_FOUND"]
        return EXIT_CODES["SUCCESS"]
    except TimeoutError:
        typer.echo("ERROR: deploy API timed out.", err=True)
        return EXIT_CODES["DEPLOY_TIMEOUT"]

# In your Typer command handler:
@app.command()
def deploy(service: str, env: str = "staging"):
    """Push a new revision to an environment."""
    code = deploy_service(service, env)
    raise typer.Exit(code=code)

The raise typer.Exit(code=code) pattern — rather than sys.exit() — is the Typer-idiomatic way to set the exit code because it lets Typer finish any cleanup it needs to do (flushing rich output, etc.) before the process exits. sys.exit() works too, but typer.Exit() is cleaner in that context.

On the shell side, verify your tools exit correctly. It’s one line:

./deploy.py deploy my-service --env production
echo "Exit code was: $?"

And in CI, use set -e in your shell scripts or rely on the exit code propagation in your pipeline DSL. The whole chain only works if every tool in it tells the truth.

You’re building for the person debugging at 2 a.m.

That person is often you, six months from now, or your teammate who inherited the script. A CLI with clear subcommands, honest — help output, and exit codes that map to real outcomes isn’t gold-plating — it’s the minimum you owe the humans downstream of your tool.

Typer gets you there with less friction than argparse. But whichever you use, the discipline is the same: every command needs a description, every argument needs a reason to exist, and every failure path needs to exit non-zero.

If this kind of practical, no-fluff engineering content is what you’re here for, follow me on Medium. I write about Python, Go, infrastructure, and the operational reality behind the patterns — not just what to do, but why it breaks when you skip the step.


메타데이터
post_id
0644d2cc6add
slug
stop-writing-python-clis-like-its-2015-0644d2cc6add
url
https://towardsdev.com/stop-writing-python-clis-like-its-2015-0644d2cc6add
canonical_url
https://towardsdev.com/stop-writing-python-clis-like-its-2015-0644d2cc6add
author_url
https://medium.com/@danielvalev
status
ok
fetched_at
2026-06-11 05:11:55