Tab completion in a Typer spatial CLI requires three things: completer functions that resolve paths or EPSG codes in under 200 ms, binding those functions to parameters via shell_complete, and registering the generated script in the user’s shell config. Install typer[all] (which pulls in shellingham) and run --install-completion to activate. This page is part of the Argument Parsing with Typer guide under CLI Architecture & Design Patterns.
Prerequisites
- Python 3.9+ with
pip install "typer[all]"— the[all]extra includesshellingham(shell detection) andrich(help formatting) - A working Typer app; if you are starting from scratch, see How to Build a Typer CLI for Shapefile Conversion
- Bash 4+, Zsh 5+, or Fish 3+ — the three shells Typer’s completion engine supports natively
No rasterio, geopandas, or GDAL is needed for completion itself; keep those imports out of completer functions entirely.
How the Completion Subprocess Works
The shell passes the partial command line via environment variables (COMP_WORDS, _TYPER_COMPLETE_ARGS), spawns your CLI as a subprocess, and collects its stdout within a hard timeout (typically 100–200 ms). Because the subprocess starts cold, any import that triggers GDAL driver registration or loads a large GeoDataFrame will silently blow past that limit — no error, just no suggestions.
Complete Working Implementation
The following module is self-contained. Copy it to spatial_cli.py, install the dependency, and run python spatial_cli.py --install-completion.
# spatial_cli.py
from __future__ import annotations
import functools
from pathlib import Path
from typing import List
import typer
app = typer.Typer(
name="spatial-cli",
help="Geospatial batch processing toolkit with shell completion.",
add_completion=True, # (1) exposes --install-completion and --show-completion
)
# ---------------------------------------------------------------------------
# Completer functions — must be pure, fast, and import-free of heavy GIS libs
# ---------------------------------------------------------------------------
SUPPORTED_FORMATS: List[str] = [
"GeoTIFF", "Shapefile", "GeoPackage", "NetCDF", "GeoJSON", "FlatGeobuf"
]
# EPSG codes that cover the most common GIS CRS choices;
# extend or replace with a lightweight SQLite lookup for broader coverage.
EPSG_REGISTRY: List[str] = [
"EPSG:4326", # WGS 84 geographic
"EPSG:3857", # Web Mercator
"EPSG:32633", # UTM zone 33N
"EPSG:32632", # UTM zone 32N
"EPSG:26918", # UTM zone 18N (NAD83)
"EPSG:27700", # British National Grid
]
SPATIAL_EXTS = frozenset({".tif", ".tiff", ".shp", ".gpkg", ".geojson", ".nc", ".fgb"})
def complete_formats(ctx: typer.Context, param: typer.CallbackParam, incomplete: str) -> List[str]: # (2)
"""Return spatial format names that start with the partial input."""
return [f for f in SUPPORTED_FORMATS if f.lower().startswith(incomplete.lower())]
def complete_crs(ctx: typer.Context, param: typer.CallbackParam, incomplete: str) -> List[str]:
"""Return EPSG codes matching the partial string."""
return [c for c in EPSG_REGISTRY if incomplete.upper() in c.upper()]
@functools.lru_cache(maxsize=64) # (3) cache per unique parent-dir string
def _list_spatial_files(parent: str) -> List[str]:
try:
return [
str(p) for p in Path(parent).iterdir()
if p.suffix.lower() in SPATIAL_EXTS
]
except (PermissionError, NotADirectoryError):
return []
def complete_paths(ctx: typer.Context, param: typer.CallbackParam, incomplete: str) -> List[str]:
"""Suggest .tif/.shp/.gpkg/.geojson files relative to the partial path."""
p = Path(incomplete)
parent = str(p.parent) if p.parent != Path(".") else "."
candidates = _list_spatial_files(parent)
return [c for c in candidates if Path(c).name.startswith(p.name)]
# ---------------------------------------------------------------------------
# CLI command
# ---------------------------------------------------------------------------
@app.command()
def process(
input_path: Path = typer.Argument(
...,
help="Path to the input raster (.tif) or vector (.gpkg, .shp) dataset.",
shell_complete=complete_paths, # (4)
),
output_format: str = typer.Option(
"GeoTIFF",
"--format", "-f",
help="Output spatial format.",
shell_complete=complete_formats,
),
target_crs: str = typer.Option(
"EPSG:4326",
"--crs", "-c",
help="Target coordinate reference system as an EPSG code.",
shell_complete=complete_crs,
),
) -> None:
"""Reproject and convert a spatial dataset to the requested format and CRS."""
typer.echo(f"Input : {input_path}")
typer.echo(f"Format: {output_format}")
typer.echo(f"CRS : {target_crs}")
# Replace this block with rasterio.open / pyogrio.read_dataframe logic.
if __name__ == "__main__":
app()
Step Annotations
(1) add_completion=True is the default, but setting it explicitly makes the intent clear and is required if you ever construct the app with add_completion=False during testing.
(2) Completer signature — modern Typer (≥ 0.9) passes (ctx, param, incomplete). Older examples you may find online use only (incomplete,), which still works but skips context access. Prefer the three-argument form for forward compatibility.
(3) functools.lru_cache on _list_spatial_files caches the directory listing between rapid successive TAB presses. The cache key is the parent directory string, so scanning /data/project/ only triggers one iterdir() call per shell session rather than one per keystroke.
(4) shell_complete=complete_paths replaces the legacy autocompletion= keyword used in Click 7 and early Typer versions. Using autocompletion= on Typer ≥ 0.9 raises a DeprecationWarning and will be removed in a future release.
Registering the Completion Script
Interactive install (personal tooling)
python spatial_cli.py --install-completion
# Restart the shell or run:
source ~/.bashrc # Bash
source ~/.zshrc # Zsh
Typer uses shellingham to detect the active shell automatically and appends the source line to the correct config file.
Static export (team distribution)
# Export once and commit the file:
python spatial_cli.py --show-completion bash > completions/spatial_cli.bash
python spatial_cli.py --show-completion zsh > completions/spatial_cli.zsh
# System-wide activation (Linux):
sudo cp completions/spatial_cli.bash /etc/profile.d/spatial_cli_completion.sh
For containerised environments or configuration-managed deployments, source the script from your Dockerfile’s shell config rather than relying on --install-completion inside the container build.
Named Gotcha: Missing shellingham Breaks Detection
Symptom: --install-completion exits silently without modifying any shell config file, or raises ImportError: No module named 'shellingham'.
Cause: Installing typer without extras (pip install typer) skips shellingham. Typer then cannot identify the active shell and writes nothing.
Fix:
pip install "typer[all]"
# Verify shellingham is present:
python -c "import shellingham; print(shellingham.detect_shell())"
# Expected output: ('bash', '/bin/bash') or ('zsh', '/usr/bin/zsh')
If you are in a locked virtual environment where you cannot change the install, pass the shell name explicitly:
python spatial_cli.py --install-completion bash
python spatial_cli.py --install-completion zsh
Verification
After sourcing the config file, confirm that all three parameter types resolve correctly:
# 1. Spatial file path completion — should list .tif/.gpkg/.shp files in ./data/
python spatial_cli.py process data/<TAB>
# 2. Format completion
python spatial_cli.py process data/input.tif --format Geo<TAB>
# Expected: GeoTIFF GeoPackage GeoJSON
# 3. CRS completion
python spatial_cli.py process data/input.tif --crs EPSG:32<TAB>
# Expected: EPSG:3257 EPSG:32633 EPSG:32632
# 4. Smoke-test the completion subprocess directly (Bash):
COMP_WORDS="spatial_cli process data/in" COMP_CWORD=2 \
_TYPER_COMPLETE_ARGS="process data/in" \
_SPATIAL_CLI_COMPLETE=bash_complete \
python spatial_cli.py
If step 4 returns no output, the completer function is either raising an unhandled exception or timing out. Add a try/except Exception guard inside complete_paths and log to /tmp/comp_debug.log to capture the error without disturbing the shell.
Why does completion work in my terminal but not in a Docker container?
Containers built with FROM python:3.x-slim often lack the Bash bash-completion package. Install it in the Dockerfile (apt-get install -y bash-completion) and ensure your ENTRYPOINT or shell config sources /etc/profile.d/. Also confirm that shellingham can detect the shell inside the container — some minimal images replace /bin/bash with dash, which Typer does not support for completion.
Can I complete subcommand names as well as flags?
Yes — Typer generates subcommand completion automatically when you register sub-apps with app.add_typer(sub_app, name="subcommand"). No additional shell_complete callback is needed; the completion engine introspects the registered command tree at TAB time. For the broader subcommand structure, see CLI Subcommand Organization.
How do I surface EPSG codes from a live pyproj database without blocking the shell?
Build a one-time index at tool install time rather than at completion time. A post-install script can query pyproj.database.query_crs_info(), write all authority codes to a local SQLite file, and then your completer reads from that file with a fast LIKE query. A cold SQLite lookup over 6,000 EPSG codes completes in under 5 ms — well within the shell timeout. Refresh the index with a --rebuild-crs-cache flag users can run manually after updating pyproj.
Related
- Argument Parsing with Typer for GIS CLI Tools — parent guide covering type-safe parameter definitions, validators, and help text generation
- How to Build a Typer CLI for Shapefile Conversion — sibling page that builds the CLI this page adds completion to
- CLI Subcommand Organization — structuring multi-command tools so completion works across the full command tree