Input & UX

Enabling Tab Completion in Click and Typer

Turn on shell completion in Click and Typer, add dynamic completions for arguments and options, and ship a one-command install-completion setup.

Updated

This is the Python side of shell completion: everything you do in your code so that pressing Tab produces useful suggestions. Both Click and Typer ship a completion engine, so subcommand and option names complete for free the moment it is switched on. The work you actually write is the dynamic part — completing an argument from a live data source, a config file, or the other arguments already on the line. This guide turns completion on in both frameworks and then builds up dynamic completions, choice/enum completion, and a one-command install experience. Installing the generated script into each shell is the sibling guide; here we stay in Python.

TL;DR

  • Typer: completion is built in. Every app gains --install-completion and --show-completion with no code from you.
  • Click: completion is built in too, but you activate it with eval "$(_YOURCLI_COMPLETE=bash_source yourcli)" — the framework prints the script, you decide where it goes.
  • Dynamic completion in Click: pass shell_complete=callback to an option/argument; the callback returns strings or CompletionItems.
  • Dynamic completion in Typer: pass autocompletion=callback to typer.Option/typer.Argument.
  • Choices and enums complete automaticallyclick.Choice([...]) and a Python Enum in Typer need no callback at all.

Typer: completion with zero code

A Typer app has completion wired in. Define commands as usual and the framework already knows how to install a completion script:

# app.py
import typer

app = typer.Typer(help="Deploy things.")

@app.command()
def deploy(env: str, replicas: int = 1) -> None:
    """Deploy the service to ENV."""
    typer.echo(f"Deploying to {env} with {replicas} replicas")

@app.command()
def status(env: str) -> None:
    """Show deployment status for ENV."""
    typer.echo(f"Status for {env}")

if __name__ == "__main__":
    app()
$ python app.py --install-completion    # writes + registers the script for your shell
$ python app.py --show-completion        # prints the script to stdout so you can inspect it

--install-completion detects your current shell, writes the completion script into the right location, and (for zsh/fish) makes sure it will be sourced on the next shell start. --show-completion prints the same script without touching your system — handy for packaging it or for feeding it to a system directory yourself. Once installed, python app.py <Tab> offers deploy and status, and python app.py deploy --<Tab> offers --replicas and --help. You wrote no completion code to get any of that.

For a real tool you would expose it through a console script rather than python app.py, so completion keys off the installed command name (see entry points for Python CLIs).

Click: turning completion on

Click uses the same engine but does not add an install command — you activate completion by evaluating the script it generates. Given this app:

# cli.py
import click

@click.group()
def cli() -> None:
    """Example tool."""

@cli.command()
@click.option("--env", required=True)
def deploy(env: str) -> None:
    """Deploy the service."""
    click.echo(f"Deploying to {env}")

if __name__ == "__main__":
    cli()

If it is installed as the console script yourcli, a user activates completion for the current shell like this:

$ eval "$(_YOURCLI_COMPLETE=bash_source yourcli)"     # bash
$ eval "$(_YOURCLI_COMPLETE=zsh_source yourcli)"      # zsh
$ _YOURCLI_COMPLETE=fish_source yourcli | source      # fish

The _YOURCLI_COMPLETE variable is Click's trigger. Its name is derived from your command: uppercase the console-script name, replace hyphens with underscores, and append _COMPLETE. So yourcli_YOURCLI_COMPLETE, and my-tool_MY_TOOL_COMPLETE. The value (bash_source, zsh_source, fish_source) selects which shell's script to print. Persisting that eval into a startup file — or shipping a generated script — is the job of the installing guide.

Completing choices and enums for free

Before writing any callback, know that fixed value sets complete automatically. In Click, a click.Choice completes its members:

import click

@click.command()
@click.option("--level", type=click.Choice(["debug", "info", "warn"]))
def run(level: str) -> None:
    click.echo(level)

yourcli run --level d<Tab> fills in debug. In Typer, a Python Enum does the same and also gives you validation and type safety:

import typer
from enum import Enum

class Level(str, Enum):
    debug = "debug"
    info = "info"
    warn = "warn"

app = typer.Typer()

@app.command()
def run(level: Level = Level.info) -> None:
    typer.echo(level.value)

Reach for a callback only when the valid values are not knowable at code-writing time. If they are a fixed list, a Choice or Enum is less code and completes just as well.

Dynamic completion in Click with shell_complete

When the valid values live outside your source — deploy targets in a config file, dataset IDs from an API, branch names from git — write a shell_complete callback. It receives the Click context, the parameter, and the partial word the user has typed so far, and returns the candidates:

import click

def complete_env(ctx: click.Context, param: click.Parameter, incomplete: str) -> list[str]:
    known = ["staging", "prod-eu", "prod-us", "prod-ap"]
    return [e for e in known if e.startswith(incomplete)]

@click.command()
@click.option("--env", shell_complete=complete_env, required=True)
def deploy(env: str) -> None:
    click.echo(f"Deploying to {env}")

yourcli deploy --env prod-<Tab> now offers the three prod-* targets. Filtering on incomplete yourself keeps the returned list short, which matters because the shell shows everything you return.

To attach help text to each suggestion, return CompletionItem objects instead of bare strings. The second argument becomes the description the shell shows alongside the value:

from click.shell_completion import CompletionItem

def complete_env(ctx, param, incomplete):
    targets = {
        "staging": "shared pre-prod",
        "prod-eu": "Frankfurt",
        "prod-us": "N. Virginia",
    }
    return [
        CompletionItem(name, help=desc)
        for name, desc in targets.items()
        if name.startswith(incomplete)
    ]

zsh and fish render that help text next to each candidate; bash shows the value alone. That is a shell limitation, not a bug in your code.

Dynamic completion from a real data source

The point of dynamic completion is reaching outside your program. Here the targets come from a YAML config, so completion always reflects what the user has actually configured — a natural fit with handling config files and env vars:

import click
import yaml
from pathlib import Path

def load_targets() -> list[str]:
    cfg = Path.home() / ".config" / "yourcli" / "targets.yaml"
    try:
        data = yaml.safe_load(cfg.read_text()) or {}
        return list(data.get("environments", {}))
    except (OSError, yaml.YAMLError):
        return []   # fail closed: a bad config must never break the shell

def complete_env(ctx, param, incomplete):
    return [e for e in load_targets() if e.startswith(incomplete)]

@click.command()
@click.option("--env", shell_complete=complete_env, required=True)
def deploy(env: str) -> None:
    click.echo(f"Deploying to {env}")

Two things make this production-grade. First, the callback fails closed: any read or parse error returns an empty list, so a malformed config never wedges the user's Tab key. Second, it is cheap — reading a small local file per keypress is fine, but if this were a network call you would cache the result (for example in a short-lived file under the user's cache dir) so completion stays instant.

You can also read earlier arguments from ctx.params to make a completion depend on what the user has already typed — for example, only suggesting regions valid for the --env already on the line.

The same thing in Typer: autocompletion

Typer exposes the identical capability through the autocompletion argument. The callback takes the incomplete string and returns strings, (value, help) tuples, or CompletionItems:

import typer

def complete_env(incomplete: str) -> list[tuple[str, str]]:
    targets = {"staging": "shared pre-prod", "prod-eu": "Frankfurt", "prod-us": "N. Virginia"}
    return [(name, desc) for name, desc in targets.items() if name.startswith(incomplete)]

app = typer.Typer()

@app.command()
def deploy(
    env: str = typer.Option(..., autocompletion=complete_env),
) -> None:
    typer.echo(f"Deploying to {env}")

Because Typer is Click underneath, the runtime behaviour is the same; you just declare the callback on the typer.Option/typer.Argument instead of passing shell_complete. If you are still on the older autocompletion= name in Click, migrate to shell_complete= — it replaced the former in Click 8 and is the API these examples use.

Verify completion without a shell

You do not need to install anything into your shell to know your callback works. Drive Click's completion protocol directly and read the candidates it prints:

$ _YOURCLI_COMPLETE=bash_complete COMP_WORDS="yourcli deploy --env prod-" COMP_CWORD=3 yourcli
plain,prod-eu
plain,prod-us
plain,prod-ap

Each line is type,value. If this prints the values you expect, your Python side is correct and any remaining problem is in the install step covered by the sibling guide. This is also the ideal hook for a unit test — invoke your CLI with those environment variables set and assert on stdout.

Production notes

  • Completion imports your whole program. Every Tab re-runs your module up to the callback, so heavy top-level imports make suggestions feel sluggish. Keep imports lazy; see CLI startup performance and lazy loading.
  • Never write to stdout during completion. With _YOURCLI_COMPLETE set, anything on stdout is parsed as a candidate. Send diagnostics to stderr and guard any banner or logging.
  • Bound network calls in callbacks. A slow or hanging API turns Tab into a hang. Add a short timeout and cache; on failure, return [].
  • Match the trigger to the installed name. _YOURCLI_COMPLETE is derived from the console-script name — keep it in sync with your entry point, and regenerate the script if you rename the command.
  • Pin versions. shell_complete is Click ≥8.0; Typer's install flags stabilised around 0.12. Pin click>=8.1 / typer>=0.12.