Completing subcommands and flag names is table stakes once shell completion is installed. The completion users really notice is for values: pressing Tab after --project and seeing the project names from your API, after --env and seeing the environments from the config file, after --branch and seeing the git branches. Those values are dynamic — they live in a service, a file or another tool — and fetching them on every Tab press is where completion goes wrong: a two-second pause, a traceback printed into the middle of the command line, or a spinner that corrupts the shell prompt. This guide builds value completion that is fast, quiet and correct: a cache with a time-to-live, a hard deadline on fetching, silent fallback to stale data, descriptions in the candidate list, and tests for each behaviour. It belongs to the shell completion for Python CLIs topic.
Prerequisites
- A Typer or Click CLI with shell completion enabled and installed, as in enabling tab completion in Click and Typer and installing shell completion for bash, zsh and fish.
platformdirsfor the cache location.
How value completion runs
When the user presses Tab, the shell's completion script runs your CLI with special environment variables describing the command line so far. Click (and Typer on top of it) parses what it can, finds the parameter being completed, and calls that parameter's completion callback with the incomplete text. Whatever the callback returns is printed back to the shell as candidates. Three facts follow from that. The whole CLI starts on every Tab press, so startup time matters. The callback runs synchronously while the user waits. And anything the callback prints or raises lands in the shell, not in a log.
Choosing sources
Static choices (an Enum or click.Choice) complete for free. Local sources — files in a directory, profiles in a config file — are fast enough to read directly. Remote sources need care: an HTTP round trip is often slower than a person's patience for Tab, and it may fail entirely when offline. The recipe below is about that last case, but the structure works for any slow source.
The recipe
# src/mytool/completion.py
from __future__ import annotations
import json
import os
import tempfile
import time
from collections.abc import Callable
from pathlib import Path
from platformdirs import user_cache_path
CACHE = user_cache_path("mytool", appauthor=False) / "completion-projects.json"
TTL = 300.0 # seconds before cached names are refreshed
DEADLINE = 0.8 # never block Tab for longer than this
Project = tuple[str, str] # (name, description)
def _read_cache(path: Path, now: float) -> list[Project] | None:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if now - data.get("at", 0) > TTL:
return None
return [tuple(p) for p in data["projects"]]
def _write_cache(path: Path, projects: list[Project], now: float) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump({"at": now, "projects": projects}, fh)
os.replace(tmp, path)
def project_names(fetch: Callable[[float], list[Project]], *, cache: Path | None = None,
clock: Callable[[], float] = time.time) -> list[Project]:
"""Cached project list; on a miss, fetch within DEADLINE or return what we have."""
cache = cache or CACHE
now = clock()
cached = _read_cache(cache, now)
if cached is not None:
return cached
try:
projects = fetch(DEADLINE)
except Exception: # completion must never raise or print
stale = _read_stale(cache)
return stale or []
_write_cache(cache, projects, now)
return projects
def _read_stale(path: Path) -> list[Project] | None:
try:
return [tuple(p) for p in json.loads(path.read_text(encoding="utf-8"))["projects"]]
except (OSError, ValueError, KeyError):
return None
def complete_project(incomplete: str, fetch: Callable[[float], list[Project]]) -> list[Project]:
return [(n, d) for n, d in project_names(fetch) if n.startswith(incomplete)]
# src/mytool/cli.py
from __future__ import annotations
from typing import Annotated
import typer
from mytool.completion import Project, complete_project
app = typer.Typer()
API = "https://api.example.com/v1"
def fetch_projects(timeout: float) -> list[Project]:
import httpx # only paid for on a cache miss, never on --help
r = httpx.get(f"{API}/projects", timeout=timeout)
r.raise_for_status()
return [(p["name"], p.get("description", "")) for p in r.json()]
def _complete(incomplete: str) -> list[Project]:
return complete_project(incomplete, fetch_projects)
@app.callback()
def main() -> None:
"""Deploy tool."""
@app.command()
def deploy(project: Annotated[str, typer.Option(autocompletion=_complete, help="Project to deploy.")]) -> None:
"""Deploy PROJECT."""
typer.echo(f"deploying {project}")
if __name__ == "__main__":
app()
The rules it follows
A cache with a time-to-live. Project lists change rarely; five minutes of staleness is invisible to users and turns almost every Tab into a file read. The cache lives in the platform cache directory, and writes are atomic so two shells completing at once cannot corrupt it — the pattern from caching expensive work between CLI runs.
A strict deadline. On a miss, the fetch gets 0.8 seconds, passed down as the HTTP timeout. A completion that sometimes takes ten seconds teaches users not to press Tab.
Failure is silent. The broad except Exception is deliberate: in a completion callback, any error message would be inserted into the user's command line. On failure the callback falls back to stale cached data, or to no suggestions at all. Log to a file if you need to debug it.
Descriptions travel with values. Returning (value, help) tuples lets zsh and fish show "billing -- Billing service" next to each candidate; bash ignores the help text.
Filtering by prefix. The callback returns only candidates starting with what the user typed. The shell would filter anyway, but returning fewer items is faster and keeps descriptions aligned.
The Click version
In plain Click, the same function plugs in through shell_complete, which receives the context, the parameter and the incomplete string, and returns CompletionItem objects:
import click
from click.shell_completion import CompletionItem
from mytool.completion import complete_project
def shell_complete_project(ctx, param, incomplete):
return [CompletionItem(name, help=desc)
for name, desc in complete_project(incomplete, fetch_projects)]
@click.command()
@click.option("--project", shell_complete=shell_complete_project)
def deploy(project: str) -> None:
click.echo(f"deploying {project}")
For values that depend on other options — branches of the repository given in --repo — read ctx.params inside the callback; Click has parsed the earlier options by the time it completes a later one.
UX considerations
- Fast startup is part of completion. Every Tab press starts your CLI, so heavy imports (httpx, Rich, pydantic) at module level cost the user on every keystroke. Import them inside the functions that need them — the
fetch_projectsfunction is the only place httpx is needed. - Offer a refresh. A
mytool cache clearcommand (or--refreshon the list command that rewrites the cache) helps when a brand-new project should appear immediately. - Complete what users type, not what the API stores. If users refer to projects by slug, complete slugs; do not complete internal IDs.
- Keep credentials out of the path. If fetching needs a token, read it the same way the command does; never prompt from a completion callback — there is no terminal to prompt on.
Testing the behaviour
The completion logic is ordinary Python, so it is tested with a counting fake fetcher, a fake clock and a temporary cache file:
# tests/test_completion.py
import pytest
from mytool.completion import complete_project, project_names
PROJECTS = [("billing", "Billing service"), ("blog", "Marketing blog"), ("web", "Website")]
class Clock:
def __init__(self):
self.now = 1000.0
def __call__(self):
return self.now
def counting_fetch(calls):
def fetch(timeout):
calls.append(timeout)
return PROJECTS
return fetch
def test_prefix_filtering(tmp_path, monkeypatch):
monkeypatch.setattr("mytool.completion.CACHE", tmp_path / "c.json")
assert [n for n, _ in complete_project("b", counting_fetch([]))] == ["billing", "blog"]
def test_cache_hit_avoids_fetching(tmp_path):
calls, clock = [], Clock()
project_names(counting_fetch(calls), cache=tmp_path / "c.json", clock=clock)
project_names(counting_fetch(calls), cache=tmp_path / "c.json", clock=clock)
assert len(calls) == 1 and calls[0] <= 1.0 # fetched once, with a short deadline
def test_expired_cache_refetches(tmp_path):
calls, clock = [], Clock()
project_names(counting_fetch(calls), cache=tmp_path / "c.json", clock=clock)
clock.now += 301
project_names(counting_fetch(calls), cache=tmp_path / "c.json", clock=clock)
assert len(calls) == 2
def test_failure_falls_back_to_stale_then_empty(tmp_path):
clock = Clock()
cache = tmp_path / "c.json"
def broken(timeout):
raise TimeoutError
assert project_names(broken, cache=cache, clock=clock) == []
project_names(counting_fetch([]), cache=cache, clock=clock)
clock.now += 10_000
assert project_names(broken, cache=cache, clock=clock) == PROJECTS
The tests pin the four behaviours users experience: filtering, no network call on a cache hit (with the fetch receiving the short deadline), refresh after the TTL, and graceful degradation when the source fails. To test the full shell round-trip, drive the completion protocol directly with environment variables, as shown in testing shell completion in Python CLIs.
Conclusion
Dynamic completion is valuable and fragile: it runs your whole CLI on every Tab press, synchronously, with its output going straight into the user's command line. Make it fast with a TTL cache in the platform cache directory, bound it with a strict deadline on any network call, make it silent on failure with a stale-data fallback, return descriptions for shells that show them, and keep heavy imports out of the startup path. Tested with a fake clock and fetcher, it becomes one of the most-appreciated features of the tool.
Frequently asked questions
Should the cache be refreshed in the background?
It can: when serving a stale entry, start a detached process that refreshes the cache, so the next Tab press gets fresh data without waiting. Keep it simple unless users complain; a five-minute TTL is usually enough.
Can completion call git?
Yes, and it is fast: git for-each-ref --format='%(refname:short)' refs/heads returns branch names in milliseconds. Use the same subprocess care as elsewhere, including a short timeout; see wrapping git and other tools from a Python CLI.
Why do my completions work in zsh but not bash?
Usually the bash-completion package is missing, or the completion script was generated for the wrong shell. Descriptions are also not shown in bash by design. The troubleshooting list in the installation guide covers both.
How many candidates is too many?
Shells ask before showing hundreds of candidates, which makes completion feel slow. If a prefix matches more than a few dozen values, consider completing a narrower field or requiring a longer prefix.